diff --git a/docs/spec/07-learning.md b/docs/spec/07-learning.md index 32d62ad..33ad5aa 100644 --- a/docs/spec/07-learning.md +++ b/docs/spec/07-learning.md @@ -73,6 +73,8 @@ **הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2). +> **המלצת-הכיול style-ranked (#213):** `recommend_effort` ב-`scripts/calibrate_effort.py` מדרג efforts לפי `anti_pattern_total` **ראשי** (סיגנל-סגנון נקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש). `change_percent` **מדווח-לא-מדורג** מאותה אזהרת-פרשנות לעיל (מערבב סגנון עם שלמות-תוכן). ההמלצה נושאת דגל-`confidence` ("clear"/"weak") כך שבחירה שההובלה-ב-anti שלה בתוך-הרעש (epsilon=max(0.5, 0.20·spread)) לא מוצגת כוודאית. אין מסלול-מדד מקביל — שימוש-חוזר ב-`style_distance` (G2). + > **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא. --- diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index 6580400..8cce214 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -76,7 +76,7 @@ | `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest `. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) | | `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha__.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py `. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) | | `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex___.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py `. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) | -| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח את לוגיקת-המדידה) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב | +| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק (style-clean, #213) לפי `anti_pattern_total` **ראשי** (סיגנל-הסגנון הנקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש); **`change_percent` מדווח-לא-מדורג** (מערבב סגנון עם שלמות-תוכן, 07-learning §0.7). ההמלצה נושאת דגל-`confidence` ("clear"/"weak" — הובלת-anti < epsilon=max(0.5, 0.20·spread) ⇒ ⚠️weak בתוך-הרעש, לא מוצג כוודאי) לצד ברירת-המחדל מ-#204. מפיק `data/eval/effort-calibration-.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + דירוג-סגנון + confidence + rerank) · **`--rerank `** (offline, אפס LLM/DB — מדרג-מחדש דוח-כיול שמור תחת המפתח החדש, מדפיס טבלה + כותב `*-reranked.md`) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב | | `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) | | `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) | | `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה | diff --git a/scripts/calibrate_effort.py b/scripts/calibrate_effort.py index 4fe92c7..9785ad0 100644 --- a/scripts/calibrate_effort.py +++ b/scripts/calibrate_effort.py @@ -19,9 +19,15 @@ WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid): • distance — normalized composite (lower = closer to Dafna) No parallel metric path: it reuses style_distance + learning_loop (G2). -RECOMMENDATION: for each block, the effort with the lowest MEAN composite distance -across cases (ties → fewer anti-patterns → lower change_percent). Reported next to -the #204 current default so a regression/improvement is visible. +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 @@ -94,20 +100,66 @@ VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"} # ── pure helpers (offline-testable) ────────────────────────────────────────── -def recommend_effort(cells: list[dict]) -> dict | None: - """Pick the best effort for ONE block from its scored cells. +# 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 - cells: [{"effort","distance","anti_pattern_total","change_percent","n"}]. - Lowest mean composite distance wins; ties broken by fewer anti-patterns, - then lower change_percent. Pure → unit-tested in --self-test. + +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["distance"], c["anti_pattern_total"], c["change_percent"]), + 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"], + ), ) - return ranked[0] + 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: @@ -133,6 +185,88 @@ def _current_default(block_id: str) -> str | 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 @@ -180,16 +314,66 @@ def _self_test() -> int: {"background", "claims", "discussion", "summary"})) chk("split found ≥1 section", len(secs) >= 1) - # recommend_effort: lowest distance wins; tie → fewer anti-patterns. + # 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.40, "anti_pattern_total": 5, "change_percent": 40, "n": 1}, - {"effort": "high", "distance": 0.20, "anti_pattern_total": 2, "change_percent": 25, "n": 1}, - {"effort": "xhigh", "distance": 0.20, "anti_pattern_total": 1, "change_percent": 22, "n": 1}, + {"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 picks lowest distance", rec["distance"] == 0.20) - chk("recommend tie → fewer anti-patterns", rec["effort"] == "xhigh") + 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}, @@ -199,6 +383,23 @@ def _self_test() -> int: 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 @@ -329,6 +530,8 @@ async def _run(args, ts: str) -> dict: "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, } @@ -386,13 +589,18 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]: mp.write_text("\n".join(lines) + "\n", encoding="utf-8") return jp, mp - lines += ["## המלצה per-בלוק (distance נמוך = קרוב יותר לדפנה)\n", - "| block | section | current | **recommended** | n |", "|---|---|---|---|---|"] + 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 '—'} | **{rec}**{mark} | {n_b} |") + 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", @@ -405,8 +613,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]: 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("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); " - "anti_total הוא הסיגנל הנקי-יותר לסגנון.\n") + 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 @@ -414,6 +624,8 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]: 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)})") @@ -428,6 +640,9 @@ async def main() -> int: 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: @@ -452,7 +667,8 @@ async def main() -> int: 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 '—'}") + f"→ recommended={bd.get('recommended') or '—':6} " + f"[{bd.get('confidence') or '—'}]") print(f" report: {mp}") return 0