tickwork 0.3.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.
tickwork/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """The public tickwork engine: a generic, instance-neutral workflow engine.
2
+
3
+ It drives states, transitions and sweeps over a queue of work items. It is
4
+ developed in-tree but deliberately names nothing of the instance it runs
5
+ for: no persona, host, repository or person names appear here, and the
6
+ package imports only the standard library, PyYAML and other ``tickwork``
7
+ modules — everything
8
+ instance-specific is supplied by the caller.
9
+ """
10
+
11
+ __all__: list[str] = []
tickwork/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m tickwork`` — delegates to the CLI."""
2
+
3
+ import sys
4
+
5
+ from tickwork.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
tickwork/cli.py ADDED
@@ -0,0 +1,288 @@
1
+ """The ``tickwork`` command line: ``check`` (the exactly-one-state check),
2
+ ``replay`` (re-send recorded LLM calls to a model and score the replies) and
3
+ ``effects`` (summarize an effect log).
4
+
5
+ Runs :func:`~tickwork.workflow_states.check` over a states file, a JSONL
6
+ items file and a YAML logins map supplied on the command line, so a
7
+ standalone tickwork installation can run the exactly-one-state check
8
+ without any instance-specific fetcher:
9
+
10
+ .. code-block:: text
11
+
12
+ tickwork check STATES --items ITEMS --logins LOGINS
13
+
14
+ * STATES is loaded with :func:`~tickwork.workflow_states.load`; the allowed
15
+ owner roles are the keys of LOGINS.
16
+ * ITEMS is a JSONL file with one item per line in the shape
17
+ :func:`~tickwork.workflow_states.evaluate` describes.
18
+ * LOGINS is a YAML mapping of role name to login.
19
+
20
+ One line is printed per anomaly (repo, issue, pr, matched states) followed
21
+ by a summary line. Exit codes: ``1`` when any anomaly was found, ``0``
22
+ when clean, ``2`` when a file cannot be read, parsed or validated (e.g. a
23
+ states file naming a role that is not a key of LOGINS).
24
+
25
+ ``tickwork effects LOG_DIR [--role ROLE] [--since ISO] [--json]`` summarizes
26
+ an effect log with :func:`~tickwork.effect_log.summarize`. It is read-only:
27
+ it never creates or writes LOG_DIR, and a missing or empty log prints an
28
+ empty summary and exits 0.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import datetime
35
+ import json
36
+ import os
37
+ import sys
38
+ from collections.abc import Sequence
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ import yaml
43
+
44
+ from tickwork import replay, workflow_states
45
+ from tickwork.effect_log import PENDING, EffectLog, summarize
46
+ from tickwork.providers import OpenAICompatProvider
47
+
48
+ EMPTY_SUMMARY = {"effects": {}, "finished_later": 0, "unfinished": []}
49
+
50
+
51
+ def _read_logins(path: str | Path) -> dict[str, str]:
52
+ """Read a YAML mapping of role name → login from *path*."""
53
+ logins = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
54
+ if not isinstance(logins, dict) or not all(
55
+ isinstance(role, str) and isinstance(login, str) for role, login in logins.items()
56
+ ):
57
+ raise ValueError(f"{path}: logins must be a mapping of role name to login")
58
+ return logins
59
+
60
+
61
+ def _read_items(path: str | Path) -> list[dict[str, Any]]:
62
+ """Read JSONL items, one dict per line, from *path* (blank lines skipped)."""
63
+ items: list[dict[str, Any]] = []
64
+ with open(path, encoding="utf-8") as handle:
65
+ for lineno, line in enumerate(handle, 1):
66
+ line = line.strip()
67
+ if not line:
68
+ continue
69
+ item = json.loads(line)
70
+ if not isinstance(item, dict):
71
+ raise ValueError(f"{path}:{lineno}: item must be a JSON object")
72
+ items.append(item)
73
+ return items
74
+
75
+
76
+ def _format_anomaly(anomaly: dict[str, Any]) -> str:
77
+ return (
78
+ f"{anomaly['repo']}: issue {anomaly['issue']} pr {anomaly['pr']} "
79
+ f"matched {', '.join(anomaly['matched']) or 'nothing'}"
80
+ )
81
+
82
+
83
+ # The one CLI provider name, resolving to a stdlib OpenAI-compatible adapter.
84
+ _PROVIDER = "openai-compat"
85
+
86
+
87
+ def _length_exact_scorer(record: dict[str, Any], reply: dict[str, Any]) -> dict[str, Any]:
88
+ """The built-in scorer: response length and whether the reply is an
89
+ exact match of the recorded response (null when there is nothing to
90
+ compare against)."""
91
+ recorded = record.get("response")
92
+ response = reply.get("response")
93
+ return {
94
+ "response_len": len(response) if response is not None else None,
95
+ "exact_match": None if recorded is None or response is None else response == recorded,
96
+ }
97
+
98
+
99
+ def _run_replay(args: argparse.Namespace) -> int:
100
+ """Run the generic replay over the given JSONL record paths.
101
+
102
+ Only the ``openai-compat`` provider is wired in: importing
103
+ :mod:`tickwork.providers` in the CLI (not in :mod:`tickwork.replay`)
104
+ keeps the replay core free of transport code.
105
+ """
106
+ if args.provider != _PROVIDER:
107
+ print(f"tickwork: unknown provider {args.provider!r}", file=sys.stderr)
108
+ return 2
109
+ if args.repeats < 1:
110
+ print(f"tickwork: --repeats must be >= 1 (got {args.repeats})", file=sys.stderr)
111
+ return 2
112
+ skips: dict[str, int] = {}
113
+ lines = 0
114
+ try:
115
+ records = replay.read_records(args.records)
116
+ for metrics in replay.replay(
117
+ records,
118
+ OpenAICompatProvider(base_url=args.base_url, key_env=args.key_env),
119
+ [args.model],
120
+ {("*", "*"): _length_exact_scorer},
121
+ repeats=args.repeats,
122
+ skips=skips,
123
+ ):
124
+ print(json.dumps(metrics))
125
+ lines += 1
126
+ except (OSError, ValueError) as exc:
127
+ print(f"tickwork: {exc}", file=sys.stderr)
128
+ return 2
129
+ for reason, count in sorted(skips.items()):
130
+ print(f"skipped {count} record(s): {reason}", file=sys.stderr)
131
+ return 0 if lines or skips else 1
132
+
133
+
134
+ def _roles_in(log_dir: str) -> list[str]:
135
+ """Role names with a ``<role>.jsonl`` in *log_dir*, sorted. Read-only."""
136
+ try:
137
+ names = sorted(os.listdir(log_dir))
138
+ except (FileNotFoundError, OSError):
139
+ return []
140
+ return [name[: -len(".jsonl")] for name in names if name.endswith(".jsonl")]
141
+
142
+
143
+ def _rows_for(log_dir: str, role: str) -> list[dict]:
144
+ """All of *role*'s rows. Read-only: a missing log reads as no rows and the
145
+ log directory is never created or written."""
146
+ return EffectLog(log_dir)._read(role)
147
+
148
+
149
+ def print_summary(summary: dict, out=None) -> None:
150
+ """Render ``summarize()``'s object as text. Pure: writes only to *out*."""
151
+ out = out if out is not None else sys.stdout
152
+ effects = summary.get("effects") or {}
153
+ for role in sorted(effects):
154
+ for effect in sorted(effects[role]):
155
+ s = effects[role][effect]
156
+ p50 = "-" if s["p50"] is None else f"{s['p50']}ms"
157
+ p90 = "-" if s["p90"] is None else f"{s['p90']}ms"
158
+ line = (
159
+ f"{role}/{effect}: applied={s['applied']} failed={s['failed']} "
160
+ f"superseded={s['superseded']} pending={s['pending']} "
161
+ f"finished_later={s['finished_later']} p50={p50} p90={p90}"
162
+ )
163
+ print(line, file=out)
164
+ for u in summary.get("unfinished") or []:
165
+ if u["role"] == role and u["effect"] == effect:
166
+ print(
167
+ f" unfinished: {u['item']} {u['tick_id']}/{u['seq']}",
168
+ file=out,
169
+ )
170
+ if not effects and not summary.get("unfinished"):
171
+ print("(empty log)", file=out)
172
+
173
+
174
+ def _rows_since(rows: list[dict], cutoff: float) -> list[dict]:
175
+ """Drop whole fold keys whose final status row finished before *cutoff*.
176
+
177
+ ``--since`` filters fold keys, not rows: a plan row is written with
178
+ ``finished: None`` and never rewritten, so dropping only old status rows
179
+ would leave the plan row to fold to pending and report long-finished
180
+ work as unfinished. A key ``(item, tick_id, seq)`` — including every
181
+ status row that resumed it — is dropped when its last row in log order
182
+ has a status other than ``pending`` and a numeric ``finished`` earlier
183
+ than the cutoff; keys still pending survive so they stay resumable.
184
+ """
185
+ last: dict[tuple, dict] = {}
186
+ for r in rows:
187
+ last[(r.get("item"), r.get("tick_id"), r.get("seq"))] = r
188
+ stale = {
189
+ key
190
+ for key, r in last.items()
191
+ if r.get("status") != PENDING
192
+ and isinstance(r.get("finished"), (int, float))
193
+ and r["finished"] < cutoff
194
+ }
195
+ return [r for r in rows if (r.get("item"), r.get("tick_id"), r.get("seq")) not in stale]
196
+
197
+
198
+ def _effects(args: argparse.Namespace) -> int:
199
+ """``tickwork effects``: print the effect-log summary. Read-only."""
200
+ roles = args.role
201
+ if not roles:
202
+ roles = _roles_in(args.log_dir)
203
+ cutoff = _since_epoch(args.since) if args.since else None
204
+ all_rows: list[dict] = []
205
+ for role in roles:
206
+ rows = _rows_for(args.log_dir, role)
207
+ if cutoff is not None:
208
+ rows = _rows_since(rows, cutoff)
209
+ all_rows.extend(rows)
210
+ summary = summarize(all_rows) if all_rows else EMPTY_SUMMARY
211
+ if args.json:
212
+ print(json.dumps(summary, sort_keys=True), file=sys.stdout)
213
+ else:
214
+ print_summary(summary)
215
+ return 0
216
+
217
+
218
+ def _since_epoch(iso_date: str) -> float:
219
+ """Epoch seconds for an ISO date (a date-only string is UTC midnight)."""
220
+ date = datetime.date.fromisoformat(iso_date)
221
+ return datetime.datetime(date.year, date.month, date.day, tzinfo=datetime.UTC).timestamp()
222
+
223
+
224
+ def main(argv: Sequence[str] | None = None) -> int:
225
+ parser = argparse.ArgumentParser(prog="tickwork", description=__doc__)
226
+ sub = parser.add_subparsers(dest="command", required=True)
227
+ check_parser = sub.add_parser("check", help="report items matching zero or 2+ states")
228
+ check_parser.add_argument("states", help="the workflow states file")
229
+ check_parser.add_argument("--items", required=True, help="a JSONL file of items, one per line")
230
+ check_parser.add_argument("--logins", required=True, help="a YAML map of role name to login")
231
+ replay_parser = sub.add_parser(
232
+ "replay",
233
+ help="re-send recorded final transcript records to a model and score the replies",
234
+ )
235
+ replay_parser.add_argument("records", nargs="+", help="JSONL transcript files (finals)")
236
+ replay_parser.add_argument("--model", required=True, help="the model to replay each call against")
237
+ replay_parser.add_argument(
238
+ "--provider", default=_PROVIDER, help=f"the provider adapter (default {_PROVIDER})"
239
+ )
240
+ replay_parser.add_argument("--base-url", required=True, help="the API base URL of the endpoint")
241
+ replay_parser.add_argument(
242
+ "--key-env",
243
+ default="OPENAI_API_KEY",
244
+ help="the env var carrying the API key (the key itself is never logged)",
245
+ )
246
+ replay_parser.add_argument("--repeats", type=int, default=1, help="repeats per record and model")
247
+ effects = sub.add_parser("effects", help="summarize an effect log (read-only)")
248
+ effects.add_argument("log_dir", help="the effects log directory (read-only)")
249
+ effects.add_argument(
250
+ "--role",
251
+ action="append",
252
+ default=None,
253
+ help="role to summarize (repeatable); default: every <role>.jsonl in LOG_DIR",
254
+ )
255
+ effects.add_argument(
256
+ "--since",
257
+ default=None,
258
+ help="ISO date; drop fold keys whose final status row finished before it",
259
+ )
260
+ effects.add_argument("--json", action="store_true", help="print the summary as one JSON object")
261
+ args = parser.parse_args(argv)
262
+ if args.command == "effects":
263
+ return _effects(args)
264
+
265
+ if args.command == "replay":
266
+ return _run_replay(args)
267
+
268
+ # The evaluation stays inside the guard: workflow_states.load() validates
269
+ # only the `owner` field, so a predicate naming a role absent from `logins`
270
+ # raises ValueError here, and that must exit 2 like the other file errors,
271
+ # not surface as a traceback the shell cannot distinguish from anomalies.
272
+ try:
273
+ logins = _read_logins(args.logins)
274
+ states = workflow_states.load(args.states, set(logins))
275
+ items = _read_items(args.items)
276
+ anomalies = workflow_states.check(states, items, logins)
277
+ except (OSError, ValueError, yaml.YAMLError, json.JSONDecodeError) as exc:
278
+ print(f"tickwork: {exc}", file=sys.stderr)
279
+ return 2
280
+
281
+ for anomaly in anomalies:
282
+ print(_format_anomaly(anomaly))
283
+ print(f"{len(items)} items checked, {len(anomalies)} anomalies")
284
+ return 1 if anomalies else 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ sys.exit(main())
tickwork/effect_log.py ADDED
@@ -0,0 +1,319 @@
1
+ """Append-only JSONL effect log, instance-neutral.
2
+
3
+ Ship records every write *before* applying it, then marks the row
4
+ applied / failed / superseded with timing. The next tick resumes unfinished
5
+ effects before deciding again, because once a write has landed the item may
6
+ no longer be in the state that produced it — re-deciding alone can never
7
+ finish the job ("Partial ship").
8
+
9
+ Nothing here is specific to one deployment: the log directory is the
10
+ object's constructor parameter, every call names its role explicitly (there
11
+ is no default role), and the module imports only the standard library.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import math
18
+ import os
19
+ import sys
20
+ import time
21
+
22
+ # Status vocab. A reader folds rows by (item, tick_id, seq): the latest status
23
+ # row wins; only an `applied` finish counts as done.
24
+ APPLIED = "applied"
25
+ FAILED = "failed"
26
+ SUPERSEDED = "superseded"
27
+ PENDING = "pending"
28
+ DONE_STATUSES = frozenset({APPLIED, SUPERSEDED})
29
+
30
+
31
+ def fold(rows: list[dict]) -> dict[tuple, str]:
32
+ """Latest status per (item, tick_id, seq) key."""
33
+ latest: dict[tuple, str] = {}
34
+ for r in rows:
35
+ key = (r.get("item"), r.get("tick_id"), r.get("seq"))
36
+ latest[key] = r.get("status")
37
+ return latest
38
+
39
+
40
+ class EffectLog:
41
+ """The append-only JSONL effect log for one directory: one file per role.
42
+
43
+ ``role`` is required on every call that does not take it from a row —
44
+ no default — so an omitted role is a TypeError, never a write into some
45
+ default role's file. A row that carries no ``role`` of its own is a
46
+ ValueError; ``apply`` raises it *before* running the effect so no
47
+ not-logged effect ever runs.
48
+ """
49
+
50
+ def __init__(self, log_dir: str):
51
+ self.log_dir = log_dir
52
+
53
+ def path(self, role: str) -> str:
54
+ """The JSONL file for *role*'s effects: ``<log dir>/<role>.jsonl``."""
55
+ return os.path.join(self.log_dir, f"{role}.jsonl")
56
+
57
+ def _append(self, row: dict) -> dict:
58
+ path = self.path(row["role"])
59
+ os.makedirs(os.path.dirname(path), exist_ok=True)
60
+ with open(path, "a", encoding="utf-8") as f:
61
+ f.write(json.dumps(row, sort_keys=True) + "\n")
62
+ return row
63
+
64
+ def _read(self, role: str) -> list[dict]:
65
+ """All rows for *role*. A missing or unreadable log reads as empty."""
66
+ try:
67
+ with open(self.path(role), encoding="utf-8") as f:
68
+ lines = f.readlines()
69
+ except (FileNotFoundError, OSError):
70
+ return []
71
+ rows: list[dict] = []
72
+ for line in lines:
73
+ line = line.strip()
74
+ if not line:
75
+ continue
76
+ try:
77
+ data = json.loads(line)
78
+ except ValueError:
79
+ continue # a corrupt line never blinds the reader to the rest
80
+ if isinstance(data, dict):
81
+ rows.append(data)
82
+ return rows
83
+
84
+ def _status_row(self, row, status, error=None, *, started=None, finished=None, ms=None):
85
+ role = row.get("role")
86
+ if not role:
87
+ raise ValueError(
88
+ f"row for item {row.get('item')!r} has no 'role': a status row names its "
89
+ "role explicitly, never a default role's file"
90
+ )
91
+ return {
92
+ "role": role,
93
+ "item": row["item"],
94
+ "tick_id": row["tick_id"],
95
+ "seq": row["seq"],
96
+ "effect": row["effect"],
97
+ "payload": row.get("payload"),
98
+ "head_sha": row.get("head_sha"),
99
+ "status": status,
100
+ "started": started,
101
+ "finished": finished,
102
+ "ms": ms,
103
+ "error": error,
104
+ }
105
+
106
+ def plan(self, item, tick_id, effects, head_sha=None, *, role):
107
+ """Log one ``pending`` row per effect BEFORE any of them is applied, then
108
+ return the rows in order. ``effects`` is a list of ``(effect_name,
109
+ payload)`` pairs; ``seq`` numbers them within this plan (0-based).
110
+ ``item`` is ``<repo>#<n>``; ``head_sha`` pins resume to the exact commit
111
+ the rows were decided for, and every row of one plan carries the same
112
+ ``tick_id`` for the reader to fold by. Each append is best-effort: a
113
+ failed log write is skipped so a logging outage never blocks the ship
114
+ (that row simply never makes it into the log; the pending in-memory row
115
+ is still returned so the caller can apply its effect).
116
+ """
117
+ rows = [
118
+ {
119
+ "role": role,
120
+ "item": item,
121
+ "tick_id": tick_id,
122
+ "seq": seq,
123
+ "effect": effect,
124
+ "payload": payload,
125
+ "head_sha": head_sha,
126
+ "status": PENDING,
127
+ "started": None,
128
+ "finished": None,
129
+ "ms": None,
130
+ "error": None,
131
+ }
132
+ for seq, (effect, payload) in enumerate(effects)
133
+ ]
134
+ for row in rows:
135
+ try:
136
+ self._append(row)
137
+ except OSError as e:
138
+ print(f"[effect_log] plan append failed for {item}: {e}", file=sys.stderr)
139
+ continue # a failed log write never blocks the ship
140
+ return rows
141
+
142
+ def mark(self, row, status, error=None, *, started=None, finished=None, ms=None):
143
+ """Append a status row for *row* (a ``plan()`` row). Statuses are
144
+ ``applied``, ``failed`` and ``superseded``; ``superseded`` marks a row
145
+ void because the item's head has moved on (the resume path marks each
146
+ remaining row itself).
147
+
148
+ Timing is supplied by ``apply()`` (``started``/``finished`` wall-clock
149
+ + an ``ms`` duration measured around the write): ``mark()`` alone records
150
+ the *state change* — ``started``/``finished`` are the moment the status
151
+ landed (defaulting to now), and ``ms`` stays None because no write was
152
+ measured. A bare ``mark()`` never claims an ``ms`` the code never took.
153
+ """
154
+ if started is None:
155
+ started = time.time()
156
+ if finished is None:
157
+ finished = started
158
+ return self._append(self._status_row(row, status, error, started=started, finished=finished, ms=ms))
159
+
160
+ def apply(self, row, fn):
161
+ """Run ``fn`` and mark *row* ``applied`` or ``failed`` with timing around
162
+ it. Returns ``fn()``'s result; the exception (if any) propagates so the
163
+ caller keeps today's control flow — the log only records what happened.
164
+
165
+ The status append is best-effort here too: an OSError landing on the
166
+ log write is reported as a logging failure, never as the effect having
167
+ failed. A row with no ``role`` is a ValueError raised *before* ``fn``
168
+ runs — the effect never runs for a row that can't be logged."""
169
+ if not row.get("role"):
170
+ raise ValueError(
171
+ f"row for item {row.get('item')!r} has no 'role': refusing to run an "
172
+ "effect that cannot be logged to its role's file"
173
+ )
174
+ started = time.time()
175
+ pc_start = time.perf_counter()
176
+ wall_finished = None
177
+ ms = None
178
+ try:
179
+ result = fn()
180
+ except Exception as e:
181
+ ms = round((time.perf_counter() - pc_start) * 1000, 3)
182
+ wall_finished = time.time()
183
+ try:
184
+ self.mark(row, FAILED, str(e), started=started, finished=wall_finished, ms=ms)
185
+ except OSError as le:
186
+ print(f"[effect_log] apply mark (failed) append error: {le}", file=sys.stderr)
187
+ raise
188
+ wall_finished = time.time()
189
+ ms = round((time.perf_counter() - pc_start) * 1000, 3)
190
+ try:
191
+ self.mark(row, APPLIED, None, started=started, finished=wall_finished, ms=ms)
192
+ except OSError as le:
193
+ print(f"[effect_log] apply mark (applied) append error: {le}", file=sys.stderr)
194
+ return result
195
+
196
+ def latest_plans(self, rows: list[dict]) -> dict:
197
+ """The latest plan (by log order) per item, as ``{item: [pending rows]}``
198
+ with each row annotated with its folded status. A plan is keyed by its
199
+ ``tick_id`` — pending rows of an *earlier* plan for the same item never
200
+ leak into the latest one's row list, so the returned rows are always one
201
+ plan's, in that plan's seq order."""
202
+ plans: dict[str, list[dict]] = {}
203
+ latest_tick: dict[str, str] = {}
204
+ status_by_key = fold(rows)
205
+ pending_rows = [r for r in rows if r.get("status") == PENDING]
206
+ # The item's latest plan is the LAST pending row in log order — a re-plan
207
+ # replaces any earlier draft, whose rows never resume.
208
+ for r in pending_rows:
209
+ latest_tick[r.get("item")] = r.get("tick_id")
210
+ for r in pending_rows:
211
+ item = r.get("item")
212
+ if r.get("tick_id") != latest_tick.get(item):
213
+ continue # an older draft of this item — a re-plan replaced it
214
+ key = (item, r.get("tick_id"), r.get("seq"))
215
+ plans.setdefault(item, []).append({**r, "status": status_by_key.get(key, PENDING)})
216
+ return plans
217
+
218
+ def unfinished(self, role: str):
219
+ """Items whose latest plan still has rows that are neither ``applied`` nor
220
+ ``superseded``: ``{item: [pending rows in seq order]}``. A missing or
221
+ empty log reads as no unfinished items. Never raises."""
222
+ pending = self.latest_plans(self._read(role))
223
+ out: dict[str, list[dict]] = {}
224
+ for item, rows in pending.items():
225
+ live = [r for r in rows if r["status"] not in DONE_STATUSES]
226
+ if live:
227
+ out[item] = sorted(live, key=lambda r: r.get("seq", 0))
228
+ return out
229
+
230
+
231
+ def _percentile(values: list, p: int):
232
+ """Nearest-rank percentile of *values* — ``idx = ceil(p/100 * n) - 1``, no
233
+ interpolation, stdlib only. Empty input is ``None``, never ``0``."""
234
+ if not values:
235
+ return None
236
+ ordered = sorted(values)
237
+ return ordered[math.ceil(p / 100 * len(ordered)) - 1]
238
+
239
+
240
+ def _empty_stats() -> dict:
241
+ return {
242
+ "applied": 0,
243
+ "failed": 0,
244
+ "superseded": 0,
245
+ "pending": 0,
246
+ "p50": None,
247
+ "p90": None,
248
+ "finished_later": 0,
249
+ }
250
+
251
+
252
+ def summarize(rows: list[dict]) -> dict:
253
+ """A JSON-able, read-only summary of effect-log *rows*.
254
+
255
+ Rows are folded by ``(item, tick_id, seq)`` — the latest status row wins —
256
+ and counted per ``(role, effect)`` as ``applied`` / ``failed`` /
257
+ ``superseded`` / ``pending``; a folded ``pending`` key is its item's still
258
+ unfinished work. ``p50``/``p90`` are nearest-rank percentiles over ``ms``
259
+ on folded-``applied`` keys only (``mark()`` records a state change without
260
+ measuring a write and leaves ``ms`` None; only ``apply()`` measures it),
261
+ and stay ``None`` when no applied ``ms`` exists. ``finished_later`` counts
262
+ fold keys — one each — whose status sequence holds a ``failed`` row
263
+ followed, later in log order, by an ``applied`` row for the same key: a
264
+ resume finished it. ``unfinished`` lists the folded-pending rows in log
265
+ order. The command never writes to the log directory.
266
+ """
267
+ folded_rows: dict[tuple, dict] = {}
268
+ for r in rows:
269
+ key = (r.get("item"), r.get("tick_id"), r.get("seq"))
270
+ folded_rows[key] = r # the last row in log order wins, like fold()
271
+
272
+ groups: dict = {}
273
+ ms_by_group: dict[tuple, list] = {}
274
+ for _key, r in folded_rows.items():
275
+ gkey = (r.get("role"), r.get("effect"))
276
+ stats = groups.setdefault(gkey[0], {}).setdefault(gkey[1], _empty_stats())
277
+ status = r.get("status")
278
+ if status == APPLIED:
279
+ stats["applied"] += 1
280
+ ms = r.get("ms")
281
+ if ms is not None:
282
+ ms_by_group.setdefault(gkey, []).append(ms)
283
+ elif status == FAILED:
284
+ stats["failed"] += 1
285
+ elif status == SUPERSEDED:
286
+ stats["superseded"] += 1
287
+ else:
288
+ stats["pending"] += 1
289
+
290
+ for gkey, ms_values in ms_by_group.items():
291
+ stats = groups[gkey[0]][gkey[1]]
292
+ stats["p50"] = _percentile(ms_values, 50)
293
+ stats["p90"] = _percentile(ms_values, 90)
294
+
295
+ finished_later = 0
296
+ seen_failed: set[tuple] = set()
297
+ for r in rows: # log order: a `failed` row finished by a later `applied` row
298
+ key = (r.get("item"), r.get("tick_id"), r.get("seq"))
299
+ if r.get("status") == FAILED:
300
+ seen_failed.add(key)
301
+ elif r.get("status") == APPLIED and key in seen_failed:
302
+ groups.setdefault(r.get("role"), {}).setdefault(r.get("effect"), _empty_stats())[
303
+ "finished_later"
304
+ ] += 1
305
+ finished_later += 1
306
+ seen_failed.discard(key) # count each fold key once
307
+
308
+ unfinished = [
309
+ {
310
+ "role": r.get("role"),
311
+ "item": r.get("item"),
312
+ "tick_id": r.get("tick_id"),
313
+ "seq": r.get("seq"),
314
+ "effect": r.get("effect"),
315
+ }
316
+ for r in folded_rows.values()
317
+ if r.get("status") not in (APPLIED, FAILED, SUPERSEDED)
318
+ ]
319
+ return {"effects": groups, "finished_later": finished_later, "unfinished": unfinished}