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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/verdict_cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Generic registry → CLI wiring for verdict verbs (docs/86 §2, the modular seam).
|
|
2
|
+
|
|
3
|
+
The point: a consumer (`cli.py`) hard-wires `dos verify`/`dos liveness`/… today —
|
|
4
|
+
one `cmd_X` + `add_parser` + `set_defaults` per verb. That is the coupling the
|
|
5
|
+
registry exists to dissolve. This module is the **generic dispatcher**: it reads
|
|
6
|
+
`verdicts.all_specs()` and builds a subparser + a uniform run-handler for every
|
|
7
|
+
verb that carries a CLI adapter, so:
|
|
8
|
+
|
|
9
|
+
adding a verb = one register(...) (+ its CLI adapter) — and `cli.py` is
|
|
10
|
+
edited ONCE (a single `verdict_cli.attach(sub, ...)` call), NEVER per verb.
|
|
11
|
+
|
|
12
|
+
That single-line hook into `cli.py` is the only edit the real CLI needs; it is
|
|
13
|
+
deliberately deferred until the (currently hot) `cli.py` settles, so this module
|
|
14
|
+
is built and tested STANDALONE first. Everything here works against a plain
|
|
15
|
+
`argparse` parser with no dependency on `cli.py`'s internals — the consumer
|
|
16
|
+
injects how it resolves a `SubstrateConfig` via `config_resolver`, so this module
|
|
17
|
+
stays decoupled from `cli.py`'s workspace plumbing.
|
|
18
|
+
|
|
19
|
+
Layering: this is a CONSUMER, like `cli.py` / `dos_mcp` — it imports the registry
|
|
20
|
+
and the verbs; nothing under `src/dos/*.py` that is a verb imports it. The
|
|
21
|
+
verb-agnostic CORE of each spec (name/classify/summary) lives in `verdicts.py`
|
|
22
|
+
(no I/O); the CLI ADAPTER (argument flags, the git-diff gather, exit codes) is a
|
|
23
|
+
consumer concern and lives HERE — the mechanism-vs-consumer split the kernel
|
|
24
|
+
draws everywhere. `_ensure_cli_specs()` enriches the registry's core specs with
|
|
25
|
+
their adapters the first time the dispatcher is built, confining the boundary I/O
|
|
26
|
+
(git) to this module.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import subprocess
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Callable
|
|
36
|
+
|
|
37
|
+
from . import verdicts, scope
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_GIT_TIMEOUT_S = 15
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Boundary I/O — the git-diff reader (the `git_delta` mold: the subprocess lives
|
|
45
|
+
# in the consumer, the pure classifier gets an already-gathered set).
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
def _git_diff_names(base: str, head: str, *, root: Path | str) -> frozenset[str]:
|
|
48
|
+
"""`git diff --name-only <base>..<head>` over `root`, as a frozenset of paths.
|
|
49
|
+
|
|
50
|
+
Degrades to an empty set on any failure (non-git dir, bad ref, missing git,
|
|
51
|
+
timeout, non-zero exit) — the same fail-safe as `git_delta.commits_since`: a
|
|
52
|
+
scope verdict in a repo with no diff is the honest "empty footprint", never a
|
|
53
|
+
crash.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
raw = subprocess.run(
|
|
57
|
+
["git", "diff", "--name-only", f"{base}..{head}"],
|
|
58
|
+
cwd=str(root), capture_output=True, text=True,
|
|
59
|
+
check=False, timeout=_GIT_TIMEOUT_S,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
62
|
+
return frozenset()
|
|
63
|
+
if raw.returncode != 0:
|
|
64
|
+
return frozenset()
|
|
65
|
+
return frozenset(ln.strip() for ln in raw.stdout.splitlines() if ln.strip())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# The CLI adapter for `scope` (a consumer concern — args, gather, exit codes).
|
|
70
|
+
# Lives here, not in scope.py (pure) or verdicts.py (no I/O), so the kernel verb
|
|
71
|
+
# stays pure and the registry stays light. A future verb adds its adapter the
|
|
72
|
+
# same way; the dispatcher below is unchanged.
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
def _scope_add_args(p: argparse.ArgumentParser) -> None:
|
|
75
|
+
p.add_argument("--lane", required=True,
|
|
76
|
+
help="the lane the diff is stamped against (a key in [lanes])")
|
|
77
|
+
p.add_argument("--base", default="HEAD~1",
|
|
78
|
+
help="base ref of the diff range (default HEAD~1)")
|
|
79
|
+
p.add_argument("--head", default="HEAD",
|
|
80
|
+
help="head ref of the diff range (default HEAD)")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _scope_gather(args: argparse.Namespace, cfg: Any) -> scope.ScopeEvidence:
|
|
84
|
+
"""Build `ScopeEvidence` from CLI args + config: the touched-file set from
|
|
85
|
+
`git diff`, the lane tree from `cfg.lanes.trees[lane]` (generic `("**/*",)`
|
|
86
|
+
if the lane is undeclared — the no-plan floor)."""
|
|
87
|
+
root = cfg.paths.root
|
|
88
|
+
touched = _git_diff_names(args.base, args.head, root=root)
|
|
89
|
+
lane_tree = tuple(cfg.lanes.trees.get(args.lane, ("**/*",)))
|
|
90
|
+
return scope.ScopeEvidence(touched_files=touched, lane_tree=lane_tree, lane=args.lane)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Exit codes per scope verdict (distinct from argparse's 2=usage and liveness's
|
|
94
|
+
# 3/4): a clean footprint is 0; a violation is non-zero so a shell `if dos scope`
|
|
95
|
+
# branches on it. SCOPE_CREEP < WRONG_TARGET by severity.
|
|
96
|
+
_SCOPE_EXIT = {"IN_SCOPE": 0, "SCOPE_CREEP": 5, "WRONG_TARGET": 6}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_CLI_SPECS_READY = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_cli_specs() -> None:
|
|
103
|
+
"""Enrich the registry's core specs with their CLI adapters (idempotent).
|
|
104
|
+
|
|
105
|
+
`verdicts.py` seed-registers the verb-agnostic core (name/classify/…, no I/O).
|
|
106
|
+
Here we add the consumer-side adapter — argument flags, the git-diff gather,
|
|
107
|
+
exit codes — by re-registering with `replace=True`. Confines git I/O to this
|
|
108
|
+
module and keeps the registry the single source of truth for *which* verbs
|
|
109
|
+
exist while letting each consumer own *how* it surfaces them.
|
|
110
|
+
"""
|
|
111
|
+
global _CLI_SPECS_READY
|
|
112
|
+
if _CLI_SPECS_READY:
|
|
113
|
+
return
|
|
114
|
+
core = verdicts.get("scope")
|
|
115
|
+
verdicts.register(verdicts.VerdictSpec(
|
|
116
|
+
name=core.name, classify=core.classify, summary=core.summary,
|
|
117
|
+
distrusts=core.distrusts, reviewed=core.reviewed,
|
|
118
|
+
add_arguments=_scope_add_args,
|
|
119
|
+
gather=_scope_gather,
|
|
120
|
+
exit_codes=dict(_SCOPE_EXIT),
|
|
121
|
+
), replace=True)
|
|
122
|
+
# NOTE: `liveness` already has a bespoke `cmd_liveness` in cli.py (with the
|
|
123
|
+
# journal-rung logic); migrating it onto this dispatcher is a follow-up to do
|
|
124
|
+
# against the real cli.py, not blind here. `verify` waits on ShipVerdict
|
|
125
|
+
# harmonization (it has no typed `.verdict` yet). So `scope` is the first verb
|
|
126
|
+
# surfaced through the generic path — the proof the wiring is real.
|
|
127
|
+
_CLI_SPECS_READY = True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _render(verdict_obj: Any, output: str) -> None:
|
|
131
|
+
if output == "json":
|
|
132
|
+
print(json.dumps(verdict_obj.to_dict(), indent=2))
|
|
133
|
+
else:
|
|
134
|
+
print(f"{verdict_obj.verdict.value}: {verdict_obj.reason}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def attach(
|
|
138
|
+
subparsers: Any,
|
|
139
|
+
*,
|
|
140
|
+
config_resolver: Callable[[argparse.Namespace], Any],
|
|
141
|
+
) -> list[str]:
|
|
142
|
+
"""Build a subcommand for every registered verb that carries a CLI adapter.
|
|
143
|
+
|
|
144
|
+
`subparsers` is the object returned by `parser.add_subparsers()`.
|
|
145
|
+
`config_resolver(args) -> SubstrateConfig` is injected by the consumer (cli.py
|
|
146
|
+
passes its own workspace-resolution), so this module never duplicates cli.py's
|
|
147
|
+
plumbing. Returns the list of verb names it wired (for tests / help). The
|
|
148
|
+
`cli.py` integration is one line: `verdict_cli.attach(sub, config_resolver=...)`.
|
|
149
|
+
"""
|
|
150
|
+
_ensure_cli_specs()
|
|
151
|
+
wired: list[str] = []
|
|
152
|
+
for spec in verdicts.all_specs().values():
|
|
153
|
+
if spec.add_arguments is None or spec.gather is None:
|
|
154
|
+
continue # a library-only verb (no CLI surface) — skip, don't crash
|
|
155
|
+
p = subparsers.add_parser(spec.name, help=spec.summary)
|
|
156
|
+
spec.add_arguments(p)
|
|
157
|
+
p.add_argument("--workspace", default=".",
|
|
158
|
+
help="the workspace whose state the verb reads")
|
|
159
|
+
p.add_argument("--output", choices=["text", "json"], default="text")
|
|
160
|
+
p.set_defaults(_verdict_spec=spec, _config_resolver=config_resolver)
|
|
161
|
+
wired.append(spec.name)
|
|
162
|
+
return wired
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run(args: argparse.Namespace) -> int:
|
|
166
|
+
"""The uniform handler for any verdict verb wired by `attach`.
|
|
167
|
+
|
|
168
|
+
gather (boundary I/O) → classify (pure) → render → exit code. One function for
|
|
169
|
+
ALL verbs — the dispatcher's payoff. Set as the subparser's func by `attach`
|
|
170
|
+
via `_verdict_spec`; a consumer with its own dispatch can call this directly.
|
|
171
|
+
"""
|
|
172
|
+
spec: verdicts.VerdictSpec = args._verdict_spec
|
|
173
|
+
cfg = args._config_resolver(args)
|
|
174
|
+
evidence = spec.gather(args, cfg)
|
|
175
|
+
policy = spec.policy_from(cfg) if spec.policy_from is not None else _default_policy(spec)
|
|
176
|
+
verdict_obj = spec.classify(evidence, policy) if policy is not None else spec.classify(evidence)
|
|
177
|
+
_render(verdict_obj, getattr(args, "output", "text"))
|
|
178
|
+
return spec.exit_codes.get(verdict_obj.verdict.value, 0)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _default_policy(spec: verdicts.VerdictSpec) -> Any:
|
|
182
|
+
"""The verb's own default policy when the spec declares no `policy_from`.
|
|
183
|
+
|
|
184
|
+
Looked up by name so we don't couple to a specific module here. (The
|
|
185
|
+
`dos.toml [<name>]` policy seam is wired per-verb via `policy_from`; until a
|
|
186
|
+
verb declares it, its module default applies — e.g. `scope.DEFAULT_POLICY`.)"""
|
|
187
|
+
if spec.name == "scope":
|
|
188
|
+
return scope.DEFAULT_POLICY
|
|
189
|
+
return None # classify(evidence) with its own default argument
|
dos/verdict_journal.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Verdict-journal — a write-ahead log for the kernel's own adjudications (docs/262).
|
|
2
|
+
|
|
3
|
+
DOS adjudicates a firehose of verdicts every loop — `verify` (SHIPPED /
|
|
4
|
+
NOT_SHIPPED), `liveness` (ADVANCING / SPINNING / STALLED), `productivity`,
|
|
5
|
+
`efficiency`, `breaker`, `hook_exit`, `reward`, every hook decision — and until
|
|
6
|
+
this module **only one of them persisted**: `arbitrate`, and only because the lane
|
|
7
|
+
WAL (`lane_journal`) happened to need the lease set across processes. Every other
|
|
8
|
+
verdict was computed at the CLI boundary, printed, and evaporated. So "every
|
|
9
|
+
liveness verdict this run emitted," "when did efficiency cross into WASTEFUL," and
|
|
10
|
+
"what did this fleet decide last hour" were all unanswerable — the read-only
|
|
11
|
+
projections (`trace`/`decisions`/`top`) could only join the surfaces that
|
|
12
|
+
incidentally persisted.
|
|
13
|
+
|
|
14
|
+
This module is the missing substrate: the **same WAL discipline `lane_journal`
|
|
15
|
+
proves, re-aimed from leases onto verdicts** (the relationship `efficiency` has to
|
|
16
|
+
`productivity`, or `resume` to `liveness`). Every adjudication the kernel makes can
|
|
17
|
+
be appended — and `fsync`'d — to an append-only JSONL file as a structured,
|
|
18
|
+
`run_id`-correlated `VerdictEvent`; `rollup()` folds the log into per-dimension
|
|
19
|
+
verdict counts (pure: entries in, counts out, no disk), and `read_all`/`tail`
|
|
20
|
+
answer history queries. A new `dos observe` projection reads it.
|
|
21
|
+
|
|
22
|
+
Design rules (inherited verbatim from `lane_journal` — the LJ scope boundary):
|
|
23
|
+
|
|
24
|
+
* **Pure where it can be.** `rollup()` / `for_run()` take entries and return data
|
|
25
|
+
— entries in, value out, no disk — so the suite folds them without touching a
|
|
26
|
+
file. Only `record` / `read_all` / `tail` touch disk.
|
|
27
|
+
* **Fail-soft, never fail-loud.** Observability must never take down the thing it
|
|
28
|
+
observes: `record()` that cannot write logs-and-drops (the `notify.send_safely`
|
|
29
|
+
posture), it never raises into the syscall that emitted the verdict. A truth
|
|
30
|
+
syscall is not made less true by a full disk.
|
|
31
|
+
* **Torn-tail tolerant.** A process killed mid-`record` can leave a partial final
|
|
32
|
+
line. `read_all` skips an unparseable *trailing* line (and only the trailing
|
|
33
|
+
one); a non-trailing corrupt line is kept as a `_CORRUPT` sentinel so an audit
|
|
34
|
+
still sees the integrity breach.
|
|
35
|
+
* **Host-local + run-correlated.** Every event stamps the `run_id` spine key (or
|
|
36
|
+
`""` when the emitter had none — surfaced honestly, never guessed by a time
|
|
37
|
+
window). The join to `trace`/`decisions` is the existing `run_id`, nothing
|
|
38
|
+
fabricated (the `trace` non-goal: no second parallel correlation id).
|
|
39
|
+
* **The recorder is not a judge.** This module records verdicts other syscalls
|
|
40
|
+
*already minted* — it adds no precondition, runs no `classify`, mints no belief.
|
|
41
|
+
Delete it and you lose the record, not any adjudication. (The `trace` contract.)
|
|
42
|
+
* **Byte-clean by construction (docs/138).** A `VerdictEvent.detail` carries the
|
|
43
|
+
*environment-authored* evidence counts the verdict was computed from (tokens,
|
|
44
|
+
work units, ages — the same byte-clean inputs `efficiency`/`liveness` trust),
|
|
45
|
+
NEVER the agent's narration. The recorder is handed a typed verdict downstream of
|
|
46
|
+
the classify; it is structurally incapable of recording "the agent says it's
|
|
47
|
+
done."
|
|
48
|
+
|
|
49
|
+
Read::
|
|
50
|
+
|
|
51
|
+
dos observe # fleet-wide rollup over the whole journal
|
|
52
|
+
dos observe --run <run_id> # one run's verdict history
|
|
53
|
+
dos observe --syscall NAME # filter to one dimension
|
|
54
|
+
dos observe --tail N # the last N events, raw
|
|
55
|
+
dos observe --json # machine-readable
|
|
56
|
+
|
|
57
|
+
Write is library-only (a syscall verb / hook sensor calls `record()` as it emits)
|
|
58
|
+
— there is deliberately no `record` CLI subcommand, so nothing can journal a
|
|
59
|
+
verdict the kernel did not actually return.
|
|
60
|
+
"""
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import datetime as dt
|
|
64
|
+
import io
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import sys
|
|
68
|
+
from dataclasses import dataclass, field
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Any, Iterable, Mapping
|
|
71
|
+
|
|
72
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
73
|
+
try:
|
|
74
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
75
|
+
except Exception: # pragma: no cover
|
|
76
|
+
pass
|
|
77
|
+
elif not isinstance(sys.stdout, io.TextIOWrapper): # pragma: no cover
|
|
78
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
from dos import config as _config
|
|
81
|
+
|
|
82
|
+
# The durable-schema family + version for verdict-journal records (docs/207). Every
|
|
83
|
+
# record is tagged from line 1 (unlike the lane journal, whose lease ops predate the
|
|
84
|
+
# tag contract) so a future non-additive shape change migrates cleanly. A new field
|
|
85
|
+
# is additive and never bumps it; the version bumps only on a breaking change.
|
|
86
|
+
SCHEMA_FAMILY = "verdict-journal"
|
|
87
|
+
VERDICT_JOURNAL_SCHEMA = 1
|
|
88
|
+
|
|
89
|
+
# The closed-ish set of syscall dimensions a verdict event names. "Closed-ish"
|
|
90
|
+
# because a host driver may emit its own verdict kind (a JUDGE rung verdict, a
|
|
91
|
+
# custom sensor) and the recorder must not refuse it — the set below is the kernel's
|
|
92
|
+
# OWN verdict-emitting syscalls, used for the `--syscall` filter's known-values hint
|
|
93
|
+
# and the rollup's stable ordering, NOT a validation gate. An unknown syscall is
|
|
94
|
+
# recorded as-is (the tolerant-floor posture: never drop a real event).
|
|
95
|
+
SYSCALL_VERIFY = "verify"
|
|
96
|
+
SYSCALL_LIVENESS = "liveness"
|
|
97
|
+
SYSCALL_PRODUCTIVITY = "productivity"
|
|
98
|
+
SYSCALL_EFFICIENCY = "efficiency"
|
|
99
|
+
SYSCALL_ARBITRATE = "arbitrate"
|
|
100
|
+
SYSCALL_REWARD = "reward"
|
|
101
|
+
SYSCALL_BREAKER = "breaker"
|
|
102
|
+
SYSCALL_HOOK_EXIT = "hook_exit"
|
|
103
|
+
SYSCALL_PRETOOL = "pretool"
|
|
104
|
+
SYSCALL_POSTTOOL = "posttool"
|
|
105
|
+
SYSCALL_STOP = "stop"
|
|
106
|
+
|
|
107
|
+
# Stable display/rollup order for the kernel's own dimensions; an unknown syscall
|
|
108
|
+
# sorts after these (alphabetically) so a custom emitter is visible, just last.
|
|
109
|
+
KNOWN_SYSCALLS = (
|
|
110
|
+
SYSCALL_VERIFY,
|
|
111
|
+
SYSCALL_LIVENESS,
|
|
112
|
+
SYSCALL_PRODUCTIVITY,
|
|
113
|
+
SYSCALL_EFFICIENCY,
|
|
114
|
+
SYSCALL_ARBITRATE,
|
|
115
|
+
SYSCALL_REWARD,
|
|
116
|
+
SYSCALL_BREAKER,
|
|
117
|
+
SYSCALL_HOOK_EXIT,
|
|
118
|
+
SYSCALL_PRETOOL,
|
|
119
|
+
SYSCALL_POSTTOOL,
|
|
120
|
+
SYSCALL_STOP,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Who emitted the event — a kernel syscall verb, or a hook sensor. (A driver may
|
|
124
|
+
# pass its own source string; like `syscall`, this is descriptive, not validated.)
|
|
125
|
+
SOURCE_KERNEL = "kernel"
|
|
126
|
+
SOURCE_SENSOR = "sensor"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def journal_now_iso() -> str:
|
|
130
|
+
"""Second-resolution UTC stamp for verdict events.
|
|
131
|
+
|
|
132
|
+
The same stamp grammar the lane journal uses (`lane_journal.journal_now_iso`):
|
|
133
|
+
fine enough to order events within a minute, with the monotonic `seq` as the
|
|
134
|
+
real tiebreak. Human-readable without ambiguity.
|
|
135
|
+
"""
|
|
136
|
+
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# The record + the fold result — frozen value objects with to_dict()/from_dict()
|
|
141
|
+
# so the JSONL round-trips (mirrors lane_journal entries + decisions.Decision).
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class VerdictEvent:
|
|
147
|
+
"""One adjudication the kernel made — a row in the verdict journal.
|
|
148
|
+
|
|
149
|
+
`syscall` is the dimension (`verify`/`liveness`/…); `verdict` is the typed token
|
|
150
|
+
that syscall returned (`SHIPPED`/`STALLED`/`WASTEFUL`…). `run_id` is the
|
|
151
|
+
correlation spine key (or "" when the emitter had none — surfaced honestly, the
|
|
152
|
+
docs/118 fail-toward-no-match rule). `subject` is an optional free identifier
|
|
153
|
+
(a `(plan,phase)`, a command, a step id); `lane` an optional region. `detail` is
|
|
154
|
+
a small dict of the ENVIRONMENT-authored evidence counts the verdict was
|
|
155
|
+
computed from (tokens, work, ages) — never the agent's narration (docs/138).
|
|
156
|
+
`source` names the emitter kind (`kernel`/`sensor`).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
syscall: str
|
|
160
|
+
verdict: str
|
|
161
|
+
run_id: str = ""
|
|
162
|
+
lane: str = ""
|
|
163
|
+
subject: str = ""
|
|
164
|
+
detail: Mapping[str, Any] = field(default_factory=dict)
|
|
165
|
+
source: str = SOURCE_KERNEL
|
|
166
|
+
ts: str = ""
|
|
167
|
+
seq: int = 0
|
|
168
|
+
|
|
169
|
+
def to_record(self) -> dict:
|
|
170
|
+
"""The JSONL record — schema-tagged, every field present.
|
|
171
|
+
|
|
172
|
+
The durable-schema tag rides at the top level (`schema_family`/
|
|
173
|
+
`schema_version`) so a reader can branch on shape without guessing.
|
|
174
|
+
"""
|
|
175
|
+
return {
|
|
176
|
+
"schema_family": SCHEMA_FAMILY,
|
|
177
|
+
"schema_version": VERDICT_JOURNAL_SCHEMA,
|
|
178
|
+
"ts": self.ts or journal_now_iso(),
|
|
179
|
+
"seq": int(self.seq),
|
|
180
|
+
"syscall": self.syscall,
|
|
181
|
+
"verdict": self.verdict,
|
|
182
|
+
"run_id": self.run_id,
|
|
183
|
+
"lane": self.lane,
|
|
184
|
+
"subject": self.subject,
|
|
185
|
+
"detail": dict(self.detail) if isinstance(self.detail, Mapping) else {},
|
|
186
|
+
"source": self.source,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# `to_dict` is an alias kept for symmetry with `decisions.Decision.to_dict` /
|
|
190
|
+
# `trace`'s value objects, so a `--json` renderer can call the same method name
|
|
191
|
+
# across every projection.
|
|
192
|
+
to_dict = to_record
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_record(cls, rec: Mapping[str, Any]) -> "VerdictEvent":
|
|
196
|
+
"""Rebuild a `VerdictEvent` from a parsed JSONL record (tolerant).
|
|
197
|
+
|
|
198
|
+
Missing fields degrade to their defaults — a record written by an older
|
|
199
|
+
kernel (fewer fields) reads cleanly, the additive-schema floor. `detail` is
|
|
200
|
+
coerced to a dict (a malformed non-dict detail becomes {} rather than
|
|
201
|
+
raising — a reader never crashes on one bad row)."""
|
|
202
|
+
detail = rec.get("detail")
|
|
203
|
+
if not isinstance(detail, Mapping):
|
|
204
|
+
detail = {}
|
|
205
|
+
try:
|
|
206
|
+
seq = int(rec.get("seq") or 0)
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
seq = 0
|
|
209
|
+
return cls(
|
|
210
|
+
syscall=str(rec.get("syscall") or ""),
|
|
211
|
+
verdict=str(rec.get("verdict") or ""),
|
|
212
|
+
run_id=str(rec.get("run_id") or ""),
|
|
213
|
+
lane=str(rec.get("lane") or ""),
|
|
214
|
+
subject=str(rec.get("subject") or ""),
|
|
215
|
+
detail=dict(detail),
|
|
216
|
+
source=str(rec.get("source") or SOURCE_KERNEL),
|
|
217
|
+
ts=str(rec.get("ts") or ""),
|
|
218
|
+
seq=seq,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class VerdictRollup:
|
|
224
|
+
"""The pure fold over a set of verdict events — counts per (dimension, token).
|
|
225
|
+
|
|
226
|
+
`by` names the dimension folded on (`syscall` by default; also `verdict`,
|
|
227
|
+
`run_id`, `lane`, `source`). `counts` maps each dimension value to a
|
|
228
|
+
{verdict-token: count} sub-map, so "47 liveness verdicts: 40 ADVANCING, 5
|
|
229
|
+
SPINNING, 2 STALLED" is one lookup. `total` is the event count; `corrupt` the
|
|
230
|
+
number of `_CORRUPT` sentinel lines seen (an integrity tally, surfaced not
|
|
231
|
+
hidden — the lane-journal posture). `dimensions` is the dimension values in
|
|
232
|
+
stable order (known syscalls first, then the rest alphabetically).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
by: str
|
|
236
|
+
counts: Mapping[str, Mapping[str, int]]
|
|
237
|
+
dimensions: tuple[str, ...]
|
|
238
|
+
total: int
|
|
239
|
+
corrupt: int = 0
|
|
240
|
+
|
|
241
|
+
def to_dict(self) -> dict:
|
|
242
|
+
return {
|
|
243
|
+
"by": self.by,
|
|
244
|
+
"total": self.total,
|
|
245
|
+
"corrupt": self.corrupt,
|
|
246
|
+
"dimensions": list(self.dimensions),
|
|
247
|
+
"counts": {k: dict(v) for k, v in self.counts.items()},
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Path resolution — the active workspace's verdict journal, with an env override.
|
|
253
|
+
# Mirrors lane_journal._journal_path exactly.
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
# The workspace-neutral env override (parallel to DISPATCH_LANE_JOURNAL_PATH).
|
|
257
|
+
_ENV_PATH = "DISPATCH_VERDICT_JOURNAL_PATH"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _default_journal_path() -> Path:
|
|
261
|
+
"""The active workspace's verdict journal.
|
|
262
|
+
|
|
263
|
+
Falls back to a `verdict-journal.jsonl` sibling of the lane journal when the
|
|
264
|
+
config's `verdict_journal` field is unset (a `PathLayout` constructed
|
|
265
|
+
positionally without the defaulted field) — so a partially-built layout still
|
|
266
|
+
resolves to a sane sibling path rather than crashing on `None`."""
|
|
267
|
+
paths = _config.active().paths
|
|
268
|
+
vj = getattr(paths, "verdict_journal", None)
|
|
269
|
+
if vj is not None:
|
|
270
|
+
return Path(vj)
|
|
271
|
+
return Path(paths.lane_journal).with_name("verdict-journal.jsonl")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _journal_path(path: Path | None = None) -> Path:
|
|
275
|
+
"""Resolve the journal path: explicit arg › env override › active config.
|
|
276
|
+
|
|
277
|
+
Re-read each call so a test that sets the env var after import still redirects
|
|
278
|
+
(the lane-journal idiom)."""
|
|
279
|
+
if path is not None:
|
|
280
|
+
return Path(path)
|
|
281
|
+
env = os.environ.get(_ENV_PATH)
|
|
282
|
+
if env:
|
|
283
|
+
return Path(env)
|
|
284
|
+
return _default_journal_path()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# Write — append one event, fsync'd, FAIL-SOFT. The only mutating I/O.
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _next_seq(path: Path) -> int:
|
|
293
|
+
"""The seq to stamp on the next event = max existing seq + 1 (1-based).
|
|
294
|
+
|
|
295
|
+
Best-effort: a read failure yields 1 (start fresh) rather than raising — the
|
|
296
|
+
seq is a within-file tiebreak, not a correctness invariant (the `ts` orders
|
|
297
|
+
across files; an O_APPEND write orders within one). Unlike the lane journal,
|
|
298
|
+
verdict events are not serialized under a lease mutex (they are emitted from
|
|
299
|
+
many independent syscalls), so two concurrent writers MAY mint the same seq —
|
|
300
|
+
tolerable, because the seq is cosmetic-ordering, never an identity. `read_all`
|
|
301
|
+
folds by append order and uses `ts`+`seq` only for display sort.
|
|
302
|
+
"""
|
|
303
|
+
mx = 0
|
|
304
|
+
for e in read_all(path):
|
|
305
|
+
try:
|
|
306
|
+
s = int(e.get("seq") or 0)
|
|
307
|
+
except (TypeError, ValueError):
|
|
308
|
+
s = 0
|
|
309
|
+
if s > mx:
|
|
310
|
+
mx = s
|
|
311
|
+
return mx + 1
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def record(event: VerdictEvent, *, path: Path | None = None,
|
|
315
|
+
stamp_seq: bool = True) -> bool:
|
|
316
|
+
"""Append one `VerdictEvent` to the journal as JSONL, `fsync`'d. FAIL-SOFT.
|
|
317
|
+
|
|
318
|
+
Returns True on a successful durable append, False if the write failed (a full
|
|
319
|
+
disk, a permission error, a missing parent that could not be created) — the
|
|
320
|
+
caller's syscall is NEVER interrupted by an observability failure (the
|
|
321
|
+
`notify.send_safely` contract: the thing that observes must not crash the thing
|
|
322
|
+
observed). The dir is created on demand (`mkdir(parents=True)`), like the lease
|
|
323
|
+
writers. When `stamp_seq` (the default), an unset `seq`/`ts` is filled in here so
|
|
324
|
+
a caller can `record(VerdictEvent(syscall=…, verdict=…, run_id=…))` without
|
|
325
|
+
plumbing the clock/counter — the recorder owns the stamp the way `lane_lease`
|
|
326
|
+
owns the journal stamp.
|
|
327
|
+
"""
|
|
328
|
+
try:
|
|
329
|
+
p = _journal_path(path)
|
|
330
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
rec = event.to_record()
|
|
332
|
+
if stamp_seq:
|
|
333
|
+
if not rec.get("ts"):
|
|
334
|
+
rec["ts"] = journal_now_iso()
|
|
335
|
+
if not rec.get("seq"):
|
|
336
|
+
rec["seq"] = _next_seq(p)
|
|
337
|
+
line = json.dumps(rec, ensure_ascii=False, sort_keys=True)
|
|
338
|
+
# O_APPEND keeps concurrent appends from interleaving a single line; fsync
|
|
339
|
+
# makes the record outlive the process that wrote it (the WAL invariant).
|
|
340
|
+
with open(p, "a", encoding="utf-8") as fh:
|
|
341
|
+
fh.write(line + "\n")
|
|
342
|
+
fh.flush()
|
|
343
|
+
try:
|
|
344
|
+
os.fsync(fh.fileno())
|
|
345
|
+
except (OSError, ValueError): # pragma: no cover - platform fsync quirks
|
|
346
|
+
pass
|
|
347
|
+
return True
|
|
348
|
+
except Exception:
|
|
349
|
+
# FAIL-SOFT: observability never takes down the observed. We deliberately
|
|
350
|
+
# swallow EVERY exception here (not a narrow set) — there is no failure mode
|
|
351
|
+
# of a verdict log worth crashing a truth syscall over.
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Read — every event in append order, torn-tail tolerant. Mirrors
|
|
357
|
+
# lane_journal.read_all byte-for-byte in posture.
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def read_all(path: Path | None = None) -> list[dict]:
|
|
362
|
+
"""Return every journal record (raw dict) in append order.
|
|
363
|
+
|
|
364
|
+
Skips an unparseable TRAILING line (a torn final record from a crash
|
|
365
|
+
mid-append — "didn't happen," the safe WAL read) but keeps a non-trailing
|
|
366
|
+
corrupt line as a `{"op": "_CORRUPT", ...}` sentinel so an audit still sees the
|
|
367
|
+
integrity breach (the lane-journal reader, verbatim — same sentinel shape so a
|
|
368
|
+
shared audit can recognize it).
|
|
369
|
+
"""
|
|
370
|
+
p = _journal_path(path)
|
|
371
|
+
if not p.exists():
|
|
372
|
+
return []
|
|
373
|
+
try:
|
|
374
|
+
raw = p.read_text(encoding="utf-8", errors="replace")
|
|
375
|
+
except OSError:
|
|
376
|
+
return []
|
|
377
|
+
lines = raw.splitlines()
|
|
378
|
+
out: list[dict] = []
|
|
379
|
+
for i, line in enumerate(lines):
|
|
380
|
+
s = line.strip()
|
|
381
|
+
if not s:
|
|
382
|
+
continue
|
|
383
|
+
try:
|
|
384
|
+
obj = json.loads(s)
|
|
385
|
+
except json.JSONDecodeError:
|
|
386
|
+
if i == len(lines) - 1:
|
|
387
|
+
break # torn final line — tolerated
|
|
388
|
+
out.append({"op": "_CORRUPT", "_raw": s, "_line": i})
|
|
389
|
+
continue
|
|
390
|
+
if isinstance(obj, dict):
|
|
391
|
+
out.append(obj)
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def tail(n: int = 20, path: Path | None = None) -> list[dict]:
|
|
396
|
+
"""The last `n` records (raw dicts) — reads the whole file then slices.
|
|
397
|
+
|
|
398
|
+
The journal is NOT auto-rotated; on a long-lived fleet it grows unbounded and
|
|
399
|
+
this is O(file), the documented lane-journal posture (docs/262 Phase 4 folds
|
|
400
|
+
the `[retention]` caps over it). `n <= 0` returns all.
|
|
401
|
+
"""
|
|
402
|
+
entries = read_all(path)
|
|
403
|
+
return entries[-n:] if n > 0 else entries
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def read_events(path: Path | None = None) -> list[VerdictEvent]:
|
|
407
|
+
"""`read_all`, decoded into `VerdictEvent`s, dropping `_CORRUPT` sentinels.
|
|
408
|
+
|
|
409
|
+
The typed reader the projections + folds consume. A corrupt sentinel is NOT a
|
|
410
|
+
verdict, so it is excluded here (its existence is still counted by `rollup` via
|
|
411
|
+
a separate pass over `read_all`, so the integrity tally survives)."""
|
|
412
|
+
return [
|
|
413
|
+
VerdictEvent.from_record(rec)
|
|
414
|
+
for rec in read_all(path)
|
|
415
|
+
if rec.get("op") != "_CORRUPT"
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# The pure folds — entries in, data out, no disk. The unit-test surface.
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _dimension_value(ev: VerdictEvent, by: str) -> str:
|
|
425
|
+
"""The value of event `ev` along dimension `by` (defaults to syscall)."""
|
|
426
|
+
if by == "verdict":
|
|
427
|
+
return ev.verdict or "(none)"
|
|
428
|
+
if by == "run_id":
|
|
429
|
+
return ev.run_id or "(unattributed)"
|
|
430
|
+
if by == "lane":
|
|
431
|
+
return ev.lane or "(none)"
|
|
432
|
+
if by == "source":
|
|
433
|
+
return ev.source or "(none)"
|
|
434
|
+
return ev.syscall or "(none)" # "syscall" / default
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _order_dimensions(values: Iterable[str], by: str) -> tuple[str, ...]:
|
|
438
|
+
"""Stable order: known syscalls first (when by==syscall), then the rest sorted.
|
|
439
|
+
|
|
440
|
+
For any other dimension the order is plain sorted (no privileged set). The
|
|
441
|
+
unattributed/none buckets sort to the end of the alphabetic tail naturally
|
|
442
|
+
because of the parenthesis prefix sort — acceptable; they are clearly labelled.
|
|
443
|
+
"""
|
|
444
|
+
vals = set(values)
|
|
445
|
+
if by == "syscall":
|
|
446
|
+
head = [s for s in KNOWN_SYSCALLS if s in vals]
|
|
447
|
+
tail = sorted(v for v in vals if v not in set(KNOWN_SYSCALLS))
|
|
448
|
+
return tuple(head + tail)
|
|
449
|
+
return tuple(sorted(vals))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def rollup(events: Iterable[VerdictEvent], *, by: str = "syscall",
|
|
453
|
+
corrupt: int = 0) -> VerdictRollup:
|
|
454
|
+
"""Fold events into per-`by` verdict counts. PURE — no disk.
|
|
455
|
+
|
|
456
|
+
For each event, increments `counts[dimension_value][verdict_token]`. `by` is one
|
|
457
|
+
of "syscall" (default) / "verdict" / "run_id" / "lane" / "source". `corrupt` is
|
|
458
|
+
the integrity tally the caller carries in from `read_all` (the count of
|
|
459
|
+
`_CORRUPT` sentinels) so the rollup can surface it without re-reading the file.
|
|
460
|
+
|
|
461
|
+
This is the lane-journal `replay` analogue: the pure reduction that turns the
|
|
462
|
+
raw event stream into the answer a projection renders. Unit-tested in isolation
|
|
463
|
+
(no file needed) exactly like `replay`.
|
|
464
|
+
"""
|
|
465
|
+
counts: dict[str, dict[str, int]] = {}
|
|
466
|
+
total = 0
|
|
467
|
+
for ev in events:
|
|
468
|
+
total += 1
|
|
469
|
+
dim = _dimension_value(ev, by)
|
|
470
|
+
token = ev.verdict or "(none)"
|
|
471
|
+
bucket = counts.setdefault(dim, {})
|
|
472
|
+
bucket[token] = bucket.get(token, 0) + 1
|
|
473
|
+
dims = _order_dimensions(counts.keys(), by)
|
|
474
|
+
# Freeze the sub-maps in a stable order too (verdict tokens sorted) so the
|
|
475
|
+
# rendered + JSON output is deterministic.
|
|
476
|
+
frozen = {d: dict(sorted(counts[d].items())) for d in dims}
|
|
477
|
+
return VerdictRollup(by=by, counts=frozen, dimensions=dims,
|
|
478
|
+
total=total, corrupt=corrupt)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def for_run(events: Iterable[VerdictEvent], run_id: str) -> list[VerdictEvent]:
|
|
482
|
+
"""The slice of events attributed to `run_id` — the `trace` join (docs/262 P3).
|
|
483
|
+
|
|
484
|
+
The join key is the existing `run_id` spine, nothing fabricated (the `trace`
|
|
485
|
+
non-goal). Events with no `run_id` are NEVER guessed onto a run by time (the
|
|
486
|
+
docs/118 fail-toward-no-match rule) — a `for_run` over a run that emitted no
|
|
487
|
+
correlated verdict honestly returns []. Preserves append order.
|
|
488
|
+
"""
|
|
489
|
+
return [ev for ev in events if ev.run_id == run_id]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def count_corrupt(raw: Iterable[Mapping[str, Any]]) -> int:
|
|
493
|
+
"""Count the `_CORRUPT` sentinel rows in a `read_all` result (integrity tally).
|
|
494
|
+
|
|
495
|
+
A tiny helper so a projection can do one `read_all`, hand the typed events to
|
|
496
|
+
`rollup`/`for_run` and the raw rows here, without re-reading the file."""
|
|
497
|
+
return sum(1 for r in raw if r.get("op") == "_CORRUPT")
|