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,269 @@
|
|
|
1
|
+
"""What tasks a Climb is about, stated so two parties can compare it.
|
|
2
|
+
|
|
3
|
+
Spec section 21.2.
|
|
4
|
+
|
|
5
|
+
Membership is an ordered list of task hashes and nothing else. The order is the
|
|
6
|
+
taskset's own iteration order, the first ``num_tasks`` of it, with no shuffle
|
|
7
|
+
anywhere in WP0–WP5 (decisions document 0001). Two things follow.
|
|
8
|
+
|
|
9
|
+
*Identity crosses the boundary; content does not.* The engine helper reports a
|
|
10
|
+
position, a hash, a name, and a class name. It never reports a prompt or an
|
|
11
|
+
answer, so a membership commitment can be published without publishing the
|
|
12
|
+
taskset. :func:`load_inspection_output` is where that report becomes typed
|
|
13
|
+
Techtree data, and it is the only place raw Verifiers hashes — bare
|
|
14
|
+
64-character lowercase hexadecimal — turn into Techtree digests. The conversion
|
|
15
|
+
goes through :func:`~techtree.canonical.normalize_verifiers_task_hash`, which
|
|
16
|
+
accepts that one spelling and refuses everything else, including an
|
|
17
|
+
already-prefixed digest: a boundary that waved prefixed strings through would
|
|
18
|
+
be a place where unvalidated text could enter the protocol looking official.
|
|
19
|
+
|
|
20
|
+
*The digest is taken over a named object, not over a bare array.* Hashing the
|
|
21
|
+
list alone would give the same bytes to any other list of strings that happened
|
|
22
|
+
to hold the same values. Wrapping it in ``{"ordered_task_hashes": [...]}`` puts
|
|
23
|
+
the meaning of the array inside the hashed bytes, so a membership digest can
|
|
24
|
+
only ever be a membership digest.
|
|
25
|
+
|
|
26
|
+
:func:`compare_membership` reports; it does not decide. It returns the
|
|
27
|
+
``ValidationCheck`` a receipt carries, naming the first position where two
|
|
28
|
+
memberships disagree, because "these two lists differ" is not something an
|
|
29
|
+
operator can act on and "position 7 is a different task" is.
|
|
30
|
+
:func:`assert_unique_task_hashes` is the one function here that refuses, since a
|
|
31
|
+
membership that names the same task twice would score it twice under one
|
|
32
|
+
commitment and there is no reading of that which is merely a warning.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Annotated, Final, Literal, Self
|
|
39
|
+
|
|
40
|
+
from pydantic import AfterValidator, Field, model_validator
|
|
41
|
+
|
|
42
|
+
from techtree.canonical import (
|
|
43
|
+
digest_object,
|
|
44
|
+
normalize_verifiers_task_hash,
|
|
45
|
+
validate_digest,
|
|
46
|
+
)
|
|
47
|
+
from techtree.errors import ValidationError, VerificationError
|
|
48
|
+
from techtree.models.base import Digest, NonEmptyString, ProtocolModel
|
|
49
|
+
from techtree.models.validation import ValidationCheck
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"INSPECTION_SCHEMA_VERSION",
|
|
53
|
+
"MEMBERSHIP_DIGEST_KEY",
|
|
54
|
+
"MEMBERSHIP_MATCH_CHECK",
|
|
55
|
+
"MEMBERSHIP_REPEATABILITY_CHECK",
|
|
56
|
+
"TaskInspection",
|
|
57
|
+
"TasksetInspection",
|
|
58
|
+
"VerifiersTaskHash",
|
|
59
|
+
"assert_unique_task_hashes",
|
|
60
|
+
"compare_membership",
|
|
61
|
+
"load_inspection_output",
|
|
62
|
+
"membership_digest",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
#: The shape the engine helper writes. Owned by the engine bundle
|
|
66
|
+
#: (``tools/inspect_taskset.py``, decisions document 0003 A3); this module is
|
|
67
|
+
#: its reader, and reads exactly this version.
|
|
68
|
+
INSPECTION_SCHEMA_VERSION: Final = "techtree.taskset-inspection.v1"
|
|
69
|
+
|
|
70
|
+
#: The single key of the object a membership digest is taken over. Changing it
|
|
71
|
+
#: changes every membership digest in existence, which is why it is spelled
|
|
72
|
+
#: once, here.
|
|
73
|
+
MEMBERSHIP_DIGEST_KEY: Final = "ordered_task_hashes"
|
|
74
|
+
|
|
75
|
+
#: The check that two inspections of the same taskset agreed.
|
|
76
|
+
MEMBERSHIP_REPEATABILITY_CHECK: Final = "membership_repeatability"
|
|
77
|
+
#: The check that what was resolved is what the Campaign committed to.
|
|
78
|
+
MEMBERSHIP_MATCH_CHECK: Final = "committed_membership_match"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalized_task_hash(value: str) -> Digest:
|
|
82
|
+
"""Normalize at the boundary, in the vocabulary Pydantic understands."""
|
|
83
|
+
try:
|
|
84
|
+
return normalize_verifiers_task_hash(value)
|
|
85
|
+
except ValidationError as error:
|
|
86
|
+
raise ValueError(error.message) from error
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
type VerifiersTaskHash = Annotated[str, AfterValidator(_normalized_task_hash)]
|
|
90
|
+
"""A raw Verifiers task hash on the way in, a Techtree digest once validated."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TaskInspection(ProtocolModel):
|
|
94
|
+
"""The identity of one task, as the engine reported it."""
|
|
95
|
+
|
|
96
|
+
position: int = Field(ge=0)
|
|
97
|
+
task_hash: VerifiersTaskHash
|
|
98
|
+
name: NonEmptyString | None
|
|
99
|
+
task_type: NonEmptyString
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TasksetInspection(ProtocolModel):
|
|
103
|
+
"""One engine inspection of one taskset."""
|
|
104
|
+
|
|
105
|
+
schema_version: Literal["techtree.taskset-inspection.v1"]
|
|
106
|
+
taskset_id: NonEmptyString
|
|
107
|
+
taskset_class: NonEmptyString
|
|
108
|
+
requested_num_tasks: int = Field(ge=1)
|
|
109
|
+
task_count: int = Field(ge=1)
|
|
110
|
+
tasks: list[TaskInspection]
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def _check_records_are_a_dense_ordered_run(self) -> Self:
|
|
114
|
+
"""Reject gaps, repeats, or a count the records do not support.
|
|
115
|
+
|
|
116
|
+
Position is what makes membership an *ordered* commitment, so a report
|
|
117
|
+
whose positions are not ``0..n-1`` in order cannot be read as one.
|
|
118
|
+
"""
|
|
119
|
+
positions = [task.position for task in self.tasks]
|
|
120
|
+
if positions != list(range(len(self.tasks))):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"inspection records must be sorted by position and cover 0..n-1 "
|
|
123
|
+
"without gaps"
|
|
124
|
+
)
|
|
125
|
+
if self.task_count != len(self.tasks):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"inspection lists {len(self.tasks)} tasks but reports a "
|
|
128
|
+
f"task_count of {self.task_count}"
|
|
129
|
+
)
|
|
130
|
+
if self.requested_num_tasks != self.task_count:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"inspection reports {self.task_count} tasks for a request of "
|
|
133
|
+
f"{self.requested_num_tasks}"
|
|
134
|
+
)
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def ordered_task_hashes(self) -> list[Digest]:
|
|
139
|
+
"""Return the normalized task hashes in the order they were reported."""
|
|
140
|
+
return [task.task_hash for task in self.tasks]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def membership_digest(ordered_task_hashes: list[Digest]) -> Digest:
|
|
144
|
+
"""Digest the canonical ordered-hash object.
|
|
145
|
+
|
|
146
|
+
Every hash is revalidated on the way in. The parameter is typed as a list of
|
|
147
|
+
digests, but a type annotation is a claim about the caller, not a check on
|
|
148
|
+
the value, and this digest is a commitment other parties will be held to.
|
|
149
|
+
"""
|
|
150
|
+
if not ordered_task_hashes:
|
|
151
|
+
raise ValidationError(
|
|
152
|
+
"a membership digest commits to at least one task",
|
|
153
|
+
code="membership_empty",
|
|
154
|
+
details={"task_count": 0},
|
|
155
|
+
)
|
|
156
|
+
return digest_object(
|
|
157
|
+
{
|
|
158
|
+
MEMBERSHIP_DIGEST_KEY: [
|
|
159
|
+
validate_digest(value) for value in ordered_task_hashes
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def assert_unique_task_hashes(hashes: list[Digest]) -> None:
|
|
166
|
+
"""Reject a membership that names the same task twice."""
|
|
167
|
+
first_seen: dict[Digest, int] = {}
|
|
168
|
+
for position, value in enumerate(hashes):
|
|
169
|
+
earlier = first_seen.setdefault(value, position)
|
|
170
|
+
if earlier != position:
|
|
171
|
+
raise VerificationError(
|
|
172
|
+
f"task {value} appears at positions {earlier} and {position}; a "
|
|
173
|
+
"repeated task would be scored twice under one commitment",
|
|
174
|
+
code="taskset_task_hash_repeated",
|
|
175
|
+
details={
|
|
176
|
+
"task_hash": value,
|
|
177
|
+
"first_position": earlier,
|
|
178
|
+
"repeated_position": position,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def compare_membership(
|
|
184
|
+
actual: list[Digest],
|
|
185
|
+
committed: list[Digest],
|
|
186
|
+
*,
|
|
187
|
+
check_id: str = MEMBERSHIP_MATCH_CHECK,
|
|
188
|
+
) -> ValidationCheck:
|
|
189
|
+
"""Return pass or fail for two memberships, naming the first difference.
|
|
190
|
+
|
|
191
|
+
``check_id`` selects which of the receipt's required checks this comparison
|
|
192
|
+
answers: the same comparison proves repeatability when it runs over two
|
|
193
|
+
inspections and commitment match when it runs against a Campaign.
|
|
194
|
+
"""
|
|
195
|
+
if not actual and not committed:
|
|
196
|
+
return ValidationCheck(
|
|
197
|
+
id=check_id,
|
|
198
|
+
status="failed",
|
|
199
|
+
detail="both memberships are empty, so there is nothing to compare",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
mismatch = _first_difference(actual, committed)
|
|
203
|
+
if mismatch is None and len(actual) == len(committed):
|
|
204
|
+
return ValidationCheck(
|
|
205
|
+
id=check_id,
|
|
206
|
+
status="passed",
|
|
207
|
+
detail=f"all {len(actual)} task hashes match in order",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return ValidationCheck(
|
|
211
|
+
id=check_id,
|
|
212
|
+
status="failed",
|
|
213
|
+
detail=_mismatch_detail(actual, committed, mismatch),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_inspection_output(path: Path) -> TasksetInspection:
|
|
218
|
+
"""Validate one engine inspection document and normalize its hashes."""
|
|
219
|
+
try:
|
|
220
|
+
raw = path.read_bytes()
|
|
221
|
+
except OSError as error:
|
|
222
|
+
raise ValidationError(
|
|
223
|
+
f"the engine wrote no inspection output at {path}",
|
|
224
|
+
code="taskset_inspection_missing",
|
|
225
|
+
details={"path": str(path)},
|
|
226
|
+
) from error
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
return TasksetInspection.model_validate_json(raw)
|
|
230
|
+
except ValueError as error:
|
|
231
|
+
raise ValidationError(
|
|
232
|
+
f"the engine's inspection output is not a valid "
|
|
233
|
+
f"{INSPECTION_SCHEMA_VERSION} document: {error}",
|
|
234
|
+
code="taskset_inspection_invalid",
|
|
235
|
+
details={"path": str(path)},
|
|
236
|
+
) from error
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _first_difference(
|
|
240
|
+
actual: list[Digest], committed: list[Digest]
|
|
241
|
+
) -> tuple[int, Digest | None, Digest | None] | None:
|
|
242
|
+
"""Return the first position the two memberships disagree at, if any."""
|
|
243
|
+
for position in range(max(len(actual), len(committed))):
|
|
244
|
+
left = actual[position] if position < len(actual) else None
|
|
245
|
+
right = committed[position] if position < len(committed) else None
|
|
246
|
+
if left != right:
|
|
247
|
+
return position, left, right
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _mismatch_detail(
|
|
252
|
+
actual: list[Digest],
|
|
253
|
+
committed: list[Digest],
|
|
254
|
+
mismatch: tuple[int, Digest | None, Digest | None] | None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Describe a failed comparison in terms an operator can act on."""
|
|
257
|
+
parts: list[str] = []
|
|
258
|
+
if len(actual) != len(committed):
|
|
259
|
+
parts.append(
|
|
260
|
+
f"membership records {len(actual)} tasks but the commitment names "
|
|
261
|
+
f"{len(committed)}"
|
|
262
|
+
)
|
|
263
|
+
if mismatch is not None:
|
|
264
|
+
position, left, right = mismatch
|
|
265
|
+
parts.append(
|
|
266
|
+
f"first difference at position {position}: "
|
|
267
|
+
f"recorded {left or 'nothing'}, committed {right or 'nothing'}"
|
|
268
|
+
)
|
|
269
|
+
return "; ".join(parts)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Answering a run's validation question with a real Verifiers run.
|
|
2
|
+
|
|
3
|
+
Spec section 21.5, decisions document 0003 A1.
|
|
4
|
+
|
|
5
|
+
:class:`~techtree.runs.validation.PublisherFixtureValidationProvider` re-reads a
|
|
6
|
+
commitment somebody else made. This one does the work: it resolves the
|
|
7
|
+
Campaign's taskset against the managed engine, runs the pinned model-free
|
|
8
|
+
validation over every task, has the engine normalize the result, and issues a
|
|
9
|
+
receipt of its own.
|
|
10
|
+
|
|
11
|
+
Then it compares. Because the receipt is deterministic, "did this machine reach
|
|
12
|
+
the same conclusion as the publisher?" is answerable by equality rather than by
|
|
13
|
+
narrative: the local receipt's content digest must be exactly the digest the
|
|
14
|
+
Campaign commits to. Anything less would be two documents that merely resemble
|
|
15
|
+
each other.
|
|
16
|
+
|
|
17
|
+
The engine is not chosen locally. It is the engine the publisher's receipt was
|
|
18
|
+
issued under, so a disagreement between the two receipts can only ever be a
|
|
19
|
+
disagreement about the taskset — never about which Verifiers build ran.
|
|
20
|
+
|
|
21
|
+
:func:`worker_validation_provider` is the routing this build performs, and it
|
|
22
|
+
routes on a capability rather than on a preference. A Campaign whose taskset
|
|
23
|
+
lives in a package the engine bundle ships can be validated here, for real. A
|
|
24
|
+
Campaign whose taskset lives anywhere else cannot be — this build has no way to
|
|
25
|
+
obtain that package — and the only source left is the publisher's snapshotted
|
|
26
|
+
commitment, which the fake executor may use because everything it produces is
|
|
27
|
+
marked development-only. The run's validation marker records which of the two
|
|
28
|
+
spoke, so the difference is never invisible.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Final
|
|
34
|
+
|
|
35
|
+
from techtree.canonical import digest_object
|
|
36
|
+
from techtree.engines.bundle import default_engine_descriptor
|
|
37
|
+
from techtree.engines.registry import EngineRegistry
|
|
38
|
+
from techtree.errors import PrerequisiteError, VerificationError
|
|
39
|
+
from techtree.models.base import Digest, JsonValue
|
|
40
|
+
from techtree.models.campaign import CampaignSpec
|
|
41
|
+
from techtree.paths import TechtreePaths
|
|
42
|
+
from techtree.runs.artifacts import RunInputBundle
|
|
43
|
+
from techtree.runs.validation import (
|
|
44
|
+
TASKSET_VALIDATION_INVALID,
|
|
45
|
+
PublisherFixtureValidationProvider,
|
|
46
|
+
TasksetValidationOutcome,
|
|
47
|
+
TasksetValidationProvider,
|
|
48
|
+
TasksetValidationSource,
|
|
49
|
+
)
|
|
50
|
+
from techtree.settings import resolved_settings
|
|
51
|
+
from techtree.tasksets.service import TasksetService
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"ENGINE_NOT_INSTALLED",
|
|
55
|
+
"LocalVerifiersValidationProvider",
|
|
56
|
+
"can_validate_locally",
|
|
57
|
+
"locally_validatable_packages",
|
|
58
|
+
"worker_validation_provider",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
#: What a run reports when the engine its Campaign was validated under is not
|
|
62
|
+
#: installed on this machine. Local validation has no substitute, so this is a
|
|
63
|
+
#: prerequisite the operator can satisfy rather than a failure of the run.
|
|
64
|
+
ENGINE_NOT_INSTALLED: Final = "engine_not_installed"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def locally_validatable_packages() -> frozenset[str]:
|
|
68
|
+
"""Return the taskset packages this build can validate for itself.
|
|
69
|
+
|
|
70
|
+
A package inside the engine bundle is one this machine already has the
|
|
71
|
+
source of. Every other package would have to be fetched from somewhere,
|
|
72
|
+
which WP0–WP5 does not do.
|
|
73
|
+
"""
|
|
74
|
+
return frozenset(package.name for package in default_engine_descriptor().packages)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def can_validate_locally(campaign: CampaignSpec) -> bool:
|
|
78
|
+
"""Return whether this build ships the package this Campaign's taskset needs."""
|
|
79
|
+
package = campaign.taskset.ref.package
|
|
80
|
+
return package.kind == "embedded" and package.name in locally_validatable_packages()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LocalVerifiersValidationProvider:
|
|
84
|
+
"""Validates a run's taskset here, with the pinned Verifiers build."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, paths: TechtreePaths) -> None:
|
|
87
|
+
self._paths = paths
|
|
88
|
+
|
|
89
|
+
def validate(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
run_id: str,
|
|
93
|
+
inputs: RunInputBundle,
|
|
94
|
+
) -> TasksetValidationOutcome:
|
|
95
|
+
"""Run the real validation and require it to agree with the publisher."""
|
|
96
|
+
campaign = inputs.campaign
|
|
97
|
+
publisher = inputs.source.publisher_validation
|
|
98
|
+
committed_receipt_digest = campaign.taskset.validation_receipt_digest
|
|
99
|
+
|
|
100
|
+
service = TasksetService(
|
|
101
|
+
self._require_engine(run_id, publisher.engine_digest),
|
|
102
|
+
publisher.engine_digest,
|
|
103
|
+
)
|
|
104
|
+
result = service.resolve_and_validate(
|
|
105
|
+
campaign=campaign,
|
|
106
|
+
run_dir=self._paths.run_dir(run_id),
|
|
107
|
+
)
|
|
108
|
+
receipt = result.receipt
|
|
109
|
+
|
|
110
|
+
_require(
|
|
111
|
+
receipt.status == "valid",
|
|
112
|
+
f"validating this taskset here reported {receipt.status}, so "
|
|
113
|
+
"nothing may be scored on it",
|
|
114
|
+
run_id,
|
|
115
|
+
status=receipt.status,
|
|
116
|
+
failed_checks=[
|
|
117
|
+
check.id for check in receipt.checks if check.status == "failed"
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
_require(
|
|
121
|
+
receipt.taskset_lock_digest == publisher.taskset_lock_digest,
|
|
122
|
+
"the taskset this machine locked is not the one the publisher's "
|
|
123
|
+
"receipt was issued under",
|
|
124
|
+
run_id,
|
|
125
|
+
local=receipt.taskset_lock_digest,
|
|
126
|
+
published=publisher.taskset_lock_digest,
|
|
127
|
+
)
|
|
128
|
+
_require(
|
|
129
|
+
result.receipt_digest == committed_receipt_digest,
|
|
130
|
+
"validating this taskset here did not reproduce the receipt the "
|
|
131
|
+
"Campaign commits to",
|
|
132
|
+
run_id,
|
|
133
|
+
local=result.receipt_digest,
|
|
134
|
+
committed=committed_receipt_digest,
|
|
135
|
+
)
|
|
136
|
+
_require(
|
|
137
|
+
digest_object(result.evidence) == digest_object(inputs.validation_evidence),
|
|
138
|
+
"the evidence this machine produced is not the evidence the Campaign ships",
|
|
139
|
+
run_id,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return TasksetValidationOutcome(
|
|
143
|
+
lock=result.lock,
|
|
144
|
+
receipt=receipt,
|
|
145
|
+
execution_record=result.execution_record,
|
|
146
|
+
source=TasksetValidationSource.LOCAL_VERIFIERS,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _require_engine(self, run_id: str, engine_digest: Digest) -> EngineRegistry:
|
|
150
|
+
"""Return the registry, having checked this engine is usable here."""
|
|
151
|
+
registry = EngineRegistry(self._paths, resolved_settings(self._paths))
|
|
152
|
+
installation = registry.installation(engine_digest)
|
|
153
|
+
if installation is None or not installation.verified:
|
|
154
|
+
raise PrerequisiteError(
|
|
155
|
+
f"engine {engine_digest} is not installed and verified here, so "
|
|
156
|
+
"this taskset cannot be validated locally",
|
|
157
|
+
code=ENGINE_NOT_INSTALLED,
|
|
158
|
+
details={"run_id": run_id, "engine_digest": engine_digest},
|
|
159
|
+
)
|
|
160
|
+
return registry
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def worker_validation_provider(paths: TechtreePaths) -> TasksetValidationProvider:
|
|
164
|
+
"""Return the validation source available to a run on this machine.
|
|
165
|
+
|
|
166
|
+
The decision cannot be made from a
|
|
167
|
+
:class:`~techtree.models.run.RunRequest` alone — nothing in a request names
|
|
168
|
+
the taskset's package — so the routing lives in a provider that reads the
|
|
169
|
+
run's own inputs and then delegates. Both destinations are permanent: one
|
|
170
|
+
is what a real Campaign gets, the other is the development source spec
|
|
171
|
+
§21.5 leaves the fake executor.
|
|
172
|
+
"""
|
|
173
|
+
return _AvailableValidationProvider(paths)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _AvailableValidationProvider:
|
|
177
|
+
"""Delegates to whichever validation source this Campaign actually has."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, paths: TechtreePaths) -> None:
|
|
180
|
+
self._paths = paths
|
|
181
|
+
|
|
182
|
+
def validate(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
run_id: str,
|
|
186
|
+
inputs: RunInputBundle,
|
|
187
|
+
) -> TasksetValidationOutcome:
|
|
188
|
+
"""Validate locally when this build ships the taskset's package."""
|
|
189
|
+
if can_validate_locally(inputs.campaign):
|
|
190
|
+
return LocalVerifiersValidationProvider(self._paths).validate(
|
|
191
|
+
run_id=run_id, inputs=inputs
|
|
192
|
+
)
|
|
193
|
+
return PublisherFixtureValidationProvider().validate(
|
|
194
|
+
run_id=run_id, inputs=inputs
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _require(condition: bool, message: str, run_id: str, **details: object) -> None:
|
|
199
|
+
"""Raise a typed validation failure unless the condition holds."""
|
|
200
|
+
if condition:
|
|
201
|
+
return
|
|
202
|
+
reported: dict[str, JsonValue] = {"run_id": run_id}
|
|
203
|
+
for key, value in details.items():
|
|
204
|
+
reported[key] = (
|
|
205
|
+
[str(item) for item in value] if isinstance(value, list) else str(value)
|
|
206
|
+
)
|
|
207
|
+
raise VerificationError(message, code=TASKSET_VALIDATION_INVALID, details=reported)
|