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,326 @@
1
+ """Running the checks and deciding what to do about them. Spec section 13.2.
2
+
3
+ The service owns three decisions the CLI must not make for itself: the order
4
+ checks run in, which failures actually block, and which repairs are worth
5
+ offering. Keeping them here is what lets the Doctor command be nine lines of
6
+ plumbing with no opinion in it.
7
+
8
+ Repairs are chosen by priority and capped at three. Some problems have a
9
+ concrete argument vector that fixes them — a permissions change, an engine
10
+ install. Others cannot be fixed by anything Techtree is allowed to run, and for
11
+ those the action's argv is the re-check and the reason says what a person has
12
+ to do first. That is deliberate: a next action is a promise that running the
13
+ vector is safe and sensible, so it never carries a placeholder to be filled in
14
+ or an installer piped out of the network.
15
+
16
+ When nothing needs repair the caller is not left without a next step. The
17
+ useful thing to do on a healthy host is to look at what there is to climb.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable, Iterable
23
+ from typing import Final
24
+
25
+ from techtree.doctor.checks import (
26
+ check_active_engine,
27
+ check_docker_cli,
28
+ check_docker_daemon,
29
+ check_hermes_cli,
30
+ check_hermes_plugin,
31
+ check_host_platform,
32
+ check_python_version,
33
+ check_techtree_home,
34
+ check_uv_cli,
35
+ detect_host_platform,
36
+ )
37
+ from techtree.doctor.execution_checks import execution_checks
38
+ from techtree.engines.registry import EngineRegistry
39
+ from techtree.models.base import NonEmptyString, ProtocolModel
40
+ from techtree.models.campaign import CampaignSpec
41
+ from techtree.models.cli import MAX_NEXT_ACTIONS, CheckStatus, DoctorCheck, NextAction
42
+ from techtree.paths import TechtreePaths
43
+ from techtree.settings import Settings
44
+ from techtree.version import version_info
45
+
46
+ __all__ = ["DoctorReport", "DoctorService"]
47
+
48
+ #: Where uv's own installation instructions live. It goes in a reason, never in
49
+ #: an argument vector: Techtree does not offer to pipe an installer into a
50
+ #: shell on someone's behalf.
51
+ UV_INSTALL_DOCUMENTATION: Final = (
52
+ "https://docs.astral.sh/uv/getting-started/installation/"
53
+ )
54
+
55
+
56
+ class DoctorReport(ProtocolModel):
57
+ """Everything one Doctor run found.
58
+
59
+ The two platform strings are separate on purpose. ``host_platform`` is
60
+ where Techtree runs; ``docker_platform`` is where an evaluated subject
61
+ would run, and is absent whenever the daemon could not say.
62
+ """
63
+
64
+ techtree_home: NonEmptyString
65
+ host_platform: NonEmptyString | None
66
+ docker_platform: NonEmptyString | None
67
+ versions: dict[str, NonEmptyString]
68
+ checks: list[DoctorCheck]
69
+
70
+
71
+ class DoctorService:
72
+ """Runs environment checks and turns them into repairs."""
73
+
74
+ def __init__(self, paths: TechtreePaths, settings: Settings) -> None:
75
+ self._paths = paths
76
+ self._settings = settings
77
+
78
+ def run(
79
+ self,
80
+ *,
81
+ for_evaluation: bool = False,
82
+ campaign: CampaignSpec | None = None,
83
+ ) -> list[DoctorCheck]:
84
+ """Run checks in deterministic order.
85
+
86
+ ``for_evaluation`` adds the execution checks of spec section 6.18 and
87
+ makes them blocking. An ordinary Doctor is a question about the machine
88
+ and answers it with warnings; the evaluation Doctor is the gate in front
89
+ of a run that provisions containers and spends money on model calls, so
90
+ the same facts stop it rather than merely being noted.
91
+
92
+ A ``campaign`` narrows the question from "could anything run here" to
93
+ "could this run here", which is the only form in which the subject's
94
+ credential and image can be checked at all.
95
+ """
96
+ host_platform = detect_host_platform()
97
+ checks = [
98
+ check_python_version(),
99
+ check_host_platform(host_platform),
100
+ check_techtree_home(self._paths),
101
+ check_uv_cli(),
102
+ check_docker_cli(),
103
+ check_docker_daemon(),
104
+ check_hermes_cli(),
105
+ check_hermes_plugin(),
106
+ check_active_engine(self._paths, self._settings),
107
+ ]
108
+ if not for_evaluation:
109
+ return checks
110
+ registry = EngineRegistry(self._paths, self._settings)
111
+ checks.extend(execution_checks(engine_registry=registry, campaign=campaign))
112
+ return checks
113
+
114
+ def report(self, checks: list[DoctorCheck]) -> DoctorReport:
115
+ """Project the checks into the command's response payload."""
116
+ return DoctorReport(
117
+ techtree_home=str(self._paths.root),
118
+ host_platform=_metadata_string(checks, "host_platform", "host_platform"),
119
+ docker_platform=_metadata_string(
120
+ checks, "docker_daemon", "docker_platform"
121
+ ),
122
+ versions=version_info(),
123
+ checks=list(checks),
124
+ )
125
+
126
+ def blocking_failures(self, checks: list[DoctorCheck]) -> list[DoctorCheck]:
127
+ """Return blocking failures."""
128
+ return [check for check in checks if check.blocking]
129
+
130
+ def warnings(self, checks: list[DoctorCheck]) -> list[DoctorCheck]:
131
+ """Return warnings."""
132
+ return [
133
+ check
134
+ for check in checks
135
+ if check.status is CheckStatus.WARN
136
+ or (check.status is CheckStatus.FAIL and not check.blocking)
137
+ ]
138
+
139
+ def next_actions(self, checks: list[DoctorCheck]) -> list[NextAction]:
140
+ """Create no more than three repair actions."""
141
+ by_id = {check.id: check for check in checks}
142
+ actions: list[NextAction] = []
143
+ offered: set[str] = set()
144
+
145
+ for check_id, build in _REPAIRS:
146
+ check = by_id.get(check_id)
147
+ if check is None or check.status in (CheckStatus.PASS, CheckStatus.SKIP):
148
+ continue
149
+ action = build(self._paths)
150
+ # Two checks can share one repair — a missing Docker CLI and an
151
+ # unreachable daemon are both fixed by installing and starting it.
152
+ if action is None or action.id in offered:
153
+ continue
154
+ actions.append(action)
155
+ offered.add(action.id)
156
+ if len(actions) == MAX_NEXT_ACTIONS:
157
+ return actions
158
+
159
+ if actions:
160
+ return actions
161
+ # Nothing offered a repair. That means either the host is fine, or the
162
+ # only things wrong with it are things Techtree cannot fix — a Campaign
163
+ # that is not meant to be executed yet, for instance. Saying "this host
164
+ # is ready" in the second case would be a lie told cheerfully.
165
+ return [_browse_climbs(ready=not any(check.blocking for check in checks))]
166
+
167
+
168
+ def _metadata_string(
169
+ checks: Iterable[DoctorCheck], check_id: str, key: str
170
+ ) -> str | None:
171
+ """Read one string out of a check's metadata, if the check reported it."""
172
+ for check in checks:
173
+ if check.id != check_id:
174
+ continue
175
+ value = check.metadata.get(key)
176
+ return value if isinstance(value, str) else None
177
+ return None
178
+
179
+
180
+ def _fix_home_permissions(paths: TechtreePaths) -> NextAction:
181
+ return NextAction(
182
+ id="fix_techtree_home_permissions",
183
+ label="Make the Techtree home directory private and writable",
184
+ reason="Techtree stores drafts, runs, and engines there for one user only.",
185
+ cli=["chmod", "700", str(paths.root)],
186
+ hermes_tool=None,
187
+ hermes_args=None,
188
+ requires_user_confirmation=True,
189
+ )
190
+
191
+
192
+ def _install_supported_python(_: TechtreePaths) -> NextAction:
193
+ return NextAction(
194
+ id="install_supported_python",
195
+ label="Install a supported Python and re-run Doctor",
196
+ reason="Techtree requires Python 3.12 or 3.13.",
197
+ cli=["uv", "python", "install", "3.12"],
198
+ hermes_tool=None,
199
+ hermes_args=None,
200
+ requires_user_confirmation=True,
201
+ )
202
+
203
+
204
+ def _install_engine(_: TechtreePaths) -> NextAction:
205
+ return NextAction(
206
+ id="install_engine",
207
+ label="Install the managed evaluation engine",
208
+ reason="Preparing or starting a Climb needs an installed, active engine.",
209
+ cli=["techtree", "engine", "install"],
210
+ hermes_tool=None,
211
+ hermes_args=None,
212
+ requires_user_confirmation=False,
213
+ )
214
+
215
+
216
+ def _recheck_after_installing_uv(_: TechtreePaths) -> NextAction:
217
+ return NextAction(
218
+ id="recheck_after_installing_uv",
219
+ label="Install uv, then re-run Doctor",
220
+ reason=(
221
+ "The managed evaluation engine is installed with uv. Installation "
222
+ f"instructions: {UV_INSTALL_DOCUMENTATION}"
223
+ ),
224
+ cli=["techtree", "doctor"],
225
+ hermes_tool=None,
226
+ hermes_args=None,
227
+ requires_user_confirmation=False,
228
+ )
229
+
230
+
231
+ def _recheck_after_starting_docker(_: TechtreePaths) -> NextAction:
232
+ return NextAction(
233
+ id="recheck_after_starting_docker",
234
+ label="Start Docker, then re-run Doctor",
235
+ reason="An evaluated subject runs in a container on this host.",
236
+ cli=["techtree", "doctor"],
237
+ hermes_tool=None,
238
+ hermes_args=None,
239
+ requires_user_confirmation=False,
240
+ )
241
+
242
+
243
+ def _unsupported_host(_: TechtreePaths) -> None:
244
+ """No repair exists for the wrong machine, so no action is offered."""
245
+ return None
246
+
247
+
248
+ def _sign_in_to_prime(_: TechtreePaths) -> NextAction:
249
+ return NextAction(
250
+ id="sign_in_to_prime",
251
+ label="Sign in to Prime, then re-run this check",
252
+ reason=(
253
+ "The evaluated subject's model calls are paid for by a credential "
254
+ "a run reads for itself, which is why signing in to Prime works "
255
+ "and exporting the credential in a terminal does not. It is "
256
+ "separate from whatever your own agent is signed in with. After "
257
+ "signing in, run techtree doctor --for-evaluation again."
258
+ ),
259
+ cli=["prime", "login"],
260
+ hermes_tool=None,
261
+ hermes_args=None,
262
+ requires_user_confirmation=True,
263
+ )
264
+
265
+
266
+ def _pull_subject_image(_: TechtreePaths) -> NextAction:
267
+ return NextAction(
268
+ id="pull_subject_image",
269
+ label="Pull the subject's container image, then re-run this check",
270
+ reason=(
271
+ "The evaluated subject runs in the image the Campaign pins. "
272
+ "Downloading it is a deliberate setup step, so Techtree reports it "
273
+ "rather than doing it during a check."
274
+ ),
275
+ cli=["techtree", "doctor", "--for-evaluation"],
276
+ hermes_tool=None,
277
+ hermes_args=None,
278
+ requires_user_confirmation=False,
279
+ )
280
+
281
+
282
+ def _not_runnable_yet(_: TechtreePaths) -> None:
283
+ """A Campaign that is not meant to be executed has no repair."""
284
+ return None
285
+
286
+
287
+ def _browse_climbs(*, ready: bool) -> NextAction:
288
+ return NextAction(
289
+ id="list_climbs",
290
+ label="Browse the available Climbs",
291
+ reason=(
292
+ "This host is ready."
293
+ if ready
294
+ else (
295
+ "Nothing left to fix automatically; each failed check says what "
296
+ "it needs."
297
+ )
298
+ ),
299
+ cli=["techtree", "climb", "list"],
300
+ hermes_tool=None,
301
+ hermes_args=None,
302
+ requires_user_confirmation=False,
303
+ )
304
+
305
+
306
+ #: Check identifier to repair builder, most important first. A builder returns
307
+ #: None when the problem is real and nothing runnable would fix it.
308
+ type _Repair = Callable[[TechtreePaths], NextAction | None]
309
+
310
+ _REPAIRS: Final[tuple[tuple[str, _Repair], ...]] = (
311
+ ("techtree_home", _fix_home_permissions),
312
+ ("python_version", _install_supported_python),
313
+ ("host_platform", _unsupported_host),
314
+ ("uv", _recheck_after_installing_uv),
315
+ ("active_engine", _install_engine),
316
+ ("docker_cli", _recheck_after_starting_docker),
317
+ ("docker_daemon", _recheck_after_starting_docker),
318
+ # Spec section 6.18. These only appear under --for-evaluation, and they sit
319
+ # last because a host that fails an ordinary check fails these too, and the
320
+ # ordinary repair is the one worth offering first.
321
+ ("execution_docker_platform", _recheck_after_starting_docker),
322
+ ("execution_engine_eval", _install_engine),
323
+ ("execution_model_credential", _sign_in_to_prime),
324
+ ("execution_subject_image", _pull_subject_image),
325
+ ("execution_live_campaign", _not_runnable_yet),
326
+ )
@@ -0,0 +1,32 @@
1
+ """Immutable submission drafts and the one-time handover that starts one.
2
+
3
+ Spec PR6 §6.7.
4
+
5
+ A draft is the complete, reviewable statement of what a participant is about to
6
+ run: the public graph it came from, the two experiment variants, the proof that
7
+ those variants differ only where permitted, the snapshotted candidate skill,
8
+ and the rights the participant is being asked to accept. Once written it is
9
+ never edited. A change of mind is a new draft.
10
+
11
+ Two modules divide the work:
12
+
13
+ :mod:`techtree.drafts.source`
14
+ What a draft is prepared against: a Campaign, its DataPolicy, its
15
+ publisher validation receipt, and the public Climb that invited it when
16
+ there is one. A Skill replacement (spec section 7.19) is derived locally
17
+ and no Climb wraps it, so the Climb is the one part that may be absent.
18
+
19
+ :mod:`techtree.drafts.store`
20
+ Placement, verification, and the start claim. Where each file lives under
21
+ ``drafts/draft_<id>/``, how the whole graph is re-verified offline without
22
+ consulting the catalog, and the exactly-once handover to a run.
23
+
24
+ Preparing a draft is not accepting its data policy. Decisions document 0019
25
+ section 2 puts the acceptance where a person can act on it: the draft is
26
+ reviewed, the review is answered, and the run records that it was — nothing
27
+ here may be read as consent.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ __all__: list[str] = []
@@ -0,0 +1,146 @@
1
+ """What a draft is prepared against, with or without a public Climb.
2
+
3
+ Every draft this build produced before WP7d came from a
4
+ :class:`~techtree.models.climb.ResolvedClimb`: a public invitation wrapping a
5
+ Campaign. Spec section 7.19 asks for one that does not. A Skill-replacement
6
+ Campaign is derived locally from a run that already finished, and no Climb
7
+ wraps it — :class:`~techtree.models.climb.CandidatePolicy` can only require
8
+ ``skill_insertion``, so a ``ResolvedClimb`` holding a replacement Campaign is
9
+ unrepresentable rather than merely unusual.
10
+
11
+ :class:`CampaignSource` is what the kernel carries instead. It holds the three
12
+ objects a run's science actually needs — the Campaign, the DataPolicy it runs
13
+ under, and the publisher's taskset validation receipt — and the public Climb
14
+ beside them when there is one. Preparation, the draft store, the run's staged
15
+ inputs, and the report stage all read it, so a local replacement travels the
16
+ same path as a public submission rather than a parallel one.
17
+
18
+ Two properties are deliberate.
19
+
20
+ *The Climb's own edge checks stay in one place.* When a source comes from a
21
+ Climb it comes through :meth:`CampaignSource.from_climb`, which takes an
22
+ already-validated ``ResolvedClimb``; when it is read back from disk the Climb
23
+ is reassembled into a ``ResolvedClimb`` first, so its validator runs on the
24
+ bytes. Nothing here re-implements those checks, and a Climb-free source is not
25
+ given a weaker version of them: it has no Climb to check.
26
+
27
+ *A source is a container, not an authority.* The Campaign-to-DataPolicy and
28
+ Campaign-to-receipt edges are verified where the bytes are — in
29
+ :meth:`~techtree.drafts.store.DraftStore.verify_snapshot` and in
30
+ :class:`~techtree.runs.artifacts.RunArtifactStore` — because those are the
31
+ places that know which digest a *request* said to expect.
32
+
33
+ :class:`StagedSkill` is the other half of the same generalization. An insertion
34
+ stages one Skill, the candidate's. A replacement stages two, because the
35
+ baseline variant carries the Skill being revised and the subject has to be
36
+ given its files, not just its digest.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+
44
+ from techtree.canonical import digest_object
45
+ from techtree.models.base import Digest
46
+ from techtree.models.campaign import CampaignSpec, MutationKind, PublicContext
47
+ from techtree.models.climb import ClimbManifest, ResolvedClimb
48
+ from techtree.models.data_policy import DataPolicy
49
+ from techtree.models.skill import SkillArtifact
50
+ from techtree.models.validation import TasksetValidationReceipt
51
+
52
+ __all__ = ["CampaignSource", "StagedSkill"]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class StagedSkill:
57
+ """One Skill as a draft or a run owns it: the artifact and its bytes."""
58
+
59
+ artifact: SkillArtifact
60
+ archive: Path
61
+ files: Path
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class CampaignSource:
66
+ """The Campaign graph a draft is prepared against.
67
+
68
+ ``climb`` and ``climb_digest`` are present together or absent together: a
69
+ Climb-free source is a local Campaign, which spec section 7.19 derives for
70
+ a Skill replacement and which no public invitation wraps.
71
+ """
72
+
73
+ campaign: CampaignSpec
74
+ campaign_digest: Digest
75
+ data_policy: DataPolicy
76
+ data_policy_digest: Digest
77
+ publisher_validation: TasksetValidationReceipt
78
+ publisher_validation_digest: Digest
79
+ climb: ClimbManifest | None
80
+ climb_digest: Digest | None
81
+
82
+ def __post_init__(self) -> None:
83
+ """Reject a half-present Climb, which no reader could interpret."""
84
+ if (self.climb is None) != (self.climb_digest is None):
85
+ raise ValueError(
86
+ "a campaign source names a public Climb and the digest it was "
87
+ "loaded under together, or neither"
88
+ )
89
+
90
+ @classmethod
91
+ def from_climb(cls, resolved: ResolvedClimb) -> CampaignSource:
92
+ """Return the source one resolved public Climb describes."""
93
+ return cls(
94
+ campaign=resolved.campaign,
95
+ campaign_digest=resolved.campaign_digest,
96
+ data_policy=resolved.data_policy,
97
+ data_policy_digest=resolved.data_policy_digest,
98
+ publisher_validation=resolved.publisher_validation,
99
+ publisher_validation_digest=resolved.publisher_validation_digest,
100
+ climb=resolved.climb,
101
+ climb_digest=resolved.climb_digest,
102
+ )
103
+
104
+ @classmethod
105
+ def local(
106
+ cls,
107
+ *,
108
+ campaign: CampaignSpec,
109
+ data_policy: DataPolicy,
110
+ publisher_validation: TasksetValidationReceipt,
111
+ ) -> CampaignSource:
112
+ """Return a source for a locally derived Campaign that no Climb wraps."""
113
+ return cls(
114
+ campaign=campaign,
115
+ campaign_digest=digest_object(campaign),
116
+ data_policy=data_policy,
117
+ data_policy_digest=digest_object(data_policy),
118
+ publisher_validation=publisher_validation,
119
+ publisher_validation_digest=digest_object(publisher_validation),
120
+ climb=None,
121
+ climb_digest=None,
122
+ )
123
+
124
+ @property
125
+ def public_context(self) -> PublicContext | None:
126
+ """Return the public context artifacts built from this source carry."""
127
+ if self.climb_digest is None:
128
+ return None
129
+ return PublicContext(kind="climb", climb_digest=self.climb_digest)
130
+
131
+ @property
132
+ def title(self) -> str:
133
+ """Return what to call this comparison in a result a person reads.
134
+
135
+ A public Climb has a title written for readers. A locally derived
136
+ Campaign has only its own identity, and saying so is better than
137
+ inventing a name for it.
138
+ """
139
+ if self.climb is not None:
140
+ return self.climb.metadata.title
141
+ return f"{self.campaign.metadata.id} v{self.campaign.metadata.version}"
142
+
143
+ @property
144
+ def replaces_a_skill(self) -> bool:
145
+ """Return whether this Campaign's baseline carries the Skill being revised."""
146
+ return self.campaign.mutation_contract.kind is MutationKind.SKILL_REPLACEMENT