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,489 @@
1
+ """The run state machine and its projection. Spec sections 18.2, 7.6.
2
+
3
+ Everything in this module is a pure function of the event log. Nothing here
4
+ touches the filesystem, the clock, or the process table, which is what makes
5
+ "the state rebuilds from the events alone" a property that can be tested rather
6
+ than a claim.
7
+
8
+ The normal path is a straight line::
9
+
10
+ created → validating_taskset → running_baseline → running_candidate
11
+ → building_receipts → verifying_comparison → building_report → completed
12
+
13
+ ``running_variants`` is the one branch off it: a run whose two variants execute
14
+ side by side leaves ``validating_taskset`` for it instead of for
15
+ ``running_baseline``, and rejoins the line at ``building_receipts``. The
16
+ sequential pair stays for the fake executor.
17
+
18
+ Two escapes leave it. Any phase that is still working may fail, and any phase
19
+ that is still working may have cancellation requested of it; a run that has been
20
+ asked to stop then reaches ``cancelled`` when the worker winds down, or
21
+ ``failed`` if it breaks while doing so. ``completed``, ``failed``, and
22
+ ``cancelled`` have no outgoing edges at all: once a run has ended, its log is
23
+ closed.
24
+
25
+ No phase has an edge to itself. A run does still have things to report while it
26
+ stays where it is — the worker's process id, its progress through a phase, the
27
+ digest of the report it just wrote, and how each variant of a concurrent
28
+ comparison is getting on — but those are a closed set of events, named
29
+ in :data:`techtree.runs.events.SAME_PHASE_EVENT_KINDS`, and
30
+ :func:`validate_same_phase_event` rather than the transition table is what
31
+ admits them. Recording them as events is what keeps the projection derivable
32
+ from the log; recording them anywhere else would make ``state.json`` hold facts
33
+ the log cannot rebuild.
34
+
35
+ One fact deliberately does not come from events: ``heartbeat_at``. A liveness
36
+ signal refreshed every couple of seconds would bury a run's actual history under
37
+ thousands of lines that mean nothing after the fact, so the heartbeat is a
38
+ single overwritten file and :func:`reduce_events` always leaves the field unset.
39
+ :class:`techtree.runs.store.RunStore` merges it in.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import Final
45
+
46
+ from pydantic import ValidationError as PydanticValidationError
47
+
48
+ from techtree.canonical import validate_digest
49
+ from techtree.errors import RunError, ValidationError
50
+ from techtree.models.base import Digest, JsonValue
51
+ from techtree.models.cli import CliError
52
+ from techtree.models.run import (
53
+ RunEvent,
54
+ RunPhase,
55
+ RunProgress,
56
+ RunState,
57
+ VariantProgress,
58
+ )
59
+ from techtree.runs.events import (
60
+ CANCEL_REQUESTED,
61
+ DETAIL_COMPLETED,
62
+ DETAIL_CURRENT,
63
+ DETAIL_ERROR,
64
+ DETAIL_ERRORED,
65
+ DETAIL_LABEL,
66
+ DETAIL_RESULT_DIGEST,
67
+ DETAIL_RUNNING,
68
+ DETAIL_STATE,
69
+ DETAIL_TOTAL,
70
+ DETAIL_VARIANT,
71
+ DETAIL_WORKER_PID,
72
+ PROGRESS_UPDATED,
73
+ RESULT_WRITTEN,
74
+ RUN_COMPLETED,
75
+ RUN_CREATED,
76
+ RUN_FAILED,
77
+ SAME_PHASE_EVENT_KINDS,
78
+ VARIANT_EVENT_KINDS,
79
+ WORKER_STARTED,
80
+ validate_event_kind,
81
+ )
82
+
83
+ __all__ = [
84
+ "ALLOWED_TRANSITIONS",
85
+ "NORMAL_PATH",
86
+ "apply_event",
87
+ "can_cancel",
88
+ "initial_state",
89
+ "is_terminal",
90
+ "phase_progress_allowed",
91
+ "reduce_events",
92
+ "validate_same_phase_event",
93
+ "validate_transition",
94
+ ]
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Transitions
99
+ # ---------------------------------------------------------------------------
100
+
101
+ #: The successful sequence, in order. Each phase's successor is the next entry.
102
+ NORMAL_PATH: Final[tuple[RunPhase, ...]] = (
103
+ RunPhase.CREATED,
104
+ RunPhase.VALIDATING_TASKSET,
105
+ RunPhase.RUNNING_BASELINE,
106
+ RunPhase.RUNNING_CANDIDATE,
107
+ RunPhase.BUILDING_RECEIPTS,
108
+ RunPhase.VERIFYING_COMPARISON,
109
+ RunPhase.BUILDING_REPORT,
110
+ RunPhase.COMPLETED,
111
+ )
112
+
113
+ _TERMINAL_PHASES: Final[frozenset[RunPhase]] = frozenset(
114
+ {RunPhase.COMPLETED, RunPhase.FAILED, RunPhase.CANCELLED}
115
+ )
116
+
117
+ #: The concurrent alternative to ``running_baseline → running_candidate``, as
118
+ #: the phase it leaves, the phase it is, and the phase it rejoins at. Spec
119
+ #: section 3.3 adds it beside the sequential pair rather than in place of it, so
120
+ #: ``validating_taskset`` has two successors and both routes meet again at
121
+ #: ``building_receipts``.
122
+ _VARIANT_BRANCH: Final[tuple[RunPhase, RunPhase, RunPhase]] = (
123
+ RunPhase.VALIDATING_TASKSET,
124
+ RunPhase.RUNNING_VARIANTS,
125
+ RunPhase.BUILDING_RECEIPTS,
126
+ )
127
+
128
+
129
+ def _build_allowed_transitions() -> dict[RunPhase, frozenset[RunPhase]]:
130
+ """Derive the transition table from the normal path and the two escapes."""
131
+ allowed: dict[RunPhase, frozenset[RunPhase]] = {}
132
+
133
+ working = [phase for phase in NORMAL_PATH if phase not in _TERMINAL_PHASES]
134
+ for position, phase in enumerate(working):
135
+ allowed[phase] = frozenset(
136
+ {
137
+ NORMAL_PATH[position + 1],
138
+ RunPhase.FAILED,
139
+ RunPhase.CANCEL_REQUESTED,
140
+ }
141
+ )
142
+
143
+ entered_from, branch, rejoins_at = _VARIANT_BRANCH
144
+ allowed[entered_from] = allowed[entered_from] | {branch}
145
+ allowed[branch] = frozenset(
146
+ {rejoins_at, RunPhase.FAILED, RunPhase.CANCEL_REQUESTED}
147
+ )
148
+
149
+ allowed[RunPhase.CANCEL_REQUESTED] = frozenset(
150
+ {
151
+ RunPhase.CANCELLED,
152
+ RunPhase.FAILED,
153
+ }
154
+ )
155
+
156
+ for phase in _TERMINAL_PHASES:
157
+ allowed[phase] = frozenset()
158
+
159
+ return allowed
160
+
161
+
162
+ #: Every phase a run may move to from each phase it can be in. A phase mapped to
163
+ #: the empty set is terminal. No phase maps to itself: an event that leaves the
164
+ #: run where it is passes :func:`validate_same_phase_event` instead.
165
+ ALLOWED_TRANSITIONS: Final[dict[RunPhase, frozenset[RunPhase]]] = (
166
+ _build_allowed_transitions()
167
+ )
168
+
169
+
170
+ def validate_transition(current: RunPhase, target: RunPhase) -> None:
171
+ """Raise on invalid transition."""
172
+ if target in ALLOWED_TRANSITIONS[current]:
173
+ return
174
+
175
+ if is_terminal(current):
176
+ raise RunError(
177
+ f"run has already ended in {current.value}; it records no further "
178
+ f"events, so it cannot move to {target.value}",
179
+ code="run_transition_invalid",
180
+ details={"phase": current.value, "target_phase": target.value},
181
+ )
182
+
183
+ if current is target:
184
+ raise RunError(
185
+ f"a run in {current.value} does not move to itself; only "
186
+ f"{_named(SAME_PHASE_EVENT_KINDS)} report without leaving a phase",
187
+ code="run_transition_invalid",
188
+ details={"phase": current.value, "target_phase": target.value},
189
+ )
190
+
191
+ allowed: list[JsonValue] = [
192
+ phase.value for phase in sorted(ALLOWED_TRANSITIONS[current])
193
+ ]
194
+ raise RunError(
195
+ f"a run in {current.value} cannot move to {target.value}",
196
+ code="run_transition_invalid",
197
+ details={
198
+ "phase": current.value,
199
+ "target_phase": target.value,
200
+ "allowed": allowed,
201
+ },
202
+ )
203
+
204
+
205
+ def validate_same_phase_event(state: RunState, event: RunEvent) -> None:
206
+ """Raise unless this event may leave the run in the phase it is in."""
207
+ if is_terminal(state.phase):
208
+ raise RunError(
209
+ f"run has already ended in {state.phase.value}; it records no "
210
+ "further events",
211
+ code="run_transition_invalid",
212
+ details={"run_id": state.run_id, "phase": state.phase.value},
213
+ )
214
+ if event.kind not in SAME_PHASE_EVENT_KINDS:
215
+ raise RunError(
216
+ f"a run records nothing without leaving {state.phase.value} except "
217
+ f"{_named(SAME_PHASE_EVENT_KINDS)}",
218
+ code="run_transition_invalid",
219
+ details={
220
+ "run_id": state.run_id,
221
+ "phase": state.phase.value,
222
+ "kind": event.kind,
223
+ },
224
+ )
225
+ if event.kind == PROGRESS_UPDATED and not phase_progress_allowed(state.phase):
226
+ raise RunError(
227
+ f"a run in {state.phase.value} has no progress to report",
228
+ code="run_transition_invalid",
229
+ details={"run_id": state.run_id, "phase": state.phase.value},
230
+ )
231
+
232
+
233
+ def is_terminal(phase: RunPhase) -> bool:
234
+ """Return terminal state."""
235
+ return not ALLOWED_TRANSITIONS[phase]
236
+
237
+
238
+ def can_cancel(phase: RunPhase) -> bool:
239
+ """Return cancellation eligibility.
240
+
241
+ A run that has ended cannot be stopped, and a run that has already been
242
+ asked to stop is not asked twice — the second request would change nothing
243
+ and the caller deserves to be told so rather than given a second receipt.
244
+ """
245
+ return RunPhase.CANCEL_REQUESTED in ALLOWED_TRANSITIONS[phase]
246
+
247
+
248
+ def phase_progress_allowed(phase: RunPhase) -> bool:
249
+ """Return whether a phase measures work a run can be part-way through.
250
+
251
+ A run that has ended is not part-way through anything, and ``created`` is a
252
+ run that exists rather than a run doing something.
253
+ """
254
+ return not is_terminal(phase) and phase is not RunPhase.CREATED
255
+
256
+
257
+ def _named(kinds: frozenset[str]) -> str:
258
+ """Return a list of event kinds as English, for a message a human reads."""
259
+ return ", ".join(sorted(kinds))
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # Projection
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ def reduce_events(events: list[RunEvent]) -> RunState:
268
+ """Project complete state."""
269
+ if not events:
270
+ raise ValidationError(
271
+ "a run event log cannot be empty; every run opens with its created event",
272
+ code="run_event_log_corrupt",
273
+ )
274
+
275
+ state = initial_state(events[0])
276
+ for event in events[1:]:
277
+ state = apply_event(state, event)
278
+ return state
279
+
280
+
281
+ def initial_state(created_event: RunEvent) -> RunState:
282
+ """Project the event every run opens with."""
283
+ if created_event.kind != RUN_CREATED:
284
+ raise ValidationError(
285
+ f"a run event log opens with {RUN_CREATED}, not {created_event.kind}",
286
+ code="run_event_kind_invalid",
287
+ details={"run_id": created_event.run_id, "kind": created_event.kind},
288
+ )
289
+ validate_event_kind(created_event)
290
+ return _project(_opening_state(created_event), created_event)
291
+
292
+
293
+ def apply_event(state: RunState, event: RunEvent) -> RunState:
294
+ """Apply one event."""
295
+ if event.run_id != state.run_id:
296
+ raise ValidationError(
297
+ f"run event belongs to {event.run_id}, not to {state.run_id}",
298
+ code="run_event_log_corrupt",
299
+ details={"run_id": state.run_id, "event_run_id": event.run_id},
300
+ )
301
+ if event.sequence != state.sequence + 1:
302
+ raise ValidationError(
303
+ f"run event {event.sequence} does not follow {state.sequence}",
304
+ code="run_event_sequence_invalid",
305
+ details={
306
+ "run_id": state.run_id,
307
+ "sequence": event.sequence,
308
+ "expected_sequence": state.sequence + 1,
309
+ },
310
+ )
311
+ if event.previous_phase is not state.phase:
312
+ recorded = None if event.previous_phase is None else event.previous_phase.value
313
+ raise ValidationError(
314
+ f"run event claims to leave {recorded}, but the run is in "
315
+ f"{state.phase.value}",
316
+ code="run_event_log_corrupt",
317
+ details={
318
+ "run_id": state.run_id,
319
+ "phase": state.phase.value,
320
+ "event_previous_phase": recorded,
321
+ },
322
+ )
323
+
324
+ validate_event_kind(event)
325
+ if event.phase is state.phase:
326
+ validate_same_phase_event(state, event)
327
+ else:
328
+ validate_transition(state.phase, event.phase)
329
+ return _project(state, event)
330
+
331
+
332
+ def _project(previous: RunState, event: RunEvent) -> RunState:
333
+ """Return the state that results from applying one validated event.
334
+
335
+ Which fields an event touches follows from its kind, which is why the kind
336
+ is a closed set: a reader never has to guess whether a detail was meant to
337
+ be interpreted.
338
+ """
339
+ worker_pid = _worker_pid(event) if event.kind == WORKER_STARTED else None
340
+ progress = _progress(event) if event.kind == PROGRESS_UPDATED else None
341
+ carries_result = event.kind in (RESULT_WRITTEN, RUN_COMPLETED)
342
+ entered_new_phase = previous.phase is not event.phase
343
+
344
+ # Variant progress belongs to the phase that reported it, exactly as
345
+ # ``progress`` does, so entering a new phase discards it. Each variant keeps
346
+ # its own entry, replaced whole by the most recent event that named it.
347
+ variant_progress = {} if entered_new_phase else dict(previous.variant_progress)
348
+ if event.kind in VARIANT_EVENT_KINDS:
349
+ reported = _variant_progress(event)
350
+ variant_progress[reported.variant] = reported
351
+
352
+ return RunState(
353
+ run_id=event.run_id,
354
+ phase=event.phase,
355
+ sequence=event.sequence,
356
+ updated_at=event.timestamp,
357
+ worker_pid=worker_pid if worker_pid is not None else previous.worker_pid,
358
+ # The worker's start time is the moment it announced its process id.
359
+ worker_started_at=(
360
+ event.timestamp if worker_pid is not None else previous.worker_started_at
361
+ ),
362
+ # Liveness is not an event-sourced fact. See the module docstring.
363
+ heartbeat_at=None,
364
+ cancel_requested_at=(
365
+ event.timestamp
366
+ if event.kind == CANCEL_REQUESTED
367
+ else previous.cancel_requested_at
368
+ ),
369
+ error=(_error(event) if event.kind == RUN_FAILED else previous.error),
370
+ # Progress measures a position inside one phase, so entering a new phase
371
+ # discards it.
372
+ progress=(
373
+ progress
374
+ if progress is not None
375
+ else (None if entered_new_phase else previous.progress)
376
+ ),
377
+ variant_progress=variant_progress,
378
+ result_digest=(
379
+ _result_digest(event) if carries_result else previous.result_digest
380
+ ),
381
+ )
382
+
383
+
384
+ def _opening_state(event: RunEvent) -> RunState:
385
+ """Return the empty state a run's created event is applied to.
386
+
387
+ A run starts with nothing known about it. Spelling that as a state rather
388
+ than as a special case in every field keeps the projection one shape.
389
+ """
390
+ return RunState(
391
+ run_id=event.run_id,
392
+ phase=RunPhase.CREATED,
393
+ sequence=0,
394
+ updated_at=event.timestamp,
395
+ worker_pid=None,
396
+ worker_started_at=None,
397
+ heartbeat_at=None,
398
+ cancel_requested_at=None,
399
+ error=None,
400
+ progress=None,
401
+ variant_progress={},
402
+ result_digest=None,
403
+ )
404
+
405
+
406
+ def _worker_pid(event: RunEvent) -> int:
407
+ """Return the worker process id this event announces."""
408
+ raw = event.details[DETAIL_WORKER_PID]
409
+ if not isinstance(raw, int) or isinstance(raw, bool) or raw <= 0:
410
+ raise _detail_invalid(event, f"{DETAIL_WORKER_PID} must be a positive integer")
411
+ return raw
412
+
413
+
414
+ def _progress(event: RunEvent) -> RunProgress:
415
+ """Return the progress this event reports."""
416
+ try:
417
+ return RunProgress.model_validate(
418
+ {
419
+ "current": event.details[DETAIL_CURRENT],
420
+ "total": event.details[DETAIL_TOTAL],
421
+ "label": event.details[DETAIL_LABEL],
422
+ }
423
+ )
424
+ except PydanticValidationError as error:
425
+ raise _detail_invalid(
426
+ event,
427
+ f"progress is not valid ({error.errors()[0]['msg']})",
428
+ ) from error
429
+
430
+
431
+ def _variant_progress(event: RunEvent) -> VariantProgress:
432
+ """Return the one variant's position this event reports."""
433
+ try:
434
+ return VariantProgress.model_validate(
435
+ {
436
+ "variant": event.details[DETAIL_VARIANT],
437
+ "completed": event.details[DETAIL_COMPLETED],
438
+ "total": event.details[DETAIL_TOTAL],
439
+ "running": event.details[DETAIL_RUNNING],
440
+ "errored": event.details[DETAIL_ERRORED],
441
+ "state": event.details[DETAIL_STATE],
442
+ }
443
+ )
444
+ except PydanticValidationError as error:
445
+ raise _detail_invalid(
446
+ event,
447
+ f"variant progress is not valid ({error.errors()[0]['msg']})",
448
+ ) from error
449
+
450
+
451
+ def _result_digest(event: RunEvent) -> Digest:
452
+ """Return the report digest this event records."""
453
+ raw = event.details[DETAIL_RESULT_DIGEST]
454
+ if not isinstance(raw, str):
455
+ raise _detail_invalid(event, f"{DETAIL_RESULT_DIGEST} must be a digest string")
456
+ try:
457
+ return validate_digest(raw)
458
+ except ValidationError as error:
459
+ raise _detail_invalid(
460
+ event, f"{DETAIL_RESULT_DIGEST} is not a digest"
461
+ ) from error
462
+
463
+
464
+ def _error(event: RunEvent) -> CliError:
465
+ """Return the failure this event carries.
466
+
467
+ A run that failed without saying why leaves the caller nothing to act on, so
468
+ the error is part of the transition rather than an optional annotation.
469
+ """
470
+ try:
471
+ return CliError.model_validate(event.details[DETAIL_ERROR])
472
+ except PydanticValidationError as error:
473
+ raise _detail_invalid(
474
+ event,
475
+ f"{DETAIL_ERROR} is not a CLI error ({error.errors()[0]['msg']})",
476
+ ) from error
477
+
478
+
479
+ def _detail_invalid(event: RunEvent, reason: str) -> ValidationError:
480
+ """Return the failure a detail that its kind cannot interpret raises."""
481
+ return ValidationError(
482
+ f"run event {reason}",
483
+ code="run_event_kind_invalid",
484
+ details={
485
+ "run_id": event.run_id,
486
+ "sequence": event.sequence,
487
+ "kind": event.kind,
488
+ },
489
+ )