Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion orchestrator/cli/resources/actuator_configuration/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,16 @@ def create_actuator_configuration(parameters: AdoCreateCommandParameters) -> str
console_print(ADO_CREATE_DRY_RUN_CONFIG_VALID, stderr=True)
return None

from orchestrator.modules.actuators.registry import ActuatorRegistry

registry = ActuatorRegistry.globalRegistry()
actuator_provenance = registry.provenance_for_actuator(
actuatorconfig_configuration.actuatorIdentifier
)

resource_to_be_created = ActuatorConfigurationResource(
config=actuatorconfig_configuration
config=actuatorconfig_configuration,
actuatorProvenance=actuator_provenance,
)

sql = get_sql_store(project_context=parameters.ado_configuration.project_context)
Expand Down
13 changes: 13 additions & 0 deletions orchestrator/core/actuatorconfiguration/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pydantic

from orchestrator.core.actuatorconfiguration.config import ActuatorConfiguration
from orchestrator.core.metadata import PackageProvenance
from orchestrator.core.resources import ADOResource, CoreResourceKinds
from orchestrator.utilities.pydantic import Defaultable

Expand All @@ -26,3 +27,15 @@ def _identifier_from_data(data: dict[str, Any]) -> str:
default_factory=_identifier_from_data,
),
]
actuatorProvenance: Annotated[
PackageProvenance | None,
pydantic.Field(
default=None,
description=(
"Python distribution that provided the actuator at the time this "
"configuration was created. None for resources created before "
"provenance tracking was introduced, or when the distribution could "
"not be resolved."
),
),
]
30 changes: 29 additions & 1 deletion orchestrator/core/discoveryspace/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
# SPDX-License-Identifier: MIT
import typing
import uuid
from typing import Annotated

import pydantic
import rich.box

from orchestrator.core.discoveryspace.config import DiscoverySpaceConfiguration
from orchestrator.core.metadata import PackageProvenance
from orchestrator.core.resources import ADOResource, CoreResourceKinds
from orchestrator.schema.measurementspace import MeasurementSpaceConfiguration
from orchestrator.utilities.pydantic import Defaultable
Expand All @@ -21,13 +23,39 @@ class DiscoverySpaceResource(ADOResource):
kind: CoreResourceKinds = CoreResourceKinds.DISCOVERYSPACE
config: DiscoverySpaceConfiguration

identifier: typing.Annotated[
identifier: Annotated[
Defaultable[str],
pydantic.Field(
default_factory=lambda: f"space-{str(uuid.uuid4())[:8]}",
),
]

actuatorProvenance: Annotated[
dict[str, PackageProvenance],
pydantic.Field(
default_factory=dict,
description=(
"Mapping of actuator identifier to the Python distribution that "
"provided it at the time this space was created. Populated automatically "
"from the installed environment; empty for resources created before "
"provenance tracking was introduced."
),
),
]

customExperimentProvenance: Annotated[
dict[str, PackageProvenance],
pydantic.Field(
default_factory=dict,
description=(
"Mapping of custom experiment identifier to the Python distribution "
"that provided it at the time this space was created. Only populated "
"for experiments registered via the ``ado.custom_experiments`` entry "
"point whose source module can be resolved to a distribution."
),
),
]

def __rich__(self) -> "RenderableType":
"""Render this discovery space resource using rich."""
from rich.console import Group
Expand Down
68 changes: 67 additions & 1 deletion orchestrator/core/discoveryspace/space.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,13 +532,79 @@ def config(self) -> DiscoverySpaceConfiguration:
metadata=metadata,
)

def _build_provenance(
self,
) -> tuple[
dict[str, "orchestrator.core.metadata.PackageProvenance"],
dict[str, "orchestrator.core.metadata.PackageProvenance"],
]:
"""Resolve package provenance for all actuators and custom experiments.

Returns:
A tuple of (actuatorProvenance, customExperimentProvenance) where:
- *actuatorProvenance* maps each unique actuator identifier used in the
measurement space to the distribution that provides it.
- *customExperimentProvenance* maps each custom experiment identifier
(from the ``custom_experiments`` actuator) to the distribution that
provides the experiment function.
"""
from orchestrator.core.metadata import PackageProvenance
from orchestrator.modules.actuators.registry import ActuatorRegistry
from orchestrator.utilities.distribution import distribution_from_module

registry = ActuatorRegistry.globalRegistry()
actuator_provenance: dict[str, PackageProvenance] = {}
custom_experiment_provenance: dict[str, PackageProvenance] = {}

for experiment in self.measurementSpace.experiments:
actuator_id = experiment.actuatorIdentifier

# Per-actuator provenance (deduplicated)
if actuator_id not in actuator_provenance:
provenance = registry.provenance_for_actuator(actuator_id)
if provenance is not None:
actuator_provenance[actuator_id] = provenance

# Per-custom-experiment provenance
if actuator_id == "custom_experiments":
module_conf = experiment.metadata.get("module")
if module_conf is not None:
import importlib.metadata

module_name = (
module_conf.get("moduleName")
if isinstance(module_conf, dict)
else getattr(module_conf, "moduleName", None)
)
if module_name is not None:
try:
dist_name = distribution_from_module(module_name)
if dist_name is not None:
dist = importlib.metadata.distribution(dist_name)
version = dist.metadata.get("Version")
if version is not None:
custom_experiment_provenance[
experiment.identifier
] = PackageProvenance(
distributionName=dist_name,
distributionVersion=version,
)
except Exception: # noqa: S110
pass

return actuator_provenance, custom_experiment_provenance

@property
def resource(
self,
) -> orchestrator.core.discoveryspace.resource.DiscoverySpaceResource:

actuator_provenance, custom_experiment_provenance = self._build_provenance()
return orchestrator.core.discoveryspace.resource.DiscoverySpaceResource(
identifier=self._identifier, config=self.config
identifier=self._identifier,
config=self.config,
actuatorProvenance=actuator_provenance,
customExperimentProvenance=custom_experiment_provenance,
)

def saveSpace(self) -> None:
Expand Down
28 changes: 28 additions & 0 deletions orchestrator/core/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,31 @@ class ConfigurationMetadata(pydantic.BaseModel):
description="Optional labels to allow for quick filtering of this resource"
),
] = None


class PackageProvenance(pydantic.BaseModel):
"""Records the Python distribution package that provided a plugin at resource creation time.

Captures the PyPI distribution name and installed version so that the exact
package used when a resource was created can be identified later for
replication or debugging.

Attributes:
distributionName: The PyPI distribution name (e.g. ``"ado-ray-tune"``).
distributionVersion: The installed version of the distribution (e.g. ``"1.7.1"``).
"""

model_config = ConfigDict(frozen=True)

distributionName: Annotated[
str,
pydantic.Field(
description="PyPI distribution name (e.g. 'ado-ray-tune', 'ado-core')."
),
]
distributionVersion: Annotated[
str,
pydantic.Field(
description="Installed version of the distribution (e.g. '1.7.1')."
),
]
10 changes: 10 additions & 0 deletions orchestrator/core/operation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ class OperatorMetadata(pydantic.BaseModel):
description="The discovery operation type this operator belongs to."
),
]
distributionName: Annotated[
str | None,
pydantic.Field(
description=(
"PyPI distribution name that provides this operator "
"(e.g. 'ado-ray-tune', 'ado-core'). Resolved at registration time; "
"None when the module is not installed as a distribution package."
),
),
] = None

@pydantic.field_validator("version", mode="after")
@classmethod
Expand Down
12 changes: 12 additions & 0 deletions orchestrator/core/operation/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pydantic

from orchestrator.core.metadata import PackageProvenance
from orchestrator.core.operation.config import (
DiscoveryOperationEnum,
DiscoveryOperationResourceConfiguration,
Expand Down Expand Up @@ -86,6 +87,17 @@ class OperationResource(ADOResource):
description="A list of status objects",
),
]
operatorProvenance: Annotated[
PackageProvenance | None,
pydantic.Field(
default=None,
description=(
"Python distribution that provided the operator at the time this "
"operation was created. None for operations created before provenance "
"tracking was introduced, or when the distribution could not be resolved."
),
),
]

@pydantic.model_validator(mode="before")
@classmethod
Expand Down
42 changes: 39 additions & 3 deletions orchestrator/modules/actuators/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from orchestrator.core.actuatorconfiguration.config import (
GenericActuatorParameters,
)
from orchestrator.core.metadata import PackageProvenance
from orchestrator.modules.actuators.base import (
ActuatorBase,
)
Expand Down Expand Up @@ -87,7 +88,7 @@ def __init__(
self.actuatorIdentifierMap: dict[str, type[ActuatorBase]] = {}
# Maps actuator ids to ExperimentCatalog instances
self.catalogIdentifierMap: dict[str, ExperimentCatalog] = {}
# Maps actuator ids to metadata (version and description)
# Maps actuator ids to metadata (version, description, and distributionName)
self.actuatorMetadataMap: dict[str, dict[str, str | None]] = {}
self.log = logging.getLogger("registry")
self.id = uuid.uuid4()
Expand Down Expand Up @@ -186,7 +187,11 @@ def _get_builtin_actuator_metadata(
except (AttributeError, IndexError):
pass

return {"version": version, "description": description}
return {
"version": version,
"description": description,
"distributionName": "ado-core",
}

def _get_plugin_actuator_metadata(
self, actuator_class: "type[ActuatorBase]"
Expand All @@ -203,6 +208,7 @@ def _get_plugin_actuator_metadata(

version = None
description = None
distribution_name = None

try:
# Get the module name from the actuator class
Expand All @@ -216,12 +222,17 @@ def _get_plugin_actuator_metadata(
dist = importlib.metadata.distribution(dist_name)
version = dist.metadata.get("Version", None)
description = dist.metadata.get("Summary", None)
distribution_name = dist_name
except Exception as e:
self.log.debug(
f"Could not extract metadata for plugin actuator {actuator_class}: {e}"
)

return {"version": version, "description": description}
return {
"version": version,
"description": description,
"distributionName": distribution_name,
}

def registerActuator(
self,
Expand Down Expand Up @@ -250,6 +261,31 @@ def registerActuator(

self.actuatorMetadataMap[actuatorid] = metadata

def provenance_for_actuator(self, identifier: str) -> PackageProvenance | None:
"""Return the package provenance for a registered actuator.

Returns ``None`` if the actuator is not registered or its distribution
could not be resolved (e.g. the actuator was loaded from an unpackaged
local path).

Args:
identifier: The actuator identifier.

Returns:
A :class:`~orchestrator.core.metadata.PackageProvenance` instance,
or ``None`` if provenance is unavailable.
"""
metadata = self.actuatorMetadataMap.get(identifier)
if metadata is None:
return None
dist_name = metadata.get("distributionName")
version = metadata.get("version")
if dist_name is None or version is None:
return None
return PackageProvenance(
distributionName=dist_name, distributionVersion=version
)

def catalogForActuatorIdentifier(self, actuatorid: str) -> ExperimentCatalog:
"""Returns the catalog for a given actuator via its identifier

Expand Down
26 changes: 22 additions & 4 deletions orchestrator/modules/operators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,12 +403,21 @@ def add_operation_and_output_to_metastore(
metastore: SQLStore,
) -> OperationResource:
"""Creates an operation resource from the given configuration and adds it and its outputs to the resource store"""
from orchestrator.modules.operators.collections import provenance_for_operator

operator_module = operation_resource_configuration.operation.module
operator_provenance = None
if isinstance(operator_module, OperatorReference):
operator_provenance = provenance_for_operator(
operator_module.operatorName, operator_module.operationType
)

operation = OperationResource(
operationType=operation_resource_configuration.operation.module.operationType,
operatorIdentifier=operation_resource_configuration.operation.module.operatorIdentifier,
operationType=operator_module.operationType,
operatorIdentifier=operator_module.operatorIdentifier,
config=operation_resource_configuration,
status=[output.exitStatus],
operatorProvenance=operator_provenance,
)

# ValueError means the resource has already been added
Expand Down Expand Up @@ -452,6 +461,7 @@ def create_operation_and_add_to_metastore(
"""

from orchestrator.core.operation.config import DiscoveryOperationConfiguration
from orchestrator.modules.operators.collections import provenance_for_operator

operation_resource_configuration = DiscoveryOperationResourceConfiguration(
operation=DiscoveryOperationConfiguration(
Expand All @@ -463,11 +473,19 @@ def create_operation_and_add_to_metastore(
spaces=[discovery_space.resource.identifier],
)

op_module = operation_resource_configuration.operation.module
operator_provenance = None
if isinstance(op_module, OperatorReference):
operator_provenance = provenance_for_operator(
op_module.operatorName, op_module.operationType
)

operation = OperationResource(
identifier=operation_identifier,
operationType=operation_resource_configuration.operation.module.operationType,
operatorIdentifier=operation_resource_configuration.operation.module.operatorIdentifier,
operationType=op_module.operationType,
operatorIdentifier=op_module.operatorIdentifier,
config=operation_resource_configuration,
operatorProvenance=operator_provenance,
)

related_identifiers = [
Expand Down
Loading