@chrono-meta/fh-gate 3.0.0 → 3.1.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 (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +43 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +97 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,579 @@
1
+ #!/usr/bin/env python3
2
+ """probe_live_eval_lib.py — parsing + scoring core for scripts/probe_live_eval.sh.
3
+
4
+ Split out of the .sh entrypoint on purpose: the lane test
5
+ (scripts/test_probe_live_eval_lanes.sh) needs to calibrate the SCORER against known-pair fixture
6
+ text — PASS/FAIL/UNCALIBRATED/FAILED-TO-RUN — without ever invoking `claude`. A pure, import-safe
7
+ module makes that a direct function call instead of a shell-out with a live API dependency.
8
+
9
+ No third-party deps (no pyyaml) — `probes_live.yaml` is deliberately NOT full YAML; it's a fixed,
10
+ hand-authored subset this module parses with a small line-based reader (see `parse_probes_live`).
11
+ If that format ever needs real YAML nesting, switch formats deliberately — don't let a stdlib-only
12
+ parser silently start guessing.
13
+ """
14
+ import json
15
+ import re
16
+ import sys
17
+ import os
18
+ import glob
19
+ from datetime import date
20
+
21
+ ID_RE = re.compile(r'^[A-Z][A-Z0-9]*-[A-Z0-9]+-[0-9]+$')
22
+ UTTERANCE_RE = re.compile(r'`[^`]+`|"[^"]+"')
23
+ VALID_CLASSES = ('mandatory-pass', 'measured', 'judged')
24
+
25
+ # The one judgment call the mechanical rule (class + quoted-literal) cannot make: these rows ARE
26
+ # backtick-quoted (`npm test`, `npm publish`) but the quoted text is a SHELL COMMAND a CI job runs,
27
+ # not something a user types to Claude in conversation. Passing it as --prompt would test "does
28
+ # Claude talk about npm test", not "does npm test actually gate publish" — a different question the
29
+ # probe was never about. Named here, not folded into the regex, so it stays greppable as a decision
30
+ # rather than disappearing into pattern tuning.
31
+ CLI_EVENT_EXCLUDE = {"G-CODE-01", "G-CODE-02", "G-CODE-03"}
32
+
33
+ # The second judgment call, and a different one: these rows are perfectly good CHAT utterances, but
34
+ # the behavior they check is delegated to a surface THE ARM DOES NOT HAVE. G-TRIG-03 is the measured
35
+ # case (2026-09-06): its probes.md rationale cites CLAUDE.md's Autonomous-Initiative table, but the
36
+ # `harness-doctor` row was deliberately REMOVED from that table in the 2026-07-17 row diet and
37
+ # delegated to the skill's own frontmatter `description`. The arm runs with
38
+ # `--tools "Read,Grep,Glob"` and no Skill tool — a known-pair confirmed it ("list every Skill
39
+ # available to you" -> NO-SKILL-TOOL, while the tool-listing control answered correctly) — so the
40
+ # probe was scoring a route that cannot exist in its own environment. It failed 0/5 for that reason
41
+ # and for no other.
42
+ #
43
+ # 🟥 EXCLUDING IT DOES NOT MEAN THE HARNESS IS FINE. "Does the row-diet delegation actually fire at
44
+ # the floor tier?" is now UNMEASURED, not answered — that question needs a different instrument (an
45
+ # arm that can see the skill layer), and building one re-calibrates all 12 probes, so it is not a
46
+ # calibration-week change. Named here so the gap stays greppable instead of dissolving into a
47
+ # permanently-red lane nobody reads.
48
+ ARM_CAPABILITY_EXCLUDE = {"G-TRIG-03"}
49
+
50
+
51
+ def parse_probes_md(path):
52
+ """Return [{id, input, class, raw_class}] for every table-row probe in probes.md.
53
+
54
+ Markdown-table split by '|', not a markdown library — probes.md cells contain no literal
55
+ pipe characters (checked: the file is hand-authored prose + code spans only), so this is a
56
+ faithful parse, not a heuristic one. Rows are recognized by column-1 matching ID_RE after
57
+ stripping backticks; the header row, the `|---|` separator, and prose lines that happen to
58
+ start with '|' (none currently do) are excluded by that same test.
59
+ """
60
+ rows = []
61
+ with open(path, encoding='utf-8') as f:
62
+ for line in f:
63
+ line = line.rstrip('\n')
64
+ if not line.startswith('|'):
65
+ continue
66
+ cells = [c.strip() for c in line.split('|')]
67
+ # split('|') on "| a | b | c | d | e |" yields ['', a, b, c, d, e, ''] — 7 elements.
68
+ if len(cells) != 7:
69
+ continue
70
+ id_cell = cells[1].strip('`').strip()
71
+ if not ID_RE.match(id_cell):
72
+ continue
73
+ input_cell = cells[2]
74
+ class_cell = cells[5]
75
+ class_tok = class_cell.split('—')[0].strip()
76
+ rows.append({
77
+ 'id': id_cell,
78
+ 'input': input_cell,
79
+ 'class': class_tok,
80
+ 'raw_class': class_cell,
81
+ })
82
+ return rows
83
+
84
+
85
+ def parse_probes_live(path):
86
+ """Fixed-format reader for probes_live.yaml's `- id: ...` / ` key: value` block shape.
87
+
88
+ Deliberately not a general YAML parser (no pyyaml dependency, see module docstring). Values
89
+ are taken verbatim after the first ':' and unquoted if wrapped in matching double-quotes —
90
+ this is why every value in probes_live.yaml that could contain a literal colon is avoided by
91
+ convention (documented in that file's header) rather than escaped here.
92
+ """
93
+ probes = []
94
+ cur = None
95
+ with open(path, encoding='utf-8') as f:
96
+ for raw in f:
97
+ line = raw.rstrip('\n')
98
+ stripped = line.strip()
99
+ if not stripped or stripped.startswith('#'):
100
+ continue
101
+ if stripped == 'probes:':
102
+ continue
103
+ if stripped.startswith('- id:'):
104
+ if cur is not None:
105
+ probes.append(cur)
106
+ cur = {'id': _unquote(stripped[len('- id:'):].strip())}
107
+ continue
108
+ if cur is None:
109
+ continue
110
+ m = re.match(r'^([a-z_]+):\s*(.*)$', stripped)
111
+ if not m:
112
+ continue
113
+ key, val = m.group(1), _unquote(m.group(2).strip())
114
+ cur[key] = val
115
+ if cur is not None:
116
+ probes.append(cur)
117
+ return probes
118
+
119
+
120
+ def _unquote(s):
121
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
122
+ return s[1:-1]
123
+ return s
124
+
125
+
126
+ def classify(id_, input_pat, class_tok):
127
+ """Apply the mechanical selection rule. Returns (bucket, reason) — reason is None iff
128
+ bucket == 'SELECTABLE'."""
129
+ if class_tok == 'judged':
130
+ return 'EXCLUDED', 'JUDGED'
131
+ if class_tok not in VALID_CLASSES:
132
+ return 'EXCLUDED', 'UNKNOWN-CLASS(%s)' % (class_tok or '<empty>')
133
+ if input_pat.strip().startswith('[INERT-ANCHOR]'):
134
+ return 'EXCLUDED', 'INERT-ANCHOR'
135
+ if id_ in CLI_EVENT_EXCLUDE:
136
+ return 'EXCLUDED', 'NOT-CHAT-UTTERANCE (shell command, not a chat turn)'
137
+ if id_ in ARM_CAPABILITY_EXCLUDE:
138
+ return 'EXCLUDED', 'NOT-LIVE-MEASURABLE (skill-description route; the arm has no Skill tool)'
139
+ if not UTTERANCE_RE.search(input_pat):
140
+ return 'EXCLUDED', 'NO-UTTERANCE (no quoted/backticked literal a user would type)'
141
+ return 'SELECTABLE', None
142
+
143
+
144
+ def build_selection(probes_md_rows, live_rows):
145
+ """Cross-reference probes.md (source of truth for WHICH probes exist and their class) against
146
+ probes_live.yaml (source of truth for HOW to score the selected ones).
147
+
148
+ Returns dict: selected [{id,input,control_input,polarity,expect_re}], excluded [{id,reason}],
149
+ warnings [str] — warnings never block a run, they name drift between the two files.
150
+ """
151
+ live_by_id = {p['id']: p for p in live_rows}
152
+ md_ids = set()
153
+ selected, excluded, warnings = [], [], []
154
+
155
+ for row in probes_md_rows:
156
+ md_ids.add(row['id'])
157
+ bucket, reason = classify(row['id'], row['input'], row['class'])
158
+ if bucket == 'EXCLUDED':
159
+ in_yaml = row['id'] in live_by_id
160
+ excluded.append({'id': row['id'], 'reason': reason, 'in_probes_live': in_yaml})
161
+ if in_yaml:
162
+ warnings.append(
163
+ "STALE-YAML-ENTRY: %s is authored in probes_live.yaml but the mechanical rule "
164
+ "excludes it (%s) — re-curate or remove it." % (row['id'], reason))
165
+ continue
166
+ # SELECTABLE per the mechanical rule.
167
+ if row['id'] not in live_by_id:
168
+ excluded.append({'id': row['id'], 'reason': 'NOT-YET-AUTHORED', 'in_probes_live': False})
169
+ warnings.append(
170
+ "NOT-YET-AUTHORED: %s passes the mechanical selection rule but has no entry in "
171
+ "probes_live.yaml — add polarity/expect_re/control_input by hand to include it."
172
+ % row['id'])
173
+ continue
174
+ spec = live_by_id[row['id']]
175
+ missing = [k for k in ('polarity', 'input', 'expect_re', 'control_input') if k not in spec]
176
+ if missing:
177
+ excluded.append({'id': row['id'], 'reason': 'INCOMPLETE-YAML-ENTRY(%s)' % ','.join(missing),
178
+ 'in_probes_live': True})
179
+ warnings.append("INCOMPLETE-YAML-ENTRY: %s is missing field(s): %s"
180
+ % (row['id'], ','.join(missing)))
181
+ continue
182
+ if spec['polarity'] not in ('present', 'absent'):
183
+ excluded.append({'id': row['id'], 'reason': 'BAD-POLARITY(%s)' % spec['polarity'],
184
+ 'in_probes_live': True})
185
+ warnings.append("BAD-POLARITY: %s declares polarity=%r (must be present|absent)"
186
+ % (row['id'], spec['polarity']))
187
+ continue
188
+ selected.append({
189
+ 'id': row['id'],
190
+ 'input': spec['input'],
191
+ 'control_input': spec['control_input'],
192
+ 'polarity': spec['polarity'],
193
+ 'expect_re': spec['expect_re'],
194
+ })
195
+
196
+ # Dead-pointer guard: an id in probes_live.yaml that does not exist in probes.md AT ALL.
197
+ for p in live_rows:
198
+ if p['id'] not in md_ids:
199
+ warnings.append(
200
+ "DEAD-POINTER: %s is in probes_live.yaml but does not exist in probes.md — "
201
+ "probes.md is the source of truth, remove or fix the id." % p['id'])
202
+
203
+ return {'selected': selected, 'excluded': excluded, 'warnings': warnings}
204
+
205
+
206
+ def filter_selection(selected, subset=None, ids=None):
207
+ """Apply --subset N (first N in file order) or --ids P1,P2 (exact set, order preserved from
208
+ the request; unknown ids are reported, not silently dropped)."""
209
+ if ids:
210
+ wanted = [i.strip() for i in ids.split(',') if i.strip()]
211
+ by_id = {p['id']: p for p in selected}
212
+ out, unknown = [], []
213
+ for w in wanted:
214
+ if w in by_id:
215
+ out.append(by_id[w])
216
+ else:
217
+ unknown.append(w)
218
+ return out, unknown
219
+ if subset:
220
+ n = int(subset)
221
+ return selected[:n], []
222
+ return selected, []
223
+
224
+
225
+ # ── Scoring ──────────────────────────────────────────────────────────────────────────────────
226
+ FAILED_TO_RUN = 'FAILED-TO-RUN'
227
+ UNCALIBRATED = 'UNCALIBRATED'
228
+ PASS = 'PASS'
229
+ FAIL = 'FAIL'
230
+
231
+
232
+ def _read_text(path):
233
+ if not os.path.isfile(path):
234
+ return None
235
+ try:
236
+ with open(path, encoding='utf-8', errors='replace') as f:
237
+ return f.read()
238
+ except OSError:
239
+ return None
240
+
241
+
242
+ def _read_first_line(path):
243
+ """First non-empty line of `path`, or None if the file is absent/empty entirely. Diagnostic
244
+ use only (the `reason` column below) — never feeds score_probe's verdict."""
245
+ text = _read_text(path)
246
+ if not text:
247
+ return None
248
+ for line in text.splitlines():
249
+ line = line.strip()
250
+ if line:
251
+ return line
252
+ return None
253
+
254
+
255
+ def _failure_reason(base, arm):
256
+ """Best-effort one-line reason a FAILED-TO-RUN arm produced nothing to score: the arm's own
257
+ stderr first line (usually the actual shell error, e.g. "timeout: command not found") when
258
+ there is one; otherwise an rc/bytes fallback — rc parsed from the runner's own console log
259
+ (sim_isolated_run.sh prints "(rc=<n>, ...)" there) and bytes from the actual output file's
260
+ size (0 for a file that was never written). Purely a diagnostic label for the report/console —
261
+ never used by score_probe, which stays unchanged."""
262
+ stderr_line = _read_first_line(os.path.join(base, '%s_r1.stderr.txt' % arm))
263
+ if stderr_line:
264
+ return stderr_line
265
+ out_path = os.path.join(base, '%s_r1.txt' % arm)
266
+ try:
267
+ nbytes = os.path.getsize(out_path) if os.path.isfile(out_path) else 0
268
+ except OSError:
269
+ nbytes = 0
270
+ log_text = _read_text(os.path.join(base, '_runner_%s.log' % arm)) or ''
271
+ m = re.search(r'\(rc=(-?\d+)', log_text)
272
+ rc_s = m.group(1) if m else '?'
273
+ return 'rc=%s bytes=%d' % (rc_s, nbytes)
274
+
275
+
276
+ def _md_escape(s):
277
+ """Keep a diagnostic string from breaking a markdown table row — reason text comes from
278
+ arbitrary stderr/log content, which can contain a literal '|' or a newline."""
279
+ return (s or '').replace('|', '\\|').replace('\n', ' ').replace('\r', ' ')
280
+
281
+
282
+ def score_probe(primary_text, control_text, polarity, expect_re):
283
+ """Three-valued-plus-one verdict for one probe. `primary_text`/`control_text` are None when
284
+ the corresponding output file was missing or empty — that is FAILED-TO-RUN, never a silent
285
+ FAIL (an unreachable API and a wrong response are different failure classes; collapsing them
286
+ is the exact defect CLAUDE.md's not-found-is-not-zero memory entry names)."""
287
+ if primary_text is None or control_text is None or primary_text == '' or control_text == '':
288
+ return FAILED_TO_RUN, False, False
289
+ pat = re.compile(expect_re)
290
+ primary_hit = bool(pat.search(primary_text))
291
+ control_hit = bool(pat.search(control_text))
292
+ if polarity == 'present':
293
+ if control_hit:
294
+ # The pattern fired on a KNOWN-NEGATIVE input too — it cannot discriminate, so a
295
+ # primary hit proves nothing. Per-probe calibration failure, not a pass or a fail.
296
+ return UNCALIBRATED, primary_hit, control_hit
297
+ return (PASS if primary_hit else FAIL), primary_hit, control_hit
298
+ else: # polarity == 'absent'
299
+ if not control_hit:
300
+ # The control was supposed to be the case where the pattern DOES fire, proving the
301
+ # instrument can detect it at all. If it never fires here either, primary's silence
302
+ # is not evidence of anything — it could just be a pattern that never matches.
303
+ return UNCALIBRATED, primary_hit, control_hit
304
+ return (PASS if not primary_hit else FAIL), primary_hit, control_hit
305
+
306
+
307
+ def score_run(live_rows, run_root, ids_in_order, threshold, model, reps=1):
308
+ """Read <run_root>/<id>/{primary,control}_r{1..reps}.txt for every id, score EACH rep, take the
309
+ majority, and return a report dict. Does not touch the network or spawn `claude` — pure
310
+ filesystem + regex.
311
+
312
+ WHY MAJORITY AND WHY THE DISTRIBUTION IS KEPT (2026-09-06, measured). Re-scoring three live run
313
+ artifacts found 5 of 12 probes FLAKY: two runs 15 minutes apart, identical `corpus_head_date`,
314
+ identical model and prompts, flipped 4 probes. Observed pass_rate across those runs was
315
+ 0.50 / 0.67 / 0.67 — i.e. the single-rep noise floor is wider than the distance to the 0.80
316
+ threshold, so a reps=1 pass_rate cannot support a threshold decision at all. Majority over
317
+ reps>=3 narrows it; keeping `pass_k/ran_k` in the row keeps the variance visible instead of
318
+ collapsing it into a bare verdict (a verdict with no spread reads the same whether it was 3/3
319
+ or 2/3, and those are different facts).
320
+
321
+ Verdict composition, in this order — each branch exists to keep a distinct non-answer distinct:
322
+ * no rep produced BOTH texts -> FAILED-TO-RUN (the run, not the rule, is the failure)
323
+ * ANY rep scored UNCALIBRATED -> UNCALIBRATED. Deliberately NOT majority-voted: the
324
+ pattern firing on a known-negative even once means
325
+ discrimination is in doubt, and a majority would
326
+ launder that into a pass.
327
+ * otherwise -> PASS iff strict majority of the reps that ran
328
+ """
329
+ by_id = {p['id']: p for p in live_rows}
330
+ reps = max(1, int(reps or 1))
331
+ rows = []
332
+ for pid in ids_in_order:
333
+ spec = by_id.get(pid)
334
+ if spec is None:
335
+ rows.append({'id': pid, 'verdict': 'UNKNOWN-ID', 'primary_hit': None, 'control_hit': None})
336
+ continue
337
+ base = os.path.join(run_root, pid)
338
+ per_rep = [] # [(verdict, phit, chit, primary_text, control_text)]
339
+ for i in range(1, reps + 1):
340
+ pt = _read_text(os.path.join(base, 'primary_r%d.txt' % i))
341
+ ct = _read_text(os.path.join(base, 'control_r%d.txt' % i))
342
+ v, ph, ch = score_probe(pt, ct, spec['polarity'], spec['expect_re'])
343
+ per_rep.append((v, ph, ch, pt, ct))
344
+
345
+ ran_reps = [r for r in per_rep if r[0] != FAILED_TO_RUN]
346
+ uncal = [r for r in per_rep if r[0] == UNCALIBRATED]
347
+ pass_reps = [r for r in per_rep if r[0] == PASS]
348
+
349
+ if not ran_reps:
350
+ verdict = FAILED_TO_RUN
351
+ elif uncal:
352
+ verdict = UNCALIBRATED
353
+ else:
354
+ verdict = PASS if (len(pass_reps) * 2 > len(ran_reps)) else FAIL
355
+
356
+ # primary_hit/control_hit stay single-valued for backward compatibility with the existing
357
+ # report columns and lanes: they report rep 1, and `reps` carries the spread.
358
+ phit, chit = per_rep[0][1], per_rep[0][2]
359
+ row = {'id': pid, 'verdict': verdict, 'primary_hit': phit, 'control_hit': chit,
360
+ 'polarity': spec['polarity'], 'reason': '',
361
+ 'reps': '%d/%d' % (len(pass_reps), len(ran_reps)) if ran_reps else '0/0',
362
+ 'reps_requested': reps,
363
+ 'rep_verdicts': [r[0] for r in per_rep]}
364
+ if len(ran_reps) > 1 and 0 < len(pass_reps) < len(ran_reps):
365
+ row['reason'] = 'FLAKY across reps (%s)' % ','.join(r[0] for r in per_rep)
366
+ if verdict == FAILED_TO_RUN:
367
+ # Diagnostic only — score_probe already decided the verdict above from exactly the
368
+ # same texts; this never changes it, only explains it. Reported from rep 1.
369
+ reasons = []
370
+ primary_text, control_text = per_rep[0][3], per_rep[0][4]
371
+ if primary_text is None or primary_text == '':
372
+ reasons.append('primary: %s' % _failure_reason(base, 'primary'))
373
+ if control_text is None or control_text == '':
374
+ reasons.append('control: %s' % _failure_reason(base, 'control'))
375
+ row['reason'] = '; '.join(reasons)
376
+ rows.append(row)
377
+
378
+ failed_to_run = [r for r in rows if r['verdict'] == FAILED_TO_RUN]
379
+ uncalibrated = [r for r in rows if r['verdict'] == UNCALIBRATED]
380
+ ran = [r for r in rows if r['verdict'] in (PASS, FAIL)]
381
+ passed = [r for r in rows if r['verdict'] == PASS]
382
+
383
+ if uncalibrated:
384
+ overall = 'UNCALIBRATED'
385
+ rc = 2
386
+ pass_rate = None
387
+ elif not ran:
388
+ overall = 'NO-PROBES-RAN'
389
+ rc = 2
390
+ pass_rate = None
391
+ else:
392
+ pass_rate = len(passed) / float(len(ran))
393
+ if pass_rate < threshold:
394
+ overall = 'FAIL'
395
+ rc = 1
396
+ else:
397
+ overall = 'PASS'
398
+ rc = 0
399
+
400
+ return {
401
+ 'rows': rows,
402
+ 'total': len(rows),
403
+ 'failed_to_run': len(failed_to_run),
404
+ 'uncalibrated': len(uncalibrated),
405
+ 'ran': len(ran),
406
+ 'passed': len(passed),
407
+ 'pass_rate': pass_rate,
408
+ 'threshold': threshold,
409
+ 'overall': overall,
410
+ 'rc': rc,
411
+ 'model': model,
412
+ }
413
+
414
+
415
+ def render_report_md(select_result, score_result, run_date):
416
+ lines = []
417
+ lines.append("# live_eval — %s" % run_date)
418
+ lines.append("")
419
+ lines.append("Live (behavioral) probe run — see `.claude/regression/probes_live.yaml` for what "
420
+ "each probe checks and why. Static-only coverage lives in `/prompt-regression`; "
421
+ "this file is its live twin's record.")
422
+ lines.append("")
423
+ lines.append("## Selection")
424
+ lines.append("- Selected: %d" % len(select_result['selected']))
425
+ lines.append("- Excluded: %d" % len(select_result['excluded']))
426
+ if select_result['warnings']:
427
+ lines.append("- Warnings: %d (see below)" % len(select_result['warnings']))
428
+ lines.append("")
429
+ if score_result is not None:
430
+ lines.append("## Run — model=%s threshold=%.2f" % (score_result['model'], score_result['threshold']))
431
+ lines.append("")
432
+ lines.append("| Probe | Verdict | reps(pass/ran) | primary_hit | control_hit | polarity | Reason |")
433
+ lines.append("|---|---|---|---|---|---|---|")
434
+ for r in score_result['rows']:
435
+ lines.append("| %s | %s | %s | %s | %s | %s | %s |" % (
436
+ r['id'], r['verdict'], r.get('reps', '-'),
437
+ r.get('primary_hit'), r.get('control_hit'), r.get('polarity', ''),
438
+ _md_escape(r.get('reason', ''))))
439
+ lines.append("")
440
+ pr = score_result['pass_rate']
441
+ pr_s = ("%.2f" % pr) if pr is not None else "n/a"
442
+ lines.append("**Overall: %s** — ran=%d failed_to_run=%d uncalibrated=%d passed=%d pass_rate=%s"
443
+ % (score_result['overall'], score_result['ran'], score_result['failed_to_run'],
444
+ score_result['uncalibrated'], score_result['passed'], pr_s))
445
+ lines.append("")
446
+ lines.append("## Excluded (from probes.md, 33-row snapshot)")
447
+ lines.append("")
448
+ lines.append("| Probe | Reason |")
449
+ lines.append("|---|---|")
450
+ for e in select_result['excluded']:
451
+ lines.append("| %s | %s |" % (e['id'], e['reason']))
452
+ if select_result['warnings']:
453
+ lines.append("")
454
+ lines.append("## Warnings")
455
+ for w in select_result['warnings']:
456
+ lines.append("- %s" % w)
457
+ return "\n".join(lines) + "\n"
458
+
459
+
460
+ # ── CLI ──────────────────────────────────────────────────────────────────────────────────────
461
+ def _cmd_select(args):
462
+ probes_md_rows = parse_probes_md(args.probes_md)
463
+ live_rows = parse_probes_live(args.probes_live)
464
+ result = build_selection(probes_md_rows, live_rows)
465
+ filtered, unknown = filter_selection(result['selected'], subset=args.subset, ids=args.ids)
466
+
467
+ print("── probe_live_eval selection ──────────────────────────────────────")
468
+ print("probes.md rows: %d selectable-per-rule: %d authored-in-yaml: %d"
469
+ % (len(probes_md_rows),
470
+ sum(1 for r in probes_md_rows if classify(r['id'], r['input'], r['class'])[0] == 'SELECTABLE'),
471
+ len(result['selected'])))
472
+ if args.subset or args.ids:
473
+ print("filter applied: %s -> %d probe(s) this run"
474
+ % (('--subset ' + str(args.subset)) if args.subset else ('--ids ' + args.ids), len(filtered)))
475
+ if unknown:
476
+ print("⚠️ unknown ids requested via --ids (not in the live-authored set): %s" % ', '.join(unknown))
477
+ print("")
478
+ print("SELECTED (%d):" % len(filtered))
479
+ for p in filtered:
480
+ print(" %-14s polarity=%-7s input=%r" % (p['id'], p['polarity'], p['input']))
481
+ print("")
482
+ print("EXCLUDED (%d):" % len(result['excluded']))
483
+ for e in result['excluded']:
484
+ print(" %-14s %s" % (e['id'], e['reason']))
485
+ if result['warnings']:
486
+ print("")
487
+ print("WARNINGS (%d):" % len(result['warnings']))
488
+ for w in result['warnings']:
489
+ print(" - %s" % w)
490
+
491
+ if args.json_out:
492
+ with open(args.json_out, 'w', encoding='utf-8') as f:
493
+ json.dump({'selected': filtered, 'excluded': result['excluded'],
494
+ 'warnings': result['warnings'], 'unknown_ids': unknown,
495
+ 'full_selected': result['selected']}, f, ensure_ascii=False, indent=2)
496
+ if args.spec_dir:
497
+ os.makedirs(args.spec_dir, exist_ok=True)
498
+ for p in filtered:
499
+ with open(os.path.join(args.spec_dir, p['id'] + '.input.txt'), 'w', encoding='utf-8') as f:
500
+ f.write(p['input'])
501
+ with open(os.path.join(args.spec_dir, p['id'] + '.control.txt'), 'w', encoding='utf-8') as f:
502
+ f.write(p['control_input'])
503
+ with open(os.path.join(args.spec_dir, 'selected_ids.txt'), 'w', encoding='utf-8') as f:
504
+ for p in filtered:
505
+ f.write(p['id'] + '\n')
506
+
507
+ # Dead pointers and stale yaml entries are authoring bugs, not scoring failures — the lane
508
+ # test asserts this exit code directly (fixture with a deliberately dead id → nonzero).
509
+ dead = [w for w in result['warnings'] if w.startswith('DEAD-POINTER')]
510
+ return 1 if dead else 0
511
+
512
+
513
+ def _cmd_score(args):
514
+ live_rows = parse_probes_live(args.probes_live)
515
+ with open(args.select_json, encoding='utf-8') as f:
516
+ select_result = json.load(f)
517
+ ids_in_order = [line.strip() for line in open(args.ids_file, encoding='utf-8') if line.strip()]
518
+ score_result = score_run(live_rows, args.run_root, ids_in_order, args.threshold, args.model,
519
+ reps=getattr(args, 'reps', 1))
520
+
521
+ print("── probe_live_eval score ──────────────────────────────────────────")
522
+ for r in score_result['rows']:
523
+ line = (" %-14s %-14s primary_hit=%-5s control_hit=%-5s polarity=%s"
524
+ % (r['id'], r['verdict'], r.get('primary_hit'), r.get('control_hit'), r.get('polarity', '')))
525
+ reason = r.get('reason', '')
526
+ if reason:
527
+ line += " reason=%s" % reason
528
+ print(line)
529
+ print("")
530
+ pr = score_result['pass_rate']
531
+ pr_s = ("%.2f" % pr) if pr is not None else "n/a"
532
+ print("total=%d ran=%d failed_to_run=%d uncalibrated=%d passed=%d pass_rate=%s threshold=%.2f"
533
+ % (score_result['total'], score_result['ran'], score_result['failed_to_run'],
534
+ score_result['uncalibrated'], score_result['passed'], pr_s, score_result['threshold']))
535
+ print("OVERALL: %s (rc=%d)" % (score_result['overall'], score_result['rc']))
536
+
537
+ if args.report_out:
538
+ md = render_report_md(select_result, score_result, args.run_date or str(date.today()))
539
+ os.makedirs(os.path.dirname(args.report_out), exist_ok=True)
540
+ with open(args.report_out, 'w', encoding='utf-8') as f:
541
+ f.write(md)
542
+ print("report: %s" % args.report_out)
543
+
544
+ return score_result['rc']
545
+
546
+
547
+ def main():
548
+ import argparse
549
+ ap = argparse.ArgumentParser()
550
+ sub = ap.add_subparsers(dest='cmd', required=True)
551
+
552
+ sp = sub.add_parser('select')
553
+ sp.add_argument('--probes-md', required=True)
554
+ sp.add_argument('--probes-live', required=True)
555
+ sp.add_argument('--subset')
556
+ sp.add_argument('--ids')
557
+ sp.add_argument('--json-out')
558
+ sp.add_argument('--spec-dir')
559
+
560
+ sc = sub.add_parser('score')
561
+ sc.add_argument('--probes-live', required=True)
562
+ sc.add_argument('--select-json', required=True)
563
+ sc.add_argument('--ids-file', required=True)
564
+ sc.add_argument('--run-root', required=True)
565
+ sc.add_argument('--threshold', type=float, required=True)
566
+ sc.add_argument('--model', required=True)
567
+ sc.add_argument('--report-out')
568
+ sc.add_argument('--run-date')
569
+ sc.add_argument('--reps', type=int, default=1)
570
+
571
+ args = ap.parse_args()
572
+ if args.cmd == 'select':
573
+ sys.exit(_cmd_select(args))
574
+ elif args.cmd == 'score':
575
+ sys.exit(_cmd_score(args))
576
+
577
+
578
+ if __name__ == '__main__':
579
+ main()