@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.
- package/.claude/regression/probes_live.yaml +137 -0
- package/.claude/rules/.residency-patterns.defaults +7 -0
- package/.claude/rules/fh_4axis_gate.md +50 -1
- package/.claude-plugin/marketplace.json +8 -2
- package/AGENTS.md +27 -0
- package/CATALOG.md +17 -0
- package/CLAUDE.md +12 -2
- package/README.ja.md +51 -7
- package/README.ko.md +48 -7
- package/README.md +37 -5
- package/README.zh.md +45 -8
- package/docs/STANDARDS_ALIGNMENT.md +61 -0
- package/docs/USER_GUIDE.md +3 -0
- package/docs/USE_CASES.md +50 -0
- package/docs/model_tier_expectations.md +60 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
- package/package.json +40 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/skills/preprep/README.md +4 -1
- package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
- package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
- package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
- package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
- package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
- package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
- package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
- package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
- package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
- package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +43 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-qp/README.md +71 -0
- package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
- package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
- package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
- package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
- package/plugins/fh-qp/qp_profile.example.yaml +29 -0
- package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
- package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
- package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
- package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
- package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
- package/scripts/chamber_run.sh +14 -5
- package/scripts/com.forge-harness.live-eval.plist +84 -0
- package/scripts/compaction_probe.sh +9 -35
- package/scripts/directional_diff_gate.sh +14 -2
- package/scripts/frontier_digest_autopilot.sh +4 -1
- package/scripts/map_postprocess.py +90 -0
- package/scripts/outbound_query_guard.sh +131 -0
- package/scripts/outbound_query_hook.sh +373 -0
- package/scripts/package_coverage_check.sh +55 -16
- package/scripts/pipe_verdict_guard.sh +41 -1
- package/scripts/probe_live_eval.sh +240 -0
- package/scripts/probe_live_eval_lib.py +579 -0
- package/scripts/proposal_hook.sh +120 -17
- package/scripts/push_zone_check.sh +78 -0
- package/scripts/residency_closure_scan.py +252 -0
- package/scripts/selfcheck.sh +41 -1
- package/scripts/session_close_check.sh +100 -0
- package/scripts/sim_isolated_run.sh +98 -2
- package/scripts/test_action_yml_lanes.sh +97 -0
- package/scripts/test_fh_qp_lanes.sh +105 -0
- package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
- package/scripts/test_map_postprocess_lanes.sh +143 -0
- package/scripts/test_marker_affected_lanes.sh +93 -0
- package/scripts/test_marker_crossfamily_lanes.sh +90 -6
- package/scripts/test_marker_oracle_lanes.sh +136 -0
- package/scripts/test_outbound_query_hook_lanes.sh +433 -0
- package/scripts/test_outbound_query_lanes.sh +87 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
- package/scripts/test_preprep_diagram_lanes.sh +87 -0
- package/scripts/test_preprep_drift_anchor.sh +3 -3
- package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
- package/scripts/test_probe_live_eval_lanes.sh +437 -0
- package/scripts/test_proposal_hook_lanes.sh +22 -1
- package/scripts/test_push_zone_lanes.sh +304 -0
- package/scripts/test_residency_closure_lanes.sh +70 -0
- package/scripts/test_sim_isolated_run_lanes.sh +119 -0
- package/scripts/test_utterance_intake_lanes.sh +414 -0
- package/scripts/test_worktree_reclaim_lanes.sh +70 -0
- package/scripts/transcript_utterances.py +222 -0
- package/scripts/utterance_intake.sh +424 -0
- package/scripts/validate_yaml.sh +27 -0
- package/scripts/worktree_reclaim.sh +95 -0
- package/templates/.git-hooks/pre-commit +353 -1
- package/templates/.git-hooks/pre-push +91 -0
- package/templates/RED_TEAM_REPORT.md +49 -0
- 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()
|