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:
@@ -68,7 +68,9 @@ function HalachaCard({
|
||||
onSave: (patch: Partial<EditState>) => Promise<void>;
|
||||
}) {
|
||||
const variants = h.variants ?? [];
|
||||
const equivalents = h.equivalents ?? [];
|
||||
const [showVariants, setShowVariants] = useState(false);
|
||||
const [showEquiv, setShowEquiv] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<EditState>({
|
||||
rule_statement: h.rule_statement,
|
||||
@@ -122,6 +124,12 @@ function HalachaCard({
|
||||
+{variants.length} וריאנטים
|
||||
</Badge>
|
||||
)}
|
||||
{equivalents.length > 0 && (
|
||||
<Badge variant="outline"
|
||||
className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40">
|
||||
עיקרון מקביל ב-{equivalents.length}
|
||||
</Badge>
|
||||
)}
|
||||
<CorroborationBadge halacha={h} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -220,6 +228,38 @@ function HalachaCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{equivalents.length > 0 && (
|
||||
<div className="rounded-md border border-gold/30 bg-gold-wash/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEquiv((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[0.72rem] text-gold-deep hover:bg-gold-wash/70 transition-colors"
|
||||
aria-expanded={showEquiv}
|
||||
>
|
||||
{showEquiv ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
<span className="font-medium">
|
||||
עיקרון מקביל ב-{equivalents.length} החלטות אחרות (אסמכתה מקבילה)
|
||||
</span>
|
||||
<span className="me-auto text-ink-muted">לא ציטוט — הישנות עצמאית</span>
|
||||
</button>
|
||||
{showEquiv && (
|
||||
<ul className="px-4 pb-3 pt-1 space-y-2">
|
||||
{equivalents.map((e) => (
|
||||
<li key={e.halacha_id} className="text-[0.78rem] text-ink-soft leading-relaxed border-r-2 border-gold/30 pr-3" dir="rtl">
|
||||
<span className="font-semibold text-navy">{cleanCitation(e.case_number)}</span>
|
||||
{" — "}{e.rule_statement}
|
||||
{e.cosine != null && (
|
||||
<span className="text-[0.65rem] text-ink-muted tabular-nums ms-2">
|
||||
(דמיון {e.cosine.toFixed(2)})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 justify-end pt-1 border-t border-rule-soft">
|
||||
{editing ? (
|
||||
<>
|
||||
|
||||
@@ -97,6 +97,15 @@ export type Halacha = {
|
||||
* UI collapses them into one review card. cluster_size === 1 → singleton. */
|
||||
cluster_id?: string;
|
||||
cluster_size?: number;
|
||||
/* #84.2 parallel authority (present only when fetched with include_equivalents):
|
||||
* the SAME principle stated independently in OTHER precedents — recurrence, not
|
||||
* citation (distinct from corroboration_count). */
|
||||
equivalents?: {
|
||||
halacha_id: string;
|
||||
case_number: string;
|
||||
rule_statement: string;
|
||||
cosine: number | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type RelatedCase = {
|
||||
@@ -584,7 +593,7 @@ export function useHalachotPending(
|
||||
const qs = needsFix
|
||||
? `review_status=pending_review&exclude_low_quality=false&limit=${limit}`
|
||||
: `review_status=pending_review&exclude_low_quality=true`
|
||||
+ `&order_by_priority=true&cluster=true&limit=${limit}`;
|
||||
+ `&order_by_priority=true&cluster=true&include_equivalents=true&limit=${limit}`;
|
||||
return useQuery({
|
||||
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"],
|
||||
queryFn: async ({ signal }) => {
|
||||
|
||||
Reference in New Issue
Block a user