techtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )