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,262 @@
|
|
|
1
|
+
"""One typed request per operation. Field metadata defines the existing CLI path names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import MISSING, dataclass, field, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar, get_args, get_type_hints
|
|
8
|
+
|
|
9
|
+
from .artifacts.references import (
|
|
10
|
+
ArtifactDestination,
|
|
11
|
+
ArtifactReference,
|
|
12
|
+
EmbeddingsArtifact,
|
|
13
|
+
FinetunedAlgorithmArtifact,
|
|
14
|
+
PredictionsArtifact,
|
|
15
|
+
PreparedDataArtifact,
|
|
16
|
+
PretrainedAlgorithmArtifact,
|
|
17
|
+
ProbeAlgorithmArtifact,
|
|
18
|
+
SupervisedAlgorithmArtifact,
|
|
19
|
+
TrajectoriesArtifact,
|
|
20
|
+
)
|
|
21
|
+
from .configuration import ExecutionOptions
|
|
22
|
+
from .serialization import Serializable
|
|
23
|
+
from .tasks.spec import TaskSpec
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PredictionIndex(Serializable):
|
|
28
|
+
directory: Path
|
|
29
|
+
splits: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
def validate(self):
|
|
32
|
+
if (
|
|
33
|
+
not self.splits
|
|
34
|
+
or len(set(self.splits)) != len(self.splits)
|
|
35
|
+
or any(not s.strip() for s in self.splits)
|
|
36
|
+
):
|
|
37
|
+
raise ValueError("splits must be a nonempty tuple of unique names")
|
|
38
|
+
|
|
39
|
+
def read(self):
|
|
40
|
+
from .data.index import read_index
|
|
41
|
+
|
|
42
|
+
return read_index(self.directory, self.splits)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class TrainingLabels(Serializable):
|
|
47
|
+
directory: Path
|
|
48
|
+
|
|
49
|
+
def read(self, split: str = "train"):
|
|
50
|
+
from .data.labels import read_targets
|
|
51
|
+
|
|
52
|
+
if split not in ("train", "tuning"):
|
|
53
|
+
raise ValueError("Training labels permit only train and tuning")
|
|
54
|
+
return read_targets(self.directory, (split,))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PredictionContext(Serializable):
|
|
59
|
+
directory: Path
|
|
60
|
+
splits: tuple[str, ...]
|
|
61
|
+
|
|
62
|
+
def validate(self):
|
|
63
|
+
if (
|
|
64
|
+
not self.splits
|
|
65
|
+
or len(set(self.splits)) != len(self.splits)
|
|
66
|
+
or set(self.splits) - {"train", "tuning"}
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Context splits must explicitly select train and/or tuning")
|
|
69
|
+
|
|
70
|
+
def read(self, index: PredictionIndex):
|
|
71
|
+
from .data.labels import read_context_labels
|
|
72
|
+
|
|
73
|
+
return read_context_labels(
|
|
74
|
+
self.directory, self.splits, index.splits, evaluation_index_dir=index.directory
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, kw_only=True)
|
|
79
|
+
class StepRequest(Serializable):
|
|
80
|
+
output: ArtifactDestination
|
|
81
|
+
execution: ExecutionOptions = field(default_factory=ExecutionOptions)
|
|
82
|
+
predicates_file: Path | None = field(default=None, metadata={"argument": "external_predicates_file"})
|
|
83
|
+
output_argument: ClassVar[str]
|
|
84
|
+
result_type: ClassVar[type[ArtifactReference]]
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def argument_fields(cls):
|
|
88
|
+
hints = get_type_hints(cls)
|
|
89
|
+
return [(f, hints[f.name]) for f in fields(cls)]
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_arguments(cls, arguments: dict):
|
|
93
|
+
values = dict(arguments)
|
|
94
|
+
output = values.pop(cls.output_argument, None)
|
|
95
|
+
if output is None:
|
|
96
|
+
raise ValueError(f"requires {cls.output_argument}=<path>")
|
|
97
|
+
result = {
|
|
98
|
+
"output": {"directory": output},
|
|
99
|
+
"execution": {"overwrite": values.pop("do_overwrite", False), "seed": values.pop("seed", 0)},
|
|
100
|
+
}
|
|
101
|
+
for f, annotation in cls.argument_fields():
|
|
102
|
+
if f.name in ("output", "execution"):
|
|
103
|
+
continue
|
|
104
|
+
kind = next((t for t in get_args(annotation) if t is not type(None)), annotation)
|
|
105
|
+
key = f.metadata.get("argument")
|
|
106
|
+
if key and key in values:
|
|
107
|
+
value = values.pop(key)
|
|
108
|
+
result[f.name] = (
|
|
109
|
+
{"directory": value}
|
|
110
|
+
if isinstance(kind, type) and issubclass(kind, (ArtifactReference, TrainingLabels))
|
|
111
|
+
else value
|
|
112
|
+
)
|
|
113
|
+
elif kind is PredictionIndex and "external_index_dir" in values:
|
|
114
|
+
result[f.name] = {
|
|
115
|
+
"directory": values.pop("external_index_dir"),
|
|
116
|
+
"splits": values.pop("splits", None),
|
|
117
|
+
}
|
|
118
|
+
elif kind is PredictionContext and "external_labels_dir" in values:
|
|
119
|
+
result[f.name] = {
|
|
120
|
+
"directory": values.pop("external_labels_dir"),
|
|
121
|
+
"splits": values.pop("context_splits", None),
|
|
122
|
+
}
|
|
123
|
+
elif kind is TaskSpec and "external_task_criteria_file" in values:
|
|
124
|
+
result[f.name] = {
|
|
125
|
+
"criteria_file": values.pop("external_task_criteria_file"),
|
|
126
|
+
"predicates_file": arguments.get("external_predicates_file"),
|
|
127
|
+
"name": values.pop("task_name", None),
|
|
128
|
+
}
|
|
129
|
+
if values:
|
|
130
|
+
raise ValueError(f"Unknown or forbidden arguments for {cls.__name__}: {sorted(values)}")
|
|
131
|
+
for f, _annotation in cls.argument_fields():
|
|
132
|
+
if f.default is MISSING and f.default_factory is MISSING and f.name not in result:
|
|
133
|
+
required = f.metadata.get(
|
|
134
|
+
"argument",
|
|
135
|
+
{
|
|
136
|
+
"index": "external_index_dir and splits",
|
|
137
|
+
"task": "external_task_criteria_file",
|
|
138
|
+
}.get(f.name, f.name),
|
|
139
|
+
)
|
|
140
|
+
raise ValueError(f"requires {required}")
|
|
141
|
+
return cls.from_dict(result)
|
|
142
|
+
|
|
143
|
+
def artifact_inputs(self):
|
|
144
|
+
for f in fields(self):
|
|
145
|
+
value = getattr(self, f.name)
|
|
146
|
+
if isinstance(value, ArtifactReference):
|
|
147
|
+
yield f.metadata["argument"], value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, kw_only=True)
|
|
151
|
+
class PreprocessDataRequest(StepRequest):
|
|
152
|
+
meds_dir: Path = field(metadata={"argument": "external_meds_dir"})
|
|
153
|
+
output_argument = "output_data_dir"
|
|
154
|
+
result_type = PreparedDataArtifact
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, kw_only=True)
|
|
158
|
+
class PretrainRequest(StepRequest):
|
|
159
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
160
|
+
output_argument = "output_pretrained_algorithm_dir"
|
|
161
|
+
result_type = PretrainedAlgorithmArtifact
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, kw_only=True)
|
|
165
|
+
class TrainSupervisedRequest(StepRequest):
|
|
166
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
167
|
+
labels: TrainingLabels = field(metadata={"argument": "external_labels_dir"})
|
|
168
|
+
output_argument = "output_supervised_algorithm_dir"
|
|
169
|
+
result_type = SupervisedAlgorithmArtifact
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, kw_only=True)
|
|
173
|
+
class TrainFinetuneRequest(StepRequest):
|
|
174
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
175
|
+
pretrained: PretrainedAlgorithmArtifact = field(metadata={"argument": "input_pretrained_algorithm_dir"})
|
|
176
|
+
labels: TrainingLabels = field(metadata={"argument": "external_labels_dir"})
|
|
177
|
+
output_argument = "output_finetuned_algorithm_dir"
|
|
178
|
+
result_type = FinetunedAlgorithmArtifact
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, kw_only=True)
|
|
182
|
+
class InferEmbeddingsRequest(StepRequest):
|
|
183
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
184
|
+
pretrained: PretrainedAlgorithmArtifact = field(metadata={"argument": "input_pretrained_algorithm_dir"})
|
|
185
|
+
index: PredictionIndex
|
|
186
|
+
output_argument = "output_embeddings_dir"
|
|
187
|
+
result_type = EmbeddingsArtifact
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, kw_only=True)
|
|
191
|
+
class TrainProbeRequest(StepRequest):
|
|
192
|
+
embeddings: EmbeddingsArtifact = field(metadata={"argument": "input_embeddings_dir"})
|
|
193
|
+
labels: TrainingLabels = field(metadata={"argument": "external_labels_dir"})
|
|
194
|
+
output_argument = "output_probe_algorithm_dir"
|
|
195
|
+
result_type = ProbeAlgorithmArtifact
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, kw_only=True)
|
|
199
|
+
class PredictSupervisedRequest(StepRequest):
|
|
200
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
201
|
+
algorithm: SupervisedAlgorithmArtifact = field(metadata={"argument": "input_supervised_algorithm_dir"})
|
|
202
|
+
index: PredictionIndex
|
|
203
|
+
output_argument = "output_predictions_dir"
|
|
204
|
+
result_type = PredictionsArtifact
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, kw_only=True)
|
|
208
|
+
class PredictFinetuneRequest(StepRequest):
|
|
209
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
210
|
+
algorithm: FinetunedAlgorithmArtifact = field(metadata={"argument": "input_finetuned_algorithm_dir"})
|
|
211
|
+
index: PredictionIndex
|
|
212
|
+
output_argument = "output_predictions_dir"
|
|
213
|
+
result_type = PredictionsArtifact
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, kw_only=True)
|
|
217
|
+
class PredictProbeRequest(StepRequest):
|
|
218
|
+
embeddings: EmbeddingsArtifact = field(metadata={"argument": "input_embeddings_dir"})
|
|
219
|
+
algorithm: ProbeAlgorithmArtifact = field(metadata={"argument": "input_probe_algorithm_dir"})
|
|
220
|
+
index: PredictionIndex
|
|
221
|
+
output_argument = "output_predictions_dir"
|
|
222
|
+
result_type = PredictionsArtifact
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, kw_only=True)
|
|
226
|
+
class PredictZeroShotDirectRequest(StepRequest):
|
|
227
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
228
|
+
pretrained: PretrainedAlgorithmArtifact = field(metadata={"argument": "input_pretrained_algorithm_dir"})
|
|
229
|
+
index: PredictionIndex
|
|
230
|
+
task: TaskSpec
|
|
231
|
+
output_argument = "output_predictions_dir"
|
|
232
|
+
result_type = PredictionsArtifact
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True, kw_only=True)
|
|
236
|
+
class InferTrajectoriesRequest(StepRequest):
|
|
237
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
238
|
+
pretrained: PretrainedAlgorithmArtifact = field(metadata={"argument": "input_pretrained_algorithm_dir"})
|
|
239
|
+
index: PredictionIndex
|
|
240
|
+
output_argument = "output_trajectories_dir"
|
|
241
|
+
result_type = TrajectoriesArtifact
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, kw_only=True)
|
|
245
|
+
class PredictZeroShotMaterializedRequest(StepRequest):
|
|
246
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
247
|
+
trajectories: TrajectoriesArtifact = field(metadata={"argument": "input_trajectories_dir"})
|
|
248
|
+
meds_dir: Path = field(metadata={"argument": "external_meds_dir"})
|
|
249
|
+
index: PredictionIndex
|
|
250
|
+
task: TaskSpec
|
|
251
|
+
output_argument = "output_predictions_dir"
|
|
252
|
+
result_type = PredictionsArtifact
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True, kw_only=True)
|
|
256
|
+
class PredictOnlyRequest(StepRequest):
|
|
257
|
+
data: PreparedDataArtifact = field(metadata={"argument": "input_data_dir"})
|
|
258
|
+
index: PredictionIndex
|
|
259
|
+
context: PredictionContext | None = None
|
|
260
|
+
task: TaskSpec | None = None
|
|
261
|
+
output_argument = "output_predictions_dir"
|
|
262
|
+
result_type = PredictionsArtifact
|
meds_algorithm/runner.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The single public execution path for typed algorithms and their CLI adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import fields, is_dataclass, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .algorithms import Algorithm, profile_class, request_type
|
|
9
|
+
from .artifacts.manifest import digest_config, digest_file, measured_artifact
|
|
10
|
+
from .artifacts.references import ArtifactDestination, PayloadMetadata, PredictionsArtifact
|
|
11
|
+
from .data.predicates import RESOLVED_PREDICATES_FILENAME, compose_feature_predicates
|
|
12
|
+
from .requests import PredictionContext, PredictionIndex, PreprocessDataRequest, StepRequest
|
|
13
|
+
from .tasks.spec import RelaxationLedger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _paths(value):
|
|
17
|
+
if isinstance(value, Path):
|
|
18
|
+
yield value
|
|
19
|
+
elif is_dataclass(value):
|
|
20
|
+
for field in fields(value):
|
|
21
|
+
yield from _paths(getattr(value, field.name))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StepRunner:
|
|
25
|
+
"""A fresh algorithm instance is supplied per call; persisted artifacts carry fitted state."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, repo: Path | None = None):
|
|
28
|
+
self.repo = None if repo is None else Path(repo).resolve()
|
|
29
|
+
|
|
30
|
+
def run(
|
|
31
|
+
self,
|
|
32
|
+
algorithm: Algorithm,
|
|
33
|
+
step: str,
|
|
34
|
+
request: StepRequest,
|
|
35
|
+
*,
|
|
36
|
+
profile: str,
|
|
37
|
+
evaluation_labels_dir: Path | None = None,
|
|
38
|
+
):
|
|
39
|
+
expected = request_type(profile, step)
|
|
40
|
+
if not isinstance(algorithm, profile_class(profile)):
|
|
41
|
+
raise TypeError(f"Algorithm does not implement {profile}")
|
|
42
|
+
if type(request) is not expected:
|
|
43
|
+
raise TypeError(f"{step} requires {expected.__name__}")
|
|
44
|
+
# Reconstruct to enforce types/value invariants even for hand-built Python requests.
|
|
45
|
+
request = expected.from_dict(request.to_dict())
|
|
46
|
+
algorithm.config_type.from_dict(algorithm.config.to_dict())
|
|
47
|
+
destination = request.output.directory
|
|
48
|
+
input_paths = [
|
|
49
|
+
path
|
|
50
|
+
for field in fields(request)
|
|
51
|
+
if field.name != "output"
|
|
52
|
+
for path in _paths(getattr(request, field.name))
|
|
53
|
+
]
|
|
54
|
+
additional = (
|
|
55
|
+
algorithm.config.additional_predicates_file
|
|
56
|
+
if isinstance(request, PreprocessDataRequest)
|
|
57
|
+
else None
|
|
58
|
+
)
|
|
59
|
+
if additional is not None:
|
|
60
|
+
input_paths.append(additional)
|
|
61
|
+
if evaluation_labels_dir is not None:
|
|
62
|
+
if request.result_type is not PredictionsArtifact:
|
|
63
|
+
raise ValueError("Evaluation labels are allowed only after prediction")
|
|
64
|
+
evaluation_labels_dir = Path(evaluation_labels_dir).resolve()
|
|
65
|
+
input_paths.append(evaluation_labels_dir)
|
|
66
|
+
for path in input_paths:
|
|
67
|
+
if not path.exists():
|
|
68
|
+
raise FileNotFoundError(path)
|
|
69
|
+
if destination == path or destination in path.parents or path in destination.parents:
|
|
70
|
+
raise ValueError("Output must not overlap any input path")
|
|
71
|
+
inputs = {}
|
|
72
|
+
resolved_predicates = None
|
|
73
|
+
if additional is not None:
|
|
74
|
+
resolved_predicates, provenance = compose_feature_predicates(request.predicates_file, additional)
|
|
75
|
+
inputs["feature_predicates"] = provenance
|
|
76
|
+
if provenance["base"] is not None:
|
|
77
|
+
inputs["external_predicates_file"] = provenance["base"]
|
|
78
|
+
for argument, artifact in request.artifact_inputs():
|
|
79
|
+
artifact.read_manifest()
|
|
80
|
+
inputs[argument] = {
|
|
81
|
+
"path": str(artifact.directory),
|
|
82
|
+
"manifest_digest": digest_file(artifact.directory / "manifest.yaml"),
|
|
83
|
+
}
|
|
84
|
+
index = getattr(request, "index", None)
|
|
85
|
+
if isinstance(index, PredictionIndex):
|
|
86
|
+
keys = index.read()
|
|
87
|
+
inputs["external_index_dir"] = {
|
|
88
|
+
"path": str(index.directory),
|
|
89
|
+
"splits": list(index.splits),
|
|
90
|
+
"keys_digest": digest_config(keys.sort(["subject_id", "prediction_time"]).write_json()),
|
|
91
|
+
}
|
|
92
|
+
context = getattr(request, "context", None)
|
|
93
|
+
if isinstance(context, PredictionContext):
|
|
94
|
+
context.read(index) # Disjoint keys and permitted split names, before algorithm code.
|
|
95
|
+
task = getattr(request, "task", None)
|
|
96
|
+
if task is not None:
|
|
97
|
+
if task.predicates_file != request.predicates_file:
|
|
98
|
+
raise ValueError("Task predicates and request predicates must agree")
|
|
99
|
+
inputs["task"] = task.provenance()
|
|
100
|
+
if resolved_predicates is None:
|
|
101
|
+
algorithm.validate_inputs(request)
|
|
102
|
+
metadata = None
|
|
103
|
+
details = {
|
|
104
|
+
"algorithm": {
|
|
105
|
+
"name": algorithm.name,
|
|
106
|
+
"implementation": f"{type(algorithm).__module__}.{type(algorithm).__qualname__}",
|
|
107
|
+
"config_type": f"{type(algorithm.config).__module__}.{type(algorithm.config).__qualname__}",
|
|
108
|
+
"config_schema_version": algorithm.config.schema_version,
|
|
109
|
+
"config": algorithm.config.to_dict(),
|
|
110
|
+
},
|
|
111
|
+
"invocation": {
|
|
112
|
+
"profile": profile,
|
|
113
|
+
"request": request.to_dict(),
|
|
114
|
+
"evaluation_labels_dir": None
|
|
115
|
+
if evaluation_labels_dir is None
|
|
116
|
+
else str(evaluation_labels_dir),
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
with measured_artifact(
|
|
120
|
+
destination,
|
|
121
|
+
artifact_type=request.result_type.kind.value,
|
|
122
|
+
command=step,
|
|
123
|
+
producer=f"{type(algorithm).__module__}.{type(algorithm).__qualname__}.{step}",
|
|
124
|
+
payload_format=lambda: f"{metadata.format}/v{metadata.version}",
|
|
125
|
+
inputs=inputs,
|
|
126
|
+
config_digest=digest_config(
|
|
127
|
+
{
|
|
128
|
+
"config": algorithm.config.to_dict(),
|
|
129
|
+
"request": request.to_dict(),
|
|
130
|
+
"feature_predicates": inputs.get("feature_predicates"),
|
|
131
|
+
}
|
|
132
|
+
),
|
|
133
|
+
semantics_digest=None if task is None else task.semantics_digest(),
|
|
134
|
+
external_predicates_file=request.predicates_file if resolved_predicates is None else None,
|
|
135
|
+
overwrite=request.execution.overwrite,
|
|
136
|
+
repo=self.repo,
|
|
137
|
+
manifest_fields=details,
|
|
138
|
+
) as staging:
|
|
139
|
+
working = replace(request, output=ArtifactDestination(staging))
|
|
140
|
+
if resolved_predicates is not None:
|
|
141
|
+
effective_path = staging / RESOLVED_PREDICATES_FILENAME
|
|
142
|
+
effective_path.write_bytes(resolved_predicates)
|
|
143
|
+
working = replace(working, predicates_file=effective_path)
|
|
144
|
+
algorithm.validate_inputs(working)
|
|
145
|
+
metadata = getattr(algorithm, step)(working)
|
|
146
|
+
if resolved_predicates is not None and (
|
|
147
|
+
not effective_path.is_file() or effective_path.read_bytes() != resolved_predicates
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("Algorithm modified the runner-owned resolved feature predicates")
|
|
150
|
+
if algorithm.config.to_dict() != details["algorithm"]["config"]:
|
|
151
|
+
raise ValueError("Algorithm configuration changed during execution")
|
|
152
|
+
if not isinstance(metadata, PayloadMetadata):
|
|
153
|
+
raise TypeError("Algorithm methods must return PayloadMetadata")
|
|
154
|
+
if (staging / "manifest.yaml").exists():
|
|
155
|
+
raise ValueError("Only the runner may write manifest.yaml")
|
|
156
|
+
if request.result_type is PredictionsArtifact:
|
|
157
|
+
self._validate_predictions(staging, index, evaluation_labels_dir)
|
|
158
|
+
if profile.startswith("zero_shot_"):
|
|
159
|
+
RelaxationLedger.read(staging)
|
|
160
|
+
for reserved in ("config.json", "request.json"):
|
|
161
|
+
if (staging / reserved).exists():
|
|
162
|
+
raise ValueError(f"{reserved} is reserved for runner provenance")
|
|
163
|
+
algorithm.config.save(staging / "config.json")
|
|
164
|
+
request.save(staging / "request.json")
|
|
165
|
+
return request.result_type.open(destination)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _validate_predictions(staging, index, evaluation_labels_dir):
|
|
169
|
+
import polars as pl
|
|
170
|
+
|
|
171
|
+
from .evaluation.predictions import attach_ground_truth, check_decoded
|
|
172
|
+
|
|
173
|
+
paths = sorted(staging.rglob("*.parquet"))
|
|
174
|
+
if not paths:
|
|
175
|
+
raise ValueError("Predictions must contain Parquet shards")
|
|
176
|
+
frames = [pl.read_parquet(path) for path in paths]
|
|
177
|
+
if any("boolean_value" in frame.columns for frame in frames):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Algorithm predictions must not contain ground truth; runner attaches it after scoring"
|
|
180
|
+
)
|
|
181
|
+
check_decoded(index.read(), pl.concat(frames))
|
|
182
|
+
if evaluation_labels_dir is not None:
|
|
183
|
+
for path, frame in zip(paths, frames, strict=True):
|
|
184
|
+
attach_ground_truth(frame, evaluation_labels_dir, index.splits).write_parquet(path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AlgorithmRepository:
|
|
188
|
+
"""Only a profile-to-implementation association; signatures and order live on the ABCs."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, implementations: dict[str, type[Algorithm]], *, repo: Path | None = None):
|
|
191
|
+
if not implementations:
|
|
192
|
+
raise ValueError("At least one profile implementation is required")
|
|
193
|
+
for profile, implementation in implementations.items():
|
|
194
|
+
if not isinstance(implementation, type) or not issubclass(implementation, profile_class(profile)):
|
|
195
|
+
raise TypeError(f"Implementation must inherit {profile_class(profile).__name__}")
|
|
196
|
+
if implementation.__abstractmethods__:
|
|
197
|
+
raise TypeError(
|
|
198
|
+
f"Unimplemented abstract methods: {sorted(implementation.__abstractmethods__)}"
|
|
199
|
+
)
|
|
200
|
+
self.implementations = dict(implementations)
|
|
201
|
+
self.profiles = tuple(implementations)
|
|
202
|
+
self.runner = StepRunner(repo=repo)
|
|
203
|
+
|
|
204
|
+
def select_profile(self, profile=None):
|
|
205
|
+
if profile is None:
|
|
206
|
+
if len(self.profiles) != 1:
|
|
207
|
+
raise ValueError("Several profiles are declared; select one with --profile")
|
|
208
|
+
return self.profiles[0]
|
|
209
|
+
if profile not in self.implementations:
|
|
210
|
+
raise ValueError(f"Profile {profile!r} is not declared")
|
|
211
|
+
return profile
|
|
212
|
+
|
|
213
|
+
def profile_commands(self, profile=None):
|
|
214
|
+
return profile_class(self.select_profile(profile)).steps
|
|
215
|
+
|
|
216
|
+
def run(self, step: str, arguments: dict, *, profile=None):
|
|
217
|
+
profile = self.select_profile(profile)
|
|
218
|
+
request_cls = request_type(profile, step)
|
|
219
|
+
arguments = dict(arguments)
|
|
220
|
+
implementation = self.implementations[profile]
|
|
221
|
+
config_file = arguments.pop("config_file", None)
|
|
222
|
+
config = (
|
|
223
|
+
implementation.config_type.from_file(config_file) if config_file else implementation.config_type()
|
|
224
|
+
)
|
|
225
|
+
evaluation = arguments.pop("external_evaluation_labels_dir", None)
|
|
226
|
+
return self.runner.run(
|
|
227
|
+
implementation(config),
|
|
228
|
+
step,
|
|
229
|
+
request_cls.from_arguments(arguments),
|
|
230
|
+
profile=profile,
|
|
231
|
+
evaluation_labels_dir=evaluation,
|
|
232
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional-platform resource sampling, explicit algorithm provenance and subprocess execution."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import subprocess
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
|
|
6
|
+
RUN_TIMEOUT = 5400
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(cmd, *, timeout=RUN_TIMEOUT, env=None):
|
|
10
|
+
"""Run in an owned process group and stop its descendants if interrupted or timed out.
|
|
11
|
+
|
|
12
|
+
Descendants must remain in the group; deliberately detached services are not supported.
|
|
13
|
+
Windows uses taskkill /T to terminate the child tree while the parent is still alive.
|
|
14
|
+
"""
|
|
15
|
+
process = subprocess.Popen(
|
|
16
|
+
[str(value) for value in cmd],
|
|
17
|
+
stdout=subprocess.PIPE,
|
|
18
|
+
stderr=subprocess.PIPE,
|
|
19
|
+
text=True,
|
|
20
|
+
start_new_session=os.name == "posix",
|
|
21
|
+
env=None if env is None else {**os.environ, **env},
|
|
22
|
+
)
|
|
23
|
+
try:
|
|
24
|
+
stdout, stderr = process.communicate(timeout=timeout)
|
|
25
|
+
except BaseException:
|
|
26
|
+
try:
|
|
27
|
+
if os.name == "posix":
|
|
28
|
+
with suppress(ProcessLookupError):
|
|
29
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
30
|
+
else:
|
|
31
|
+
subprocess.run(
|
|
32
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
33
|
+
check=True,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
timeout=30,
|
|
36
|
+
)
|
|
37
|
+
finally:
|
|
38
|
+
if process.poll() is None:
|
|
39
|
+
process.kill()
|
|
40
|
+
process.wait()
|
|
41
|
+
process.stdout.close()
|
|
42
|
+
process.stderr.close()
|
|
43
|
+
raise
|
|
44
|
+
result = subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
|
|
45
|
+
if result.returncode:
|
|
46
|
+
raise AssertionError(
|
|
47
|
+
f"command failed ({result.returncode}): {' '.join(map(str, cmd))}\n"
|
|
48
|
+
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
|
|
49
|
+
)
|
|
50
|
+
return result
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Record the caller's algorithm checkout; never infer it from the core package or cwd."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def code_provenance(repo: Path | str | None) -> dict[str, Any]:
|
|
9
|
+
unknown = {
|
|
10
|
+
"repository": None if repo is None else str(Path(repo).resolve()),
|
|
11
|
+
"git_commit": None,
|
|
12
|
+
"dirty": None,
|
|
13
|
+
}
|
|
14
|
+
if repo is None:
|
|
15
|
+
return unknown
|
|
16
|
+
root = Path(repo).resolve()
|
|
17
|
+
# Do not accidentally attribute an unpacked wheel to an enclosing unrelated checkout.
|
|
18
|
+
if not (root / ".git").exists():
|
|
19
|
+
return unknown
|
|
20
|
+
|
|
21
|
+
def git(*args):
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
["git", "-C", str(root), *args], check=True, capture_output=True, text=True
|
|
24
|
+
).stdout.strip()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
commit = git("rev-parse", "HEAD")
|
|
28
|
+
status = git("status", "--porcelain")
|
|
29
|
+
except (OSError, subprocess.CalledProcessError):
|
|
30
|
+
return unknown
|
|
31
|
+
return {"repository": str(root), "git_commit": commit, "dirty": bool(status)}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import resource
|
|
10
|
+
except ImportError:
|
|
11
|
+
resource = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _rss_bytes(value: int) -> int:
|
|
15
|
+
# Linux reports KiB; macOS reports bytes. sys.platform is deliberately avoided so unusual Unix
|
|
16
|
+
# implementations can override/report their method rather than receiving a false portable claim.
|
|
17
|
+
return value * 1024 if sys.platform != "darwin" else value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _ProcessTreeMemorySampler:
|
|
21
|
+
"""Sample aggregate PSS/RSS for this process and its live descendants on Linux."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, interval_seconds: float = 0.25) -> None:
|
|
24
|
+
self.interval_seconds = interval_seconds
|
|
25
|
+
self.peak_pss_bytes: int | None = None
|
|
26
|
+
self.peak_rss_bytes: int | None = None
|
|
27
|
+
self.source = "linux_proc_smaps_rollup" if Path("/proc/self/smaps_rollup").is_file() else "getrusage"
|
|
28
|
+
self.includes_descendants = self.source == "linux_proc_smaps_rollup"
|
|
29
|
+
self._stop = threading.Event()
|
|
30
|
+
self._thread = threading.Thread(target=self._run, name="meds-memory-sampler", daemon=True)
|
|
31
|
+
|
|
32
|
+
def start(self) -> None:
|
|
33
|
+
self._sample()
|
|
34
|
+
self._thread.start()
|
|
35
|
+
|
|
36
|
+
def stop(self) -> None:
|
|
37
|
+
self._sample()
|
|
38
|
+
self._stop.set()
|
|
39
|
+
self._thread.join(timeout=max(1.0, self.interval_seconds * 4))
|
|
40
|
+
self._sample()
|
|
41
|
+
if self.peak_rss_bytes is None:
|
|
42
|
+
self.includes_descendants = False
|
|
43
|
+
if resource is not None:
|
|
44
|
+
self.source = "getrusage"
|
|
45
|
+
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
46
|
+
self.peak_rss_bytes = _rss_bytes(usage.ru_maxrss)
|
|
47
|
+
else:
|
|
48
|
+
self.source = "unavailable"
|
|
49
|
+
|
|
50
|
+
def _run(self) -> None:
|
|
51
|
+
while not self._stop.wait(self.interval_seconds):
|
|
52
|
+
self._sample()
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _process_tree(root_pid: int) -> set[int]:
|
|
56
|
+
found: set[int] = set()
|
|
57
|
+
pending = [root_pid]
|
|
58
|
+
while pending:
|
|
59
|
+
pid = pending.pop()
|
|
60
|
+
if pid in found:
|
|
61
|
+
continue
|
|
62
|
+
found.add(pid)
|
|
63
|
+
try:
|
|
64
|
+
tasks = list(Path(f"/proc/{pid}/task").iterdir())
|
|
65
|
+
except OSError:
|
|
66
|
+
continue
|
|
67
|
+
for task in tasks:
|
|
68
|
+
try:
|
|
69
|
+
pending.extend(int(value) for value in (task / "children").read_text().split())
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
continue
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _smaps(pid: int) -> tuple[int, int] | None:
|
|
76
|
+
try:
|
|
77
|
+
values = {}
|
|
78
|
+
for line in Path(f"/proc/{pid}/smaps_rollup").read_text().splitlines():
|
|
79
|
+
key, separator, rest = line.partition(":")
|
|
80
|
+
if separator and key in {"Pss", "Rss"}:
|
|
81
|
+
values[key] = int(rest.split()[0]) * 1024
|
|
82
|
+
return values["Pss"], values["Rss"]
|
|
83
|
+
except (FileNotFoundError, KeyError, PermissionError, ProcessLookupError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _sample(self) -> None:
|
|
87
|
+
if self.source != "linux_proc_smaps_rollup":
|
|
88
|
+
return
|
|
89
|
+
samples = [sample for pid in self._process_tree(os.getpid()) if (sample := self._smaps(pid))]
|
|
90
|
+
if not samples:
|
|
91
|
+
return
|
|
92
|
+
if len(samples) != len(self._process_tree(os.getpid())):
|
|
93
|
+
self.includes_descendants = False
|
|
94
|
+
pss = sum(sample[0] for sample in samples)
|
|
95
|
+
rss = sum(sample[1] for sample in samples)
|
|
96
|
+
self.peak_pss_bytes = max(self.peak_pss_bytes or 0, pss)
|
|
97
|
+
self.peak_rss_bytes = max(self.peak_rss_bytes or 0, rss)
|