meds-algorithm 0.4.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. meds_algorithm/ARCHITECTURE.md +376 -0
  2. meds_algorithm/CONTRACT.md +567 -0
  3. meds_algorithm/__init__.py +94 -0
  4. meds_algorithm/algorithms.py +157 -0
  5. meds_algorithm/artifacts/__init__.py +1 -0
  6. meds_algorithm/artifacts/inputs.py +38 -0
  7. meds_algorithm/artifacts/manifest.py +142 -0
  8. meds_algorithm/artifacts/publication.py +68 -0
  9. meds_algorithm/artifacts/references.py +79 -0
  10. meds_algorithm/artifacts/results.py +76 -0
  11. meds_algorithm/configuration.py +64 -0
  12. meds_algorithm/conformance/__init__.py +1 -0
  13. meds_algorithm/conformance/assertions.py +62 -0
  14. meds_algorithm/conformance/end_to_end.py +150 -0
  15. meds_algorithm/conformance/meds_dev.py +127 -0
  16. meds_algorithm/conformance/reports.py +49 -0
  17. meds_algorithm/conformance/signal.py +141 -0
  18. meds_algorithm/conformance/types.py +29 -0
  19. meds_algorithm/contracts/__init__.py +1 -0
  20. meds_algorithm/contracts/commands.py +182 -0
  21. meds_algorithm/contracts/enums.py +28 -0
  22. meds_algorithm/data/__init__.py +1 -0
  23. meds_algorithm/data/index.py +56 -0
  24. meds_algorithm/data/labels.py +55 -0
  25. meds_algorithm/data/predicates.py +60 -0
  26. meds_algorithm/data/validation.py +25 -0
  27. meds_algorithm/evaluation/__init__.py +1 -0
  28. meds_algorithm/evaluation/predictions.py +56 -0
  29. meds_algorithm/integrations/__init__.py +1 -0
  30. meds_algorithm/integrations/meds_dev.py +51 -0
  31. meds_algorithm/requests.py +262 -0
  32. meds_algorithm/runner.py +232 -0
  33. meds_algorithm/runtime/__init__.py +1 -0
  34. meds_algorithm/runtime/process.py +50 -0
  35. meds_algorithm/runtime/provenance.py +31 -0
  36. meds_algorithm/runtime/resources.py +97 -0
  37. meds_algorithm/serialization.py +122 -0
  38. meds_algorithm/spec.py +23 -0
  39. meds_algorithm/tasks/__init__.py +1 -0
  40. meds_algorithm/tasks/spec.py +136 -0
  41. meds_algorithm/version.py +3 -0
  42. meds_algorithm-0.4.0a4.dist-info/METADATA +212 -0
  43. meds_algorithm-0.4.0a4.dist-info/RECORD +46 -0
  44. meds_algorithm-0.4.0a4.dist-info/WHEEL +4 -0
  45. meds_algorithm-0.4.0a4.dist-info/entry_points.txt +4 -0
  46. meds_algorithm-0.4.0a4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,94 @@
1
+ """Shared MEDS algorithm infrastructure; data dependencies are imported only when used."""
2
+
3
+ from .algorithms import (
4
+ Algorithm,
5
+ FinetuneAlgorithm,
6
+ PredictOnlyAlgorithm,
7
+ PretrainedAlgorithm,
8
+ ProbeAlgorithm,
9
+ SupervisedAlgorithm,
10
+ ZeroShotDirectAlgorithm,
11
+ ZeroShotMaterializedAlgorithm,
12
+ )
13
+ from .artifacts.manifest import digest_config, digest_file, measured_artifact, read_manifest
14
+ from .artifacts.references import (
15
+ ArtifactDestination,
16
+ ArtifactReference,
17
+ EmbeddingsArtifact,
18
+ FinetunedAlgorithmArtifact,
19
+ PayloadMetadata,
20
+ PredictionsArtifact,
21
+ PreparedDataArtifact,
22
+ PretrainedAlgorithmArtifact,
23
+ ProbeAlgorithmArtifact,
24
+ SupervisedAlgorithmArtifact,
25
+ TrajectoriesArtifact,
26
+ )
27
+ from .artifacts.results import write_run_result
28
+ from .configuration import AlgorithmConfig, ExecutionOptions, NNConfig
29
+ from .contracts.commands import (
30
+ ALGORITHM_PROFILES,
31
+ STEP_SPECS,
32
+ ZERO_SHOT_TASK_COMMANDS,
33
+ ArtifactInput,
34
+ ArtifactType,
35
+ Command,
36
+ CommandName,
37
+ LegacyAlgorithmRepository,
38
+ StepSpec,
39
+ )
40
+ from .data.index import read_index, split_files
41
+ from .data.labels import read_context_labels
42
+ from .evaluation.predictions import attach_ground_truth, check_decoded
43
+ from .runner import AlgorithmRepository, StepRunner
44
+ from .tasks.spec import RelaxationLedger, TaskSpec
45
+ from .version import CONTRACT_VERSION
46
+
47
+ __all__ = [
48
+ "ALGORITHM_PROFILES",
49
+ "CONTRACT_VERSION",
50
+ "STEP_SPECS",
51
+ "ZERO_SHOT_TASK_COMMANDS",
52
+ "Algorithm",
53
+ "AlgorithmConfig",
54
+ "AlgorithmRepository",
55
+ "ArtifactDestination",
56
+ "ArtifactInput",
57
+ "ArtifactReference",
58
+ "ArtifactType",
59
+ "Command",
60
+ "CommandName",
61
+ "EmbeddingsArtifact",
62
+ "ExecutionOptions",
63
+ "FinetuneAlgorithm",
64
+ "FinetunedAlgorithmArtifact",
65
+ "LegacyAlgorithmRepository",
66
+ "NNConfig",
67
+ "PayloadMetadata",
68
+ "PredictOnlyAlgorithm",
69
+ "PredictionsArtifact",
70
+ "PreparedDataArtifact",
71
+ "PretrainedAlgorithm",
72
+ "PretrainedAlgorithmArtifact",
73
+ "ProbeAlgorithm",
74
+ "ProbeAlgorithmArtifact",
75
+ "RelaxationLedger",
76
+ "StepRunner",
77
+ "StepSpec",
78
+ "SupervisedAlgorithm",
79
+ "SupervisedAlgorithmArtifact",
80
+ "TaskSpec",
81
+ "TrajectoriesArtifact",
82
+ "ZeroShotDirectAlgorithm",
83
+ "ZeroShotMaterializedAlgorithm",
84
+ "attach_ground_truth",
85
+ "check_decoded",
86
+ "digest_config",
87
+ "digest_file",
88
+ "measured_artifact",
89
+ "read_context_labels",
90
+ "read_index",
91
+ "read_manifest",
92
+ "split_files",
93
+ "write_run_result",
94
+ ]
@@ -0,0 +1,157 @@
1
+ """Profile ABCs are the authority for lifecycle order and typed operation signatures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import ClassVar, get_type_hints
7
+
8
+ from .artifacts.references import PayloadMetadata
9
+ from .configuration import AlgorithmConfig
10
+ from .contracts.enums import CommandName
11
+ from .requests import (
12
+ InferEmbeddingsRequest,
13
+ InferTrajectoriesRequest,
14
+ PredictFinetuneRequest,
15
+ PredictOnlyRequest,
16
+ PredictProbeRequest,
17
+ PredictSupervisedRequest,
18
+ PredictZeroShotDirectRequest,
19
+ PredictZeroShotMaterializedRequest,
20
+ PreprocessDataRequest,
21
+ PretrainRequest,
22
+ StepRequest,
23
+ TrainFinetuneRequest,
24
+ TrainProbeRequest,
25
+ TrainSupervisedRequest,
26
+ )
27
+
28
+
29
+ class Algorithm(ABC):
30
+ name: ClassVar[str]
31
+ config_type: ClassVar[type[AlgorithmConfig]] = AlgorithmConfig
32
+
33
+ def __init__(self, config: AlgorithmConfig | None = None):
34
+ self.config = self.config_type() if config is None else config
35
+ if type(self.config) is not self.config_type:
36
+ raise TypeError(f"Expected {self.config_type.__name__}")
37
+ if not getattr(self, "name", "").strip():
38
+ raise ValueError("Algorithm.name must be nonempty")
39
+ self.config.validate()
40
+
41
+ @abstractmethod
42
+ def preprocess_data(self, request: PreprocessDataRequest) -> PayloadMetadata:
43
+ """Write task-independent prepared data into request.output.directory."""
44
+
45
+ def validate_inputs(self, request: StepRequest) -> None:
46
+ """Default: fitted inputs must match this algorithm and resolved configuration.
47
+
48
+ Extend for payload/vocabulary/cutoff checks. Override deliberately if a documented family
49
+ supports compatible external pretrained algorithms or different training/inference settings.
50
+ """
51
+ for _, artifact in request.artifact_inputs():
52
+ if artifact.kind.value.endswith("algorithm"):
53
+ producer = artifact.read_manifest().get("algorithm", {})
54
+ recorded = producer.get("config")
55
+ if isinstance(recorded, dict):
56
+ # Artifacts predating this optional field mean no feature additions.
57
+ recorded = {"additional_predicates_file": None, **recorded}
58
+ if producer.get("name") != self.name or recorded != self.config.to_dict():
59
+ raise ValueError(
60
+ "Algorithm algorithm/configuration mismatch; load its recorded configuration"
61
+ )
62
+
63
+
64
+ class SupervisedAlgorithm(Algorithm):
65
+ profile = "supervised"
66
+ steps = ("preprocess_data", "train_supervised", "predict_supervised")
67
+
68
+ @abstractmethod
69
+ def train_supervised(self, request: TrainSupervisedRequest) -> PayloadMetadata: ...
70
+
71
+ @abstractmethod
72
+ def predict_supervised(self, request: PredictSupervisedRequest) -> PayloadMetadata: ...
73
+
74
+
75
+ class PretrainedAlgorithm(Algorithm):
76
+ @abstractmethod
77
+ def pretrain(self, request: PretrainRequest) -> PayloadMetadata: ...
78
+
79
+
80
+ class FinetuneAlgorithm(PretrainedAlgorithm):
81
+ profile = "finetune"
82
+ steps = ("preprocess_data", "pretrain", "train_finetune", "predict_finetune")
83
+
84
+ @abstractmethod
85
+ def train_finetune(self, request: TrainFinetuneRequest) -> PayloadMetadata: ...
86
+
87
+ @abstractmethod
88
+ def predict_finetune(self, request: PredictFinetuneRequest) -> PayloadMetadata: ...
89
+
90
+
91
+ class ProbeAlgorithm(PretrainedAlgorithm):
92
+ profile = "probe"
93
+ steps = ("preprocess_data", "pretrain", "infer_embeddings", "train_probe", "predict_probe")
94
+
95
+ @abstractmethod
96
+ def infer_embeddings(self, request: InferEmbeddingsRequest) -> PayloadMetadata: ...
97
+
98
+ @abstractmethod
99
+ def train_probe(self, request: TrainProbeRequest) -> PayloadMetadata: ...
100
+
101
+ @abstractmethod
102
+ def predict_probe(self, request: PredictProbeRequest) -> PayloadMetadata: ...
103
+
104
+
105
+ class ZeroShotDirectAlgorithm(PretrainedAlgorithm):
106
+ profile = "zero_shot_direct"
107
+ steps = ("preprocess_data", "pretrain", "predict_zero_shot_direct")
108
+
109
+ @abstractmethod
110
+ def predict_zero_shot_direct(self, request: PredictZeroShotDirectRequest) -> PayloadMetadata: ...
111
+
112
+
113
+ class ZeroShotMaterializedAlgorithm(PretrainedAlgorithm):
114
+ profile = "zero_shot_materialized"
115
+ steps = ("preprocess_data", "pretrain", "infer_trajectories", "predict_zero_shot_materialized")
116
+
117
+ @abstractmethod
118
+ def infer_trajectories(self, request: InferTrajectoriesRequest) -> PayloadMetadata: ...
119
+
120
+ @abstractmethod
121
+ def predict_zero_shot_materialized(
122
+ self, request: PredictZeroShotMaterializedRequest
123
+ ) -> PayloadMetadata: ...
124
+
125
+
126
+ class PredictOnlyAlgorithm(Algorithm):
127
+ profile = "predict_only"
128
+ steps = ("preprocess_data", "predict_only")
129
+
130
+ @abstractmethod
131
+ def predict_only(self, request: PredictOnlyRequest) -> PayloadMetadata: ...
132
+
133
+
134
+ def profile_classes() -> tuple[type[Algorithm], ...]:
135
+ """Explicit built-ins; no plugin discovery or duplicated command specification."""
136
+ return (
137
+ SupervisedAlgorithm,
138
+ FinetuneAlgorithm,
139
+ ProbeAlgorithm,
140
+ ZeroShotDirectAlgorithm,
141
+ ZeroShotMaterializedAlgorithm,
142
+ PredictOnlyAlgorithm,
143
+ )
144
+
145
+
146
+ def profile_class(profile: str) -> type[Algorithm]:
147
+ for cls in profile_classes():
148
+ if cls.profile == profile:
149
+ return cls
150
+ raise ValueError(f"Unknown algorithm profile: {profile}")
151
+
152
+
153
+ def request_type(profile: str, step: str | CommandName) -> type[StepRequest]:
154
+ cls = profile_class(profile)
155
+ if step not in cls.steps:
156
+ raise ValueError(f"{step} is not part of the {profile} algorithm profile")
157
+ return get_type_hints(getattr(cls, step))["request"]
@@ -0,0 +1 @@
1
+ """Artifact manifests, publication, input resolution and run evidence."""
@@ -0,0 +1,38 @@
1
+ """Expose an upstream artifact reference as an explicit orchestration argument."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from ..contracts.commands import STEP_SPECS, CommandName
9
+ from .manifest import read_manifest
10
+
11
+
12
+ def resolve_input(artifact: Path, argument: str) -> Path:
13
+ """Resolve a producer's recorded input; validate the referenced envelope type."""
14
+ manifest = read_manifest(artifact)
15
+ spec = STEP_SPECS[CommandName(manifest["command"]["name"])]
16
+ expected = next((item for item in spec.artifact_inputs if item.argument == argument), None)
17
+ if expected is None:
18
+ raise ValueError(f"{argument!r} is not an artifact input of this producer")
19
+ entry = manifest.get("inputs", {}).get(argument)
20
+ if not isinstance(entry, dict) or not entry.get("path"):
21
+ raise ValueError(f"Producer must record inputs.{argument}.path")
22
+ path = Path(entry["path"])
23
+ if not path.is_absolute():
24
+ path = artifact.resolve() / path
25
+ path = path.resolve()
26
+ upstream = read_manifest(path)
27
+ if upstream["artifact"]["type"] != expected.artifact_type.value:
28
+ raise ValueError(f"{argument} has the wrong artifact type")
29
+ # Content, vocabulary, and algorithm-specific compatibility are still checked by the consumer.
30
+ return path
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser(description=__doc__)
35
+ parser.add_argument("artifact", type=Path)
36
+ parser.add_argument("argument")
37
+ args = parser.parse_args()
38
+ print(resolve_input(args.artifact, args.argument))
@@ -0,0 +1,142 @@
1
+ """Measured artifacts with explicit algorithm provenance and rollback-safe replacement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from collections.abc import Callable, Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from ..contracts.commands import STEP_SPECS, CommandName
16
+ from ..runtime.provenance import code_provenance
17
+ from ..runtime.resources import _ProcessTreeMemorySampler
18
+ from ..version import CONTRACT_VERSION
19
+ from .publication import staged_directory
20
+
21
+
22
+ def read_manifest(directory: Path | str) -> dict[str, Any]:
23
+ path = Path(directory) / "manifest.yaml"
24
+ if not path.is_file():
25
+ raise FileNotFoundError(f"Artifact {directory} has no manifest.yaml")
26
+ value = yaml.safe_load(path.read_text())
27
+ if not isinstance(value, dict) or value.get("contract_version") != CONTRACT_VERSION:
28
+ raise ValueError(f"{path} is not a version 4 MEDS algorithm artifact manifest")
29
+ for field in ("artifact", "command", "code", "inputs", "resources"):
30
+ if not isinstance(value.get(field), dict):
31
+ raise ValueError(f"{path} requires a {field} mapping")
32
+ try:
33
+ spec = STEP_SPECS[CommandName(value["command"]["name"])]
34
+ except (KeyError, ValueError) as error:
35
+ raise ValueError(f"{path} has an unknown or missing command") from error
36
+ if value["artifact"].get("type") != spec.artifact_type.value:
37
+ raise ValueError(f"{path} artifact type does not match its command")
38
+ for field in ("producer", "payload_format"):
39
+ if not isinstance(value["artifact"].get(field), str) or not value["artifact"][field].strip():
40
+ raise ValueError(f"{path} requires artifact.{field}")
41
+ return value
42
+
43
+
44
+ def digest_file(path: Path | str) -> str:
45
+ with Path(path).open("rb") as stream:
46
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
47
+ return f"sha256:{digest}"
48
+
49
+
50
+ def digest_config(config: Any) -> str:
51
+ """Digest a command's configuration: its arguments plus any algorithm-owned settings it resolved.
52
+
53
+ The value is canonical JSON (sorted keys; paths and other objects as strings), so the same arguments
54
+ always give the same digest regardless of order.
55
+ """
56
+ canonical = json.dumps(config, sort_keys=True, default=str, separators=(",", ":"))
57
+ return f"sha256:{hashlib.sha256(canonical.encode()).hexdigest()}"
58
+
59
+
60
+ @contextmanager
61
+ def measured_artifact(
62
+ destination: Path | str,
63
+ *,
64
+ artifact_type: str,
65
+ command: str,
66
+ producer: str,
67
+ payload_format: str | Callable[[], str],
68
+ inputs: dict[str, Any] | None = None,
69
+ config_digest: str | None = None,
70
+ semantics_digest: str | None = None,
71
+ external_predicates_file: Path | str | None = None,
72
+ overwrite: bool = False,
73
+ repo: Path | str | None = None,
74
+ manifest_fields: dict[str, Any] | None = None,
75
+ ) -> Iterator[Path]:
76
+ """Measure staged output and publish its manifest with rollback on replacement failure."""
77
+ if STEP_SPECS[CommandName(command)].artifact_type.value != artifact_type:
78
+ raise ValueError("Artifact type does not match its command")
79
+ if (
80
+ not isinstance(producer, str)
81
+ or not producer.strip()
82
+ or (
83
+ not callable(payload_format)
84
+ and (not isinstance(payload_format, str) or not payload_format.strip())
85
+ )
86
+ ):
87
+ raise ValueError("producer and payload_format must be nonempty strings")
88
+ with staged_directory(destination, overwrite=overwrite) as staging:
89
+ started = time.monotonic()
90
+ sampler = _ProcessTreeMemorySampler()
91
+ sampler.start()
92
+ try:
93
+ yield staging
94
+ finally:
95
+ sampler.stop()
96
+ resolved_format = payload_format() if callable(payload_format) else payload_format
97
+ if not isinstance(resolved_format, str) or not resolved_format.strip():
98
+ raise ValueError("payload_format must resolve to a nonempty string")
99
+ predicates = None
100
+ if external_predicates_file is not None:
101
+ predicates = {
102
+ "path": str(Path(external_predicates_file).resolve()),
103
+ "digest": digest_file(external_predicates_file),
104
+ }
105
+ recorded_inputs = dict(inputs or {})
106
+ if predicates is not None:
107
+ recorded_inputs["external_predicates_file"] = predicates
108
+ manifest = {
109
+ "contract_version": CONTRACT_VERSION,
110
+ "artifact": {
111
+ "type": artifact_type,
112
+ "payload_format": resolved_format,
113
+ "producer": producer,
114
+ },
115
+ "command": {"name": command, "config_digest": config_digest},
116
+ "code": code_provenance(repo),
117
+ "inputs": recorded_inputs,
118
+ "semantics_digest": semantics_digest,
119
+ "resources": {
120
+ "wall_seconds": time.monotonic() - started,
121
+ "memory": {
122
+ "peak_process_tree_pss_bytes": sampler.peak_pss_bytes,
123
+ "peak_process_tree_rss_bytes": sampler.peak_rss_bytes,
124
+ "sampling_interval_seconds": sampler.interval_seconds,
125
+ "source": sampler.source,
126
+ "includes_descendants": sampler.includes_descendants,
127
+ },
128
+ "gpu": {
129
+ "peak_allocated_bytes": None,
130
+ "peak_reserved_bytes": None,
131
+ "source": None,
132
+ },
133
+ "measurement": {
134
+ "schema_version": "1",
135
+ },
136
+ },
137
+ }
138
+ if manifest_fields:
139
+ if manifest.keys() & manifest_fields.keys():
140
+ raise ValueError("Extra manifest fields cannot replace the common envelope")
141
+ manifest.update(manifest_fields)
142
+ (staging / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
@@ -0,0 +1,68 @@
1
+ """Unique staging, exclusive writers, and recoverable directory replacement.
2
+
3
+ New artifacts appear by one rename. Overwrite uses two renames and can briefly hide the
4
+ destination from readers. A process crash may leave the backup/lock for manual recovery;
5
+ we never silently delete another writer's staging area or a previous artifact.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ import warnings
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+
18
+ @contextmanager
19
+ def staged_directory(destination: Path | str, *, overwrite: bool = False) -> Iterator[Path]:
20
+ destination = Path(destination).absolute()
21
+ destination.parent.mkdir(parents=True, exist_ok=True)
22
+ lock = destination.with_name(f".{destination.name}.lock")
23
+ # Atomic acquisition also serializes writers in different threads/processes.
24
+ try:
25
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
26
+ except FileExistsError as error:
27
+ raise FileExistsError(
28
+ f"Artifact is locked: {lock}; inspect the writer before recovering a stale lock"
29
+ ) from error
30
+ staging = None
31
+ backup = None
32
+ try:
33
+ with os.fdopen(fd, "w") as stream:
34
+ stream.write(f"pid={os.getpid()}\n")
35
+ if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
36
+ raise ValueError(f"Artifact destination must be a directory, not a file/symlink: {destination}")
37
+ if destination.exists() and not overwrite:
38
+ raise FileExistsError(f"{destination} already exists; pass overwrite=true to replace it")
39
+ staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}.staging-", dir=destination.parent))
40
+ yield staging
41
+ if destination.exists():
42
+ backup = destination.with_name(f".{destination.name}.backup-{uuid4().hex}")
43
+ destination.replace(backup)
44
+ try:
45
+ staging.replace(destination)
46
+ except BaseException as publish_error:
47
+ if backup is not None:
48
+ try:
49
+ backup.replace(destination)
50
+ except BaseException as restore_error:
51
+ raise RuntimeError(
52
+ f"Publication failed; previous artifact is preserved at {backup}; "
53
+ f"restore also failed: {restore_error}"
54
+ ) from publish_error
55
+ raise
56
+ if backup is not None:
57
+ try:
58
+ shutil.rmtree(backup)
59
+ except OSError as error:
60
+ warnings.warn(
61
+ f"Published {destination}; retained old backup {backup}: {error}",
62
+ RuntimeWarning,
63
+ stacklevel=2,
64
+ )
65
+ finally:
66
+ if staging is not None and staging.exists():
67
+ shutil.rmtree(staging, ignore_errors=True)
68
+ lock.unlink(missing_ok=True)
@@ -0,0 +1,79 @@
1
+ """Immutable, lazy references to persisted artifacts; no payload loading at construction."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ from ..contracts.enums import ArtifactType
8
+ from ..serialization import Serializable
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ArtifactReference(Serializable):
13
+ directory: Path
14
+ kind: ClassVar[ArtifactType]
15
+
16
+ @classmethod
17
+ def open(cls, directory: Path | str):
18
+ reference = cls(Path(directory))
19
+ reference.read_manifest()
20
+ return reference
21
+
22
+ def read_manifest(self) -> dict:
23
+ from .manifest import read_manifest
24
+
25
+ manifest = read_manifest(self.directory)
26
+ if manifest["artifact"]["type"] != self.kind.value:
27
+ raise ValueError(f"Expected {self.kind.value} artifact: {self.directory}")
28
+ return manifest
29
+
30
+
31
+ class PreparedDataArtifact(ArtifactReference):
32
+ kind = ArtifactType.patients
33
+
34
+
35
+ class PretrainedAlgorithmArtifact(ArtifactReference):
36
+ kind = ArtifactType.pretrained_algorithm
37
+
38
+
39
+ class EmbeddingsArtifact(ArtifactReference):
40
+ kind = ArtifactType.embeddings
41
+
42
+
43
+ class TrajectoriesArtifact(ArtifactReference):
44
+ kind = ArtifactType.trajectories
45
+
46
+
47
+ class SupervisedAlgorithmArtifact(ArtifactReference):
48
+ kind = ArtifactType.supervised_algorithm
49
+
50
+
51
+ class FinetunedAlgorithmArtifact(ArtifactReference):
52
+ kind = ArtifactType.finetuned_algorithm
53
+
54
+
55
+ class ProbeAlgorithmArtifact(ArtifactReference):
56
+ kind = ArtifactType.probe_algorithm
57
+
58
+
59
+ class PredictionsArtifact(ArtifactReference):
60
+ kind = ArtifactType.predictions
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ArtifactDestination(Serializable):
65
+ """Final destination for callers; runner substitutes its private staging path for algorithm code."""
66
+
67
+ directory: Path
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PayloadMetadata(Serializable):
72
+ """Algorithm methods describe their payload; only the runner publishes the final artifact."""
73
+
74
+ format: str
75
+ version: int = 1
76
+
77
+ def validate(self):
78
+ if not self.format.strip() or self.version < 1:
79
+ raise ValueError("Payload format must be nonempty and version must be positive")
@@ -0,0 +1,76 @@
1
+ """Structured, commit-safe run evidence assembled from command manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from ..contracts.commands import ALGORITHM_PROFILES, STEP_SPECS, CommandName
14
+ from ..runtime.provenance import code_provenance
15
+ from .manifest import digest_file, read_manifest
16
+
17
+
18
+ def write_run_result(
19
+ run_dir: Path | str,
20
+ *,
21
+ repo: Path | str,
22
+ profile: str,
23
+ artifacts: dict[str, Path | str],
24
+ dataset: dict[str, Any],
25
+ task: dict[str, Any],
26
+ external_predicates_file: Path | str | None = None,
27
+ status: str = "complete",
28
+ ) -> Path:
29
+ """Write `spec.yaml`, copied manifests, and a run-level `result.yaml`."""
30
+ if profile not in ALGORITHM_PROFILES or set(artifacts) - set(ALGORITHM_PROFILES[profile]):
31
+ raise ValueError("Run artifacts must belong to a declared contract profile")
32
+ run_dir = Path(run_dir)
33
+ repo = Path(repo).resolve()
34
+ manifests_dir = run_dir / "manifests"
35
+ manifests_dir.mkdir(parents=True, exist_ok=True)
36
+ manifest_refs = {}
37
+ for command, artifact in artifacts.items():
38
+ manifest = read_manifest(artifact)
39
+ if (
40
+ manifest["command"]["name"] != command
41
+ or manifest["artifact"]["type"] != STEP_SPECS[CommandName(command)].artifact_type.value
42
+ ):
43
+ raise ValueError(f"Run entry {command} disagrees with its artifact manifest")
44
+ destination = manifests_dir / f"{command}.yaml"
45
+ shutil.copy2(Path(artifact) / "manifest.yaml", destination)
46
+ manifest_refs[command] = {
47
+ "path": str(destination.relative_to(run_dir)),
48
+ "artifact_type": manifest["artifact"]["type"],
49
+ "resources": manifest["resources"],
50
+ }
51
+
52
+ predicates = None
53
+ if external_predicates_file is not None:
54
+ predicates = {
55
+ "path": str(Path(external_predicates_file).resolve()),
56
+ "digest": digest_file(external_predicates_file),
57
+ }
58
+ provenance = code_provenance(repo)
59
+ spec = {"profile": profile, "dataset": dataset, "task": task, "external_predicates_file": predicates}
60
+ result = {
61
+ "profile": profile,
62
+ "status": status,
63
+ "source": provenance,
64
+ "environment": {
65
+ "hostname": platform.node(),
66
+ "platform": platform.platform(),
67
+ "python": platform.python_version(),
68
+ "cpu_count": os.cpu_count(),
69
+ "slurm_job_id": os.environ.get("SLURM_JOB_ID"),
70
+ },
71
+ "commands": manifest_refs,
72
+ }
73
+ (run_dir / "spec.yaml").write_text(yaml.safe_dump(spec, sort_keys=False))
74
+ output = run_dir / "result.yaml"
75
+ output.write_text(yaml.safe_dump(result, sort_keys=False))
76
+ return output