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,269 @@
1
+ """What tasks a Climb is about, stated so two parties can compare it.
2
+
3
+ Spec section 21.2.
4
+
5
+ Membership is an ordered list of task hashes and nothing else. The order is the
6
+ taskset's own iteration order, the first ``num_tasks`` of it, with no shuffle
7
+ anywhere in WP0–WP5 (decisions document 0001). Two things follow.
8
+
9
+ *Identity crosses the boundary; content does not.* The engine helper reports a
10
+ position, a hash, a name, and a class name. It never reports a prompt or an
11
+ answer, so a membership commitment can be published without publishing the
12
+ taskset. :func:`load_inspection_output` is where that report becomes typed
13
+ Techtree data, and it is the only place raw Verifiers hashes — bare
14
+ 64-character lowercase hexadecimal — turn into Techtree digests. The conversion
15
+ goes through :func:`~techtree.canonical.normalize_verifiers_task_hash`, which
16
+ accepts that one spelling and refuses everything else, including an
17
+ already-prefixed digest: a boundary that waved prefixed strings through would
18
+ be a place where unvalidated text could enter the protocol looking official.
19
+
20
+ *The digest is taken over a named object, not over a bare array.* Hashing the
21
+ list alone would give the same bytes to any other list of strings that happened
22
+ to hold the same values. Wrapping it in ``{"ordered_task_hashes": [...]}`` puts
23
+ the meaning of the array inside the hashed bytes, so a membership digest can
24
+ only ever be a membership digest.
25
+
26
+ :func:`compare_membership` reports; it does not decide. It returns the
27
+ ``ValidationCheck`` a receipt carries, naming the first position where two
28
+ memberships disagree, because "these two lists differ" is not something an
29
+ operator can act on and "position 7 is a different task" is.
30
+ :func:`assert_unique_task_hashes` is the one function here that refuses, since a
31
+ membership that names the same task twice would score it twice under one
32
+ commitment and there is no reading of that which is merely a warning.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from pathlib import Path
38
+ from typing import Annotated, Final, Literal, Self
39
+
40
+ from pydantic import AfterValidator, Field, model_validator
41
+
42
+ from techtree.canonical import (
43
+ digest_object,
44
+ normalize_verifiers_task_hash,
45
+ validate_digest,
46
+ )
47
+ from techtree.errors import ValidationError, VerificationError
48
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel
49
+ from techtree.models.validation import ValidationCheck
50
+
51
+ __all__ = [
52
+ "INSPECTION_SCHEMA_VERSION",
53
+ "MEMBERSHIP_DIGEST_KEY",
54
+ "MEMBERSHIP_MATCH_CHECK",
55
+ "MEMBERSHIP_REPEATABILITY_CHECK",
56
+ "TaskInspection",
57
+ "TasksetInspection",
58
+ "VerifiersTaskHash",
59
+ "assert_unique_task_hashes",
60
+ "compare_membership",
61
+ "load_inspection_output",
62
+ "membership_digest",
63
+ ]
64
+
65
+ #: The shape the engine helper writes. Owned by the engine bundle
66
+ #: (``tools/inspect_taskset.py``, decisions document 0003 A3); this module is
67
+ #: its reader, and reads exactly this version.
68
+ INSPECTION_SCHEMA_VERSION: Final = "techtree.taskset-inspection.v1"
69
+
70
+ #: The single key of the object a membership digest is taken over. Changing it
71
+ #: changes every membership digest in existence, which is why it is spelled
72
+ #: once, here.
73
+ MEMBERSHIP_DIGEST_KEY: Final = "ordered_task_hashes"
74
+
75
+ #: The check that two inspections of the same taskset agreed.
76
+ MEMBERSHIP_REPEATABILITY_CHECK: Final = "membership_repeatability"
77
+ #: The check that what was resolved is what the Campaign committed to.
78
+ MEMBERSHIP_MATCH_CHECK: Final = "committed_membership_match"
79
+
80
+
81
+ def _normalized_task_hash(value: str) -> Digest:
82
+ """Normalize at the boundary, in the vocabulary Pydantic understands."""
83
+ try:
84
+ return normalize_verifiers_task_hash(value)
85
+ except ValidationError as error:
86
+ raise ValueError(error.message) from error
87
+
88
+
89
+ type VerifiersTaskHash = Annotated[str, AfterValidator(_normalized_task_hash)]
90
+ """A raw Verifiers task hash on the way in, a Techtree digest once validated."""
91
+
92
+
93
+ class TaskInspection(ProtocolModel):
94
+ """The identity of one task, as the engine reported it."""
95
+
96
+ position: int = Field(ge=0)
97
+ task_hash: VerifiersTaskHash
98
+ name: NonEmptyString | None
99
+ task_type: NonEmptyString
100
+
101
+
102
+ class TasksetInspection(ProtocolModel):
103
+ """One engine inspection of one taskset."""
104
+
105
+ schema_version: Literal["techtree.taskset-inspection.v1"]
106
+ taskset_id: NonEmptyString
107
+ taskset_class: NonEmptyString
108
+ requested_num_tasks: int = Field(ge=1)
109
+ task_count: int = Field(ge=1)
110
+ tasks: list[TaskInspection]
111
+
112
+ @model_validator(mode="after")
113
+ def _check_records_are_a_dense_ordered_run(self) -> Self:
114
+ """Reject gaps, repeats, or a count the records do not support.
115
+
116
+ Position is what makes membership an *ordered* commitment, so a report
117
+ whose positions are not ``0..n-1`` in order cannot be read as one.
118
+ """
119
+ positions = [task.position for task in self.tasks]
120
+ if positions != list(range(len(self.tasks))):
121
+ raise ValueError(
122
+ "inspection records must be sorted by position and cover 0..n-1 "
123
+ "without gaps"
124
+ )
125
+ if self.task_count != len(self.tasks):
126
+ raise ValueError(
127
+ f"inspection lists {len(self.tasks)} tasks but reports a "
128
+ f"task_count of {self.task_count}"
129
+ )
130
+ if self.requested_num_tasks != self.task_count:
131
+ raise ValueError(
132
+ f"inspection reports {self.task_count} tasks for a request of "
133
+ f"{self.requested_num_tasks}"
134
+ )
135
+ return self
136
+
137
+ @property
138
+ def ordered_task_hashes(self) -> list[Digest]:
139
+ """Return the normalized task hashes in the order they were reported."""
140
+ return [task.task_hash for task in self.tasks]
141
+
142
+
143
+ def membership_digest(ordered_task_hashes: list[Digest]) -> Digest:
144
+ """Digest the canonical ordered-hash object.
145
+
146
+ Every hash is revalidated on the way in. The parameter is typed as a list of
147
+ digests, but a type annotation is a claim about the caller, not a check on
148
+ the value, and this digest is a commitment other parties will be held to.
149
+ """
150
+ if not ordered_task_hashes:
151
+ raise ValidationError(
152
+ "a membership digest commits to at least one task",
153
+ code="membership_empty",
154
+ details={"task_count": 0},
155
+ )
156
+ return digest_object(
157
+ {
158
+ MEMBERSHIP_DIGEST_KEY: [
159
+ validate_digest(value) for value in ordered_task_hashes
160
+ ]
161
+ }
162
+ )
163
+
164
+
165
+ def assert_unique_task_hashes(hashes: list[Digest]) -> None:
166
+ """Reject a membership that names the same task twice."""
167
+ first_seen: dict[Digest, int] = {}
168
+ for position, value in enumerate(hashes):
169
+ earlier = first_seen.setdefault(value, position)
170
+ if earlier != position:
171
+ raise VerificationError(
172
+ f"task {value} appears at positions {earlier} and {position}; a "
173
+ "repeated task would be scored twice under one commitment",
174
+ code="taskset_task_hash_repeated",
175
+ details={
176
+ "task_hash": value,
177
+ "first_position": earlier,
178
+ "repeated_position": position,
179
+ },
180
+ )
181
+
182
+
183
+ def compare_membership(
184
+ actual: list[Digest],
185
+ committed: list[Digest],
186
+ *,
187
+ check_id: str = MEMBERSHIP_MATCH_CHECK,
188
+ ) -> ValidationCheck:
189
+ """Return pass or fail for two memberships, naming the first difference.
190
+
191
+ ``check_id`` selects which of the receipt's required checks this comparison
192
+ answers: the same comparison proves repeatability when it runs over two
193
+ inspections and commitment match when it runs against a Campaign.
194
+ """
195
+ if not actual and not committed:
196
+ return ValidationCheck(
197
+ id=check_id,
198
+ status="failed",
199
+ detail="both memberships are empty, so there is nothing to compare",
200
+ )
201
+
202
+ mismatch = _first_difference(actual, committed)
203
+ if mismatch is None and len(actual) == len(committed):
204
+ return ValidationCheck(
205
+ id=check_id,
206
+ status="passed",
207
+ detail=f"all {len(actual)} task hashes match in order",
208
+ )
209
+
210
+ return ValidationCheck(
211
+ id=check_id,
212
+ status="failed",
213
+ detail=_mismatch_detail(actual, committed, mismatch),
214
+ )
215
+
216
+
217
+ def load_inspection_output(path: Path) -> TasksetInspection:
218
+ """Validate one engine inspection document and normalize its hashes."""
219
+ try:
220
+ raw = path.read_bytes()
221
+ except OSError as error:
222
+ raise ValidationError(
223
+ f"the engine wrote no inspection output at {path}",
224
+ code="taskset_inspection_missing",
225
+ details={"path": str(path)},
226
+ ) from error
227
+
228
+ try:
229
+ return TasksetInspection.model_validate_json(raw)
230
+ except ValueError as error:
231
+ raise ValidationError(
232
+ f"the engine's inspection output is not a valid "
233
+ f"{INSPECTION_SCHEMA_VERSION} document: {error}",
234
+ code="taskset_inspection_invalid",
235
+ details={"path": str(path)},
236
+ ) from error
237
+
238
+
239
+ def _first_difference(
240
+ actual: list[Digest], committed: list[Digest]
241
+ ) -> tuple[int, Digest | None, Digest | None] | None:
242
+ """Return the first position the two memberships disagree at, if any."""
243
+ for position in range(max(len(actual), len(committed))):
244
+ left = actual[position] if position < len(actual) else None
245
+ right = committed[position] if position < len(committed) else None
246
+ if left != right:
247
+ return position, left, right
248
+ return None
249
+
250
+
251
+ def _mismatch_detail(
252
+ actual: list[Digest],
253
+ committed: list[Digest],
254
+ mismatch: tuple[int, Digest | None, Digest | None] | None,
255
+ ) -> str:
256
+ """Describe a failed comparison in terms an operator can act on."""
257
+ parts: list[str] = []
258
+ if len(actual) != len(committed):
259
+ parts.append(
260
+ f"membership records {len(actual)} tasks but the commitment names "
261
+ f"{len(committed)}"
262
+ )
263
+ if mismatch is not None:
264
+ position, left, right = mismatch
265
+ parts.append(
266
+ f"first difference at position {position}: "
267
+ f"recorded {left or 'nothing'}, committed {right or 'nothing'}"
268
+ )
269
+ return "; ".join(parts)
@@ -0,0 +1,207 @@
1
+ """Answering a run's validation question with a real Verifiers run.
2
+
3
+ Spec section 21.5, decisions document 0003 A1.
4
+
5
+ :class:`~techtree.runs.validation.PublisherFixtureValidationProvider` re-reads a
6
+ commitment somebody else made. This one does the work: it resolves the
7
+ Campaign's taskset against the managed engine, runs the pinned model-free
8
+ validation over every task, has the engine normalize the result, and issues a
9
+ receipt of its own.
10
+
11
+ Then it compares. Because the receipt is deterministic, "did this machine reach
12
+ the same conclusion as the publisher?" is answerable by equality rather than by
13
+ narrative: the local receipt's content digest must be exactly the digest the
14
+ Campaign commits to. Anything less would be two documents that merely resemble
15
+ each other.
16
+
17
+ The engine is not chosen locally. It is the engine the publisher's receipt was
18
+ issued under, so a disagreement between the two receipts can only ever be a
19
+ disagreement about the taskset — never about which Verifiers build ran.
20
+
21
+ :func:`worker_validation_provider` is the routing this build performs, and it
22
+ routes on a capability rather than on a preference. A Campaign whose taskset
23
+ lives in a package the engine bundle ships can be validated here, for real. A
24
+ Campaign whose taskset lives anywhere else cannot be — this build has no way to
25
+ obtain that package — and the only source left is the publisher's snapshotted
26
+ commitment, which the fake executor may use because everything it produces is
27
+ marked development-only. The run's validation marker records which of the two
28
+ spoke, so the difference is never invisible.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from typing import Final
34
+
35
+ from techtree.canonical import digest_object
36
+ from techtree.engines.bundle import default_engine_descriptor
37
+ from techtree.engines.registry import EngineRegistry
38
+ from techtree.errors import PrerequisiteError, VerificationError
39
+ from techtree.models.base import Digest, JsonValue
40
+ from techtree.models.campaign import CampaignSpec
41
+ from techtree.paths import TechtreePaths
42
+ from techtree.runs.artifacts import RunInputBundle
43
+ from techtree.runs.validation import (
44
+ TASKSET_VALIDATION_INVALID,
45
+ PublisherFixtureValidationProvider,
46
+ TasksetValidationOutcome,
47
+ TasksetValidationProvider,
48
+ TasksetValidationSource,
49
+ )
50
+ from techtree.settings import resolved_settings
51
+ from techtree.tasksets.service import TasksetService
52
+
53
+ __all__ = [
54
+ "ENGINE_NOT_INSTALLED",
55
+ "LocalVerifiersValidationProvider",
56
+ "can_validate_locally",
57
+ "locally_validatable_packages",
58
+ "worker_validation_provider",
59
+ ]
60
+
61
+ #: What a run reports when the engine its Campaign was validated under is not
62
+ #: installed on this machine. Local validation has no substitute, so this is a
63
+ #: prerequisite the operator can satisfy rather than a failure of the run.
64
+ ENGINE_NOT_INSTALLED: Final = "engine_not_installed"
65
+
66
+
67
+ def locally_validatable_packages() -> frozenset[str]:
68
+ """Return the taskset packages this build can validate for itself.
69
+
70
+ A package inside the engine bundle is one this machine already has the
71
+ source of. Every other package would have to be fetched from somewhere,
72
+ which WP0–WP5 does not do.
73
+ """
74
+ return frozenset(package.name for package in default_engine_descriptor().packages)
75
+
76
+
77
+ def can_validate_locally(campaign: CampaignSpec) -> bool:
78
+ """Return whether this build ships the package this Campaign's taskset needs."""
79
+ package = campaign.taskset.ref.package
80
+ return package.kind == "embedded" and package.name in locally_validatable_packages()
81
+
82
+
83
+ class LocalVerifiersValidationProvider:
84
+ """Validates a run's taskset here, with the pinned Verifiers build."""
85
+
86
+ def __init__(self, paths: TechtreePaths) -> None:
87
+ self._paths = paths
88
+
89
+ def validate(
90
+ self,
91
+ *,
92
+ run_id: str,
93
+ inputs: RunInputBundle,
94
+ ) -> TasksetValidationOutcome:
95
+ """Run the real validation and require it to agree with the publisher."""
96
+ campaign = inputs.campaign
97
+ publisher = inputs.source.publisher_validation
98
+ committed_receipt_digest = campaign.taskset.validation_receipt_digest
99
+
100
+ service = TasksetService(
101
+ self._require_engine(run_id, publisher.engine_digest),
102
+ publisher.engine_digest,
103
+ )
104
+ result = service.resolve_and_validate(
105
+ campaign=campaign,
106
+ run_dir=self._paths.run_dir(run_id),
107
+ )
108
+ receipt = result.receipt
109
+
110
+ _require(
111
+ receipt.status == "valid",
112
+ f"validating this taskset here reported {receipt.status}, so "
113
+ "nothing may be scored on it",
114
+ run_id,
115
+ status=receipt.status,
116
+ failed_checks=[
117
+ check.id for check in receipt.checks if check.status == "failed"
118
+ ],
119
+ )
120
+ _require(
121
+ receipt.taskset_lock_digest == publisher.taskset_lock_digest,
122
+ "the taskset this machine locked is not the one the publisher's "
123
+ "receipt was issued under",
124
+ run_id,
125
+ local=receipt.taskset_lock_digest,
126
+ published=publisher.taskset_lock_digest,
127
+ )
128
+ _require(
129
+ result.receipt_digest == committed_receipt_digest,
130
+ "validating this taskset here did not reproduce the receipt the "
131
+ "Campaign commits to",
132
+ run_id,
133
+ local=result.receipt_digest,
134
+ committed=committed_receipt_digest,
135
+ )
136
+ _require(
137
+ digest_object(result.evidence) == digest_object(inputs.validation_evidence),
138
+ "the evidence this machine produced is not the evidence the Campaign ships",
139
+ run_id,
140
+ )
141
+
142
+ return TasksetValidationOutcome(
143
+ lock=result.lock,
144
+ receipt=receipt,
145
+ execution_record=result.execution_record,
146
+ source=TasksetValidationSource.LOCAL_VERIFIERS,
147
+ )
148
+
149
+ def _require_engine(self, run_id: str, engine_digest: Digest) -> EngineRegistry:
150
+ """Return the registry, having checked this engine is usable here."""
151
+ registry = EngineRegistry(self._paths, resolved_settings(self._paths))
152
+ installation = registry.installation(engine_digest)
153
+ if installation is None or not installation.verified:
154
+ raise PrerequisiteError(
155
+ f"engine {engine_digest} is not installed and verified here, so "
156
+ "this taskset cannot be validated locally",
157
+ code=ENGINE_NOT_INSTALLED,
158
+ details={"run_id": run_id, "engine_digest": engine_digest},
159
+ )
160
+ return registry
161
+
162
+
163
+ def worker_validation_provider(paths: TechtreePaths) -> TasksetValidationProvider:
164
+ """Return the validation source available to a run on this machine.
165
+
166
+ The decision cannot be made from a
167
+ :class:`~techtree.models.run.RunRequest` alone — nothing in a request names
168
+ the taskset's package — so the routing lives in a provider that reads the
169
+ run's own inputs and then delegates. Both destinations are permanent: one
170
+ is what a real Campaign gets, the other is the development source spec
171
+ §21.5 leaves the fake executor.
172
+ """
173
+ return _AvailableValidationProvider(paths)
174
+
175
+
176
+ class _AvailableValidationProvider:
177
+ """Delegates to whichever validation source this Campaign actually has."""
178
+
179
+ def __init__(self, paths: TechtreePaths) -> None:
180
+ self._paths = paths
181
+
182
+ def validate(
183
+ self,
184
+ *,
185
+ run_id: str,
186
+ inputs: RunInputBundle,
187
+ ) -> TasksetValidationOutcome:
188
+ """Validate locally when this build ships the taskset's package."""
189
+ if can_validate_locally(inputs.campaign):
190
+ return LocalVerifiersValidationProvider(self._paths).validate(
191
+ run_id=run_id, inputs=inputs
192
+ )
193
+ return PublisherFixtureValidationProvider().validate(
194
+ run_id=run_id, inputs=inputs
195
+ )
196
+
197
+
198
+ def _require(condition: bool, message: str, run_id: str, **details: object) -> None:
199
+ """Raise a typed validation failure unless the condition holds."""
200
+ if condition:
201
+ return
202
+ reported: dict[str, JsonValue] = {"run_id": run_id}
203
+ for key, value in details.items():
204
+ reported[key] = (
205
+ [str(item) for item in value] if isinstance(value, list) else str(value)
206
+ )
207
+ raise VerificationError(message, code=TASKSET_VALIDATION_INVALID, details=reported)