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,752 @@
1
+ """Turning a directory into a prepared submission. Spec PR6 §6.8.
2
+
3
+ This is the only place where a participant's working directory becomes a
4
+ scientific input, and it is written on the assumption that everything about
5
+ that directory is provisional until it has been copied and hashed.
6
+
7
+ The order of operations is the safety property. Nothing is written where a
8
+ later command could find it until every check has passed: the Climb is
9
+ resolved, its status and this machine's compatibility are confirmed, the skill
10
+ is scanned, the files are copied into a staging directory and re-verified
11
+ against what was scanned, both manifests are derived from the Campaign, the
12
+ comparison is required to be controlled, the draft is built and digested, and
13
+ only then is the whole tree renamed into place by the draft store. A failure
14
+ anywhere leaves the drafts directory exactly as it was.
15
+
16
+ Three things this module deliberately does not do.
17
+
18
+ It does not re-implement the scanner. PR5 already decides what a skill may
19
+ contain and what a credential looks like; duplicating that logic here would
20
+ create two answers to one question.
21
+
22
+ It does not call a model, start a process, or open a socket. Preparation is
23
+ pure local computation, and the integration test asserts it.
24
+
25
+ It does not ask anyone to accept anything. The draft states the rights that
26
+ will have to be accepted — decisions document 0003 A5's
27
+ ``PolicyAcceptanceRequirement`` — and the acceptance itself happens at start,
28
+ after the review of what the run would do has been shown, recorded as a
29
+ deliberate act.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ import uuid
36
+ from collections.abc import Callable
37
+ from dataclasses import dataclass
38
+ from datetime import datetime
39
+ from pathlib import Path, PurePosixPath
40
+ from typing import Final, assert_never
41
+
42
+ from techtree.canonical import (
43
+ canonical_json_bytes,
44
+ digest_object,
45
+ sha256_digest_bytes,
46
+ )
47
+ from techtree.catalog.service import CatalogService
48
+ from techtree.constants import SKILL_SCHEMA_VERSION, SUBMISSION_DRAFT_SCHEMA_VERSION
49
+ from techtree.drafts.source import CampaignSource, StagedSkill
50
+ from techtree.drafts.store import DraftStore, utc_now
51
+ from techtree.errors import (
52
+ PolicyError,
53
+ PrerequisiteError,
54
+ ValidationError,
55
+ VerificationError,
56
+ )
57
+ from techtree.fs import atomic_write_bytes, ensure_private_directory, remove_tree
58
+ from techtree.ids import new_id
59
+ from techtree.manifests.builder import (
60
+ build_baseline_manifest,
61
+ build_candidate_manifest,
62
+ skill_content_digest,
63
+ )
64
+ from techtree.manifests.compare import assert_controlled_comparison, compare_manifests
65
+ from techtree.models.base import Digest
66
+ from techtree.models.campaign import CampaignSpec, PublicContext, VariantSchedule
67
+ from techtree.models.climb import ResolvedClimb
68
+ from techtree.models.data_policy import DataPolicy
69
+ from techtree.models.experiment import ExperimentManifest, ManifestComparison
70
+ from techtree.models.skill import (
71
+ PolicyAcceptanceRequirement,
72
+ SkillArtifact,
73
+ SkillFile,
74
+ SubmissionDraft,
75
+ )
76
+ from techtree.models.uplift_report import UpliftReport
77
+ from techtree.models.validation import TasksetValidationReceipt, ValidationEvidence
78
+ from techtree.paths import TechtreePaths
79
+ from techtree.runs.real import executor_kind_for
80
+ from techtree.skills.archive import build_deterministic_tar
81
+ from techtree.skills.policy import SkillPolicy, default_instruction_skill_policy
82
+ from techtree.skills.scanner import ScannedFile, SkillScanResult, scan_skill
83
+ from techtree.uplift.derive import (
84
+ derive_replacement_manifests,
85
+ derive_skill_replacement_campaign,
86
+ )
87
+
88
+ __all__ = [
89
+ "PreparedDraft",
90
+ "ResolvedClimbBundle",
91
+ "SkillPreparationService",
92
+ ]
93
+
94
+ #: Stable error codes. Spec PR6 §6.10.
95
+ _CLIMB_NOT_PREPARABLE: Final = "climb_not_preparable"
96
+ _CANDIDATE_POLICY_VIOLATION: Final = "candidate_policy_violation"
97
+ _SKILL_INVALID: Final = "skill_invalid"
98
+ _SKILL_SNAPSHOT_FAILED: Final = "skill_snapshot_failed"
99
+ _PUBLISHER_EVIDENCE_MISSING: Final = "publisher_validation_evidence_missing"
100
+
101
+ #: Which Climbs accept submissions. A closed Climb is still readable; it is
102
+ #: simply not something a new candidate can be entered into.
103
+ _PREPARABLE_STATUSES: Final[frozenset[str]] = frozenset({"open", "development"})
104
+
105
+ #: Every variant runs the whole taskset once per rollout, and there are two
106
+ #: variants.
107
+ _VARIANTS: Final = 2
108
+
109
+ #: What a candidate label may be made of. Deliberately narrow: the label is
110
+ #: carried in a public artifact, so it holds a name and nothing that could be
111
+ #: read as a path, a flag, or markup.
112
+ _LABEL_PATTERN: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
113
+
114
+ #: Staging lives beside the drafts so the draft store's final placement is a
115
+ #: rename within one directory.
116
+ _STAGING_PREFIX: Final = ".prepare-"
117
+
118
+ _SKILL_DIR: Final = "skill"
119
+ _ARTIFACT_FILE: Final = "artifact.json"
120
+ _BUNDLE_FILE: Final = "bundle.tar"
121
+ _FILES_DIR: Final = "files"
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ResolvedClimbBundle:
126
+ """A resolved Climb together with the evidence a draft has to carry."""
127
+
128
+ resolved: ResolvedClimb
129
+ validation_evidence: ValidationEvidence
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class PreparedDraft:
134
+ """What preparation produced, and what it was prepared against."""
135
+
136
+ draft: SubmissionDraft
137
+ draft_digest: Digest
138
+ manifest_comparison: ManifestComparison
139
+ source: CampaignSource
140
+
141
+
142
+ class SkillPreparationService:
143
+ """Builds one complete, immutable, offline-verifiable submission draft."""
144
+
145
+ def __init__(
146
+ self,
147
+ *,
148
+ paths: TechtreePaths,
149
+ catalog: CatalogService,
150
+ draft_store: DraftStore,
151
+ skill_policy: SkillPolicy | None = None,
152
+ clock: Callable[[], datetime] = utc_now,
153
+ ) -> None:
154
+ self._paths = paths
155
+ self._catalog = catalog
156
+ self._drafts = draft_store
157
+ self._policy = skill_policy or default_instruction_skill_policy()
158
+ self._clock = clock
159
+
160
+ def prepare(
161
+ self,
162
+ *,
163
+ climb_reference: str,
164
+ skill_path: Path,
165
+ candidate_label: str | None = None,
166
+ ) -> PreparedDraft:
167
+ """Resolve, snapshot, derive, compare, and persist one draft."""
168
+ created_at = self._clock()
169
+
170
+ bundle = self._resolve(climb_reference)
171
+ resolved = bundle.resolved
172
+ self._require_preparable(resolved)
173
+
174
+ scan = self._scan(skill_path)
175
+ self._validate_candidate_policy(resolved, scan)
176
+
177
+ ensure_private_directory(self._paths.drafts_dir)
178
+ staging = self._paths.drafts_dir / f"{_STAGING_PREFIX}{uuid.uuid4().hex}"
179
+
180
+ source = CampaignSource.from_climb(resolved)
181
+ try:
182
+ ensure_private_directory(staging)
183
+ staged = self._snapshot_skill(
184
+ skill_dir=staging / _SKILL_DIR,
185
+ scan=scan,
186
+ candidate_label=candidate_label,
187
+ )
188
+ public_context = PublicContext(
189
+ kind="climb", climb_digest=resolved.climb_digest
190
+ )
191
+ baseline = build_baseline_manifest(
192
+ campaign=resolved.campaign,
193
+ campaign_digest=resolved.campaign_digest,
194
+ public_context=public_context,
195
+ created_at=created_at,
196
+ )
197
+ candidate = build_candidate_manifest(
198
+ campaign=resolved.campaign,
199
+ campaign_digest=resolved.campaign_digest,
200
+ skill=staged.artifact,
201
+ public_context=public_context,
202
+ created_at=created_at,
203
+ )
204
+ comparison = self._require_controlled(
205
+ baseline, candidate, resolved.campaign
206
+ )
207
+ draft = self._build_draft(
208
+ source=source,
209
+ skill=staged.artifact,
210
+ baseline=baseline,
211
+ candidate=candidate,
212
+ policy=self._build_policy_requirement(resolved.data_policy),
213
+ created_at=created_at,
214
+ warnings=self._warnings(resolved),
215
+ )
216
+ draft_digest = digest_object(draft)
217
+
218
+ self._drafts.create(
219
+ draft=draft,
220
+ baseline=baseline,
221
+ candidate=candidate,
222
+ comparison=comparison,
223
+ source=source,
224
+ validation_evidence=bundle.validation_evidence,
225
+ staged_candidate_skill=staged,
226
+ )
227
+ finally:
228
+ # The store renames its own staging tree into place; this one is
229
+ # always scratch, so it always goes away.
230
+ remove_tree(staging)
231
+
232
+ return PreparedDraft(
233
+ draft=draft,
234
+ draft_digest=draft_digest,
235
+ manifest_comparison=comparison,
236
+ source=source,
237
+ )
238
+
239
+ def prepare_replacement(
240
+ self,
241
+ *,
242
+ source_campaign: CampaignSpec,
243
+ data_policy: DataPolicy,
244
+ publisher_validation: TasksetValidationReceipt,
245
+ validation_evidence: ValidationEvidence,
246
+ source_report: UpliftReport,
247
+ baseline_skill: StagedSkill,
248
+ candidate_skill_path: Path,
249
+ candidate_label: str | None = None,
250
+ ) -> PreparedDraft:
251
+ """Prepare a Skill v1 against Skill v2 draft. Spec sections 7.19, 7.20.
252
+
253
+ Everything a public submission goes through happens here too, in the
254
+ same order and through the same store: the new Skill is scanned by the
255
+ same scanner, snapshotted by the same snapshotter, and compared by the
256
+ same comparison. Only two things differ, and both come from the
257
+ Campaign rather than from a caller.
258
+
259
+ *The Campaign is derived, not resolved.* Spec section 7.19 fixes every
260
+ scientific field to the source run's and changes exactly the mutation
261
+ contract and the Skill the baseline carries, so no public Climb wraps
262
+ it and the draft names no public context.
263
+
264
+ *The baseline carries a Skill.* ``baseline_skill`` is Skill v1 exactly
265
+ as it was evaluated, taken from the source run's own verified inputs
266
+ rather than rescanned from a directory that may have changed since,
267
+ and it is snapshotted beside the candidate because the subject has to
268
+ be handed its files.
269
+ """
270
+ created_at = self._clock()
271
+ scan = self._scan(candidate_skill_path)
272
+ self._require_candidate_files(scan)
273
+
274
+ ensure_private_directory(self._paths.drafts_dir)
275
+ staging = self._paths.drafts_dir / f"{_STAGING_PREFIX}{uuid.uuid4().hex}"
276
+
277
+ try:
278
+ ensure_private_directory(staging)
279
+ staged = self._snapshot_skill(
280
+ skill_dir=staging / _SKILL_DIR,
281
+ scan=scan,
282
+ candidate_label=candidate_label,
283
+ parent_skill_digest=baseline_skill.artifact.root_digest,
284
+ )
285
+ campaign = derive_skill_replacement_campaign(
286
+ source_campaign=source_campaign,
287
+ source_run=source_report,
288
+ baseline_skill=baseline_skill.artifact,
289
+ candidate_skill=staged.artifact,
290
+ )
291
+ source = CampaignSource.local(
292
+ campaign=campaign,
293
+ data_policy=data_policy,
294
+ publisher_validation=publisher_validation,
295
+ )
296
+ baseline, candidate, comparison = derive_replacement_manifests(
297
+ campaign=campaign,
298
+ candidate_skill=staged.artifact,
299
+ campaign_digest=source.campaign_digest,
300
+ created_at=created_at,
301
+ )
302
+ draft = self._build_draft(
303
+ source=source,
304
+ skill=staged.artifact,
305
+ baseline=baseline,
306
+ candidate=candidate,
307
+ policy=self._build_policy_requirement(data_policy),
308
+ created_at=created_at,
309
+ warnings=self._replacement_warnings(),
310
+ )
311
+ draft_digest = digest_object(draft)
312
+
313
+ self._drafts.create(
314
+ draft=draft,
315
+ baseline=baseline,
316
+ candidate=candidate,
317
+ comparison=comparison,
318
+ source=source,
319
+ validation_evidence=validation_evidence,
320
+ staged_candidate_skill=staged,
321
+ staged_baseline_skill=baseline_skill,
322
+ )
323
+ finally:
324
+ remove_tree(staging)
325
+
326
+ return PreparedDraft(
327
+ draft=draft,
328
+ draft_digest=draft_digest,
329
+ manifest_comparison=comparison,
330
+ source=source,
331
+ )
332
+
333
+ # -- Resolution and eligibility ----------------------------------------
334
+
335
+ def _resolve(self, climb_reference: str) -> ResolvedClimbBundle:
336
+ """Resolve Climb, Campaign, DataPolicy, receipt, and evidence."""
337
+ resolved = self._catalog.get_climb(climb_reference)
338
+ reference = resolved.publisher_validation.normalized_evidence
339
+ if reference is None:
340
+ raise VerificationError(
341
+ "this Climb's publisher validation names no normalized "
342
+ "evidence, so a draft prepared from it could not be checked "
343
+ "offline",
344
+ code=_PUBLISHER_EVIDENCE_MISSING,
345
+ details={"climb_reference": climb_reference},
346
+ )
347
+ return ResolvedClimbBundle(
348
+ resolved=resolved,
349
+ validation_evidence=self._catalog.validation_evidence(resolved),
350
+ )
351
+
352
+ def _require_preparable(self, resolved: ResolvedClimb) -> None:
353
+ """Refuse a Climb that is closed, or that this machine cannot run."""
354
+ status = resolved.climb.metadata.status
355
+ if status not in _PREPARABLE_STATUSES:
356
+ raise PolicyError(
357
+ f"this Climb is {status}, so it is not accepting submissions",
358
+ code=_CLIMB_NOT_PREPARABLE,
359
+ details={"status": status},
360
+ )
361
+
362
+ compatibility = self._catalog.compatibility(resolved)
363
+ blocking = [issue for issue in compatibility.issues if issue.blocking]
364
+ if blocking:
365
+ raise PrerequisiteError(
366
+ "this machine cannot run this Climb yet: "
367
+ + " ".join(issue.message for issue in blocking),
368
+ code=_CLIMB_NOT_PREPARABLE,
369
+ details={"blocking_issues": [issue.code for issue in blocking]},
370
+ )
371
+
372
+ def _validate_candidate_policy(
373
+ self, resolved: ResolvedClimb, scan: SkillScanResult
374
+ ) -> None:
375
+ """Enforce Climb candidate constraints and DataPolicy."""
376
+ constraints = resolved.climb.candidate_policy.constraints
377
+ if not constraints.min_skills <= 1 <= constraints.max_skills:
378
+ raise PolicyError(
379
+ "this Climb does not accept a single candidate skill; it asks "
380
+ f"for {constraints.min_skills} to {constraints.max_skills}",
381
+ code=_CANDIDATE_POLICY_VIOLATION,
382
+ details={
383
+ "min_skills": constraints.min_skills,
384
+ "max_skills": constraints.max_skills,
385
+ },
386
+ )
387
+
388
+ release = resolved.data_policy.candidate_skill.public_release
389
+ if release == "prohibited":
390
+ raise PolicyError(
391
+ "this Climb's DataPolicy prohibits releasing a candidate skill, "
392
+ "so there is nothing a submission could be entered as",
393
+ code=_CANDIDATE_POLICY_VIOLATION,
394
+ details={"candidate_skill_public_release": release},
395
+ )
396
+
397
+ self._require_candidate_files(scan)
398
+
399
+ def _require_candidate_files(self, scan: SkillScanResult) -> None:
400
+ """Enforce the file-count ceiling every candidate is held to.
401
+
402
+ The Climb's own constraints and its public-release rule are checked
403
+ beside this one when a Climb is what invited the submission. A locally
404
+ derived replacement has neither, and inventing a public rule for a
405
+ private comparison would be inventing a policy nobody stated.
406
+ """
407
+ if len(scan.files) > self._policy.maximum_files:
408
+ raise PolicyError(
409
+ f"this candidate has {len(scan.files)} files and the limit is "
410
+ f"{self._policy.maximum_files}",
411
+ code=_CANDIDATE_POLICY_VIOLATION,
412
+ details={"file_count": len(scan.files)},
413
+ )
414
+
415
+ # -- The snapshot ------------------------------------------------------
416
+
417
+ def _scan(self, skill_path: Path) -> SkillScanResult:
418
+ """Run the PR5 scanner and report its refusals in PR6's vocabulary."""
419
+ try:
420
+ return scan_skill(skill_path, self._policy)
421
+ except ValidationError as error:
422
+ # The scanner's own message and details are kept; only the code is
423
+ # added here, so a caller can branch on it.
424
+ raise ValidationError(
425
+ error.message,
426
+ code=_SKILL_INVALID,
427
+ details=error.details,
428
+ ) from error
429
+
430
+ def _snapshot_skill(
431
+ self,
432
+ *,
433
+ skill_dir: Path,
434
+ scan: SkillScanResult,
435
+ candidate_label: str | None,
436
+ parent_skill_digest: Digest | None = None,
437
+ ) -> StagedSkill:
438
+ """Create ``artifact.json``, ``bundle.tar``, and ``files/`` in staging.
439
+
440
+ Each file is read once, checked against what the scan recorded, and
441
+ written into the snapshot. The archive is then built from the copies
442
+ rather than from the originals, so the expanded tree and the archive
443
+ cannot disagree even if the source directory changes underneath us.
444
+
445
+ The staged ``artifact.json`` makes the staging skill directory complete
446
+ and self-describing. The draft store writes its own copy from
447
+ ``draft.skill_artifact`` rather than moving this one, because the only
448
+ artifact document a draft may hold is the one the draft's own digest
449
+ commits to.
450
+
451
+ ``parent_skill_digest`` names the Skill this one revises. It is set
452
+ for a replacement candidate and absent for an insertion, which is the
453
+ only lineage a ``SkillArtifact`` records.
454
+ """
455
+ files_dir = skill_dir / _FILES_DIR
456
+ archive_path = skill_dir / _BUNDLE_FILE
457
+ ensure_private_directory(skill_dir)
458
+ ensure_private_directory(files_dir)
459
+
460
+ copied = [self._copy_one(item, files_dir) for item in scan.files]
461
+ archive_digest = build_deterministic_tar(copied, archive_path)
462
+
463
+ entries = [
464
+ SkillFile(
465
+ path=item.relative_path.as_posix(),
466
+ media_type=item.media_type,
467
+ size=item.size,
468
+ digest=item.digest,
469
+ )
470
+ for item in copied
471
+ ]
472
+ artifact = SkillArtifact(
473
+ schema_version=SKILL_SCHEMA_VERSION,
474
+ name=_candidate_name(candidate_label, scan.root),
475
+ root_digest=skill_content_digest(entries),
476
+ archive_digest=archive_digest,
477
+ files=entries,
478
+ source_kind="manual",
479
+ parent_skill_digest=parent_skill_digest,
480
+ )
481
+ atomic_write_bytes(skill_dir / _ARTIFACT_FILE, canonical_json_bytes(artifact))
482
+ return StagedSkill(artifact=artifact, archive=archive_path, files=files_dir)
483
+
484
+ def _copy_one(self, item: ScannedFile, files_dir: Path) -> ScannedFile:
485
+ """Copy one scanned file into the snapshot, or refuse the snapshot."""
486
+ try:
487
+ data = item.source_path.read_bytes()
488
+ except OSError as error:
489
+ raise VerificationError(
490
+ "a candidate skill file could not be read while it was being "
491
+ f"snapshotted: {item.relative_path}",
492
+ code=_SKILL_SNAPSHOT_FAILED,
493
+ details={"path": item.relative_path.as_posix()},
494
+ ) from error
495
+
496
+ if len(data) != item.size or sha256_digest_bytes(data) != item.digest:
497
+ raise VerificationError(
498
+ "a candidate skill file changed while it was being "
499
+ f"snapshotted, so the draft was abandoned: {item.relative_path}",
500
+ code=_SKILL_SNAPSHOT_FAILED,
501
+ details={"path": item.relative_path.as_posix()},
502
+ )
503
+
504
+ target = files_dir / item.relative_path
505
+ ensure_private_directory(target.parent)
506
+ atomic_write_bytes(target, data)
507
+ return ScannedFile(
508
+ source_path=target,
509
+ relative_path=PurePosixPath(item.relative_path),
510
+ size=item.size,
511
+ media_type=item.media_type,
512
+ digest=item.digest,
513
+ )
514
+
515
+ # -- The science -------------------------------------------------------
516
+
517
+ def _require_controlled(
518
+ self,
519
+ baseline: ExperimentManifest,
520
+ candidate: ExperimentManifest,
521
+ campaign: CampaignSpec,
522
+ ) -> ManifestComparison:
523
+ """Compare both variants and refuse a pair that measures more than one thing."""
524
+ comparison = compare_manifests(baseline, candidate, campaign.mutation_contract)
525
+ assert_controlled_comparison(comparison)
526
+ return comparison
527
+
528
+ def _build_policy_requirement(
529
+ self, data_policy: DataPolicy
530
+ ) -> PolicyAcceptanceRequirement:
531
+ """State the rights a participant will be asked to accept."""
532
+ return PolicyAcceptanceRequirement(
533
+ data_policy_digest=digest_object(data_policy),
534
+ required=True,
535
+ summary=rights_summary(data_policy),
536
+ )
537
+
538
+ def _build_draft(
539
+ self,
540
+ *,
541
+ source: CampaignSource,
542
+ skill: SkillArtifact,
543
+ baseline: ExperimentManifest,
544
+ candidate: ExperimentManifest,
545
+ policy: PolicyAcceptanceRequirement,
546
+ created_at: datetime,
547
+ warnings: list[str],
548
+ ) -> SubmissionDraft:
549
+ """Construct the immutable protocol draft."""
550
+ return SubmissionDraft(
551
+ schema_version=SUBMISSION_DRAFT_SCHEMA_VERSION,
552
+ id=new_id("draft"),
553
+ campaign_spec_digest=source.campaign_digest,
554
+ program_ref=source.campaign.context.program_ref,
555
+ public_context=source.public_context,
556
+ data_policy_digest=source.data_policy_digest,
557
+ outcome_contract_digest=source.campaign.context.outcome_contract_digest,
558
+ skill_artifact=skill,
559
+ baseline_manifest_digest=digest_object(baseline),
560
+ candidate_manifest_digest=digest_object(candidate),
561
+ included_files=[file.path for file in skill.files],
562
+ estimated_episodes=self._estimate_episodes(source.campaign),
563
+ policy_acceptance=policy,
564
+ warnings=warnings,
565
+ created_at=created_at,
566
+ )
567
+
568
+ def _estimate_episodes(self, campaign: CampaignSpec) -> int:
569
+ """Return tasks × rollouts × two variants."""
570
+ selection = campaign.taskset.selection
571
+ return selection.num_tasks * selection.num_rollouts * _VARIANTS
572
+
573
+ def _warnings(self, resolved: ResolvedClimb) -> list[str]:
574
+ """Say everything a participant should know before confirming."""
575
+ warnings: list[str] = []
576
+
577
+ if resolved.climb.publication.proof_grade == "development_only":
578
+ warnings.append(
579
+ "This is a development Climb. Its results are for trying the "
580
+ "flow out and are not comparable evidence."
581
+ )
582
+
583
+ warnings.append(
584
+ "Results are attested by this machine only. Nobody else has "
585
+ "verified that this run happened as described."
586
+ )
587
+ warnings.append(_comparison_warning(resolved.campaign))
588
+ if executor_kind_for(resolved.campaign) == "fake":
589
+ warnings.append(
590
+ "Nothing produced here is a public proof. No agent is "
591
+ "evaluated and no model is called on this Climb's runs."
592
+ )
593
+ else:
594
+ warnings.append(
595
+ "Nothing produced here is a public proof. Starting this run "
596
+ "evaluates the agent for real and spends model tokens on "
597
+ "inference at the model provider you configured. A provider "
598
+ "that charges for tokens bills that use to your own account; "
599
+ "a model you run yourself sends no bill."
600
+ )
601
+
602
+ release = resolved.data_policy.candidate_skill.public_release
603
+ if release == "required_for_climb":
604
+ warnings.append(
605
+ "Entering this Climb requires releasing the candidate skill publicly."
606
+ )
607
+ elif release == "consent_required":
608
+ warnings.append(
609
+ "Releasing the candidate skill publicly needs your separate consent."
610
+ )
611
+
612
+ return warnings
613
+
614
+ def _replacement_warnings(self) -> list[str]:
615
+ """Say what a local Skill-against-Skill comparison is, and is not.
616
+
617
+ None of the public warnings apply: there is no Climb to enter, nothing
618
+ is released, and no leaderboard is involved. What a reader has to know
619
+ instead is that the baseline is no longer "no Skill" — a candidate that
620
+ loses here lost to a Skill that already worked.
621
+ """
622
+ return [
623
+ # First, because the rights summary this draft carries is the
624
+ # policy of the Climb the first run entered, and read on its own it
625
+ # would describe a public submission this is not.
626
+ "The data rights stated for this run are the ones the run it was "
627
+ "prepared from was carried out under. They still govern what "
628
+ "happens to this run's material, and they are why acceptance is "
629
+ "asked for again.",
630
+ "This comparison is local. No Climb wraps it, and nothing is "
631
+ "entered anywhere. Nothing is published unless you publish this "
632
+ "run yourself once it has finished, and what travels then is the "
633
+ "run's proof — the signed report and its receipts — and never the "
634
+ "episodes. Model inference still goes to the model provider you "
635
+ "configured, under that provider's policies.",
636
+ "This compares one Skill against another Skill, not against no "
637
+ "Skill. The baseline is the version measured by the run this was "
638
+ "prepared from.",
639
+ "Results are attested by this machine only. Nobody else has "
640
+ "verified that this run happened as described.",
641
+ ]
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # How the comparison is run
646
+ # ---------------------------------------------------------------------------
647
+
648
+
649
+ def _comparison_warning(campaign: CampaignSpec) -> str:
650
+ """Say how this Campaign runs its two sides, reading it off the Campaign.
651
+
652
+ The approval screen is where a person decides to spend model tokens on a
653
+ comparison, so the sentence describing how that comparison is controlled
654
+ has to be the one this Campaign will actually produce. It is therefore
655
+ derived from ``execution.order`` — the same field the executor dispatches
656
+ on when it picks between running the two sides side by side and running one
657
+ after the other (``runs/real.py``). Writing the sentence out by hand is how
658
+ it came to describe an order the Campaign had not asked for since the day
659
+ side-by-side execution arrived.
660
+
661
+ Exhaustive on purpose: a Campaign that grows a third way of running its two
662
+ sides fails to typecheck here rather than quietly inheriting a sentence
663
+ written for a different one.
664
+ """
665
+ match campaign.execution.order:
666
+ case VariantSchedule.PARALLEL:
667
+ return (
668
+ "The baseline and the candidate launch in parallel against the "
669
+ "same committed task set, under matched configuration, so the "
670
+ "two are compared and not merely reported."
671
+ )
672
+ case VariantSchedule.SEQUENTIAL:
673
+ return (
674
+ "The baseline runs first and the candidate second, on the same "
675
+ "committed tasks, so the two are compared and not merely "
676
+ "reported."
677
+ )
678
+ assert_never(campaign.execution.order)
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # The rights summary
683
+ # ---------------------------------------------------------------------------
684
+
685
+ #: How each permission is said in a sentence. Fixed strings: the summary is
686
+ #: stored in a protocol artifact and must read the same way every time the same
687
+ #: policy is prepared against.
688
+ _PERMISSION_PHRASE: Final[dict[str, str]] = {
689
+ "allowed": "allowed",
690
+ "prohibited": "prohibited",
691
+ "consent_required": "only with your separate consent",
692
+ }
693
+
694
+ _RELEASE_PHRASE: Final[dict[str, str]] = {
695
+ "required_for_climb": "required in order to enter this Climb",
696
+ "allowed": "allowed",
697
+ "prohibited": "prohibited",
698
+ "consent_required": "only with your separate consent",
699
+ }
700
+
701
+ _OWNER_PHRASE: Final[dict[str, str]] = {
702
+ "participant": "You own",
703
+ "account": "Your account owns",
704
+ "shared": "You and Techtree jointly own",
705
+ }
706
+
707
+ _VISIBILITY_PHRASE: Final[dict[str, str]] = {
708
+ "public": "published",
709
+ "private": "kept private",
710
+ "prohibited": "not produced",
711
+ }
712
+
713
+
714
+ def rights_summary(data_policy: DataPolicy) -> str:
715
+ """Return the stable sentence-per-right summary shown before confirming.
716
+
717
+ Derived entirely from the policy, so it cannot describe rights the policy
718
+ does not grant, and worded from fixed phrases, so the same policy always
719
+ produces the same bytes.
720
+ """
721
+ owner = _OWNER_PHRASE[data_policy.owner.kind]
722
+ raw = data_policy.raw_episodes
723
+ return " ".join(
724
+ [
725
+ f"{owner} the candidate skill and everything this run produces.",
726
+ "Publishing the candidate skill is "
727
+ f"{_RELEASE_PHRASE[data_policy.candidate_skill.public_release]}.",
728
+ "Uploading raw episodes to a server is "
729
+ f"{_PERMISSION_PHRASE[raw.server_upload]}.",
730
+ f"Training on raw episodes is {_PERMISSION_PHRASE[raw.training_use]}.",
731
+ "The uplift report is "
732
+ f"{_VISIBILITY_PHRASE[data_policy.derived_artifacts.uplift_report]}.",
733
+ (
734
+ "You can withdraw future uses later."
735
+ if data_policy.revocation.future_use_revocable
736
+ else "Future uses cannot be withdrawn later."
737
+ ),
738
+ ]
739
+ )
740
+
741
+
742
+ def _candidate_name(candidate_label: str | None, root: Path) -> str:
743
+ """Return the name this candidate is filed under."""
744
+ label = (candidate_label or root.name).strip()
745
+ if _LABEL_PATTERN.fullmatch(label) is None:
746
+ raise ValidationError(
747
+ "a candidate label is up to 64 letters, digits, spaces, dots, "
748
+ "dashes, or underscores, and starts with a letter or a digit",
749
+ code=_SKILL_INVALID,
750
+ details={"label": label},
751
+ )
752
+ return label