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
techtree/runs/real.py ADDED
@@ -0,0 +1,789 @@
1
+ """The executor that actually evaluates something. Spec section 6.17.
2
+
3
+ Everything the development executor invented, this one measures. What it does
4
+ *not* do is decide what the measurements mean: it returns a
5
+ :class:`~techtree.verifiers.models.RealExecutionResult` — two variants, their
6
+ raw evidence, and the engine's own normalized projection of it — and WP7 turns
7
+ that into receipts, a comparison and a report. Spec section 6.22 states the
8
+ boundary as a rule: WP6 does not invent final uplift.
9
+
10
+ The sequence in :meth:`RealVerifiersExecutor.execute` is section 6.17's,
11
+ unchanged, and its order is the whole safety argument. Every cheap refusal
12
+ happens before every expensive one. The staged inputs are verified before the
13
+ engine is resolved; the engine is resolved before the credential is required;
14
+ the credential is required before the taskset is validated; the taskset is
15
+ validated before a configuration is compiled; and both configurations survive a
16
+ model-free dry run against the real engine before a single container starts. A
17
+ run that is going to fail should fail while it is still free.
18
+
19
+ Two things this module owns that the specification's sketch leaves implicit.
20
+
21
+ *The skill mount is materialized at its content address.* The run stages the
22
+ candidate's skill tree as the artifact describes it; the compiler mounts a
23
+ skill from a directory named after its root digest, because the folder name is
24
+ part of what the subject sees and must therefore be a property of the skill
25
+ rather than of whoever prepared it. Reconciling the two is a verified copy
26
+ inside the run's own input tree, never a link to anything a participant could
27
+ still edit.
28
+
29
+ *A development-placeholder Campaign is refused here, not warned about.* Spec
30
+ section 16 requires that the fake executor stop being what a real Campaign
31
+ gets; the converse is that the real executor must not be what a placeholder
32
+ Campaign gets. One predicate answers both questions, and
33
+ :func:`campaign_is_executable` is it.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import contextlib
39
+ from collections.abc import Mapping, Sequence
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+ from typing import Final, Protocol
43
+
44
+ from techtree.canonical import sha256_digest_bytes, to_json_value
45
+ from techtree.doctor.execution_checks import check_live_campaign
46
+ from techtree.engines.bundle import default_engine_digest, read_engine_descriptor
47
+ from techtree.engines.registry import EngineRegistry
48
+ from techtree.engines.runner import EngineRunner
49
+ from techtree.errors import (
50
+ EngineError,
51
+ PrerequisiteError,
52
+ ValidationError,
53
+ VerificationError,
54
+ )
55
+ from techtree.fs import atomic_write_json, ensure_private_directory, open_exclusive
56
+ from techtree.models.base import Digest, JsonValue
57
+ from techtree.models.campaign import (
58
+ SUBJECT_AGENT,
59
+ AgentSpec,
60
+ CampaignSpec,
61
+ ModelSpec,
62
+ VariantSchedule,
63
+ )
64
+ from techtree.models.cli import CheckStatus
65
+ from techtree.models.engine import EngineDescriptor, EngineInstallation
66
+ from techtree.models.evaluation_backend import SUPPORTED_EVALUATION_BACKEND_KINDS
67
+ from techtree.models.experiment import ExperimentManifest
68
+ from techtree.models.run import ExecutorKind, RunPhase, RunRequest
69
+ from techtree.models.skill import SkillArtifact
70
+ from techtree.paths import TechtreePaths
71
+ from techtree.runs.artifacts import RunInputBundle
72
+ from techtree.runs.child_registry import ChildRegistry, EvaluationChild, execution_dir
73
+ from techtree.runs.executor import ExecutionContext
74
+ from techtree.runs.validation import TasksetValidationOutcome
75
+ from techtree.runs.variants import (
76
+ DEFAULT_POLL_INTERVAL_SECONDS,
77
+ VariantPair,
78
+ VariantPairOutcome,
79
+ VariantScheduler,
80
+ require_concurrency_budget,
81
+ )
82
+ from techtree.tasksets.service import TASKSET_DIRECTORY
83
+ from techtree.verifiers.budget import (
84
+ price_profile_for,
85
+ require_cost_bound,
86
+ require_executable_budget,
87
+ )
88
+ from techtree.verifiers.child import (
89
+ DEFAULT_GRACE_SECONDS,
90
+ VerifiersChild,
91
+ eval_argv,
92
+ )
93
+ from techtree.verifiers.compiler import (
94
+ compile_plans,
95
+ compile_variant_config,
96
+ skill_directory_name,
97
+ write_variant_config,
98
+ )
99
+ from techtree.verifiers.credentials import (
100
+ require_credentials,
101
+ scrubbed_child_environment,
102
+ )
103
+ from techtree.verifiers.image import resolve_subject_image
104
+ from techtree.verifiers.models import (
105
+ ChildProcessOutcome,
106
+ RealExecutionResult,
107
+ RunPaths,
108
+ SubjectImageResolution,
109
+ VariantExecutionPlan,
110
+ VariantExecutionResult,
111
+ VariantName,
112
+ )
113
+ from techtree.verifiers.outputs import (
114
+ DEFAULT_NORMALIZE_TIMEOUT_SECONDS,
115
+ build_variant_result,
116
+ )
117
+ from techtree.verifiers.verify import (
118
+ DEFAULT_DRY_RUN_TIMEOUT_SECONDS,
119
+ dry_run_variant_config,
120
+ verify_variant_execution,
121
+ )
122
+
123
+ __all__ = [
124
+ "REAL_EXECUTION_RESULT_FILENAME",
125
+ "REAL_EXECUTION_UNSUPPORTED",
126
+ "TASKSET_LOCK_FILENAME",
127
+ "VARIANT_NOT_USABLE",
128
+ "ChildFactory",
129
+ "RealVerifiersExecutor",
130
+ "campaign_is_executable",
131
+ "executor_kind_for",
132
+ "keep_evaluation_private",
133
+ "real_execution_result_path",
134
+ "require_bounded_campaign",
135
+ "require_live_campaign",
136
+ ]
137
+
138
+ #: Stable error codes. Spec section 6.17's failure policy reports through these.
139
+ REAL_EXECUTION_UNSUPPORTED: Final = "real_execution_unsupported"
140
+ VARIANT_NOT_USABLE: Final = "variant_execution_not_usable"
141
+
142
+ #: The run's own copy of the membership the normalizer joins episodes onto.
143
+ #: Spec section 6.19.
144
+ TASKSET_LOCK_FILENAME: Final = "taskset-lock.json"
145
+
146
+ #: What WP6 hands WP7, written where WP7 will look for it. Spec section 6.19.
147
+ REAL_EXECUTION_RESULT_FILENAME: Final = "real-execution-result.json"
148
+
149
+ #: Owner read and write only, matching every other file Techtree writes.
150
+ _PRIVATE_FILE_MODE: Final = 0o600
151
+
152
+ #: Owner traversal only, matching every directory Techtree creates.
153
+ _PRIVATE_DIRECTORY_MODE: Final = 0o700
154
+
155
+ #: Both sides, always in comparison order.
156
+ _VARIANT_ORDER: Final[tuple[VariantName, ...]] = (
157
+ VariantName.BASELINE,
158
+ VariantName.CANDIDATE,
159
+ )
160
+
161
+
162
+ class ChildFactory(Protocol):
163
+ """How one variant's evaluation child is constructed.
164
+
165
+ :class:`~techtree.verifiers.child.VerifiersChild` is the implementation and
166
+ the default. The seam exists so that the executor's own sequence can be
167
+ exercised without a container or a provider, which is the only way to test
168
+ a code path whose real form spends money.
169
+ """
170
+
171
+ def __call__(
172
+ self,
173
+ *,
174
+ variant: VariantName,
175
+ argv: Sequence[str],
176
+ cwd: Path,
177
+ env: Mapping[str, str],
178
+ stdout_path: Path,
179
+ stderr_path: Path,
180
+ supervision_record_path: Path,
181
+ ) -> EvaluationChild:
182
+ """Build one unstarted child."""
183
+ ...
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class _ResolvedEngine:
188
+ """The engine this run will execute, and the facts read back from it."""
189
+
190
+ digest: Digest
191
+ installation: EngineInstallation
192
+ descriptor: EngineDescriptor
193
+ runner: EngineRunner
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Which executor a Campaign gets
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def campaign_is_executable(campaign: CampaignSpec) -> bool:
202
+ """Whether this Campaign names a subject that may be executed for real.
203
+
204
+ One predicate, consulted both by the worker choosing an executor and by the
205
+ real executor refusing to start. The Campaign this build ships names the
206
+ ratified release subject (decisions 0025/0029), so the predicate answers
207
+ yes for the packaged bytes; a development-placeholder Campaign — any
208
+ Campaign whose subject cannot actually be paid for and executed — remains
209
+ a thing to compile and dry-run, never a thing to run.
210
+ """
211
+ return check_live_campaign(campaign).status is CheckStatus.PASS
212
+
213
+
214
+ def executor_kind_for(campaign: CampaignSpec) -> ExecutorKind:
215
+ """Return the name of the executor this Campaign will be run by.
216
+
217
+ The same predicate that routes the worker, answered early enough to be
218
+ written down. A run records this when it is created, which is the moment a
219
+ person is shown what is about to happen, so the request says whether real
220
+ containers and a real provider are about to be used or whether this is the
221
+ development executor inventing numbers.
222
+
223
+ Recording it does not make it true — the worker re-reads the run's own
224
+ staged Campaign and asks this same question again, and the fake executor
225
+ refuses a request that names anything but itself. What recording it buys
226
+ is that nobody has to run a Campaign through a predicate to find out what a
227
+ finished run was.
228
+ """
229
+ return "verifiers" if campaign_is_executable(campaign) else "fake"
230
+
231
+
232
+ def require_live_campaign(campaign: CampaignSpec) -> None:
233
+ """Refuse a Campaign whose coordinates are development placeholders."""
234
+ check = check_live_campaign(campaign)
235
+ if check.status is CheckStatus.PASS:
236
+ return
237
+ raise PrerequisiteError(
238
+ f"this Campaign cannot be executed for real: {check.detail}",
239
+ code=REAL_EXECUTION_UNSUPPORTED,
240
+ details={"campaign_id": campaign.metadata.id},
241
+ )
242
+
243
+
244
+ def require_bounded_campaign(campaign: CampaignSpec) -> None:
245
+ """Refuse to spend anything for a Campaign whose limits are not limits.
246
+
247
+ Both halves of decisions document 0029's layer A, asked in the one place
248
+ where a real run's first container is still one refusal away: every
249
+ declared execution limit is enforceable, and the most this comparison can
250
+ cost under those limits is not above what the Campaign says it may spend.
251
+ Asked here rather than when the run was created, because this is the
252
+ boundary between a run that has cost nothing and a run that is spending —
253
+ and it is the same boundary that already refuses a placeholder Campaign.
254
+ """
255
+ require_executable_budget(campaign)
256
+ require_cost_bound(campaign, price_profile_for(campaign.subject.model.model_id))
257
+
258
+
259
+ def real_execution_result_path(run_root: Path) -> Path:
260
+ """Return where one run records what WP6 hands WP7."""
261
+ return execution_dir(run_root) / REAL_EXECUTION_RESULT_FILENAME
262
+
263
+
264
+ def keep_evaluation_private(run_paths: RunPaths) -> Path:
265
+ """Make everything one run's engine produced readable by its owner only.
266
+
267
+ Spec section 6.19: raw Verifiers artifacts are mode ``0600`` and remain
268
+ local. Techtree writes its own capture files that way, but the engine
269
+ writes ``traces.jsonl``, ``eval.log``, the resolved configuration and the
270
+ taskset validator's output under the operator's ordinary umask — and
271
+ ``traces.jsonl`` is the participant's complete subject transcripts.
272
+ Tightening them afterwards is the only moment available: the files belong
273
+ to the child while it is running, and a run that never reached them has
274
+ nothing to tighten.
275
+
276
+ Both trees the engine writes into are swept, not just the evaluation one.
277
+ ``taskset/validation/`` is the validator's output and was left at the
278
+ operator's umask until WP11g S6 noticed; a task hash is not a transcript,
279
+ but "which of these two directories a stranger may read" is not a
280
+ distinction worth maintaining.
281
+
282
+ Directories are swept as well as files, because a directory the engine
283
+ created inside one of these trees carries the umask exactly as its
284
+ contents do.
285
+
286
+ Applied whether the run succeeded, failed or was cancelled: a partial
287
+ transcript is exactly as private as a complete one.
288
+ """
289
+ root = run_paths.verifiers_dir
290
+ for tree in (root, run_paths.root / TASKSET_DIRECTORY):
291
+ if not tree.is_dir():
292
+ continue
293
+ _make_private(tree)
294
+ for path in tree.rglob("*"):
295
+ _make_private(path)
296
+ return root
297
+
298
+
299
+ def _make_private(path: Path) -> None:
300
+ """Tighten one path the engine left behind, whatever kind it is."""
301
+ if path.is_symlink():
302
+ # Never chmod through a link: the target may not be the run's.
303
+ return
304
+ with contextlib.suppress(OSError):
305
+ if path.is_dir():
306
+ path.chmod(_PRIVATE_DIRECTORY_MODE)
307
+ elif path.is_file():
308
+ path.chmod(_PRIVATE_FILE_MODE)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # The executor
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ class RealVerifiersExecutor:
317
+ """Executes both variants of a real Campaign through the pinned engine."""
318
+
319
+ def __init__(
320
+ self,
321
+ *,
322
+ paths: TechtreePaths,
323
+ engine_registry: EngineRegistry,
324
+ child_registry: ChildRegistry,
325
+ engine_digest: Digest | None = None,
326
+ child_factory: ChildFactory | None = None,
327
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
328
+ grace_seconds: float = DEFAULT_GRACE_SECONDS,
329
+ dry_run_timeout_seconds: float = DEFAULT_DRY_RUN_TIMEOUT_SECONDS,
330
+ normalize_timeout_seconds: float = DEFAULT_NORMALIZE_TIMEOUT_SECONDS,
331
+ ) -> None:
332
+ self._paths = paths
333
+ self._registry = engine_registry
334
+ self._child_registry = child_registry
335
+ self._engine_digest = engine_digest or default_engine_digest()
336
+ self._child_factory: ChildFactory = child_factory or VerifiersChild
337
+ self._poll_interval = poll_interval_seconds
338
+ self._grace = grace_seconds
339
+ self._dry_run_timeout = dry_run_timeout_seconds
340
+ self._normalize_timeout = normalize_timeout_seconds
341
+
342
+ def execute(self, context: ExecutionContext) -> RealExecutionResult:
343
+ """Execute one real Campaign's two variants. Spec section 6.17."""
344
+ request = context.request
345
+ run_id = request.run_id
346
+ run_paths = RunPaths.for_run(self._paths, run_id)
347
+
348
+ # 1-3. The run's own inputs, verified, and the rights it runs under.
349
+ inputs = context.artifact_store.load_inputs(run_id, request)
350
+ campaign = inputs.campaign
351
+ self._require_acknowledged_policy(request, campaign)
352
+ self._require_supported_backend(campaign)
353
+ require_live_campaign(campaign)
354
+ subject = self._subject(campaign)
355
+
356
+ # 4-5. The engine, and the credential the subject's calls are paid with.
357
+ engine = self._resolve_engine()
358
+ require_credentials(subject.model)
359
+
360
+ # 5a. What the Campaign may spend, and whether it can be held to it.
361
+ # After the credential rather than before it, because both refusals are
362
+ # free and this is the order the two are useful in: a machine that
363
+ # cannot pay for any run at all is told that first, and a Campaign
364
+ # whose limits are not limits is refused before the first container
365
+ # either way (decisions document 0029, layer A).
366
+ require_bounded_campaign(campaign)
367
+
368
+ # 6-7. Local taskset validation, and the membership it commits to.
369
+ validation = self._validate_taskset(context, inputs)
370
+ lock_path = self._write_taskset_lock(run_paths, validation)
371
+
372
+ # 8-11. Both configurations compiled and survived by the real engine,
373
+ # and what this machine's daemon holds for the container they pin —
374
+ # asked for both variants before either is launched, so that a missing
375
+ # or unexpected image costs nothing rather than half a comparison.
376
+ self._materialize_skill_mounts(inputs, run_paths)
377
+ pair = self._compile_pair(campaign, inputs, run_paths, engine, subject.model)
378
+ images = {
379
+ variant: resolve_subject_image(subject.runtime, variant)
380
+ for variant in _VARIANT_ORDER
381
+ }
382
+
383
+ try:
384
+ # 12-15. Both children, started and watched.
385
+ outcome = self._run_children(
386
+ context, campaign=campaign, pair=pair, engine=engine, subject=subject
387
+ )
388
+
389
+ # 16-18. The engine's own reading of what each child left behind.
390
+ results = self._normalize(
391
+ pair=pair,
392
+ outcome=outcome,
393
+ images=images,
394
+ inputs=inputs,
395
+ validation=validation,
396
+ engine=engine,
397
+ lock_path=lock_path,
398
+ primary_reward=campaign.scoring.primary_reward,
399
+ )
400
+ return self._record(run_paths, engine, outcome, results)
401
+ finally:
402
+ keep_evaluation_private(run_paths)
403
+
404
+ # -- preconditions ------------------------------------------------------
405
+
406
+ def _require_acknowledged_policy(
407
+ self, request: RunRequest, campaign: CampaignSpec
408
+ ) -> None:
409
+ """Refuse to execute under rights nobody accepted for this Campaign."""
410
+ acknowledged = request.policy_acknowledgement.data_policy_digest
411
+ if acknowledged == campaign.data_policy_digest:
412
+ return
413
+ raise ValidationError(
414
+ "the DataPolicy this run acknowledged is not the one its Campaign "
415
+ "is governed by",
416
+ code=REAL_EXECUTION_UNSUPPORTED,
417
+ details={
418
+ "run_id": request.run_id,
419
+ "acknowledged": acknowledged,
420
+ "campaign": campaign.data_policy_digest,
421
+ },
422
+ )
423
+
424
+ def _require_supported_backend(self, campaign: CampaignSpec) -> None:
425
+ """Refuse a Campaign this build has no evaluation backend for."""
426
+ kind = campaign.evaluation_backend.kind
427
+ if kind in SUPPORTED_EVALUATION_BACKEND_KINDS:
428
+ return
429
+ raise ValidationError(
430
+ f"this Campaign is evaluated by {kind.value}, which this build "
431
+ "does not run",
432
+ code=REAL_EXECUTION_UNSUPPORTED,
433
+ details={"evaluation_backend": kind.value},
434
+ )
435
+
436
+ def _subject(self, campaign: CampaignSpec) -> AgentSpec:
437
+ """Return the evaluated agent, or refuse a Campaign that names none."""
438
+ subject = campaign.agents.get(SUBJECT_AGENT)
439
+ if subject is None:
440
+ raise ValidationError(
441
+ f"this Campaign defines no {SUBJECT_AGENT!r} agent to execute",
442
+ code=REAL_EXECUTION_UNSUPPORTED,
443
+ details={"campaign_id": campaign.metadata.id},
444
+ )
445
+ return subject
446
+
447
+ def _resolve_engine(self) -> _ResolvedEngine:
448
+ """Resolve the pinned engine and require it to be verified in place."""
449
+ status = self._registry.status(self._engine_digest)
450
+ if not status.installed or not status.verified:
451
+ raise EngineError(
452
+ "the pinned evaluation engine is not installed and verified on "
453
+ "this machine",
454
+ code="engine_not_verified",
455
+ details={
456
+ "engine_digest": self._engine_digest,
457
+ "installed": status.installed,
458
+ "verified": status.verified,
459
+ },
460
+ )
461
+ installation = self._registry.installation(self._engine_digest)
462
+ if installation is None:
463
+ raise EngineError(
464
+ "the pinned evaluation engine records no installation",
465
+ code="engine_not_verified",
466
+ details={"engine_digest": self._engine_digest},
467
+ )
468
+ return _ResolvedEngine(
469
+ digest=self._engine_digest,
470
+ installation=installation,
471
+ descriptor=read_engine_descriptor(self._registry.path(self._engine_digest)),
472
+ runner=EngineRunner(self._registry, self._engine_digest),
473
+ )
474
+
475
+ # -- taskset ------------------------------------------------------------
476
+
477
+ def _validate_taskset(
478
+ self, context: ExecutionContext, inputs: RunInputBundle
479
+ ) -> TasksetValidationOutcome:
480
+ """Validate the taskset locally and require it to be the committed one."""
481
+ run_id = context.request.run_id
482
+ context.run_store.append(run_id, phase=RunPhase.VALIDATING_TASKSET)
483
+ validation = context.validation_provider.validate(run_id=run_id, inputs=inputs)
484
+ context.artifact_store.write_validation_marker(
485
+ run_id, validation.marker_document()
486
+ )
487
+
488
+ if validation.receipt.status != "valid":
489
+ raise VerificationError(
490
+ "the taskset validation this run was given reports "
491
+ f"{validation.receipt.status}, so nothing may be scored on it",
492
+ code=VARIANT_NOT_USABLE,
493
+ details={"run_id": run_id, "status": validation.receipt.status},
494
+ )
495
+ if validation.lock.ordered_task_hashes != inputs.ordered_task_hashes:
496
+ raise VerificationError(
497
+ "the validated taskset does not hold the tasks the Campaign commits to",
498
+ code=VARIANT_NOT_USABLE,
499
+ details={"run_id": run_id},
500
+ )
501
+ return validation
502
+
503
+ def _write_taskset_lock(
504
+ self, run_paths: RunPaths, validation: TasksetValidationOutcome
505
+ ) -> Path:
506
+ """Write the run's own copy of the membership the normalizer joins on."""
507
+ path = run_paths.inputs_dir / TASKSET_LOCK_FILENAME
508
+ if path.is_file():
509
+ return path
510
+ ensure_private_directory(path.parent)
511
+ with open_exclusive(path) as handle:
512
+ handle.write(validation.lock.model_dump_json().encode("utf-8"))
513
+ return path
514
+
515
+ # -- inputs the subject actually reads ----------------------------------
516
+
517
+ def _materialize_skill_mounts(
518
+ self, inputs: RunInputBundle, run_paths: RunPaths
519
+ ) -> None:
520
+ """Place each declared skill at the content address the compiler mounts.
521
+
522
+ The bytes are re-verified against the artifact as they are copied, so
523
+ what the subject reads is provably the tree the run's manifests name
524
+ rather than whatever is sitting in the staged directory by then.
525
+
526
+ A Skill insertion places one tree; a Skill replacement places two,
527
+ because its baseline carries the Skill being revised. Which is which
528
+ never has to be decided here: a skill is found by the content address
529
+ the variant declares, among the skills the run owns, so a variant
530
+ asking for anything the run did not stage stops the run.
531
+ """
532
+ owned = {
533
+ staged.artifact.root_digest: staged for staged in inputs.declared_skills
534
+ }
535
+ for manifest in (inputs.baseline, inputs.candidate):
536
+ subject = manifest.configuration.agents.get(SUBJECT_AGENT)
537
+ if subject is None:
538
+ continue
539
+ for reference in subject.harness.skills:
540
+ staged = owned.get(reference.digest)
541
+ if staged is None:
542
+ raise VerificationError(
543
+ "a variant declares a skill this run does not own",
544
+ code=VARIANT_NOT_USABLE,
545
+ details={
546
+ "declared": reference.digest,
547
+ "staged": ", ".join(sorted(owned)),
548
+ },
549
+ )
550
+ self._copy_skill_tree(
551
+ skill=staged.artifact,
552
+ source=staged.files,
553
+ destination=run_paths.skill_files_dir
554
+ / skill_directory_name(reference.digest),
555
+ )
556
+
557
+ def _copy_skill_tree(
558
+ self, *, skill: SkillArtifact, source: Path, destination: Path
559
+ ) -> None:
560
+ """Copy one skill's files under a content-addressed directory name."""
561
+ for entry in skill.files:
562
+ target = destination / entry.path
563
+ if target.is_file():
564
+ continue
565
+ ensure_private_directory(target.parent)
566
+ data = (source / entry.path).read_bytes()
567
+ if len(data) != entry.size or sha256_digest_bytes(data) != entry.digest:
568
+ raise VerificationError(
569
+ f"this run's copy of the skill file {entry.path} is not "
570
+ "what the artifact says it is, so it is not what the "
571
+ "subject may be given",
572
+ code=VARIANT_NOT_USABLE,
573
+ details={"path": entry.path},
574
+ )
575
+ with open_exclusive(target) as handle:
576
+ handle.write(data)
577
+
578
+ # -- compilation --------------------------------------------------------
579
+
580
+ def _compile_pair(
581
+ self,
582
+ campaign: CampaignSpec,
583
+ inputs: RunInputBundle,
584
+ run_paths: RunPaths,
585
+ engine: _ResolvedEngine,
586
+ model: ModelSpec,
587
+ ) -> VariantPair:
588
+ """Compile, write and dry-run both variants' configurations.
589
+
590
+ ``compile_plans`` divides the Campaign's concurrency and returns the
591
+ plans; the configuration itself is recompiled per variant from the
592
+ permits the plan settled on. Compilation is a pure function of the
593
+ manifest, the run paths and that number, so the two agree by
594
+ construction rather than by assertion — and the recompilation is what
595
+ gives the dry run the exact document that was written.
596
+ """
597
+ baseline_plan, candidate_plan = compile_plans(
598
+ campaign=campaign,
599
+ baseline=inputs.baseline,
600
+ candidate=inputs.candidate,
601
+ run_paths=run_paths,
602
+ )
603
+ pair = VariantPair(baseline=baseline_plan, candidate=candidate_plan)
604
+ require_concurrency_budget(
605
+ pair, max_concurrent=campaign.execution.max_concurrent
606
+ )
607
+
608
+ manifests = {
609
+ VariantName.BASELINE: inputs.baseline,
610
+ VariantName.CANDIDATE: inputs.candidate,
611
+ }
612
+ for variant in _VARIANT_ORDER:
613
+ plan = pair.plan(variant)
614
+ compiled = compile_variant_config(
615
+ campaign=campaign,
616
+ experiment=manifests[variant],
617
+ run_paths=run_paths,
618
+ variant=variant,
619
+ variant_max_concurrent=plan.max_concurrent,
620
+ )
621
+ input_path = run_paths.variant_input_config(variant)
622
+ write_variant_config(compiled, input_path)
623
+
624
+ outcome = dry_run_variant_config(
625
+ engine_runner=engine.runner,
626
+ variant=variant,
627
+ compiled=compiled,
628
+ input_config_path=input_path,
629
+ dry_run_dir=run_paths.variant_dry_run_dir(variant),
630
+ model=model,
631
+ timeout=self._dry_run_timeout,
632
+ )
633
+ if not outcome.ok:
634
+ failures: list[JsonValue] = [
635
+ to_json_value(check) for check in outcome.failures
636
+ ]
637
+ raise ValidationError(
638
+ f"the {variant.value} configuration did not survive the "
639
+ "engine's own validation, so nothing was executed",
640
+ code=VARIANT_NOT_USABLE,
641
+ details={"variant": variant.value, "checks": failures},
642
+ )
643
+ return pair
644
+
645
+ # -- execution ----------------------------------------------------------
646
+
647
+ def _run_children(
648
+ self,
649
+ context: ExecutionContext,
650
+ *,
651
+ campaign: CampaignSpec,
652
+ pair: VariantPair,
653
+ engine: _ResolvedEngine,
654
+ subject: AgentSpec,
655
+ ) -> VariantPairOutcome:
656
+ """Start and watch both variants under the Campaign's own schedule."""
657
+ run_id = context.request.run_id
658
+ run_paths = RunPaths.for_run(self._paths, run_id)
659
+ environment = scrubbed_child_environment(
660
+ model=subject.model, engine=engine.installation
661
+ )
662
+ executable = self._registry.executable(engine.digest, "eval")
663
+ children = {
664
+ variant: self._build_child(
665
+ variant=variant,
666
+ plan=pair.plan(variant),
667
+ executable=executable,
668
+ environment=environment,
669
+ run_paths=run_paths,
670
+ )
671
+ for variant in _VARIANT_ORDER
672
+ }
673
+ scheduler = VariantScheduler(
674
+ run_store=context.run_store,
675
+ child_registry=self._child_registry,
676
+ poll_interval_seconds=self._poll_interval,
677
+ grace_seconds=self._grace,
678
+ )
679
+ run = (
680
+ scheduler.execute_parallel
681
+ if campaign.execution.order is VariantSchedule.PARALLEL
682
+ else scheduler.execute_sequential
683
+ )
684
+ return run(
685
+ run_id=run_id,
686
+ run_root=run_paths.root,
687
+ pair=pair,
688
+ baseline_child=children[VariantName.BASELINE],
689
+ candidate_child=children[VariantName.CANDIDATE],
690
+ )
691
+
692
+ def _build_child(
693
+ self,
694
+ *,
695
+ variant: VariantName,
696
+ plan: VariantExecutionPlan,
697
+ executable: Path,
698
+ environment: Mapping[str, str],
699
+ run_paths: RunPaths,
700
+ ) -> EvaluationChild:
701
+ """Construct one unstarted evaluation child."""
702
+ return self._child_factory(
703
+ variant=variant,
704
+ argv=eval_argv(
705
+ eval_executable=executable,
706
+ input_config_path=Path(plan.verifiers_input_config_path),
707
+ ),
708
+ cwd=Path(plan.verifiers_output_dir),
709
+ env=environment,
710
+ stdout_path=run_paths.variant_stdout_log(variant),
711
+ stderr_path=run_paths.variant_stderr_log(variant),
712
+ supervision_record_path=run_paths.variant_supervision_record(variant),
713
+ )
714
+
715
+ # -- the engine's reading of the evidence -------------------------------
716
+
717
+ def _normalize(
718
+ self,
719
+ *,
720
+ pair: VariantPair,
721
+ outcome: VariantPairOutcome,
722
+ images: Mapping[VariantName, SubjectImageResolution],
723
+ inputs: RunInputBundle,
724
+ validation: TasksetValidationOutcome,
725
+ engine: _ResolvedEngine,
726
+ lock_path: Path,
727
+ primary_reward: str,
728
+ ) -> dict[VariantName, VariantExecutionResult]:
729
+ """Normalize and verify both variants, or fail the whole execution."""
730
+ outcomes: dict[VariantName, ChildProcessOutcome] = {
731
+ VariantName.BASELINE: outcome.baseline,
732
+ VariantName.CANDIDATE: outcome.candidate,
733
+ }
734
+ manifests: dict[VariantName, ExperimentManifest] = {
735
+ VariantName.BASELINE: inputs.baseline,
736
+ VariantName.CANDIDATE: inputs.candidate,
737
+ }
738
+
739
+ results: dict[VariantName, VariantExecutionResult] = {}
740
+ for variant in _VARIANT_ORDER:
741
+ result = build_variant_result(
742
+ plan=pair.plan(variant),
743
+ outcome=outcomes[variant],
744
+ image_resolution=images[variant],
745
+ engine_registry=self._registry,
746
+ engine_digest=engine.digest,
747
+ engine_runner=engine.runner,
748
+ taskset_lock_path=lock_path,
749
+ timeout=self._normalize_timeout,
750
+ )
751
+ checks = verify_variant_execution(
752
+ result=result,
753
+ experiment=manifests[variant],
754
+ taskset_lock=validation.lock,
755
+ primary_reward=primary_reward,
756
+ engine=engine.descriptor,
757
+ )
758
+ failed = [check for check in checks if check.status == "failed"]
759
+ if failed:
760
+ detail: list[JsonValue] = [to_json_value(check) for check in failed]
761
+ raise VerificationError(
762
+ f"the {variant.value} variant's execution is not "
763
+ "scientifically usable",
764
+ code=VARIANT_NOT_USABLE,
765
+ details={"variant": variant.value, "checks": detail},
766
+ )
767
+ results[variant] = result
768
+ return results
769
+
770
+ def _record(
771
+ self,
772
+ run_paths: RunPaths,
773
+ engine: _ResolvedEngine,
774
+ outcome: VariantPairOutcome,
775
+ results: dict[VariantName, VariantExecutionResult],
776
+ ) -> RealExecutionResult:
777
+ """Assemble what WP6 hands WP7, and write it beside the run."""
778
+ result = RealExecutionResult(
779
+ execution_backend="verifiers",
780
+ engine_digest=engine.digest,
781
+ verifiers_revision=engine.descriptor.verifiers_revision,
782
+ schedule=outcome.schedule,
783
+ baseline=results[VariantName.BASELINE],
784
+ candidate=results[VariantName.CANDIDATE],
785
+ )
786
+ path = real_execution_result_path(run_paths.root)
787
+ ensure_private_directory(path.parent)
788
+ atomic_write_json(path, to_json_value(result))
789
+ return result