codereview-loop 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
codereview/__init__.py
ADDED
codereview/__main__.py
ADDED
codereview/acceptance.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Tech Lead acceptance runner (refutation-process solution
|
|
2
|
+
§6, owner-adopted 2026-11-04): run the SHADOW Tech Lead over
|
|
3
|
+
the known-answer corpus at ~/workspace/codereview-acceptance/
|
|
4
|
+
and score the adopted gates M1–M6.
|
|
5
|
+
|
|
6
|
+
The corpus is the contract: case sets, scoring rules and
|
|
7
|
+
thresholds are read from the corpus's scorecard.json, never
|
|
8
|
+
hardcoded here. Scoring follows the scorecard's
|
|
9
|
+
scored-decision definition — probes are NOT executed (probe
|
|
10
|
+
results ship inside the cases and are presented as context);
|
|
11
|
+
a needs_probe outcome scores as escalate; a decision whose
|
|
12
|
+
deciding quote does not validate scores as escalate (the
|
|
13
|
+
shadow draw machinery already enforces that normalization).
|
|
14
|
+
|
|
15
|
+
The machinery stratum's isolation rule is honoured by
|
|
16
|
+
construction: shadow adjudication never consults the
|
|
17
|
+
precedent store, and every (case, variant) run uses a fresh
|
|
18
|
+
decision cache.
|
|
19
|
+
|
|
20
|
+
Protocol v2 (`run_acceptance_v2`) scores the redesigned
|
|
21
|
+
adjudicator (techlead.adjudicate) through the SAME scorer
|
|
22
|
+
and the SAME metric computation (`_scorecard_metrics`) —
|
|
23
|
+
M1 is counted by scored decision over the scorecard's
|
|
24
|
+
real-finding set, exactly as the scorecard's
|
|
25
|
+
counting_rules define a false dismissal; the per-case
|
|
26
|
+
`false_dismissal` flag score_case returns is never set
|
|
27
|
+
by the scorer and is not used (it disagreed with the M1
|
|
28
|
+
metric in the v1 run record — the metric follows the
|
|
29
|
+
scorecard, the flag is vestigial). The two adjudication
|
|
30
|
+
paths are reported separately in the v2 record, never
|
|
31
|
+
blended: deterministic-path cases are decided by the
|
|
32
|
+
probe, so they take no verdict draws and fall outside
|
|
33
|
+
M6's measured set, which is stated on the record.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
from . import core
|
|
42
|
+
from . import techlead
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_corpus(corpus_dir):
|
|
46
|
+
def read(name):
|
|
47
|
+
with open(os.path.join(corpus_dir, name),
|
|
48
|
+
encoding="utf-8") as fh:
|
|
49
|
+
return json.load(fh)
|
|
50
|
+
|
|
51
|
+
index = read("index.json")
|
|
52
|
+
cases = {}
|
|
53
|
+
for item in index["cases"]:
|
|
54
|
+
cid = item["case_id"]
|
|
55
|
+
cases[cid] = read(os.path.join("cases", f"{cid}.json"))
|
|
56
|
+
return {"index": index, "scorecard": read("scorecard.json"),
|
|
57
|
+
"mutations": read("mutations.json"), "cases": cases}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _independence(seat_family, raiser_fam, refuter_fam):
|
|
61
|
+
if seat_family in (raiser_fam, refuter_fam, "unknown", None):
|
|
62
|
+
return "reduced"
|
|
63
|
+
return "independent"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def case_inputs(case, seat_family, *, family_swap=False,
|
|
67
|
+
vendor_strip=False, paraphrase=None):
|
|
68
|
+
"""Map a corpus case to (finding, dispute, region,
|
|
69
|
+
independence) for shadow_adjudicate, applying the
|
|
70
|
+
requested mutation to the PRESENTED inputs only — quotes
|
|
71
|
+
and the code region are never mutated."""
|
|
72
|
+
f0 = case["finding"]
|
|
73
|
+
r0 = case["refutation"]
|
|
74
|
+
evidence = f0.get("evidence") or f0.get("claim") or ""
|
|
75
|
+
reason = r0.get("reason") or ""
|
|
76
|
+
if paraphrase is not None:
|
|
77
|
+
evidence, reason = paraphrase
|
|
78
|
+
if vendor_strip:
|
|
79
|
+
evidence = core._scrub_vendor(evidence)
|
|
80
|
+
reason = core._scrub_vendor(reason)
|
|
81
|
+
raiser_fam = f0.get("raised_by_family")
|
|
82
|
+
refuter_fam = r0.get("by_family")
|
|
83
|
+
if family_swap:
|
|
84
|
+
raiser_fam, refuter_fam = refuter_fam, raiser_fam
|
|
85
|
+
finding = {
|
|
86
|
+
"id": case["case_id"],
|
|
87
|
+
"file": f0.get("file"), "line": f0.get("line"),
|
|
88
|
+
"severity": f0.get("severity_as_raised"),
|
|
89
|
+
"title": f0.get("claim") or "",
|
|
90
|
+
"evidence": evidence,
|
|
91
|
+
"evidence_quote": f0.get("evidence_quote") or ""}
|
|
92
|
+
# Cited-line discipline at input-build time (P06): a
|
|
93
|
+
# stored region that does not contain the finding's cited
|
|
94
|
+
# line marks the input region_unverified, so the produced
|
|
95
|
+
# record says loudly that the presented region may not be
|
|
96
|
+
# the finding's site — instead of the seat silently
|
|
97
|
+
# judging a sibling call site.
|
|
98
|
+
if core.region_unverified_for(
|
|
99
|
+
case.get("code_region"), f0.get("line"),
|
|
100
|
+
f0.get("evidence_quote") or ""):
|
|
101
|
+
finding["region_unverified"] = True
|
|
102
|
+
dispute = {
|
|
103
|
+
"by": "refuter", "family": refuter_fam,
|
|
104
|
+
"raiser_family": raiser_fam,
|
|
105
|
+
"evidence_quote": r0.get("quote") or "",
|
|
106
|
+
"reason": reason}
|
|
107
|
+
region = (case.get("code_region") or {}).get("text") or ""
|
|
108
|
+
return finding, dispute, region, _independence(
|
|
109
|
+
seat_family, raiser_fam, refuter_fam)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def scored_decision(entry):
|
|
113
|
+
"""The scored decision per scorecard.json: the entry's
|
|
114
|
+
final decision, with every non-decision state normalized
|
|
115
|
+
to escalate."""
|
|
116
|
+
decision = (entry or {}).get("decision")
|
|
117
|
+
if decision in ("sustain_finding", "sustain_refutation"):
|
|
118
|
+
return decision
|
|
119
|
+
return "escalate"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def scored_severity(entry):
|
|
123
|
+
return ((entry or {}).get("severity_after_rubric") or {}
|
|
124
|
+
).get("level")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def score_case(case, entry):
|
|
128
|
+
"""Score one baseline entry under the case's own scoring
|
|
129
|
+
rule. Returns {"correct", "false_dismissal",
|
|
130
|
+
"scored_decision"}."""
|
|
131
|
+
expected = case.get("expected") or {}
|
|
132
|
+
rule = expected.get("scoring") or "exact"
|
|
133
|
+
scored = scored_decision(entry)
|
|
134
|
+
out = {"correct": False, "false_dismissal": False,
|
|
135
|
+
"scored_decision": scored, "rule": rule}
|
|
136
|
+
if rule == "dismissal_evidence_rule":
|
|
137
|
+
if scored == "escalate":
|
|
138
|
+
out["correct"] = True
|
|
139
|
+
elif scored == "sustain_refutation":
|
|
140
|
+
quote = ((entry.get("deciding_evidence") or {})
|
|
141
|
+
.get("quote") or "")
|
|
142
|
+
region = (case.get("code_region") or {}).get(
|
|
143
|
+
"text") or ""
|
|
144
|
+
refuter_quote = (case.get("refutation") or {}).get(
|
|
145
|
+
"quote") or ""
|
|
146
|
+
f0 = case.get("finding") or {}
|
|
147
|
+
claim_text = (str(f0.get("claim") or "") + "\n"
|
|
148
|
+
+ str(f0.get("evidence") or ""))
|
|
149
|
+
# The same dismissal-evidence rule the draw
|
|
150
|
+
# machinery enforces (L06): grounded, distinct
|
|
151
|
+
# from the refuter's quote, and relevant to the
|
|
152
|
+
# claim — scored here independently so a record
|
|
153
|
+
# produced by any path is judged by the full rule.
|
|
154
|
+
out["correct"] = bool(
|
|
155
|
+
quote
|
|
156
|
+
and core._quote_grounded(quote, region)
|
|
157
|
+
and core._dismissal_quote_check(
|
|
158
|
+
quote, refuter_quote,
|
|
159
|
+
claim_text)["eligible"])
|
|
160
|
+
return out
|
|
161
|
+
if rule == "machinery_two_field":
|
|
162
|
+
want = expected.get("decision")
|
|
163
|
+
if want == "refutation_rejected":
|
|
164
|
+
decision_ok = scored == "sustain_finding"
|
|
165
|
+
else:
|
|
166
|
+
decision_ok = scored == want
|
|
167
|
+
validity = expected.get("reasoning_validity") or {}
|
|
168
|
+
assess = entry.get("reasoning_assessment") or {}
|
|
169
|
+
claim_ok = ("true" if assess.get("claim_valid")
|
|
170
|
+
else "false") == validity.get("claim")
|
|
171
|
+
refutation_ok = assess.get(
|
|
172
|
+
"refutation_validity") == validity.get("refutation")
|
|
173
|
+
out["correct"] = bool(
|
|
174
|
+
decision_ok and claim_ok and refutation_ok)
|
|
175
|
+
out["reasoning_match"] = {
|
|
176
|
+
"claim": claim_ok, "refutation": refutation_ok}
|
|
177
|
+
return out
|
|
178
|
+
# exact
|
|
179
|
+
if scored != expected.get("decision"):
|
|
180
|
+
return out
|
|
181
|
+
want_sev = expected.get("severity_after")
|
|
182
|
+
if want_sev is not None \
|
|
183
|
+
and scored_severity(entry) != want_sev:
|
|
184
|
+
return out
|
|
185
|
+
out["correct"] = True
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_PARAPHRASE_PROMPT = (
|
|
190
|
+
"Paraphrase the following text, preserving every factual "
|
|
191
|
+
"claim and its direction exactly, changing only the "
|
|
192
|
+
"wording. Return ONLY the paraphrased text.\n\n{text}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _paraphrase(seat_fn, text, timeout):
|
|
196
|
+
"""One paraphrase call through the seat itself (the
|
|
197
|
+
mutation protocol allows an LLM paraphrase under a
|
|
198
|
+
semantics-preserving instruction). Plain-text call — the
|
|
199
|
+
JSON job machinery does not apply; the seat's own
|
|
200
|
+
transport timeout bounds it. A failed paraphrase keeps
|
|
201
|
+
the original text and the case is scored as-is."""
|
|
202
|
+
if not text:
|
|
203
|
+
return text
|
|
204
|
+
try:
|
|
205
|
+
out = seat_fn(_PARAPHRASE_PROMPT.format(text=text[:4000]))
|
|
206
|
+
except Exception: # noqa: BLE001
|
|
207
|
+
return text
|
|
208
|
+
return str(out).strip() or text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _scorecard_metrics(cases, baseline, scores, scorecard,
|
|
212
|
+
*, flips, do_mutations, log):
|
|
213
|
+
"""The M1–M6 measurements against the scorecard's own
|
|
214
|
+
sets and thresholds — the single counting implementation
|
|
215
|
+
both protocol runners share, so a v1 and a v2 record can
|
|
216
|
+
never drift apart in what the numbers mean. M1 counts
|
|
217
|
+
false dismissals BY SCORED DECISION over the scorecard's
|
|
218
|
+
real-finding set (the scorecard's counting_rules), never
|
|
219
|
+
by score_case's per-case false_dismissal flag, which the
|
|
220
|
+
scorer never sets. With do_mutations False, M4 is
|
|
221
|
+
measured None and passes False: an unmeasured gate does
|
|
222
|
+
not pass."""
|
|
223
|
+
metrics_spec = {m["id"]: m
|
|
224
|
+
for m in scorecard.get("metrics") or []}
|
|
225
|
+
|
|
226
|
+
def metric_set(mid):
|
|
227
|
+
spec = metrics_spec.get(mid) or {}
|
|
228
|
+
s = spec.get("set")
|
|
229
|
+
if isinstance(s, list):
|
|
230
|
+
return s
|
|
231
|
+
return sorted(cases)
|
|
232
|
+
|
|
233
|
+
def threshold(mid):
|
|
234
|
+
spec = metrics_spec.get(mid) or {}
|
|
235
|
+
return spec.get("threshold") or {}
|
|
236
|
+
|
|
237
|
+
def passes(value, mid):
|
|
238
|
+
th = threshold(mid)
|
|
239
|
+
op, want = th.get("op"), th.get("value")
|
|
240
|
+
if op == "==":
|
|
241
|
+
return value == want
|
|
242
|
+
if op == ">=":
|
|
243
|
+
return value >= want
|
|
244
|
+
if op == "<=":
|
|
245
|
+
return value <= want
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def present(mid, ids, table):
|
|
249
|
+
"""The scorecard's metric set restricted to case ids
|
|
250
|
+
this run actually produced. An explicit set naming
|
|
251
|
+
cases absent from the corpus (mismatch, or an empty
|
|
252
|
+
corpus under the shipped scorecard) must score over
|
|
253
|
+
what exists — with the gap logged — never die on a
|
|
254
|
+
bare KeyError (633acb885291)."""
|
|
255
|
+
kept = [cid for cid in ids if cid in table]
|
|
256
|
+
if len(kept) != len(ids):
|
|
257
|
+
log(f"scorecard {mid}: {len(ids) - len(kept)} "
|
|
258
|
+
f"set case id(s) absent from the corpus — "
|
|
259
|
+
f"scored over {len(kept)} present case(s)")
|
|
260
|
+
return kept
|
|
261
|
+
|
|
262
|
+
# M1: false dismissals on the real-finding set.
|
|
263
|
+
m1_set = present("M1_false_dismissals_on_real_findings",
|
|
264
|
+
metric_set(
|
|
265
|
+
"M1_false_dismissals_on_real_findings"),
|
|
266
|
+
baseline)
|
|
267
|
+
m1 = sum(1 for cid in m1_set
|
|
268
|
+
if scored_decision(baseline[cid])
|
|
269
|
+
== "sustain_refutation")
|
|
270
|
+
# M2: accuracy on the probe-settled set.
|
|
271
|
+
m2_set = present("M2_probe_settled_accuracy",
|
|
272
|
+
metric_set("M2_probe_settled_accuracy"),
|
|
273
|
+
scores)
|
|
274
|
+
m2 = (sum(1 for cid in m2_set if scores[cid]["correct"])
|
|
275
|
+
/ len(m2_set)) if m2_set else 0.0
|
|
276
|
+
# M3: overall accuracy — guarded like M2/M5: an empty
|
|
277
|
+
# corpus measures 0.0, it does not divide by zero.
|
|
278
|
+
m3 = (sum(1 for c in scores.values() if c["correct"])
|
|
279
|
+
/ len(scores)) if scores else 0.0
|
|
280
|
+
# M4: mutation flip rate.
|
|
281
|
+
total_flips = sum(len(v) for v in flips.values())
|
|
282
|
+
denom = len(cases) * len(flips) if do_mutations else 0
|
|
283
|
+
m4 = (total_flips / denom) if denom else None
|
|
284
|
+
# M5: protected + insufficient all escalated.
|
|
285
|
+
m5_set = present(
|
|
286
|
+
"M5_protected_and_insufficient_escalated",
|
|
287
|
+
metric_set("M5_protected_and_insufficient_escalated"),
|
|
288
|
+
baseline)
|
|
289
|
+
m5 = (sum(1 for cid in m5_set
|
|
290
|
+
if scored_decision(baseline[cid]) == "escalate")
|
|
291
|
+
/ len(m5_set)) if m5_set else 0.0
|
|
292
|
+
# M6: double-draw agreement on majors.
|
|
293
|
+
major_ids = [cid for cid, c in cases.items()
|
|
294
|
+
if (c["finding"].get("severity_as_raised")
|
|
295
|
+
in ("blocker", "major"))]
|
|
296
|
+
agreed = [cid for cid in major_ids
|
|
297
|
+
if baseline[cid].get("draws_agree") is True]
|
|
298
|
+
measured = [cid for cid in major_ids
|
|
299
|
+
if baseline[cid].get("draws_agree") is not None]
|
|
300
|
+
m6 = (len(agreed) / len(measured)) if measured else None
|
|
301
|
+
|
|
302
|
+
metrics = {
|
|
303
|
+
"M1_false_dismissals_on_real_findings": {
|
|
304
|
+
"measured": m1, "threshold": threshold(
|
|
305
|
+
"M1_false_dismissals_on_real_findings"),
|
|
306
|
+
"pass": passes(
|
|
307
|
+
m1, "M1_false_dismissals_on_real_findings"),
|
|
308
|
+
"set_size": len(m1_set)},
|
|
309
|
+
"M2_probe_settled_accuracy": {
|
|
310
|
+
"measured": m2, "threshold": threshold(
|
|
311
|
+
"M2_probe_settled_accuracy"),
|
|
312
|
+
"pass": passes(m2, "M2_probe_settled_accuracy"),
|
|
313
|
+
"set_size": len(m2_set)},
|
|
314
|
+
"M3_overall_accuracy": {
|
|
315
|
+
"measured": m3,
|
|
316
|
+
"threshold": threshold("M3_overall_accuracy"),
|
|
317
|
+
"pass": passes(m3, "M3_overall_accuracy"),
|
|
318
|
+
"set_size": len(scores)},
|
|
319
|
+
"M4_mutation_flip_rate": {
|
|
320
|
+
"measured": m4,
|
|
321
|
+
"threshold": threshold("M4_mutation_flip_rate"),
|
|
322
|
+
"pass": bool(do_mutations) and m4 is not None
|
|
323
|
+
and passes(m4, "M4_mutation_flip_rate"),
|
|
324
|
+
"flips": flips, "denominator": denom},
|
|
325
|
+
"M5_protected_and_insufficient_escalated": {
|
|
326
|
+
"measured": m5, "threshold": threshold(
|
|
327
|
+
"M5_protected_and_insufficient_escalated"),
|
|
328
|
+
"pass": passes(
|
|
329
|
+
m5, "M5_protected_and_insufficient_escalated"),
|
|
330
|
+
"set_size": len(m5_set)},
|
|
331
|
+
"M6_double_draw_agreement_on_majors": {
|
|
332
|
+
"measured": m6, "threshold": threshold(
|
|
333
|
+
"M6_double_draw_agreement_on_majors"),
|
|
334
|
+
"pass": m6 is not None and passes(
|
|
335
|
+
m6, "M6_double_draw_agreement_on_majors"),
|
|
336
|
+
"majors": len(major_ids),
|
|
337
|
+
"measured_cases": len(measured),
|
|
338
|
+
"agreed_cases": len(agreed)},
|
|
339
|
+
}
|
|
340
|
+
return metrics
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run_acceptance(corpus_dir, seat_fn, *, seat_family,
|
|
344
|
+
seat_name="acceptance-tech-lead",
|
|
345
|
+
do_mutations=True, timeout=180, log=None):
|
|
346
|
+
"""Run the full acceptance protocol and return the run
|
|
347
|
+
record: per-case entries and scores, mutation flips, and
|
|
348
|
+
the M1–M6 measurements against the scorecard's own
|
|
349
|
+
thresholds. The unlock verdict requires EVERY gate metric
|
|
350
|
+
to pass on this single run."""
|
|
351
|
+
log = log or (lambda _msg: None)
|
|
352
|
+
corpus = load_corpus(corpus_dir)
|
|
353
|
+
cases = corpus["cases"]
|
|
354
|
+
scorecard = corpus["scorecard"]
|
|
355
|
+
metrics_spec = {m["id"]: m
|
|
356
|
+
for m in scorecard.get("metrics") or []}
|
|
357
|
+
|
|
358
|
+
def adjudicate(case, **kw):
|
|
359
|
+
finding, dispute, region, indep = case_inputs(
|
|
360
|
+
case, seat_family, **kw)
|
|
361
|
+
return core.shadow_adjudicate(
|
|
362
|
+
seat_fn, finding, region, dispute,
|
|
363
|
+
seat_name=seat_name, seat_family=seat_family,
|
|
364
|
+
independence=indep, probe_fn=None,
|
|
365
|
+
probe_context=case.get("probe"), cache={},
|
|
366
|
+
timeout=timeout)
|
|
367
|
+
|
|
368
|
+
baseline = {}
|
|
369
|
+
scores = {}
|
|
370
|
+
for cid in sorted(cases):
|
|
371
|
+
entry = adjudicate(cases[cid])
|
|
372
|
+
baseline[cid] = entry
|
|
373
|
+
scores[cid] = score_case(cases[cid], entry)
|
|
374
|
+
log(f"baseline {cid}: {scored_decision(entry)} "
|
|
375
|
+
f"(correct={scores[cid]['correct']})")
|
|
376
|
+
|
|
377
|
+
mutation_ids = [m["id"] for m in
|
|
378
|
+
(corpus["mutations"].get("mutations") or [])]
|
|
379
|
+
flips = {mid: [] for mid in mutation_ids}
|
|
380
|
+
paraphrases = {}
|
|
381
|
+
if do_mutations:
|
|
382
|
+
for cid in sorted(cases):
|
|
383
|
+
case = cases[cid]
|
|
384
|
+
base = (scored_decision(baseline[cid]),
|
|
385
|
+
scored_severity(baseline[cid]))
|
|
386
|
+
for mid in mutation_ids:
|
|
387
|
+
if mid == "position_swap":
|
|
388
|
+
# Force the opposite A/B order relative to
|
|
389
|
+
# the baseline draw for this case.
|
|
390
|
+
finding, dispute, region, indep = \
|
|
391
|
+
case_inputs(case, seat_family)
|
|
392
|
+
entry = core.shadow_adjudicate(
|
|
393
|
+
seat_fn, finding, region, dispute,
|
|
394
|
+
seat_name=seat_name,
|
|
395
|
+
seat_family=seat_family,
|
|
396
|
+
independence=indep, probe_fn=None,
|
|
397
|
+
probe_context=case.get("probe"),
|
|
398
|
+
cache={}, timeout=timeout,
|
|
399
|
+
force_swap=True)
|
|
400
|
+
elif mid == "family_swap":
|
|
401
|
+
entry = adjudicate(case, family_swap=True)
|
|
402
|
+
elif mid == "vendor_strip":
|
|
403
|
+
entry = adjudicate(case, vendor_strip=True)
|
|
404
|
+
elif mid == "reason_paraphrase":
|
|
405
|
+
pair = (
|
|
406
|
+
_paraphrase(
|
|
407
|
+
seat_fn,
|
|
408
|
+
case["finding"].get("evidence")
|
|
409
|
+
or case["finding"].get("claim")
|
|
410
|
+
or "", timeout),
|
|
411
|
+
_paraphrase(
|
|
412
|
+
seat_fn,
|
|
413
|
+
case["refutation"].get("reason")
|
|
414
|
+
or "", timeout))
|
|
415
|
+
paraphrases[cid] = {
|
|
416
|
+
"evidence": pair[0], "reason": pair[1]}
|
|
417
|
+
entry = adjudicate(case, paraphrase=pair)
|
|
418
|
+
else:
|
|
419
|
+
continue
|
|
420
|
+
now = (scored_decision(entry),
|
|
421
|
+
scored_severity(entry))
|
|
422
|
+
if now != base:
|
|
423
|
+
flips[mid].append(cid)
|
|
424
|
+
log(f"mutation {mid} {cid}: {now[0]} "
|
|
425
|
+
f"(flip={now != base})")
|
|
426
|
+
|
|
427
|
+
metrics = _scorecard_metrics(
|
|
428
|
+
cases, baseline, scores, scorecard,
|
|
429
|
+
flips=flips, do_mutations=do_mutations,
|
|
430
|
+
log=log)
|
|
431
|
+
# An empty corpus unlocks nothing, whatever the vacuous
|
|
432
|
+
# metrics say (633acb885291).
|
|
433
|
+
unlock = bool(cases) and all(
|
|
434
|
+
m["pass"] for m in metrics.values())
|
|
435
|
+
return {
|
|
436
|
+
"seat_family": seat_family, "seat_name": seat_name,
|
|
437
|
+
"corpus": corpus_dir,
|
|
438
|
+
"protocol_version": core.ADJUDICATION_PROTOCOL_VERSION,
|
|
439
|
+
"rubric_version": core.RUBRIC_VERSION,
|
|
440
|
+
"cases": {cid: {"entry": baseline[cid],
|
|
441
|
+
"score": scores[cid]}
|
|
442
|
+
for cid in sorted(cases)},
|
|
443
|
+
"paraphrases": paraphrases,
|
|
444
|
+
"metrics": metrics,
|
|
445
|
+
"unlock_all_gates_pass": unlock}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ------------------------------------------------------- protocol v2
|
|
449
|
+
|
|
450
|
+
def adapt_v2_entry(entry):
|
|
451
|
+
"""Map one protocol-v2 entry (techlead.adjudicate) onto
|
|
452
|
+
the shape score_case / _scorecard_metrics consume. The
|
|
453
|
+
mapping is field-for-field and invents nothing:
|
|
454
|
+
|
|
455
|
+
decision <- entry decision (None unless the
|
|
456
|
+
harness decided; the scorer
|
|
457
|
+
normalises non-decisions to
|
|
458
|
+
"escalate", as in v1)
|
|
459
|
+
severity_after_rubric<- entry severity_after_rubric
|
|
460
|
+
deciding_evidence <- entry deciding_evidence (the
|
|
461
|
+
dismissal-evidence scoring rule
|
|
462
|
+
reads its quote)
|
|
463
|
+
draws_agree <- entry draws_agree (None on the
|
|
464
|
+
deterministic path and on
|
|
465
|
+
routing escalations: those cases
|
|
466
|
+
take no verdict draws)
|
|
467
|
+
reasoning_assessment <- entry reasoning_assessment —
|
|
468
|
+
the seat's OWN two-field grading,
|
|
469
|
+
recorded from the canonical draw.
|
|
470
|
+
A v2 entry without one (the
|
|
471
|
+
deterministic path takes no
|
|
472
|
+
verdict draw) adapts to
|
|
473
|
+
claim_valid False /
|
|
474
|
+
refutation_validity None, which
|
|
475
|
+
the two-field rule scores as a
|
|
476
|
+
mismatch: the adapter reports the
|
|
477
|
+
absence, it does not paper over it.
|
|
478
|
+
|
|
479
|
+
Every other v2 field (path, escalation rules, probe
|
|
480
|
+
adequacy, stage, draws) rides along untouched, so the run
|
|
481
|
+
record keeps the full v2 evidence next to the scores."""
|
|
482
|
+
out = dict(entry or {})
|
|
483
|
+
out["decision"] = out.get("decision")
|
|
484
|
+
sev = out.get("severity_after_rubric")
|
|
485
|
+
if not isinstance(sev, dict) or "level" not in sev:
|
|
486
|
+
out["severity_after_rubric"] = {
|
|
487
|
+
"level": None, "rubric_clause": None}
|
|
488
|
+
de = out.get("deciding_evidence")
|
|
489
|
+
if not isinstance(de, dict):
|
|
490
|
+
out["deciding_evidence"] = {"quote": ""}
|
|
491
|
+
assess = out.get("reasoning_assessment")
|
|
492
|
+
if not isinstance(assess, dict):
|
|
493
|
+
out["reasoning_assessment"] = {
|
|
494
|
+
"claim_valid": False, "refutation_validity": None}
|
|
495
|
+
else:
|
|
496
|
+
out["reasoning_assessment"] = {
|
|
497
|
+
"claim_valid": bool(assess.get("claim_valid")),
|
|
498
|
+
"refutation_validity":
|
|
499
|
+
assess.get("refutation_validity")}
|
|
500
|
+
return out
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _path_metrics(cases, baseline, scores, scorecard):
|
|
504
|
+
"""Per-path results for a v2 run record — reported
|
|
505
|
+
SEPARATELY, never blended (§3.3.2): the deterministic
|
|
506
|
+
path's numbers describe probe settlement + adequacy
|
|
507
|
+
grading; the model path's describe seat judgement.
|
|
508
|
+
Entries whose path is neither (protected-class and
|
|
509
|
+
independence routing escalations, seat unavailable) are
|
|
510
|
+
counted in their own bucket so the two path totals plus
|
|
511
|
+
the bucket reconcile to the corpus size."""
|
|
512
|
+
metrics_spec = {m["id"]: m
|
|
513
|
+
for m in scorecard.get("metrics") or []}
|
|
514
|
+
spec = metrics_spec.get(
|
|
515
|
+
"M1_false_dismissals_on_real_findings") or {}
|
|
516
|
+
m1_set = spec.get("set")
|
|
517
|
+
if not isinstance(m1_set, list):
|
|
518
|
+
m1_set = []
|
|
519
|
+
out = {}
|
|
520
|
+
raw = [baseline[cid] for cid in sorted(baseline)]
|
|
521
|
+
for path in ("deterministic_probe", "model"):
|
|
522
|
+
sel = [cid for cid in sorted(baseline)
|
|
523
|
+
if baseline[cid].get("path") == path]
|
|
524
|
+
rules = {}
|
|
525
|
+
for cid in sel:
|
|
526
|
+
for rule in baseline[cid].get(
|
|
527
|
+
"escalation_rules") or []:
|
|
528
|
+
rules[rule] = rules.get(rule, 0) + 1
|
|
529
|
+
out[path] = {
|
|
530
|
+
"total": len(sel),
|
|
531
|
+
"correct": sum(
|
|
532
|
+
1 for cid in sel if scores[cid]["correct"]),
|
|
533
|
+
"accuracy": (
|
|
534
|
+
sum(1 for cid in sel if scores[cid]["correct"])
|
|
535
|
+
/ len(sel)) if sel else None,
|
|
536
|
+
"false_dismissals_on_m1_set": sum(
|
|
537
|
+
1 for cid in sel if cid in m1_set
|
|
538
|
+
and scored_decision(baseline[cid])
|
|
539
|
+
== "sustain_refutation"),
|
|
540
|
+
"escalated": sum(
|
|
541
|
+
1 for cid in sel
|
|
542
|
+
if scored_decision(baseline[cid])
|
|
543
|
+
== "escalate"),
|
|
544
|
+
"escalation_rules_fired": rules,
|
|
545
|
+
"cases": sel,
|
|
546
|
+
}
|
|
547
|
+
other = [cid for cid in sorted(baseline)
|
|
548
|
+
if baseline[cid].get("path")
|
|
549
|
+
not in ("deterministic_probe", "model")]
|
|
550
|
+
out["routing_or_unavailable"] = {
|
|
551
|
+
"total": len(other),
|
|
552
|
+
"correct": sum(
|
|
553
|
+
1 for cid in other if scores[cid]["correct"]),
|
|
554
|
+
"accuracy": (
|
|
555
|
+
sum(1 for cid in other if scores[cid]["correct"])
|
|
556
|
+
/ len(other)) if other else None,
|
|
557
|
+
"cases": other,
|
|
558
|
+
}
|
|
559
|
+
out["summary"] = techlead.summarise_paths(raw)
|
|
560
|
+
return out
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def run_acceptance_v2(corpus_dir, seat_fn, *, seat_family,
|
|
564
|
+
seat_name="acceptance-tech-lead-v2",
|
|
565
|
+
confidence_threshold=(
|
|
566
|
+
techlead.CONFIDENCE_THRESHOLD_DEFAULT),
|
|
567
|
+
timeout=180, log=None):
|
|
568
|
+
"""Run the acceptance corpus under adjudication protocol
|
|
569
|
+
v2 (techlead.adjudicate, evaluation mode: every model-path
|
|
570
|
+
case takes both A/B draws) and score it with the SAME
|
|
571
|
+
scorer and metric computation as the v1 runner.
|
|
572
|
+
|
|
573
|
+
Protocol differences from v1, all stated on the record:
|
|
574
|
+
corpus probe results are HARNESS-EXECUTED inputs here —
|
|
575
|
+
a settling probe decides deterministically (the seat only
|
|
576
|
+
grades its adequacy), where v1 presented probes as
|
|
577
|
+
context to the seat; protected-class cases are routed
|
|
578
|
+
by the harness before any seat call (the corpus owns the
|
|
579
|
+
tag); M4 is not run (mutations belong to the final
|
|
580
|
+
candidate's evaluation, not to baseline scoring — and
|
|
581
|
+
vendor-strip / family-swap are invariant by construction
|
|
582
|
+
under canonicalised packets), so M4 is measured None and
|
|
583
|
+
the unlock verdict is False on any v2 baseline run."""
|
|
584
|
+
log = log or (lambda _msg: None)
|
|
585
|
+
corpus = load_corpus(corpus_dir)
|
|
586
|
+
cases = corpus["cases"]
|
|
587
|
+
scorecard = corpus["scorecard"]
|
|
588
|
+
|
|
589
|
+
baseline = {}
|
|
590
|
+
scores = {}
|
|
591
|
+
for cid in sorted(cases):
|
|
592
|
+
case = cases[cid]
|
|
593
|
+
finding, dispute, region, _indep = case_inputs(
|
|
594
|
+
case, seat_family)
|
|
595
|
+
protected = \
|
|
596
|
+
case.get("case_class") == "protected_class"
|
|
597
|
+
reason = case.get("protected_class")
|
|
598
|
+
entry = techlead.adjudicate(
|
|
599
|
+
seat_fn, finding, region, dispute,
|
|
600
|
+
seat_name=seat_name, seat_family=seat_family,
|
|
601
|
+
probe_result=case.get("probe"),
|
|
602
|
+
protected=protected,
|
|
603
|
+
protected_reason=reason if isinstance(
|
|
604
|
+
reason, str) else None,
|
|
605
|
+
confidence_threshold=confidence_threshold,
|
|
606
|
+
evaluation_mode=True, timeout=timeout)
|
|
607
|
+
adapted = adapt_v2_entry(entry)
|
|
608
|
+
baseline[cid] = adapted
|
|
609
|
+
scores[cid] = score_case(case, adapted)
|
|
610
|
+
log(f"baseline {cid}: {scored_decision(adapted)} "
|
|
611
|
+
f"path={adapted.get('path')} "
|
|
612
|
+
f"(correct={scores[cid]['correct']})")
|
|
613
|
+
|
|
614
|
+
metrics = _scorecard_metrics(
|
|
615
|
+
cases, baseline, scores, scorecard,
|
|
616
|
+
flips={}, do_mutations=False, log=log)
|
|
617
|
+
# An empty corpus unlocks nothing, and an unmeasured M4
|
|
618
|
+
# passes nothing (633acb885291, as in v1).
|
|
619
|
+
unlock = bool(cases) and all(
|
|
620
|
+
m["pass"] for m in metrics.values())
|
|
621
|
+
return {
|
|
622
|
+
"seat_family": seat_family, "seat_name": seat_name,
|
|
623
|
+
"corpus": corpus_dir,
|
|
624
|
+
"protocol": "v2",
|
|
625
|
+
"protocol_version": techlead.TL_PROTOCOL_VERSION,
|
|
626
|
+
"rubric_version": techlead.TL_RUBRIC_VERSION,
|
|
627
|
+
"confidence_threshold": confidence_threshold,
|
|
628
|
+
"evaluation_mode": True,
|
|
629
|
+
"cases": {cid: {"entry": baseline[cid],
|
|
630
|
+
"score": scores[cid]}
|
|
631
|
+
for cid in sorted(cases)},
|
|
632
|
+
"metrics": metrics,
|
|
633
|
+
"paths": _path_metrics(
|
|
634
|
+
cases, baseline, scores, scorecard),
|
|
635
|
+
"counting_rules_note": (
|
|
636
|
+
"M1 counted by scored decision over the "
|
|
637
|
+
"scorecard's real-finding set (the scorecard's "
|
|
638
|
+
"counting_rules.false_dismissal); score_case's "
|
|
639
|
+
"per-case false_dismissal flag is never set by "
|
|
640
|
+
"the scorer and was NOT used — in the v1 run "
|
|
641
|
+
"record the flag read false on cases the M1 "
|
|
642
|
+
"metric counted. M6 is measured over majors "
|
|
643
|
+
"with two verdict draws (the model path in "
|
|
644
|
+
"evaluation mode); deterministic-path majors "
|
|
645
|
+
"are decided by their probe, take no draws, and "
|
|
646
|
+
"are outside M6's measured set — see the "
|
|
647
|
+
"per-path report. M4 not run in v2 baseline "
|
|
648
|
+
"mode (measured None, pass False)."),
|
|
649
|
+
"unlock_all_gates_pass": unlock}
|