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,432 @@
1
+ """One resolved experiment becomes one Verifiers configuration. Spec section 6.8.
2
+
3
+ Compilation is a translation with no judgement in it. Everything the compiler
4
+ could have decided has already been decided by the Campaign and frozen into the
5
+ two experiment manifests, and this module's job is to refuse rather than to
6
+ reconcile: if a manifest and its Campaign disagree about anything at all, the
7
+ right answer is a named error, not a compiled document that splits the
8
+ difference.
9
+
10
+ Determinism is the property the rest of WP6 leans on. The same manifest, the
11
+ same run paths and the same variant always produce byte-identical JSON, which
12
+ is what lets a resolved config be compared against a compiled one and what lets
13
+ a run be reproduced from its own inputs.
14
+
15
+ The evaluation client is derived rather than chosen. Techtree emits the
16
+ credential variable the Campaign declared and omits ``base_url`` entirely, so a
17
+ Campaign that declares ``PRIME_API_KEY`` gets the pinned client's Prime
18
+ resolution (spec section 6.8's Prime profile) without Techtree writing a
19
+ provider URL into a run's inputs. There is no provider table here; which
20
+ provider a name belongs to is ``credentials``' question, not the compiler's.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import os
27
+ from collections.abc import Mapping
28
+ from pathlib import Path
29
+ from typing import Final, NoReturn
30
+
31
+ from techtree.canonical import digest_object, sha256_digest_bytes
32
+ from techtree.errors import ValidationError
33
+ from techtree.fs import ensure_private_directory, fsync_directory, open_exclusive
34
+ from techtree.models.base import ArtifactRef, Digest
35
+ from techtree.models.campaign import (
36
+ SUBJECT_AGENT,
37
+ AgentSpec,
38
+ CampaignSpec,
39
+ VariantSchedule,
40
+ )
41
+ from techtree.models.experiment import ExperimentManifest, ExperimentVariant
42
+ from techtree.verifiers.config import (
43
+ DockerRuntimeToml,
44
+ EnvToml,
45
+ EvalClientToml,
46
+ EvalToml,
47
+ HermesHarnessToml,
48
+ SamplingToml,
49
+ SubjectAgentToml,
50
+ TasksetToml,
51
+ TimeoutToml,
52
+ config_to_json_bytes,
53
+ egress_for,
54
+ )
55
+ from techtree.verifiers.models import (
56
+ EVAL_RUN_NAME,
57
+ RunPaths,
58
+ VariantExecutionPlan,
59
+ VariantName,
60
+ )
61
+
62
+ __all__ = [
63
+ "EVAL_CONFIG_MEDIA_TYPE",
64
+ "MANIFEST_NOT_COMPILABLE",
65
+ "REFERENCE_HARNESS_ID",
66
+ "compile_plans",
67
+ "compile_variant_config",
68
+ "divide_concurrency",
69
+ "skill_directory_name",
70
+ "write_variant_config",
71
+ ]
72
+
73
+ #: Stable error code. Spec section 6.8.
74
+ MANIFEST_NOT_COMPILABLE: Final = "manifest_not_compilable"
75
+
76
+ #: The only subject harness WP6 executes. Spec section 6.2.
77
+ REFERENCE_HARNESS_ID: Final = "hermes-agent"
78
+
79
+ #: What Techtree writes for one variant. JSON rather than TOML because the
80
+ #: dashboard can only be turned off by an explicit null, and TOML has none
81
+ #: (``src/techtree/verifiers/config.py``, ``docs/verifiers-pin-0.3.1.md`` D5).
82
+ EVAL_CONFIG_MEDIA_TYPE: Final = "application/json"
83
+
84
+ #: The minimum Campaign-wide concurrency a parallel schedule can be divided
85
+ #: from: one permit each, and no variant may be starved to zero.
86
+ _MINIMUM_PARALLEL_CONCURRENCY: Final = 2
87
+
88
+ _VARIANTS: Final[dict[ExperimentVariant, VariantName]] = {
89
+ ExperimentVariant.BASELINE: VariantName.BASELINE,
90
+ ExperimentVariant.CANDIDATE: VariantName.CANDIDATE,
91
+ }
92
+
93
+
94
+ def _refuse(message: str, **details: str | int | bool | None) -> NoReturn:
95
+ """Raise the one error this module reports, with identifying details."""
96
+ raise ValidationError(
97
+ message,
98
+ code=MANIFEST_NOT_COMPILABLE,
99
+ details=dict(details),
100
+ )
101
+
102
+
103
+ def skill_directory_name(digest: Digest) -> str:
104
+ """Return the run-owned directory name one skill's content tree occupies.
105
+
106
+ A skill is mounted by content, so its directory is named after its root
107
+ digest rather than after a human-chosen label. Hermes lands each folder at
108
+ ``<skills dir>/<folder name>``, so the folder name is part of what the
109
+ subject sees and must therefore be a property of the skill rather than of
110
+ whoever prepared it.
111
+ """
112
+ return digest.replace(":", "-", 1)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Compilation
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def compile_variant_config(
121
+ *,
122
+ campaign: CampaignSpec,
123
+ experiment: ExperimentManifest,
124
+ run_paths: RunPaths,
125
+ variant: VariantName,
126
+ variant_max_concurrent: int,
127
+ ) -> EvalToml:
128
+ """Translate one resolved Techtree experiment into the strict config.
129
+
130
+ Every disagreement between the manifest and the Campaign it claims to
131
+ derive from is refused here, before any file is written, because a
132
+ compiled config is the last point at which the two documents can still be
133
+ compared cheaply.
134
+ """
135
+ campaign_digest = digest_object(campaign)
136
+ _check_manifest_derives_from(experiment, campaign, campaign_digest)
137
+ _check_variant_matches(experiment, variant)
138
+
139
+ subject = _subject_of(experiment)
140
+ _check_subject_is_executable(subject)
141
+ skill_paths = _resolve_skill_paths(subject, run_paths)
142
+
143
+ # ``output_dir`` names the directory the engine *groups* runs under, not
144
+ # the directory this run writes into; the invocation's ``--run.name`` names
145
+ # that (deviation D2). The two are joined in exactly one place,
146
+ # :meth:`RunPaths.variant_output_dir`.
147
+ output_dir = run_paths.variant_output_group_dir(variant)
148
+ taskset = experiment.configuration.taskset
149
+ allow, block = egress_for(subject.runtime.network_policy)
150
+
151
+ # Every limit the Campaign declares, compiled into the place the engine
152
+ # reads it. A declared budget the engine never sees is decorative, and
153
+ # decision 0029 forbids one.
154
+ #
155
+ # Binding v0.1 interpretation: one Verifiers model turn is the supported
156
+ # Hermes model-call budget unit, so ``maximum_model_calls`` compiles to
157
+ # ``max_turns``. That mapping is VALID ONLY IF the turn-conformance check
158
+ # holds for the pinned profile — intercepted subject generations equal
159
+ # ``Trace.num_turns`` on every recorded canonical episode
160
+ # (``tools/verify_turn_conformance.py``, spec section 7). If it ever
161
+ # fails, the answer is a new ``maximum_turns`` field and
162
+ # ``maximum_model_calls`` left unsupported, never a silent false mapping.
163
+ #
164
+ # ``max_total_tokens`` is derived rather than declared: a Campaign exposes
165
+ # three publisher decisions (turns, input, output) and the total is their
166
+ # sum (decision 0029, resolution 1).
167
+ maximum_input = campaign.budgets.maximum_input_tokens
168
+ maximum_output = campaign.budgets.maximum_output_tokens
169
+ maximum_turns = campaign.budgets.maximum_model_calls
170
+ maximum_total = (
171
+ maximum_input + maximum_output
172
+ if maximum_input is not None and maximum_output is not None
173
+ else None
174
+ )
175
+
176
+ if variant_max_concurrent < 1:
177
+ _refuse(
178
+ "a variant needs at least one concurrency permit",
179
+ variant=variant.value,
180
+ variant_max_concurrent=variant_max_concurrent,
181
+ )
182
+
183
+ return EvalToml(
184
+ model=subject.model.model_id,
185
+ client=EvalClientToml(api_key_var=subject.model.credential_env),
186
+ sampling=SamplingToml(
187
+ temperature=subject.sampling.temperature,
188
+ max_tokens=subject.sampling.max_tokens,
189
+ ),
190
+ env=EnvToml(
191
+ taskset=TasksetToml(id=taskset.ref.id),
192
+ subject=SubjectAgentToml(
193
+ harness=HermesHarnessToml(
194
+ version=subject.harness.version,
195
+ skills=skill_paths,
196
+ ),
197
+ runtime=DockerRuntimeToml(
198
+ image=subject.runtime.image,
199
+ allow=allow,
200
+ block=block,
201
+ cpu=subject.runtime.cpu,
202
+ memory=subject.runtime.memory_gb,
203
+ ),
204
+ max_turns=maximum_turns,
205
+ max_input_tokens=maximum_input,
206
+ max_output_tokens=maximum_output,
207
+ max_total_tokens=maximum_total,
208
+ # The Campaign's ``timeout_seconds`` bounds ONE subject
209
+ # rollout, not the whole variant. The variant's own bound is
210
+ # the supervisor's hard deadline (decision 0029, layer B).
211
+ timeout=TimeoutToml(rollout=float(campaign.execution.timeout_seconds)),
212
+ ),
213
+ max_concurrent_agents=1,
214
+ ),
215
+ num_tasks=taskset.selection.num_tasks,
216
+ max_concurrent=variant_max_concurrent,
217
+ output_dir=str(output_dir),
218
+ )
219
+
220
+
221
+ def _subject_of(experiment: ExperimentManifest) -> AgentSpec:
222
+ """Return the manifest's subject agent, or refuse."""
223
+ subject = experiment.configuration.agents.get(SUBJECT_AGENT)
224
+ if subject is None:
225
+ _refuse(
226
+ f"an experiment configuration defines a {SUBJECT_AGENT!r} agent",
227
+ manifest_id=experiment.id,
228
+ )
229
+ return subject
230
+
231
+
232
+ def _check_manifest_derives_from(
233
+ experiment: ExperimentManifest,
234
+ campaign: CampaignSpec,
235
+ campaign_digest: Digest,
236
+ ) -> None:
237
+ """Refuse a manifest that is not this Campaign, fully resolved."""
238
+ if experiment.campaign_spec_digest != campaign_digest:
239
+ _refuse(
240
+ "the experiment manifest was derived from a different Campaign",
241
+ manifest_id=experiment.id,
242
+ declared=experiment.campaign_spec_digest,
243
+ actual=campaign_digest,
244
+ )
245
+ if digest_object(experiment.configuration) != experiment.configuration_digest:
246
+ _refuse(
247
+ "the experiment configuration does not hash to its own digest",
248
+ manifest_id=experiment.id,
249
+ )
250
+
251
+ configuration = experiment.configuration
252
+ if configuration.data_policy_digest != campaign.data_policy_digest:
253
+ _refuse(
254
+ "the experiment and its Campaign disagree about the data policy",
255
+ manifest_id=experiment.id,
256
+ )
257
+ if configuration.evaluation_backend != campaign.evaluation_backend:
258
+ _refuse(
259
+ "the experiment and its Campaign disagree about the evaluation backend",
260
+ manifest_id=experiment.id,
261
+ )
262
+ if configuration.taskset.ref != campaign.taskset.ref:
263
+ _refuse(
264
+ "the experiment and its Campaign point at different tasksets",
265
+ manifest_id=experiment.id,
266
+ )
267
+ if configuration.taskset.membership != campaign.taskset.membership:
268
+ _refuse(
269
+ "the experiment and its Campaign commit different task membership",
270
+ manifest_id=experiment.id,
271
+ )
272
+ committed = len(configuration.taskset.membership.ordered_task_hashes)
273
+ if committed != configuration.taskset.selection.num_tasks:
274
+ _refuse(
275
+ "the experiment commits a task count its selection does not ask for",
276
+ manifest_id=experiment.id,
277
+ committed=committed,
278
+ selected=configuration.taskset.selection.num_tasks,
279
+ )
280
+
281
+
282
+ def _check_variant_matches(
283
+ experiment: ExperimentManifest, variant: VariantName
284
+ ) -> None:
285
+ """Refuse a manifest compiled under the other variant's name."""
286
+ if _VARIANTS[experiment.variant] is not variant:
287
+ _refuse(
288
+ "the experiment manifest describes the other variant",
289
+ manifest_id=experiment.id,
290
+ manifest_variant=experiment.variant.value,
291
+ requested=variant.value,
292
+ )
293
+
294
+
295
+ def _check_subject_is_executable(subject: AgentSpec) -> None:
296
+ """Refuse a subject WP6 has no way to run."""
297
+ if subject.harness.id != REFERENCE_HARNESS_ID:
298
+ _refuse(
299
+ f"WP6 executes the {REFERENCE_HARNESS_ID!r} harness only",
300
+ harness_id=subject.harness.id,
301
+ )
302
+ if subject.harness.use_bundled_skill:
303
+ _refuse(
304
+ "a bundled skill catalogue is an uncontrolled second difference",
305
+ harness_id=subject.harness.id,
306
+ )
307
+ if subject.runtime.type != "docker":
308
+ _refuse(
309
+ "the subject runtime is Docker",
310
+ runtime_type=subject.runtime.type,
311
+ )
312
+ if subject.trainable:
313
+ _refuse(
314
+ "the subject is evaluated, never trained",
315
+ harness_id=subject.harness.id,
316
+ )
317
+
318
+
319
+ def _resolve_skill_paths(subject: AgentSpec, run_paths: RunPaths) -> list[str]:
320
+ """Return the run-owned directory each declared skill is mounted from."""
321
+ paths: list[str] = []
322
+ for skill in subject.harness.skills:
323
+ directory = run_paths.skill_files_dir / skill_directory_name(skill.digest)
324
+ if not run_paths.owns(directory):
325
+ _refuse(
326
+ "a skill must be mounted from the run's own input tree",
327
+ path=str(directory),
328
+ )
329
+ paths.append(str(directory))
330
+ return paths
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Writing
335
+ # ---------------------------------------------------------------------------
336
+
337
+
338
+ def write_variant_config(config: EvalToml, destination: Path) -> ArtifactRef:
339
+ """Write deterministic JSON and return its artifact reference.
340
+
341
+ The write is exclusive: a compiled config is an input to exactly one run,
342
+ and silently overwriting one would make a re-read prove nothing about what
343
+ the child was actually handed.
344
+ """
345
+ data = config_to_json_bytes(config)
346
+ ensure_private_directory(destination.parent)
347
+ with open_exclusive(destination) as handle:
348
+ handle.write(data)
349
+ handle.flush()
350
+ os.fsync(handle.fileno())
351
+ fsync_directory(destination.parent)
352
+ return ArtifactRef(
353
+ digest=sha256_digest_bytes(data),
354
+ media_type=EVAL_CONFIG_MEDIA_TYPE,
355
+ size=len(data),
356
+ relative_path=None,
357
+ )
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Planning both variants
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ def divide_concurrency(
366
+ schedule: VariantSchedule, max_concurrent: int
367
+ ) -> tuple[int, int]:
368
+ """Split the Campaign-wide concurrency bound between the two variants.
369
+
370
+ Under a sequential schedule only one variant is ever running, so each may
371
+ use the whole declared allowance. Under a parallel schedule the bound is
372
+ Campaign-wide (spec section 3.2) and dividing it is the executor's job:
373
+ granting each side the full allowance would double the live subject runs
374
+ the Campaign said it wanted.
375
+ """
376
+ if max_concurrent < 1:
377
+ _refuse(
378
+ "a Campaign declares at least one concurrency permit",
379
+ max_concurrent=max_concurrent,
380
+ )
381
+ if schedule is VariantSchedule.SEQUENTIAL:
382
+ return max_concurrent, max_concurrent
383
+ if max_concurrent < _MINIMUM_PARALLEL_CONCURRENCY:
384
+ _refuse(
385
+ "a parallel schedule needs at least two concurrency permits, one "
386
+ "for each variant",
387
+ max_concurrent=max_concurrent,
388
+ )
389
+ baseline = max(1, math.floor(max_concurrent / 2))
390
+ return baseline, max(1, max_concurrent - baseline)
391
+
392
+
393
+ def compile_plans(
394
+ *,
395
+ campaign: CampaignSpec,
396
+ baseline: ExperimentManifest,
397
+ candidate: ExperimentManifest,
398
+ run_paths: RunPaths,
399
+ ) -> tuple[VariantExecutionPlan, VariantExecutionPlan]:
400
+ """Build both variants' plans and divide the Campaign's concurrency."""
401
+ schedule = campaign.execution.order
402
+ baseline_permits, candidate_permits = divide_concurrency(
403
+ schedule, campaign.execution.max_concurrent
404
+ )
405
+ manifests: Mapping[VariantName, tuple[ExperimentManifest, int]] = {
406
+ VariantName.BASELINE: (baseline, baseline_permits),
407
+ VariantName.CANDIDATE: (candidate, candidate_permits),
408
+ }
409
+
410
+ plans: dict[VariantName, VariantExecutionPlan] = {}
411
+ for variant, (manifest, permits) in manifests.items():
412
+ config = compile_variant_config(
413
+ campaign=campaign,
414
+ experiment=manifest,
415
+ run_paths=run_paths,
416
+ variant=variant,
417
+ variant_max_concurrent=permits,
418
+ )
419
+ plans[variant] = VariantExecutionPlan(
420
+ variant=variant,
421
+ experiment_manifest_digest=digest_object(manifest),
422
+ experiment_manifest_path=str(run_paths.manifest_path(variant)),
423
+ verifiers_input_config_path=str(run_paths.variant_input_config(variant)),
424
+ # The engine's ``--output-dir`` groups runs; the plan names the one
425
+ # this variant writes into, which is the group plus the name
426
+ # :func:`~techtree.verifiers.child.eval_argv` pins (deviation D2).
427
+ verifiers_output_dir=str(Path(config.output_dir) / EVAL_RUN_NAME),
428
+ skill_paths=list(config.env.subject.harness.skills),
429
+ task_count=config.num_tasks,
430
+ max_concurrent=config.max_concurrent,
431
+ )
432
+ return plans[VariantName.BASELINE], plans[VariantName.CANDIDATE]