fix(calibration): drop block-yod + per-cell guard + incremental write (#208)
A live calibration run crashed with no error handling and lost ~2h of work:
the per-cell loop in _run() reached block-yod (discussion), block_writer.write_block
raised ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…") because the discussion
block needs an approved direction calibration cases don't have, and since the report
was only written at the very end of main(), every completed generation was lost.
Three robustness fixes (no metric/scoring change — G2):
1. Drop block-yod from default CALIBRATABLE_BLOCKS — out of WS5 interim-draft scope
AND structurally non-calibratable standalone (needs brainstorm→approve_direction).
Kept in new VALID_BLOCKS so a user can still force it via --blocks block-yod.
2. Per-cell try/except in _run(): any single (case, block, effort) cell failure is
logged as a warning (case/block/effort/error) and skipped, never fatal —
defense-in-depth for a forced block-yod and for transient rate-limits mid-run.
3. Incremental persistence: _write_report flushes after each completed block (ts
threaded into _run), so a crash later in the grid never loses finished blocks.
JSON/MD shape unchanged — partial results omit not-yet-done blocks from by_block.
--self-test: ALL PASS (14/14). block-yod gone from --dry-run defaults.
Invariants — נוגע / מקיים:
- G8 (eval-harness robustness) — partial results persist; a single-cell failure is
non-fatal; the harness survives block-yod's missing-direction raise and rate-limits.
- G2 (מקור-מדידה יחיד) — no metric-path change; _score_cell / block_distance_to_final
/ recommend_effort / aggregate_cell untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,12 +55,15 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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"))
|
||||
|
||||
@@ -77,9 +80,14 @@ 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 out of
|
||||
# WS5's interim scope but mappable, so it's included when present.
|
||||
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod", "block-yod-alef"]
|
||||
# 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"}
|
||||
|
||||
@@ -228,7 +236,7 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
return agg
|
||||
|
||||
|
||||
async def _run(args) -> dict:
|
||||
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
|
||||
@@ -279,10 +287,21 @@ async def _run(args) -> dict:
|
||||
final_section = c["sections"][section]
|
||||
case_cells = []
|
||||
for effort in efforts:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
)
|
||||
# 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",
|
||||
@@ -313,6 +332,15 @@ async def _run(args) -> dict:
|
||||
"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}
|
||||
|
||||
|
||||
@@ -383,6 +411,8 @@ async def main() -> int:
|
||||
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()
|
||||
|
||||
@@ -392,13 +422,13 @@ async def main() -> int:
|
||||
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 CALIBRATABLE_BLOCKS]
|
||||
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: {CALIBRATABLE_BLOCKS}", file=sys.stderr)
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = await _run(args)
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
jp, mp = _write_report(result, ts)
|
||||
|
||||
g = result["grid"]
|
||||
|
||||
Reference in New Issue
Block a user