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,227 @@
1
+ """The packaged catalog and what it can tell a caller. Decisions 0003 A6/A7.
2
+
3
+ Three things live here.
4
+
5
+ The *index* is the map from a public Climb reference, and from every object
6
+ digest it depends on, to a file inside the package. It is generated, never
7
+ hand-edited, and it is typed and schema'd so that a hand-edit is caught rather
8
+ than shipped. Paths are relative and non-escaping; digests appear once; two
9
+ digests never claim the same file.
10
+
11
+ The *summary* is what ``techtree climb show`` needs in one object: identity,
12
+ what is being measured, and the rights the participant would be agreeing to.
13
+ ``DataPolicySummary`` is a deliberate projection rather than the whole
14
+ ``DataPolicy``, because a listing that reprinted the full policy tree would be
15
+ read by nobody.
16
+
17
+ The *compatibility result* is the honest answer to "could I actually run this
18
+ here?" — host platform, engine state, evaluation backend — as a list of typed
19
+ issues. ``compatible`` is derived from the blocking issues rather than asserted
20
+ alongside them, so a result cannot claim to be runnable while listing the
21
+ reason it is not. Before WP4 an absent engine blocks ``prepare`` while ``list``
22
+ and ``show`` still display the Climb.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from enum import StrEnum
28
+ from typing import Literal, Self
29
+
30
+ from pydantic import Field, model_validator
31
+
32
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel
33
+ from techtree.models.evaluation_backend import EvaluationBackendKind
34
+
35
+ __all__ = [
36
+ "CatalogClimbEntry",
37
+ "CatalogIndex",
38
+ "CatalogObjectLocation",
39
+ "ClimbSummary",
40
+ "CompatibilityIssue",
41
+ "CompatibilityResult",
42
+ "DataPolicySummary",
43
+ "EngineCompatibilityStatus",
44
+ ]
45
+
46
+
47
+ def _check_relative_catalog_path(value: str) -> str:
48
+ """Reject absolute, escaping, or non-POSIX catalog paths."""
49
+ if value != value.strip():
50
+ raise ValueError(f"catalog path has surrounding whitespace: {value!r}")
51
+ if value.startswith("/") or (len(value) > 1 and value[1] == ":"):
52
+ raise ValueError(f"catalog paths are relative to the catalog root: {value!r}")
53
+ if "\\" in value:
54
+ raise ValueError(f"catalog paths use forward slashes: {value!r}")
55
+ if any(segment in ("", ".", "..") for segment in value.split("/")):
56
+ raise ValueError(f"catalog path is not a normalized relative path: {value!r}")
57
+ return value
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # The index
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class CatalogClimbEntry(ProtocolModel):
66
+ """One public Climb the package ships."""
67
+
68
+ reference: NonEmptyString
69
+ digest: Digest
70
+ path: NonEmptyString
71
+
72
+ @model_validator(mode="after")
73
+ def _check_path(self) -> Self:
74
+ """Reject a path that leaves the catalog root."""
75
+ _check_relative_catalog_path(self.path)
76
+ return self
77
+
78
+
79
+ class CatalogObjectLocation(ProtocolModel):
80
+ """Where one content-addressed object lives, and what kind it is."""
81
+
82
+ kind: Literal[
83
+ "campaign",
84
+ "data_policy",
85
+ "taskset_validation",
86
+ "validation_evidence",
87
+ ]
88
+ path: NonEmptyString
89
+ media_type: NonEmptyString
90
+
91
+ @model_validator(mode="after")
92
+ def _check_path(self) -> Self:
93
+ """Reject a path that leaves the catalog root."""
94
+ _check_relative_catalog_path(self.path)
95
+ return self
96
+
97
+
98
+ class CatalogIndex(ProtocolModel):
99
+ """The generated map of everything the package ships.
100
+
101
+ Decisions document 0003 A7. Path existence, digest verification, and the
102
+ kind-matches-the-request check belong to the repository that loads these
103
+ files; what can be decided from the document alone is decided here.
104
+ """
105
+
106
+ schema_version: Literal["techtree.catalog.v1alpha1"]
107
+ climbs: list[CatalogClimbEntry]
108
+ objects: dict[Digest, CatalogObjectLocation]
109
+
110
+ @model_validator(mode="after")
111
+ def _check_index_is_unambiguous(self) -> Self:
112
+ """Reject repeated references, repeated digests, or shared paths."""
113
+ references = [entry.reference for entry in self.climbs]
114
+ if len(set(references)) != len(references):
115
+ raise ValueError("each Climb reference appears once in the catalog")
116
+ climb_digests = [entry.digest for entry in self.climbs]
117
+ if len(set(climb_digests)) != len(climb_digests):
118
+ raise ValueError("each Climb digest appears once in the catalog")
119
+
120
+ paths = [entry.path for entry in self.climbs] + [
121
+ location.path for location in self.objects.values()
122
+ ]
123
+ if len(set(paths)) != len(paths):
124
+ raise ValueError(
125
+ "two catalog entries claim the same file; a digest-to-path map "
126
+ "that is not one-to-one cannot be verified"
127
+ )
128
+ return self
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Compatibility
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class CompatibilityIssue(ProtocolModel):
137
+ """One reason a Climb might not run here."""
138
+
139
+ code: NonEmptyString
140
+ severity: Literal["warning", "error"]
141
+ message: NonEmptyString
142
+ blocking: bool
143
+
144
+ @model_validator(mode="after")
145
+ def _check_blocking_implies_error(self) -> Self:
146
+ """Reject a warning that also claims to block."""
147
+ if self.blocking and self.severity != "error":
148
+ raise ValueError("a blocking issue is an error, not a warning")
149
+ return self
150
+
151
+
152
+ class EngineCompatibilityStatus(StrEnum):
153
+ """What is known about the required engine on this host."""
154
+
155
+ UNKNOWN = "unknown"
156
+ NOT_INSTALLED = "not_installed"
157
+ INSTALLED_UNVERIFIED = "installed_unverified"
158
+ VERIFIED = "verified"
159
+
160
+
161
+ class CompatibilityResult(ProtocolModel):
162
+ """Whether this host can run this Climb, and what stands in the way."""
163
+
164
+ compatible: bool
165
+ host_platform: NonEmptyString
166
+ host_supported: bool
167
+ required_engine_digest: Digest
168
+ engine_status: EngineCompatibilityStatus
169
+ evaluation_backend_kind: EvaluationBackendKind
170
+ evaluation_backend_supported: bool
171
+ issues: list[CompatibilityIssue]
172
+
173
+ @model_validator(mode="after")
174
+ def _check_compatibility_follows_from_the_issues(self) -> Self:
175
+ """Reject a verdict that disagrees with the issues it lists."""
176
+ blocking = any(issue.blocking for issue in self.issues)
177
+ if self.compatible and blocking:
178
+ raise ValueError("compatible is true only when no listed issue is blocking")
179
+ if not self.compatible and not blocking:
180
+ raise ValueError(
181
+ "an incompatible result must list the blocking issue that made "
182
+ "it incompatible"
183
+ )
184
+ if len({issue.code for issue in self.issues}) != len(self.issues):
185
+ raise ValueError("each compatibility issue is reported once")
186
+ return self
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Summaries
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ class DataPolicySummary(ProtocolModel):
195
+ """The four rights a participant most needs to see before starting."""
196
+
197
+ raw_episode_server_upload: Literal["allowed", "prohibited", "consent_required"]
198
+ raw_episode_training_use: Literal["allowed", "prohibited", "consent_required"]
199
+ candidate_skill_public_release: Literal[
200
+ "required_for_climb",
201
+ "allowed",
202
+ "prohibited",
203
+ "consent_required",
204
+ ]
205
+ uplift_report_visibility: Literal["public", "private", "prohibited"]
206
+
207
+
208
+ class ClimbSummary(ProtocolModel):
209
+ """Everything ``climb list`` and ``climb show`` display, in one object."""
210
+
211
+ reference: NonEmptyString
212
+ climb_digest: Digest
213
+ campaign_spec_digest: Digest
214
+ title: NonEmptyString
215
+ summary: NonEmptyString
216
+ status: Literal["open", "closed", "development"]
217
+ purpose: NonEmptyString
218
+ taskset_id: NonEmptyString
219
+ task_count: int = Field(ge=1)
220
+ subject_harness: NonEmptyString
221
+ subject_harness_version: NonEmptyString
222
+ mutation_kind: Literal["skill_insertion"]
223
+ candidate_skill_visibility: Literal["public", "private"]
224
+ evaluation_backend: EvaluationBackendKind
225
+ proof_grade: Literal["development_only", "P1"]
226
+ data_policy: DataPolicySummary
227
+ compatibility: CompatibilityResult
techtree/models/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """The machine-readable CLI surface. Spec section 11.14.
2
+
3
+ The CLI is the stable boundary a host agent programs against, so its output has
4
+ a shape rather than a format. Every command returns one ``CliEnvelope``:
5
+ succeeded or not, what it produced, what it wants to say, and — at most three —
6
+ what could sensibly be done next.
7
+
8
+ ``NextAction`` is the part that makes the boundary usable by something that is
9
+ not a person. An action carries an argv list, or a named host-agent tool with
10
+ arguments, and says whether a human must confirm it first. That last flag is
11
+ not advisory: it is how an irreversible step stays irreversible-by-a-person
12
+ even when a machine is driving.
13
+
14
+ Nothing in this module knows about the filesystem, the catalog, or a run. It is
15
+ the vocabulary those layers speak in, which is also why
16
+ :mod:`techtree.errors` can project a typed error into a ``CliError`` without
17
+ either module depending on the other's behavior.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from enum import StrEnum
23
+ from typing import Final, Literal, Self
24
+
25
+ from pydantic import Field, model_validator
26
+
27
+ from techtree.models.base import JsonValue, NonEmptyString, ProtocolModel
28
+
29
+ __all__ = [
30
+ "MAX_NEXT_ACTIONS",
31
+ "CheckStatus",
32
+ "CliEnvelope",
33
+ "CliError",
34
+ "CliMessage",
35
+ "DoctorCheck",
36
+ "MessageLevel",
37
+ "NextAction",
38
+ ]
39
+
40
+ #: A host agent that is offered ten choices is being asked to plan, not to act.
41
+ #: Three is the documented ceiling.
42
+ MAX_NEXT_ACTIONS: Final = 3
43
+
44
+
45
+ class MessageLevel(StrEnum):
46
+ """How much attention a message deserves."""
47
+
48
+ INFO = "info"
49
+ WARNING = "warning"
50
+ ERROR = "error"
51
+
52
+
53
+ class CliMessage(ProtocolModel):
54
+ """One thing the CLI has to say."""
55
+
56
+ level: MessageLevel
57
+ code: NonEmptyString | None
58
+ text: NonEmptyString
59
+
60
+
61
+ class NextAction(ProtocolModel):
62
+ """A concrete next step, addressed to a person or to a host agent."""
63
+
64
+ id: NonEmptyString
65
+ label: NonEmptyString
66
+ reason: NonEmptyString | None
67
+ cli: list[NonEmptyString] | None
68
+ hermes_tool: NonEmptyString | None
69
+ hermes_args: dict[str, JsonValue] | None
70
+ requires_user_confirmation: bool
71
+
72
+ @model_validator(mode="after")
73
+ def _check_action_is_actionable(self) -> Self:
74
+ """Reject an action nothing could carry out."""
75
+ if self.cli is None and self.hermes_tool is None:
76
+ raise ValueError(
77
+ "a next action must offer a CLI argv list, a host-agent tool, or both"
78
+ )
79
+ if self.cli is not None and not self.cli:
80
+ raise ValueError("a CLI next action needs at least the command name")
81
+ if self.hermes_args is not None and self.hermes_tool is None:
82
+ raise ValueError("hermes_args without hermes_tool has nothing to call")
83
+ return self
84
+
85
+
86
+ class CliError(ProtocolModel):
87
+ """The machine-safe projection of a failure."""
88
+
89
+ code: NonEmptyString
90
+ message: NonEmptyString
91
+ retryable: bool
92
+ details: dict[str, JsonValue]
93
+
94
+
95
+ class CliEnvelope[T](ProtocolModel):
96
+ """The single response shape every command returns.
97
+
98
+ ``data`` is command-specific, which is why the type is a parameter. The
99
+ published schema describes the envelope; each command documents its own
100
+ payload.
101
+ """
102
+
103
+ schema_version: Literal["techtree.cli.v1"]
104
+ ok: bool
105
+ command: NonEmptyString
106
+ data: T | None
107
+ messages: list[CliMessage]
108
+ warnings: list[CliMessage]
109
+ next_actions: list[NextAction] = Field(max_length=MAX_NEXT_ACTIONS)
110
+ error: CliError | None
111
+
112
+ @model_validator(mode="after")
113
+ def _check_success_and_failure_are_distinguishable(self) -> Self:
114
+ """Reject an envelope that reports success and failure at once."""
115
+ if self.ok and self.error is not None:
116
+ raise ValueError("a successful command reports no error")
117
+ if not self.ok and self.error is None:
118
+ raise ValueError("a failed command must say why it failed")
119
+ if len({action.id for action in self.next_actions}) != len(self.next_actions):
120
+ raise ValueError("next actions must have distinct identifiers")
121
+ return self
122
+
123
+
124
+ class CheckStatus(StrEnum):
125
+ """The outcome of one Doctor check."""
126
+
127
+ PASS = "pass"
128
+ WARN = "warn"
129
+ FAIL = "fail"
130
+ SKIP = "skip"
131
+
132
+
133
+ class DoctorCheck(ProtocolModel):
134
+ """One environment check and what it found."""
135
+
136
+ id: NonEmptyString
137
+ label: NonEmptyString
138
+ status: CheckStatus
139
+ detail: NonEmptyString
140
+ blocking: bool
141
+ metadata: dict[str, JsonValue]
142
+
143
+ @model_validator(mode="after")
144
+ def _check_blocking_implies_failure(self) -> Self:
145
+ """Reject a blocking check that reports nothing wrong."""
146
+ if self.blocking and self.status in (CheckStatus.PASS, CheckStatus.SKIP):
147
+ raise ValueError(
148
+ "a check that passed or was skipped cannot block; blocking is "
149
+ "what a failure means, not a separate opinion about it"
150
+ )
151
+ return self
@@ -0,0 +1,254 @@
1
+ """The public wrapper around a Campaign. Spec section 11.6, decisions 0002.
2
+
3
+ A Climb is an invitation: a slug, a title, a window, and the rules a
4
+ participant is agreeing to. It holds a digest of the Campaign and nothing of
5
+ the Campaign's contents. No model, harness, runtime, taskset, scoring, or
6
+ mutation setting appears here, and ``extra="forbid"`` keeps it that way.
7
+
8
+ ``ResolvedClimb`` is the assembled graph — Climb, Campaign, DataPolicy, and
9
+ publisher validation receipt, each with the digest it was loaded under. Putting
10
+ the four objects in one validated model is what lets the contradictions be
11
+ caught in one place: a public Climb that promises to publish a report its data
12
+ policy forbids is not a malformed document, it is a document that lies, and it
13
+ raises :class:`~techtree.errors.PolicyError` rather than a validation error so
14
+ the caller can tell those apart.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Literal, Self
20
+
21
+ from pydantic import Field, model_validator
22
+
23
+ from techtree.errors import PolicyError
24
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel, UtcDateTime
25
+ from techtree.models.campaign import CampaignSpec
26
+ from techtree.models.data_policy import DataPolicy
27
+ from techtree.models.validation import TasksetValidationReceipt
28
+
29
+ __all__ = [
30
+ "CandidateConstraints",
31
+ "CandidatePolicy",
32
+ "ClimbManifest",
33
+ "ClimbMetadata",
34
+ "LeaderboardPolicy",
35
+ "PublicationPolicy",
36
+ "ResolvedClimb",
37
+ "check_climb_policy_consistency",
38
+ ]
39
+
40
+
41
+ class CandidateConstraints(ProtocolModel):
42
+ """How many skills a candidate submits, and in which format."""
43
+
44
+ min_skills: int = Field(ge=0)
45
+ max_skills: int = Field(ge=1)
46
+ format: Literal["techtree-instruction-skill-v1"]
47
+
48
+ @model_validator(mode="after")
49
+ def _check_bounds(self) -> Self:
50
+ """Reject bounds no submission could satisfy."""
51
+ if self.min_skills > self.max_skills:
52
+ raise ValueError("min_skills cannot exceed max_skills")
53
+ return self
54
+
55
+
56
+ class CandidatePolicy(ProtocolModel):
57
+ """What participants submit and whether it becomes public."""
58
+
59
+ required_mutation: Literal["skill_insertion"]
60
+ skill_visibility: Literal["public", "private"]
61
+ constraints: CandidateConstraints
62
+
63
+
64
+ class PublicationPolicy(ProtocolModel):
65
+ """What Techtree publishes about a completed run."""
66
+
67
+ report_visibility: Literal["public", "private"]
68
+ raw_episode_visibility: Literal["private", "prohibited"]
69
+ public_trace_projection: Literal["redacted", "none"]
70
+ proof_grade: Literal["development_only", "P1"]
71
+
72
+
73
+ class LeaderboardPolicy(ProtocolModel):
74
+ """Whether results are ranked publicly, and on what evidence."""
75
+
76
+ enabled: bool
77
+ evidence_required: Literal[
78
+ "not_required",
79
+ "complete",
80
+ "complete_or_partial",
81
+ ]
82
+
83
+
84
+ class ClimbMetadata(ProtocolModel):
85
+ """Public identity and schedule."""
86
+
87
+ id: NonEmptyString
88
+ slug: NonEmptyString
89
+ version: int = Field(ge=1)
90
+ title: NonEmptyString
91
+ summary: NonEmptyString
92
+ status: Literal["open", "closed", "development"]
93
+ opens_at: UtcDateTime | None = None
94
+ closes_at: UtcDateTime | None = None
95
+
96
+ @model_validator(mode="after")
97
+ def _check_window(self) -> Self:
98
+ """Reject a schedule that closes before it opens."""
99
+ if (
100
+ self.opens_at is not None
101
+ and self.closes_at is not None
102
+ and self.closes_at <= self.opens_at
103
+ ):
104
+ raise ValueError("closes_at must be after opens_at")
105
+ return self
106
+
107
+
108
+ class ClimbManifest(ProtocolModel):
109
+ """A public invitation to run one Campaign."""
110
+
111
+ schema_version: Literal["techtree.climb.v1alpha1"]
112
+ kind: Literal["Climb"]
113
+ metadata: ClimbMetadata
114
+ campaign_spec_digest: Digest
115
+ candidate_policy: CandidatePolicy
116
+ publication: PublicationPolicy
117
+ leaderboard: LeaderboardPolicy
118
+
119
+ @model_validator(mode="after")
120
+ def _check_leaderboard_matches_proof_grade(self) -> Self:
121
+ """Reject a public ranking built on development-only proofs."""
122
+ if self.publication.proof_grade == "development_only" and (
123
+ self.leaderboard.enabled
124
+ ):
125
+ raise ValueError(
126
+ "a development_only Climb cannot enable a leaderboard; its "
127
+ "results are not evidence of anything comparable"
128
+ )
129
+ return self
130
+
131
+
132
+ def check_climb_policy_consistency(
133
+ climb: ClimbManifest,
134
+ data_policy: DataPolicy,
135
+ ) -> None:
136
+ """Raise :class:`PolicyError` when a Climb promises what its policy forbids.
137
+
138
+ Every check compares a public promise against the rights statement the
139
+ Campaign runs under. A mismatch is a rights problem, not a schema problem,
140
+ so it is reported as one.
141
+ """
142
+ candidate = climb.candidate_policy
143
+ publication = climb.publication
144
+ skill_release = data_policy.candidate_skill.public_release
145
+
146
+ if candidate.skill_visibility == "public" and skill_release == "prohibited":
147
+ raise PolicyError(
148
+ "the Climb publishes candidate skills but the DataPolicy prohibits "
149
+ "releasing them",
150
+ details={
151
+ "skill_visibility": candidate.skill_visibility,
152
+ "candidate_skill_public_release": skill_release,
153
+ },
154
+ )
155
+ if candidate.skill_visibility == "private" and (
156
+ skill_release == "required_for_climb"
157
+ ):
158
+ raise PolicyError(
159
+ "the DataPolicy requires candidate skills to be released but the "
160
+ "Climb keeps them private",
161
+ details={
162
+ "skill_visibility": candidate.skill_visibility,
163
+ "candidate_skill_public_release": skill_release,
164
+ },
165
+ )
166
+
167
+ if publication.report_visibility == "public" and (
168
+ data_policy.derived_artifacts.uplift_report != "public"
169
+ ):
170
+ raise PolicyError(
171
+ "the Climb publishes the uplift report but the DataPolicy does not "
172
+ "make it public",
173
+ details={
174
+ "report_visibility": publication.report_visibility,
175
+ "uplift_report": data_policy.derived_artifacts.uplift_report,
176
+ },
177
+ )
178
+
179
+ if publication.public_trace_projection == "redacted" and (
180
+ data_policy.derived_artifacts.redacted_trace_projection != "public"
181
+ ):
182
+ raise PolicyError(
183
+ "the Climb publishes a redacted trace projection but the DataPolicy "
184
+ "does not make it public",
185
+ details={
186
+ "public_trace_projection": publication.public_trace_projection,
187
+ "redacted_trace_projection": (
188
+ data_policy.derived_artifacts.redacted_trace_projection
189
+ ),
190
+ },
191
+ )
192
+
193
+ if publication.raw_episode_visibility == "private" and (
194
+ data_policy.raw_episodes.local_retention == "prohibited"
195
+ ):
196
+ raise PolicyError(
197
+ "the Climb keeps raw episodes privately visible but the DataPolicy "
198
+ "prohibits retaining them at all",
199
+ details={
200
+ "raw_episode_visibility": publication.raw_episode_visibility,
201
+ "local_retention": data_policy.raw_episodes.local_retention,
202
+ },
203
+ )
204
+
205
+
206
+ class ResolvedClimb(ProtocolModel):
207
+ """A Climb with every object it points at, loaded and cross-checked."""
208
+
209
+ climb: ClimbManifest
210
+ climb_digest: Digest
211
+ campaign: CampaignSpec
212
+ campaign_digest: Digest
213
+ data_policy: DataPolicy
214
+ data_policy_digest: Digest
215
+ publisher_validation: TasksetValidationReceipt
216
+ publisher_validation_digest: Digest
217
+
218
+ @model_validator(mode="after")
219
+ def _check_graph(self) -> Self:
220
+ """Reject a graph whose edges do not point where its objects say."""
221
+ if self.climb.campaign_spec_digest != self.campaign_digest:
222
+ raise ValueError(
223
+ "the Climb references a different Campaign than the one resolved"
224
+ )
225
+ if self.campaign.data_policy_digest != self.data_policy_digest:
226
+ raise ValueError(
227
+ "the Campaign references a different DataPolicy than the one resolved"
228
+ )
229
+ if self.campaign.taskset.validation_receipt_digest != (
230
+ self.publisher_validation_digest
231
+ ):
232
+ raise ValueError(
233
+ "the Campaign references a different validation receipt than "
234
+ "the one resolved"
235
+ )
236
+
237
+ mutation = self.campaign.mutation_contract
238
+ candidate = self.climb.candidate_policy
239
+ if candidate.required_mutation != mutation.kind:
240
+ raise ValueError(
241
+ "the Climb requires a different mutation than the Campaign "
242
+ "contract defines"
243
+ )
244
+ if (
245
+ candidate.constraints.min_skills != mutation.minimum_skills
246
+ or candidate.constraints.max_skills != mutation.maximum_skills
247
+ ):
248
+ raise ValueError(
249
+ "the Climb candidate constraints do not match the Campaign "
250
+ "mutation bounds"
251
+ )
252
+
253
+ check_climb_policy_consistency(self.climb, self.data_policy)
254
+ return self