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:
2026-06-06 21:29:46 +00:00
parent 86f5797dbd
commit b7b44f4453
6 changed files with 249 additions and 6 deletions

View File

@@ -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()