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,419 @@
1
+ """What a Climb is, and whether you could run it. Spec 14.3, decisions 0003.
2
+
3
+ The repository can prove that a file is the object it claims to be. It cannot
4
+ say whether four such objects, each individually valid, tell one consistent
5
+ story. That is this module's job, and it is the reason the graph is assembled
6
+ in one place instead of wherever a command happens to need a piece of it.
7
+
8
+ :class:`~techtree.models.climb.ResolvedClimb` performs the edge checks the
9
+ moment the four objects are put together: the Climb points at the Campaign that
10
+ was loaded, the Campaign at the DataPolicy and the receipt that were loaded, and
11
+ the candidate policy matches the mutation contract. It also runs the rights
12
+ comparison, which raises :class:`~techtree.errors.PolicyError` rather than a
13
+ validation error — a Climb that promises to publish something its DataPolicy
14
+ forbids is not malformed, it is dishonest, and a caller should be able to tell
15
+ those two failures apart.
16
+
17
+ Three checks sit outside that model because they need more than the four
18
+ objects it holds:
19
+
20
+ * the shipped validation evidence must resolve through the catalog and describe
21
+ the same taskset lock the receipt was issued for (decisions 0003 A4: a public
22
+ catalog has no dangling references);
23
+ * the validated tasks must be exactly the tasks the Campaign committed to, in
24
+ order;
25
+ * a development Climb and a development-only proof grade imply each other, so a
26
+ fixture cannot be dressed up as evidence or a real Climb quietly demoted.
27
+
28
+ Compatibility is answered separately and never conflated with validity. A Climb
29
+ can be perfectly well-formed and still not runnable here, and before the managed
30
+ engine exists that is the ordinary case: the absent engine blocks preparing a
31
+ submission while ``list`` and ``show`` continue to display the Climb.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import platform
37
+ import sys
38
+ from dataclasses import dataclass
39
+ from typing import Final, Protocol
40
+
41
+ from techtree.catalog.repository import EmbeddedCatalogRepository, climb_reference
42
+ from techtree.engines.registry import EngineRegistry
43
+ from techtree.errors import NotFoundError, PolicyError, PrerequisiteError, UsageError
44
+ from techtree.models.base import Digest
45
+ from techtree.models.catalog import (
46
+ ClimbSummary,
47
+ CompatibilityIssue,
48
+ CompatibilityResult,
49
+ DataPolicySummary,
50
+ EngineCompatibilityStatus,
51
+ )
52
+ from techtree.models.climb import ResolvedClimb, check_climb_policy_consistency
53
+ from techtree.models.data_policy import DataPolicy
54
+ from techtree.models.engine import normalize_host_platform
55
+ from techtree.models.evaluation_backend import SUPPORTED_EVALUATION_BACKEND_KINDS
56
+ from techtree.models.validation import ValidationEvidence
57
+ from techtree.paths import TechtreePaths
58
+ from techtree.settings import resolved_settings
59
+
60
+ __all__ = [
61
+ "CLIMB_LIST_STATUSES",
62
+ "CatalogService",
63
+ "EngineStatusSource",
64
+ "HostInfo",
65
+ "InstalledEngineStatus",
66
+ "current_host_info",
67
+ "data_policy_summary",
68
+ ]
69
+
70
+ #: What ``list_climbs`` accepts. ``available`` is the default because a reader
71
+ #: browsing Climbs wants the ones they could enter, and in a build whose only
72
+ #: Climbs are development fixtures that has to include them.
73
+ CLIMB_LIST_STATUSES: Final[frozenset[str]] = frozenset(
74
+ {"available", "all", "open", "closed", "development"}
75
+ )
76
+
77
+ _AVAILABLE_STATUSES: Final[frozenset[str]] = frozenset({"open", "development"})
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class HostInfo:
82
+ """The three facts about this machine a compatibility answer rests on."""
83
+
84
+ operating_system: str
85
+ architecture: str
86
+ python_version: str
87
+
88
+
89
+ def current_host_info() -> HostInfo:
90
+ """Return the host Techtree is running on, unnormalized.
91
+
92
+ The raw values are carried rather than the normalized platform string so
93
+ that an unsupported machine can still be named in the answer it gets.
94
+ """
95
+ return HostInfo(
96
+ operating_system=sys.platform,
97
+ architecture=platform.machine(),
98
+ python_version=platform.python_version(),
99
+ )
100
+
101
+
102
+ class EngineStatusSource(Protocol):
103
+ """Whatever can say what is known about one engine bundle on this host."""
104
+
105
+ def status_for(self, engine_digest: Digest) -> EngineCompatibilityStatus:
106
+ """Return what is known about that engine here."""
107
+ ...
108
+
109
+
110
+ class InstalledEngineStatus:
111
+ """Answers the engine question from what the filesystem can prove.
112
+
113
+ The managed engine registry owns the installation marker and its verified
114
+ bit, so compatibility reads that same record instead of guessing from a
115
+ directory that may have been left by an interrupted install.
116
+ """
117
+
118
+ def __init__(self, paths: TechtreePaths) -> None:
119
+ self._paths = paths
120
+
121
+ def status_for(self, engine_digest: Digest) -> EngineCompatibilityStatus:
122
+ """Return whether that engine bundle is installed on this host."""
123
+ status = EngineRegistry(self._paths, resolved_settings(self._paths)).status(
124
+ engine_digest
125
+ )
126
+ if not status.installed:
127
+ return EngineCompatibilityStatus.NOT_INSTALLED
128
+ if status.verified:
129
+ return EngineCompatibilityStatus.VERIFIED
130
+ return EngineCompatibilityStatus.INSTALLED_UNVERIFIED
131
+
132
+
133
+ class CatalogService:
134
+ """Resolves, checks, and summarizes the Climbs a build ships."""
135
+
136
+ def __init__(
137
+ self,
138
+ repository: EmbeddedCatalogRepository,
139
+ host_info: HostInfo,
140
+ engine_status: EngineStatusSource,
141
+ ) -> None:
142
+ self._repository = repository
143
+ self._host_info = host_info
144
+ self._engine_status = engine_status
145
+
146
+ def list_climbs(self, *, status: str = "available") -> list[ClimbSummary]:
147
+ """Return one summary per Climb, in catalog order.
148
+
149
+ Every listed Climb is fully resolved first. Listing a Climb that cannot
150
+ be resolved would be advertising something no reader could enter, and
151
+ the failure would then surface at ``prepare`` instead of here.
152
+ """
153
+ if status not in CLIMB_LIST_STATUSES:
154
+ raise UsageError(
155
+ f"unknown Climb status {status!r}; choose one of "
156
+ + ", ".join(sorted(CLIMB_LIST_STATUSES)),
157
+ code="unknown_climb_status",
158
+ details={"status": status},
159
+ )
160
+
161
+ summaries: list[ClimbSummary] = []
162
+ for reference in self._repository.list_climb_references():
163
+ resolved = self.get_climb(reference)
164
+ if _status_matches(resolved.climb.metadata.status, status):
165
+ summaries.append(self.climb_summary(resolved))
166
+ return summaries
167
+
168
+ def get_climb(self, reference: str) -> ResolvedClimb:
169
+ """Resolve one Climb into its complete, cross-checked object graph."""
170
+ entry = self._repository.climb_entry(reference)
171
+ climb = self._repository.load_climb(entry.reference)
172
+ campaign = self._repository.load_campaign(climb.campaign_spec_digest)
173
+ data_policy = self._repository.load_data_policy(campaign.data_policy_digest)
174
+ receipt = self._repository.load_validation_receipt(
175
+ campaign.taskset.validation_receipt_digest
176
+ )
177
+
178
+ resolved = ResolvedClimb(
179
+ climb=climb,
180
+ climb_digest=entry.digest,
181
+ campaign=campaign,
182
+ campaign_digest=climb.campaign_spec_digest,
183
+ data_policy=data_policy,
184
+ data_policy_digest=campaign.data_policy_digest,
185
+ publisher_validation=receipt,
186
+ publisher_validation_digest=campaign.taskset.validation_receipt_digest,
187
+ )
188
+
189
+ self._check_validation_evidence(resolved)
190
+ self.validate_public_policy(resolved)
191
+ return resolved
192
+
193
+ def compatibility(self, resolved: ResolvedClimb) -> CompatibilityResult:
194
+ """Report whether this host could run this Climb, and what is missing."""
195
+ issues: list[CompatibilityIssue] = []
196
+
197
+ host_platform, host_supported = self._host_platform()
198
+ if not host_supported:
199
+ issues.append(
200
+ CompatibilityIssue(
201
+ code="host_unsupported",
202
+ severity="error",
203
+ message=(
204
+ f"Techtree does not support {host_platform}; it runs on "
205
+ "macOS and Linux, on arm64 and amd64."
206
+ ),
207
+ blocking=True,
208
+ )
209
+ )
210
+
211
+ engine_digest = resolved.publisher_validation.engine_digest
212
+ engine_status = self._engine_status.status_for(engine_digest)
213
+ engine_issue = _engine_issue(engine_status)
214
+ if engine_issue is not None:
215
+ issues.append(engine_issue)
216
+
217
+ backend_kind = resolved.campaign.evaluation_backend.kind
218
+ backend_supported = backend_kind in SUPPORTED_EVALUATION_BACKEND_KINDS
219
+ if not backend_supported:
220
+ issues.append(
221
+ CompatibilityIssue(
222
+ code="evaluation_backend_unsupported",
223
+ severity="error",
224
+ message=(
225
+ f"This Climb is evaluated by {backend_kind.value}, which "
226
+ "this version of Techtree cannot run."
227
+ ),
228
+ blocking=True,
229
+ )
230
+ )
231
+
232
+ return CompatibilityResult(
233
+ compatible=not any(issue.blocking for issue in issues),
234
+ host_platform=host_platform,
235
+ host_supported=host_supported,
236
+ required_engine_digest=engine_digest,
237
+ engine_status=engine_status,
238
+ evaluation_backend_kind=backend_kind,
239
+ evaluation_backend_supported=backend_supported,
240
+ issues=issues,
241
+ )
242
+
243
+ def validation_evidence(self, resolved: ResolvedClimb) -> ValidationEvidence:
244
+ """Return the normalized evidence this Climb's receipt was issued from.
245
+
246
+ Preparing a submission copies the evidence into the draft so that the
247
+ draft stays verifiable when the catalog that shipped it does not
248
+ (decisions document 0003 A4). The graph was already checked when it was
249
+ resolved, so a receipt with no evidence here is a caller asking for
250
+ something this Climb does not have.
251
+ """
252
+ reference = resolved.publisher_validation.normalized_evidence
253
+ if reference is None:
254
+ raise NotFoundError(
255
+ "this Climb's publisher validation names no normalized evidence",
256
+ code="publisher_validation_evidence_missing",
257
+ details={"climb": climb_reference(resolved.climb)},
258
+ )
259
+ return self._repository.load_validation_evidence(reference.digest)
260
+
261
+ def validate_public_policy(self, resolved: ResolvedClimb) -> None:
262
+ """Reject contradictions among the Climb, Campaign, and DataPolicy.
263
+
264
+ The rights comparison already ran when the graph was assembled; running
265
+ it again costs nothing and means a caller holding a ``ResolvedClimb``
266
+ from anywhere can ask the question directly.
267
+ """
268
+ check_climb_policy_consistency(resolved.climb, resolved.data_policy)
269
+
270
+ status = resolved.climb.metadata.status
271
+ proof_grade = resolved.climb.publication.proof_grade
272
+ if (status == "development") != (proof_grade == "development_only"):
273
+ raise PolicyError(
274
+ f"this Climb is {status} and claims a {proof_grade} proof grade; "
275
+ "a development Climb produces development-only results and "
276
+ "nothing else does",
277
+ code="proof_grade_contradiction",
278
+ details={"status": status, "proof_grade": proof_grade},
279
+ )
280
+
281
+ def climb_summary(self, resolved: ResolvedClimb) -> ClimbSummary:
282
+ """Project a resolved graph into what ``list`` and ``show`` display."""
283
+ campaign = resolved.campaign
284
+ climb = resolved.climb
285
+
286
+ return ClimbSummary(
287
+ reference=climb_reference(climb),
288
+ climb_digest=resolved.climb_digest,
289
+ campaign_spec_digest=resolved.campaign_digest,
290
+ title=climb.metadata.title,
291
+ summary=climb.metadata.summary,
292
+ status=climb.metadata.status,
293
+ purpose=campaign.metadata.purpose,
294
+ taskset_id=campaign.taskset.ref.id,
295
+ task_count=campaign.taskset.selection.num_tasks,
296
+ subject_harness=campaign.subject.harness.id,
297
+ subject_harness_version=campaign.subject.harness.version,
298
+ # Read off the Climb rather than the Campaign: a public Climb still
299
+ # requires skill_insertion, and ``ResolvedClimb`` refuses to exist
300
+ # unless the Campaign's mutation kind is the one the Climb names.
301
+ mutation_kind=climb.candidate_policy.required_mutation,
302
+ candidate_skill_visibility=climb.candidate_policy.skill_visibility,
303
+ evaluation_backend=campaign.evaluation_backend.kind,
304
+ proof_grade=climb.publication.proof_grade,
305
+ data_policy=data_policy_summary(resolved.data_policy),
306
+ compatibility=self.compatibility(resolved),
307
+ )
308
+
309
+ # -- Internals ---------------------------------------------------------
310
+
311
+ def _host_platform(self) -> tuple[str, bool]:
312
+ """Return the normalized host platform, or the raw pair and False."""
313
+ try:
314
+ return (
315
+ normalize_host_platform(
316
+ self._host_info.operating_system, self._host_info.architecture
317
+ ),
318
+ True,
319
+ )
320
+ except PrerequisiteError:
321
+ return (
322
+ f"{self._host_info.operating_system}/{self._host_info.architecture}",
323
+ False,
324
+ )
325
+
326
+ def _check_validation_evidence(self, resolved: ResolvedClimb) -> None:
327
+ """Resolve the receipt's evidence and check it describes this taskset.
328
+
329
+ Decisions document 0003 A4: an artifact a public object references is
330
+ resolvable through the catalog, or it is not referenced at all. The
331
+ evidence is also the only shipped record of which tasks were actually
332
+ validated, so it is compared against the Campaign's commitment here.
333
+ """
334
+ reference = resolved.publisher_validation.normalized_evidence
335
+ if reference is None:
336
+ return
337
+
338
+ evidence = self._repository.load_validation_evidence(reference.digest)
339
+
340
+ if evidence.taskset_lock_digest != (
341
+ resolved.publisher_validation.taskset_lock_digest
342
+ ):
343
+ raise PolicyError(
344
+ "the shipped validation evidence was produced for a different "
345
+ "taskset than the receipt it belongs to",
346
+ code="validation_evidence_mismatch",
347
+ details={
348
+ "receipt_taskset_lock_digest": (
349
+ resolved.publisher_validation.taskset_lock_digest
350
+ ),
351
+ "evidence_taskset_lock_digest": evidence.taskset_lock_digest,
352
+ },
353
+ )
354
+
355
+ validated = [task.task_hash for task in evidence.tasks]
356
+ committed = list(resolved.campaign.taskset.membership.ordered_task_hashes)
357
+ if validated != committed:
358
+ raise PolicyError(
359
+ "the Campaign commits to different tasks than the ones the "
360
+ "publisher validated",
361
+ code="validated_membership_mismatch",
362
+ details={
363
+ "committed_task_count": len(committed),
364
+ "validated_task_count": len(validated),
365
+ },
366
+ )
367
+
368
+
369
+ def data_policy_summary(data_policy: DataPolicy) -> DataPolicySummary:
370
+ """Project a DataPolicy into the four rights a reader most needs."""
371
+ return DataPolicySummary(
372
+ raw_episode_server_upload=data_policy.raw_episodes.server_upload,
373
+ raw_episode_training_use=data_policy.raw_episodes.training_use,
374
+ candidate_skill_public_release=data_policy.candidate_skill.public_release,
375
+ uplift_report_visibility=data_policy.derived_artifacts.uplift_report,
376
+ )
377
+
378
+
379
+ def _status_matches(climb_status: str, requested: str) -> bool:
380
+ if requested == "all":
381
+ return True
382
+ if requested == "available":
383
+ return climb_status in _AVAILABLE_STATUSES
384
+ return climb_status == requested
385
+
386
+
387
+ def _engine_issue(status: EngineCompatibilityStatus) -> CompatibilityIssue | None:
388
+ """Turn what is known about the engine into what it means for this Climb."""
389
+ if status is EngineCompatibilityStatus.VERIFIED:
390
+ return None
391
+ if status is EngineCompatibilityStatus.INSTALLED_UNVERIFIED:
392
+ return CompatibilityIssue(
393
+ code="engine_not_verified",
394
+ severity="warning",
395
+ message=(
396
+ "The evaluation engine is installed but has not been checked "
397
+ "since. Run `techtree engine verify` before you rely on a result."
398
+ ),
399
+ blocking=False,
400
+ )
401
+ if status is EngineCompatibilityStatus.NOT_INSTALLED:
402
+ return CompatibilityIssue(
403
+ code="engine_not_installed",
404
+ severity="error",
405
+ message=(
406
+ "The evaluation engine this Climb needs is not installed yet. "
407
+ "Run `techtree engine install` before preparing a submission."
408
+ ),
409
+ blocking=True,
410
+ )
411
+ return CompatibilityIssue(
412
+ code="engine_status_unknown",
413
+ severity="error",
414
+ message=(
415
+ "Techtree cannot tell whether the evaluation engine this Climb "
416
+ "needs is available on this machine."
417
+ ),
418
+ blocking=True,
419
+ )
@@ -0,0 +1 @@
1
+ """Techtree command-line interface."""