dos-kernel 0.22.0__py3-none-win_amd64.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 (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. dos_mcp/server.py +779 -0
dos/tool_stream.py ADDED
@@ -0,0 +1,393 @@
1
+ """TS — the stall verdict: *is the tool loop producing new bytes, or just spinning?*
2
+
3
+ docs/145 — the loop-economics axis. `arg_provenance` (docs/143) and the intervention
4
+ ladder (docs/144) are both *prevent-down* on the **Integrity** verifier slice, and both
5
+ **vanish on a strong model** — a model that reads-before-it-writes mints nothing, so the
6
+ detector catches nothing and adds nothing. Their ceiling is the *minting rate*, which → 0
7
+ as the model improves.
8
+
9
+ This module opens the axis neither doc built. A cheap model on a long horizon **thrashes**:
10
+ it re-issues the same read, polls for an eventual-consistency write to land, or loops
11
+ without progress until it times out at `max_iterations` with the task half-finished. The
12
+ benchmark's own headline is that success **decays monotonically with horizon** (~35 %@4 steps
13
+ → <20 %@16). A slice of that decay is **not planning** (the off-limits +14–35 pp lever) — it
14
+ is the agent failing to *use* a value it already received. This leaf detects that slice and
15
+ lets a consumer **re-surface the env-authored value the agent already holds**, converting a
16
+ doomed re-read loop into a finished task on the **same budget**. It is the first DOS lever on
17
+ this benchmark that can move success **UP** (add a finishing step) rather than only prevent a
18
+ wrong one — and the first whose value is **independent of minting** (it fires on *any* looping
19
+ model, including a strong one stuck on eventual consistency).
20
+
21
+ This module is `liveness.classify`'s sibling — a **pure** verdict function, re-aimed off git
22
+ onto the in-process tool-result stream:
23
+
24
+ liveness.classify (ProgressEvidence, policy) -> LivenessVerdict (git/journal stream)
25
+ tool_stream.classify_stream (ToolStream, policy) -> StreamVerdict (tool-result stream)
26
+ ^ THIS module
27
+
28
+ `liveness` asks "did the GIT/JOURNAL state advance?"; `tool_stream` asks "did the TOOL-RESULT
29
+ stream advance, or did the env return the same bytes again?" Same temporal-distrust shape,
30
+ different stream — the `journal_delta`-vs-`git_delta` "different input, separate leaf" split
31
+ the kernel already uses (and the `recurring_wedge`-vs-`wedge_reason` precedent).
32
+
33
+ Why it is byte-clean (the §5a survival argument)
34
+ ================================================
35
+
36
+ The crux: does this survive the **mirror-verifier trap** (docs/141, docs/143 §5a) where the
37
+ obvious "is the agent making PROGRESS?" version does not? Walk the provenance of a `StreamStep`:
38
+
39
+ * `tool_name` / `args_digest` — the **agent** authored these (it chose the call).
40
+ * `result_digest` — the **gym MCP server** authored these (it produced the result).
41
+
42
+ The reader's only question is **"is this env-authored `result_digest` byte-identical to one the
43
+ env already returned, N times in a row?"** — *provenance-of-the-identity-of-repeated-output*, a
44
+ pure byte question about **env-authored** bytes. The agent did **not** author the *identity* of
45
+ its own repeated tool results; the gym did. So the signal cannot be forged in the agent's favor.
46
+ The dangerous version — "is the agent making real progress / has it done the right thing yet?" —
47
+ is a **satisfaction predicate** the wrapper would author from agent-visible prose (forgeable);
48
+ this reader **never asks it.** "The same bytes came back N times" is a measured fact about the
49
+ env's outputs, not a judgment of the agent. No answer key, no held-out state, no oracle plan —
50
+ exactly `arg_provenance`'s provenance-of-a-string honesty, re-aimed from "is this id minted?" to
51
+ "did this exact output repeat?".
52
+
53
+ The honest hole, named (not buried)
54
+ ===================================
55
+
56
+ **Eventual-consistency polling is a legitimate reason to re-read with the same result.** A task
57
+ that correctly waits for an async write to land produces identical reads until it lands — a true
58
+ REPEATING that is *not* a stall. This is why the intervention a consumer attaches to REPEATING
59
+ must be a **WARN that re-surfaces the value, never a cut**: re-presenting bytes the agent already
60
+ holds is harmless if the agent was right to wait (it ignores a value it does not yet need) and
61
+ helpful if it was stuck (it gets the value it kept failing to use). The `ignore_tools` allow-list
62
+ lets a host exempt known pollers from the reader entirely. The verdict itself only REPORTS; the
63
+ turn-preserving discipline lives in the consumer (the docs/99 advisory line, the docs/144 −9 pp
64
+ intervention-cost lesson made structural).
65
+
66
+ ⚓ Pure kernel, I/O on the edge (the dos idiom — mirrors `liveness.classify`,
67
+ `arg_provenance.classify_call`, `churn.decide_coalesce`): `classify_stream(ToolStream, policy)
68
+ -> StreamVerdict` is a frozen tuple of digests in, a frozen verdict out. The CALLER computes the
69
+ `args_digest` / `result_digest` at the boundary (it hashes the normalized args + the result
70
+ bytes the gym returned); the kernel **hashes nothing live**, reads no clock, no disk. That is
71
+ what lets the whole verdict be replay-tested on frozen fixtures with zero benchmark/LLM/MCP
72
+ access — the keystone the audit calls "testable with zero benchmark access."
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import enum
78
+ from dataclasses import dataclass
79
+ from pathlib import Path
80
+ from typing import Optional
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # The typed stall verdict — three states, mutually exclusive (the Liveness shape).
85
+ # ---------------------------------------------------------------------------
86
+ class StreamState(str, enum.Enum):
87
+ """The typed stall verdict over a tool-result stream — three states.
88
+
89
+ `str`-valued so it round-trips through a CLI token / JSON / exit-code map without a
90
+ lookup table (mirrors `liveness.Liveness`, `arg_provenance.ProvenanceStance`).
91
+
92
+ ADVANCING — the recent window is producing NEW env-authored bytes: the most recent
93
+ `(tool, args, result_digest)` triple is not a long consecutive repeat.
94
+ The loop is moving; no intervention. Also the floor when the stream is too
95
+ short to judge a repeat (fewer than `repeat_n` steps) — too young to accuse.
96
+ REPEATING — the SAME `(tool, args, result_digest)` triple has recurred `repeat_n`
97
+ consecutive times (but fewer than `stall_n`): the agent is re-issuing a
98
+ call the env answers identically — no new information is entering the loop.
99
+ The actionable rung — a consumer re-surfaces the repeated env value (WARN).
100
+ STALLED — the repeat run has reached `stall_n` consecutive identical triples: the
101
+ loop is almost certainly doomed to time out. The hard rung — a host MAY opt
102
+ a turn-preserving BLOCK to it (re-surface + reclaim the iteration); the
103
+ default still only re-surfaces (a STALLED on a legitimately-polling task is
104
+ a read the agent needed).
105
+ """
106
+
107
+ ADVANCING = "ADVANCING" # the tool stream is producing new env-authored bytes
108
+ REPEATING = "REPEATING" # the same (tool, args, result) triple is recurring (no new info)
109
+ STALLED = "STALLED" # the repeat run is long enough to be near-certainly doomed
110
+
111
+ def __str__(self) -> str: # pragma: no cover - trivial
112
+ return self.value
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # The thresholds — policy, not mechanism (the LivenessPolicy seam).
117
+ # ---------------------------------------------------------------------------
118
+ @dataclass(frozen=True)
119
+ class StreamPolicy:
120
+ """The run-length windows that separate ADVANCING/REPEATING/STALLED — policy, not mechanism.
121
+
122
+ The same "mechanism is kernel, thresholds are config" split as `LivenessPolicy`'s
123
+ `grace_ms`/`spin_ms`. Defaults are GENERIC (no host tuning); a workspace declares its own
124
+ in `dos.toml [tool_stream]` read back through `SubstrateConfig` (the closed-config-as-data
125
+ pattern, like `[liveness]` / `[reasons]` / `[intervention]`).
126
+
127
+ repeat_n — the consecutive-identical-triple run-length at which the loop is REPEATING.
128
+ Default 3: the first two identical results may be a benign re-check; the
129
+ THIRD identical result is where a no-progress loop is established. Mirrors
130
+ `churn.DEFAULT_MIN_COALESCE_RUN`'s "the FIRST stands alone, the cycle starts
131
+ at the next" reasoning, one rung later (a tool re-read is cheaper to repeat
132
+ once than a no-op commit, so the floor is 3 not 2).
133
+ stall_n — the run-length at which REPEATING hardens to STALLED (the BLOCK-eligible
134
+ rung). Default 5. Must be ≥ repeat_n (validated) — STALLED is strictly more
135
+ repetition than REPEATING.
136
+ ignore_tools — a host's allow-list of known-poller tool names (normalized lower). A step
137
+ whose tool is on this list is NEVER counted toward a repeat run — it breaks
138
+ the run as if it were new (eventual-consistency pollers a host KNOWS about
139
+ are exempted at the source, so the reader never false-fires on them).
140
+ """
141
+
142
+ repeat_n: int = 3
143
+ stall_n: int = 5
144
+ ignore_tools: frozenset = frozenset()
145
+
146
+ def __post_init__(self) -> None:
147
+ if self.repeat_n < 1:
148
+ raise ValueError("repeat_n must be >= 1")
149
+ if self.stall_n < self.repeat_n:
150
+ raise ValueError(
151
+ f"stall_n ({self.stall_n}) must be >= repeat_n ({self.repeat_n}) — "
152
+ f"STALLED is strictly more repetition than REPEATING"
153
+ )
154
+
155
+
156
+ DEFAULT_POLICY = StreamPolicy()
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Frozen inputs — the pure datum a caller gathers at the boundary and hands in.
161
+ # ---------------------------------------------------------------------------
162
+ @dataclass(frozen=True)
163
+ class StreamStep:
164
+ """One step of the tool-result stream, as the pure datum the verdict sees.
165
+
166
+ The caller computes the digests at the boundary (it normalizes the args and hashes them;
167
+ it hashes the result BYTES the gym returned). The kernel never hashes a live value — it
168
+ compares pre-computed digests, the `liveness` "clock is injected, never read inside"
169
+ discipline applied to hashing.
170
+
171
+ tool_name — the tool that was called (normalized lower at the boundary is fine; the
172
+ verdict casefolds for the `ignore_tools` test). Agent-authored.
173
+ args_digest — a stable digest of the call's NORMALIZED arguments (sorted keys, canonical
174
+ scalar repr). Agent-authored. Part of the repeat-identity key so two
175
+ reads of DIFFERENT rows are not a repeat.
176
+ result_digest — a stable digest of the RESULT bytes the env returned. ENV-AUTHORED — the
177
+ load-bearing field: two steps repeat only if the env returned the SAME
178
+ bytes, which the agent cannot forge. `None` marks a step with no result
179
+ (a call that errored / returned nothing) — it never matches another step,
180
+ so it breaks a run rather than extending it (the fail-safe: no result is
181
+ not "the same result").
182
+ """
183
+
184
+ tool_name: str
185
+ args_digest: str
186
+ result_digest: Optional[str] = None
187
+
188
+ def _key(self, policy: "StreamPolicy") -> "Optional[tuple[str, str, str]]":
189
+ """The repeat-identity key, or None if this step can never match another.
190
+
191
+ None when: the result is absent (no result is not 'the same result'), OR the tool is on
192
+ the `ignore_tools` allow-list (a known poller is exempt at the source). A None-keyed
193
+ step breaks any in-progress repeat run — the fail-safe direction (when in doubt, the
194
+ loop is NOT stalled)."""
195
+ if self.result_digest is None:
196
+ return None
197
+ if self.tool_name.casefold() in {t.casefold() for t in policy.ignore_tools}:
198
+ return None
199
+ return (self.tool_name.casefold(), self.args_digest, self.result_digest)
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ToolStream:
204
+ """The whole tool-result stream accumulated so far — the `ProgressEvidence` analogue.
205
+
206
+ `steps` is a tuple of `StreamStep` in call order. Empty (`()`) or short (< `repeat_n`) reads
207
+ as ADVANCING — too little has happened to prove a no-progress loop (the load-bearing
208
+ too-young-to-judge floor, the `liveness` young-and-alive guard's sibling). The stream is
209
+ kept WHOLE (not pre-reduced) so the verdict measures the run-length ending at the LATEST
210
+ step — the live "is it stuck right now?" question, not a whole-history histogram.
211
+ """
212
+
213
+ steps: tuple[StreamStep, ...] = ()
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Frozen verdict — the folded answer, advisory only (the LivenessVerdict shape).
218
+ # ---------------------------------------------------------------------------
219
+ @dataclass(frozen=True)
220
+ class StreamVerdict:
221
+ """The single verdict `classify_stream` returns — typed state + the legible run detail.
222
+
223
+ `state` is the typed `StreamState`. `repeat_run` is the length of the consecutive-identical
224
+ run ending at the latest step (1 = the latest step is unique; ≥ `repeat_n` = REPEATING).
225
+ `repeated_step` is the `StreamStep` that is repeating (None when ADVANCING) — the consumer
226
+ re-surfaces ITS env-authored `result_digest`/value, so the WARN names the exact prior output
227
+ the agent already holds (never a fabricated one). `reason` is the one-line operator summary.
228
+ Advisory: never raises, never dispatches — the consumer reads `state` and decides whether to
229
+ re-surface.
230
+ """
231
+
232
+ state: StreamState
233
+ repeat_run: int
234
+ repeated_step: Optional[StreamStep]
235
+ reason: str
236
+
237
+ def to_dict(self) -> dict:
238
+ rs = self.repeated_step
239
+ return {
240
+ "state": self.state.value,
241
+ "repeat_run": self.repeat_run,
242
+ "repeated_step": (
243
+ {
244
+ "tool_name": rs.tool_name,
245
+ "args_digest": rs.args_digest,
246
+ "result_digest": rs.result_digest,
247
+ }
248
+ if rs is not None
249
+ else None
250
+ ),
251
+ "reason": self.reason,
252
+ }
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # The pure verdict — consecutive-identical run-length, lifted from churn.
257
+ # ---------------------------------------------------------------------------
258
+ def _trailing_run(stream: ToolStream, policy: StreamPolicy) -> "tuple[int, Optional[StreamStep]]":
259
+ """The length of the consecutive-identical-key run ENDING at the latest step, and the step
260
+ that repeats. PURE — the `churn.decide_coalesce` consecutive-same-cause run-length, lifted
261
+ off git history onto the tool stream.
262
+
263
+ Walks backward from the last step while the repeat-identity key matches. A step whose key is
264
+ None (no result, or an `ignore_tools` poller) cannot extend or start a run — the fail-safe:
265
+ an absent/exempt result is never 'the same result', so it can only BREAK a run. Returns
266
+ (run_length, repeated_step) — run_length 0 with an empty stream, 1 when the latest step does
267
+ not match the one before it (or its key is None)."""
268
+ steps = stream.steps
269
+ if not steps:
270
+ return 0, None
271
+ last = steps[-1]
272
+ last_key = last._key(policy)
273
+ if last_key is None:
274
+ # The latest step has no comparable result (absent / exempt poller) — not a repeat.
275
+ return 1, None
276
+ run = 1
277
+ for prev in reversed(steps[:-1]):
278
+ if prev._key(policy) == last_key:
279
+ run += 1
280
+ else:
281
+ break
282
+ return run, (last if run >= 2 else None)
283
+
284
+
285
+ def classify_stream(
286
+ stream: ToolStream, policy: StreamPolicy = DEFAULT_POLICY
287
+ ) -> StreamVerdict:
288
+ """Classify the tool-result stream's stall state from the accumulated steps. PURE — no I/O.
289
+
290
+ Reads the ladder top to bottom (this function IS the answer to "is the loop stuck?"):
291
+
292
+ 1. ADVANCING — the trailing identical-run is shorter than `repeat_n` (incl. an empty or
293
+ too-short stream): the loop is producing new env-authored bytes, or has not repeated
294
+ long enough to accuse. The benign / no-action verdict (the `liveness` young-and-alive
295
+ floor's sibling).
296
+ 2. REPEATING — the trailing run has reached `repeat_n` but is shorter than `stall_n`: the
297
+ same `(tool, args, result)` triple recurred — no new information is entering the loop.
298
+ The actionable rung; the consumer re-surfaces `repeated_step`'s env value (WARN).
299
+ 3. STALLED — the trailing run has reached `stall_n`: long enough to be near-certainly
300
+ doomed. The hard rung (a host MAY opt a turn-preserving BLOCK).
301
+
302
+ The ADVANCING/REPEATING boundary is the `repeat_n` run-length; the REPEATING/STALLED boundary
303
+ is `stall_n`. Both are pure counts over env-authored `result_digest` identity — never a
304
+ judgment of whether the agent is *succeeding* (that would be the §5a satisfaction predicate
305
+ this module exists to avoid). The verdict only REPORTS; the turn-preserving re-surface (never
306
+ a cut) is the consumer's, the docs/99 advisory line.
307
+ """
308
+ run, repeated = _trailing_run(stream, policy)
309
+
310
+ if run >= policy.stall_n:
311
+ return StreamVerdict(
312
+ state=StreamState.STALLED,
313
+ repeat_run=run,
314
+ repeated_step=repeated,
315
+ reason=(
316
+ f"the same (tool, args, result) triple repeated {run} consecutive times "
317
+ f"(>= stall {policy.stall_n}) — the loop is near-certainly doomed; the env "
318
+ f"returned identical bytes each time (no new information)"
319
+ ),
320
+ )
321
+ if run >= policy.repeat_n:
322
+ return StreamVerdict(
323
+ state=StreamState.REPEATING,
324
+ repeat_run=run,
325
+ repeated_step=repeated,
326
+ reason=(
327
+ f"the same (tool, args, result) triple repeated {run} consecutive times "
328
+ f"(>= repeat {policy.repeat_n}) — no new env-authored bytes are entering the "
329
+ f"loop; re-surface the value the agent already received"
330
+ ),
331
+ )
332
+ return StreamVerdict(
333
+ state=StreamState.ADVANCING,
334
+ repeat_run=run,
335
+ repeated_step=None,
336
+ reason=(
337
+ f"trailing identical-run {run} (< repeat {policy.repeat_n}) — the tool stream is "
338
+ f"producing new env-authored bytes (or too short to judge a stall)"
339
+ ),
340
+ )
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # The declarative on-ramp — read a policy out of dos.toml (mirror reasons/intervention).
345
+ # ---------------------------------------------------------------------------
346
+ def policy_from_table(table: dict) -> StreamPolicy:
347
+ """Turn a parsed `[tool_stream]` TOML table into a `StreamPolicy`. PURE (no I/O).
348
+
349
+ `table` is `{repeat_n?, stall_n?, ignore_tools?}` — the shape
350
+ `tomllib.load(...)["tool_stream"]` yields. Missing keys fall back to the generic defaults;
351
+ a malformed value raises (via `StreamPolicy.__post_init__`), so a bad declaration fails
352
+ loudly at load. `ignore_tools` accepts a list or a single string.
353
+ """
354
+ if not table:
355
+ return DEFAULT_POLICY
356
+ ig = table.get("ignore_tools", ())
357
+ if isinstance(ig, str):
358
+ ig = (ig,)
359
+ return StreamPolicy(
360
+ repeat_n=int(table.get("repeat_n", DEFAULT_POLICY.repeat_n)),
361
+ stall_n=int(table.get("stall_n", DEFAULT_POLICY.stall_n)),
362
+ ignore_tools=frozenset(str(t) for t in ig),
363
+ )
364
+
365
+
366
+ def load_from_toml(
367
+ path: "Path | str", *, base: StreamPolicy = DEFAULT_POLICY
368
+ ) -> StreamPolicy:
369
+ """Build a `StreamPolicy` from a `dos.toml`'s `[tool_stream]` table.
370
+
371
+ Returns `base` unchanged when the file is absent, has no `[tool_stream]` table, or
372
+ `tomllib` is unavailable — the declarative path is purely additive, so a missing/empty
373
+ config degrades to the generic default, never an error. A *present but malformed* table
374
+ raises (`StreamPolicy.__post_init__`). Reads with `utf-8-sig` to strip a PowerShell-written
375
+ BOM (the `reasons.load_from_toml` / `intervention.load_from_toml` fix). The OVERRIDE shape
376
+ (the `[stamp]`/`[liveness]` pattern): a present table replaces the windows wholesale; an
377
+ absent one inherits `base`.
378
+ """
379
+ p = Path(path)
380
+ if not p.exists():
381
+ return base
382
+ try:
383
+ import tomllib # py3.11+
384
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
385
+ try:
386
+ import tomli as tomllib # type: ignore
387
+ except ModuleNotFoundError:
388
+ return base
389
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
390
+ table = data.get("tool_stream")
391
+ if not isinstance(table, dict) or not table:
392
+ return base
393
+ return policy_from_table(table)
@@ -0,0 +1,226 @@
1
+ """The stall-reader evaluation harness — score a `StreamPolicy` by its NET RECOVERY.
2
+
3
+ docs/145 §9 — the per-axis eval, the `judge_eval` / `overlap_eval` / `intervention_eval`
4
+ discipline re-aimed at the loop-economics axis. Every DOS axis ships an eval that turns its
5
+ thresholds from a hunch into a measured, per-deployment decision (the research-friendliness
6
+ thesis, docs/90 §2). The stall reader's two thresholds (`repeat_n` / `stall_n`) and its
7
+ `ignore_tools` allow-list need exactly that instrument: a backtest that answers **"on this
8
+ deployment's real tool streams, does firing a re-surface on REPEATING recover stuck tasks
9
+ more often than it false-fires on a legitimate poller?"**
10
+
11
+ The decisive numbers (the dual of `intervention_eval`'s wasted-disruption rate):
12
+
13
+ * **recovered_rate** — of the streams that were ACTUALLY stuck (the agent had the value and
14
+ was looping), the fraction this policy fired REPEATING/STALLED on. Recall-of-action: a
15
+ too-timid policy (`repeat_n` set huge) scores ~0 — it never fires, never recovers.
16
+ * **false_resurface_rate** — of the streams that were LEGITIMATELY repeating (eventual-
17
+ consistency polling, a correct re-check), the fraction this policy ALSO fired on. The
18
+ dangerous cell — the §3 honest hole made measurable. A re-surface on a legitimate poller is
19
+ *harmless by design* (re-presenting bytes the agent holds), but a high rate means the
20
+ threshold is too eager and the host should raise `repeat_n` or grow `ignore_tools`.
21
+
22
+ The honesty stance (the same as judge_eval / overlap_eval / intervention_eval)
23
+ ==============================================================================
24
+
25
+ The labels are the RESEARCHER's ground truth, never the reader's:
26
+
27
+ * `actually_stuck` — was the agent ACTUALLY looping with a value it already had, and would a
28
+ re-surface have unstuck it? Known from the replay (a labelled stuck stream), NOT from the
29
+ `result_digest` repetition the reader keys on. The `overlap_eval.collided` discipline.
30
+ * `legit_polling` — was the repetition a LEGITIMATE re-check (eventual-consistency wait, an
31
+ idempotent retry that later succeeds)? The false-fire denominator.
32
+ * `recovered_if_fired` — counterfactual ground truth from the EXECUTED replay: under a
33
+ re-surface WARN, did the agent then finish? (NOT a guessed label — measured, the
34
+ `intervention_eval.recovered_if_blocked` discipline.)
35
+
36
+ Everything is **pure**: it consumes already-built `StreamCase`s, runs each through the SAME
37
+ `tool_stream.classify_stream` the consumer's reader takes (so the grid reflects what would
38
+ actually fire — the `overlap_eval` "score under the floor" discipline), and counts in one
39
+ pass. No I/O, no host names — it sits in the kernel layer beside `tool_stream`.
40
+
41
+ ⚠ This is NOT a detector eval of `arg_provenance`, and NOT `intervention_eval`. It measures the
42
+ STALL READER specifically — does firing on a repeat recover more than it wastes — an axis
43
+ orthogonal to both the mint detector (docs/143) and the actuation ladder (docs/144).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from dataclasses import dataclass
49
+ from typing import Iterable
50
+
51
+ from dos.tool_stream import (
52
+ StreamPolicy,
53
+ StreamState,
54
+ ToolStream,
55
+ classify_stream,
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # A labelled example — one replayed tool stream + the GROUND-TRUTH outcome.
61
+ # ---------------------------------------------------------------------------
62
+ @dataclass(frozen=True)
63
+ class StreamCase:
64
+ """One replayed tool stream + the ground-truth labels for what firing on it would do.
65
+
66
+ The `state` the policy assigns is NOT stored — it is DERIVED in `score()` via
67
+ `classify_stream` from the embedded `stream`, so the scored action can never drift from a
68
+ hand-labelled state (the label-drift trap, the `intervention_eval` discipline). Every other
69
+ field is a researcher ground-truth label from a replay, NOT a guess.
70
+
71
+ Fields:
72
+ stream — the real `ToolStream` accumulated for this episode. The policy is
73
+ scored against THIS via `classify_stream` (same path as the consumer's
74
+ reader), so the grid measures what would actually fire.
75
+ actually_stuck — ground truth: was the agent looping with a value it already had (a
76
+ re-surface would help)? The recovered_rate numerator's truth.
77
+ legit_polling — ground truth: was the repetition a LEGITIMATE re-check (eventual-
78
+ consistency wait / idempotent retry that later succeeds)? The
79
+ false-resurface denominator. (A stream can be neither — a normal
80
+ advancing run — in which case the reader should not fire at all.)
81
+ recovered_if_fired — counterfactual ground truth: under a re-surface WARN, did the agent
82
+ then finish the task? (Measured in replay, not guessed.)
83
+ label — optional human handle (carried, never scored).
84
+ """
85
+
86
+ stream: ToolStream
87
+ actually_stuck: bool
88
+ legit_polling: bool
89
+ recovered_if_fired: bool
90
+ label: str = ""
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # The report — frozen, @property rates with div-guard, to_dict (mirror intervention_eval).
95
+ # ---------------------------------------------------------------------------
96
+ @dataclass(frozen=True)
97
+ class StreamEvalReport:
98
+ """A `StreamPolicy` scored over labelled cases — the recovery ledger + the false-fire rate.
99
+
100
+ The grid splits the ground-truth crosstab (independent of the policy) from the firing ledger
101
+ (what the policy actually flagged). The named dangerous cell is `fired_on_polling` — a fire
102
+ on a legitimately-polling stream (harmless by design, but the signal the threshold is too
103
+ eager).
104
+ """
105
+
106
+ n: int
107
+ # ground-truth grid (policy-independent):
108
+ n_stuck: int # actually_stuck (the recoverable population)
109
+ n_polling: int # legit_polling (the false-fire denominator)
110
+ # firing ledger (what the policy did):
111
+ n_fired: int # REPEATING or STALLED assigned
112
+ n_fired_stuck: int # fired AND actually_stuck (a useful fire)
113
+ n_fired_polling: int # fired AND legit_polling (the dangerous cell)
114
+ n_recovered: int # fired_stuck whose recovered_if_fired is True (the payoff)
115
+
116
+ # --- derived rates (all guard against divide-by-zero) ---
117
+
118
+ @property
119
+ def recovered_rate(self) -> float:
120
+ """Of all ACTUALLY-stuck streams, the fraction the policy fired on AND that then
121
+ recovered — the HEADLINE payoff (the loop-economics analogue of the +Npp the audit
122
+ chases). A timid policy that never fires scores ~0 here."""
123
+ return (self.n_recovered / self.n_stuck) if self.n_stuck else 0.0
124
+
125
+ @property
126
+ def fire_recall(self) -> float:
127
+ """Of all ACTUALLY-stuck streams, the fraction the policy fired on (regardless of whether
128
+ the agent then recovered) — recall-of-action, the counterweight to the false-fire rate."""
129
+ return (self.n_fired_stuck / self.n_stuck) if self.n_stuck else 0.0
130
+
131
+ @property
132
+ def false_resurface_rate(self) -> float:
133
+ """Of all LEGITIMATELY-polling streams, the fraction the policy ALSO fired on — THE
134
+ DANGEROUS-CELL RATE (the `intervention_eval.wasted_disruption_rate` /
135
+ `overlap_eval.false_admit_rate` analogue). A re-surface here is harmless by design, but a
136
+ high rate says the threshold is too eager — raise `repeat_n` or grow `ignore_tools`."""
137
+ return (self.n_fired_polling / self.n_polling) if self.n_polling else 0.0
138
+
139
+ @property
140
+ def fire_precision(self) -> float:
141
+ """Of all the streams the policy fired on, the fraction that were actually stuck — how
142
+ much of the firing was well-aimed (vs spent on a poller or an advancing run)."""
143
+ return (self.n_fired_stuck / self.n_fired) if self.n_fired else 0.0
144
+
145
+ @property
146
+ def net_positive(self) -> bool:
147
+ """True iff the policy recovers more stuck streams than it false-fires on pollers — the
148
+ boolean a `dos tool-stream-eval` exit code could ride (the `intervention_eval.net_harmful`
149
+ analogue, inverted to the friendly direction). Recovery is a real task win; a false
150
+ re-surface is harmless-but-noise, so net-positive is `recovered > fired_on_polling`."""
151
+ return self.n_recovered > self.n_fired_polling
152
+
153
+ def to_dict(self) -> dict:
154
+ return {
155
+ "n": self.n,
156
+ "grid": {
157
+ "stuck": self.n_stuck,
158
+ "polling": self.n_polling,
159
+ },
160
+ "firing": {
161
+ "fired": self.n_fired,
162
+ "fired_stuck": self.n_fired_stuck,
163
+ "fired_polling": self.n_fired_polling,
164
+ "recovered": self.n_recovered,
165
+ },
166
+ "rates": {
167
+ "recovered_rate": round(self.recovered_rate, 4),
168
+ "fire_recall": round(self.fire_recall, 4),
169
+ "false_resurface_rate": round(self.false_resurface_rate, 4),
170
+ "fire_precision": round(self.fire_precision, 4),
171
+ },
172
+ "net_positive": self.net_positive,
173
+ }
174
+
175
+
176
+ def score(
177
+ policy: StreamPolicy,
178
+ cases: Iterable[StreamCase],
179
+ *,
180
+ _classify=classify_stream,
181
+ ) -> StreamEvalReport:
182
+ """Run `policy` over labelled `cases` (via `classify_stream`) and tabulate the ledger.
183
+
184
+ The policy is scored through the SAME `classify_stream` path the consumer's reader uses (the
185
+ `overlap_eval._admits` / `intervention_eval` "score under the floor" discipline), so the grid
186
+ reflects exactly what would FIRE. A stream FIRES iff its state is REPEATING or STALLED (both
187
+ actionable — the `ladder.actuates()` data-driven-not-hardcoded discipline, here a simple
188
+ not-ADVANCING test since the two firing states are the closed set). PURE: reads cases, counts
189
+ in one pass.
190
+
191
+ Invariant (pinned by a test): a stream is counted in `n_fired_stuck` / `n_fired_polling` only
192
+ if it both fired AND carried the matching ground-truth label, so the firing ledger never
193
+ exceeds `n_fired`, and `n_recovered <= n_fired_stuck`.
194
+ """
195
+ n = 0
196
+ n_stuck = n_polling = 0
197
+ n_fired = n_fired_stuck = n_fired_polling = n_recovered = 0
198
+
199
+ for case in cases:
200
+ n += 1
201
+ verdict = _classify(case.stream, policy)
202
+ fired = verdict.state is not StreamState.ADVANCING
203
+
204
+ if case.actually_stuck:
205
+ n_stuck += 1
206
+ if case.legit_polling:
207
+ n_polling += 1
208
+
209
+ if fired:
210
+ n_fired += 1
211
+ if case.actually_stuck:
212
+ n_fired_stuck += 1
213
+ if case.recovered_if_fired:
214
+ n_recovered += 1
215
+ if case.legit_polling:
216
+ n_fired_polling += 1
217
+
218
+ return StreamEvalReport(
219
+ n=n,
220
+ n_stuck=n_stuck,
221
+ n_polling=n_polling,
222
+ n_fired=n_fired,
223
+ n_fired_stuck=n_fired_stuck,
224
+ n_fired_polling=n_fired_polling,
225
+ n_recovered=n_recovered,
226
+ )