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,433 @@
1
+ """Deriving the two experiment variants from one Campaign. Spec PR6 §6.4.
2
+
3
+ The Campaign is the experiment. A manifest is the Campaign with the one open
4
+ choice made — which skill the subject harness carries — and nothing else decided
5
+ differently. Everything in this module exists to keep that sentence literally
6
+ true.
7
+
8
+ Four rules make it true rather than merely intended.
9
+
10
+ *Copy, never re-derive.* The taskset, environment, agents, mutation contract,
11
+ evaluation backend, execution plan, scoring rule, evidence requirements,
12
+ budgets, DataPolicy digest, and OutcomeContract digest are copied out of the
13
+ Campaign exactly as they were found. Nothing is normalized, defaulted, or
14
+ recomputed on the way through, because a value this module produced would be a
15
+ value the Campaign did not commit to.
16
+
17
+ *Copy deeply.* A frozen model still holds real lists and dicts. Deep copies are
18
+ what stop a manifest and the Campaign it came from — or a baseline and its
19
+ candidate — from sharing a container a later caller could mutate.
20
+
21
+ *Carry no local path and no public policy.* A manifest is a scientific document
22
+ two machines must be able to produce identically, so the candidate skill enters
23
+ as a content address and nothing about the Climb's schedule, leaderboard, or
24
+ publication policy enters at all. The public wrapper is referenced through
25
+ ``public_context`` and never folded into ``configuration``; that separation is
26
+ what lets :mod:`techtree.manifests.compare` compare configurations alone.
27
+
28
+ *Identify the skill by its content tree.* The harness-facing reference carries
29
+ ``SkillArtifact.root_digest``, not the archive digest. The archive is transport
30
+ integrity — the bytes that happen to move the skill from here to there — while
31
+ the tree digest is what the experiment actually inserted. Two archives of the
32
+ same tree are the same science; the manifest has to say so.
33
+
34
+ Timestamps and identifiers can be injected. A test that pins both gets a
35
+ byte-stable manifest, which is the only way to assert on a digest.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from collections.abc import Sequence
41
+ from datetime import UTC, datetime
42
+ from typing import Final
43
+
44
+ from techtree.canonical import digest_object
45
+ from techtree.constants import EXPERIMENT_SCHEMA_VERSION
46
+ from techtree.errors import ValidationError
47
+ from techtree.models.base import ArtifactRef, Digest, JsonValue
48
+ from techtree.models.campaign import (
49
+ AgentSpec,
50
+ CampaignSpec,
51
+ HarnessSpec,
52
+ MutationKind,
53
+ PublicContext,
54
+ )
55
+ from techtree.models.experiment import (
56
+ ExperimentConfiguration,
57
+ ExperimentManifest,
58
+ ExperimentVariant,
59
+ )
60
+ from techtree.models.skill import SkillArtifact, SkillFile
61
+
62
+ __all__ = [
63
+ "MANIFEST_BUILD_FAILED",
64
+ "MANIFEST_ID_PREFIX",
65
+ "SKILL_MEDIA_TYPE",
66
+ "assert_manifest_matches_campaign",
67
+ "build_baseline_manifest",
68
+ "build_candidate_manifest",
69
+ "build_experiment_configuration",
70
+ "build_skill_reference",
71
+ "manifest_id_for",
72
+ "skill_content_digest",
73
+ ]
74
+
75
+ #: The media type of an instruction skill's canonical content tree. It is not
76
+ #: ``application/x-tar``: what the reference addresses is the tree, and the tar
77
+ #: is one possible encoding of it.
78
+ SKILL_MEDIA_TYPE: Final = "application/vnd.techtree.instruction-skill.v1"
79
+
80
+ #: Manifest identifiers are derived from the configuration digest rather than
81
+ #: drawn at random. Two derivations of the same experiment then name it the
82
+ #: same way, and an identifier can never disagree with the document it labels.
83
+ MANIFEST_ID_PREFIX: Final = "experiment"
84
+
85
+ #: The one error code every failure in this module reports. Spec PR6 §6.10.
86
+ MANIFEST_BUILD_FAILED: Final = "manifest_build_failed"
87
+
88
+ #: How much of the digest a derived identifier carries, matching the display-id
89
+ #: convention of decisions document 0003 A1.
90
+ _ID_HEX_LENGTH: Final = 24
91
+
92
+
93
+ def build_experiment_configuration(
94
+ campaign: CampaignSpec,
95
+ ) -> ExperimentConfiguration:
96
+ """Copy the Campaign scientific fields into an ``ExperimentConfiguration``.
97
+
98
+ This is the baseline configuration, because a Campaign describes the subject
99
+ as the candidate is measured against it — with no skill for an insertion,
100
+ and with the skill being revised for a replacement.
101
+ :func:`build_candidate_manifest` produces the other variant by replacing
102
+ exactly one field of exactly one agent.
103
+ """
104
+ return ExperimentConfiguration(
105
+ taskset=campaign.taskset.model_copy(deep=True),
106
+ environment=campaign.environment.model_copy(deep=True),
107
+ agents={
108
+ name: agent.model_copy(deep=True) for name, agent in campaign.agents.items()
109
+ },
110
+ mutation_contract=campaign.mutation_contract.model_copy(deep=True),
111
+ evaluation_backend=campaign.evaluation_backend.model_copy(deep=True),
112
+ execution=campaign.execution.model_copy(deep=True),
113
+ scoring=campaign.scoring.model_copy(deep=True),
114
+ evidence=campaign.evidence.model_copy(deep=True),
115
+ budgets=campaign.budgets.model_copy(deep=True),
116
+ data_policy_digest=campaign.data_policy_digest,
117
+ outcome_contract_digest=campaign.context.outcome_contract_digest,
118
+ )
119
+
120
+
121
+ def skill_content_digest(files: Sequence[SkillFile]) -> Digest:
122
+ """Return the digest of a skill's canonical content tree.
123
+
124
+ The tree is exactly its sorted file list: each path, its media type, its
125
+ size, and the digest of its bytes. Digesting that list commits to every
126
+ file's content and to the shape of the directory, and to nothing about how
127
+ the tree was packed or where it was read from.
128
+ """
129
+ return digest_object(list(files))
130
+
131
+
132
+ def build_skill_reference(skill: SkillArtifact) -> ArtifactRef:
133
+ """Create the harness-facing reference to canonical skill content."""
134
+ total = sum(file.size for file in skill.files)
135
+ if total <= 0:
136
+ raise ValidationError(
137
+ "the candidate skill has no content to insert, so there is nothing "
138
+ "for the experiment to measure",
139
+ code=MANIFEST_BUILD_FAILED,
140
+ details={"file_count": len(skill.files), "total_bytes": total},
141
+ )
142
+
143
+ recomputed = skill_content_digest(skill.files)
144
+ if recomputed != skill.root_digest:
145
+ raise ValidationError(
146
+ "the candidate skill's root digest does not describe the files it lists",
147
+ code=MANIFEST_BUILD_FAILED,
148
+ details={
149
+ "stated_root_digest": skill.root_digest,
150
+ "computed_root_digest": recomputed,
151
+ },
152
+ )
153
+
154
+ return ArtifactRef(
155
+ digest=skill.root_digest,
156
+ media_type=SKILL_MEDIA_TYPE,
157
+ size=total,
158
+ relative_path=None,
159
+ )
160
+
161
+
162
+ def build_baseline_manifest(
163
+ *,
164
+ campaign: CampaignSpec,
165
+ campaign_digest: Digest,
166
+ public_context: PublicContext | None,
167
+ created_at: datetime | None = None,
168
+ manifest_id: str | None = None,
169
+ ) -> ExperimentManifest:
170
+ """Build the baseline variant."""
171
+ configuration = build_experiment_configuration(campaign)
172
+ _require_campaign_baseline(configuration, campaign)
173
+ return finalize_manifest(
174
+ campaign=campaign,
175
+ campaign_digest=campaign_digest,
176
+ public_context=public_context,
177
+ variant=ExperimentVariant.BASELINE,
178
+ configuration=configuration,
179
+ created_at=created_at,
180
+ manifest_id=manifest_id,
181
+ )
182
+
183
+
184
+ def build_candidate_manifest(
185
+ *,
186
+ campaign: CampaignSpec,
187
+ campaign_digest: Digest,
188
+ skill: SkillArtifact,
189
+ public_context: PublicContext | None,
190
+ created_at: datetime | None = None,
191
+ manifest_id: str | None = None,
192
+ ) -> ExperimentManifest:
193
+ """Build the candidate variant with exactly one skill reference.
194
+
195
+ The candidate configuration is the baseline configuration with the subject
196
+ harness's skill list replaced. Building it by replacement rather than by a
197
+ second derivation from the Campaign is what guarantees no other field can
198
+ drift: a field this function does not name cannot differ from the baseline.
199
+ """
200
+ configuration = build_experiment_configuration(campaign)
201
+ baseline_subject = _require_campaign_baseline(configuration, campaign)
202
+ reference = build_skill_reference(skill)
203
+
204
+ mutation = campaign.mutation_contract
205
+ if mutation.kind is MutationKind.SKILL_REPLACEMENT:
206
+ replaced = baseline_subject.harness.skills[0]
207
+ if replaced.digest == reference.digest:
208
+ raise ValidationError(
209
+ "the replacement skill has the same content tree as the skill it "
210
+ "replaces, so the pair would measure nothing",
211
+ code=MANIFEST_BUILD_FAILED,
212
+ details={
213
+ "baseline_root_digest": replaced.digest,
214
+ "candidate_root_digest": reference.digest,
215
+ },
216
+ )
217
+
218
+ if not mutation.minimum_skills <= 1 <= mutation.maximum_skills:
219
+ raise ValidationError(
220
+ "this Campaign's mutation contract does not permit a single "
221
+ f"candidate skill; it allows {mutation.minimum_skills} to "
222
+ f"{mutation.maximum_skills}",
223
+ code=MANIFEST_BUILD_FAILED,
224
+ details={
225
+ "minimum_skills": mutation.minimum_skills,
226
+ "maximum_skills": mutation.maximum_skills,
227
+ },
228
+ )
229
+
230
+ target = mutation.target_agent
231
+ subject = configuration.agents[target]
232
+ with_skill = AgentSpec(
233
+ model=subject.model,
234
+ sampling=subject.sampling,
235
+ harness=HarnessSpec(
236
+ id=subject.harness.id,
237
+ version=subject.harness.version,
238
+ use_bundled_skill=subject.harness.use_bundled_skill,
239
+ skills=[reference],
240
+ ),
241
+ runtime=subject.runtime,
242
+ trainable=subject.trainable,
243
+ )
244
+
245
+ return finalize_manifest(
246
+ campaign=campaign,
247
+ campaign_digest=campaign_digest,
248
+ public_context=public_context,
249
+ variant=ExperimentVariant.CANDIDATE,
250
+ configuration=configuration.model_copy(
251
+ update={"agents": {**configuration.agents, target: with_skill}}
252
+ ),
253
+ created_at=created_at,
254
+ manifest_id=manifest_id,
255
+ )
256
+
257
+
258
+ def finalize_manifest(
259
+ *,
260
+ campaign: CampaignSpec,
261
+ campaign_digest: Digest,
262
+ public_context: PublicContext | None,
263
+ variant: ExperimentVariant,
264
+ configuration: ExperimentConfiguration,
265
+ created_at: datetime | None,
266
+ manifest_id: str | None,
267
+ ) -> ExperimentManifest:
268
+ """Validate lineage, calculate ``configuration_digest``, and construct."""
269
+ if digest_object(campaign) != campaign_digest:
270
+ raise ValidationError(
271
+ "the Campaign digest a manifest was asked to carry is not the "
272
+ "digest of the Campaign it was built from",
273
+ code=MANIFEST_BUILD_FAILED,
274
+ details={
275
+ "stated_campaign_digest": campaign_digest,
276
+ "computed_campaign_digest": digest_object(campaign),
277
+ },
278
+ )
279
+
280
+ configuration_digest = digest_object(configuration)
281
+ manifest = ExperimentManifest(
282
+ schema_version=EXPERIMENT_SCHEMA_VERSION,
283
+ id=manifest_id or manifest_id_for(configuration_digest),
284
+ campaign_spec_digest=campaign_digest,
285
+ program_ref=(
286
+ None
287
+ if campaign.context.program_ref is None
288
+ else campaign.context.program_ref.model_copy(deep=True)
289
+ ),
290
+ public_context=(
291
+ None if public_context is None else public_context.model_copy(deep=True)
292
+ ),
293
+ variant=variant,
294
+ configuration=configuration,
295
+ configuration_digest=configuration_digest,
296
+ created_at=created_at or datetime.now(UTC),
297
+ )
298
+ assert_manifest_matches_campaign(manifest, campaign, campaign_digest)
299
+ return manifest
300
+
301
+
302
+ def assert_manifest_matches_campaign(
303
+ manifest: ExperimentManifest,
304
+ campaign: CampaignSpec,
305
+ campaign_digest: Digest,
306
+ ) -> None:
307
+ """Check, after the fact, that a manifest is the Campaign it claims to be.
308
+
309
+ Everything here was already true by construction when the manifest came
310
+ from this module. It is checked again because a manifest can also arrive
311
+ from disk, and because a postcondition that only holds when the code is
312
+ correct is not a postcondition.
313
+ """
314
+ configuration = manifest.configuration
315
+ for label, expected, found in (
316
+ ("Campaign digest", campaign_digest, manifest.campaign_spec_digest),
317
+ ("improvement program", campaign.context.program_ref, manifest.program_ref),
318
+ (
319
+ "OutcomeContract digest",
320
+ campaign.context.outcome_contract_digest,
321
+ configuration.outcome_contract_digest,
322
+ ),
323
+ (
324
+ "DataPolicy digest",
325
+ campaign.data_policy_digest,
326
+ configuration.data_policy_digest,
327
+ ),
328
+ (
329
+ "evaluation backend",
330
+ campaign.evaluation_backend,
331
+ configuration.evaluation_backend,
332
+ ),
333
+ ("taskset", campaign.taskset, configuration.taskset),
334
+ ("environment", campaign.environment, configuration.environment),
335
+ (
336
+ "mutation contract",
337
+ campaign.mutation_contract,
338
+ configuration.mutation_contract,
339
+ ),
340
+ ("execution plan", campaign.execution, configuration.execution),
341
+ ("scoring rule", campaign.scoring, configuration.scoring),
342
+ ("evidence requirement", campaign.evidence, configuration.evidence),
343
+ ("budget", campaign.budgets, configuration.budgets),
344
+ ):
345
+ if expected != found:
346
+ raise ValidationError(
347
+ f"the manifest's {label} is not the Campaign's",
348
+ code=MANIFEST_BUILD_FAILED,
349
+ details={"field": label},
350
+ )
351
+
352
+ if set(configuration.agents) != set(campaign.agents):
353
+ agents: dict[str, JsonValue] = {
354
+ "campaign_agents": list(sorted(campaign.agents)),
355
+ "manifest_agents": list(sorted(configuration.agents)),
356
+ }
357
+ raise ValidationError(
358
+ "the manifest names different agents than the Campaign",
359
+ code=MANIFEST_BUILD_FAILED,
360
+ details=agents,
361
+ )
362
+
363
+ target = campaign.mutation_contract.target_agent
364
+ for name, agent in configuration.agents.items():
365
+ expected_agent = campaign.agents[name]
366
+ if name == target:
367
+ # The skill list is the one field the mutation contract allows to
368
+ # differ, so the subject is compared with it set aside.
369
+ agent = agent.model_copy(
370
+ update={
371
+ "harness": agent.harness.model_copy(
372
+ update={"skills": list(expected_agent.harness.skills)}
373
+ )
374
+ }
375
+ )
376
+ if agent != expected_agent:
377
+ raise ValidationError(
378
+ f"the manifest's {name} agent differs from the Campaign's "
379
+ "outside the skill list",
380
+ code=MANIFEST_BUILD_FAILED,
381
+ details={"agent": name},
382
+ )
383
+
384
+
385
+ def manifest_id_for(configuration_digest: Digest) -> str:
386
+ """Return the display identifier one configuration digest implies."""
387
+ hexadecimal = configuration_digest.split(":", 1)[1]
388
+ return f"{MANIFEST_ID_PREFIX}_{hexadecimal[:_ID_HEX_LENGTH]}"
389
+
390
+
391
+ def _require_campaign_baseline(
392
+ configuration: ExperimentConfiguration, campaign: CampaignSpec
393
+ ) -> AgentSpec:
394
+ """Fail closed when the Campaign's subject is not the baseline its kind needs.
395
+
396
+ An insertion measures a skill against no skill, so its Campaign carries
397
+ none. A replacement measures a revision against the skill it revises, so its
398
+ Campaign carries exactly that one. Either way the Campaign *is* the baseline,
399
+ and a Campaign that is not the baseline its own contract describes has no
400
+ comparison to offer.
401
+ """
402
+ target = campaign.mutation_contract.target_agent
403
+ subject = configuration.agents.get(target)
404
+ if subject is None:
405
+ missing: dict[str, JsonValue] = {
406
+ "target_agent": target,
407
+ "agents": list(sorted(configuration.agents)),
408
+ }
409
+ raise ValidationError(
410
+ f"the Campaign defines no {target} agent for the mutation contract "
411
+ "to target",
412
+ code=MANIFEST_BUILD_FAILED,
413
+ details=missing,
414
+ )
415
+
416
+ skills = subject.harness.skills
417
+ if campaign.mutation_contract.kind is MutationKind.SKILL_INSERTION:
418
+ if skills:
419
+ raise ValidationError(
420
+ "the Campaign's own subject already carries a skill, so there is "
421
+ "no baseline to compare a candidate against",
422
+ code=MANIFEST_BUILD_FAILED,
423
+ details={"skill_count": len(skills)},
424
+ )
425
+ elif len(skills) != 1:
426
+ raise ValidationError(
427
+ "a skill_replacement Campaign's subject carries exactly the one "
428
+ "skill the candidate replaces, so there is no baseline to compare a "
429
+ "candidate against",
430
+ code=MANIFEST_BUILD_FAILED,
431
+ details={"skill_count": len(skills)},
432
+ )
433
+ return subject