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,52 @@
1
+ """Turning a real evaluation into receipts. Spec sections 7.6-7.8.
2
+
3
+ This package is where measured evidence becomes a Techtree claim. Everything
4
+ above it — the child processes, the containers, the provider calls — has
5
+ finished by the time anything here runs, and nothing here may change what was
6
+ measured. Three rules shape every module:
7
+
8
+ *The engine's normalizer is the boundary.* Nothing in this package reads
9
+ ``traces.jsonl``. The raw upstream record is read once, by the pinned Verifiers
10
+ build that wrote it, inside the engine bundle (spec section 6.12); this package
11
+ reads that projection and no other source. A receipt built by re-parsing
12
+ upstream wire records with a different library version would be an
13
+ interpretation wearing the authority of a measurement.
14
+
15
+ *Rewards are copied, never computed.* A reward reaches a receipt exactly as
16
+ Verifiers recorded it — not rescored, not rounded, not re-weighted. The only
17
+ decisions taken here are about *status*: whether the number may be presented as
18
+ a result at all.
19
+
20
+ *A structural doubt is a refusal, a recorded failure is a status.* An
21
+ evaluation missing a committed task, scoring one twice, or carrying a trace in
22
+ the wrong seat cannot be turned into receipts at all, and those are the typed
23
+ refusals of spec section 15. An episode that genuinely failed, or a trace whose
24
+ scoring genuinely errored, is recorded with an honest ``score_status`` — the
25
+ receipt exists and says what happened.
26
+
27
+ The division of labour:
28
+
29
+ ``episode``
30
+ The parsing boundary and one immutable receipt per committed task.
31
+ ``set``
32
+ The ordered, content-addressed commitment over one variant's receipts.
33
+ ``observed``
34
+ What the engine actually resolved, fingerprinted for comparison against
35
+ what the manifest declared.
36
+ ``compare``
37
+ Whether the two variants' executions were one experiment: declared against
38
+ declared, observed against observed, and observed against declared.
39
+ ``uplift``
40
+ The paired rewards, the aggregate over them, and the report — which is the
41
+ only place in this package where two variants meet.
42
+ ``bundle``
43
+ The portable proof: which documents travel, and the decisions-0005 section
44
+ 3.4 conditions a report has to establish before it may be graded.
45
+ ``verify``
46
+ Reading one of those bundles back and answering, offline and from its own
47
+ bytes, whether it still holds together.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ __all__: list[str] = []
@@ -0,0 +1,578 @@
1
+ """The portable local proof. Spec section 7.11.
2
+
3
+ A run directory holds everything: inputs, raw upstream traces, container logs,
4
+ the worker's own log. Most of it may not leave the machine — the DataPolicy
5
+ prohibits uploading or publishing raw episodes — and most of it is not what a
6
+ reader checking a claim needs. The proof bundle is the subset that travels:
7
+
8
+ ```text
9
+ runs/<run-id>/proof/
10
+ ├── bundle.json signed manifest over everything below
11
+ ├── executor-public.json the key the participant signed with
12
+ ├── campaign.json
13
+ ├── data-policy.json
14
+ ├── taskset-lock.json
15
+ ├── taskset-validation-receipt.json
16
+ ├── baseline-experiment.json
17
+ ├── candidate-experiment.json
18
+ ├── baseline-receipt-set.json
19
+ ├── candidate-receipt-set.json
20
+ ├── receipts/{baseline,candidate}/NNNN.json signed EpisodeReceipt envelopes
21
+ ├── comparison-execution.json the signed ComparisonExecutionRecord,
22
+ │ present only when the run recorded one
23
+ └── uplift-report.json the signed UpliftReport envelope
24
+ ```
25
+
26
+ ``comparison-execution.json`` is the one optional member. Decisions document
27
+ 0007 R6 makes it the operational record of what the comparison *consumed* —
28
+ cost, timing, the provider's own figures — rather than of what it measured, so
29
+ a run whose economics could not be recorded still has a complete proof of its
30
+ result. It is listed here because it is written, verified and covered by the
31
+ signed manifest exactly like the others when it is there.
32
+
33
+ Raw ``traces.jsonl``, the upstream log and the worker log stay behind. The
34
+ receipts cite their digests, so a reader holding the run directory can check
35
+ them; a reader holding only the bundle cannot, and the bundle never pretends
36
+ otherwise.
37
+
38
+ Three decisions shape this module.
39
+
40
+ *Every file is canonical bytes.* The digest of a bundle file and the digest of
41
+ the object inside it are the same number, so verification hashes what is on
42
+ disk rather than what a parser reconstructed. That is what "verifiable from
43
+ exact stored bytes" has to mean to be worth saying.
44
+
45
+ *The manifest is signed too.* Section 7.11 describes ``bundle.json`` as the
46
+ manifest; it is stored as a signed envelope around it. Without that, the one
47
+ thing the artifact digests cannot protect is the artifact list itself: a
48
+ bundle whose manifest was rewritten to name a different set of files — a
49
+ report from another run of the same participant, say — would otherwise verify.
50
+ Signing it binds the list to the same key as everything it lists.
51
+
52
+ *P1 is a conclusion, never an assumption.* :data:`P1_CONDITIONS` is the
53
+ decisions-0005 section 3.4 list, spelled once. It is evaluated twice on
54
+ purpose: :func:`assess_local_attestation` decides, before a report exists,
55
+ whether this run is entitled to a signed grade at all, and
56
+ :func:`techtree.receipts.verify.verify_local_bundle` re-derives every condition
57
+ from the written bytes afterwards. A report claiming P1 whose bundle does not
58
+ re-establish all six is not recorded as a result.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ from collections.abc import Mapping, Sequence
64
+ from dataclasses import dataclass
65
+ from pathlib import Path
66
+ from typing import Final, Literal, Self
67
+
68
+ from pydantic import BaseModel, model_validator
69
+
70
+ from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
71
+ from techtree.fs import atomic_write_bytes, ensure_private_directory
72
+ from techtree.identity.models import ExecutorIdentity
73
+ from techtree.identity.service import IdentityService, verify_signed_object
74
+ from techtree.models.base import (
75
+ ArtifactRef,
76
+ Digest,
77
+ NonEmptyString,
78
+ ObjectEnvelope,
79
+ ProtocolModel,
80
+ )
81
+ from techtree.models.campaign import CampaignSpec
82
+ from techtree.models.data_policy import DataPolicy
83
+ from techtree.models.episode_receipt import EpisodeReceipt, ScoreStatus
84
+ from techtree.models.experiment import ExperimentManifest, ExperimentVariant
85
+ from techtree.models.uplift_report import ComparisonStatus, UpliftReport
86
+ from techtree.models.validation import TasksetLock, TasksetValidationReceipt
87
+ from techtree.receipts.execution import (
88
+ EXECUTION_RECORD_FILENAME,
89
+ ComparisonExecutionRecord,
90
+ )
91
+ from techtree.receipts.set import ReceiptSetManifest
92
+ from techtree.receipts.uplift import LocalAttestation
93
+
94
+ __all__ = [
95
+ "BUNDLE_DIRECTORY",
96
+ "BUNDLE_MANIFEST_FILENAME",
97
+ "BUNDLE_MEDIA_TYPE",
98
+ "LOCAL_PROOF_BUNDLE_SCHEMA_VERSION",
99
+ "P1_ARTIFACT_DIGESTS_VERIFY",
100
+ "P1_COMPARISON_CONTROLLED",
101
+ "P1_CONDITIONS",
102
+ "P1_PUBLIC_KEY_PRESENT",
103
+ "P1_RECEIPTS_SIGNED",
104
+ "P1_REPORT_SIGNED",
105
+ "P1_SCORE_VALID",
106
+ "PROOF_BUNDLE_INVALID",
107
+ "PUBLIC_IDENTITY_FILENAME",
108
+ "REPORT_FILENAME",
109
+ "AttestationAssessment",
110
+ "LocalProofBundleContents",
111
+ "LocalProofBundleManifest",
112
+ "ProofCondition",
113
+ "ReferencedObject",
114
+ "assess_local_attestation",
115
+ "build_local_bundle",
116
+ "bundle_files",
117
+ "experiment_filename",
118
+ "proof_bundle_dir",
119
+ "receipt_filename",
120
+ "receipt_set_filename",
121
+ "write_local_bundle",
122
+ ]
123
+
124
+ #: A local bundle manifest, versioned as one: the frozen v0.1 protocol has no
125
+ #: proof-bundle object and inventing one would be an unratified amendment.
126
+ LOCAL_PROOF_BUNDLE_SCHEMA_VERSION: Final = "techtree.local-proof-bundle.v1alpha1"
127
+
128
+ #: Stable error code. Spec section 15.
129
+ PROOF_BUNDLE_INVALID: Final = "proof_bundle_invalid"
130
+
131
+ BUNDLE_DIRECTORY: Final = "proof"
132
+ BUNDLE_MANIFEST_FILENAME: Final = "bundle.json"
133
+ PUBLIC_IDENTITY_FILENAME: Final = "executor-public.json"
134
+ CAMPAIGN_FILENAME: Final = "campaign.json"
135
+ DATA_POLICY_FILENAME: Final = "data-policy.json"
136
+ TASKSET_LOCK_FILENAME: Final = "taskset-lock.json"
137
+ VALIDATION_RECEIPT_FILENAME: Final = "taskset-validation-receipt.json"
138
+ REPORT_FILENAME: Final = "uplift-report.json"
139
+ RECEIPTS_DIRECTORY: Final = "receipts"
140
+
141
+ BUNDLE_MEDIA_TYPE: Final = "application/json"
142
+
143
+ #: Receipt filenames are positional, so reading the directory in name order
144
+ #: reads the receipts back in the Campaign's committed task order.
145
+ _POSITION_WIDTH: Final = 4
146
+
147
+ _PASSED: Final = "passed"
148
+ _FAILED: Final = "failed"
149
+
150
+ #: Both sides, always in comparison order, so a bundle written twice from the
151
+ #: same run places its files identically.
152
+ _VARIANT_ORDER: Final[tuple[ExperimentVariant, ...]] = (
153
+ ExperimentVariant.BASELINE,
154
+ ExperimentVariant.CANDIDATE,
155
+ )
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # The section 3.4 conditions, spelled once
159
+ # ---------------------------------------------------------------------------
160
+
161
+ #: "all referenced artifact digests verify"
162
+ P1_ARTIFACT_DIGESTS_VERIFY: Final = "artifact_digests_verify"
163
+ #: "all EpisodeReceipts are wrapped in signed ObjectEnvelopes"
164
+ P1_RECEIPTS_SIGNED: Final = "receipts_signed"
165
+ #: "the UpliftReport is wrapped in a signed ObjectEnvelope"
166
+ P1_REPORT_SIGNED: Final = "report_signed"
167
+ #: "the local public key is included in the bundle"
168
+ P1_PUBLIC_KEY_PRESENT: Final = "public_key_in_bundle"
169
+ #: "the comparison is controlled or controlled_with_warnings"
170
+ P1_COMPARISON_CONTROLLED: Final = "comparison_controlled"
171
+ #: "score status is valid"
172
+ P1_SCORE_VALID: Final = "score_valid"
173
+
174
+ #: Decisions document 0005 section 3.4, in the order it states them. Every one
175
+ #: is a condition on stored bytes; none of them is satisfied by a claim in a
176
+ #: document about itself.
177
+ P1_CONDITIONS: Final[tuple[str, ...]] = (
178
+ P1_ARTIFACT_DIGESTS_VERIFY,
179
+ P1_RECEIPTS_SIGNED,
180
+ P1_REPORT_SIGNED,
181
+ P1_PUBLIC_KEY_PRESENT,
182
+ P1_COMPARISON_CONTROLLED,
183
+ P1_SCORE_VALID,
184
+ )
185
+
186
+
187
+ class ProofCondition(ProtocolModel):
188
+ """One decisions-0005 section 3.4 condition and whether it holds."""
189
+
190
+ id: NonEmptyString
191
+ status: Literal["passed", "failed"]
192
+ detail: NonEmptyString
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class AttestationAssessment:
197
+ """Whether this run may sign its report into a P1 grade, and why."""
198
+
199
+ attestation: LocalAttestation
200
+ conditions: list[ProofCondition]
201
+
202
+ @property
203
+ def failures(self) -> list[ProofCondition]:
204
+ """Return every condition that does not hold."""
205
+ return [
206
+ condition for condition in self.conditions if condition.status == _FAILED
207
+ ]
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class ReferencedObject:
212
+ """One object a report cites, together with the digest it cites it under."""
213
+
214
+ label: str
215
+ value: BaseModel
216
+ expected: Digest
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # The manifest
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ class LocalProofBundleManifest(ProtocolModel):
225
+ """Everything one portable local proof carries, committed to by digest."""
226
+
227
+ schema_version: Literal["techtree.local-proof-bundle.v1alpha1"]
228
+ run_id: NonEmptyString
229
+ campaign_spec_digest: Digest
230
+ data_policy_digest: Digest
231
+ executor_identity: ExecutorIdentity
232
+ artifacts: list[ArtifactRef]
233
+ root_report_digest: Digest
234
+
235
+ @model_validator(mode="after")
236
+ def _check_every_artifact_is_placed_once(self) -> Self:
237
+ """Reject a manifest that names a file twice or names none."""
238
+ paths = [artifact.relative_path for artifact in self.artifacts]
239
+ if not paths:
240
+ raise ValueError("a proof bundle commits to at least one artifact")
241
+ if any(path is None for path in paths):
242
+ raise ValueError(
243
+ "every artifact in a proof bundle is placed by relative path; "
244
+ "a digest with nowhere to look is not checkable"
245
+ )
246
+ if len(set(paths)) != len(paths):
247
+ raise ValueError("a proof bundle places each artifact exactly once")
248
+ return self
249
+
250
+ def artifact(self, relative_path: str) -> ArtifactRef | None:
251
+ """Return one placed artifact, or ``None`` when it is not carried."""
252
+ for reference in self.artifacts:
253
+ if reference.relative_path == relative_path:
254
+ return reference
255
+ return None
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class LocalProofBundleContents:
260
+ """The objects one bundle is written from.
261
+
262
+ The receipts and the report arrive already signed. This module places
263
+ bytes; it does not decide what is worth signing, and it never holds private
264
+ key material.
265
+ """
266
+
267
+ identity: ExecutorIdentity
268
+ campaign: CampaignSpec
269
+ data_policy: DataPolicy
270
+ taskset_lock: TasksetLock
271
+ validation_receipt: TasksetValidationReceipt
272
+ experiments: Mapping[ExperimentVariant, ExperimentManifest]
273
+ receipt_sets: Mapping[ExperimentVariant, ReceiptSetManifest]
274
+ receipts: Mapping[ExperimentVariant, Sequence[ObjectEnvelope[EpisodeReceipt]]]
275
+ report: ObjectEnvelope[UpliftReport]
276
+ #: Decisions document 0007 R6's operational record, already signed. It is
277
+ #: optional because it describes what the comparison consumed rather than
278
+ #: what it measured: a run that could not record its economics still has a
279
+ #: complete proof of its result, and R6 forbids treating one as the other.
280
+ execution_record: ObjectEnvelope[ComparisonExecutionRecord] | None = None
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Placement
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ def proof_bundle_dir(run_root: Path) -> Path:
289
+ """Return where one run's portable proof lives."""
290
+ return run_root / BUNDLE_DIRECTORY
291
+
292
+
293
+ def experiment_filename(variant: ExperimentVariant) -> str:
294
+ """Return one variant's experiment manifest filename."""
295
+ return f"{variant.value}-experiment.json"
296
+
297
+
298
+ def receipt_set_filename(variant: ExperimentVariant) -> str:
299
+ """Return one variant's receipt-set manifest filename."""
300
+ return f"{variant.value}-receipt-set.json"
301
+
302
+
303
+ def receipt_filename(variant: ExperimentVariant, position: int) -> str:
304
+ """Return one receipt's placement inside the bundle."""
305
+ return f"{RECEIPTS_DIRECTORY}/{variant.value}/{position:0{_POSITION_WIDTH}d}.json"
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Assembly
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ def bundle_files(contents: LocalProofBundleContents) -> dict[str, bytes]:
314
+ """Return every bundle file except the manifest, as canonical bytes.
315
+
316
+ The manifest is excluded because it commits to these: a file cannot carry
317
+ the digest of a document that carries its digest.
318
+ """
319
+ files: dict[str, bytes] = {
320
+ PUBLIC_IDENTITY_FILENAME: canonical_json_bytes(contents.identity),
321
+ CAMPAIGN_FILENAME: canonical_json_bytes(contents.campaign),
322
+ DATA_POLICY_FILENAME: canonical_json_bytes(contents.data_policy),
323
+ TASKSET_LOCK_FILENAME: canonical_json_bytes(contents.taskset_lock),
324
+ VALIDATION_RECEIPT_FILENAME: canonical_json_bytes(contents.validation_receipt),
325
+ REPORT_FILENAME: canonical_json_bytes(contents.report),
326
+ }
327
+ if contents.execution_record is not None:
328
+ files[EXECUTION_RECORD_FILENAME] = canonical_json_bytes(
329
+ contents.execution_record
330
+ )
331
+ for variant in _VARIANT_ORDER:
332
+ files[experiment_filename(variant)] = canonical_json_bytes(
333
+ contents.experiments[variant]
334
+ )
335
+ files[receipt_set_filename(variant)] = canonical_json_bytes(
336
+ contents.receipt_sets[variant]
337
+ )
338
+ for position, envelope in enumerate(contents.receipts[variant]):
339
+ files[receipt_filename(variant, position)] = canonical_json_bytes(envelope)
340
+ return files
341
+
342
+
343
+ def build_local_bundle(
344
+ *, run_id: str, contents: LocalProofBundleContents
345
+ ) -> LocalProofBundleManifest:
346
+ """Return the manifest committing to every file this bundle carries."""
347
+ files = bundle_files(contents)
348
+ return LocalProofBundleManifest(
349
+ schema_version=LOCAL_PROOF_BUNDLE_SCHEMA_VERSION,
350
+ run_id=run_id,
351
+ campaign_spec_digest=digest_object(contents.campaign),
352
+ data_policy_digest=digest_object(contents.data_policy),
353
+ executor_identity=contents.identity,
354
+ artifacts=[
355
+ ArtifactRef(
356
+ digest=sha256_digest_bytes(data),
357
+ media_type=BUNDLE_MEDIA_TYPE,
358
+ size=len(data),
359
+ relative_path=relative_path,
360
+ )
361
+ for relative_path, data in sorted(files.items())
362
+ ],
363
+ root_report_digest=contents.report.payload_digest,
364
+ )
365
+
366
+
367
+ def write_local_bundle(
368
+ *,
369
+ run_root: Path,
370
+ contents: LocalProofBundleContents,
371
+ identity_service: IdentityService,
372
+ ) -> Path:
373
+ """Write the bundle and its signed manifest, and return the directory.
374
+
375
+ The manifest is written last. A bundle interrupted halfway has no manifest
376
+ and is therefore not a bundle, which is a better outcome than one that
377
+ names files that are not there yet.
378
+ """
379
+ directory = proof_bundle_dir(run_root)
380
+ ensure_private_directory(directory)
381
+
382
+ for relative_path, data in sorted(bundle_files(contents).items()):
383
+ destination = directory / relative_path
384
+ ensure_private_directory(destination.parent)
385
+ atomic_write_bytes(destination, data)
386
+
387
+ manifest = build_local_bundle(
388
+ run_id=contents.report.payload.run_id, contents=contents
389
+ )
390
+ sealed = identity_service.sign_object(manifest)
391
+ atomic_write_bytes(
392
+ directory / BUNDLE_MANIFEST_FILENAME, canonical_json_bytes(sealed)
393
+ )
394
+ return directory
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # The section 3.4 assessment
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ def assess_local_attestation(
403
+ *,
404
+ identity: ExecutorIdentity | None,
405
+ identity_self_check: bool,
406
+ referenced_objects: Sequence[ReferencedObject],
407
+ signed_receipts: Mapping[
408
+ ExperimentVariant, Sequence[ObjectEnvelope[EpisodeReceipt]]
409
+ ],
410
+ comparison: ComparisonStatus,
411
+ score: ScoreStatus,
412
+ ) -> AttestationAssessment:
413
+ """Decide whether this run's report may be signed into a P1 grade.
414
+
415
+ Five of the six section 3.4 conditions can be established before a report
416
+ exists, and they are established here, from the objects themselves rather
417
+ than from anything anyone recorded about them. The sixth — that the report
418
+ travels in a signed envelope — is a fact about a document that does not
419
+ exist yet; it is settled by verifying the written bundle before the report
420
+ is recorded as the run's result, and it is reported here as pending on the
421
+ same identity that would sign it.
422
+
423
+ Any failure returns :attr:`~techtree.receipts.uplift.LocalAttestation.
424
+ UNATTESTED`, which is what makes the report withhold its verdict instead of
425
+ claiming a grade it did not earn.
426
+ """
427
+ conditions = [
428
+ _artifact_digests_condition(referenced_objects),
429
+ _receipts_signed_condition(identity, signed_receipts),
430
+ _report_signed_condition(identity, identity_self_check),
431
+ _public_key_condition(identity, identity_self_check),
432
+ _comparison_condition(comparison),
433
+ _score_condition(score),
434
+ ]
435
+ failed = [condition for condition in conditions if condition.status == _FAILED]
436
+ return AttestationAssessment(
437
+ attestation=(
438
+ LocalAttestation.UNATTESTED if failed else LocalAttestation.LOCAL_ED25519
439
+ ),
440
+ conditions=conditions,
441
+ )
442
+
443
+
444
+ def _artifact_digests_condition(
445
+ referenced: Sequence[ReferencedObject],
446
+ ) -> ProofCondition:
447
+ """Require every object the report cites to re-digest to the cited digest."""
448
+ if not referenced:
449
+ return ProofCondition(
450
+ id=P1_ARTIFACT_DIGESTS_VERIFY,
451
+ status=_FAILED,
452
+ detail="no referenced artifact was offered for checking",
453
+ )
454
+ broken = [
455
+ item.label for item in referenced if digest_object(item.value) != item.expected
456
+ ]
457
+ if broken:
458
+ return ProofCondition(
459
+ id=P1_ARTIFACT_DIGESTS_VERIFY,
460
+ status=_FAILED,
461
+ detail=(
462
+ "these objects no longer match the digests this run cites them "
463
+ f"under: {', '.join(sorted(broken))}"
464
+ ),
465
+ )
466
+ return ProofCondition(
467
+ id=P1_ARTIFACT_DIGESTS_VERIFY,
468
+ status=_PASSED,
469
+ detail=f"{len(referenced)} referenced artifact digests recomputed and matched",
470
+ )
471
+
472
+
473
+ def _receipts_signed_condition(
474
+ identity: ExecutorIdentity | None,
475
+ signed_receipts: Mapping[
476
+ ExperimentVariant, Sequence[ObjectEnvelope[EpisodeReceipt]]
477
+ ],
478
+ ) -> ProofCondition:
479
+ """Require every receipt to travel in an envelope this key's signature verifies."""
480
+ if identity is None:
481
+ return ProofCondition(
482
+ id=P1_RECEIPTS_SIGNED,
483
+ status=_FAILED,
484
+ detail="there is no local identity, so no receipt is signed",
485
+ )
486
+ total = 0
487
+ unverified: list[str] = []
488
+ for variant in _VARIANT_ORDER:
489
+ for position, envelope in enumerate(signed_receipts.get(variant, ())):
490
+ total += 1
491
+ result = verify_signed_object(identity=identity, envelope=envelope)
492
+ if not result.verified:
493
+ unverified.append(f"{variant.value}/{position}")
494
+ if not total:
495
+ return ProofCondition(
496
+ id=P1_RECEIPTS_SIGNED,
497
+ status=_FAILED,
498
+ detail="this comparison has no receipts to sign",
499
+ )
500
+ if unverified:
501
+ return ProofCondition(
502
+ id=P1_RECEIPTS_SIGNED,
503
+ status=_FAILED,
504
+ detail=(
505
+ "these receipts are not sealed by a verifying signature: "
506
+ + ", ".join(unverified)
507
+ ),
508
+ )
509
+ return ProofCondition(
510
+ id=P1_RECEIPTS_SIGNED,
511
+ status=_PASSED,
512
+ detail=f"{total} receipts travel in signed envelopes that verify",
513
+ )
514
+
515
+
516
+ def _report_signed_condition(
517
+ identity: ExecutorIdentity | None, self_check: bool
518
+ ) -> ProofCondition:
519
+ """Require a usable key for the report envelope that is about to be made."""
520
+ if identity is None or not self_check:
521
+ return ProofCondition(
522
+ id=P1_REPORT_SIGNED,
523
+ status=_FAILED,
524
+ detail=("no usable local identity is available to sign this run's report"),
525
+ )
526
+ return ProofCondition(
527
+ id=P1_REPORT_SIGNED,
528
+ status=_PASSED,
529
+ detail=(
530
+ f"the report is signed under key {identity.key_id}, and the written "
531
+ "bundle is verified before the report is recorded"
532
+ ),
533
+ )
534
+
535
+
536
+ def _public_key_condition(
537
+ identity: ExecutorIdentity | None, self_check: bool
538
+ ) -> ProofCondition:
539
+ """Require the public half to exist and to match the key that signs."""
540
+ if identity is None:
541
+ return ProofCondition(
542
+ id=P1_PUBLIC_KEY_PRESENT,
543
+ status=_FAILED,
544
+ detail="this machine has no local identity to include in a bundle",
545
+ )
546
+ if not self_check:
547
+ return ProofCondition(
548
+ id=P1_PUBLIC_KEY_PRESENT,
549
+ status=_FAILED,
550
+ detail=("the stored public key does not describe the key that would sign"),
551
+ )
552
+ return ProofCondition(
553
+ id=P1_PUBLIC_KEY_PRESENT,
554
+ status=_PASSED,
555
+ detail=f"public key {identity.key_id} travels with the proof",
556
+ )
557
+
558
+
559
+ def _comparison_condition(comparison: ComparisonStatus) -> ProofCondition:
560
+ """Require a controlled comparison, warnings included."""
561
+ controlled = comparison in (
562
+ ComparisonStatus.CONTROLLED,
563
+ ComparisonStatus.CONTROLLED_WITH_WARNINGS,
564
+ )
565
+ return ProofCondition(
566
+ id=P1_COMPARISON_CONTROLLED,
567
+ status=_PASSED if controlled else _FAILED,
568
+ detail=f"the comparison is {comparison.value}",
569
+ )
570
+
571
+
572
+ def _score_condition(score: ScoreStatus) -> ProofCondition:
573
+ """Require valid scores."""
574
+ return ProofCondition(
575
+ id=P1_SCORE_VALID,
576
+ status=_PASSED if score is ScoreStatus.VALID else _FAILED,
577
+ detail=f"the recorded score status is {score.value}",
578
+ )