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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
techtree/models/climb.py
ADDED
|
@@ -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
|