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,474 @@
1
+ """What the engine actually resolved and ran. Spec section 7.8.
2
+
3
+ A manifest says what the experiment was supposed to be. This module says what
4
+ it turned out to be, computed from three sources that were written by something
5
+ other than Techtree:
6
+
7
+ * the traces the pinned Verifiers build recorded — which model answered, under
8
+ which sampling settings, which harness drove it, which container it was given,
9
+ which tools it was offered, and which rewards were scored;
10
+ * the configuration the engine resolved and wrote back out — which is where the
11
+ skill mount paths live, and which carries its own copy of the sampling
12
+ settings;
13
+ * what the local Docker daemon answered about the pinned subject image, asked
14
+ once per variant immediately before that variant was launched.
15
+
16
+ Keeping them together is what makes the fingerprint an observation rather than
17
+ a restatement. The resolved configuration is the engine's own answer to "what
18
+ did you understand Techtree to be asking for", the traces are the runtime's
19
+ answer to "what did you actually do", and the daemon's answer is the only one
20
+ that can say which content was really on this machine; a disagreement between
21
+ any of them is exactly the sort of drift a controlled comparison has to detect,
22
+ so this module requires them to agree and refuses when they do not.
23
+
24
+ Three details are worth stating.
25
+
26
+ *The skill digests are recovered from the mount paths.* The normalized trace
27
+ carries the skill digests the *manifest* declared, because Verifiers records
28
+ the host directory a skill was mounted from rather than its content address. But
29
+ Techtree names that directory after the skill's root digest precisely so the
30
+ folder name is a property of the content, so the resolved configuration lets the
31
+ mounted skill be read back by digest. That reading is an observation; the
32
+ trace's copy is a declaration, and the two are required to match.
33
+
34
+ *The sampling settings are read from the rollouts, and checked against the
35
+ file.* Both are the engine's own answers, but only the rollout's copy travels
36
+ with the thing it describes. The resolved configuration's copy is required to
37
+ match it, because an engine whose file and whose rollouts disagree about how
38
+ the subject was sampled has not established either.
39
+
40
+ *Which bytes ran is a three-way agreement, not a claim.* The evaluation records
41
+ the reference it was given; the daemon says it holds that content and which
42
+ platform it served; the Campaign pins one manifest digest per platform. All
43
+ three must line up, and when they do, the digest that ran is a fact rather than
44
+ the Campaign's hope. When they do not, this module refuses — an unidentifiable
45
+ container is not a weaker result, it is no result.
46
+
47
+ Comparing this fingerprint against the declared manifest is the next slice's
48
+ work (spec section 7.9); everything here is extraction and internal
49
+ consistency.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ from collections.abc import Iterable, Mapping, Sequence
56
+ from pathlib import Path
57
+ from typing import Any, Final
58
+
59
+ from techtree.canonical import digest_object, validate_digest
60
+ from techtree.errors import ValidationError, VerificationError
61
+ from techtree.models.base import Digest, JsonValue, NonEmptyString, ProtocolModel
62
+ from techtree.models.campaign import RuntimeSpec
63
+ from techtree.verifiers.models import (
64
+ NormalizedEpisode,
65
+ NormalizedTrace,
66
+ SubjectImageResolution,
67
+ )
68
+
69
+ __all__ = [
70
+ "OBSERVED_CONFIGURATION_MISMATCH",
71
+ "ObservedSubjectConfiguration",
72
+ "observed_from_episodes",
73
+ "read_resolved_config",
74
+ ]
75
+
76
+ #: Stable error code. Spec section 15.
77
+ OBSERVED_CONFIGURATION_MISMATCH: Final = "observed_configuration_mismatch"
78
+
79
+ #: The directory-name spelling of a digest, which is how a mounted skill's
80
+ #: content address survives into a filesystem path.
81
+ _DIGEST_DIRECTORY_PREFIX: Final = "sha256-"
82
+
83
+
84
+ class ObservedSubjectConfiguration(ProtocolModel):
85
+ """One normalized fingerprint of what a variant actually executed."""
86
+
87
+ model_id: NonEmptyString
88
+ sampling_digest: Digest
89
+ harness_id: NonEmptyString
90
+ harness_version: NonEmptyString
91
+ use_bundled_skill: bool
92
+ skill_root_digests: list[Digest]
93
+ runtime_kind: NonEmptyString
94
+ runtime_image: NonEmptyString
95
+ runtime_image_index_digest: Digest
96
+ runtime_platform: NonEmptyString
97
+ runtime_image_platform_digest: Digest
98
+ tool_inventory_digest: Digest
99
+ reward_contract_digest: Digest
100
+ verifiers_version: NonEmptyString
101
+ verifiers_revision: NonEmptyString
102
+
103
+
104
+ def read_resolved_config(path: Path) -> dict[str, Any]:
105
+ """Read the configuration the engine resolved and wrote back out.
106
+
107
+ JSON, because that is what the engine writes: it dumps the resolved model
108
+ whole, nulls included, so an explicitly-unset setting round-trips as itself
109
+ (``docs/verifiers-pin-0.3.1.md``, deviation D1). TOML could not carry those
110
+ nulls, which is why upstream stopped using it here.
111
+ """
112
+ try:
113
+ document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
114
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
115
+ raise ValidationError(
116
+ "the configuration this variant's engine resolved could not be "
117
+ "read, so what it actually ran cannot be established",
118
+ code=OBSERVED_CONFIGURATION_MISMATCH,
119
+ details={"path": str(path)},
120
+ ) from error
121
+ return document
122
+
123
+
124
+ def observed_from_episodes(
125
+ episodes: Sequence[NormalizedEpisode],
126
+ *,
127
+ resolved_config: Mapping[str, Any],
128
+ image_resolution: SubjectImageResolution,
129
+ runtime: RuntimeSpec,
130
+ ) -> ObservedSubjectConfiguration:
131
+ """Fingerprint one variant, requiring every episode to agree.
132
+
133
+ Spec section 7.8 names this ``observed_from_receipts``. It takes the
134
+ normalized episodes instead, because the frozen
135
+ :class:`~techtree.models.episode_receipt.EpisodeReceipt` carries a
136
+ reward-and-lineage record rather than a configuration record: it does not
137
+ hold the model, the harness, the tool inventory or the sampling parameters,
138
+ and reading those off it is not possible. The episodes are the evidence the
139
+ receipts were built from, so the fingerprint and the receipts describe the
140
+ same execution.
141
+ """
142
+ traces = [trace for episode in episodes for trace in episode.traces]
143
+ if not traces:
144
+ raise VerificationError(
145
+ "this variant recorded no subject trace, so there is no observed "
146
+ "configuration to establish",
147
+ code=OBSERVED_CONFIGURATION_MISMATCH,
148
+ details={"episodes": len(episodes)},
149
+ )
150
+
151
+ reference = traces[0]
152
+ _require_no_drift(traces)
153
+
154
+ mounted = _mounted_skill_digests(resolved_config)
155
+ declared = [validate_digest(value) for value in reference.skill_root_digests]
156
+ if mounted != declared:
157
+ raise VerificationError(
158
+ "the skills the engine mounted are not the skills this variant's "
159
+ "traces record, so what the subject was given is not what the "
160
+ "experiment declared",
161
+ code=OBSERVED_CONFIGURATION_MISMATCH,
162
+ details={
163
+ "mounted": _digest_detail(mounted),
164
+ "recorded": _digest_detail(declared),
165
+ },
166
+ )
167
+ _require_config_agrees_with_traces(reference, resolved_config)
168
+ platform_digest = _resolved_platform_digest(reference, image_resolution, runtime)
169
+
170
+ return ObservedSubjectConfiguration(
171
+ model_id=reference.model_id,
172
+ sampling_digest=digest_object(reference.sampling),
173
+ harness_id=reference.harness_id,
174
+ harness_version=reference.harness_version,
175
+ use_bundled_skill=reference.use_bundled_skill,
176
+ skill_root_digests=mounted,
177
+ runtime_kind=reference.runtime.kind,
178
+ runtime_image=reference.runtime.image,
179
+ runtime_image_index_digest=image_resolution.index_digest,
180
+ runtime_platform=image_resolution.platform,
181
+ runtime_image_platform_digest=platform_digest,
182
+ tool_inventory_digest=digest_object(
183
+ [
184
+ {
185
+ "name": tool.name,
186
+ "description_digest": tool.description_digest,
187
+ "parameters_digest": tool.parameters_digest,
188
+ }
189
+ for tool in sorted(reference.tools, key=lambda tool: tool.name)
190
+ ]
191
+ ),
192
+ reward_contract_digest=digest_object(
193
+ [
194
+ {"name": reward.name, "weight": reward.weight}
195
+ for reward in sorted(reference.rewards, key=lambda item: item.name)
196
+ ]
197
+ ),
198
+ verifiers_version=reference.verifiers_version,
199
+ verifiers_revision=reference.verifiers_revision,
200
+ )
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Agreement
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ def _require_no_drift(traces: Sequence[NormalizedTrace]) -> None:
209
+ """Reject a variant whose own traces describe two different subjects.
210
+
211
+ Every rollout of one variant is one configuration executed many times. Two
212
+ rollouts that disagree about the model, the harness, the container or the
213
+ tools were not the same experiment, and averaging their rewards would
214
+ average two experiments.
215
+ """
216
+ for label, observed in (
217
+ ("model", {trace.model_id for trace in traces}),
218
+ ("harness", {trace.harness_id for trace in traces}),
219
+ ("harness version", {trace.harness_version for trace in traces}),
220
+ ("bundled-skill setting", {trace.use_bundled_skill for trace in traces}),
221
+ ("sampling", {digest_object(trace.sampling) for trace in traces}),
222
+ ("runtime kind", {trace.runtime.kind for trace in traces}),
223
+ ("runtime image", {trace.runtime.image for trace in traces}),
224
+ (
225
+ "runtime image digest",
226
+ {trace.runtime.image_index_digest for trace in traces},
227
+ ),
228
+ (
229
+ "skill list",
230
+ {tuple(trace.skill_root_digests) for trace in traces},
231
+ ),
232
+ (
233
+ "tool inventory",
234
+ {tuple(sorted(tool.name for tool in trace.tools)) for trace in traces},
235
+ ),
236
+ (
237
+ "reward contract",
238
+ {
239
+ tuple(sorted((reward.name, reward.weight) for reward in trace.rewards))
240
+ for trace in traces
241
+ },
242
+ ),
243
+ (
244
+ "Verifiers build",
245
+ {(trace.verifiers_version, trace.verifiers_revision) for trace in traces},
246
+ ),
247
+ ):
248
+ if len(observed) > 1:
249
+ raise VerificationError(
250
+ f"this variant's rollouts do not agree on the {label} they ran, "
251
+ "so they are not one configuration measured many times",
252
+ code=OBSERVED_CONFIGURATION_MISMATCH,
253
+ details={
254
+ "field": label,
255
+ "observed": _text_detail(observed),
256
+ },
257
+ )
258
+
259
+
260
+ def _require_config_agrees_with_traces(
261
+ trace: NormalizedTrace, resolved_config: Mapping[str, Any]
262
+ ) -> None:
263
+ """Require the engine's resolution and the runtime's record to agree."""
264
+ subject = _subject_of(resolved_config)
265
+ harness = _table(subject, "harness")
266
+ runtime = _table(subject, "runtime")
267
+
268
+ for label, resolved, recorded in (
269
+ ("model", resolved_config.get("model"), trace.model_id),
270
+ ("harness", harness.get("id"), trace.harness_id),
271
+ ("harness version", harness.get("version"), trace.harness_version),
272
+ ("runtime kind", runtime.get("type"), trace.runtime.kind),
273
+ ("runtime image", runtime.get("image"), trace.runtime.image),
274
+ (
275
+ "bundled-skill setting",
276
+ harness.get("use_bundled_skill"),
277
+ trace.use_bundled_skill,
278
+ ),
279
+ ):
280
+ if resolved == recorded:
281
+ continue
282
+ raise VerificationError(
283
+ f"the engine resolved a different {label} than this variant's "
284
+ "traces record",
285
+ code=OBSERVED_CONFIGURATION_MISMATCH,
286
+ details={
287
+ "field": label,
288
+ "resolved": str(resolved),
289
+ "recorded": str(recorded),
290
+ },
291
+ )
292
+
293
+ if _sampling_of(resolved_config) != _set_knobs(dict(trace.sampling)):
294
+ raise VerificationError(
295
+ "the sampling settings the engine resolved are not the ones this "
296
+ "variant's rollouts record being sampled under",
297
+ code=OBSERVED_CONFIGURATION_MISMATCH,
298
+ details={
299
+ "resolved": _text_detail(_sampling_of(resolved_config)),
300
+ "recorded": _text_detail(trace.sampling),
301
+ },
302
+ )
303
+
304
+
305
+ def _resolved_platform_digest(
306
+ trace: NormalizedTrace,
307
+ image_resolution: SubjectImageResolution,
308
+ runtime: RuntimeSpec,
309
+ ) -> Digest:
310
+ """Return the image digest that actually ran, and refuse if it is unclear.
311
+
312
+ Three separate statements have to line up before the phrase "this container
313
+ ran" means anything. The evaluation says which reference it was given. The
314
+ local daemon says it holds that reference's content, and which platform it
315
+ resolved the reference to on this host. The Campaign says which manifest
316
+ that platform pins. Any disagreement leaves what ran unidentified, which is
317
+ a refusal rather than a weaker claim.
318
+ """
319
+ if image_resolution.image != trace.runtime.image:
320
+ raise VerificationError(
321
+ "the image the daemon was asked about is not the image this "
322
+ "variant's rollouts ran",
323
+ code=OBSERVED_CONFIGURATION_MISMATCH,
324
+ details={
325
+ "resolved": image_resolution.image,
326
+ "recorded": trace.runtime.image,
327
+ },
328
+ )
329
+ if image_resolution.index_digest != trace.runtime.image_index_digest:
330
+ raise VerificationError(
331
+ "the content the daemon holds for the subject image is not the "
332
+ "content this variant's rollouts name",
333
+ code=OBSERVED_CONFIGURATION_MISMATCH,
334
+ details={
335
+ "resolved": image_resolution.index_digest,
336
+ "recorded": trace.runtime.image_index_digest,
337
+ },
338
+ )
339
+ if image_resolution.index_digest != runtime.image_index_digest:
340
+ raise VerificationError(
341
+ "the content the daemon holds for the subject image is not the "
342
+ "content the Campaign pinned",
343
+ code=OBSERVED_CONFIGURATION_MISMATCH,
344
+ details={
345
+ "resolved": image_resolution.index_digest,
346
+ "pinned": runtime.image_index_digest,
347
+ },
348
+ )
349
+ pinned = runtime.image_platform_digests.get(image_resolution.platform)
350
+ if pinned is None:
351
+ raise VerificationError(
352
+ "the daemon served the subject image on a platform the Campaign "
353
+ "pins no manifest for, so which bytes ran cannot be established",
354
+ code=OBSERVED_CONFIGURATION_MISMATCH,
355
+ details={
356
+ "platform": image_resolution.platform,
357
+ "pinned_platforms": _text_detail(runtime.image_platform_digests),
358
+ },
359
+ )
360
+ return validate_digest(pinned)
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Reading the resolved configuration
365
+ # ---------------------------------------------------------------------------
366
+
367
+
368
+ def _subject_of(resolved_config: Mapping[str, Any]) -> Mapping[str, Any]:
369
+ """Return the resolved subject seat, or refuse a configuration without one."""
370
+ environment = _table(resolved_config, "env")
371
+ subject = environment.get("subject")
372
+ if not isinstance(subject, Mapping):
373
+ raise VerificationError(
374
+ "the configuration the engine resolved declares no subject seat, so "
375
+ "it does not describe an evaluation of a subject",
376
+ code=OBSERVED_CONFIGURATION_MISMATCH,
377
+ details={"tables": _text_detail(resolved_config)},
378
+ )
379
+ return subject
380
+
381
+
382
+ def _table(document: Mapping[str, Any], name: str) -> Mapping[str, Any]:
383
+ """Return one required table of a resolved configuration."""
384
+ value = document.get(name)
385
+ if not isinstance(value, Mapping):
386
+ raise VerificationError(
387
+ f"the configuration the engine resolved has no {name!r} table, so "
388
+ "what it ran cannot be established",
389
+ code=OBSERVED_CONFIGURATION_MISMATCH,
390
+ details={"table": name},
391
+ )
392
+ return value
393
+
394
+
395
+ def _set_knobs(sampling: Mapping[str, Any]) -> dict[str, JsonValue]:
396
+ """Return only the knobs a rollout actually recorded a value for.
397
+
398
+ The same rule as `_sampling_of`, applied to the other side, so neither
399
+ side can make "not set" look like a difference.
400
+ """
401
+ return {str(k): v for k, v in sorted(sampling.items()) if v is not None}
402
+
403
+
404
+ def _sampling_of(resolved_config: Mapping[str, Any]) -> dict[str, JsonValue]:
405
+ """Return the sampling knobs the engine wrote back out, as set values.
406
+
407
+ A knob the engine resolved to null was not set, and a rollout that omits
408
+ the knob is recording the same fact in fewer words. Keeping the nulls made
409
+ the two spellings of "not set" compare unequal, which failed a real paid
410
+ run whose sampling was in fact identical on both sides: the engine had
411
+ grown two nullable knobs the rollouts do not mention.
412
+
413
+ Dropping them narrows nothing. A knob with a value on one side and not the
414
+ other still differs, and two knobs with different values still differ -
415
+ the only comparison that stops failing is the one that was never a
416
+ difference.
417
+ """
418
+ sampling = _table(resolved_config, "sampling")
419
+ values: dict[str, JsonValue] = {}
420
+ for key, value in sorted(sampling.items()):
421
+ if value is None:
422
+ continue
423
+ if not isinstance(value, str | int | float | bool):
424
+ raise VerificationError(
425
+ "the resolved sampling parameters hold a value that is not a "
426
+ "scalar, so they cannot be fingerprinted",
427
+ code=OBSERVED_CONFIGURATION_MISMATCH,
428
+ details={"key": str(key)},
429
+ )
430
+ values[str(key)] = value
431
+ if not values:
432
+ raise VerificationError(
433
+ "the configuration the engine resolved records no sampling "
434
+ "parameters, so how the subject was sampled is unknown",
435
+ code=OBSERVED_CONFIGURATION_MISMATCH,
436
+ details={},
437
+ )
438
+ return values
439
+
440
+
441
+ def _mounted_skill_digests(resolved_config: Mapping[str, Any]) -> list[Digest]:
442
+ """Recover the content address of every skill the engine mounted."""
443
+ harness = _table(_subject_of(resolved_config), "harness")
444
+ mounted = harness.get("skills", [])
445
+ if not isinstance(mounted, list):
446
+ raise VerificationError(
447
+ "the resolved harness does not list the skills it mounted",
448
+ code=OBSERVED_CONFIGURATION_MISMATCH,
449
+ details={},
450
+ )
451
+
452
+ digests: list[Digest] = []
453
+ for entry in mounted:
454
+ name = Path(str(entry)).name
455
+ if not name.startswith(_DIGEST_DIRECTORY_PREFIX):
456
+ raise VerificationError(
457
+ "the engine mounted a skill from a directory that is not named "
458
+ "after the skill's content, so what the subject read cannot be "
459
+ "identified",
460
+ code=OBSERVED_CONFIGURATION_MISMATCH,
461
+ details={"directory": name},
462
+ )
463
+ digests.append(validate_digest(name.replace("-", ":", 1)))
464
+ return digests
465
+
466
+
467
+ def _digest_detail(digests: Sequence[Digest]) -> list[JsonValue]:
468
+ """Return a digest list in the shape typed error details carry."""
469
+ return [value for value in digests]
470
+
471
+
472
+ def _text_detail(values: Iterable[object]) -> list[JsonValue]:
473
+ """Return an ordered, printable list in the shape error details carry."""
474
+ return [text for text in sorted(str(value) for value in values)]