feat(halacha): equivalent-halacha (parallel-authority) links across precedents
Cross-precedent recurrence of a principle is real but is NOT citation
corroboration (X11) — the 5 candidate pairs have ZERO citations between their
precedents. Recording them in halacha_citation_corroboration would fabricate
citation data and inflate corroboration_count. This adds a proper, separate
halacha-level link for parallel authority.
Schema (V28): equivalent_halachot — symmetric (halacha_a < halacha_b, CHECK +
UNIQUE), non-citation, cross-precedent-only. ON DELETE CASCADE.
db.py:
- link_equivalent_halachot (idempotent; rejects same-id and SAME-precedent pairs
— parallel authority is cross-precedent by definition), unlink, and
list_equivalent_for_halacha.
- list_halachot gains include_equivalents → _annotate_equivalents attaches an
`equivalents` list (both directions) per row.
API: include_equivalents on GET /api/halachot; GET/POST/DELETE
/api/halachot/{id}/equivalents for the chair to view/link/unlink manually.
scripts/halacha_batch_reconcile.py: --link records found cross-precedent pairs
as equivalent_halachot (non-destructive, idempotent).
web-ui: Halacha.equivalents type; the clean review queue fetches
include_equivalents; the review card shows a gold "עיקרון מקביל ב-N" badge + an
expandable list (case + rule + similarity) labeled "אסמכתה מקבילה — לא ציטוט".
Populated the 5 reviewed pairs (chair decision: keep all + link as parallel
authority). Verified: 5 rows; the 1023-20 hub annotates 3 of its halachot with
equivalents; tsc --noEmit exits 0.
Invariants: G1 (model recurrence at source in its own table, not by abusing the
citator); G2 (no parallel path — extends list_halachot); citator integrity
preserved (corroboration stays citation-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1232,6 +1232,30 @@ CREATE INDEX IF NOT EXISTS idx_style_exemplars_section ON style_exemplars(sectio
|
||||
CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source);
|
||||
"""
|
||||
|
||||
SCHEMA_V28_SQL = """
|
||||
-- equivalent_halachot (#84.2 follow-up): halacha-level PARALLEL-AUTHORITY links.
|
||||
-- Distinct from halacha_citation_corroboration (X11): that records an actual
|
||||
-- citation of a halacha by a later decision; this records that two halachot of
|
||||
-- DIFFERENT precedents state the same legal principle INDEPENDENTLY (no citation
|
||||
-- between them). Symmetric and non-directional — stored with halacha_a < halacha_b
|
||||
-- so each pair is unique and self-links are impossible. Never merges/deletes the
|
||||
-- halachot; it only relates them so the chair sees a principle recurs across
|
||||
-- committees (a real-but-non-citation signal the citator must not fabricate).
|
||||
CREATE TABLE IF NOT EXISTS equivalent_halachot (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
halacha_a UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||
halacha_b UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
|
||||
cosine NUMERIC(4,3) DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
CHECK (halacha_a < halacha_b),
|
||||
UNIQUE (halacha_a, halacha_b)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_a ON equivalent_halachot(halacha_a);
|
||||
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_b ON equivalent_halachot(halacha_b);
|
||||
"""
|
||||
|
||||
|
||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
@@ -1263,7 +1287,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V25_SQL)
|
||||
await conn.execute(SCHEMA_V26_SQL)
|
||||
await conn.execute(SCHEMA_V27_SQL)
|
||||
logger.info("Database schema initialized (v1-v27)")
|
||||
await conn.execute(SCHEMA_V28_SQL)
|
||||
logger.info("Database schema initialized (v1-v28)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -3795,6 +3820,7 @@ async def list_halachot(
|
||||
exclude_low_quality: bool = False,
|
||||
order_by_priority: bool = False,
|
||||
cluster: bool = False,
|
||||
include_equivalents: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List halachot with optional triage controls (#84).
|
||||
|
||||
@@ -3874,6 +3900,8 @@ async def list_halachot(
|
||||
out.append(d)
|
||||
if cluster and out:
|
||||
await _annotate_clusters(pool, out)
|
||||
if include_equivalents and out:
|
||||
await _annotate_equivalents(pool, out)
|
||||
return out
|
||||
|
||||
|
||||
@@ -4135,6 +4163,113 @@ async def store_corroboration(
|
||||
)
|
||||
|
||||
|
||||
# ── Parallel-authority (equivalent halachot) — #84.2 follow-up ───────────────
|
||||
#
|
||||
# A NON-citation, symmetric link between halachot of different precedents that
|
||||
# state the same principle. Kept entirely separate from the citation corroboration
|
||||
# above so the citator's counts never include non-citation recurrences.
|
||||
|
||||
def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]:
|
||||
"""Canonical ordering (halacha_a < halacha_b) so the pair is symmetric+unique."""
|
||||
return (a, b) if str(a) < str(b) else (b, a)
|
||||
|
||||
|
||||
async def link_equivalent_halachot(
|
||||
a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
|
||||
) -> bool:
|
||||
"""Record that two halachot (different precedents) state the same principle.
|
||||
|
||||
Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
|
||||
the two belong to the SAME precedent (parallel authority is cross-precedent
|
||||
by definition; within-precedent sameness is the dedup/cluster concern)."""
|
||||
if a == b:
|
||||
return False
|
||||
pool = await get_pool()
|
||||
same = await pool.fetchval(
|
||||
"SELECT (SELECT case_law_id FROM halachot WHERE id=$1) "
|
||||
" = (SELECT case_law_id FROM halachot WHERE id=$2)", a, b,
|
||||
)
|
||||
if same:
|
||||
return False
|
||||
lo, hi = _equiv_order(a, b)
|
||||
await pool.execute(
|
||||
"INSERT INTO equivalent_halachot (halacha_a, halacha_b, cosine, note, created_by) "
|
||||
"VALUES ($1,$2,$3,$4,$5) ON CONFLICT (halacha_a, halacha_b) DO UPDATE SET "
|
||||
"cosine=GREATEST(equivalent_halachot.cosine, EXCLUDED.cosine), "
|
||||
"note=COALESCE(NULLIF(EXCLUDED.note,''), equivalent_halachot.note)",
|
||||
lo, hi, round(float(cosine), 3), note, created_by,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def unlink_equivalent_halachot(a: UUID, b: UUID) -> bool:
|
||||
pool = await get_pool()
|
||||
lo, hi = _equiv_order(a, b)
|
||||
res = await pool.execute(
|
||||
"DELETE FROM equivalent_halachot WHERE halacha_a=$1 AND halacha_b=$2", lo, hi,
|
||||
)
|
||||
return res.endswith(" 1")
|
||||
|
||||
|
||||
async def list_equivalent_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||
"""The other halachot linked as parallel authority to this one (both sides)."""
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT e.cosine, h.id::text AS halacha_id, h.rule_statement, "
|
||||
" cl.case_number, cl.case_name "
|
||||
"FROM equivalent_halachot e "
|
||||
"JOIN halachot h ON h.id = CASE WHEN e.halacha_a=$1 THEN e.halacha_b ELSE e.halacha_a END "
|
||||
"JOIN case_law cl ON cl.id = h.case_law_id "
|
||||
"WHERE e.halacha_a=$1 OR e.halacha_b=$1 "
|
||||
"ORDER BY e.cosine DESC", halacha_id,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"halacha_id": r["halacha_id"],
|
||||
"rule_statement": r["rule_statement"],
|
||||
"case_number": r["case_number"],
|
||||
"case_name": r["case_name"],
|
||||
"cosine": float(r["cosine"]) if r["cosine"] is not None else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def _annotate_equivalents(pool, out: list[dict]) -> None:
|
||||
"""Attach an `equivalents` list to each row (#84.2) — parallel-authority links.
|
||||
|
||||
Adds both directions, so when both halachot of a pair are on the same page
|
||||
each one lists the other."""
|
||||
ids = [d["id"] for d in out]
|
||||
rows = await pool.fetch(
|
||||
"SELECT e.halacha_a, e.halacha_b, e.cosine, "
|
||||
" ha.rule_statement AS a_rule, cla.case_number AS a_case, "
|
||||
" hb.rule_statement AS b_rule, clb.case_number AS b_case "
|
||||
"FROM equivalent_halachot e "
|
||||
"JOIN halachot ha ON ha.id = e.halacha_a "
|
||||
"JOIN case_law cla ON cla.id = ha.case_law_id "
|
||||
"JOIN halachot hb ON hb.id = e.halacha_b "
|
||||
"JOIN case_law clb ON clb.id = hb.case_law_id "
|
||||
"WHERE e.halacha_a = ANY($1::uuid[]) OR e.halacha_b = ANY($1::uuid[])",
|
||||
ids,
|
||||
)
|
||||
idset = {str(i) for i in ids}
|
||||
by_src: dict[str, list[dict]] = {}
|
||||
for r in rows:
|
||||
a, b = str(r["halacha_a"]), str(r["halacha_b"])
|
||||
cos = float(r["cosine"]) if r["cosine"] is not None else None
|
||||
if a in idset:
|
||||
by_src.setdefault(a, []).append({
|
||||
"halacha_id": b, "case_number": r["b_case"],
|
||||
"rule_statement": r["b_rule"], "cosine": cos})
|
||||
if b in idset:
|
||||
by_src.setdefault(b, []).append({
|
||||
"halacha_id": a, "case_number": r["a_case"],
|
||||
"rule_statement": r["a_rule"], "cosine": cos})
|
||||
for d in out:
|
||||
d["equivalents"] = by_src.get(str(d["id"]), [])
|
||||
|
||||
|
||||
async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||
"""Return all corroboration rows for one halacha, ordered by match_score DESC."""
|
||||
pool = await get_pool()
|
||||
|
||||
Reference in New Issue
Block a user