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,228 @@
1
+ """Which evaluation children a run currently owns. Spec section 6.16.
2
+
3
+ Two variants running side by side turn a single question — "stop this run" —
4
+ into two answers, and neither of them is reachable from the run's journal. The
5
+ journal records that a run was asked to stop; only the process that started the
6
+ children knows which processes those are. This module is that knowledge, and it
7
+ is deliberately the smallest thing that can hold it: an in-memory map from run
8
+ identifier to the live children, guarded by a lock because the poller and a
9
+ signal-driven wind-down touch it from different threads.
10
+
11
+ Nothing here is scientific truth. A registry entry says a process exists, not
12
+ that an experiment happened, and the registry is discarded when the worker
13
+ exits. What survives the worker is one diagnostic file,
14
+ ``execution/children.json``, written once both children are up: if the worker
15
+ dies without unwinding, that file is the only record of which process groups
16
+ were left holding Docker containers, and an operator cleaning up by hand has
17
+ nothing else to go on.
18
+
19
+ The file is written after both children start rather than after each one,
20
+ because a write between the two launches would land inside the interval the
21
+ run is measuring as launch skew.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import threading
27
+ from collections.abc import Sequence
28
+ from dataclasses import dataclass
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+ from typing import Final, Protocol, runtime_checkable
32
+
33
+ from techtree.canonical import to_json_value
34
+ from techtree.fs import atomic_write_json, ensure_private_directory
35
+ from techtree.models.base import Digest, JsonValue
36
+ from techtree.models.campaign import VariantSchedule
37
+ from techtree.verifiers.models import ChildProcessOutcome, VariantName
38
+
39
+ __all__ = [
40
+ "CHILDREN_FILENAME",
41
+ "CHILDREN_RECORD_SCHEMA_VERSION",
42
+ "EXECUTION_DIRECTORY",
43
+ "ChildRegistry",
44
+ "EvaluationChild",
45
+ "LaunchedChild",
46
+ "children_record_path",
47
+ "execution_dir",
48
+ "write_children_record",
49
+ ]
50
+
51
+ #: Where a run keeps what its execution did, as opposed to what it was given.
52
+ #: Spec section 6.19.
53
+ EXECUTION_DIRECTORY: Final = "execution"
54
+
55
+ #: The diagnostic record of which processes a run started. Spec sections 6.16
56
+ #: and 6.19.
57
+ CHILDREN_FILENAME: Final = "children.json"
58
+
59
+ #: A local operational document, not a protocol object. It is named and
60
+ #: versioned anyway so that a file found after a crash says what it is.
61
+ CHILDREN_RECORD_SCHEMA_VERSION: Final = "techtree.run-children.v1"
62
+
63
+ #: Variants always appear in comparison order, never in start order, so that a
64
+ #: registry listing reads the same way whichever child happened to be quicker.
65
+ _VARIANT_ORDER: Final[tuple[VariantName, ...]] = (
66
+ VariantName.BASELINE,
67
+ VariantName.CANDIDATE,
68
+ )
69
+
70
+
71
+ @runtime_checkable
72
+ class EvaluationChild(Protocol):
73
+ """The part of one evaluation child process a run's control path uses.
74
+
75
+ :class:`~techtree.verifiers.child.VerifiersChild` is the implementation.
76
+ Stating the surface as a protocol is what lets the scheduler's own
77
+ behaviour — the start barrier, sibling termination, the launch-skew
78
+ measurement — be tested without a subprocess, a container, or a provider.
79
+ """
80
+
81
+ @property
82
+ def variant(self) -> VariantName:
83
+ """Which side of the comparison this child is running."""
84
+ ...
85
+
86
+ @property
87
+ def pid(self) -> int | None:
88
+ """The child's process id once it has started."""
89
+ ...
90
+
91
+ @property
92
+ def argv_digest(self) -> Digest:
93
+ """The digest of this child's invocation."""
94
+ ...
95
+
96
+ def start(self) -> int:
97
+ """Start the child and return its pid."""
98
+ ...
99
+
100
+ def poll(self) -> int | None:
101
+ """Return the exit code, or ``None`` while the child is still running."""
102
+ ...
103
+
104
+ def terminate(self, grace_seconds: float = ...) -> None:
105
+ """Stop the child's process group, allowing runtime teardown first."""
106
+ ...
107
+
108
+ def outcome(self) -> ChildProcessOutcome:
109
+ """Describe the finished child."""
110
+ ...
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class LaunchedChild:
115
+ """One child as it was at the moment the run started it.
116
+
117
+ The start time is the parent's observation of the launch rather than the
118
+ child's own, because it is taken the instant ``start`` returns and the
119
+ child's is only readable once the process has exited. The two bracket each
120
+ other within the cost of a ``fork``.
121
+ """
122
+
123
+ variant: VariantName
124
+ pid: int | None
125
+ argv_digest: Digest
126
+ started_at: datetime
127
+
128
+
129
+ class ChildRegistry:
130
+ """The live evaluation children of every run this process is executing.
131
+
132
+ Registration is by variant, so a run holds at most one child per side and
133
+ re-registering a variant replaces it rather than accumulating a second
134
+ entry that nothing would ever terminate.
135
+ """
136
+
137
+ def __init__(self) -> None:
138
+ self._lock = threading.Lock()
139
+ self._children: dict[str, dict[VariantName, EvaluationChild]] = {}
140
+
141
+ def register(self, run_id: str, child: EvaluationChild) -> None:
142
+ """Record that this run owns one child process."""
143
+ with self._lock:
144
+ self._children.setdefault(run_id, {})[child.variant] = child
145
+
146
+ def children(self, run_id: str) -> tuple[EvaluationChild, ...]:
147
+ """Return this run's live children, in comparison order."""
148
+ with self._lock:
149
+ registered = dict(self._children.get(run_id, {}))
150
+ return tuple(
151
+ registered[variant] for variant in _VARIANT_ORDER if variant in registered
152
+ )
153
+
154
+ def terminate_all(self, run_id: str, grace_seconds: float) -> None:
155
+ """Stop every child this run owns, then forget them.
156
+
157
+ Each child is given the same grace period rather than sharing one, so
158
+ the second variant's containers get as long to tear down as the first
159
+ variant's did. Every child is attempted even if an earlier one raised:
160
+ a run that failed to stop one process group must still stop the other.
161
+ """
162
+ failures: list[BaseException] = []
163
+ for child in self.children(run_id):
164
+ try:
165
+ child.terminate(grace_seconds)
166
+ except BaseException as error:
167
+ failures.append(error)
168
+ finally:
169
+ self.unregister(run_id, child.variant)
170
+ if failures:
171
+ raise failures[0]
172
+
173
+ def unregister(self, run_id: str, variant: VariantName) -> None:
174
+ """Forget one child, and the run itself once it owns none."""
175
+ with self._lock:
176
+ registered = self._children.get(run_id)
177
+ if registered is None:
178
+ return
179
+ registered.pop(variant, None)
180
+ if not registered:
181
+ del self._children[run_id]
182
+
183
+
184
+ def execution_dir(run_root: Path) -> Path:
185
+ """Return where one run records what its execution did."""
186
+ return run_root / EXECUTION_DIRECTORY
187
+
188
+
189
+ def children_record_path(run_root: Path) -> Path:
190
+ """Return where one run's diagnostic child record lives."""
191
+ return execution_dir(run_root) / CHILDREN_FILENAME
192
+
193
+
194
+ def write_children_record(
195
+ *,
196
+ run_root: Path,
197
+ run_id: str,
198
+ schedule: VariantSchedule,
199
+ children: Sequence[LaunchedChild],
200
+ launch_skew_seconds: float | None,
201
+ ) -> Path:
202
+ """Write the diagnostic record of what this run started.
203
+
204
+ ``launch_skew_seconds`` is the gap between the two launches under a
205
+ parallel schedule and ``None`` under a sequential one, where the second
206
+ child does not exist yet and a gap would be a measurement of the first
207
+ variant's duration rather than of a launch.
208
+ """
209
+ path = children_record_path(run_root)
210
+ ensure_private_directory(path.parent)
211
+ rows: list[JsonValue] = [
212
+ {
213
+ "variant": child.variant.value,
214
+ "pid": child.pid,
215
+ "argv_digest": child.argv_digest,
216
+ "started_at": to_json_value(child.started_at),
217
+ }
218
+ for child in children
219
+ ]
220
+ document: dict[str, JsonValue] = {
221
+ "schema_version": CHILDREN_RECORD_SCHEMA_VERSION,
222
+ "run_id": run_id,
223
+ "schedule": schedule.value,
224
+ "launch_skew_seconds": launch_skew_seconds,
225
+ "children": rows,
226
+ }
227
+ atomic_write_json(path, document)
228
+ return path
@@ -0,0 +1,478 @@
1
+ """The append-only run event log and its vocabulary. Spec sections 18.1, 7.4.
2
+
3
+ One run event is one line of canonical JSON. The log is only ever appended to,
4
+ never rewritten, which is what lets a reader that arrives halfway through a run
5
+ reconstruct everything that has happened without coordinating with the writer.
6
+
7
+ Three properties are deliberate.
8
+
9
+ *Canonical bytes.* Lines are serialized with
10
+ :func:`techtree.canonical.canonical_json_bytes`, so the same events always
11
+ produce the same file and :func:`event_digest` is a meaningful identity for a
12
+ run's history rather than an accident of dictionary ordering.
13
+
14
+ *Durability.* Each append is a single write to a file opened with ``O_APPEND``
15
+ followed by an ``fsync``. A crash can therefore lose a whole trailing event, but
16
+ it cannot interleave one event's bytes with another's.
17
+
18
+ *Discontinuity is fatal.* Sequence numbers start at zero and increase by exactly
19
+ one. A log that skips is a log that lost something, and a state projected from
20
+ it would be quietly wrong; :func:`read_events` refuses it instead. That check is
21
+ also what catches a truncated final line, because a partly written event fails
22
+ to parse before its sequence is ever considered.
23
+
24
+ The kinds a run may record are a closed set. ``RunEvent.kind`` is a string in
25
+ the frozen protocol model, but thirteen names exhaust what a local run can say
26
+ about itself, and each name fixes the phases it may appear between and the
27
+ details it carries. :func:`validate_event_kind` holds every reader to that, so a
28
+ log cannot contain an event whose name and position disagree.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from pathlib import Path
35
+ from typing import Final
36
+
37
+ from pydantic import ValidationError as PydanticValidationError
38
+
39
+ from techtree.canonical import canonical_json_bytes, sha256_digest_bytes
40
+ from techtree.errors import NotFoundError, ValidationError
41
+ from techtree.fs import fsync_directory
42
+ from techtree.models.base import Digest, JsonValue
43
+ from techtree.models.run import RunEvent, RunPhase
44
+
45
+ __all__ = [
46
+ "CANCEL_REQUESTED",
47
+ "DETAIL_ACTOR",
48
+ "DETAIL_APPROVED_AT",
49
+ "DETAIL_COMPLETED",
50
+ "DETAIL_CURRENT",
51
+ "DETAIL_DRAFT_DIGEST",
52
+ "DETAIL_ERROR",
53
+ "DETAIL_ERRORED",
54
+ "DETAIL_LABEL",
55
+ "DETAIL_REQUESTED_BY",
56
+ "DETAIL_REQUEST_DIGEST",
57
+ "DETAIL_RESULT_DIGEST",
58
+ "DETAIL_RUNNING",
59
+ "DETAIL_STATE",
60
+ "DETAIL_TOTAL",
61
+ "DETAIL_VARIANT",
62
+ "DETAIL_WORKER_PID",
63
+ "EVENT_KINDS",
64
+ "PHASE_ENTERED",
65
+ "PROGRESS_UPDATED",
66
+ "RESULT_WRITTEN",
67
+ "RUN_APPROVED",
68
+ "RUN_CANCELLED",
69
+ "RUN_COMPLETED",
70
+ "RUN_CREATED",
71
+ "RUN_FAILED",
72
+ "SAME_PHASE_EVENT_KINDS",
73
+ "VARIANT_COMPLETED",
74
+ "VARIANT_EVENT_KINDS",
75
+ "VARIANT_PROGRESS",
76
+ "VARIANT_STARTED",
77
+ "WORKER_STARTED",
78
+ "append_event",
79
+ "event_digest",
80
+ "next_sequence",
81
+ "read_events",
82
+ "validate_event_kind",
83
+ ]
84
+
85
+ _LINE_SEPARATOR: Final = b"\n"
86
+ #: Owner read and write only, matching every other file Techtree writes.
87
+ _FILE_MODE: Final = 0o600
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Event kinds
92
+ #
93
+ # Twelve names, and no thirteenth. A kind is not a free-text annotation: it
94
+ # fixes what the event means, which phases it may sit between, and which details
95
+ # it must carry, so a reader can interpret an event without guessing.
96
+ # ---------------------------------------------------------------------------
97
+
98
+ #: The run exists and its request is fixed. Always sequence zero.
99
+ RUN_CREATED: Final = "run.created"
100
+ #: A person read the review of what this run would do and approved it. Decisions
101
+ #: document 0019 section 2: an audit fact recorded beside everything else that
102
+ #: happened to the run, not a cryptographic acceptance artifact.
103
+ RUN_APPROVED: Final = "run.approved"
104
+ #: A detached worker took the run on and announced its process id.
105
+ WORKER_STARTED: Final = "worker.started"
106
+ #: The run moved from one phase to another.
107
+ PHASE_ENTERED: Final = "phase.entered"
108
+ #: How far through the current phase the worker is.
109
+ PROGRESS_UPDATED: Final = "progress.updated"
110
+ #: Someone asked the run to stop.
111
+ CANCEL_REQUESTED: Final = "cancel.requested"
112
+ #: The run stopped because something went wrong.
113
+ RUN_FAILED: Final = "run.failed"
114
+ #: The run stopped because it was asked to.
115
+ RUN_CANCELLED: Final = "run.cancelled"
116
+ #: The report was persisted and its digest recorded.
117
+ RESULT_WRITTEN: Final = "result.written"
118
+ #: The run finished the normal path.
119
+ RUN_COMPLETED: Final = "run.completed"
120
+ #: One side of a concurrent comparison began. Spec section 3.3.
121
+ VARIANT_STARTED: Final = "variant.started"
122
+ #: How far one side of a concurrent comparison has got.
123
+ VARIANT_PROGRESS: Final = "variant.progress"
124
+ #: One side of a concurrent comparison stopped, whichever way it stopped.
125
+ VARIANT_COMPLETED: Final = "variant.completed"
126
+
127
+ #: The three kinds that speak for one variant rather than for the run. They
128
+ #: exist only while both variants are in flight, which is one phase and no
129
+ #: other.
130
+ VARIANT_EVENT_KINDS: Final[frozenset[str]] = frozenset(
131
+ {VARIANT_STARTED, VARIANT_PROGRESS, VARIANT_COMPLETED}
132
+ )
133
+
134
+ #: Every kind a run may record. Anything else is a corrupt log.
135
+ EVENT_KINDS: Final[frozenset[str]] = frozenset(
136
+ {
137
+ RUN_CREATED,
138
+ RUN_APPROVED,
139
+ WORKER_STARTED,
140
+ PHASE_ENTERED,
141
+ PROGRESS_UPDATED,
142
+ CANCEL_REQUESTED,
143
+ RUN_FAILED,
144
+ RUN_CANCELLED,
145
+ RESULT_WRITTEN,
146
+ RUN_COMPLETED,
147
+ *VARIANT_EVENT_KINDS,
148
+ }
149
+ )
150
+
151
+ #: The kinds that report something true of a run without moving it on. Every
152
+ #: other kind changes the phase. Spec section 7.6, extended by section 3.3.
153
+ SAME_PHASE_EVENT_KINDS: Final[frozenset[str]] = frozenset(
154
+ {
155
+ RUN_APPROVED,
156
+ WORKER_STARTED,
157
+ PROGRESS_UPDATED,
158
+ RESULT_WRITTEN,
159
+ *VARIANT_EVENT_KINDS,
160
+ }
161
+ )
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Detail keys
166
+ #
167
+ # ``RunEvent.details`` is free-form JSON so that a worker can record whatever a
168
+ # human will want to read later. These keys are the exception: each is part of
169
+ # the definition of the kind that carries it, and the projection interprets it.
170
+ # ---------------------------------------------------------------------------
171
+
172
+ #: The digest of the ``RunRequest`` this run executes.
173
+ DETAIL_REQUEST_DIGEST: Final = "request_digest"
174
+ #: The digest of the draft that was reviewed and approved.
175
+ DETAIL_DRAFT_DIGEST: Final = "draft_digest"
176
+ #: Who approved it: a person answering the prompt, or an operator who passed
177
+ #: the flag that stands in for the answer.
178
+ DETAIL_ACTOR: Final = "actor"
179
+ #: When the approval was given.
180
+ DETAIL_APPROVED_AT: Final = "approved_at"
181
+ #: The process id of the detached worker executing this run.
182
+ DETAIL_WORKER_PID: Final = "worker_pid"
183
+ #: Position within the work the current phase describes.
184
+ DETAIL_CURRENT: Final = "current"
185
+ #: How much work the current phase amounts to.
186
+ DETAIL_TOTAL: Final = "total"
187
+ #: What the current phase is working through.
188
+ DETAIL_LABEL: Final = "label"
189
+ #: Who asked the run to stop.
190
+ DETAIL_REQUESTED_BY: Final = "requested_by"
191
+ #: A ``CliError`` object saying why the run failed.
192
+ DETAIL_ERROR: Final = "error"
193
+ #: The digest of the ``UpliftReport`` this run produced.
194
+ DETAIL_RESULT_DIGEST: Final = "result_digest"
195
+ #: Which side of a concurrent comparison a variant event speaks for.
196
+ DETAIL_VARIANT: Final = "variant"
197
+ #: Episodes of this variant that have finished.
198
+ DETAIL_COMPLETED: Final = "completed"
199
+ #: Episodes of this variant currently in flight.
200
+ DETAIL_RUNNING: Final = "running"
201
+ #: Episodes of this variant that failed.
202
+ DETAIL_ERRORED: Final = "errored"
203
+ #: Where this variant is in its own lifecycle.
204
+ DETAIL_STATE: Final = "state"
205
+
206
+ #: The details every variant event carries. They are the whole of
207
+ #: :class:`~techtree.models.run.VariantProgress`, because a variant event that
208
+ #: reported only part of it would leave the projection guessing at the rest.
209
+ _VARIANT_DETAILS: Final[tuple[str, ...]] = (
210
+ DETAIL_VARIANT,
211
+ DETAIL_COMPLETED,
212
+ DETAIL_TOTAL,
213
+ DETAIL_RUNNING,
214
+ DETAIL_ERRORED,
215
+ DETAIL_STATE,
216
+ )
217
+
218
+ #: What each kind is defined to carry. A kind missing one of its own details
219
+ #: cannot be interpreted, so it is refused rather than projected as a blank.
220
+ _REQUIRED_DETAILS: Final[dict[str, tuple[str, ...]]] = {
221
+ RUN_CREATED: (DETAIL_REQUEST_DIGEST,),
222
+ RUN_APPROVED: (DETAIL_DRAFT_DIGEST, DETAIL_ACTOR, DETAIL_APPROVED_AT),
223
+ WORKER_STARTED: (DETAIL_WORKER_PID,),
224
+ PROGRESS_UPDATED: (DETAIL_CURRENT, DETAIL_TOTAL, DETAIL_LABEL),
225
+ CANCEL_REQUESTED: (DETAIL_REQUESTED_BY,),
226
+ RUN_FAILED: (DETAIL_ERROR,),
227
+ RESULT_WRITTEN: (DETAIL_RESULT_DIGEST,),
228
+ RUN_COMPLETED: (DETAIL_RESULT_DIGEST,),
229
+ VARIANT_STARTED: _VARIANT_DETAILS,
230
+ VARIANT_PROGRESS: _VARIANT_DETAILS,
231
+ VARIANT_COMPLETED: _VARIANT_DETAILS,
232
+ }
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Kind and phase agreement
237
+ # ---------------------------------------------------------------------------
238
+
239
+ #: Four phases each belong to exactly one kind, in both directions: no other
240
+ #: kind may enter the phase, and the kind may enter no other phase. Ending a run
241
+ #: is too consequential to be recorded by a generic phase change.
242
+ _EXCLUSIVE_PHASE_KINDS: Final[dict[RunPhase, str]] = {
243
+ RunPhase.CANCEL_REQUESTED: CANCEL_REQUESTED,
244
+ RunPhase.FAILED: RUN_FAILED,
245
+ RunPhase.CANCELLED: RUN_CANCELLED,
246
+ RunPhase.COMPLETED: RUN_COMPLETED,
247
+ }
248
+
249
+ #: The one phase each of these kinds may appear in. A worker announces itself
250
+ #: before the run starts working; a result exists only while the report is
251
+ #: being built.
252
+ _ONLY_IN_PHASE: Final[dict[str, RunPhase]] = {
253
+ # Approval is given before anything is launched, so it can only ever be
254
+ # recorded against the phase a run exists in and has not yet left.
255
+ RUN_APPROVED: RunPhase.CREATED,
256
+ WORKER_STARTED: RunPhase.CREATED,
257
+ RESULT_WRITTEN: RunPhase.BUILDING_REPORT,
258
+ # A variant is a side of a concurrent comparison, and nothing is running
259
+ # two variants at once outside the one phase that says so.
260
+ **dict.fromkeys(VARIANT_EVENT_KINDS, RunPhase.RUNNING_VARIANTS),
261
+ }
262
+
263
+ #: The phase each of these kinds must have been reached from. Both are the last
264
+ #: step of a path, and the step before it is not optional.
265
+ _REQUIRED_PREVIOUS_PHASE: Final[dict[str, RunPhase]] = {
266
+ RUN_CANCELLED: RunPhase.CANCEL_REQUESTED,
267
+ RUN_COMPLETED: RunPhase.BUILDING_REPORT,
268
+ }
269
+
270
+
271
+ def validate_event_kind(event: RunEvent) -> None:
272
+ """Reject unknown kinds, kind and phase mismatches, and missing details."""
273
+ if event.kind not in EVENT_KINDS:
274
+ known: list[JsonValue] = [kind for kind in sorted(EVENT_KINDS)]
275
+ raise ValidationError(
276
+ f"{event.kind!r} is not a run event kind",
277
+ code="run_event_kind_invalid",
278
+ details={
279
+ "run_id": event.run_id,
280
+ "sequence": event.sequence,
281
+ "kind": event.kind,
282
+ "known": known,
283
+ },
284
+ )
285
+
286
+ _check_phases(event)
287
+ _check_required_details(event)
288
+
289
+
290
+ def _check_phases(event: RunEvent) -> None:
291
+ """Refuse an event whose kind and phases describe different things."""
292
+ if event.kind == RUN_CREATED:
293
+ _check_created_event(event)
294
+ elif event.previous_phase is None:
295
+ raise _kind_mismatch(
296
+ event,
297
+ f"only {RUN_CREATED} comes from no earlier phase",
298
+ )
299
+
300
+ # The exclusive kinds own the *transition into* their phase. An event that
301
+ # stays where it is (a progress report landing just after cancellation was
302
+ # requested, for instance) is governed by the same-phase whitelist instead.
303
+ entering = event.previous_phase is not event.phase
304
+ for phase, kind in _EXCLUSIVE_PHASE_KINDS.items():
305
+ if entering and event.phase is phase and event.kind != kind:
306
+ raise _kind_mismatch(event, f"only {kind} enters {phase.value}")
307
+ if event.kind == kind and event.phase is not phase:
308
+ raise _kind_mismatch(event, f"{kind} enters {phase.value}")
309
+
310
+ required_phase = _ONLY_IN_PHASE.get(event.kind)
311
+ if required_phase is not None and event.phase is not required_phase:
312
+ raise _kind_mismatch(
313
+ event,
314
+ f"{event.kind} belongs to {required_phase.value}, "
315
+ f"not to {event.phase.value}",
316
+ )
317
+
318
+ required_previous = _REQUIRED_PREVIOUS_PHASE.get(event.kind)
319
+ if required_previous is not None and event.previous_phase is not required_previous:
320
+ raise _kind_mismatch(
321
+ event,
322
+ f"{event.kind} follows {required_previous.value}",
323
+ )
324
+
325
+ # The same-phase kinds report something true of a run while it stays where
326
+ # it is; every other kind exists to move it. Neither may pretend to be the
327
+ # other, so the two are checked as one equivalence.
328
+ same_phase = event.phase is event.previous_phase
329
+ if same_phase and event.kind not in SAME_PHASE_EVENT_KINDS:
330
+ raise _kind_mismatch(
331
+ event,
332
+ f"{event.kind} moves a run on, but this one stays in {event.phase.value}",
333
+ )
334
+ if not same_phase and event.kind in SAME_PHASE_EVENT_KINDS:
335
+ raise _kind_mismatch(
336
+ event,
337
+ f"{event.kind} does not move a run out of {event.phase.value}",
338
+ )
339
+
340
+
341
+ def _check_created_event(event: RunEvent) -> None:
342
+ """Refuse a created event anywhere but at the opening of a log."""
343
+ if event.sequence != 0:
344
+ raise _kind_mismatch(
345
+ event,
346
+ f"{RUN_CREATED} opens a log at sequence 0, not {event.sequence}",
347
+ )
348
+ if event.previous_phase is not None:
349
+ raise _kind_mismatch(
350
+ event,
351
+ "a run's first event comes from no earlier phase, but this one "
352
+ f"claims to leave {event.previous_phase.value}",
353
+ )
354
+ if event.phase is not RunPhase.CREATED:
355
+ raise _kind_mismatch(
356
+ event,
357
+ f"{RUN_CREATED} opens a run in {RunPhase.CREATED.value}",
358
+ )
359
+
360
+
361
+ def _check_required_details(event: RunEvent) -> None:
362
+ """Refuse an event that omits a detail its kind is defined to carry."""
363
+ for key in _REQUIRED_DETAILS.get(event.kind, ()):
364
+ if key not in event.details:
365
+ raise _kind_mismatch(
366
+ event,
367
+ f"a {event.kind} event carries details.{key}",
368
+ )
369
+
370
+
371
+ def _kind_mismatch(event: RunEvent, reason: str) -> ValidationError:
372
+ """Return the one failure every kind and phase disagreement raises."""
373
+ previous = None if event.previous_phase is None else event.previous_phase.value
374
+ return ValidationError(
375
+ reason,
376
+ code="run_event_kind_invalid",
377
+ details={
378
+ "run_id": event.run_id,
379
+ "sequence": event.sequence,
380
+ "kind": event.kind,
381
+ "phase": event.phase.value,
382
+ "event_previous_phase": previous,
383
+ },
384
+ )
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Bytes
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ def append_event(path: Path, event: RunEvent) -> None:
393
+ """Append compact JSONL and fsync."""
394
+ line = canonical_json_bytes(event) + _LINE_SEPARATOR
395
+
396
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
397
+ descriptor = os.open(path, flags, _FILE_MODE)
398
+ try:
399
+ written = 0
400
+ while written < len(line):
401
+ written += os.write(descriptor, line[written:])
402
+ os.fsync(descriptor)
403
+ finally:
404
+ os.close(descriptor)
405
+
406
+ # The first append also creates the directory entry, and a durable file
407
+ # whose name is not durable is not much use after a crash.
408
+ fsync_directory(path.parent)
409
+
410
+
411
+ def read_events(path: Path) -> list[RunEvent]:
412
+ """Read events and reject sequence discontinuity."""
413
+ try:
414
+ raw = path.read_bytes()
415
+ except FileNotFoundError as error:
416
+ raise NotFoundError(
417
+ f"no run event log at {path}",
418
+ details={"path": str(path)},
419
+ ) from error
420
+
421
+ lines = raw.split(_LINE_SEPARATOR)
422
+ # A well-formed log ends with a separator, which leaves one empty trailing
423
+ # element. Any other empty line is damage.
424
+ if lines and lines[-1] == b"":
425
+ lines.pop()
426
+
427
+ events: list[RunEvent] = []
428
+ for number, line in enumerate(lines, start=1):
429
+ if not line.strip():
430
+ raise ValidationError(
431
+ f"run event log line {number} is empty: {path}",
432
+ code="run_event_log_corrupt",
433
+ details={"path": str(path), "line": number},
434
+ )
435
+ try:
436
+ event = RunEvent.model_validate_json(line)
437
+ except PydanticValidationError as error:
438
+ raise ValidationError(
439
+ f"run event log line {number} is not a run event: {path} "
440
+ f"({error.errors()[0]['msg']})",
441
+ code="run_event_log_corrupt",
442
+ details={"path": str(path), "line": number},
443
+ ) from error
444
+
445
+ expected = len(events)
446
+ if event.sequence != expected:
447
+ raise ValidationError(
448
+ f"run event log skips from sequence {expected - 1} to "
449
+ f"{event.sequence}: {path}",
450
+ code="run_event_sequence_invalid",
451
+ details={
452
+ "path": str(path),
453
+ "line": number,
454
+ "expected_sequence": expected,
455
+ "sequence": event.sequence,
456
+ },
457
+ )
458
+ events.append(event)
459
+
460
+ return events
461
+
462
+
463
+ def next_sequence(events: list[RunEvent]) -> int:
464
+ """Return next sequence number."""
465
+ if not events:
466
+ return 0
467
+ return events[-1].sequence + 1
468
+
469
+
470
+ def event_digest(path: Path) -> Digest:
471
+ """Digest exact event-log bytes."""
472
+ try:
473
+ return sha256_digest_bytes(path.read_bytes())
474
+ except FileNotFoundError as error:
475
+ raise NotFoundError(
476
+ f"no run event log at {path}",
477
+ details={"path": str(path)},
478
+ ) from error