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.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. 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
+ )