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.
- meds_algorithm/ARCHITECTURE.md +376 -0
- meds_algorithm/CONTRACT.md +567 -0
- meds_algorithm/__init__.py +94 -0
- meds_algorithm/algorithms.py +157 -0
- meds_algorithm/artifacts/__init__.py +1 -0
- meds_algorithm/artifacts/inputs.py +38 -0
- meds_algorithm/artifacts/manifest.py +142 -0
- meds_algorithm/artifacts/publication.py +68 -0
- meds_algorithm/artifacts/references.py +79 -0
- meds_algorithm/artifacts/results.py +76 -0
- meds_algorithm/configuration.py +64 -0
- meds_algorithm/conformance/__init__.py +1 -0
- meds_algorithm/conformance/assertions.py +62 -0
- meds_algorithm/conformance/end_to_end.py +150 -0
- meds_algorithm/conformance/meds_dev.py +127 -0
- meds_algorithm/conformance/reports.py +49 -0
- meds_algorithm/conformance/signal.py +141 -0
- meds_algorithm/conformance/types.py +29 -0
- meds_algorithm/contracts/__init__.py +1 -0
- meds_algorithm/contracts/commands.py +182 -0
- meds_algorithm/contracts/enums.py +28 -0
- meds_algorithm/data/__init__.py +1 -0
- meds_algorithm/data/index.py +56 -0
- meds_algorithm/data/labels.py +55 -0
- meds_algorithm/data/predicates.py +60 -0
- meds_algorithm/data/validation.py +25 -0
- meds_algorithm/evaluation/__init__.py +1 -0
- meds_algorithm/evaluation/predictions.py +56 -0
- meds_algorithm/integrations/__init__.py +1 -0
- meds_algorithm/integrations/meds_dev.py +51 -0
- meds_algorithm/requests.py +262 -0
- meds_algorithm/runner.py +232 -0
- meds_algorithm/runtime/__init__.py +1 -0
- meds_algorithm/runtime/process.py +50 -0
- meds_algorithm/runtime/provenance.py +31 -0
- meds_algorithm/runtime/resources.py +97 -0
- meds_algorithm/serialization.py +122 -0
- meds_algorithm/spec.py +23 -0
- meds_algorithm/tasks/__init__.py +1 -0
- meds_algorithm/tasks/spec.py +136 -0
- meds_algorithm/version.py +3 -0
- meds_algorithm-0.4.0a4.dist-info/METADATA +212 -0
- meds_algorithm-0.4.0a4.dist-info/RECORD +46 -0
- meds_algorithm-0.4.0a4.dist-info/WHEEL +4 -0
- meds_algorithm-0.4.0a4.dist-info/entry_points.txt +4 -0
- 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
|