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

@@ -91,7 +91,22 @@ async def main(args: argparse.Namespace) -> int:
w = csv.DictWriter(f, fieldnames=list(pairs[0].keys()))
w.writeheader()
w.writerows(pairs)
print(f"\nreport: {out} (review-only — nothing changed)", flush=True)
print(f"\nreport: {out}", flush=True)
if args.link and pairs:
# #84.2 — record each pair as parallel authority (equivalent_halachot).
# Non-destructive: links only, never merges/deletes. Idempotent.
linked = 0
for p in pairs:
if await db.link_equivalent_halachot(
p["id_a"], p["id_b"], cosine=p["cosine"],
note="cross-precedent parallel authority (halacha_batch_reconcile)",
created_by="batch_reconcile",
):
linked += 1
print(f"linked {linked}/{len(pairs)} pairs as equivalent_halachot", flush=True)
elif pairs:
print("(review-only — pass --link to record them as equivalent_halachot)", flush=True)
return 0
@@ -102,5 +117,7 @@ if __name__ == "__main__":
help="min cosine for a cross-precedent candidate (default 0.95)")
ap.add_argument("--include-pending", action="store_true",
help="also scan pending_review halachot (default: approved/published only)")
ap.add_argument("--link", action="store_true",
help="record found pairs as equivalent_halachot (parallel authority, #84.2)")
args = ap.parse_args()
sys.exit(asyncio.run(main(args)))