feat(principles): retroactive cull (Phase C) + source-derived terminology (Phase D, #152)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

Phase C — scripts/cull_principles.py: re-adjudicates every existing 'original'
principle with the SAME panel regime (panel_keep_score → classify → apply_cap),
reversible (CSV backup + rejected canonical recoverable), usage-throttled.
panel_extraction.panel_keep_score + apply_cap (shared, G2). Dry-run on 3
decisions: 37→15 survive.

Phase D — services/principles.py: source-derived label הלכה (binding court) /
כלל פרשני (committee) / עיקרון (persuasive); umbrella עקרונות משפטיים. Wired into
canonical_halacha_get/list (principle_class+principle_label). UI string changes
deferred to the Claude Design gate. spec INV-LRN7; SCRIPTS.md; 7 new tests; 428 green.

Phase E needs no new code — synthesis already targets pending_synthesis, which the
cull leaves only on survivors (rejected canonicals → 'rejected').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:14:59 +00:00
parent 6b2fd562ae
commit 4ca907b97f
9 changed files with 426 additions and 14 deletions

View File

@@ -14,7 +14,7 @@ import asyncpg
from pgvector.asyncpg import register_vector
from legal_mcp import config
from legal_mcp.services import court_citation, halacha_quality
from legal_mcp.services import court_citation, halacha_quality, principles
logger = logging.getLogger(__name__)
@@ -6156,7 +6156,8 @@ async def get_canonical_halacha(canonical_id: "UUID") -> "dict | None":
"SELECT ch.id::text, ch.canonical_statement, ch.rule_type, "
" ch.practice_areas, ch.subject_tags, ch.review_status, "
" ch.instance_count, ch.created_at, ch.updated_at, "
" cl.case_number AS first_established_case "
" cl.case_number AS first_established_case, "
" cl.source_kind, cl.is_binding "
"FROM canonical_halachot ch "
"LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
"WHERE ch.id = $1",
@@ -6172,10 +6173,12 @@ async def get_canonical_halacha(canonical_id: "UUID") -> "dict | None":
"WHERE h.canonical_id = $1 ORDER BY h.instance_type, cl.case_number",
canonical_id,
)
return {
**dict(row),
"instances": [dict(i) for i in instances],
}
out = dict(row)
# #152: source-derived class + Hebrew label (הלכה / כלל פרשני / עיקרון).
out["principle_class"] = principles.principle_class(out.get("source_kind"), out.get("is_binding"))
out["principle_label"] = principles.label_for_class(out["principle_class"])
out["instances"] = [dict(i) for i in instances]
return out
async def list_canonical_halachot(
@@ -6190,24 +6193,33 @@ async def list_canonical_halachot(
params: list = []
idx = 1
if practice_area:
conditions.append(f"${ idx} = ANY(practice_areas)")
conditions.append(f"${ idx} = ANY(ch.practice_areas)")
params.append(practice_area)
idx += 1
if review_status:
conditions.append(f"review_status = ${idx}")
conditions.append(f"ch.review_status = ${idx}")
params.append(review_status)
idx += 1
params += [limit, offset]
rows = await pool.fetch(
f"SELECT id::text, canonical_statement, rule_type, practice_areas, "
f" subject_tags, review_status, instance_count, created_at, updated_at "
f"FROM canonical_halachot "
f"SELECT ch.id::text, ch.canonical_statement, ch.rule_type, ch.practice_areas, "
f" ch.subject_tags, ch.review_status, ch.instance_count, "
f" ch.created_at, ch.updated_at, cl.source_kind, cl.is_binding "
f"FROM canonical_halachot ch "
f"LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
f"WHERE {' AND '.join(conditions)} "
f"ORDER BY instance_count DESC, created_at DESC "
f"ORDER BY ch.instance_count DESC, ch.created_at DESC "
f"LIMIT ${idx} OFFSET ${idx + 1}",
*params,
)
return [dict(r) for r in rows]
out = []
for r in rows:
d = dict(r)
cls = principles.principle_class(d.pop("source_kind", None), d.pop("is_binding", None))
d["principle_class"] = cls
d["principle_label"] = principles.label_for_class(cls)
out.append(d)
return out
async def update_canonical_statement(

View File

@@ -126,6 +126,29 @@ def classify(votes: int, score: float) -> str:
return "rejected"
def apply_cap(judged: list[dict], max_new: int | None = None) -> list[dict]:
"""Per-decision cap for the retroactive cull (#152, Phase C).
``judged`` = a decision's principles, each with a panel ``verdict`` + ``score``.
Survivors (approved/pending_review) are ranked by score; those beyond ``max_new``
are downgraded to 'rejected' (over-cap). Already-rejected stay rejected. Returns
a new list with ``final_verdict`` set on each (order preserved). Pure.
"""
max_new = config.HALACHA_PANEL_MAX_NEW if max_new is None else max_new
survivors = [j for j in judged if j.get("verdict") in ("approved", "pending_review")]
survivors.sort(key=lambda j: j.get("score", 0.0), reverse=True)
keep_ids = {id(j) for j in survivors[:max_new]}
out = []
for j in judged:
v = j.get("verdict")
if v in ("approved", "pending_review") and id(j) not in keep_ids:
final = "rejected" # over the cap
else:
final = v
out.append({**j, "final_verdict": final})
return out
def cluster_candidates(
per_model: dict[str, list[dict]], embs: dict[int, list[float]],
) -> list[dict]:
@@ -195,6 +218,63 @@ def cluster_candidates(
return out
def _keep_score_system(source_kind: str, is_binding: bool) -> str:
if source_kind == "internal_committee":
nature = ("המקור הוא החלטת ועדת-ערר (מיישמת דין, אינה יוצרת הלכה). ראוי-לשמירה = "
"כלל פרשני חדש ובר-הכללה שהוועדה גיבשה; לא-ראוי = יישום תלוי-עובדות, "
"חזרה על דין מוכר, אמרת-אגב, או חזרה מילולית על הציטוט.")
else:
nature = ("ראוי-לשמירה = עיקרון משפטי בר-הכללה והסתמכות (הלכה/פרשנות/כלל-פרוצדורלי); "
"לא-ראוי = החלה תלוית-עובדות, אמרת-אגב, או חזרה מילולית על הציטוט.")
return (
"אתה משפטן בכיר בוועדת ערר לתכנון ובנייה. הוכרע אם עיקרון שחולץ מפסיקה ראוי "
f"להישמר כתקדים בר-ציטוט. {nature}\n"
"תן גם ציון-ביטחון 0-1 לכך שזהו עיקרון בר-הסתמכות אמיתי.\n"
'החזר JSON בלבד: {"keep": true/false, "score": 0.0-1.0, "reason": "<משפט קצר>"}. ללא markdown.'
)
async def panel_keep_score(
rule_statement: str,
supporting_quote: str,
reasoning_summary: str = "",
*,
source_kind: str = "external_upload",
is_binding: bool = True,
) -> dict:
"""Run the 3-judge panel on ONE existing principle (Phase C cull, #152).
Each judge votes keep + score; votes = # keepers, score = mean of the keepers'
scores (chaim: "ממוצע המצביעים"), verdict via the shared :func:`classify`.
Returns {votes, score, verdict, voters, per_judge} — per_judge keeps raw
replies for the active-learning round (FU-1). Used by the retroactive cull;
the extractor uses :func:`panel_extract` instead.
"""
import asyncio
system = _keep_score_system(source_kind, is_binding)
user = (f"ניסוח העיקרון:\n{rule_statement}\n\n"
f"היגיון:\n{reasoning_summary}\n\nציטוט תומך:\n{supporting_quote}")
async with httpx.AsyncClient() as client:
c, ds, gm = await asyncio.gather(
panel_judges.judge_claude(system, user, max_tokens=300),
panel_judges.judge_deepseek(client, system, user, max_tokens=300),
panel_judges.judge_gemini(client, system, user, max_tokens=2000),
)
raw = {"claude": c, "deepseek": ds, "gemini": gm}
keepers, scores = [], []
for name, reply in raw.items():
if panel_judges.to_bool(reply, "keep"):
keepers.append(name)
try:
scores.append(max(0.0, min(1.0, float(reply.get("score", 0.0)))))
except (TypeError, ValueError):
scores.append(0.0)
votes = len(keepers)
score = round(sum(scores) / votes, 4) if votes else 0.0
return {"votes": votes, "score": score, "verdict": classify(votes, score),
"voters": sorted(keepers), "per_judge": raw}
async def _run_three(system: str, user: str, max_tokens: int) -> dict[str, object]:
async with httpx.AsyncClient() as client:
import asyncio

View File

@@ -0,0 +1,45 @@
"""Legal-principles terminology — the single source for what a principle is CALLED (#152).
chaim 2026-06-19: "הלכה" was the wrong umbrella. The corpus holds **עקרונות
משפטיים** (legal principles); the term for one depends on its SOURCE:
• binding higher court (מחוזי/עליון) → "הלכה" (binding precedent)
• appeals committee (internal_committee) → "כלל פרשני" (interpretive rule —
the committee applies law, never makes it)
• non-binding external (persuasive) → "עיקרון" (persuasive principle)
The class is derived from where a principle was FIRST established
(canonical_halachot.first_established_in → case_law.source_kind/is_binding), so no
new column is needed. UI/tools call :func:`label` instead of hardcoding "הלכה".
"""
from __future__ import annotations
UMBRELLA = "עקרונות משפטיים"
CLASS_HALACHA = "halacha"
CLASS_INTERPRETIVE_RULE = "interpretive_rule"
CLASS_PRINCIPLE = "principle"
_LABEL = {
CLASS_HALACHA: "הלכה",
CLASS_INTERPRETIVE_RULE: "כלל פרשני",
CLASS_PRINCIPLE: "עיקרון",
}
def principle_class(source_kind: str | None, is_binding: bool | None) -> str:
"""Map a source to its principle class (stable key, not display text)."""
if source_kind == "internal_committee":
return CLASS_INTERPRETIVE_RULE
if is_binding:
return CLASS_HALACHA
return CLASS_PRINCIPLE
def label(source_kind: str | None, is_binding: bool | None) -> str:
"""Hebrew display term for a principle from this source (#152)."""
return _LABEL[principle_class(source_kind, is_binding)]
def label_for_class(cls: str) -> str:
return _LABEL.get(cls, _LABEL[CLASS_PRINCIPLE])

View File

@@ -407,7 +407,10 @@ async def canonical_halacha_list(
limit: int = 50,
offset: int = 0,
) -> str:
"""רשימת עקרונות קנוניים (canonical_halachot) — שאילתת נוחות לסוכני-הכתיבה.
"""רשימת עקרונות משפטיים קנוניים — שאילתת נוחות לסוכני-הכתיבה.
כל פריט כולל principle_label לפי מקורו (#152): 'הלכה' (פס"ד מחוזי/עליון מחייב),
'כלל פרשני' (החלטת ועדת-ערר), או 'עיקרון' (פסיקה משכנעת).
Args:
practice_area: סינון לפי תחום עיסוק (ריק = הכל).

View File

@@ -106,6 +106,25 @@ def test_cluster_same_model_twice_counts_one_vote_keeps_best_score():
assert cl["rule_statement"] == "X"
def test_apply_cap_downgrades_over_cap_survivors_by_score():
judged = [
{"verdict": "approved", "score": 0.9},
{"verdict": "approved", "score": 0.7},
{"verdict": "pending_review", "score": 0.8},
{"verdict": "rejected", "score": 0.95}, # already rejected stays
]
out = pe.apply_cap(judged, max_new=2)
fv = [j["final_verdict"] for j in out]
# top-2 survivors by score = 0.9(approved) + 0.8(pending); 0.7 → over cap → rejected
assert fv == ["approved", "rejected", "pending_review", "rejected"]
def test_apply_cap_keeps_all_when_under_cap():
judged = [{"verdict": "approved", "score": 0.9}, {"verdict": "pending_review", "score": 0.5}]
out = pe.apply_cap(judged, max_new=5)
assert [j["final_verdict"] for j in out] == ["approved", "pending_review"]
def test_cluster_sorted_strongest_first():
a = _c("X", 0.9) # 1 vote
b, c = _c("Y", 0.9), _c("Y", 0.9) # 2 votes

View File

@@ -0,0 +1,27 @@
"""Terminology mapping — הלכה / כלל פרשני / עיקרון by source (#152, Phase D)."""
from __future__ import annotations
from legal_mcp.services import principles as pr
def test_binding_higher_court_is_halacha():
assert pr.label("external_upload", True) == "הלכה"
assert pr.principle_class("external_upload", True) == pr.CLASS_HALACHA
def test_committee_is_interpretive_rule():
# the appeals committee applies law — never makes a הלכה
assert pr.label("internal_committee", True) == "כלל פרשני"
assert pr.label("internal_committee", False) == "כלל פרשני"
assert pr.principle_class("internal_committee", False) == pr.CLASS_INTERPRETIVE_RULE
def test_non_binding_external_is_principle():
assert pr.label("external_upload", False) == "עיקרון"
assert pr.label(None, None) == "עיקרון"
def test_label_for_class_roundtrip():
for sk, binding in [("external_upload", True), ("internal_committee", False), (None, False)]:
cls = pr.principle_class(sk, binding)
assert pr.label_for_class(cls) == pr.label(sk, binding)