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,368 @@
|
|
|
1
|
+
"""The files one variant's evaluation must have left behind. Spec section 6.13.
|
|
2
|
+
|
|
3
|
+
A real ``eval`` run leaves three files Techtree keeps: the resolved
|
|
4
|
+
configuration, the raw upstream episodes, and the upstream log. The normalizer
|
|
5
|
+
that the engine runs over them does not replace any of them (spec section 6.12)
|
|
6
|
+
— Techtree retains raw upstream evidence *and* its normalized projection,
|
|
7
|
+
because a projection nobody can check against its source is a claim rather than
|
|
8
|
+
evidence.
|
|
9
|
+
|
|
10
|
+
*Two of the three live in subdirectories now.* The released engine writes
|
|
11
|
+
``configs/resolved/eval.json`` and ``logs/attempt_<n>/eval.log`` rather than the
|
|
12
|
+
flat ``config.toml`` and ``eval.log`` of the development build
|
|
13
|
+
(``docs/verifiers-pin-0.3.1.md``, deviations D1 and D5), and the resolved
|
|
14
|
+
configuration is JSON because only JSON round-trips the explicit nulls the
|
|
15
|
+
engine writes. The attempt number is ``1`` and can be nothing else: an attempt
|
|
16
|
+
directory is minted per *launch*, and Techtree launches a run directory exactly
|
|
17
|
+
once — it never resumes and never re-enters one (deviation D3).
|
|
18
|
+
|
|
19
|
+
*The engine also writes ``logs/latest``, and Techtree ignores it.* It is a
|
|
20
|
+
symbolic link to the current attempt directory, repointed by every later
|
|
21
|
+
launch. A digest taken over a link is not a digest of the file it names, and a
|
|
22
|
+
path that another launch can silently redirect is not a citation, so the
|
|
23
|
+
evidence this module hashes is the attempt directory itself. Nothing here
|
|
24
|
+
follows, records or hashes the link.
|
|
25
|
+
|
|
26
|
+
*The engine writes no copy of the launch configuration for a Techtree run.* It
|
|
27
|
+
copies the launch file verbatim to ``configs/eval.toml`` only when that file is
|
|
28
|
+
TOML, and Techtree's is ``input.json`` (deviation D5). Nothing is lost: the
|
|
29
|
+
compiled input is already the run's own file and already digested where it was
|
|
30
|
+
written.
|
|
31
|
+
|
|
32
|
+
A dry-run directory is deliberately not accepted here. Dry run writes only the
|
|
33
|
+
resolved configuration (``docs/verifiers-eval.md``, finding E2), so applying
|
|
34
|
+
the run's requirements to it would report a truncated run where there was never
|
|
35
|
+
a run at all.
|
|
36
|
+
|
|
37
|
+
``build_variant_result`` does not interpret anything itself. It hands the raw
|
|
38
|
+
directory to the engine tool ``normalize_eval_output.py``, which runs under the
|
|
39
|
+
engine's own interpreter with the pinned Verifiers wire models available to it
|
|
40
|
+
(decisions document 0003 A3). Reading a Trace with the version of Verifiers
|
|
41
|
+
that wrote it is the difference between interpreting the record and guessing at
|
|
42
|
+
it, and it is why the interpretation is pinned exactly as tightly as the
|
|
43
|
+
library.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Final
|
|
51
|
+
|
|
52
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
53
|
+
|
|
54
|
+
from techtree.canonical import sha256_digest_bytes
|
|
55
|
+
from techtree.engines.registry import EngineRegistry
|
|
56
|
+
from techtree.engines.runner import EngineRunner
|
|
57
|
+
from techtree.errors import EngineError, ValidationError
|
|
58
|
+
from techtree.models.base import ArtifactRef, Digest
|
|
59
|
+
from techtree.verifiers.models import (
|
|
60
|
+
NORMALIZED_EPISODES_FILENAME,
|
|
61
|
+
ChildProcessOutcome,
|
|
62
|
+
NormalizedEpisode,
|
|
63
|
+
SubjectImageResolution,
|
|
64
|
+
VariantExecutionPlan,
|
|
65
|
+
VariantExecutionResult,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"DEFAULT_NORMALIZE_TIMEOUT_SECONDS",
|
|
70
|
+
"EVAL_LOG_MEDIA_TYPE",
|
|
71
|
+
"EVAL_LOG_PATH",
|
|
72
|
+
"NORMALIZED_EPISODES_FILENAME",
|
|
73
|
+
"NORMALIZED_EPISODES_MEDIA_TYPE",
|
|
74
|
+
"NORMALIZE_EVAL_OUTPUT_TOOL",
|
|
75
|
+
"RESOLVED_CONFIG_MEDIA_TYPE",
|
|
76
|
+
"RESOLVED_CONFIG_PATH",
|
|
77
|
+
"TRACES_FILENAME",
|
|
78
|
+
"TRACES_MEDIA_TYPE",
|
|
79
|
+
"VARIANT_OUTPUT_INCOMPLETE",
|
|
80
|
+
"artifact_for",
|
|
81
|
+
"build_variant_result",
|
|
82
|
+
"normalize_eval_output",
|
|
83
|
+
"read_normalized_episodes",
|
|
84
|
+
"require_output_files",
|
|
85
|
+
"required_output_paths",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
#: Stable error code. Spec section 6.13.
|
|
89
|
+
VARIANT_OUTPUT_INCOMPLETE: Final = "variant_output_incomplete"
|
|
90
|
+
|
|
91
|
+
#: The configuration the engine resolved and wrote back, relative to the run's
|
|
92
|
+
#: own directory. Nested and JSON since v0.3.1 (deviation D1), and the same
|
|
93
|
+
#: relative path whether the run was real or a dry run, which is what lets the
|
|
94
|
+
#: validation confirm the path the real run's reader will use.
|
|
95
|
+
RESOLVED_CONFIG_PATH: Final = "configs/resolved/eval.json"
|
|
96
|
+
|
|
97
|
+
TRACES_FILENAME: Final = "traces.jsonl"
|
|
98
|
+
|
|
99
|
+
#: This launch's log. One attempt directory is minted per launch and Techtree
|
|
100
|
+
#: launches once, so the run's log is always attempt one's (deviation D5). The
|
|
101
|
+
#: ``logs/latest`` link beside it is deliberately not what is read: see the
|
|
102
|
+
#: module docstring.
|
|
103
|
+
EVAL_LOG_PATH: Final = "logs/attempt_1/eval.log"
|
|
104
|
+
|
|
105
|
+
#: The engine helper that turns raw episodes into the protocol projection. It
|
|
106
|
+
#: lives inside the digested bundle (decisions document 0003 A3).
|
|
107
|
+
NORMALIZE_EVAL_OUTPUT_TOOL: Final = "normalize_eval_output.py"
|
|
108
|
+
|
|
109
|
+
RESOLVED_CONFIG_MEDIA_TYPE: Final = "application/json"
|
|
110
|
+
TRACES_MEDIA_TYPE: Final = "application/x-ndjson"
|
|
111
|
+
EVAL_LOG_MEDIA_TYPE: Final = "text/plain"
|
|
112
|
+
NORMALIZED_EPISODES_MEDIA_TYPE: Final = "application/x-ndjson"
|
|
113
|
+
|
|
114
|
+
DEFAULT_NORMALIZE_TIMEOUT_SECONDS: Final = 300.0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def required_output_paths(output_dir: Path) -> dict[str, Path]:
|
|
118
|
+
"""Return the three files a completed evaluation writes.
|
|
119
|
+
|
|
120
|
+
``output_dir`` is the run's own directory — the one ``--run.name`` pins,
|
|
121
|
+
one level below the directory the compiled configuration groups runs under.
|
|
122
|
+
"""
|
|
123
|
+
return {
|
|
124
|
+
"config": output_dir / RESOLVED_CONFIG_PATH,
|
|
125
|
+
"traces": output_dir / TRACES_FILENAME,
|
|
126
|
+
"eval_log": output_dir / EVAL_LOG_PATH,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def require_output_files(output_dir: Path) -> dict[str, Path]:
|
|
131
|
+
"""Return the three files, or refuse.
|
|
132
|
+
|
|
133
|
+
An empty ``traces.jsonl`` counts as missing. Upstream truncates the file to
|
|
134
|
+
empty before the first rollout, so its emptiness after the child exits
|
|
135
|
+
means no episode ever completed, which is a failed run rather than a run
|
|
136
|
+
with no results.
|
|
137
|
+
"""
|
|
138
|
+
paths = required_output_paths(output_dir)
|
|
139
|
+
missing = sorted(name for name, path in paths.items() if not path.is_file())
|
|
140
|
+
if missing:
|
|
141
|
+
raise ValidationError(
|
|
142
|
+
"the evaluation output is incomplete; "
|
|
143
|
+
f"missing {', '.join(missing)} under {output_dir.name}",
|
|
144
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
145
|
+
details={"output_dir": str(output_dir), "missing": list(missing)},
|
|
146
|
+
)
|
|
147
|
+
if paths["traces"].stat().st_size == 0:
|
|
148
|
+
raise ValidationError(
|
|
149
|
+
"the evaluation recorded no episodes; traces.jsonl is empty",
|
|
150
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
151
|
+
details={"output_dir": str(output_dir), "missing": ["episodes"]},
|
|
152
|
+
)
|
|
153
|
+
return paths
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def artifact_for(path: Path, media_type: str) -> ArtifactRef:
|
|
157
|
+
"""Hash a file's exact bytes and build its artifact reference."""
|
|
158
|
+
try:
|
|
159
|
+
data = path.read_bytes()
|
|
160
|
+
except OSError as error:
|
|
161
|
+
raise ValidationError(
|
|
162
|
+
f"the evaluation artifact {path.name} could not be read",
|
|
163
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
164
|
+
details={"path": str(path)},
|
|
165
|
+
) from error
|
|
166
|
+
if not data:
|
|
167
|
+
raise ValidationError(
|
|
168
|
+
f"the evaluation artifact {path.name} is empty",
|
|
169
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
170
|
+
details={"path": str(path)},
|
|
171
|
+
)
|
|
172
|
+
return ArtifactRef(
|
|
173
|
+
digest=sha256_digest_bytes(data),
|
|
174
|
+
media_type=media_type,
|
|
175
|
+
size=len(data),
|
|
176
|
+
relative_path=None,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_normalized_episodes(path: Path) -> list[NormalizedEpisode]:
|
|
181
|
+
"""Parse every record of a normalized JSONL file.
|
|
182
|
+
|
|
183
|
+
A missing final newline is a refusal rather than a shrug. The normalizer
|
|
184
|
+
writes whole records; a file that stops mid-line is a file that was
|
|
185
|
+
truncated, and parsing the part that survived would silently drop an
|
|
186
|
+
episode from a comparison that claims to cover every task.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
raw = path.read_bytes()
|
|
190
|
+
except OSError as error:
|
|
191
|
+
raise ValidationError(
|
|
192
|
+
"the normalized episode file could not be read",
|
|
193
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
194
|
+
details={"path": str(path)},
|
|
195
|
+
) from error
|
|
196
|
+
|
|
197
|
+
if not raw:
|
|
198
|
+
raise ValidationError(
|
|
199
|
+
"the normalized episode file is empty",
|
|
200
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
201
|
+
details={"path": str(path)},
|
|
202
|
+
)
|
|
203
|
+
if not raw.endswith(b"\n"):
|
|
204
|
+
raise ValidationError(
|
|
205
|
+
"the normalized episode file does not end with a newline, so its "
|
|
206
|
+
"last record is incomplete",
|
|
207
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
208
|
+
details={"path": str(path)},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
episodes: list[NormalizedEpisode] = []
|
|
212
|
+
for number, line in enumerate(raw.decode("utf-8").splitlines(), start=1):
|
|
213
|
+
if not line.strip():
|
|
214
|
+
raise ValidationError(
|
|
215
|
+
f"the normalized episode file has a blank record at line {number}",
|
|
216
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
217
|
+
details={"path": str(path), "line": number},
|
|
218
|
+
)
|
|
219
|
+
episodes.append(_parse_episode(line, path=path, number=number))
|
|
220
|
+
return episodes
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_episode(line: str, *, path: Path, number: int) -> NormalizedEpisode:
|
|
224
|
+
"""Parse one normalized record, or refuse with its line number."""
|
|
225
|
+
try:
|
|
226
|
+
return NormalizedEpisode.model_validate_json(line)
|
|
227
|
+
except PydanticValidationError as error:
|
|
228
|
+
detail = error.errors()[0]["msg"]
|
|
229
|
+
raise ValidationError(
|
|
230
|
+
f"the normalized episode at line {number} is malformed: {detail}",
|
|
231
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
232
|
+
details={"path": str(path), "line": number},
|
|
233
|
+
) from error
|
|
234
|
+
except json.JSONDecodeError as error:
|
|
235
|
+
raise ValidationError(
|
|
236
|
+
f"the normalized episode at line {number} is not JSON",
|
|
237
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
238
|
+
details={"path": str(path), "line": number},
|
|
239
|
+
) from error
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# The engine's own interpretation
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def normalize_eval_output(
|
|
248
|
+
*,
|
|
249
|
+
engine_registry: EngineRegistry,
|
|
250
|
+
engine_digest: Digest,
|
|
251
|
+
engine_runner: EngineRunner,
|
|
252
|
+
output_dir: Path,
|
|
253
|
+
taskset_lock_path: Path,
|
|
254
|
+
experiment_manifest_path: Path,
|
|
255
|
+
destination: Path,
|
|
256
|
+
timeout: float = DEFAULT_NORMALIZE_TIMEOUT_SECONDS,
|
|
257
|
+
) -> Path:
|
|
258
|
+
"""Run the engine's normalizer over one variant's raw output.
|
|
259
|
+
|
|
260
|
+
Nothing about the upstream record is interpreted on this side of the
|
|
261
|
+
boundary. The helper runs under the engine's interpreter, refuses anything
|
|
262
|
+
it cannot join onto the Campaign's committed membership, and writes the
|
|
263
|
+
projection ordered by that membership rather than by completion order.
|
|
264
|
+
"""
|
|
265
|
+
script = engine_registry.tool_path(engine_digest, NORMALIZE_EVAL_OUTPUT_TOOL)
|
|
266
|
+
result = engine_runner.run_python_script(
|
|
267
|
+
script,
|
|
268
|
+
[
|
|
269
|
+
"--output-dir",
|
|
270
|
+
str(output_dir),
|
|
271
|
+
"--membership",
|
|
272
|
+
str(taskset_lock_path),
|
|
273
|
+
"--experiment-manifest",
|
|
274
|
+
str(experiment_manifest_path),
|
|
275
|
+
"--output",
|
|
276
|
+
str(destination),
|
|
277
|
+
],
|
|
278
|
+
timeout=timeout,
|
|
279
|
+
)
|
|
280
|
+
if result.exit_code != 0:
|
|
281
|
+
raise EngineError(
|
|
282
|
+
"the engine could not normalize the evaluation output: "
|
|
283
|
+
f"{_last_line(result.stderr) or _last_line(result.stdout)}",
|
|
284
|
+
code="eval_normalization_failed",
|
|
285
|
+
details={
|
|
286
|
+
"engine_digest": engine_digest,
|
|
287
|
+
"output_dir": str(output_dir),
|
|
288
|
+
"exit_code": result.exit_code,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
return destination
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_variant_result(
|
|
295
|
+
*,
|
|
296
|
+
plan: VariantExecutionPlan,
|
|
297
|
+
outcome: ChildProcessOutcome,
|
|
298
|
+
image_resolution: SubjectImageResolution,
|
|
299
|
+
engine_registry: EngineRegistry,
|
|
300
|
+
engine_digest: Digest,
|
|
301
|
+
engine_runner: EngineRunner,
|
|
302
|
+
taskset_lock_path: Path,
|
|
303
|
+
timeout: float = DEFAULT_NORMALIZE_TIMEOUT_SECONDS,
|
|
304
|
+
) -> VariantExecutionResult:
|
|
305
|
+
"""Assemble one variant's complete result from what the child left behind.
|
|
306
|
+
|
|
307
|
+
Raw evidence and its projection are both retained and both hashed. The
|
|
308
|
+
projection is what later stages read; the raw files are what makes the
|
|
309
|
+
projection checkable, and a receipt that cited only the tidy document would
|
|
310
|
+
be asking to be trusted rather than verified.
|
|
311
|
+
"""
|
|
312
|
+
if outcome.variant is not plan.variant:
|
|
313
|
+
raise ValidationError(
|
|
314
|
+
f"a {plan.variant.value} plan cannot be completed by a "
|
|
315
|
+
f"{outcome.variant.value} child process",
|
|
316
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
317
|
+
details={"plan": plan.variant.value, "outcome": outcome.variant.value},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
output_dir = Path(plan.verifiers_output_dir)
|
|
321
|
+
paths = require_output_files(output_dir)
|
|
322
|
+
destination = output_dir / NORMALIZED_EPISODES_FILENAME
|
|
323
|
+
|
|
324
|
+
normalize_eval_output(
|
|
325
|
+
engine_registry=engine_registry,
|
|
326
|
+
engine_digest=engine_digest,
|
|
327
|
+
engine_runner=engine_runner,
|
|
328
|
+
output_dir=output_dir,
|
|
329
|
+
taskset_lock_path=taskset_lock_path,
|
|
330
|
+
experiment_manifest_path=Path(plan.experiment_manifest_path),
|
|
331
|
+
destination=destination,
|
|
332
|
+
timeout=timeout,
|
|
333
|
+
)
|
|
334
|
+
episodes = read_normalized_episodes(destination)
|
|
335
|
+
|
|
336
|
+
if len(episodes) != plan.task_count:
|
|
337
|
+
raise ValidationError(
|
|
338
|
+
f"the {plan.variant.value} variant normalized {len(episodes)} "
|
|
339
|
+
f"episodes for {plan.task_count} committed tasks",
|
|
340
|
+
code=VARIANT_OUTPUT_INCOMPLETE,
|
|
341
|
+
details={
|
|
342
|
+
"variant": plan.variant.value,
|
|
343
|
+
"normalized": len(episodes),
|
|
344
|
+
"expected": plan.task_count,
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return VariantExecutionResult(
|
|
349
|
+
variant=plan.variant,
|
|
350
|
+
experiment_manifest_digest=plan.experiment_manifest_digest,
|
|
351
|
+
resolved_verifiers_config=artifact_for(
|
|
352
|
+
paths["config"], RESOLVED_CONFIG_MEDIA_TYPE
|
|
353
|
+
),
|
|
354
|
+
raw_traces=artifact_for(paths["traces"], TRACES_MEDIA_TYPE),
|
|
355
|
+
eval_log=artifact_for(paths["eval_log"], EVAL_LOG_MEDIA_TYPE),
|
|
356
|
+
normalized_episodes=artifact_for(destination, NORMALIZED_EPISODES_MEDIA_TYPE),
|
|
357
|
+
child_outcome=outcome,
|
|
358
|
+
image_resolution=image_resolution,
|
|
359
|
+
episodes=episodes,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _last_line(stream: str) -> str:
|
|
364
|
+
"""Return the last line that says something."""
|
|
365
|
+
for line in reversed(stream.splitlines()):
|
|
366
|
+
if line.strip():
|
|
367
|
+
return line.strip()
|
|
368
|
+
return ""
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""How far one variant has got, read from its own evidence. Spec section 6.11.
|
|
2
|
+
|
|
3
|
+
Progress is derived from the file the engine is already writing rather than
|
|
4
|
+
from anything the child says on its way past. ``traces.jsonl`` is append-only:
|
|
5
|
+
upstream serializes one whole Episode, appends it under a run-wide lock in a
|
|
6
|
+
shielded worker thread, and ends every record with a newline
|
|
7
|
+
(``docs/verifiers-eval.md``). Counting the records that are complete is
|
|
8
|
+
therefore an exact, cheap, read-only measurement that a concurrent writer cannot
|
|
9
|
+
be disturbed by — provided the reader ignores a final line that has not been
|
|
10
|
+
terminated yet, and never writes to the file to "repair" it.
|
|
11
|
+
|
|
12
|
+
One rule governs how the number may be used. **Line position is never task
|
|
13
|
+
position.** Records land in completion order, and the preflight watched four
|
|
14
|
+
tasks arrive in exactly reverse order (``docs/verifiers-eval.md``, finding E4).
|
|
15
|
+
A count of finished episodes is honest; an inference from line seven to task
|
|
16
|
+
seven is not. Pairing happens later, by task hash, in the engine's own
|
|
17
|
+
normalizer.
|
|
18
|
+
|
|
19
|
+
The file's absence means *pending*, not broken. Upstream truncates
|
|
20
|
+
``traces.jsonl`` to empty only after the taskset package has imported and the
|
|
21
|
+
environment has been constructed, roughly a second and a half in on a trivial
|
|
22
|
+
taskset, so a poller that treated "no file yet" as failure would fail every run
|
|
23
|
+
it watched.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Final, Literal
|
|
31
|
+
|
|
32
|
+
from techtree.models.run import VariantProgress
|
|
33
|
+
from techtree.runs.events import (
|
|
34
|
+
DETAIL_COMPLETED,
|
|
35
|
+
DETAIL_ERRORED,
|
|
36
|
+
DETAIL_RUNNING,
|
|
37
|
+
DETAIL_STATE,
|
|
38
|
+
DETAIL_TOTAL,
|
|
39
|
+
DETAIL_VARIANT,
|
|
40
|
+
VARIANT_PROGRESS,
|
|
41
|
+
)
|
|
42
|
+
from techtree.runs.store import RunStore
|
|
43
|
+
from techtree.verifiers.child import CANCELLATION_EXIT_CODE
|
|
44
|
+
from techtree.verifiers.models import VariantName
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"count_complete_jsonl_records",
|
|
48
|
+
"emit_progress_if_changed",
|
|
49
|
+
"inspect_progress",
|
|
50
|
+
"pending_progress",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
#: Reading the tail of a growing file is done in whole chunks rather than line
|
|
54
|
+
#: by line so that one poll is one pass, however many episodes have landed.
|
|
55
|
+
_READ_CHUNK_BYTES: Final = 1 << 20
|
|
56
|
+
|
|
57
|
+
type _VariantLiteral = Literal["baseline", "candidate"]
|
|
58
|
+
type _StateLiteral = Literal["pending", "running", "completed", "failed", "cancelled"]
|
|
59
|
+
|
|
60
|
+
#: ``VariantProgress`` is a projection model and names its variants as literals.
|
|
61
|
+
#: Spelling the correspondence out keeps the enum the single source of the two
|
|
62
|
+
#: names while still handing the model the exact type it declares.
|
|
63
|
+
_VARIANT_LITERALS: Final[dict[VariantName, _VariantLiteral]] = {
|
|
64
|
+
VariantName.BASELINE: "baseline",
|
|
65
|
+
VariantName.CANDIDATE: "candidate",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def count_complete_jsonl_records(path: Path) -> int:
|
|
70
|
+
"""Count the whole, valid JSON object records in an append-only JSONL file.
|
|
71
|
+
|
|
72
|
+
A final line without its newline is a record still being written, and is not
|
|
73
|
+
counted. Neither is a line that is newline-terminated but not yet a valid
|
|
74
|
+
JSON object, which is what a partial write looks like on a filesystem that
|
|
75
|
+
reorders. Nothing is rewritten: the file belongs to the child, and a reader
|
|
76
|
+
that repaired it would be destroying the evidence it came to measure.
|
|
77
|
+
|
|
78
|
+
A missing file counts as zero, because the engine creates it a moment after
|
|
79
|
+
the child starts and before the first rollout.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
handle = path.open("rb")
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return 0
|
|
85
|
+
except OSError:
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
complete = 0
|
|
89
|
+
remainder = b""
|
|
90
|
+
with handle:
|
|
91
|
+
while chunk := handle.read(_READ_CHUNK_BYTES):
|
|
92
|
+
remainder += chunk
|
|
93
|
+
*lines, remainder = remainder.split(b"\n")
|
|
94
|
+
complete += sum(1 for line in lines if _is_complete_record(line))
|
|
95
|
+
return complete
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_complete_record(line: bytes) -> bool:
|
|
99
|
+
"""Whether one newline-terminated line is a whole JSON object."""
|
|
100
|
+
if not line.strip():
|
|
101
|
+
return False
|
|
102
|
+
try:
|
|
103
|
+
return isinstance(json.loads(line), dict)
|
|
104
|
+
except (ValueError, UnicodeDecodeError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pending_progress(variant: VariantName, total: int) -> VariantProgress:
|
|
109
|
+
"""The progress of a variant whose child has not been started yet."""
|
|
110
|
+
return VariantProgress(
|
|
111
|
+
variant=_VARIANT_LITERALS[variant],
|
|
112
|
+
completed=0,
|
|
113
|
+
total=total,
|
|
114
|
+
running=0,
|
|
115
|
+
errored=0,
|
|
116
|
+
state="pending",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def inspect_progress(
|
|
121
|
+
*,
|
|
122
|
+
variant: VariantName,
|
|
123
|
+
traces_path: Path,
|
|
124
|
+
total: int,
|
|
125
|
+
child_exit_code: int | None,
|
|
126
|
+
max_concurrent: int = 1,
|
|
127
|
+
) -> VariantProgress:
|
|
128
|
+
"""Measure one variant without interpreting any reward.
|
|
129
|
+
|
|
130
|
+
``completed`` is the number of episodes the engine has finished writing.
|
|
131
|
+
``running`` is how many can be in flight given what is left and how many
|
|
132
|
+
permits the variant was compiled with — the file records finished episodes
|
|
133
|
+
only, so an exact in-flight count is not observable and a bound that cannot
|
|
134
|
+
exceed the truth is the honest answer.
|
|
135
|
+
|
|
136
|
+
``errored`` stays zero here. Whether an episode is scientifically usable is
|
|
137
|
+
settled by :mod:`techtree.verifiers.verify` against the normalized
|
|
138
|
+
projection, and a progress line that guessed at it early would be reporting
|
|
139
|
+
a verdict the run has not reached.
|
|
140
|
+
"""
|
|
141
|
+
completed = min(count_complete_jsonl_records(traces_path), total)
|
|
142
|
+
remaining = max(total - completed, 0)
|
|
143
|
+
|
|
144
|
+
state: _StateLiteral
|
|
145
|
+
if child_exit_code is None:
|
|
146
|
+
state = "running" if traces_path.exists() else "pending"
|
|
147
|
+
running = min(remaining, max(max_concurrent, 0))
|
|
148
|
+
elif child_exit_code == CANCELLATION_EXIT_CODE:
|
|
149
|
+
state, running = "cancelled", 0
|
|
150
|
+
elif child_exit_code == 0 and remaining == 0:
|
|
151
|
+
state, running = "completed", 0
|
|
152
|
+
else:
|
|
153
|
+
state, running = "failed", 0
|
|
154
|
+
|
|
155
|
+
return VariantProgress(
|
|
156
|
+
variant=_VARIANT_LITERALS[variant],
|
|
157
|
+
completed=completed,
|
|
158
|
+
total=total,
|
|
159
|
+
running=running,
|
|
160
|
+
errored=0,
|
|
161
|
+
state=state,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def emit_progress_if_changed(
|
|
166
|
+
*,
|
|
167
|
+
run_store: RunStore,
|
|
168
|
+
run_id: str,
|
|
169
|
+
previous: VariantProgress | None,
|
|
170
|
+
current: VariantProgress,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Append one ``variant.progress`` event only when something moved.
|
|
173
|
+
|
|
174
|
+
A poller runs several times a second and a run's event log is the record
|
|
175
|
+
other processes rebuild their picture of the run from. Appending an
|
|
176
|
+
unchanged projection would grow that log without adding a fact to it.
|
|
177
|
+
"""
|
|
178
|
+
if previous is not None and previous == current:
|
|
179
|
+
return
|
|
180
|
+
run_store.append(
|
|
181
|
+
run_id,
|
|
182
|
+
phase=None,
|
|
183
|
+
kind=VARIANT_PROGRESS,
|
|
184
|
+
details={
|
|
185
|
+
DETAIL_VARIANT: current.variant,
|
|
186
|
+
DETAIL_COMPLETED: current.completed,
|
|
187
|
+
DETAIL_TOTAL: current.total,
|
|
188
|
+
DETAIL_RUNNING: current.running,
|
|
189
|
+
DETAIL_ERRORED: current.errored,
|
|
190
|
+
DETAIL_STATE: current.state,
|
|
191
|
+
},
|
|
192
|
+
)
|