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 ADDED
@@ -0,0 +1,6 @@
1
+ """codereview — multi-model code review with a deterministic gate."""
2
+
3
+ from .core import VERSION, run_review, run_verification
4
+
5
+ __all__ = ["VERSION", "run_review", "run_verification"]
6
+ __version__ = VERSION
codereview/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import console_main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(console_main())
@@ -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}