@r1z4x/tezgah 1.6.0 → 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.
- package/CHANGELOG.md +80 -1
- package/MANIFEST +5 -0
- package/README.md +6 -3
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +234 -10
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +589 -17
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +60 -22
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +346 -13
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +2 -2
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +29 -10
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +425 -406
- package/hooks/tezgah_gate.py +583 -33
- package/hooks/tezgah_integrity.py +1480 -197
- package/hooks/tezgah_models.py +337 -15
- package/hooks/tezgah_paths.py +103 -10
- package/hooks/tezgah_policy.py +258 -126
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1294 -9
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +915 -6
- package/hosts/codex/hook.py +9 -10
- package/hosts/cursor/hook.py +21 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +102 -2
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +5 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +151 -56
package/hooks/tezgah_rank.py
CHANGED
|
@@ -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.
|
|
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(
|
|
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,
|
|
37
|
-
#
|
|
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
|
|
52
|
-
"""
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
70
|
-
for
|
|
62
|
+
out = []
|
|
63
|
+
for d in docs:
|
|
71
64
|
size = sum(d.values())
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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]]
|
package/hooks/tezgah_replay.py
CHANGED
|
@@ -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 ...]
|
|
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
|
-
|
|
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"])
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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", "
|
|
792
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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)
|