@r1z4x/tezgah 1.5.1 → 1.7.0

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 (81) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/MANIFEST +11 -0
  3. package/README.md +6 -3
  4. package/bin/consult +13 -10
  5. package/bin/tezgah-docs +18 -16
  6. package/bin/tezgah-doctor +152 -0
  7. package/bin/tezgah-research +286 -12
  8. package/bin/tezgah-route +48 -1
  9. package/bin/tezgah-setup +27 -14
  10. package/bin/tezgah-task +771 -46
  11. package/bin/tezgah-taste +48 -10
  12. package/docs/architecture.md +7 -4
  13. package/docs/citations-baseline.json +1 -1
  14. package/docs/clarity-baseline.json +5 -5
  15. package/docs/contract.md +112 -25
  16. package/docs/evidence.md +201 -28
  17. package/docs/feature-audit.md +25 -4
  18. package/docs/gate.md +70 -33
  19. package/docs/glossary.md +4 -4
  20. package/docs/hosts.md +8 -8
  21. package/docs/index.json +30 -10
  22. package/docs/judge.md +9 -3
  23. package/docs/models.md +35 -2
  24. package/docs/operations.md +103 -3
  25. package/docs/research.md +392 -13
  26. package/docs/skills.md +24 -6
  27. package/docs/status-line.md +5 -5
  28. package/docs/testing.md +2 -2
  29. package/docs/vision.md +12 -4
  30. package/hooks/hooks.json +2 -2
  31. package/hooks/lang/en.json +323 -0
  32. package/hooks/lang/tr.json +344 -0
  33. package/hooks/projects-posttooluse.py +24 -5
  34. package/hooks/projects-pretooluse.py +9 -4
  35. package/hooks/projects-stop.py +28 -16
  36. package/hooks/tezgah_agents.py +29 -9
  37. package/hooks/tezgah_clarity.py +7 -3
  38. package/hooks/tezgah_components.py +2 -1
  39. package/hooks/tezgah_context.py +460 -390
  40. package/hooks/tezgah_gate.py +640 -104
  41. package/hooks/tezgah_integrity.py +1480 -197
  42. package/hooks/tezgah_models.py +337 -15
  43. package/hooks/tezgah_paths.py +103 -10
  44. package/hooks/tezgah_policy.py +286 -130
  45. package/hooks/tezgah_product.py +107 -0
  46. package/hooks/tezgah_rank.py +29 -27
  47. package/hooks/tezgah_replay.py +200 -30
  48. package/hooks/tezgah_research.py +1297 -12
  49. package/hooks/tezgah_skill_pick.py +58 -17
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +917 -8
  53. package/hosts/codex/hook.py +9 -10
  54. package/hosts/cursor/hook.py +21 -14
  55. package/hosts/dsh/hooks.json +2 -2
  56. package/hosts/omp/hook.py +44 -23
  57. package/hosts/omp/tezgah-hook.ts.in +93 -7
  58. package/hosts/opencode/plugins/tezgah.js +119 -39
  59. package/output-styles/tezgah.md +76 -42
  60. package/package.json +1 -1
  61. package/skills/analyze-app/SKILL.md +57 -0
  62. package/skills/design-contract/EVALS.md +24 -1
  63. package/skills/design-contract/SKILL.md +17 -1
  64. package/skills/design-contract/data-views.md +86 -0
  65. package/skills/feature-audit/SKILL.md +32 -3
  66. package/skills/i-have-adhd/EVALS.md +22 -2
  67. package/skills/i-have-adhd/SKILL.md +12 -5
  68. package/skills/plan-add/EVALS.md +8 -8
  69. package/skills/plan-add/SKILL.md +22 -21
  70. package/skills/plan-sync/EVALS.md +2 -2
  71. package/skills/plan-sync/SKILL.md +14 -8
  72. package/skills/pm-frameworks/SKILL.md +6 -0
  73. package/skills/ponytail/EVALS.md +21 -2
  74. package/skills/ponytail/SKILL.md +4 -4
  75. package/skills/product-analysis/EVALS.md +23 -1
  76. package/skills/product-analysis/SKILL.md +50 -11
  77. package/skills/product-flow/EVALS.md +81 -0
  78. package/skills/product-flow/SKILL.md +91 -0
  79. package/skills/research/SKILL.md +99 -2
  80. package/skills/tezgah-contract/EVALS.md +52 -7
  81. package/skills/tezgah-contract/SKILL.md +204 -85
@@ -0,0 +1,107 @@
1
+ """The product-flow playbook (plan 074, ADR 025): the one place its steps are
2
+ written. `next_steps` computes each step's status from the topic's files under
3
+ `.tezgah/analysis/<topic>/` - `flow.md`, one `## <step>` heading per step, and
4
+ `spec.md` - and nothing stores it."""
5
+ import datetime
6
+ import os
7
+ import re
8
+
9
+ import tezgah_paths as tp
10
+
11
+ # A topic becomes a path below, so anything outside kebab-case reads no file.
12
+ TOPIC = re.compile(r"(?=.{1,64}\Z)[a-z0-9]+(?:-[a-z0-9]+)*")
13
+ OBSERVE = re.compile(r"observe after:\s*(\d{4}-\d{2}-\d{2})", re.I)
14
+
15
+
16
+ def _step(sid, skill, first=""):
17
+ """A step whose artifact is its `## <sid>` note in flow.md; `first` is what
18
+ the session does before writing it."""
19
+ return {"id": sid, "skill": skill,
20
+ "leaves": "## %s in .tezgah/analysis/<topic>/flow.md" % sid,
21
+ "command": "%swrite ## %s in .tezgah/analysis/{topic}/flow.md"
22
+ % (first, sid)}
23
+
24
+
25
+ # `{topic}` in a command is filled per topic. The design check and the Stop UI
26
+ # rule judge design and build; the flow only asks for their note.
27
+ _STEPS = (
28
+ _step("research", "research"),
29
+ _step("metrics", "product-analysis"),
30
+ _step("assumptions", "product-analysis"),
31
+ _step("decision", "product-analysis"),
32
+ _step("instrumentation", "product-analysis"),
33
+ {"id": "forward-spec", "skill": "feature-audit",
34
+ "leaves": ".tezgah/analysis/<topic>/spec.md",
35
+ "command": "feature-audit forward mode: write .tezgah/analysis/{topic}/spec.md "
36
+ "naming the decision"},
37
+ _step("design", "design-contract", "tezgah-design check, then "),
38
+ _step("build", "design-contract", "tezgah-design check, then "),
39
+ _step("re-measure", "analyze-app", "measure the decision's metric again, then "),
40
+ )
41
+ FLOWS = {
42
+ "metric-dashboard": _STEPS,
43
+ # A feature may reuse a metric another topic owns: its own metric steps join
44
+ # the flow only when its flow.md holds `## metrics` (`next_steps`).
45
+ "feature": tuple(s for s in _STEPS if s["id"] not in ("metrics", "instrumentation")),
46
+ }
47
+
48
+
49
+ def _sections(text):
50
+ """{step: body} of the `## <step>` headings in `text`, lower-cased."""
51
+ parts = re.split(r"^##[ \t]+(.+?)[ \t]*$", text, flags=re.M)
52
+ return {parts[i].lower(): parts[i + 1] for i in range(1, len(parts) - 1, 2)}
53
+
54
+
55
+ def _read(path):
56
+ try:
57
+ with open(path, encoding="utf-8", errors="replace") as fh:
58
+ return fh.read()
59
+ except OSError:
60
+ return ""
61
+
62
+
63
+ def next_steps(repo, topic, flow="metric-dashboard", today=""):
64
+ """[{"id", "skill", "status", "detail", "command"}] for `topic` under `flow`,
65
+ status one of done | missing | due, read from `.tezgah/analysis/<topic>/` of
66
+ `repo`. A topic outside TOPIC or with no folder lists every step missing."""
67
+ if flow not in FLOWS:
68
+ raise ValueError("unknown flow %r (%s)" % (flow, ", ".join(FLOWS)))
69
+ try:
70
+ day = datetime.date.fromisoformat(today) if today else datetime.date.today()
71
+ except ValueError:
72
+ raise ValueError("today %r is not YYYY-MM-DD" % (today,)) from None
73
+ safe = isinstance(topic, str) and TOPIC.fullmatch(topic)
74
+ rel = os.path.join(".tezgah", "analysis", topic if safe else "<topic>")
75
+ folder = os.path.join(tp.workspace(repo), "analysis", topic) if safe else ""
76
+ notes = _sections(_read(os.path.join(folder, "flow.md"))) if folder else {}
77
+ flow_rel = os.path.join(rel, "flow.md")
78
+ spec_rel = os.path.join(rel, "spec.md")
79
+ steps = FLOWS["metric-dashboard"] if flow == "feature" and "metrics" in notes \
80
+ else FLOWS[flow]
81
+
82
+ def status(sid):
83
+ if sid == "forward-spec":
84
+ return ("done", "%s exists" % spec_rel) \
85
+ if folder and os.path.isfile(os.path.join(folder, "spec.md")) \
86
+ else ("missing", "%s does not exist" % spec_rel)
87
+ if sid in notes:
88
+ return "done", "%s ## %s" % (flow_rel, sid)
89
+ if sid == "re-measure":
90
+ after = OBSERVE.search(notes.get("decision", ""))
91
+ try:
92
+ when = datetime.date.fromisoformat(after.group(1)) if after else None
93
+ except ValueError:
94
+ when = None
95
+ if when is not None:
96
+ return ("due", "observe after %s has passed" % when) if day > when \
97
+ else ("missing", "observe after %s, not yet" % when)
98
+ return "missing", "no observe after: in %s ## decision" % flow_rel
99
+ return "missing", "no ## %s in %s" % (sid, flow_rel)
100
+
101
+ out = []
102
+ for s in steps:
103
+ state, detail = status(s["id"])
104
+ out.append({"id": s["id"], "skill": s["skill"], "status": state,
105
+ "detail": detail, "command": s["command"].format(
106
+ topic=topic if safe else "<topic>")})
107
+ return out
@@ -13,12 +13,15 @@ import math
13
13
  import re
14
14
  from collections import Counter
15
15
 
16
+ from tezgah_paths import packs
17
+
16
18
  # Lowercased words, Turkish letters kept; one- and two-letter words carry no topic.
17
19
  # Dotted and dotless i fold together (İ, I, ı -> i) so "İSTEK" meets "istek" and
18
20
  # "KIRMIZI" meets "kırmızı" while English capitals stay "index"; plain lower()
19
- # would turn İ into i + U+0307 and split the word. Circumflexes fold too.
21
+ # would turn İ into i + U+0307 and split the word. A pack's `fold` pairs (tr:
22
+ # dotless i and the circumflexes) fold too.
20
23
  WORD = re.compile(r"[a-zçğöşü0-9_]+")
21
- FOLD = str.maketrans("ıâîû", "iaiu")
24
+ FOLD = str.maketrans({pair[0]: pair[1] for p in packs().values() for pair in p["fold"]})
22
25
  MIN_WORD = 3
23
26
  K1 = 1.2
24
27
  B = 0.75
@@ -33,32 +36,22 @@ def words(text):
33
36
  # The lessons path's own filter, passed as `max_df` (`tezgah_context.LESSON_MAX_DF`):
34
37
  # a word in most of the ledger ("the", "and" in 41 of 47 lines) ranked lines 9,
35
38
  # 7 and 34 for "update the changelog", a prompt no line is about. Function words
36
- # go first, in both languages the ledger is written in (folded the way `words`
37
- # folds them); then any term in more than `max_df` of the texts - once there are
39
+ # go first, every pack's `rank_stopwords` (folded the way `words` folds them);
40
+ # then any term in more than `max_df` of the texts - once there are
38
41
  # MIN_CAP_TEXTS of them, because on one or two texts a 50% cap drops every term.
39
42
  # The docs fallback passes no cap.
40
- STOPWORDS = frozenset((
41
- "the", "and", "that", "this", "with", "for", "from", "are", "was", "were",
42
- "not", "but", "its", "into", "than", "then", "they", "them", "their",
43
- "have", "has", "had", "will", "would", "can", "could", "should", "you",
44
- "your", "what", "when", "which", "who", "how", "all", "any", "out",
45
- "bir", "bunu", "buna", "bunlar", "şunu", "için", "ile", "ama", "gibi",
46
- "daha", "çok", "kadar", "veya", "değil", "her", "hem", "olan", "olarak",
47
- "sonra", "önce", "ise", "yani", "nasil", "neden"))
43
+ STOPWORDS = frozenset(w for p in packs().values() for w in p["rank_stopwords"])
48
44
  MIN_CAP_TEXTS = 10
49
45
 
50
46
 
51
- def rank(query, texts, k, max_df=None):
52
- """Indices of the `k` texts that score highest for `query`, best first.
53
-
54
- Only a text sharing at least one term with the query is returned (score > 0),
55
- so an unrelated query gets [] rather than an arbitrary order. `max_df` (a
56
- fraction) drops STOPWORDS and, on MIN_CAP_TEXTS or more texts, every query
57
- term found in more than that share of them."""
47
+ def scores(query, texts, max_df=None):
48
+ """Every text's BM25 score for `query`, in input order; 0.0 for a text that
49
+ shares no term with it. `rank` orders these; the research dedup reads them as
50
+ numbers (`tezgah_research.similarity_matrix`)."""
58
51
  docs = [Counter(words(t)) for t in texts]
59
52
  terms = set(words(query))
60
53
  if not docs or not terms:
61
- return []
54
+ return [0.0] * len(docs)
62
55
  avg = sum(sum(d.values()) for d in docs) / len(docs) or 1.0
63
56
  df = {t: sum(1 for d in docs if t in d) for t in terms}
64
57
  if max_df is not None:
@@ -66,12 +59,21 @@ def rank(query, texts, k, max_df=None):
66
59
  if len(docs) < MIN_CAP_TEXTS or df[t] <= max_df * len(docs)}
67
60
  idf = {t: math.log(1 + (len(docs) - df[t] + 0.5) / (df[t] + 0.5))
68
61
  for t in terms}
69
- scored = []
70
- for i, d in enumerate(docs):
62
+ out = []
63
+ for d in docs:
71
64
  size = sum(d.values())
72
- score = sum(idf[t] * d[t] * (K1 + 1)
73
- / (d[t] + K1 * (1 - B + B * size / avg))
74
- for t in terms if t in d)
75
- if score > 0:
76
- scored.append((-score, i))
65
+ out.append(sum(idf[t] * d[t] * (K1 + 1)
66
+ / (d[t] + K1 * (1 - B + B * size / avg))
67
+ for t in terms if t in d))
68
+ return out
69
+
70
+
71
+ def rank(query, texts, k, max_df=None):
72
+ """Indices of the `k` texts that score highest for `query`, best first.
73
+
74
+ Only a text sharing at least one term with the query is returned (score > 0),
75
+ so an unrelated query gets [] rather than an arbitrary order. `max_df` (a
76
+ fraction) drops STOPWORDS and, on MIN_CAP_TEXTS or more texts, every query
77
+ term found in more than that share of them."""
78
+ scored = [(-s, i) for i, s in enumerate(scores(query, texts, max_df)) if s > 0]
77
79
  return [i for _s, i in sorted(scored)[:k]]
@@ -3,8 +3,9 @@
3
3
  code, a blind label sheet drawn from them, and the report that reads the labels
4
4
  (plan 055, roadmap R12; corpus spec REPORT.md §4.4.3).
5
5
 
6
- tezgah-gate replay [--cutoff WHEN] [--since WHEN] [--json]
7
- tezgah-gate replay --report [--run DIR] [--labels FILE ...] [--json]
6
+ tezgah-gate replay [--cutoff WHEN] [--since WHEN] [--split search|holdout] [--json]
7
+ tezgah-gate replay --report [--run DIR] [--labels FILE ...]
8
+ [--split search|holdout] [--json]
8
9
  tezgah-gate replay --sheet [--run DIR] [--seed N] [--rules FILE]
9
10
  tezgah-gate replay --label-model --prompt FILE [--provider jev|deepseek]
10
11
  [--model ID] [--run DIR]
@@ -44,9 +45,20 @@ inputs), `results.jsonl` and the sheet files. The run's summary is its row in
44
45
  the cache database's replay index (`tezgah_store.add_replay`), whose newest
45
46
  row is the latest run. The sandbox HOME and the row stream are deleted when
46
47
  the child returns.
48
+
49
+ **Split.** `--split search|holdout` reads one side of the run (idea 113): a
50
+ session is on `search` when the first byte of the sha256 of its session id is
51
+ even, else on `holdout` (`split_of`), so a session's side is the same in every
52
+ process. The replay still runs the whole corpus and the indexed summary stays
53
+ the whole corpus; the split recomputes the item counts, join, fidelity and race
54
+ family over that side's items, and the report's label rates over that side's
55
+ sheet rows. The ledger, exclusion and denial-budget counts stay whole-corpus.
56
+ The report and a split run name the side they read (`split`, `all` with no
57
+ `--split`) and its `sessions`.
47
58
  """
48
59
  import argparse
49
60
  import datetime
61
+ import hashlib
50
62
  import importlib.machinery
51
63
  import importlib.util
52
64
  import json
@@ -90,7 +102,7 @@ RACE_BAR_UNMEASURABLE = ("not measurable on this corpus: no race item is on the
90
102
  # disk-state when the command reads a message file.
91
103
  STRATA = {"ledger": ("loop", "retry", "order", "drift", "race", "piped",
92
104
  "attribution", "secret", "shortcut"),
93
- "disk": ("task", "plan", "workspace", "explorer"),
105
+ "disk": ("task", "plan", "workspace", "explorer", "lease"),
94
106
  "disk_on_write": ("shortcut",),
95
107
  "disk_on_file": ("lang",)}
96
108
  MESSAGE_FILE = re.compile(r"(?:^|\s)(?:-F|--file)(?:\s|=|$)")
@@ -102,6 +114,12 @@ SEED = 55
102
114
  CUT_PROMPT, CUT_INPUT, CUT_REPLY = 1500, 2000, 3000
103
115
  STOP_CONTEXT = ("run", "edit", "verify", "verify_ok", "verify_fail",
104
116
  "interrupted", "external")
117
+ # A shell call that steps outward on the user's remote (`outward_without_grant`).
118
+ OUTWARD_COMMAND = re.compile(r"\bgit\s+push\b|\b(?:npm|pnpm|yarn)\s+publish\b|\bgh\s+"
119
+ r"(?:release\s+create|pr\s+(?:create|merge))\b|\btwine\s+upload\b")
120
+ # How Claude writes a Stop hook's refusal back into the transcript: a user row,
121
+ # but no user prompt, so no new turn.
122
+ STOP_FEEDBACK = "Stop hook feedback:"
105
123
 
106
124
 
107
125
  def replay_root():
@@ -256,6 +274,96 @@ def _transcripts(wanted):
256
274
  return found, parent_of
257
275
 
258
276
 
277
+ def residual_turns(cutoff, since=None):
278
+ """Residual sections per user turn over the top-level omp and Claude
279
+ transcripts (a subagent's reply is not user-facing). A turn starts at a user
280
+ prompt that is not a harness prompt nor a Stop hook's feedback (Claude
281
+ writes a refusal as a user row); its final reply is the last reply before
282
+ the next one, and it counts when that reply's ts is in `(since, cutoff]`.
283
+ A session in a fixture workspace (`ti.FIXTURE_WORKSPACE`, the cache) is
284
+ left out, as the corpus leaves its ledger out: a probe or a consult run is
285
+ no user turn. A turn did work when one of its calls `ti.classify`es.
286
+ `unblocked_work_turns` reads each reply with no ledger rows, so an item
287
+ whose evidence is a failed command counts as unblocked: an upper bound.
288
+ `question_endings` watches doable work rephrased as a question.
289
+ The ask policy's four counters (plan 086, S3): `process_questions`, the
290
+ final replies the Stop rule's `needless ask` reads as a process question
291
+ (no grant row exists here, so a re-ask after a grant is not counted);
292
+ `cheap_answers`, the prompts that only approve ("devam", "evet", "go on")
293
+ right after a reply that ended on a question; `asks_without_default`, the
294
+ ask-tool questions (omp `ask`, Claude AskUserQuestion) with no recommended
295
+ option (`ti.undefaulted`); `outward_without_grant`, the turns that ran an
296
+ outward shell call (OUTWARD_COMMAND) when no prompt of the session so far
297
+ had granted or named an outward step, nor approved a question that named
298
+ one - the flip condition of the S3 V3 gate reads this one.
299
+ Whole-corpus even under `--split`."""
300
+ import tezgah_context as tc # the grant reader (`user_grants`)
301
+ taste = _taste()
302
+ out = Counter()
303
+
304
+ def counted(ts):
305
+ return ts is not None and ts <= cutoff and (since is None or ts > since)
306
+
307
+ def close(reply, work, outward):
308
+ """(ended on a question, the question named an outward step) for a
309
+ counted turn, else None."""
310
+ if reply is None or not counted(reply[1]):
311
+ return None
312
+ text = reply[0]
313
+ found = ti.residual_sections(text)
314
+ lines = [ln.strip().rstrip("*_` ") for ln in text.splitlines() if ln.strip()]
315
+ asked = bool(lines and ti.QUESTION_END.search(lines[-1]))
316
+ out["turns"] += 1
317
+ out["work_turns"] += work
318
+ out["residual_turns"] += bool(found)
319
+ out["residual_work_turns"] += bool(found) and work
320
+ out["sections"] += len(found)
321
+ out["unblocked_work_turns"] += work and bool(ti.unblocked_items(text, []))
322
+ out["question_endings"] += asked
323
+ out["process_questions"] += bool(ti.needless_ask(text))
324
+ out["outward_without_grant"] += outward
325
+ return asked, asked and bool(ti.outward_classes(lines[-1]))
326
+
327
+ for host in ("omp", "claude"):
328
+ walk = taste.HOSTS[host][2]
329
+ for path, cwd in taste.sessions(host, "", nested=False):
330
+ if ti.FIXTURE_WORKSPACE.search(cwd or "") or (cwd or "").startswith(tp.CACHE):
331
+ continue
332
+ reply, work, started, outward, granted = None, False, False, False, False
333
+ for event in walk(path, calls=True):
334
+ if event[0] == "user" and not taste.harness_prompt(event[1]) \
335
+ and not event[1].lstrip().startswith(STOP_FEEDBACK):
336
+ prev = close(reply, work, outward) if started else None
337
+ prompt = event[1]
338
+ if prev and prev[0] and ti.CHEAP_ANSWER.fullmatch(prompt.strip()):
339
+ out["cheap_answers"] += 1
340
+ granted = granted or prev[1]
341
+ grants = tc.user_grants(prompt)
342
+ if grants:
343
+ granted = not grants[-1][1].startswith("lift:")
344
+ elif ti.outward_classes(prompt):
345
+ granted = True
346
+ reply, work, started, outward = None, False, True, False
347
+ elif event[0] == "reply":
348
+ reply = (event[1], event[2])
349
+ elif event[0] == "call":
350
+ name, inp = str(event[1] or "").lower(), event[2] or {}
351
+ work = work or ti.classify(event[1], inp) is not None
352
+ outward = outward or (not granted and name in ti.BASH_TOOLS and bool(
353
+ OUTWARD_COMMAND.search(str(inp.get("command") or ""))))
354
+ if name in ti.ASK_TOOLS and counted(event[-1]):
355
+ asked, defaulted = ti.undefaulted(inp)
356
+ out["asks_without_default"] += asked - defaulted
357
+ if started:
358
+ close(reply, work, outward)
359
+ keys = ("turns", "work_turns", "residual_turns", "residual_work_turns", "sections",
360
+ "unblocked_work_turns", "question_endings", "process_questions",
361
+ "cheap_answers", "asks_without_default", "outward_without_grant")
362
+ res = {k: int(out[k]) for k in keys}
363
+ res["per_turn"] = round(res["sections"] / res["turns"], 3) if res["turns"] else None
364
+ return res
365
+
366
+
259
367
  def _nearest(candidates, ts):
260
368
  best = None
261
369
  for cand in candidates:
@@ -572,15 +680,8 @@ def run_replay(cutoff=None, since=None):
572
680
  if proc.returncode:
573
681
  raise RuntimeError("replay child failed: %s" % proc.stderr[-2000:])
574
682
  results = _read_jsonl(os.path.join(run, "results.jsonl"))
575
- join = Counter()
576
- for it in items:
577
- key = it["kind"] if it["kind"] == "claim" else "%s:%s" % (
578
- it["kind"], str(it["tool"] or "unknown").lower())
579
- join[key + ":n"] += 1
580
- join[key + ":joined"] += bool(it["join"])
581
683
  by = {r["i"]: r for r in results}
582
- text_ok = sum(1 for it in items if by.get(it["i"], {}).get("text_ok"))
583
- family = race_family(items, results, parent_of)
684
+ race_family(items, results, parent_of)
584
685
  for it in items:
585
686
  writers = by.get(it["i"], {}).get("writers")
586
687
  if writers:
@@ -589,16 +690,51 @@ def run_replay(cutoff=None, since=None):
589
690
  summary = {"run": run, "cutoff": cutoff, "cutoff_local": _stamp(cutoff),
590
691
  "since": since, "since_release": SINCE_RELEASE,
591
692
  "ledgers": dict(counts["ledgers"]), "candidates": counts["candidates"],
592
- "excluded": dict(counts["excluded"]), "items": len(items),
593
- "joined": sum(1 for it in items if it["join"]), "join": dict(join),
594
- "stop_text_fidelity": text_ok,
595
- "fidelity": fidelity(items, results),
596
- "race_family": family,
597
- "deny_runs": folds}
693
+ "excluded": dict(counts["excluded"])}
694
+ summary.update(_item_stats(items, results))
695
+ summary["deny_runs"] = folds
696
+ summary["residual"] = residual_turns(cutoff, since)
598
697
  tezgah_store.add_replay(_run_key(run), summary, tp.CACHE)
599
698
  return summary
600
699
 
601
700
 
701
+ def _item_stats(items, results):
702
+ """The summary fields computed from the items and their replay results."""
703
+ by = {r["i"]: r for r in results}
704
+ join = Counter()
705
+ for it in items:
706
+ key = it["kind"] if it["kind"] == "claim" else "%s:%s" % (
707
+ it["kind"], str(it["tool"] or "unknown").lower())
708
+ join[key + ":n"] += 1
709
+ join[key + ":joined"] += bool(it["join"])
710
+ return {"items": len(items), "joined": sum(1 for it in items if it["join"]),
711
+ "join": dict(join),
712
+ "stop_text_fidelity": sum(1 for it in items
713
+ if by.get(it["i"], {}).get("text_ok")),
714
+ "fidelity": fidelity(items, results),
715
+ "race_family": _family_counts(items, by)}
716
+
717
+
718
+ def split_of(sid):
719
+ """`search` or `holdout`: the first byte of sha256(session id), even or odd.
720
+ Not `hash()`, which is salted per process."""
721
+ return "holdout" if hashlib.sha256(str(sid).encode("utf-8")).digest()[0] % 2 else "search"
722
+
723
+
724
+ def _split_view(summary, run, split):
725
+ """`summary` named by the side it reads; with a split, its item fields
726
+ recomputed over that side's items (module docstring, Split)."""
727
+ items = _read_jsonl(os.path.join(run, "corpus.jsonl"))
728
+ out = dict(summary, split=split or "all")
729
+ if split:
730
+ items = [it for it in items if split_of(it["sid"]) == split]
731
+ keep = {it["i"] for it in items}
732
+ out.update(_item_stats(items, [r for r in _read_jsonl(
733
+ os.path.join(run, "results.jsonl")) if r["i"] in keep]))
734
+ out["sessions"] = len({it["sid"] for it in items})
735
+ return out
736
+
737
+
602
738
  def relation(a, b, parent_of):
603
739
  """parent/child, sibling, unrelated, or undecided for two ledger stems."""
604
740
  pa, pb = parent_of.get(a, "unknown"), parent_of.get(b, "unknown")
@@ -616,6 +752,18 @@ def race_family(items, results, parent_of):
616
752
  the replay found (plan 055 part 6, D32a): intra-family when every writer is
617
753
  family, cross-family when any is unrelated."""
618
754
  by = {r["i"]: r for r in results}
755
+ for it in items:
756
+ if it["live"] != "deny" or it["live_rule"] != RACE_RULE:
757
+ continue
758
+ writers = by.get(it["i"], {}).get("writers") or it.get("foreign") or []
759
+ rels = {relation(it["stem"], w, parent_of) for w in writers}
760
+ it["family"] = ("no-writer" if not rels else "cross" if "unrelated" in rels
761
+ else "undecided" if "undecided" in rels else "intra")
762
+ return _family_counts(items, by)
763
+
764
+
765
+ def _family_counts(items, by):
766
+ """The race family counts over the items `race_family` labelled."""
619
767
  out = Counter()
620
768
  for it in items:
621
769
  if it["live"] != "deny" or it["live_rule"] != RACE_RULE:
@@ -624,10 +772,7 @@ def race_family(items, results, parent_of):
624
772
  writers = res.get("writers") or it.get("foreign") or []
625
773
  out["from replay" if res.get("writers") else "from live detail"
626
774
  if writers else "no writer named"] += 1
627
- rels = {relation(it["stem"], w, parent_of) for w in writers}
628
775
  out["n"] += 1
629
- it["family"] = ("no-writer" if not rels else "cross" if "unrelated" in rels
630
- else "undecided" if "undecided" in rels else "intra")
631
776
  out[it["family"]] += 1
632
777
  return dict(out)
633
778
 
@@ -786,12 +931,13 @@ def _rate(k, n):
786
931
  "ci95": [round(ci[0], 4), round(ci[1], 4)] if ci else None}
787
932
 
788
933
 
789
- def report(run, label_files=()):
790
- summary = _summary(run)
791
- out = {k: summary[k] for k in ("run", "cutoff", "cutoff_local", "ledgers", "candidates",
792
- "excluded", "items", "joined", "join",
793
- "stop_text_fidelity", "fidelity", "race_family",
934
+ def report(run, label_files=(), split=None):
935
+ summary = _split_view(_summary(run), run, split)
936
+ out = {k: summary[k] for k in ("run", "split", "sessions", "cutoff", "cutoff_local",
937
+ "ledgers", "candidates", "excluded", "items", "joined",
938
+ "join", "stop_text_fidelity", "fidelity", "race_family",
794
939
  "deny_runs")}
940
+ out["residual"] = summary.get("residual")
795
941
  keyfile = os.path.join(run, "sheet-key.jsonl")
796
942
  labels = _labels(label_files)
797
943
  raters = sorted(labels)
@@ -803,7 +949,9 @@ def report(run, label_files=()):
803
949
  out["labels"] = "unverifiable: %s" % ("no sheet drawn" if not os.path.exists(keyfile)
804
950
  else "no labels file")
805
951
  return out
806
- key = {(k["set"], k["n"]): k for k in _read_jsonl(keyfile)}
952
+ items = {it["i"]: it for it in _read_jsonl(os.path.join(run, "corpus.jsonl"))}
953
+ key = {(k["set"], k["n"]): k for k in _read_jsonl(keyfile)
954
+ if not split or split_of(items.get(k["item"], {}).get("sid")) == split}
807
955
  usable = {}
808
956
  if len(raters) >= 2:
809
957
  a, b = labels[raters[0]], labels[raters[1]]
@@ -826,7 +974,6 @@ def report(run, label_files=()):
826
974
  return out
827
975
  rules = defaultdict(Counter)
828
976
  stop = Counter()
829
- items = {it["i"]: it for it in _read_jsonl(os.path.join(run, "corpus.jsonl"))}
830
977
  race_intra = Counter()
831
978
  for x, label in usable.items():
832
979
  k = key[x]
@@ -844,8 +991,10 @@ def report(run, label_files=()):
844
991
  stop[side + ":hit"] += label == ("false" if side == "allow" else "honest")
845
992
  # a rule whose joined denies fell short of its quota is pooled with the
846
993
  # other rules (H2 protocol, stated before the draw); a rule with no item on
847
- # the sheet is neither pooled nor rated (H2 amendment 2026-10-06)
848
- drawn = Counter(k["bucket"] for k in key.values())
994
+ # the sheet is neither pooled nor rated (H2 amendment 2026-10-06). The draw
995
+ # is a property of the whole sheet, so a --split side counts it unsplit: one
996
+ # side holds about half of a rule's quota and would read as short.
997
+ drawn = Counter(k["bucket"] for k in _read_jsonl(keyfile))
849
998
  short = [(b.split(":", 1)[1], drawn[b]) for b, q in QUOTA.items()
850
999
  if b.startswith("deny:") and b != "deny:other" and drawn[b] < q]
851
1000
  out["pooled"] = sorted(rule for rule, n in short if n)
@@ -979,6 +1128,8 @@ def label_model(run, prompt_file, provider="jev", model=None):
979
1128
 
980
1129
  def _print_summary(s):
981
1130
  print("replay run: %s" % s["run"])
1131
+ if "split" in s:
1132
+ print("split: %s (%d sessions)" % (s["split"], s["sessions"]))
982
1133
  print("cutoff: %s (epoch %d)" % (s["cutoff_local"], s["cutoff"]))
983
1134
  led = s["ledgers"]
984
1135
  print("ledgers: read %d, fixture %d, test-polluted %d, kept %d" % (
@@ -1008,6 +1159,22 @@ def _print_summary(s):
1008
1159
  "sessions with %d+ denies: %d" % (
1009
1160
  runs["run_min"], runs["runs"] or "none", runs["longest"] or "none",
1010
1161
  runs["session_min"], runs["sessions_over"]))
1162
+ res = s.get("residual")
1163
+ if res:
1164
+ print("residual sections%s: %d turns (%d with work); %d with a residual section "
1165
+ "(%d in work turns), %d sections (%s per turn); %d work turns with an "
1166
+ "unblocked item; %d question endings" % (
1167
+ " (whole corpus)" if s.get("split") not in (None, "all") else "",
1168
+ res["turns"], res["work_turns"], res["residual_turns"],
1169
+ res["residual_work_turns"], res["sections"],
1170
+ "n/a" if res["per_turn"] is None else "%.3f" % res["per_turn"],
1171
+ res["unblocked_work_turns"], res["question_endings"]))
1172
+ # a summary written before plan 086 S3 carries no ask counters
1173
+ print("ask policy: %d process questions, %d cheap answers after a question, "
1174
+ "%d ask-tool questions without a default, %d outward turns without a "
1175
+ "grant" % tuple(res.get(k, 0) for k in (
1176
+ "process_questions", "cheap_answers", "asks_without_default",
1177
+ "outward_without_grant")))
1011
1178
 
1012
1179
 
1013
1180
  def _pct(k, n):
@@ -1052,6 +1219,7 @@ def main(argv):
1052
1219
  p.add_argument("--labels", action="append", default=[])
1053
1220
  p.add_argument("--seed", type=int, default=SEED)
1054
1221
  p.add_argument("--rules")
1222
+ p.add_argument("--split", choices=("search", "holdout"))
1055
1223
  p.add_argument("--json", action="store_true")
1056
1224
  args = p.parse_args(argv)
1057
1225
  if args.label_model:
@@ -1077,7 +1245,7 @@ def main(argv):
1077
1245
  print("race denies left off (written before the ledger stored `target`): %d" % left)
1078
1246
  return 0
1079
1247
  if args.report:
1080
- out = report(_run_dir(args.run), args.labels)
1248
+ out = report(_run_dir(args.run), args.labels, args.split)
1081
1249
  if args.json:
1082
1250
  print(json.dumps(out, indent=2))
1083
1251
  return 0
@@ -1085,6 +1253,8 @@ def main(argv):
1085
1253
  _print_labels(out)
1086
1254
  return 0
1087
1255
  summary = run_replay(_when(args.cutoff), _when(args.since))
1256
+ if args.split:
1257
+ summary = _split_view(summary, summary["run"], args.split)
1088
1258
  print(json.dumps(summary, indent=2) if args.json else "", end="")
1089
1259
  if not args.json:
1090
1260
  _print_summary(summary)