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,474 @@
|
|
|
1
|
+
"""What the engine actually resolved and ran. Spec section 7.8.
|
|
2
|
+
|
|
3
|
+
A manifest says what the experiment was supposed to be. This module says what
|
|
4
|
+
it turned out to be, computed from three sources that were written by something
|
|
5
|
+
other than Techtree:
|
|
6
|
+
|
|
7
|
+
* the traces the pinned Verifiers build recorded — which model answered, under
|
|
8
|
+
which sampling settings, which harness drove it, which container it was given,
|
|
9
|
+
which tools it was offered, and which rewards were scored;
|
|
10
|
+
* the configuration the engine resolved and wrote back out — which is where the
|
|
11
|
+
skill mount paths live, and which carries its own copy of the sampling
|
|
12
|
+
settings;
|
|
13
|
+
* what the local Docker daemon answered about the pinned subject image, asked
|
|
14
|
+
once per variant immediately before that variant was launched.
|
|
15
|
+
|
|
16
|
+
Keeping them together is what makes the fingerprint an observation rather than
|
|
17
|
+
a restatement. The resolved configuration is the engine's own answer to "what
|
|
18
|
+
did you understand Techtree to be asking for", the traces are the runtime's
|
|
19
|
+
answer to "what did you actually do", and the daemon's answer is the only one
|
|
20
|
+
that can say which content was really on this machine; a disagreement between
|
|
21
|
+
any of them is exactly the sort of drift a controlled comparison has to detect,
|
|
22
|
+
so this module requires them to agree and refuses when they do not.
|
|
23
|
+
|
|
24
|
+
Three details are worth stating.
|
|
25
|
+
|
|
26
|
+
*The skill digests are recovered from the mount paths.* The normalized trace
|
|
27
|
+
carries the skill digests the *manifest* declared, because Verifiers records
|
|
28
|
+
the host directory a skill was mounted from rather than its content address. But
|
|
29
|
+
Techtree names that directory after the skill's root digest precisely so the
|
|
30
|
+
folder name is a property of the content, so the resolved configuration lets the
|
|
31
|
+
mounted skill be read back by digest. That reading is an observation; the
|
|
32
|
+
trace's copy is a declaration, and the two are required to match.
|
|
33
|
+
|
|
34
|
+
*The sampling settings are read from the rollouts, and checked against the
|
|
35
|
+
file.* Both are the engine's own answers, but only the rollout's copy travels
|
|
36
|
+
with the thing it describes. The resolved configuration's copy is required to
|
|
37
|
+
match it, because an engine whose file and whose rollouts disagree about how
|
|
38
|
+
the subject was sampled has not established either.
|
|
39
|
+
|
|
40
|
+
*Which bytes ran is a three-way agreement, not a claim.* The evaluation records
|
|
41
|
+
the reference it was given; the daemon says it holds that content and which
|
|
42
|
+
platform it served; the Campaign pins one manifest digest per platform. All
|
|
43
|
+
three must line up, and when they do, the digest that ran is a fact rather than
|
|
44
|
+
the Campaign's hope. When they do not, this module refuses — an unidentifiable
|
|
45
|
+
container is not a weaker result, it is no result.
|
|
46
|
+
|
|
47
|
+
Comparing this fingerprint against the declared manifest is the next slice's
|
|
48
|
+
work (spec section 7.9); everything here is extraction and internal
|
|
49
|
+
consistency.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any, Final
|
|
58
|
+
|
|
59
|
+
from techtree.canonical import digest_object, validate_digest
|
|
60
|
+
from techtree.errors import ValidationError, VerificationError
|
|
61
|
+
from techtree.models.base import Digest, JsonValue, NonEmptyString, ProtocolModel
|
|
62
|
+
from techtree.models.campaign import RuntimeSpec
|
|
63
|
+
from techtree.verifiers.models import (
|
|
64
|
+
NormalizedEpisode,
|
|
65
|
+
NormalizedTrace,
|
|
66
|
+
SubjectImageResolution,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"OBSERVED_CONFIGURATION_MISMATCH",
|
|
71
|
+
"ObservedSubjectConfiguration",
|
|
72
|
+
"observed_from_episodes",
|
|
73
|
+
"read_resolved_config",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
#: Stable error code. Spec section 15.
|
|
77
|
+
OBSERVED_CONFIGURATION_MISMATCH: Final = "observed_configuration_mismatch"
|
|
78
|
+
|
|
79
|
+
#: The directory-name spelling of a digest, which is how a mounted skill's
|
|
80
|
+
#: content address survives into a filesystem path.
|
|
81
|
+
_DIGEST_DIRECTORY_PREFIX: Final = "sha256-"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ObservedSubjectConfiguration(ProtocolModel):
|
|
85
|
+
"""One normalized fingerprint of what a variant actually executed."""
|
|
86
|
+
|
|
87
|
+
model_id: NonEmptyString
|
|
88
|
+
sampling_digest: Digest
|
|
89
|
+
harness_id: NonEmptyString
|
|
90
|
+
harness_version: NonEmptyString
|
|
91
|
+
use_bundled_skill: bool
|
|
92
|
+
skill_root_digests: list[Digest]
|
|
93
|
+
runtime_kind: NonEmptyString
|
|
94
|
+
runtime_image: NonEmptyString
|
|
95
|
+
runtime_image_index_digest: Digest
|
|
96
|
+
runtime_platform: NonEmptyString
|
|
97
|
+
runtime_image_platform_digest: Digest
|
|
98
|
+
tool_inventory_digest: Digest
|
|
99
|
+
reward_contract_digest: Digest
|
|
100
|
+
verifiers_version: NonEmptyString
|
|
101
|
+
verifiers_revision: NonEmptyString
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_resolved_config(path: Path) -> dict[str, Any]:
|
|
105
|
+
"""Read the configuration the engine resolved and wrote back out.
|
|
106
|
+
|
|
107
|
+
JSON, because that is what the engine writes: it dumps the resolved model
|
|
108
|
+
whole, nulls included, so an explicitly-unset setting round-trips as itself
|
|
109
|
+
(``docs/verifiers-pin-0.3.1.md``, deviation D1). TOML could not carry those
|
|
110
|
+
nulls, which is why upstream stopped using it here.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
114
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
115
|
+
raise ValidationError(
|
|
116
|
+
"the configuration this variant's engine resolved could not be "
|
|
117
|
+
"read, so what it actually ran cannot be established",
|
|
118
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
119
|
+
details={"path": str(path)},
|
|
120
|
+
) from error
|
|
121
|
+
return document
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def observed_from_episodes(
|
|
125
|
+
episodes: Sequence[NormalizedEpisode],
|
|
126
|
+
*,
|
|
127
|
+
resolved_config: Mapping[str, Any],
|
|
128
|
+
image_resolution: SubjectImageResolution,
|
|
129
|
+
runtime: RuntimeSpec,
|
|
130
|
+
) -> ObservedSubjectConfiguration:
|
|
131
|
+
"""Fingerprint one variant, requiring every episode to agree.
|
|
132
|
+
|
|
133
|
+
Spec section 7.8 names this ``observed_from_receipts``. It takes the
|
|
134
|
+
normalized episodes instead, because the frozen
|
|
135
|
+
:class:`~techtree.models.episode_receipt.EpisodeReceipt` carries a
|
|
136
|
+
reward-and-lineage record rather than a configuration record: it does not
|
|
137
|
+
hold the model, the harness, the tool inventory or the sampling parameters,
|
|
138
|
+
and reading those off it is not possible. The episodes are the evidence the
|
|
139
|
+
receipts were built from, so the fingerprint and the receipts describe the
|
|
140
|
+
same execution.
|
|
141
|
+
"""
|
|
142
|
+
traces = [trace for episode in episodes for trace in episode.traces]
|
|
143
|
+
if not traces:
|
|
144
|
+
raise VerificationError(
|
|
145
|
+
"this variant recorded no subject trace, so there is no observed "
|
|
146
|
+
"configuration to establish",
|
|
147
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
148
|
+
details={"episodes": len(episodes)},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
reference = traces[0]
|
|
152
|
+
_require_no_drift(traces)
|
|
153
|
+
|
|
154
|
+
mounted = _mounted_skill_digests(resolved_config)
|
|
155
|
+
declared = [validate_digest(value) for value in reference.skill_root_digests]
|
|
156
|
+
if mounted != declared:
|
|
157
|
+
raise VerificationError(
|
|
158
|
+
"the skills the engine mounted are not the skills this variant's "
|
|
159
|
+
"traces record, so what the subject was given is not what the "
|
|
160
|
+
"experiment declared",
|
|
161
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
162
|
+
details={
|
|
163
|
+
"mounted": _digest_detail(mounted),
|
|
164
|
+
"recorded": _digest_detail(declared),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
_require_config_agrees_with_traces(reference, resolved_config)
|
|
168
|
+
platform_digest = _resolved_platform_digest(reference, image_resolution, runtime)
|
|
169
|
+
|
|
170
|
+
return ObservedSubjectConfiguration(
|
|
171
|
+
model_id=reference.model_id,
|
|
172
|
+
sampling_digest=digest_object(reference.sampling),
|
|
173
|
+
harness_id=reference.harness_id,
|
|
174
|
+
harness_version=reference.harness_version,
|
|
175
|
+
use_bundled_skill=reference.use_bundled_skill,
|
|
176
|
+
skill_root_digests=mounted,
|
|
177
|
+
runtime_kind=reference.runtime.kind,
|
|
178
|
+
runtime_image=reference.runtime.image,
|
|
179
|
+
runtime_image_index_digest=image_resolution.index_digest,
|
|
180
|
+
runtime_platform=image_resolution.platform,
|
|
181
|
+
runtime_image_platform_digest=platform_digest,
|
|
182
|
+
tool_inventory_digest=digest_object(
|
|
183
|
+
[
|
|
184
|
+
{
|
|
185
|
+
"name": tool.name,
|
|
186
|
+
"description_digest": tool.description_digest,
|
|
187
|
+
"parameters_digest": tool.parameters_digest,
|
|
188
|
+
}
|
|
189
|
+
for tool in sorted(reference.tools, key=lambda tool: tool.name)
|
|
190
|
+
]
|
|
191
|
+
),
|
|
192
|
+
reward_contract_digest=digest_object(
|
|
193
|
+
[
|
|
194
|
+
{"name": reward.name, "weight": reward.weight}
|
|
195
|
+
for reward in sorted(reference.rewards, key=lambda item: item.name)
|
|
196
|
+
]
|
|
197
|
+
),
|
|
198
|
+
verifiers_version=reference.verifiers_version,
|
|
199
|
+
verifiers_revision=reference.verifiers_revision,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Agreement
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _require_no_drift(traces: Sequence[NormalizedTrace]) -> None:
|
|
209
|
+
"""Reject a variant whose own traces describe two different subjects.
|
|
210
|
+
|
|
211
|
+
Every rollout of one variant is one configuration executed many times. Two
|
|
212
|
+
rollouts that disagree about the model, the harness, the container or the
|
|
213
|
+
tools were not the same experiment, and averaging their rewards would
|
|
214
|
+
average two experiments.
|
|
215
|
+
"""
|
|
216
|
+
for label, observed in (
|
|
217
|
+
("model", {trace.model_id for trace in traces}),
|
|
218
|
+
("harness", {trace.harness_id for trace in traces}),
|
|
219
|
+
("harness version", {trace.harness_version for trace in traces}),
|
|
220
|
+
("bundled-skill setting", {trace.use_bundled_skill for trace in traces}),
|
|
221
|
+
("sampling", {digest_object(trace.sampling) for trace in traces}),
|
|
222
|
+
("runtime kind", {trace.runtime.kind for trace in traces}),
|
|
223
|
+
("runtime image", {trace.runtime.image for trace in traces}),
|
|
224
|
+
(
|
|
225
|
+
"runtime image digest",
|
|
226
|
+
{trace.runtime.image_index_digest for trace in traces},
|
|
227
|
+
),
|
|
228
|
+
(
|
|
229
|
+
"skill list",
|
|
230
|
+
{tuple(trace.skill_root_digests) for trace in traces},
|
|
231
|
+
),
|
|
232
|
+
(
|
|
233
|
+
"tool inventory",
|
|
234
|
+
{tuple(sorted(tool.name for tool in trace.tools)) for trace in traces},
|
|
235
|
+
),
|
|
236
|
+
(
|
|
237
|
+
"reward contract",
|
|
238
|
+
{
|
|
239
|
+
tuple(sorted((reward.name, reward.weight) for reward in trace.rewards))
|
|
240
|
+
for trace in traces
|
|
241
|
+
},
|
|
242
|
+
),
|
|
243
|
+
(
|
|
244
|
+
"Verifiers build",
|
|
245
|
+
{(trace.verifiers_version, trace.verifiers_revision) for trace in traces},
|
|
246
|
+
),
|
|
247
|
+
):
|
|
248
|
+
if len(observed) > 1:
|
|
249
|
+
raise VerificationError(
|
|
250
|
+
f"this variant's rollouts do not agree on the {label} they ran, "
|
|
251
|
+
"so they are not one configuration measured many times",
|
|
252
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
253
|
+
details={
|
|
254
|
+
"field": label,
|
|
255
|
+
"observed": _text_detail(observed),
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _require_config_agrees_with_traces(
|
|
261
|
+
trace: NormalizedTrace, resolved_config: Mapping[str, Any]
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Require the engine's resolution and the runtime's record to agree."""
|
|
264
|
+
subject = _subject_of(resolved_config)
|
|
265
|
+
harness = _table(subject, "harness")
|
|
266
|
+
runtime = _table(subject, "runtime")
|
|
267
|
+
|
|
268
|
+
for label, resolved, recorded in (
|
|
269
|
+
("model", resolved_config.get("model"), trace.model_id),
|
|
270
|
+
("harness", harness.get("id"), trace.harness_id),
|
|
271
|
+
("harness version", harness.get("version"), trace.harness_version),
|
|
272
|
+
("runtime kind", runtime.get("type"), trace.runtime.kind),
|
|
273
|
+
("runtime image", runtime.get("image"), trace.runtime.image),
|
|
274
|
+
(
|
|
275
|
+
"bundled-skill setting",
|
|
276
|
+
harness.get("use_bundled_skill"),
|
|
277
|
+
trace.use_bundled_skill,
|
|
278
|
+
),
|
|
279
|
+
):
|
|
280
|
+
if resolved == recorded:
|
|
281
|
+
continue
|
|
282
|
+
raise VerificationError(
|
|
283
|
+
f"the engine resolved a different {label} than this variant's "
|
|
284
|
+
"traces record",
|
|
285
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
286
|
+
details={
|
|
287
|
+
"field": label,
|
|
288
|
+
"resolved": str(resolved),
|
|
289
|
+
"recorded": str(recorded),
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if _sampling_of(resolved_config) != _set_knobs(dict(trace.sampling)):
|
|
294
|
+
raise VerificationError(
|
|
295
|
+
"the sampling settings the engine resolved are not the ones this "
|
|
296
|
+
"variant's rollouts record being sampled under",
|
|
297
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
298
|
+
details={
|
|
299
|
+
"resolved": _text_detail(_sampling_of(resolved_config)),
|
|
300
|
+
"recorded": _text_detail(trace.sampling),
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolved_platform_digest(
|
|
306
|
+
trace: NormalizedTrace,
|
|
307
|
+
image_resolution: SubjectImageResolution,
|
|
308
|
+
runtime: RuntimeSpec,
|
|
309
|
+
) -> Digest:
|
|
310
|
+
"""Return the image digest that actually ran, and refuse if it is unclear.
|
|
311
|
+
|
|
312
|
+
Three separate statements have to line up before the phrase "this container
|
|
313
|
+
ran" means anything. The evaluation says which reference it was given. The
|
|
314
|
+
local daemon says it holds that reference's content, and which platform it
|
|
315
|
+
resolved the reference to on this host. The Campaign says which manifest
|
|
316
|
+
that platform pins. Any disagreement leaves what ran unidentified, which is
|
|
317
|
+
a refusal rather than a weaker claim.
|
|
318
|
+
"""
|
|
319
|
+
if image_resolution.image != trace.runtime.image:
|
|
320
|
+
raise VerificationError(
|
|
321
|
+
"the image the daemon was asked about is not the image this "
|
|
322
|
+
"variant's rollouts ran",
|
|
323
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
324
|
+
details={
|
|
325
|
+
"resolved": image_resolution.image,
|
|
326
|
+
"recorded": trace.runtime.image,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
if image_resolution.index_digest != trace.runtime.image_index_digest:
|
|
330
|
+
raise VerificationError(
|
|
331
|
+
"the content the daemon holds for the subject image is not the "
|
|
332
|
+
"content this variant's rollouts name",
|
|
333
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
334
|
+
details={
|
|
335
|
+
"resolved": image_resolution.index_digest,
|
|
336
|
+
"recorded": trace.runtime.image_index_digest,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
if image_resolution.index_digest != runtime.image_index_digest:
|
|
340
|
+
raise VerificationError(
|
|
341
|
+
"the content the daemon holds for the subject image is not the "
|
|
342
|
+
"content the Campaign pinned",
|
|
343
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
344
|
+
details={
|
|
345
|
+
"resolved": image_resolution.index_digest,
|
|
346
|
+
"pinned": runtime.image_index_digest,
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
pinned = runtime.image_platform_digests.get(image_resolution.platform)
|
|
350
|
+
if pinned is None:
|
|
351
|
+
raise VerificationError(
|
|
352
|
+
"the daemon served the subject image on a platform the Campaign "
|
|
353
|
+
"pins no manifest for, so which bytes ran cannot be established",
|
|
354
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
355
|
+
details={
|
|
356
|
+
"platform": image_resolution.platform,
|
|
357
|
+
"pinned_platforms": _text_detail(runtime.image_platform_digests),
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
return validate_digest(pinned)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Reading the resolved configuration
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _subject_of(resolved_config: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
369
|
+
"""Return the resolved subject seat, or refuse a configuration without one."""
|
|
370
|
+
environment = _table(resolved_config, "env")
|
|
371
|
+
subject = environment.get("subject")
|
|
372
|
+
if not isinstance(subject, Mapping):
|
|
373
|
+
raise VerificationError(
|
|
374
|
+
"the configuration the engine resolved declares no subject seat, so "
|
|
375
|
+
"it does not describe an evaluation of a subject",
|
|
376
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
377
|
+
details={"tables": _text_detail(resolved_config)},
|
|
378
|
+
)
|
|
379
|
+
return subject
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _table(document: Mapping[str, Any], name: str) -> Mapping[str, Any]:
|
|
383
|
+
"""Return one required table of a resolved configuration."""
|
|
384
|
+
value = document.get(name)
|
|
385
|
+
if not isinstance(value, Mapping):
|
|
386
|
+
raise VerificationError(
|
|
387
|
+
f"the configuration the engine resolved has no {name!r} table, so "
|
|
388
|
+
"what it ran cannot be established",
|
|
389
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
390
|
+
details={"table": name},
|
|
391
|
+
)
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _set_knobs(sampling: Mapping[str, Any]) -> dict[str, JsonValue]:
|
|
396
|
+
"""Return only the knobs a rollout actually recorded a value for.
|
|
397
|
+
|
|
398
|
+
The same rule as `_sampling_of`, applied to the other side, so neither
|
|
399
|
+
side can make "not set" look like a difference.
|
|
400
|
+
"""
|
|
401
|
+
return {str(k): v for k, v in sorted(sampling.items()) if v is not None}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _sampling_of(resolved_config: Mapping[str, Any]) -> dict[str, JsonValue]:
|
|
405
|
+
"""Return the sampling knobs the engine wrote back out, as set values.
|
|
406
|
+
|
|
407
|
+
A knob the engine resolved to null was not set, and a rollout that omits
|
|
408
|
+
the knob is recording the same fact in fewer words. Keeping the nulls made
|
|
409
|
+
the two spellings of "not set" compare unequal, which failed a real paid
|
|
410
|
+
run whose sampling was in fact identical on both sides: the engine had
|
|
411
|
+
grown two nullable knobs the rollouts do not mention.
|
|
412
|
+
|
|
413
|
+
Dropping them narrows nothing. A knob with a value on one side and not the
|
|
414
|
+
other still differs, and two knobs with different values still differ -
|
|
415
|
+
the only comparison that stops failing is the one that was never a
|
|
416
|
+
difference.
|
|
417
|
+
"""
|
|
418
|
+
sampling = _table(resolved_config, "sampling")
|
|
419
|
+
values: dict[str, JsonValue] = {}
|
|
420
|
+
for key, value in sorted(sampling.items()):
|
|
421
|
+
if value is None:
|
|
422
|
+
continue
|
|
423
|
+
if not isinstance(value, str | int | float | bool):
|
|
424
|
+
raise VerificationError(
|
|
425
|
+
"the resolved sampling parameters hold a value that is not a "
|
|
426
|
+
"scalar, so they cannot be fingerprinted",
|
|
427
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
428
|
+
details={"key": str(key)},
|
|
429
|
+
)
|
|
430
|
+
values[str(key)] = value
|
|
431
|
+
if not values:
|
|
432
|
+
raise VerificationError(
|
|
433
|
+
"the configuration the engine resolved records no sampling "
|
|
434
|
+
"parameters, so how the subject was sampled is unknown",
|
|
435
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
436
|
+
details={},
|
|
437
|
+
)
|
|
438
|
+
return values
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _mounted_skill_digests(resolved_config: Mapping[str, Any]) -> list[Digest]:
|
|
442
|
+
"""Recover the content address of every skill the engine mounted."""
|
|
443
|
+
harness = _table(_subject_of(resolved_config), "harness")
|
|
444
|
+
mounted = harness.get("skills", [])
|
|
445
|
+
if not isinstance(mounted, list):
|
|
446
|
+
raise VerificationError(
|
|
447
|
+
"the resolved harness does not list the skills it mounted",
|
|
448
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
449
|
+
details={},
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
digests: list[Digest] = []
|
|
453
|
+
for entry in mounted:
|
|
454
|
+
name = Path(str(entry)).name
|
|
455
|
+
if not name.startswith(_DIGEST_DIRECTORY_PREFIX):
|
|
456
|
+
raise VerificationError(
|
|
457
|
+
"the engine mounted a skill from a directory that is not named "
|
|
458
|
+
"after the skill's content, so what the subject read cannot be "
|
|
459
|
+
"identified",
|
|
460
|
+
code=OBSERVED_CONFIGURATION_MISMATCH,
|
|
461
|
+
details={"directory": name},
|
|
462
|
+
)
|
|
463
|
+
digests.append(validate_digest(name.replace("-", ":", 1)))
|
|
464
|
+
return digests
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _digest_detail(digests: Sequence[Digest]) -> list[JsonValue]:
|
|
468
|
+
"""Return a digest list in the shape typed error details carry."""
|
|
469
|
+
return [value for value in digests]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _text_detail(values: Iterable[object]) -> list[JsonValue]:
|
|
473
|
+
"""Return an ordered, printable list in the shape error details carry."""
|
|
474
|
+
return [text for text in sorted(str(value) for value in values)]
|