techtree 0.1.0__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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Run-owned inputs and execution outputs. Spec PR8 §8.3.
|
|
2
|
+
|
|
3
|
+
A run does not read the draft it came from. Everything the executor needs is
|
|
4
|
+
copied into ``runs/<run-id>/inputs/`` before the worker is launched, verified
|
|
5
|
+
there against the run's own :class:`~techtree.models.run.RunRequest`, and read
|
|
6
|
+
back from that copy for the rest of the run's life. Spec §10.4 is the reason:
|
|
7
|
+
the draft directory, the participant's source skill, and the packaged catalog
|
|
8
|
+
are all mutable from the worker's point of view, and a run that consulted them
|
|
9
|
+
mid-flight would be measuring whatever the disk happened to hold at the time.
|
|
10
|
+
|
|
11
|
+
Three properties follow from that.
|
|
12
|
+
|
|
13
|
+
*Nothing partial ever becomes ``inputs/``.* The tree is assembled under a
|
|
14
|
+
staging name inside the run directory, verified there, and renamed into place.
|
|
15
|
+
A rename within one directory is atomic, so a crash leaves either no ``inputs/``
|
|
16
|
+
or a complete and checked one — which is also what makes re-staging after the
|
|
17
|
+
crash windows in spec §9 a no-op rather than a second copy.
|
|
18
|
+
|
|
19
|
+
*Bytes are copied, never linked.* A hard link to a user-controlled file is the
|
|
20
|
+
same file; editing the original would edit the run's input. Every file is read
|
|
21
|
+
and written afresh, and every digest is recomputed from what landed on disk.
|
|
22
|
+
|
|
23
|
+
*Verification is anchored on the request.* The draft store already proved the
|
|
24
|
+
graph held together when the draft was written. This module proves something
|
|
25
|
+
different and later: that the bytes this run owns are the bytes the run's
|
|
26
|
+
immutable request names. It never constructs a
|
|
27
|
+
:class:`~techtree.drafts.store.DraftStore` to do it.
|
|
28
|
+
|
|
29
|
+
Two files of a run belong elsewhere and are deliberately not duplicated here.
|
|
30
|
+
``request.json`` and ``report/uplift.json`` are owned by
|
|
31
|
+
:class:`~techtree.runs.store.RunStore`, because writing the report is the same
|
|
32
|
+
act as appending the ``result.written`` event that announces its digest.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import uuid
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
from pydantic import BaseModel
|
|
45
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
46
|
+
|
|
47
|
+
from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
|
|
48
|
+
from techtree.drafts.source import CampaignSource, StagedSkill
|
|
49
|
+
from techtree.drafts.store import DraftSnapshot
|
|
50
|
+
from techtree.errors import NotFoundError, ValidationError, VerificationError
|
|
51
|
+
from techtree.fs import (
|
|
52
|
+
ensure_private_directory,
|
|
53
|
+
fsync_directory,
|
|
54
|
+
open_exclusive,
|
|
55
|
+
remove_tree,
|
|
56
|
+
)
|
|
57
|
+
from techtree.manifests.builder import skill_content_digest
|
|
58
|
+
from techtree.models.base import ArtifactRef, Digest, JsonValue
|
|
59
|
+
from techtree.models.campaign import SUBJECT_AGENT, CampaignSpec
|
|
60
|
+
from techtree.models.climb import ClimbManifest, ResolvedClimb
|
|
61
|
+
from techtree.models.data_policy import DataPolicy
|
|
62
|
+
from techtree.models.episode_receipt import EpisodeReceipt
|
|
63
|
+
from techtree.models.experiment import (
|
|
64
|
+
ExperimentManifest,
|
|
65
|
+
ExperimentVariant,
|
|
66
|
+
ManifestComparison,
|
|
67
|
+
)
|
|
68
|
+
from techtree.models.run import RunRequest
|
|
69
|
+
from techtree.models.skill import SkillArtifact, SubmissionDraft
|
|
70
|
+
from techtree.models.validation import TasksetValidationReceipt, ValidationEvidence
|
|
71
|
+
from techtree.paths import TechtreePaths
|
|
72
|
+
from techtree.skills.archive import verify_archive
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"RUN_INPUT_STAGING_FAILED",
|
|
76
|
+
"VALIDATION_MARKER_SCHEMA_VERSION",
|
|
77
|
+
"RunArtifactStore",
|
|
78
|
+
"RunInputBundle",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
#: The one code every staged-input problem reports. Spec PR8 §8.16.
|
|
82
|
+
RUN_INPUT_STAGING_FAILED: Final = "run_input_staging_failed"
|
|
83
|
+
|
|
84
|
+
#: The local development artifact recording what the validation provider
|
|
85
|
+
#: answered. Not a protocol object: it exists so an operator can see which
|
|
86
|
+
#: provider spoke and what it returned.
|
|
87
|
+
VALIDATION_MARKER_SCHEMA_VERSION: Final = "techtree.taskset-validation-outcome.v1"
|
|
88
|
+
|
|
89
|
+
_JSON_MEDIA_TYPE: Final = "application/json"
|
|
90
|
+
|
|
91
|
+
_INPUTS_DIR: Final = "inputs"
|
|
92
|
+
_STAGING_PREFIX: Final = ".staging-inputs-"
|
|
93
|
+
|
|
94
|
+
_DRAFT_FILE: Final = "draft.json"
|
|
95
|
+
_COMPARISON_FILE: Final = "comparison.json"
|
|
96
|
+
|
|
97
|
+
_PUBLIC_DIR: Final = "public"
|
|
98
|
+
_CLIMB_FILE: Final = "climb.json"
|
|
99
|
+
_CAMPAIGN_FILE: Final = "campaign.json"
|
|
100
|
+
_DATA_POLICY_FILE: Final = "data-policy.json"
|
|
101
|
+
_VALIDATION_RECEIPT_FILE: Final = "publisher-validation.json"
|
|
102
|
+
_EVIDENCE_FILE: Final = "publisher-validation-evidence.json"
|
|
103
|
+
|
|
104
|
+
_MANIFESTS_DIR: Final = "manifests"
|
|
105
|
+
_BASELINE_FILE: Final = "baseline.json"
|
|
106
|
+
_CANDIDATE_FILE: Final = "candidate.json"
|
|
107
|
+
|
|
108
|
+
_SKILL_DIR: Final = "skill"
|
|
109
|
+
_BASELINE_SKILL_DIR: Final = "baseline-skill"
|
|
110
|
+
_ARTIFACT_FILE: Final = "artifact.json"
|
|
111
|
+
_BUNDLE_FILE: Final = "bundle.tar"
|
|
112
|
+
_FILES_DIR: Final = "files"
|
|
113
|
+
|
|
114
|
+
_VALIDATION_DIR: Final = "validation"
|
|
115
|
+
_VALIDATION_MARKER_FILE: Final = "development.json"
|
|
116
|
+
_FAKE_DIR: Final = "fake"
|
|
117
|
+
_RECEIPTS_DIR: Final = "receipts"
|
|
118
|
+
|
|
119
|
+
#: Four digits order a run's artifacts lexicographically for every task count
|
|
120
|
+
#: v0.1 permits, so directory order is Campaign order without a second index.
|
|
121
|
+
_POSITION_WIDTH: Final = 4
|
|
122
|
+
|
|
123
|
+
_FILE_MODE: Final = 0o600
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class RunInputBundle:
|
|
128
|
+
"""Everything one run executes, loaded from the run's own copies."""
|
|
129
|
+
|
|
130
|
+
request: RunRequest
|
|
131
|
+
draft: SubmissionDraft
|
|
132
|
+
source: CampaignSource
|
|
133
|
+
validation_evidence: ValidationEvidence
|
|
134
|
+
baseline: ExperimentManifest
|
|
135
|
+
candidate: ExperimentManifest
|
|
136
|
+
comparison: ManifestComparison
|
|
137
|
+
candidate_skill: StagedSkill
|
|
138
|
+
baseline_skill: StagedSkill | None
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def campaign(self) -> CampaignSpec:
|
|
142
|
+
"""Return the Campaign this run's science comes from."""
|
|
143
|
+
return self.source.campaign
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def ordered_task_hashes(self) -> list[Digest]:
|
|
147
|
+
"""Return the Campaign's committed task membership, in order."""
|
|
148
|
+
return list(self.campaign.taskset.membership.ordered_task_hashes)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def declared_skills(self) -> list[StagedSkill]:
|
|
152
|
+
"""Return every Skill this run owns, baseline's first when it has one."""
|
|
153
|
+
if self.baseline_skill is None:
|
|
154
|
+
return [self.candidate_skill]
|
|
155
|
+
return [self.baseline_skill, self.candidate_skill]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RunArtifactStore:
|
|
159
|
+
"""Stages a run's immutable inputs and persists its execution outputs."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, paths: TechtreePaths) -> None:
|
|
162
|
+
self._paths = paths
|
|
163
|
+
|
|
164
|
+
# -- inputs -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def stage_inputs(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
run_id: str,
|
|
170
|
+
request: RunRequest,
|
|
171
|
+
snapshot: DraftSnapshot,
|
|
172
|
+
) -> RunInputBundle:
|
|
173
|
+
"""Copy the draft's immutable graph into this run's ``inputs/``.
|
|
174
|
+
|
|
175
|
+
Staging is idempotent. A run whose directory already holds a complete
|
|
176
|
+
``inputs/`` — the ordinary result of retrying a start that crashed
|
|
177
|
+
after the run was created — is verified and returned rather than
|
|
178
|
+
copied over, which is what spec §9.4 requires of the repair path.
|
|
179
|
+
"""
|
|
180
|
+
inputs = self._inputs_dir(run_id)
|
|
181
|
+
if inputs.exists():
|
|
182
|
+
return self.load_inputs(run_id, request)
|
|
183
|
+
|
|
184
|
+
ensure_private_directory(self._run_dir(run_id))
|
|
185
|
+
staging = self._run_dir(run_id) / f"{_STAGING_PREFIX}{uuid.uuid4().hex}"
|
|
186
|
+
try:
|
|
187
|
+
self._assemble(staging, snapshot)
|
|
188
|
+
self._verify(run_id, self._read_bundle(staging, request))
|
|
189
|
+
os.rename(staging, inputs)
|
|
190
|
+
except BaseException:
|
|
191
|
+
remove_tree(staging)
|
|
192
|
+
raise
|
|
193
|
+
fsync_directory(self._run_dir(run_id))
|
|
194
|
+
return self.load_inputs(run_id, request)
|
|
195
|
+
|
|
196
|
+
def load_inputs(self, run_id: str, request: RunRequest) -> RunInputBundle:
|
|
197
|
+
"""Load and verify the run-owned inputs, consulting no draft."""
|
|
198
|
+
root = self._inputs_dir(run_id)
|
|
199
|
+
if not root.exists():
|
|
200
|
+
raise NotFoundError(
|
|
201
|
+
f"run {run_id} has no staged inputs",
|
|
202
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
203
|
+
details={"run_id": run_id},
|
|
204
|
+
)
|
|
205
|
+
bundle = self._read_bundle(root, request)
|
|
206
|
+
self._verify(run_id, bundle)
|
|
207
|
+
return bundle
|
|
208
|
+
|
|
209
|
+
# -- execution outputs --------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def write_validation_marker(
|
|
212
|
+
self,
|
|
213
|
+
run_id: str,
|
|
214
|
+
marker: Mapping[str, JsonValue],
|
|
215
|
+
) -> ArtifactRef:
|
|
216
|
+
"""Persist what the validation provider answered.
|
|
217
|
+
|
|
218
|
+
The marker is the JSON projection a
|
|
219
|
+
:class:`~techtree.runs.validation.TasksetValidationOutcome` produces of
|
|
220
|
+
itself. Taking the projection rather than the outcome keeps the
|
|
221
|
+
direction of dependency one way: providers know about run inputs, and
|
|
222
|
+
this module does not know about providers.
|
|
223
|
+
"""
|
|
224
|
+
directory = self._run_dir(run_id) / _VALIDATION_DIR
|
|
225
|
+
ensure_private_directory(directory)
|
|
226
|
+
return self._write_artifact(
|
|
227
|
+
directory / _VALIDATION_MARKER_FILE,
|
|
228
|
+
marker,
|
|
229
|
+
run_id=run_id,
|
|
230
|
+
media_type=_JSON_MEDIA_TYPE,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def write_fake_trace(
|
|
234
|
+
self,
|
|
235
|
+
run_id: str,
|
|
236
|
+
*,
|
|
237
|
+
variant: ExperimentVariant,
|
|
238
|
+
position: int,
|
|
239
|
+
payload: BaseModel,
|
|
240
|
+
) -> ArtifactRef:
|
|
241
|
+
"""Persist one canonical fake trace payload."""
|
|
242
|
+
directory = self._run_dir(run_id) / _FAKE_DIR / variant.value
|
|
243
|
+
ensure_private_directory(directory)
|
|
244
|
+
return self._write_artifact(
|
|
245
|
+
directory / _position_file(position),
|
|
246
|
+
payload,
|
|
247
|
+
run_id=run_id,
|
|
248
|
+
media_type=_JSON_MEDIA_TYPE,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def write_episode_receipt(
|
|
252
|
+
self,
|
|
253
|
+
run_id: str,
|
|
254
|
+
*,
|
|
255
|
+
position: int,
|
|
256
|
+
receipt: EpisodeReceipt,
|
|
257
|
+
) -> ArtifactRef:
|
|
258
|
+
"""Write one immutable receipt under its variant directory.
|
|
259
|
+
|
|
260
|
+
The position is the receipt's place in the Campaign's committed task
|
|
261
|
+
order and becomes its filename, so reading the directory back in name
|
|
262
|
+
order reads the receipts back in Campaign order.
|
|
263
|
+
"""
|
|
264
|
+
directory = self._run_dir(run_id) / _RECEIPTS_DIR / receipt.variant.value
|
|
265
|
+
ensure_private_directory(directory)
|
|
266
|
+
return self._write_artifact(
|
|
267
|
+
directory / _position_file(position),
|
|
268
|
+
receipt,
|
|
269
|
+
run_id=run_id,
|
|
270
|
+
media_type=_JSON_MEDIA_TYPE,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def episode_receipts(
|
|
274
|
+
self,
|
|
275
|
+
run_id: str,
|
|
276
|
+
variant: ExperimentVariant,
|
|
277
|
+
) -> list[EpisodeReceipt]:
|
|
278
|
+
"""Load one variant's receipts in Campaign task order."""
|
|
279
|
+
directory = self._run_dir(run_id) / _RECEIPTS_DIR / variant.value
|
|
280
|
+
if not directory.exists():
|
|
281
|
+
return []
|
|
282
|
+
return [
|
|
283
|
+
self._parse(path, EpisodeReceipt, run_id)
|
|
284
|
+
for path in sorted(directory.iterdir())
|
|
285
|
+
if path.is_file()
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
# -- placement ----------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def inputs_dir(self, run_id: str) -> Path:
|
|
291
|
+
"""Return the directory holding this run's own copy of its inputs."""
|
|
292
|
+
return self._inputs_dir(run_id)
|
|
293
|
+
|
|
294
|
+
def skill_files_dir(self, run_id: str) -> Path:
|
|
295
|
+
"""Return the run-owned expanded candidate skill directory."""
|
|
296
|
+
return self._inputs_dir(run_id) / _SKILL_DIR / _FILES_DIR
|
|
297
|
+
|
|
298
|
+
def skill_archive_path(self, run_id: str) -> Path:
|
|
299
|
+
"""Return the run-owned deterministic candidate skill archive."""
|
|
300
|
+
return self._inputs_dir(run_id) / _SKILL_DIR / _BUNDLE_FILE
|
|
301
|
+
|
|
302
|
+
# -- assembly -----------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
def _assemble(self, staging: Path, snapshot: DraftSnapshot) -> None:
|
|
305
|
+
"""Write the complete input tree under a staging name."""
|
|
306
|
+
source = snapshot.source
|
|
307
|
+
try:
|
|
308
|
+
ensure_private_directory(staging)
|
|
309
|
+
self._write_immutable(staging / _DRAFT_FILE, snapshot.draft)
|
|
310
|
+
self._write_immutable(staging / _COMPARISON_FILE, snapshot.comparison)
|
|
311
|
+
|
|
312
|
+
public = staging / _PUBLIC_DIR
|
|
313
|
+
ensure_private_directory(public)
|
|
314
|
+
if source.climb is not None:
|
|
315
|
+
self._write_immutable(public / _CLIMB_FILE, source.climb)
|
|
316
|
+
self._write_immutable(public / _CAMPAIGN_FILE, source.campaign)
|
|
317
|
+
self._write_immutable(public / _DATA_POLICY_FILE, source.data_policy)
|
|
318
|
+
self._write_immutable(
|
|
319
|
+
public / _VALIDATION_RECEIPT_FILE, source.publisher_validation
|
|
320
|
+
)
|
|
321
|
+
self._write_immutable(public / _EVIDENCE_FILE, snapshot.validation_evidence)
|
|
322
|
+
|
|
323
|
+
manifests = staging / _MANIFESTS_DIR
|
|
324
|
+
ensure_private_directory(manifests)
|
|
325
|
+
self._write_immutable(manifests / _BASELINE_FILE, snapshot.baseline)
|
|
326
|
+
self._write_immutable(manifests / _CANDIDATE_FILE, snapshot.candidate)
|
|
327
|
+
|
|
328
|
+
self._copy_skill(staging / _SKILL_DIR, snapshot.candidate_skill)
|
|
329
|
+
if snapshot.baseline_skill is not None:
|
|
330
|
+
self._copy_skill(staging / _BASELINE_SKILL_DIR, snapshot.baseline_skill)
|
|
331
|
+
except OSError as error:
|
|
332
|
+
raise ValidationError(
|
|
333
|
+
f"this run's inputs could not be staged: {error.strerror or error}",
|
|
334
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
335
|
+
) from error
|
|
336
|
+
|
|
337
|
+
def _copy_skill(self, destination: Path, staged: StagedSkill) -> None:
|
|
338
|
+
"""Copy one skill's artifact, archive and files into the run's inputs."""
|
|
339
|
+
ensure_private_directory(destination)
|
|
340
|
+
self._write_immutable(destination / _ARTIFACT_FILE, staged.artifact)
|
|
341
|
+
self._copy_bytes(staged.archive, destination / _BUNDLE_FILE)
|
|
342
|
+
files = destination / _FILES_DIR
|
|
343
|
+
ensure_private_directory(files)
|
|
344
|
+
for entry in staged.artifact.files:
|
|
345
|
+
target = files / entry.path
|
|
346
|
+
ensure_private_directory(target.parent)
|
|
347
|
+
self._copy_bytes(staged.files / entry.path, target)
|
|
348
|
+
|
|
349
|
+
def _copy_bytes(self, source: Path, destination: Path) -> None:
|
|
350
|
+
"""Copy one file's contents into a newly created run-owned file.
|
|
351
|
+
|
|
352
|
+
Read and written rather than linked or cloned: a hard link would make
|
|
353
|
+
the run's input the same file as the draft's, and spec §8.3 requires
|
|
354
|
+
that a user-controlled source can never be one of a run's inputs.
|
|
355
|
+
"""
|
|
356
|
+
with open_exclusive(destination, _FILE_MODE) as handle:
|
|
357
|
+
handle.write(source.read_bytes())
|
|
358
|
+
handle.flush()
|
|
359
|
+
os.fsync(handle.fileno())
|
|
360
|
+
|
|
361
|
+
# -- reading and verification -------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _read_bundle(self, root: Path, request: RunRequest) -> RunInputBundle:
|
|
364
|
+
"""Load every staged object from one input tree."""
|
|
365
|
+
run_id = request.run_id
|
|
366
|
+
public = root / _PUBLIC_DIR
|
|
367
|
+
campaign = self._parse(public / _CAMPAIGN_FILE, CampaignSpec, run_id)
|
|
368
|
+
data_policy = self._parse(public / _DATA_POLICY_FILE, DataPolicy, run_id)
|
|
369
|
+
receipt = self._parse(
|
|
370
|
+
public / _VALIDATION_RECEIPT_FILE, TasksetValidationReceipt, run_id
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
baseline_artifact = root / _BASELINE_SKILL_DIR / _ARTIFACT_FILE
|
|
374
|
+
return RunInputBundle(
|
|
375
|
+
request=request,
|
|
376
|
+
draft=self._parse(root / _DRAFT_FILE, SubmissionDraft, run_id),
|
|
377
|
+
source=self._read_source(
|
|
378
|
+
public,
|
|
379
|
+
run_id=run_id,
|
|
380
|
+
campaign=campaign,
|
|
381
|
+
data_policy=data_policy,
|
|
382
|
+
receipt=receipt,
|
|
383
|
+
),
|
|
384
|
+
validation_evidence=self._parse(
|
|
385
|
+
public / _EVIDENCE_FILE, ValidationEvidence, run_id
|
|
386
|
+
),
|
|
387
|
+
baseline=self._parse(
|
|
388
|
+
root / _MANIFESTS_DIR / _BASELINE_FILE, ExperimentManifest, run_id
|
|
389
|
+
),
|
|
390
|
+
candidate=self._parse(
|
|
391
|
+
root / _MANIFESTS_DIR / _CANDIDATE_FILE, ExperimentManifest, run_id
|
|
392
|
+
),
|
|
393
|
+
comparison=self._parse(root / _COMPARISON_FILE, ManifestComparison, run_id),
|
|
394
|
+
candidate_skill=self._read_skill(root / _SKILL_DIR, run_id),
|
|
395
|
+
baseline_skill=(
|
|
396
|
+
self._read_skill(root / _BASELINE_SKILL_DIR, run_id)
|
|
397
|
+
if baseline_artifact.exists()
|
|
398
|
+
else None
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _read_source(
|
|
403
|
+
self,
|
|
404
|
+
public: Path,
|
|
405
|
+
*,
|
|
406
|
+
run_id: str,
|
|
407
|
+
campaign: CampaignSpec,
|
|
408
|
+
data_policy: DataPolicy,
|
|
409
|
+
receipt: TasksetValidationReceipt,
|
|
410
|
+
) -> CampaignSource:
|
|
411
|
+
"""Assemble the Campaign graph this run owns, Climb included when there is one.
|
|
412
|
+
|
|
413
|
+
A run started from a locally derived Campaign — spec section 7.19's
|
|
414
|
+
Skill replacement — snapshotted no public Climb, because none wraps it.
|
|
415
|
+
A run started under a Climb reassembles the whole graph as a
|
|
416
|
+
:class:`ResolvedClimb` so its validator runs against the run's own bytes.
|
|
417
|
+
"""
|
|
418
|
+
if not (public / _CLIMB_FILE).exists():
|
|
419
|
+
return CampaignSource.local(
|
|
420
|
+
campaign=campaign,
|
|
421
|
+
data_policy=data_policy,
|
|
422
|
+
publisher_validation=receipt,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
climb = self._parse(public / _CLIMB_FILE, ClimbManifest, run_id)
|
|
426
|
+
try:
|
|
427
|
+
resolved = ResolvedClimb(
|
|
428
|
+
climb=climb,
|
|
429
|
+
climb_digest=digest_object(climb),
|
|
430
|
+
campaign=campaign,
|
|
431
|
+
campaign_digest=digest_object(campaign),
|
|
432
|
+
data_policy=data_policy,
|
|
433
|
+
data_policy_digest=digest_object(data_policy),
|
|
434
|
+
publisher_validation=receipt,
|
|
435
|
+
publisher_validation_digest=digest_object(receipt),
|
|
436
|
+
)
|
|
437
|
+
except PydanticValidationError as error:
|
|
438
|
+
raise VerificationError(
|
|
439
|
+
"the public objects this run owns no longer describe each "
|
|
440
|
+
f"other: {error.errors()[0]['msg']}",
|
|
441
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
442
|
+
details={"run_id": run_id},
|
|
443
|
+
) from error
|
|
444
|
+
return CampaignSource.from_climb(resolved)
|
|
445
|
+
|
|
446
|
+
def _read_skill(self, directory: Path, run_id: str) -> StagedSkill:
|
|
447
|
+
"""Load one run-owned skill's artifact and name the bytes beside it."""
|
|
448
|
+
return StagedSkill(
|
|
449
|
+
artifact=self._parse(directory / _ARTIFACT_FILE, SkillArtifact, run_id),
|
|
450
|
+
archive=directory / _BUNDLE_FILE,
|
|
451
|
+
files=directory / _FILES_DIR,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def _verify(self, run_id: str, bundle: RunInputBundle) -> None:
|
|
455
|
+
"""Prove the staged bytes are the ones this run's request names."""
|
|
456
|
+
request = bundle.request
|
|
457
|
+
draft = bundle.draft
|
|
458
|
+
source = bundle.source
|
|
459
|
+
campaign = source.campaign
|
|
460
|
+
|
|
461
|
+
_require(
|
|
462
|
+
digest_object(draft) == request.draft_digest,
|
|
463
|
+
"the staged draft is not the draft this run was created from",
|
|
464
|
+
run_id,
|
|
465
|
+
expected=request.draft_digest,
|
|
466
|
+
computed=digest_object(draft),
|
|
467
|
+
)
|
|
468
|
+
_require(
|
|
469
|
+
draft.id == request.draft_id,
|
|
470
|
+
"the staged draft carries a different identifier than the request",
|
|
471
|
+
run_id,
|
|
472
|
+
)
|
|
473
|
+
_require(
|
|
474
|
+
source.campaign_digest
|
|
475
|
+
== request.campaign_spec_digest
|
|
476
|
+
== draft.campaign_spec_digest,
|
|
477
|
+
"the staged Campaign is not the Campaign this run executes",
|
|
478
|
+
run_id,
|
|
479
|
+
expected=request.campaign_spec_digest,
|
|
480
|
+
computed=source.campaign_digest,
|
|
481
|
+
)
|
|
482
|
+
_require(
|
|
483
|
+
source.climb is None
|
|
484
|
+
or source.campaign_digest == source.climb.campaign_spec_digest,
|
|
485
|
+
"the staged Campaign is not the one the staged Climb wraps",
|
|
486
|
+
run_id,
|
|
487
|
+
)
|
|
488
|
+
_require(
|
|
489
|
+
source.data_policy_digest
|
|
490
|
+
== request.data_policy_digest
|
|
491
|
+
== draft.data_policy_digest
|
|
492
|
+
== campaign.data_policy_digest,
|
|
493
|
+
"the staged DataPolicy is not the one this run executes under",
|
|
494
|
+
run_id,
|
|
495
|
+
expected=request.data_policy_digest,
|
|
496
|
+
computed=source.data_policy_digest,
|
|
497
|
+
)
|
|
498
|
+
_require(
|
|
499
|
+
draft.public_context == request.public_context
|
|
500
|
+
and draft.public_context == source.public_context
|
|
501
|
+
and draft.program_ref == request.program_ref
|
|
502
|
+
and draft.outcome_contract_digest == request.outcome_contract_digest,
|
|
503
|
+
"the staged draft names a different public context, improvement "
|
|
504
|
+
"program, or OutcomeContract than the request",
|
|
505
|
+
run_id,
|
|
506
|
+
)
|
|
507
|
+
_require(
|
|
508
|
+
campaign.evaluation_backend == request.evaluation_backend,
|
|
509
|
+
"the staged Campaign names a different evaluation backend than the request",
|
|
510
|
+
run_id,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
self._verify_validation(run_id, bundle)
|
|
514
|
+
self._verify_manifests(run_id, bundle)
|
|
515
|
+
self._verify_skill(run_id, bundle)
|
|
516
|
+
|
|
517
|
+
def _verify_validation(self, run_id: str, bundle: RunInputBundle) -> None:
|
|
518
|
+
source = bundle.source
|
|
519
|
+
receipt = source.publisher_validation
|
|
520
|
+
_require(
|
|
521
|
+
source.publisher_validation_digest
|
|
522
|
+
== source.campaign.taskset.validation_receipt_digest,
|
|
523
|
+
"the staged publisher validation is not the one the Campaign commits to",
|
|
524
|
+
run_id,
|
|
525
|
+
expected=source.campaign.taskset.validation_receipt_digest,
|
|
526
|
+
computed=source.publisher_validation_digest,
|
|
527
|
+
)
|
|
528
|
+
reference = receipt.normalized_evidence
|
|
529
|
+
if reference is None:
|
|
530
|
+
raise VerificationError(
|
|
531
|
+
"the publisher's validation receipt names no normalized "
|
|
532
|
+
"evidence, so this run could not be checked offline",
|
|
533
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
534
|
+
details={"run_id": run_id},
|
|
535
|
+
)
|
|
536
|
+
evidence_digest = digest_object(bundle.validation_evidence)
|
|
537
|
+
_require(
|
|
538
|
+
evidence_digest == reference.digest,
|
|
539
|
+
"the staged validation evidence is not what the publisher's "
|
|
540
|
+
"receipt was issued from",
|
|
541
|
+
run_id,
|
|
542
|
+
expected=reference.digest,
|
|
543
|
+
computed=evidence_digest,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def _verify_manifests(self, run_id: str, bundle: RunInputBundle) -> None:
|
|
547
|
+
baseline_digest = digest_object(bundle.baseline)
|
|
548
|
+
candidate_digest = digest_object(bundle.candidate)
|
|
549
|
+
request = bundle.request
|
|
550
|
+
|
|
551
|
+
_require(
|
|
552
|
+
baseline_digest
|
|
553
|
+
== request.baseline_manifest_digest
|
|
554
|
+
== bundle.draft.baseline_manifest_digest,
|
|
555
|
+
"the staged baseline manifest is not the one this run compares",
|
|
556
|
+
run_id,
|
|
557
|
+
expected=request.baseline_manifest_digest,
|
|
558
|
+
computed=baseline_digest,
|
|
559
|
+
)
|
|
560
|
+
_require(
|
|
561
|
+
candidate_digest
|
|
562
|
+
== request.candidate_manifest_digest
|
|
563
|
+
== bundle.draft.candidate_manifest_digest,
|
|
564
|
+
"the staged candidate manifest is not the one this run compares",
|
|
565
|
+
run_id,
|
|
566
|
+
expected=request.candidate_manifest_digest,
|
|
567
|
+
computed=candidate_digest,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
comparison = bundle.comparison
|
|
571
|
+
_require(
|
|
572
|
+
comparison.baseline_configuration_digest
|
|
573
|
+
== bundle.baseline.configuration_digest
|
|
574
|
+
and comparison.candidate_configuration_digest
|
|
575
|
+
== bundle.candidate.configuration_digest,
|
|
576
|
+
"the staged comparison was made between different configurations "
|
|
577
|
+
"than the staged manifests",
|
|
578
|
+
run_id,
|
|
579
|
+
)
|
|
580
|
+
_require(
|
|
581
|
+
comparison.controlled,
|
|
582
|
+
"the staged comparison says this candidate differs from its "
|
|
583
|
+
"baseline somewhere the Campaign does not permit",
|
|
584
|
+
run_id,
|
|
585
|
+
violations=list(comparison.violations),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _verify_skill(self, run_id: str, bundle: RunInputBundle) -> None:
|
|
589
|
+
"""Check every Skill this run owns against the bytes staged beside it.
|
|
590
|
+
|
|
591
|
+
Which Skills a run owns is decided by the variants rather than by the
|
|
592
|
+
directory listing: the candidate always, and the Skill the baseline
|
|
593
|
+
declares when this is a replacement. A staged tree nothing declares,
|
|
594
|
+
or a declared Skill nothing staged, both fail here.
|
|
595
|
+
"""
|
|
596
|
+
_require(
|
|
597
|
+
digest_object(bundle.candidate_skill.artifact)
|
|
598
|
+
== digest_object(bundle.draft.skill_artifact),
|
|
599
|
+
"the staged skill artifact is not the one the staged draft names",
|
|
600
|
+
run_id,
|
|
601
|
+
)
|
|
602
|
+
declared = _declared_baseline_skill(bundle.baseline)
|
|
603
|
+
_require(
|
|
604
|
+
(declared is None) == (bundle.baseline_skill is None),
|
|
605
|
+
"this run's baseline variant and its staged skills disagree about "
|
|
606
|
+
"whether a skill is being replaced",
|
|
607
|
+
run_id,
|
|
608
|
+
declared=None if declared is None else declared.digest,
|
|
609
|
+
)
|
|
610
|
+
if declared is not None and bundle.baseline_skill is not None:
|
|
611
|
+
_require(
|
|
612
|
+
bundle.baseline_skill.artifact.root_digest == declared.digest,
|
|
613
|
+
"the staged baseline skill is not the skill this run's baseline "
|
|
614
|
+
"variant declares",
|
|
615
|
+
run_id,
|
|
616
|
+
expected=declared.digest,
|
|
617
|
+
computed=bundle.baseline_skill.artifact.root_digest,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
for staged in bundle.declared_skills:
|
|
621
|
+
self._verify_one_skill(run_id, staged)
|
|
622
|
+
|
|
623
|
+
def _verify_one_skill(self, run_id: str, staged: StagedSkill) -> None:
|
|
624
|
+
"""Recompute one skill's tree digest, its files, and its archive."""
|
|
625
|
+
skill = staged.artifact
|
|
626
|
+
recomputed = skill_content_digest(skill.files)
|
|
627
|
+
_require(
|
|
628
|
+
recomputed == skill.root_digest,
|
|
629
|
+
"the staged skill's root digest does not describe the files it lists",
|
|
630
|
+
run_id,
|
|
631
|
+
expected=skill.root_digest,
|
|
632
|
+
computed=recomputed,
|
|
633
|
+
)
|
|
634
|
+
for entry in skill.files:
|
|
635
|
+
path = staged.files / entry.path
|
|
636
|
+
try:
|
|
637
|
+
data = path.read_bytes()
|
|
638
|
+
except OSError as error:
|
|
639
|
+
raise VerificationError(
|
|
640
|
+
f"this run's copy of the candidate skill is missing {entry.path}",
|
|
641
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
642
|
+
details={"run_id": run_id, "path": entry.path},
|
|
643
|
+
) from error
|
|
644
|
+
_require(
|
|
645
|
+
len(data) == entry.size and sha256_digest_bytes(data) == entry.digest,
|
|
646
|
+
f"this run's copy of the candidate skill file {entry.path} is "
|
|
647
|
+
"not what the artifact says it is",
|
|
648
|
+
run_id,
|
|
649
|
+
path=entry.path,
|
|
650
|
+
)
|
|
651
|
+
_require(
|
|
652
|
+
verify_archive(staged.archive, skill),
|
|
653
|
+
"this run's copy of the candidate skill archive does not match "
|
|
654
|
+
"the artifact beside it",
|
|
655
|
+
run_id,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# -- bytes --------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
def _write_artifact(
|
|
661
|
+
self,
|
|
662
|
+
path: Path,
|
|
663
|
+
value: object,
|
|
664
|
+
*,
|
|
665
|
+
run_id: str,
|
|
666
|
+
media_type: str,
|
|
667
|
+
) -> ArtifactRef:
|
|
668
|
+
"""Write one immutable output and return the reference to it."""
|
|
669
|
+
data = canonical_json_bytes(value)
|
|
670
|
+
self._write_bytes(path, data)
|
|
671
|
+
return ArtifactRef(
|
|
672
|
+
digest=sha256_digest_bytes(data),
|
|
673
|
+
media_type=media_type,
|
|
674
|
+
size=len(data),
|
|
675
|
+
relative_path=path.relative_to(self._run_dir(run_id)).as_posix(),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def _write_immutable(self, path: Path, value: object) -> None:
|
|
679
|
+
"""Create a write-once file holding one object's canonical bytes."""
|
|
680
|
+
self._write_bytes(path, canonical_json_bytes(value))
|
|
681
|
+
|
|
682
|
+
def _write_bytes(self, path: Path, data: bytes) -> None:
|
|
683
|
+
with open_exclusive(path, _FILE_MODE) as handle:
|
|
684
|
+
handle.write(data)
|
|
685
|
+
handle.flush()
|
|
686
|
+
os.fsync(handle.fileno())
|
|
687
|
+
fsync_directory(path.parent)
|
|
688
|
+
|
|
689
|
+
def _parse[ModelT: BaseModel](
|
|
690
|
+
self, path: Path, model: type[ModelT], run_id: str
|
|
691
|
+
) -> ModelT:
|
|
692
|
+
try:
|
|
693
|
+
raw = path.read_bytes()
|
|
694
|
+
except FileNotFoundError as error:
|
|
695
|
+
raise NotFoundError(
|
|
696
|
+
f"run {run_id} is missing {path.name}",
|
|
697
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
698
|
+
details={"run_id": run_id, "file": path.name},
|
|
699
|
+
) from error
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
return model.model_validate_json(raw)
|
|
703
|
+
except PydanticValidationError as error:
|
|
704
|
+
raise ValidationError(
|
|
705
|
+
f"run {run_id} holds an invalid {path.name}: "
|
|
706
|
+
f"{error.errors()[0]['msg']}",
|
|
707
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
708
|
+
details={"run_id": run_id, "file": path.name},
|
|
709
|
+
) from error
|
|
710
|
+
|
|
711
|
+
def _run_dir(self, run_id: str) -> Path:
|
|
712
|
+
return self._paths.run_dir(run_id)
|
|
713
|
+
|
|
714
|
+
def _inputs_dir(self, run_id: str) -> Path:
|
|
715
|
+
return self._run_dir(run_id) / _INPUTS_DIR
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _declared_baseline_skill(baseline: ExperimentManifest) -> ArtifactRef | None:
|
|
719
|
+
"""Return the one Skill a baseline variant declares, when it declares one."""
|
|
720
|
+
subject = baseline.configuration.agents.get(SUBJECT_AGENT)
|
|
721
|
+
if subject is None or not subject.harness.skills:
|
|
722
|
+
return None
|
|
723
|
+
return subject.harness.skills[0]
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _position_file(position: int) -> str:
|
|
727
|
+
"""Return the filename one Campaign position's artifact occupies."""
|
|
728
|
+
if position < 0:
|
|
729
|
+
raise ValidationError(
|
|
730
|
+
f"a task position is not negative, and this one is {position}",
|
|
731
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
732
|
+
details={"position": position},
|
|
733
|
+
)
|
|
734
|
+
return f"{position:0{_POSITION_WIDTH}d}.json"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _require(condition: bool, message: str, run_id: str, **details: object) -> None:
|
|
738
|
+
"""Raise a typed verification failure unless the condition holds."""
|
|
739
|
+
if condition:
|
|
740
|
+
return
|
|
741
|
+
reported: dict[str, JsonValue] = {"run_id": run_id}
|
|
742
|
+
for key, value in details.items():
|
|
743
|
+
reported[key] = (
|
|
744
|
+
[str(item) for item in value] if isinstance(value, list) else str(value)
|
|
745
|
+
)
|
|
746
|
+
raise VerificationError(
|
|
747
|
+
message,
|
|
748
|
+
code=RUN_INPUT_STAGING_FAILED,
|
|
749
|
+
details=reported,
|
|
750
|
+
)
|