fix(halacha): split authority (derived) from rule_role — stop source-conflation (INV-DM7)

The extractor classified rule_type by SOURCE bindingness (higher-court→binding,
committee→persuasive) instead of by rule KIND. The gold-set proved it: 'binding'
appeared on 19/19 external rulings & 0 committees; 'persuasive' on 13/13
committees & 0 external — only 58% agreement with the human role tags. The two
axes (authority vs rule role) were crammed into one enum.

This splits them per INV-DM7:
- authority (binding/persuasive) — DERIVED from case_law.precedent_level
  (עליון/מנהלי→binding, ועדת_ערר_מחוזית→persuasive), never stored, never
  LLM-guessed. New helper halacha_quality.derive_authority; surfaced read-only
  in list_halachot / goldset_list / search results.
- rule_type — now the rule ROLE only: holding/interpretive/procedural/
  application/obiter. Both extractor prompts unified to this vocabulary;
  _coerce_halacha no longer defaults rule_type from the source; legacy
  binding→holding / persuasive→interpretive fold for safety.

UI: authority shown as a separate read-only badge (gold=מחייב / muted=משכנע)
across the review queue, precedent detail, and gold-set; the gold-set role
selector drops binding/persuasive and adds מהותי (holding).

Migration: scripts/halacha_rule_role_backfill.py re-classifies the 276 pre-split
binding/persuasive rows into a genuine role via local claude_session (run after
deploy). Gold-set correct_type/ai_correct_type 'binding'→'holding' via SQL.

Sources (≥3, per research-decision policy): OASIS LegalRuleML v1.0
(appliesAuthority/Strength as metadata orthogonal to rule logic) · SemEval-2023
Task 6 LegalEval (rhetorical roles by function, authority kept separate) ·
Bluebook signals (weight-of-authority is a separate dimension).

Invariants: ESTABLISHES INV-DM7. Upholds G1 (normalize at source — extractor
classifies role, system derives authority) and G2 (single source of truth —
authority derived, not a parallel stored field). Tests: 211 pass + new
derive_authority/coerce coverage. web-ui build + tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:18:41 +00:00
parent 955675eb1f
commit 2e33cac043
16 changed files with 407 additions and 92 deletions

View File

@@ -0,0 +1,54 @@
"use client";
/**
* Shared halacha-classification labels + badge (INV-DM7 — two orthogonal axes).
*
* rule_type holds the rule ROLE (what KIND of statement). authority (binding vs
* persuasive) is a SEPARATE, DERIVED axis (where it came from) — rendered as a
* distinct read-only badge, never mixed into the role label.
*/
import { Badge } from "@/components/ui/badge";
/** rule ROLE labels. Legacy authority values (binding/persuasive) are kept as a
* fallback so pre-backfill rows still render a Hebrew word during rollout. */
export const RULE_TYPE_LABELS: Record<string, string> = {
holding: "עיקרון מהותי",
interpretive: "פרשני",
procedural: "פרוצדורלי",
application: "יישום",
obiter: "אמרת אגב",
// legacy (pre-split) — fold to role wording until backfill completes
binding: "עיקרון מהותי",
persuasive: "פרשני",
};
export function ruleTypeLabel(t: string | null | undefined): string {
return (t && RULE_TYPE_LABELS[t]) || t || "—";
}
export const AUTHORITY_LABELS: Record<string, string> = {
binding: "מחייב",
persuasive: "משכנע",
};
/** Read-only authority badge — derived from the source, the chair never sets it. */
export function AuthorityBadge({
authority,
}: {
authority?: string | null;
}) {
if (!authority || !AUTHORITY_LABELS[authority]) return null;
const isBinding = authority === "binding";
return (
<Badge
variant="outline"
title="דרגת-המחייבות נגזרת אוטומטית מזהות הערכאה"
className={
isBinding
? "text-[0.65rem] bg-gold/15 text-navy border-gold/50"
: "text-[0.65rem] bg-muted text-ink-muted border-border"
}
>
{AUTHORITY_LABELS[authority]}
</Badge>
);
}