Files
legal-ai/scripts/calibrate_effort.py
Chaim c7156b575e
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(calibration): style-clean recommend_effort (anti-primary) + confidence + --rerank (#213)
Make the effort-calibration recommendation style-clean (style acquisition is
the goal — write like chair Dafna Tamir):

- recommend_effort ranks by anti_pattern_total (PRIMARY, the clean style-rule
  violation count) → golden_ratio_deviation_pp (SECONDARY, structural-style,
  None=worst) → distance (faint final tiebreak). change_percent is EXCLUDED from
  the ranking (it mixes style with content-completeness per 07-learning §0.7 —
  noise for STYLE) and kept in the report as context only.
- Add a confidence flag ("clear"/"weak"): a pick whose anti-pattern lead over the
  runner-up is within noise (margin < epsilon = max(0.5, 0.20·spread)) is flagged
  ⚠️ weak, so an over-claimed pick (the old confident block-zayin "low") is never
  shown as confident. Rendered as a confidence column in the report.
- Add --rerank <report.json>: re-apply the new key to a saved calibration JSON
  offline (NO LLM, NO DB), print + write *-reranked.md. Re-ranks today's 8-final
  run without regenerating.
- recommend_effort stays a single shared pure function reused by
  scripts/calibrate_block_yod.py (G2); both --self-test suites pass.
- Spec note added (07-learning §0.7) + SCRIPTS.md updated.

Invariants: G8 (style-clean empirical ranking), G2 (single shared recommend_effort,
reused by the block-yod harness — no parallel metric path), INV-LRN5 (style-only
signal; change% reported-not-ranked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:00:41 +00:00

678 lines
35 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""#208 (WS5 / Q1, INV-G8 eval-harness) — model×effort calibration vs the finals.
Empirically picks the per-block reasoning `effort` whose regenerated block lands
CLOSEST to עו"ד דפנה תמיר's signed final, over the EXISTING `draft_final_pairs`
ledger. This is the calibration INV-G8 / 07-learning §0.7 ask for: stop choosing
the per-block effort defaults (#204: ה=medium, ו=medium, ז=high, ח=medium, ט=high)
"by feel".
WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid):
Regenerate `block` for `case` via block_writer.write_block(effort_override=…)
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
Opus 4.8, local-only), then score the regenerated block against the matching
SECTION of the final via services.style_distance.block_distance_to_final:
• change_percent — word-diff regen↔final-section (compute_diff_stats)
• anti_pattern_total — lessons.ANTI_PATTERNS hits in the regen (cleanest
style signal — see §0.7 warning below)
• golden_ratio_deviation_pp — structural-weight gap vs the final
• distance — normalized composite (lower = closer to Dafna)
No parallel metric path: it reuses style_distance + learning_loop (G2).
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
then `golden_ratio_deviation_pp` (structural-style), and `distance` only as a faint
final tiebreak. `change_percent` is REPORTED-NOT-RANKED: per 07-learning §0.7 it
mixes style with content-completeness (a case's missing facts the model can't know)
→ noise for STYLE. The pick carries a `confidence` flag ("clear"/"weak"): a pick
whose anti-pattern lead over the runner-up is within noise is flagged "weak" so an
over-claimed pick (the old block-zayin "low") is never shown as confident. Reported
next to the #204 current default so a regression/improvement is visible.
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 per block the
recommendation is advisory only; the chair/operator decides whether to adopt.
⚠️ change_percent mixes style with content completeness (07-learning §0.7): the chair
sometimes doubles length for missing substance. anti_pattern_total is the cleaner
style signal — the report surfaces both, and the composite down-weights neither
silently.
GENERATION PATH (do not violate — reference_claude_generation_path / claude_session
docstring): write_block → claude_session.query → `claude -p` uses the local claude.ai
session. It runs ONLY on the host where the `claude` CLI exists (NOT the legal-ai
container, NOT a symlinked worktree without CLI access). Hence the live A/B is
host-only; --self-test proves the measurement logic offline with zero model calls.
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
$PY scripts/calibrate_effort.py --self-test # offline proof, no DB/CLI
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
$PY scripts/calibrate_effort.py # live A/B over finals
… --efforts low,medium,high,xhigh # override the effort grid
… --blocks block-he,block-vav # restrict to some blocks
… --case 8137-11-24 # a single case
… --repeats 2 # avg N gens/cell (noise)
… --dry-run # plan the grid, no model calls
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
from statistics import mean
logger = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
if "POSTGRES_URL" not in os.environ:
os.environ["POSTGRES_URL"] = (
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
f"{os.environ.get('POSTGRES_PORT','5433')}/"
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
)
OUT_DIR = REPO_ROOT / "data" / "eval"
# Only the AI blocks that map to a golden-ratio section can be scored against the
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
# that calibration cases lack, so block_writer.write_block raises and it is NOT
# calibratable standalone — and it's out of WS5's interim-draft scope. It stays in
# VALID_BLOCKS so a user can still force it (--blocks block-yod) at their own risk;
# the per-cell guard in _run() keeps such a failure non-fatal.
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod-alef"]
VALID_BLOCKS = CALIBRATABLE_BLOCKS + ["block-yod"]
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
# ── pure helpers (offline-testable) ──────────────────────────────────────────
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
# effort's anti_pattern_total beats the 2nd-best. We call a pick "clear" only when
# that margin clears a floor that is itself the LARGER of:
# • an absolute floor (≥0.5 avg anti-patterns — a half-violation per case), and
# • a fraction of the spread across efforts (best can't claim a lead that is mere
# noise on a block whose efforts barely differ).
# Below that → "weak" (within noise; nominal pick still reported, but flagged so a
# within-noise pick is never shown as confident). Documented in the docstring.
CONFIDENCE_ABS_FLOOR = 0.5 # avg anti-patterns (a half-violation per case)
CONFIDENCE_SPREAD_FRACTION = 0.20 # of the across-effort anti spread
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
"""Confidence that the anti-primary pick is real, not within-noise.
Returns (confidence, margin, epsilon) where confidence ∈ {"clear","weak"}.
margin = (2nd-best anti) (best anti); epsilon = the bar margin must clear.
A single effort (no rival) is trivially "clear". Pure → unit-tested.
"""
if len(rows) < 2:
return "clear", float("inf"), 0.0
antis = sorted(r["anti_pattern_total"] for r in rows)
best, second = antis[0], antis[1]
margin = second - best
spread = antis[-1] - antis[0]
epsilon = max(CONFIDENCE_ABS_FLOOR, CONFIDENCE_SPREAD_FRACTION * spread)
return ("clear" if margin >= epsilon else "weak"), round(margin, 4), round(epsilon, 4)
def recommend_effort(cells: list[dict]) -> dict | None:
"""Pick the STYLE-CLEANEST effort for ONE block from its scored cells (#213).
cells: [{"effort","distance","anti_pattern_total","change_percent",
"golden_ratio_deviation_pp","n"}].
Ranking key (style-clean, G8 / 07-learning §0.7) — change_percent is EXCLUDED
from the ranking (it mixes style with content-completeness; reported only):
1. PRIMARY anti_pattern_total (↑ fewer Dafna-style violations = better)
2. SECONDARY golden_ratio_deviation_pp (↑ structural-style; None → +inf worst)
3. TIEBREAK distance (↑ faint final tiebreak only)
Returns the winning row dict (so callers reading ["effort"] keep working) with
two added keys: ``confidence`` ∈ {"clear","weak"} and ``confidence_margin`` —
so a within-noise pick is never presented as confident. Pure → unit-tested.
"""
if not cells:
return None
ranked = sorted(
cells,
key=lambda c: (
c["anti_pattern_total"],
c["golden_ratio_deviation_pp"] if c.get("golden_ratio_deviation_pp") is not None else float("inf"),
c["distance"],
),
)
best = dict(ranked[0])
conf, margin, _eps = _anti_confidence(cells)
best["confidence"] = conf
best["confidence_margin"] = margin
return best
def aggregate_cell(per_run: list[dict]) -> dict:
"""Mean each metric across repeated generations of the same (case, block, effort)."""
if not per_run:
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
"golden_ratio_deviation_pp": None, "n": 0}
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
return {
"distance": round(mean(r["distance"] for r in per_run), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(per_run),
}
def _current_default(block_id: str) -> str | None:
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
cfg = BLOCK_CONFIG.get(block_id, {})
if cfg.get("model") != "ai":
return None
return cfg.get("effort", DEFAULT_EFFORT)
# ── re-rank a saved report (offline — NO LLM, NO DB) ─────────────────────────
def rerank_saved(saved: dict) -> dict:
"""Re-apply the CURRENT (#213) recommend_effort to a saved calibration report.
Reads the per-(block,effort) rows that the run already persisted
(distance/anti/change%/ratioΔ/n) and recomputes recommended + confidence — no
regeneration, no model, no DB. Lets a past run be re-ranked under the new
style-clean key. Returns {"block_id": {old, new, confidence, confidence_margin,
current_default, efforts}}. Pure → unit-tested in --self-test.
"""
out: dict[str, dict] = {}
for block_id, bd in (saved.get("by_block") or {}).items():
rows = bd.get("efforts") or []
rec = recommend_effort(rows)
out[block_id] = {
"section": bd.get("section"),
"current_default": bd.get("current_default"),
"old_recommended": bd.get("recommended"),
"new_recommended": rec["effort"] if rec else None,
"confidence": rec["confidence"] if rec else None,
"confidence_margin": rec.get("confidence_margin") if rec else None,
"efforts": rows,
}
return out
def _render_rerank_md(saved: dict, reranked: dict, src: Path) -> str:
g = saved.get("grid", {})
finals = g.get("finals", [])
lines = [
f"# #213 — re-rank של {src.name} (style-clean, anti-primary · OFFLINE)\n",
f"> מקור: `{src.name}` · {g.get('n_finals','?')} סופיים "
f"({', '.join(finals) or ''}) · ללא LLM/DB — דירוג-מחדש בלבד.\n",
"## old pick → new pick (+confidence)\n",
"| block | section | current | old pick | **new pick** | confidence | n |",
"|---|---|---|---|---|---|---|",
]
for b, rd in reranked.items():
old = rd.get("old_recommended") or ""
new = rd.get("new_recommended") or ""
mark = "" if new == old else " ⬅︎"
conf = rd.get("confidence")
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "")
n_b = rd["efforts"][0]["n"] if rd.get("efforts") else 0
lines.append(
f"| {b} | {rd.get('section') or ''} | {rd.get('current_default') or ''} | "
f"{old} | **{new}**{mark} | {conf_cell} | {n_b} |")
lines.append("")
for b, rd in reranked.items():
if not rd.get("efforts"):
continue
lines += [f"### {b} ({rd.get('section') or ''})\n",
"| effort | distance | anti_total | change% (reported) | ratioΔpp | n |",
"|---|---|---|---|---|---|"]
for r in rd["efforts"]:
star = "" if r["effort"] == rd.get("new_recommended") else ""
ratio = r.get("golden_ratio_deviation_pp")
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines.append("> דירוג **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak); "
"**change% מדווח-לא-מדורג** (07-learning §0.7); "
"confidence=⚠weak ⇒ הובלת-anti < epsilon = max(0.5, 0.20·spread).\n")
return "\n".join(lines) + "\n"
def _do_rerank(path: Path) -> int:
"""--rerank entry: load a saved report, re-rank offline, print + write *-reranked.md."""
if not path.exists():
print(f"rerank: file not found: {path}", file=sys.stderr)
return 2
saved = json.loads(path.read_text(encoding="utf-8"))
reranked = rerank_saved(saved)
md = _render_rerank_md(saved, reranked, path)
print(md)
out = path.with_name(path.stem + "-reranked.md")
out.write_text(md, encoding="utf-8")
print(f" reranked report written: {out}", file=sys.stderr)
return 0
# ── self-test (no DB, no model) ──────────────────────────────────────────────
def _self_test() -> int:
ok = True
def chk(name, cond):
nonlocal ok
ok = ok and cond
print(f" {name:42} {'ok' if cond else 'FAIL'}")
# block_distance_to_final: regen identical to final-section ⇒ change ~0,
# distance dominated by anti-patterns (0 here) ⇒ ~0.
from legal_mcp.services.style_distance import (
block_distance_to_final, split_final_by_section,
)
final_section = "לפנינו ערר על החלטת הוועדה המקומית. " * 40
d_same = block_distance_to_final("block-he", final_section, final_section, "rejection",
section_target_total_words=len(final_section.split()))
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
chk("identical regen ⇒ anti==0", d_same["anti_pattern_total"] == 0)
chk("identical regen ⇒ distance small", d_same["distance"] < 0.05)
# A regen full of anti-patterns (markdown headers / bullet lists) scores worse
# than clean continuous narrative, holding the final fixed.
clean = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 20
dirty = "## כותרת\n- נקודה ראשונה\n- נקודה שנייה\n* עוד נקודה\n### תת\n" * 10
d_clean = block_distance_to_final("block-yod", clean, final_section, "rejection",
section_target_total_words=len(final_section.split()))
d_dirty = block_distance_to_final("block-yod", dirty, final_section, "rejection",
section_target_total_words=len(final_section.split()))
chk("dirty regen has more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
# ratio deviation is None when no total provided (never fabricated)
d_noratio = block_distance_to_final("block-he", clean, final_section, "rejection")
chk("no total ⇒ ratio deviation None", d_noratio["golden_ratio_deviation_pp"] is None)
# split_final_by_section returns mapped golden sections only.
sample_final = (
"רקע עובדתי\nהמקרקעין נשוא הערר. " * 10 + "\n\n"
"תמצית טענות הצדדים\nהעוררים טוענים כי. " * 10 + "\n\n"
"דיון והכרעה\nאנו סבורים כי. " * 10
)
secs = split_final_by_section(sample_final)
chk("split maps to golden sections", set(secs).issubset(
{"background", "claims", "discussion", "summary"}))
chk("split found ≥1 section", len(secs) >= 1)
# recommend_effort (#213, style-clean): anti_pattern_total is PRIMARY — the
# effort with the FEWEST style violations wins, even if its distance is higher.
rec = recommend_effort([
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
"golden_ratio_deviation_pp": 3.0, "n": 1},
{"effort": "high", "distance": 0.40, "anti_pattern_total": 2, "change_percent": 40,
"golden_ratio_deviation_pp": 5.0, "n": 1},
{"effort": "xhigh", "distance": 0.50, "anti_pattern_total": 1, "change_percent": 50,
"golden_ratio_deviation_pp": 9.0, "n": 1},
])
chk("recommend: anti-primary (not distance)", rec["effort"] == "xhigh")
chk("recommend: change_percent NOT ranked", rec["distance"] == 0.50) # worst distance still won
chk("recommend empty ⇒ None", recommend_effort([]) is None)
# SECONDARY = golden_ratio_deviation_pp when anti ties; distance only as last tiebreak.
rec_tie = recommend_effort([
{"effort": "low", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 10,
"golden_ratio_deviation_pp": 8.0, "n": 1},
{"effort": "high", "distance": 0.90, "anti_pattern_total": 3, "change_percent": 90,
"golden_ratio_deviation_pp": 2.0, "n": 1},
])
chk("recommend: ratio breaks anti-tie", rec_tie["effort"] == "high")
rec_ratio_tie = recommend_effort([
{"effort": "low", "distance": 0.30, "anti_pattern_total": 3, "change_percent": 10,
"golden_ratio_deviation_pp": 5.0, "n": 1},
{"effort": "high", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 90,
"golden_ratio_deviation_pp": 5.0, "n": 1},
])
chk("recommend: distance breaks final tie", rec_ratio_tie["effort"] == "high")
# None ratio is treated as worst (never beats a real deviation on the secondary key).
rec_none = recommend_effort([
{"effort": "low", "distance": 0.10, "anti_pattern_total": 2, "change_percent": 10,
"golden_ratio_deviation_pp": None, "n": 1},
{"effort": "high", "distance": 0.90, "anti_pattern_total": 2, "change_percent": 90,
"golden_ratio_deviation_pp": 4.0, "n": 1},
])
chk("recommend: None ratio is worst", rec_none["effort"] == "high")
# confidence flag (#213): a clear anti lead ⇒ "clear"; within-noise ⇒ "weak".
rec_clear = recommend_effort([
{"effort": "low", "distance": 0.5, "anti_pattern_total": 1.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5},
{"effort": "high", "distance": 0.5, "anti_pattern_total": 4.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5},
])
chk("confidence: big anti lead ⇒ clear", rec_clear["confidence"] == "clear")
rec_weak = recommend_effort([
{"effort": "low", "distance": 0.4, "anti_pattern_total": 3.6, "change_percent": 93,
"golden_ratio_deviation_pp": 6.7, "n": 5},
{"effort": "xhigh", "distance": 0.5, "anti_pattern_total": 3.6, "change_percent": 102,
"golden_ratio_deviation_pp": 6.6, "n": 5},
{"effort": "high", "distance": 0.53, "anti_pattern_total": 3.8, "change_percent": 104,
"golden_ratio_deviation_pp": 5.7, "n": 5},
])
chk("confidence: anti tie ⇒ weak", rec_weak["confidence"] == "weak")
rec_single = recommend_effort([
{"effort": "low", "distance": 0.4, "anti_pattern_total": 2.0, "change_percent": 50,
"golden_ratio_deviation_pp": 5.0, "n": 5}])
chk("confidence: single effort ⇒ clear", rec_single["confidence"] == "clear")
# aggregate_cell: means + ratio drops Nones, keeps n.
agg = aggregate_cell([
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
{"distance": 0.4, "anti_pattern_total": 4, "change_percent": 30, "golden_ratio_deviation_pp": None},
])
chk("aggregate distance mean", agg["distance"] == 0.3)
chk("aggregate n counted", agg["n"] == 2)
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
# rerank_saved: re-applies the new key to a saved report's stored rows, no I/O.
# Mimics the real 8-final block-vav rows: low has fewest anti by a clear margin.
saved = {"grid": {"n_finals": 7, "finals": ["x"]}, "by_block": {
"block-vav": {"section": "background", "current_default": "medium",
"recommended": "low", "efforts": [
{"effort": "low", "distance": 0.4454, "anti_pattern_total": 1.57, "change_percent": 87.39, "golden_ratio_deviation_pp": 6.33, "n": 7},
{"effort": "medium", "distance": 0.4749, "anti_pattern_total": 2.29, "change_percent": 87.76, "golden_ratio_deviation_pp": 6.37, "n": 7},
{"effort": "high", "distance": 0.5381, "anti_pattern_total": 4.14, "change_percent": 90.69, "golden_ratio_deviation_pp": 5.99, "n": 7},
{"effort": "xhigh", "distance": 0.5413, "anti_pattern_total": 4.57, "change_percent": 89.53, "golden_ratio_deviation_pp": 6.41, "n": 7},
]},
}}
rr = rerank_saved(saved)
chk("rerank: block-vav stays low", rr["block-vav"]["new_recommended"] == "low")
chk("rerank: block-vav clear (anti lead)", rr["block-vav"]["confidence"] == "clear")
chk("rerank: keeps old pick for diff", rr["block-vav"]["old_recommended"] == "low")
chk("rerank: renders markdown", "new pick" in _render_rerank_md(saved, rr, Path("x.json")))
print("ALL PASS" if ok else "*** FAILURES ***")
return 0 if ok else 1
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
"""draft_final_pairs whose final_text is populated (the held-out comparison set)."""
from legal_mcp.services import db
pairs = await db.list_draft_final_pairs(limit=500)
out: list[dict] = []
for p in pairs:
if case_filter and p.get("case_number") != case_filter:
continue
full = await db.get_draft_final_pair(p["id"])
if full and (full.get("final_text") or "").strip():
out.append(full)
return out
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
final_total_words: int, outcome: str, repeats: int) -> dict:
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
from legal_mcp.services import block_writer
from legal_mcp.services.style_distance import block_distance_to_final
runs: list[dict] = []
for _ in range(repeats):
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
scored = block_distance_to_final(
block_id, res.get("content", ""), final_section, outcome,
section_target_total_words=final_total_words,
)
runs.append(scored)
agg = aggregate_cell(runs)
agg["effort"] = effort
agg["runs"] = runs
return agg
async def _run(args, ts: str) -> dict:
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import canonical_outcome
from legal_mcp.services.style_distance import split_final_by_section, _BLOCK_TO_SECTION
efforts = args.efforts
blocks = args.blocks
finals = await _finals_for_calibration(args.case)
cases_meta = []
for f in finals:
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
if not case:
continue
decision = await db.get_decision_by_case(UUID(case["id"]))
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
sections = split_final_by_section(f.get("final_text", ""))
final_total_words = len((f.get("final_text", "") or "").split())
cases_meta.append({
"case_number": f["case_number"], "case_id": case["id"],
"outcome": outcome, "sections": sections, "final_total_words": final_total_words,
})
# grid plan: (block → cases that have its section)
plan: dict[str, list[dict]] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
grid_summary = {
"n_finals": len(cases_meta),
"finals": [c["case_number"] for c in cases_meta],
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
"total_generations": total_cells,
"per_block_n": {b: len(plan[b]) for b in blocks},
}
if args.dry_run:
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
by_block: dict[str, dict] = {}
for block_id in blocks:
section = _BLOCK_TO_SECTION.get(block_id)
per_effort_runs: dict[str, list[dict]] = {e: [] for e in efforts}
per_case: list[dict] = []
for c in plan[block_id]:
final_section = c["sections"][section]
case_cells = []
for effort in efforts:
# Per-cell guard: ANY failure of a single (case, block, effort) cell
# — e.g. block-yod raising "ללא כיוון מאושר", or a transient rate-limit
# mid-run — is logged and skipped, NOT allowed to kill the whole run
# (INV-G8 eval-harness robustness). Partial results still persist below.
try:
cell = await _score_cell(
UUID(c["case_id"]), block_id, effort, final_section,
c["final_total_words"], c["outcome"], args.repeats,
)
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
logger.warning(
"calibration cell skipped: case=%s block=%s effort=%s%s",
c["case_number"], block_id, effort, exc,
)
continue
per_effort_runs[effort].append(cell)
case_cells.append({k: cell[k] for k in
("effort", "distance", "anti_pattern_total",
"change_percent", "golden_ratio_deviation_pp", "n")})
per_case.append({"case_number": c["case_number"], "cells": case_cells})
# mean across cases for each effort → one comparable row per effort
effort_rows = []
for effort in efforts:
rows = per_effort_runs[effort]
if not rows:
continue
ratios = [r["golden_ratio_deviation_pp"] for r in rows if r.get("golden_ratio_deviation_pp") is not None]
effort_rows.append({
"effort": effort,
"distance": round(mean(r["distance"] for r in rows), 4),
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
"n": len(rows),
})
rec = recommend_effort(effort_rows)
by_block[block_id] = {
"section": section,
"current_default": _current_default(block_id),
"recommended": rec["effort"] if rec else None,
"confidence": rec["confidence"] if rec else None,
"confidence_margin": rec.get("confidence_margin") if rec else None,
"efforts": effort_rows,
"per_case": per_case,
}
# Incremental persistence (INV-G8): flush the report after every completed
# block so a crash later in the grid never loses blocks already generated.
# Blocks not yet done are simply absent from by_block; _write_report tolerates
# partial results. main() does the final flush once the loop finishes.
try:
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
logger.warning("incremental report write failed after block=%s%s", block_id, exc)
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
IL_TZ = ZoneInfo("Asia/Jerusalem")
def _ts() -> str:
"""Filename-safe report stamp in Israel time (INV-UI9: human-facing display).
This script runs on the host, which is UTC, so a process-clock stamp would
mislead the chair. We pin Asia/Jerusalem and suffix ``IL`` (instead of a bare
``Z``) so the displayed/filename time is unambiguously Israel-local.
"""
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
OUT_DIR.mkdir(parents=True, exist_ok=True)
jp = OUT_DIR / f"effort-calibration-{ts}.json"
mp = OUT_DIR / f"effort-calibration-{ts}.md"
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
g = result["grid"]
n = g["n_finals"]
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
lines = [
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or ''}). "
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
f"- בלוקים: {', '.join(g['blocks'])}",
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
f"- סך ייצורי-מודל: {g['total_generations']}",
"",
]
if result.get("dry_run"):
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
"| block | #cases | current default |", "|---|---|---|"]
for b in g["blocks"]:
lines.append(f"| {b} | {g['per_block_n'].get(b,0)} | {_current_default(b) or ''} |")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
"| block | section | current | **recommended** | confidence | n |",
"|---|---|---|---|---|---|"]
for b, bd in result["by_block"].items():
rec = bd.get("recommended") or ""
mark = "" if rec == bd.get("current_default") else " ⬅︎"
conf = bd.get("confidence")
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "")
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
lines.append(
f"| {b} | {bd.get('section','')} | {bd.get('current_default') or ''} | "
f"**{rec}**{mark} | {conf_cell} | {n_b} |")
lines.append("")
for b, bd in result["by_block"].items():
lines += [f"### {b} ({bd.get('section','')})\n",
"| effort | distance | anti_total | change% | ratioΔpp | n |",
"|---|---|---|---|---|---|"]
for r in bd.get("efforts", []):
star = "" if r["effort"] == bd.get("recommended") else ""
ratio = r["golden_ratio_deviation_pp"]
lines.append(
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
f"{r['change_percent']} | {ratio if ratio is not None else ''} | {r['n']} |")
lines.append("")
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠weak ⇒ הבחירה בתוך-הרעש "
"(ההובלה ב-anti קטנה מ-epsilon = max(0.5, 0.20·spread)).\n")
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jp, mp
async def main() -> int:
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
ap.add_argument("--rerank", metavar="REPORT.json", default=None,
help="re-rank a saved calibration JSON under the current style-clean key (no LLM/DB)")
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
ap.add_argument("--blocks", default=",".join(CALIBRATABLE_BLOCKS),
help="comma block ids to calibrate")
ap.add_argument("--case", default=None, help="restrict to a single case_number")
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if args.self_test:
return _self_test()
if args.rerank:
return _do_rerank(Path(args.rerank))
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
if bad:
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
return 2
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
bad_b = [b for b in args.blocks if b not in VALID_BLOCKS]
if bad_b:
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
return 2
ts = _ts()
result = await _run(args, ts)
jp, mp = _write_report(result, ts)
g = result["grid"]
print(f"CALIBRATION: {g['n_finals']} finals — DIRECTIONAL EVIDENCE, not a regression")
if g["n_finals"] == 0:
print(" no finals with final_text in draft_final_pairs — upload a signed final first.")
elif result.get("dry_run"):
print(f" dry-run: {g['total_generations']} generations planned across {len(g['blocks'])} blocks")
else:
for b, bd in result["by_block"].items():
print(f" {b:16} current={bd.get('current_default') or '':6} "
f"→ recommended={bd.get('recommended') or '':6} "
f"[{bd.get('confidence') or ''}]")
print(f" report: {mp}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))