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,484 @@
1
+ """Locking a taskset and proving it valid. Spec section 21.5.
2
+
3
+ One question runs through this module: *is the thing about to be scored the
4
+ thing that was committed to, and is it mechanically sound?* Answering it takes
5
+ two independent halves, and the shape of the code follows that split.
6
+
7
+ The first half is identity. :class:`~techtree.tasksets.resolver.TasksetResolver`
8
+ loads the taskset twice in fresh processes and produces a
9
+ :class:`~techtree.models.validation.TasksetLock`; this module compares that lock
10
+ with the membership a Campaign committed to.
11
+
12
+ The second half is soundness. The pinned Verifiers validator runs every task's
13
+ gold and setup check with no model anywhere, the engine normalizes its output
14
+ into deterministic evidence, and the counts become the mechanical checks a
15
+ :class:`~techtree.models.validation.TasksetValidationReceipt` carries.
16
+
17
+ **Nothing that varies between two correct runs enters the receipt.** Decisions
18
+ document 0003 A1 made the receipt deterministic so that the publisher's receipt
19
+ and a participant's recomputed receipt are *equal*, and that equality is the
20
+ comparison. Everything a real execution also produces — when it started, on
21
+ which host, under which command, and the raw files it left behind — goes into a
22
+ :class:`~techtree.models.validation.ValidationExecutionRecord` instead, which is
23
+ local, never shipped, and never byte-compared.
24
+
25
+ The consequence for callers is worth stating plainly: this service is used
26
+ unchanged by the publisher's catalog generator and by a participant's worker.
27
+ There is no publisher mode. If the two ever produced different receipts for the
28
+ same taskset, that would be the finding, not a detail to reconcile.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import platform
35
+ import sys
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass
38
+ from datetime import UTC, datetime
39
+ from pathlib import Path
40
+ from typing import Final, Literal
41
+
42
+ from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
43
+ from techtree.constants import (
44
+ TASKSET_VALIDATION_SCHEMA_VERSION,
45
+ VALIDATION_EXECUTION_SCHEMA_VERSION,
46
+ )
47
+ from techtree.engines.registry import EngineRegistry
48
+ from techtree.errors import PrerequisiteError, VerificationError
49
+ from techtree.fs import atomic_write_bytes, ensure_private_directory
50
+ from techtree.models.base import ArtifactRef, Digest
51
+ from techtree.models.campaign import CampaignSpec, TaskSelection, TasksetRef
52
+ from techtree.models.engine import normalize_host_platform
53
+ from techtree.models.validation import (
54
+ REQUIRED_VALIDATION_CHECKS,
55
+ TasksetLock,
56
+ TasksetValidationReceipt,
57
+ UpstreamValidationSummary,
58
+ ValidationCheck,
59
+ ValidationEvidence,
60
+ ValidationExecutionRecord,
61
+ ValidationMethod,
62
+ validation_display_id,
63
+ )
64
+ from techtree.tasksets.membership import (
65
+ MEMBERSHIP_MATCH_CHECK,
66
+ MEMBERSHIP_REPEATABILITY_CHECK,
67
+ compare_membership,
68
+ )
69
+ from techtree.tasksets.resolver import TasksetResolver
70
+ from techtree.tasksets.verifiers_cli import (
71
+ GOLD_CHECK,
72
+ SETUP_CHECK,
73
+ UpstreamCheckCounts,
74
+ VerifiersValidationRunner,
75
+ )
76
+
77
+ __all__ = [
78
+ "EVIDENCE_FILENAME",
79
+ "EXECUTION_RECORD_FILENAME",
80
+ "LOCK_FILENAME",
81
+ "RECEIPT_FILENAME",
82
+ "TASKSET_DIRECTORY",
83
+ "VALIDATION_DIRECTORY",
84
+ "TasksetService",
85
+ "TasksetValidationRun",
86
+ ]
87
+
88
+ #: Where a run keeps everything about its taskset. Spec section 28.
89
+ TASKSET_DIRECTORY: Final = "taskset"
90
+ VALIDATION_DIRECTORY: Final = "validation"
91
+
92
+ LOCK_FILENAME: Final = "lock.json"
93
+ RECEIPT_FILENAME: Final = "receipt.json"
94
+ #: Decisions document 0003 A1 added the normalized evidence and the execution
95
+ #: record after spec section 28 was written. Both live beside the receipt: the
96
+ #: evidence because the receipt points at it, the record because it is the raw
97
+ #: provenance of the same execution.
98
+ EVIDENCE_FILENAME: Final = "evidence.json"
99
+ EXECUTION_RECORD_FILENAME: Final = "execution.json"
100
+
101
+ _JSON_MEDIA_TYPE: Final = "application/json"
102
+
103
+ #: The two mechanical checks that come from the validator itself.
104
+ _UPSTREAM_GOLD_CHECK: Final = "upstream_gold"
105
+ _UPSTREAM_SETUP_CHECK: Final = "upstream_setup"
106
+ _UNIQUENESS_CHECK: Final = "task_hash_uniqueness"
107
+ _TASK_COUNT_CHECK: Final = "expected_task_count"
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class TasksetValidationRun:
112
+ """Everything one validation produced, scientific and operational.
113
+
114
+ Spec section 21.5 has ``resolve_and_validate`` return a lock and a receipt.
115
+ Decisions document 0003 A1 added two more objects with distinct lifetimes —
116
+ the shipped evidence a receipt points at, and the local record of the
117
+ execution — so they are returned together rather than fetched afterwards
118
+ from paths a caller would have to reconstruct.
119
+ """
120
+
121
+ lock: TasksetLock
122
+ receipt: TasksetValidationReceipt
123
+ evidence: ValidationEvidence
124
+ execution_record: ValidationExecutionRecord
125
+
126
+ @property
127
+ def receipt_digest(self) -> Digest:
128
+ """Return the receipt's content digest, which is its identity."""
129
+ return digest_object(self.receipt)
130
+
131
+
132
+ class TasksetService:
133
+ """Resolves, validates, and issues a receipt for one taskset.
134
+
135
+ The service is bound to one installed engine. Which engine that is comes
136
+ from the caller — a participant uses the one the publisher's receipt names,
137
+ so that a difference in receipts can only ever be a difference in the
138
+ taskset.
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ registry: EngineRegistry,
144
+ engine_digest: Digest,
145
+ ) -> None:
146
+ self._registry = registry
147
+ self._engine_digest = engine_digest
148
+ self._resolver = TasksetResolver(registry, engine_digest)
149
+ self._validator = VerifiersValidationRunner(registry, engine_digest)
150
+
151
+ # -- the two halves, and the whole -------------------------------------
152
+
153
+ def resolve(
154
+ self,
155
+ *,
156
+ taskset_ref: TasksetRef,
157
+ selection: TaskSelection,
158
+ ) -> TasksetLock:
159
+ """Return what this reference resolves to against this engine.
160
+
161
+ Exposed separately because the publisher has nothing to compare a lock
162
+ with yet: the Campaign that will commit to a membership is built from
163
+ this lock, not the other way round.
164
+ """
165
+ return self._resolver.resolve(taskset_ref=taskset_ref, selection=selection)
166
+
167
+ def validate(
168
+ self,
169
+ *,
170
+ lock: TasksetLock,
171
+ committed_task_hashes: Sequence[Digest],
172
+ expected_task_count: int,
173
+ run_dir: Path,
174
+ ) -> TasksetValidationRun:
175
+ """Run the pinned validation against an already-resolved lock."""
176
+ taskset = run_dir / TASKSET_DIRECTORY
177
+ output_dir = taskset / VALIDATION_DIRECTORY
178
+ ensure_private_directory(taskset)
179
+ ensure_private_directory(output_dir)
180
+
181
+ self._write_lock(run_dir, lock)
182
+
183
+ started_at = datetime.now(UTC)
184
+ process = self._validator.run(
185
+ taskset_id=lock.taskset_ref.id,
186
+ num_tasks=lock.task_count,
187
+ output_dir=output_dir,
188
+ )
189
+ finished_at = datetime.now(UTC)
190
+
191
+ summary = self._validator.parse_summary(output_dir)
192
+ check_counts = self._validator.parse_check_counts(output_dir)
193
+ evidence = self._validator.normalize(
194
+ output_dir, taskset_lock_digest=digest_object(lock)
195
+ )
196
+
197
+ checks = self._build_checks(
198
+ lock=lock,
199
+ committed_task_hashes=committed_task_hashes,
200
+ expected_task_count=expected_task_count,
201
+ check_counts=check_counts,
202
+ )
203
+ receipt = TasksetValidationReceipt(
204
+ schema_version=TASKSET_VALIDATION_SCHEMA_VERSION,
205
+ taskset_lock_digest=digest_object(lock),
206
+ engine_digest=lock.engine_digest,
207
+ method=ValidationMethod(
208
+ kind="verifiers_validate",
209
+ mode="all",
210
+ runtime="subprocess",
211
+ validator_revision=evidence.method.validator_revision,
212
+ ),
213
+ status=self._receipt_status(checks, summary),
214
+ upstream_summary=summary,
215
+ checks=checks,
216
+ normalized_evidence=_evidence_reference(evidence),
217
+ )
218
+
219
+ self._write_evidence(run_dir, evidence)
220
+ self._write_receipt(run_dir, receipt)
221
+
222
+ command = [str(part) for part in process.argv]
223
+ record = ValidationExecutionRecord(
224
+ schema_version=VALIDATION_EXECUTION_SCHEMA_VERSION,
225
+ id=validation_display_id(digest_object(receipt)),
226
+ receipt_digest=digest_object(receipt),
227
+ started_at=started_at,
228
+ finished_at=finished_at,
229
+ command=command,
230
+ command_digest=digest_object({"command": command}),
231
+ host_platform=_host_platform(),
232
+ worker_pid=os.getpid(),
233
+ raw_artifacts=self._validator.validation_artifacts(output_dir),
234
+ )
235
+ self._write_execution_record(run_dir, record)
236
+
237
+ return TasksetValidationRun(
238
+ lock=lock,
239
+ receipt=receipt,
240
+ evidence=evidence,
241
+ execution_record=record,
242
+ )
243
+
244
+ def resolve_and_validate(
245
+ self,
246
+ *,
247
+ campaign: CampaignSpec,
248
+ run_dir: Path,
249
+ ) -> TasksetValidationRun:
250
+ """Resolve this Campaign's taskset, validate it, and issue a receipt."""
251
+ lock = self.resolve(
252
+ taskset_ref=campaign.taskset.ref,
253
+ selection=campaign.taskset.selection,
254
+ )
255
+ return self.validate(
256
+ lock=lock,
257
+ committed_task_hashes=campaign.taskset.membership.ordered_task_hashes,
258
+ expected_task_count=campaign.taskset.selection.num_tasks,
259
+ run_dir=run_dir,
260
+ )
261
+
262
+ # -- the mechanical checks ---------------------------------------------
263
+
264
+ def _build_checks(
265
+ self,
266
+ *,
267
+ lock: TasksetLock,
268
+ committed_task_hashes: Sequence[Digest],
269
+ expected_task_count: int,
270
+ check_counts: dict[str, UpstreamCheckCounts],
271
+ ) -> list[ValidationCheck]:
272
+ """Build every check a receipt is required to report.
273
+
274
+ Every detail string is a function of the taskset alone. A path, a
275
+ duration, or a host name in any of them would make two correct receipts
276
+ for the same taskset differ, which is the one thing decisions document
277
+ 0003 A1 set out to prevent.
278
+ """
279
+ checks = [
280
+ _upstream_check(_UPSTREAM_GOLD_CHECK, check_counts.get(GOLD_CHECK)),
281
+ _upstream_check(_UPSTREAM_SETUP_CHECK, check_counts.get(SETUP_CHECK)),
282
+ ValidationCheck(
283
+ id=MEMBERSHIP_REPEATABILITY_CHECK,
284
+ status="passed",
285
+ detail=(
286
+ f"two independent inspections agreed on all {lock.task_count} "
287
+ "task hashes, in order"
288
+ ),
289
+ ),
290
+ _uniqueness_check(lock),
291
+ compare_membership(
292
+ list(lock.ordered_task_hashes),
293
+ list(committed_task_hashes),
294
+ check_id=MEMBERSHIP_MATCH_CHECK,
295
+ ),
296
+ _task_count_check(lock, expected_task_count),
297
+ ]
298
+
299
+ reported = {check.id for check in checks}
300
+ missing = [name for name in REQUIRED_VALIDATION_CHECKS if name not in reported]
301
+ if missing:
302
+ raise VerificationError(
303
+ "this validation reported no result for " + ", ".join(missing),
304
+ code="taskset_validation_incomplete",
305
+ details={"missing": list(missing)},
306
+ )
307
+ return checks
308
+
309
+ def _receipt_status(
310
+ self,
311
+ checks: Sequence[ValidationCheck],
312
+ summary: UpstreamValidationSummary,
313
+ ) -> Literal["valid", "invalid", "errored"]:
314
+ """Decide what the receipt claims about this taskset.
315
+
316
+ ``errored`` and ``invalid`` are different findings. A task whose check
317
+ raised, timed out, or never recorded a row says the validation did not
318
+ reach a verdict; a task that returned False says it reached one and the
319
+ answer was no. Reporting the first as the second would present an
320
+ unfinished run as a scientific result.
321
+ """
322
+ if summary.error or summary.timeout or summary.missing:
323
+ return "errored"
324
+ if summary.recorded != summary.total:
325
+ return "errored"
326
+ if summary.invalid or summary.valid != summary.total:
327
+ return "invalid"
328
+ if any(check.status == "failed" for check in checks):
329
+ return "invalid"
330
+ return "valid"
331
+
332
+ # -- persistence -------------------------------------------------------
333
+
334
+ def _write_lock(self, run_dir: Path, lock: TasksetLock) -> ArtifactRef:
335
+ """Persist the lock this run was issued under."""
336
+ return _write_object(run_dir / TASKSET_DIRECTORY / LOCK_FILENAME, lock, run_dir)
337
+
338
+ def _write_receipt(
339
+ self, run_dir: Path, receipt: TasksetValidationReceipt
340
+ ) -> ArtifactRef:
341
+ """Persist the receipt in exactly the bytes its digest is taken over."""
342
+ return _write_object(
343
+ run_dir / TASKSET_DIRECTORY / VALIDATION_DIRECTORY / RECEIPT_FILENAME,
344
+ receipt,
345
+ run_dir,
346
+ )
347
+
348
+ def _write_evidence(
349
+ self, run_dir: Path, evidence: ValidationEvidence
350
+ ) -> ArtifactRef:
351
+ """Persist the normalized evidence the receipt points at."""
352
+ return _write_object(
353
+ run_dir / TASKSET_DIRECTORY / VALIDATION_DIRECTORY / EVIDENCE_FILENAME,
354
+ evidence,
355
+ run_dir,
356
+ )
357
+
358
+ def _write_execution_record(
359
+ self, run_dir: Path, record: ValidationExecutionRecord
360
+ ) -> ArtifactRef:
361
+ """Persist the local provenance of this execution."""
362
+ return _write_object(
363
+ run_dir
364
+ / TASKSET_DIRECTORY
365
+ / VALIDATION_DIRECTORY
366
+ / EXECUTION_RECORD_FILENAME,
367
+ record,
368
+ run_dir,
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Check construction
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ def _upstream_check(
378
+ check_id: str, counts: UpstreamCheckCounts | None
379
+ ) -> ValidationCheck:
380
+ """Turn one of the validator's per-check breakdowns into a receipt check."""
381
+ if counts is None:
382
+ return ValidationCheck(
383
+ id=check_id,
384
+ status="failed",
385
+ detail=(
386
+ "the validator reported no per-check breakdown, so this check "
387
+ "cannot be read as either passed or failed"
388
+ ),
389
+ )
390
+ if counts.passed:
391
+ return ValidationCheck(
392
+ id=check_id,
393
+ status="passed",
394
+ detail=f"all {counts.valid} tasks passed this check",
395
+ )
396
+ return ValidationCheck(id=check_id, status="failed", detail=counts.summary())
397
+
398
+
399
+ def _uniqueness_check(lock: TasksetLock) -> ValidationCheck:
400
+ """Report whether any task appears twice in the locked membership."""
401
+ hashes = list(lock.ordered_task_hashes)
402
+ repeated = len(hashes) - len(set(hashes))
403
+ if repeated == 0:
404
+ return ValidationCheck(
405
+ id=_UNIQUENESS_CHECK,
406
+ status="passed",
407
+ detail=f"all {len(hashes)} task hashes are distinct",
408
+ )
409
+ return ValidationCheck(
410
+ id=_UNIQUENESS_CHECK,
411
+ status="failed",
412
+ detail=(
413
+ f"{repeated} of {len(hashes)} task hashes repeat, and a repeated "
414
+ "task would be scored twice under one commitment"
415
+ ),
416
+ )
417
+
418
+
419
+ def _task_count_check(lock: TasksetLock, expected: int) -> ValidationCheck:
420
+ """Report whether the lock holds as many tasks as were asked for."""
421
+ if lock.task_count == expected:
422
+ return ValidationCheck(
423
+ id=_TASK_COUNT_CHECK,
424
+ status="passed",
425
+ detail=f"{expected} tasks, as the selection asks for",
426
+ )
427
+ return ValidationCheck(
428
+ id=_TASK_COUNT_CHECK,
429
+ status="failed",
430
+ detail=(
431
+ f"the taskset resolved to {lock.task_count} tasks and the selection "
432
+ f"asks for {expected}"
433
+ ),
434
+ )
435
+
436
+
437
+ # ---------------------------------------------------------------------------
438
+ # Bytes
439
+ # ---------------------------------------------------------------------------
440
+
441
+
442
+ def _evidence_reference(evidence: ValidationEvidence) -> ArtifactRef:
443
+ """Return the reference a receipt carries to its normalized evidence.
444
+
445
+ No relative path. The receipt is a public object that travels inside a
446
+ content-addressed catalog, where an artifact is found by digest; a path
447
+ would be one machine's answer to a question the catalog answers for
448
+ everyone, and it would make the publisher's receipt and a participant's
449
+ differ over nothing.
450
+ """
451
+ data = canonical_json_bytes(evidence)
452
+ return ArtifactRef(
453
+ digest=sha256_digest_bytes(data),
454
+ media_type=_JSON_MEDIA_TYPE,
455
+ size=len(data),
456
+ relative_path=None,
457
+ )
458
+
459
+
460
+ def _write_object(path: Path, value: object, run_dir: Path) -> ArtifactRef:
461
+ """Write one object as the canonical bytes its digest is taken over."""
462
+ data = canonical_json_bytes(value)
463
+ ensure_private_directory(path.parent)
464
+ atomic_write_bytes(path, data)
465
+ return ArtifactRef(
466
+ digest=sha256_digest_bytes(data),
467
+ media_type=_JSON_MEDIA_TYPE,
468
+ size=len(data),
469
+ relative_path=path.relative_to(run_dir).as_posix(),
470
+ )
471
+
472
+
473
+ def _host_platform() -> str:
474
+ """Return this host in the protocol's vocabulary, or say it is unsupported.
475
+
476
+ Decisions document 0003 A9. An unsupported host still records *something*:
477
+ the execution record exists to say what happened, and refusing to write one
478
+ because the platform string is unfamiliar would lose the very provenance it
479
+ is for.
480
+ """
481
+ try:
482
+ return normalize_host_platform(sys.platform, platform.machine())
483
+ except PrerequisiteError:
484
+ return f"{sys.platform}/{platform.machine()}"