fraud_hunter_env / server /grader.py
VanshGoel1's picture
Upload folder using huggingface_hub
815d872 verified
Raw
History Blame Contribute Delete
31.1 kB
"""
RLVR Grader for the Government Fraud Hunter AI environment.
Format-First Hierarchical Curriculum (7 layers):
Layer 1: JSON Schema Gate
- Unparseable or schema-invalid action → FORMAT_GATE_PENALTY, episode ends
Layer 2: CoT Enforcement
- Missing <think> block → COT_MISSING_PENALTY (soft, non-terminal)
- Unclosed <think> → harder penalty
Layer 3: CoT Grounding Verifier
- Mentions of real entities in CoT → COT_GROUNDED_BONUS
- Hallucinated entity names in CoT → incremental penalty
Layer 4: Length Reward Schedule
- Steps ≤ 20: apply LENGTH_PENALTY_RATE per excess CoT token
- Steps > 20: disable (model has learned concision)
Layer 5: Duplicate Query Detection
- Exact repeat of a prior query → DUPLICATE_QUERY_PENALTY
Layer 6: NPI Strict Validation
- Provider extraction: npi_code must exactly match ground truth
- No partial credit; mismatch → NPI_MISMATCH_PENALTY
Layer 7: Per-Typology Reward Matrix
- Each confirmed contradiction earns CONTRADICTION_REWARD × typology multiplier
Bonus: Process-Based Causal Chain Scoring
- entity → shell_link → contradiction = complete proof chain → multiplier on terminal reward
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from fraud_hunter_env.models import (
ActionKind, ContradictionKind,
CASE_DISMISSED_REWARD, CASE_WON_REWARD, CASE_PARTIAL_REWARD,
CONTRADICTION_REWARD, CODEACT_BONUS, COT_GROUNDED_BONUS,
COT_MISSING_PENALTY, DOC_CLAIM_MATCH_BONUS, DUPLICATE_QUERY_PENALTY,
EXTRACT_ENTITY_REWARD, FORMAT_GATE_PENALTY, FraudHunterAction,
HALLUCINATED_ENTITY_PENALTY, HALLUCINATED_LINK_PENALTY,
LENGTH_PENALTY_PHASE_OUT_STEP,
LENGTH_PENALTY_RATE, LINK_SHELL_REWARD, NPI_EXACT_MATCH_BONUS,
NPI_MISMATCH_PENALTY, OCR_RECALL_BONUS, PDF_CHAIN_MULTIPLIER,
PROOF_CHAIN_MULTIPLIER, STEP_DECAY, TYPOLOGY_MULTIPLIERS,
)
from fraud_hunter_env.npi_utils import validate_npi_luhn
from fraud_hunter_env.schema import GT_KIND_SOURCES, TYPOLOGY_SOURCES
from pathlib import Path
from .data_loader import CaseHandle
from .sandbox import execute_code, execute_sql
# ─── OCR helpers ──────────────────────────────────────────────────────────────
def _ocr_pdf(pdf_abs_path: Path) -> tuple[Optional[str], Optional[str]]:
"""Extract text from a scanned PDF. Returns (text, error)."""
try:
import pdfplumber
except ImportError:
return None, "pdfplumber_not_installed"
if not pdf_abs_path.exists():
return None, f"pdf_not_found:{pdf_abs_path.name}"
try:
with pdfplumber.open(str(pdf_abs_path)) as pdf:
text = "\n".join((p.extract_text() or "") for p in pdf.pages)
return text, None
except Exception as exc:
return None, f"ocr_failed:{exc}"
def _normalize_field(value: Any) -> str:
"""Lower-case, strip, drop $/% so '$350.00' and '350.0' compare equal."""
if value is None:
return ""
s = str(value).strip().lower().replace("$", "").replace(",", "").replace("%", "")
try:
return f"{float(s):.4f}"
except ValueError:
return s
def _evidence_path_referenced(text: str) -> bool:
"""Detect a PDF / scanned_claims reference inside an evidence string."""
if not text:
return False
t = text.lower()
return ("scanned_claims/" in t) or t.startswith("doc_") or t.endswith(".pdf")
_BENE_ID_RE = re.compile(r"\bbene[_-]?[0-9]+\b", re.IGNORECASE)
_CLAIM_ID_RE = re.compile(r"\b(?:c|clm|claim)[_:-]?[a-z0-9_]+\b", re.IGNORECASE)
def _normalize_ocr_digits(text: str) -> str:
return (
text.upper()
.replace("O", "0")
.replace("I", "1")
.replace("L", "1")
.replace("S", "5")
.replace("B", "8")
)
def _canonicalize_evidence_token(value: str) -> str:
raw = (value or "").strip().lower()
if not raw:
return ""
if raw.startswith("beneficiary:") or raw.startswith("claim:"):
return raw
if raw.startswith("provider_npi:"):
prefix, _, suffix = raw.partition(":")
return f"{prefix}:{_normalize_ocr_digits(suffix).lower()}"
bene_match = _BENE_ID_RE.search(raw)
if bene_match:
token = bene_match.group(0).upper().replace("-", "_")
return f"beneficiary:{token}".lower()
if raw.startswith("c_") or raw.startswith("claim_") or raw.startswith("clm_"):
return f"claim:{raw.replace('claim_', 'c_')}".lower()
claim_match = _CLAIM_ID_RE.search(raw)
if claim_match:
token = claim_match.group(0).replace("claim:", "")
token = token.replace("claim_", "c_", 1)
token = token.replace("clm_", "c_", 1)
return f"claim:{token}".lower()
return raw
def _contradiction_match_type(
evidence_a: str,
evidence_b: str,
kind: str,
ground_truth: set[tuple[str, str, str]],
) -> str:
raw_pair = (evidence_a.lower(), evidence_b.lower(), kind)
if raw_pair in ground_truth or (raw_pair[1], raw_pair[0], kind) in ground_truth:
return "exact"
canonical_a = _canonicalize_evidence_token(evidence_a)
canonical_b = _canonicalize_evidence_token(evidence_b)
canonical_pair = (canonical_a, canonical_b, kind)
if canonical_pair in ground_truth or (canonical_pair[1], canonical_pair[0], kind) in ground_truth:
return "fuzzy"
return "none"
_COT_OPEN_RE = re.compile(r"<think>", re.IGNORECASE)
_COT_CLOSE_RE = re.compile(r"</think>", re.IGNORECASE)
_WORD_RE = re.compile(r"\b\w+\b")
# OCR output cap — tier-scaled. Tier-1 cases have small single-page CMS-1500
# forms; Tier-5 cases ship multi-page degraded scans where the contradiction
# fields can live past the first 2k chars. Defaults to 4000 for unknown tiers.
_OCR_CAP_BY_TIER: dict[int, int] = {1: 1500, 2: 2000, 3: 3000, 4: 4000, 5: 4000}
_OCR_CAP_DEFAULT: int = 4000
def _ocr_cap_for(tier: int) -> int:
return _OCR_CAP_BY_TIER.get(tier, _OCR_CAP_DEFAULT)
@dataclass
class GraderOutput:
reward: float
done: bool
feedback: str
tool_output: Optional[str] = None
base64_document: Optional[str] = None
hits: list[str] = field(default_factory=list) # human-readable reward ledger
proof_trace: list[str] = field(default_factory=list) # for causal chain scoring
def merge_tool(self, text: str) -> "GraderOutput":
self.tool_output = text
return self
# ─── Layer 1: Format Gate ─────────────────────────────────────────────────────
def format_gate(action_payload: dict[str, Any]) -> Optional[GraderOutput]:
"""Returns GraderOutput on schema failure (episode-terminating), None on success."""
try:
FraudHunterAction.model_validate(action_payload)
except Exception as exc:
return GraderOutput(
reward=FORMAT_GATE_PENALTY,
done=True,
feedback=f"schema_violation: {exc}",
hits=[f"format_gate={FORMAT_GATE_PENALTY}"],
)
return None
# ─── Layer 2 & 3: CoT Checker ─────────────────────────────────────────────────
def score_cot(
think_trace: Optional[str],
step_count: int,
real_names: set[str],
) -> tuple[float, list[str], list[str]]:
"""
Returns (cot_reward, cot_hits, cot_feedback_parts).
Checks presence, closure, length penalty, and entity grounding.
"""
cot_reward = 0.0
hits: list[str] = []
feedback: list[str] = []
if not think_trace:
cot_reward += COT_MISSING_PENALTY
hits.append(f"cot_missing={COT_MISSING_PENALTY}")
feedback.append("no_cot_trace")
return cot_reward, hits, feedback
has_open = bool(_COT_OPEN_RE.search(think_trace))
has_close = bool(_COT_CLOSE_RE.search(think_trace))
if has_open and not has_close:
cot_reward -= 5.0
hits.append("cot_unclosed=-5.0")
feedback.append("cot_unclosed")
# Layer 4: Length penalty (phases out after step 20)
if step_count <= LENGTH_PENALTY_PHASE_OUT_STEP:
words = _WORD_RE.findall(think_trace)
excess = max(0, len(words) - 150)
length_pen = LENGTH_PENALTY_RATE * excess
if length_pen < 0:
cot_reward += length_pen
hits.append(f"length_penalty={length_pen:.3f}")
feedback.append(f"cot_too_long({len(words)}_words)")
# Layer 3: Grounding — reward if CoT mentions real entity names
cot_lower = think_trace.lower()
grounded = sum(1 for n in real_names if n.lower() in cot_lower)
if grounded > 0:
bonus = COT_GROUNDED_BONUS * min(grounded, 3) # cap at 3x bonus
cot_reward += bonus
hits.append(f"cot_grounded={bonus:.1f}")
feedback.append(f"cot_grounded({grounded}_entities)")
return cot_reward, hits, feedback
# ─── Layer 5: Duplicate Detection ─────────────────────────────────────────────
def query_hash(action: FraudHunterAction) -> str:
"""Stable identity for an information-gathering action. Used to penalise
repeated queries. Includes ``python_code`` and ``pdf_path`` so duplicate
CodeAct probes and OCR re-reads are caught (they previously slipped through
because only the structured-query fields were hashed).
"""
payload = {
"kind": action.kind.value,
"entity_name": action.entity_name,
"entity_id": action.entity_id,
"beneficiary_id": action.beneficiary_id,
"claim_id": action.claim_id,
"sql_statement": action.sql_statement,
"python_code": action.python_code,
"pdf_path": action.pdf_path,
}
blob = json.dumps(payload, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
# ─── Layer 6: NPI Validator ───────────────────────────────────────────────────
def validate_npi(
npi_code: Optional[str],
extracted_name: str,
case: CaseHandle,
) -> tuple[float, str]:
"""
Returns (reward_delta, feedback_str).
Looks up the ground-truth NPI for the provider name and compares exactly.
Zero partial credit.
"""
if not npi_code:
return 0.0, "npi_not_provided"
# Look up ground-truth NPI from corporate_registry
cur = case.conn.execute(
"SELECT npi_code FROM corporate_registry WHERE entity_name = ? COLLATE NOCASE LIMIT 1",
(extracted_name,),
)
row = cur.fetchone()
if not row or not row[0]:
return 0.0, "npi_provider_not_in_registry"
gt_npi = str(row[0])
if npi_code == gt_npi:
return NPI_EXACT_MATCH_BONUS, f"npi_exact_match={gt_npi}"
else:
return NPI_MISMATCH_PENALTY, f"npi_mismatch(got={npi_code},expected={gt_npi})"
# ─── Main Grader ──────────────────────────────────────────────────────────────
def grade(
action: FraudHunterAction,
case: CaseHandle,
extracted: set[str],
linked: set[tuple[str, str]],
contradictions: set[tuple[str, str]],
submitted: bool,
step_count: int = 0,
proof_trace: Optional[list[str]] = None,
source_access_callback: Optional[Callable[[str], None]] = None,
sql_trace_callback: Optional[Callable[[str], None]] = None,
) -> GraderOutput:
"""
Score a single step. Caller owns state mutation; grader only reads.
Returns GraderOutput with reward, done, feedback, tool_output, hits.
"""
if proof_trace is None:
proof_trace = []
if submitted:
return GraderOutput(
reward=0.0, done=True,
feedback="episode_already_submitted",
hits=["post_submit_noop"],
)
reward = STEP_DECAY
hits: list[str] = [f"step_decay={STEP_DECAY}"]
feedback_parts: list[str] = []
tool_output: Optional[str] = None
done = False
new_proof_trace = list(proof_trace)
# Get all real entity names for CoT grounding
real_names = case.all_entity_names()
# Layer 2 & 3: CoT scoring
cot_r, cot_hits, cot_fb = score_cot(action.think_trace, step_count, real_names)
reward += cot_r
hits.extend(cot_hits)
feedback_parts.extend(cot_fb)
# Layer 5: Duplicate detection for query-type actions
if action.kind in (
ActionKind.QUERY_CORPORATE,
ActionKind.QUERY_MEDICARE,
ActionKind.SQL_QUERY,
ActionKind.CODE_ACT,
ActionKind.OCR_DOCUMENT,
):
qh = query_hash(action)
if qh in case.seen_queries:
reward += DUPLICATE_QUERY_PENALTY
hits.append(f"duplicate={DUPLICATE_QUERY_PENALTY}")
feedback_parts.append("duplicate_query")
case.seen_queries.add(qh)
# ── Action Dispatch ────────────────────────────────────────────────────────
if action.kind == ActionKind.QUERY_CORPORATE:
tool_output = case.query_corporate(action.entity_name, action.entity_id)
feedback_parts.append("corporate_registry_returned")
elif action.kind == ActionKind.QUERY_MEDICARE:
tool_output = case.query_medicare(action.beneficiary_id, action.claim_id)
feedback_parts.append("medicare_returned")
elif action.kind == ActionKind.SQL_QUERY:
output, err, rows = execute_sql(action.sql_statement or "", case.conn)
if err:
tool_output = f"SQL_ERROR: {err}"
feedback_parts.append("sql_error")
else:
tool_output = output
feedback_parts.append(f"sql_ok({rows}_rows)")
# Bonus for productive SQL (returned rows)
if rows > 0:
reward += min(rows * 0.5, 5.0) # cap at +5.0
hits.append(f"sql_rows_bonus={min(rows * 0.5, 5.0):.1f}")
elif action.kind == ActionKind.CODE_ACT:
stdout, err, stats = execute_code(
action.python_code or "",
case.conn,
case_dir=str(case.db_path.parent),
on_access=source_access_callback,
on_sql=sql_trace_callback,
)
if err:
tool_output = f"SANDBOX_ERROR:\n{err}"
feedback_parts.append("codeact_error")
else:
tool_output = stdout or "(no output)"
rows = int(stats.get("rows_returned", 0))
files_read = int(stats.get("files_read", 0))
directories_listed = int(stats.get("directories_listed", 0))
bonus = 0.0
if rows > 0:
bonus += CODEACT_BONUS * min(rows, 5)
if files_read > 0:
bonus += min(files_read, 3) * 2.5
if directories_listed > 0:
bonus += min(directories_listed, 2) * 1.0
if bonus > 0:
reward += bonus
hits.append(f"codeact_bonus={bonus:.1f}")
feedback_parts.append(
f"codeact_ok(rows={rows},files={files_read},dirs={directories_listed})"
)
elif action.kind == ActionKind.EXTRACT_ENTITY:
name = (action.extracted_name or "").strip()
kind = action.extracted_kind.value if action.extracted_kind else ""
gt_entities = {
(e["name"].lower(), e["kind"])
for e in case.ground_truth("entity")
if "name" in e and "kind" in e
}
if not name or name.lower() not in {n.lower() for n in real_names}:
reward += HALLUCINATED_ENTITY_PENALTY
hits.append(f"hallucination={HALLUCINATED_ENTITY_PENALTY}")
feedback_parts.append(f"hallucinated_entity:{name!r}")
elif (name.lower(), kind) in gt_entities and name.lower() not in extracted:
reward += EXTRACT_ENTITY_REWARD
hits.append(f"extract={EXTRACT_ENTITY_REWARD}")
feedback_parts.append(f"extracted:{name!r}")
new_proof_trace.append(f"entity:{name.lower()}")
# Layer 6: NPI validation for providers
if kind == "provider":
npi_delta, npi_fb = validate_npi(action.npi_code, name, case)
reward += npi_delta
hits.append(f"npi={npi_delta:.1f}")
feedback_parts.append(npi_fb)
else:
feedback_parts.append(f"already_extracted_or_off_target:{name!r}")
elif action.kind == ActionKind.LINK_SHELL:
child = (action.child_entity or "").lower()
parent = (action.parent_entity or "").lower()
gt_links = {
(l["child"].lower(), l["parent"].lower())
for l in case.ground_truth("shell_link")
}
if (child, parent) in gt_links and (child, parent) not in linked:
reward += LINK_SHELL_REWARD
hits.append(f"shell_link={LINK_SHELL_REWARD}")
feedback_parts.append(f"shell_link_confirmed:{child}{parent}")
new_proof_trace.append(f"link:{child}{parent}")
elif (child, parent) in linked:
feedback_parts.append(f"shell_link_already_recorded:{child}{parent}")
else:
# Penalise asserting a link between entities that don't exist or
# have no DB-grounded relationship. Mirrors HALLUCINATED_ENTITY_PENALTY
# for extract_entity so shotgun-guessing links isn't free.
real = case.all_entity_names()
real_lower = {n.lower() for n in real}
both_in_db = (child in real_lower) and (parent in real_lower)
if not both_in_db:
reward += HALLUCINATED_LINK_PENALTY
hits.append(f"hallucinated_link={HALLUCINATED_LINK_PENALTY}")
feedback_parts.append(f"hallucinated_link:{child}{parent}")
else:
feedback_parts.append(f"shell_link_unconfirmed:{child}{parent}")
elif action.kind == ActionKind.CLAIM_CONTRADICTION:
a = (action.evidence_a or "").lower()
b = (action.evidence_b or "").lower()
kind = action.contradiction_kind.value if action.contradiction_kind else ""
gt_ctr = {
(c["evidence_a"].lower(), c["evidence_b"].lower(), c["kind"])
for c in case.ground_truth("contradiction")
}
match_type = _contradiction_match_type(a, b, kind, gt_ctr)
already_seen = (a, b) in contradictions or (b, a) in contradictions
if match_type != "none" and not already_seen:
# Layer 7: typology multiplier
multiplier = TYPOLOGY_MULTIPLIERS.get(kind, 1.0)
typology_reward = CONTRADICTION_REWARD * multiplier
if match_type == "fuzzy":
typology_reward *= 0.4
# Multi-modal proof bonus: if either side cites a PDF, layer
# PDF_CHAIN_MULTIPLIER on top of the typology multiplier.
if _evidence_path_referenced(a) or _evidence_path_referenced(b):
typology_reward *= PDF_CHAIN_MULTIPLIER
hits.append(f"pdf_chain×{PDF_CHAIN_MULTIPLIER}")
feedback_parts.append("pdf_chain_proof")
reward += typology_reward
if match_type == "exact":
hits.append(f"contradiction={typology_reward:.1f}{multiplier})")
feedback_parts.append(f"contradiction_confirmed:{kind}{multiplier})")
else:
hits.append(f"contradiction_fuzzy={typology_reward:.1f}{multiplier})")
feedback_parts.append(f"contradiction_fuzzy_match:{kind}{multiplier})")
new_proof_trace.append(f"contradiction:{kind}:{a}{b}")
else:
feedback_parts.append(f"contradiction_unconfirmed:{kind}")
elif action.kind == ActionKind.OCR_DOCUMENT:
# Resolve the PDF path relative to the case directory.
rel_pdf = (action.pdf_path or "").lstrip("/").lstrip("\\")
case_root = case.db_path.parent
pdf_abs = (case_root / rel_pdf).resolve()
# Path-traversal guard: pdf must live under the case dir.
try:
pdf_abs.relative_to(case_root.resolve())
except ValueError:
tool_output = f"OCR_ERROR: path_outside_case:{rel_pdf!r}"
feedback_parts.append("ocr_path_violation")
else:
text, err = _ocr_pdf(pdf_abs)
if err:
tool_output = f"OCR_ERROR: {err}"
feedback_parts.append("ocr_error")
else:
# Cap the output to keep the agent's context tight; tier-scaled
# so harder cases (multi-page PDFs) get more headroom.
tool_output = (text or "")[:_ocr_cap_for(case.tier)]
encoded = base64.b64encode(pdf_abs.read_bytes()).decode("ascii")
feedback_parts.append(f"ocr_ok({len(text or '')}_chars)")
return GraderOutput(
reward=round(reward, 4),
done=done,
feedback="; ".join(feedback_parts) or "noop",
tool_output=tool_output,
base64_document=encoded,
hits=hits,
proof_trace=new_proof_trace,
)
elif action.kind == ActionKind.COMPARE_DOC_VS_CLAIM:
claim_id = action.claim_id or ""
extracted_fields = action.extracted_fields or {}
# Pull expected (printed-on-PDF) fields from evidence_documents.
try:
cur = case.conn.execute(
"SELECT expected_fields_json FROM evidence_documents WHERE claim_id = ?",
(claim_id,),
)
row = cur.fetchone()
except Exception as exc:
row = None
feedback_parts.append(f"compare_lookup_error:{exc}")
if not row or not row[0]:
tool_output = f"no_evidence_doc_for_claim:{claim_id!r}"
feedback_parts.append("compare_doc_missing")
else:
try:
expected: dict = json.loads(row[0])
except json.JSONDecodeError:
expected = {}
# Pull the live DB-of-record value for the same claim.
db_row: dict[str, Any] = {}
try:
cc = case.conn.execute(
"SELECT CLM_ID, PRF_PHYSN_NPI, HCPCS_CD, LINE_NCH_PMT_AMT, "
"CLM_FROM_DT FROM carrier_claims WHERE CLM_ID = ?",
(claim_id,),
)
r = cc.fetchone()
if r:
db_row = {
"claim_id": r[0], "npi": r[1], "hcpcs_code": r[2],
"amount": r[3], "service_date": r[4],
}
except Exception:
pass
ocr_correct = 0
doc_db_mismatch = 0
mismatched_keys: list[str] = []
for k, v in extracted_fields.items():
exp_v = expected.get(k)
db_v = db_row.get(k)
if exp_v is None:
continue
if _normalize_field(v) == _normalize_field(exp_v):
ocr_correct += 1
if db_v is not None and _normalize_field(exp_v) != _normalize_field(db_v):
doc_db_mismatch += 1
mismatched_keys.append(k)
ocr_bonus = OCR_RECALL_BONUS * min(ocr_correct, 5)
mismatch_bonus = DOC_CLAIM_MATCH_BONUS * doc_db_mismatch
reward += ocr_bonus + mismatch_bonus
if ocr_bonus > 0:
hits.append(f"ocr_recall={ocr_bonus:.1f}({ocr_correct}_fields)")
if mismatch_bonus > 0:
hits.append(f"doc_db_mismatch={mismatch_bonus:.1f}")
# Multi-modal proof: a doc-vs-DB mismatch is itself a proof step.
new_proof_trace.append(
f"doc_db_mismatch:{claim_id}:{','.join(mismatched_keys)}"
)
tool_output = (
f"ocr_correct={ocr_correct} mismatches={doc_db_mismatch} "
f"keys={mismatched_keys}"
)
feedback_parts.append(
f"compare_doc_vs_claim(correct={ocr_correct},mismatch={doc_db_mismatch})"
)
elif action.kind == ActionKind.SUBMIT_CASE:
done = True
won, partial = case_outcome(extracted, linked, contradictions, case)
if won:
# Bonus: causal chain multiplier (scaled by tier so a Tier-5 win
# actually requires a Tier-5-sized chain, not just one of each).
chain_complete = _check_proof_chain(new_proof_trace, case.tier)
base = CASE_WON_REWARD
if chain_complete:
base *= PROOF_CHAIN_MULTIPLIER
reward += base
hits.append(f"case_won={base:.1f}")
feedback_parts.append(f"case_won(chain_complete={chain_complete},tier={case.tier})")
elif partial:
reward += CASE_PARTIAL_REWARD
hits.append(f"case_partial={CASE_PARTIAL_REWARD}")
feedback_parts.append("case_partial")
else:
reward += CASE_DISMISSED_REWARD
hits.append(f"case_dismissed={CASE_DISMISSED_REWARD}")
feedback_parts.append("case_dismissed")
return GraderOutput(
reward=round(reward, 4),
done=done,
feedback="; ".join(feedback_parts) or "noop",
tool_output=tool_output,
hits=hits,
proof_trace=new_proof_trace,
)
# Win-threshold schedule: fraction of each ground-truth bucket the agent must
# cover for a CASE_WON outcome. Strict at low tiers (where the case is small
# enough to fully solve in 60 steps) and relaxed at high tiers (Tier-5 cases
# have ~26 GT items — requiring 100% coverage made CASE_WON practically
# unreachable, which dead-ended the policy gradient at high tiers).
_WIN_THRESHOLD_BY_TIER: dict[int, float] = {
1: 1.00,
2: 0.90,
3: 0.80,
4: 0.70,
5: 0.60,
}
def _required_chain_count(tier: int) -> int:
"""Per-bucket count the proof trace must cover to earn the multiplier.
Scales with tier so that one entity + one link + one contradiction does
not buy a 1.5× bonus on a 26-row Tier-5 case.
"""
return max(1, min(tier, 5))
def _check_proof_chain(proof_trace: list[str], tier: int = 1) -> bool:
"""Returns True if the trace covers at least ``_required_chain_count(tier)``
of each of {entity, link, contradiction}.
"""
required = _required_chain_count(tier)
n_entity = sum(1 for p in proof_trace if p.startswith("entity:"))
n_link = sum(1 for p in proof_trace if p.startswith("link:"))
n_contra = sum(1 for p in proof_trace if p.startswith("contradiction:"))
return n_entity >= required and n_link >= required and n_contra >= required
def case_outcome(
extracted: set[str],
linked: set[tuple[str, str]],
contradictions: set[tuple[str, str]],
case: CaseHandle,
) -> tuple[bool, bool]:
"""Returns (won, partial)."""
gt_entities = {e["name"].lower() for e in case.ground_truth("entity")}
gt_links = {(l["child"].lower(), l["parent"].lower()) for l in case.ground_truth("shell_link")}
gt_ctr = {
(
_canonicalize_evidence_token(c["evidence_a"]),
_canonicalize_evidence_token(c["evidence_b"]),
)
for c in case.ground_truth("contradiction")
}
norm_ctr = {
tuple(sorted((_canonicalize_evidence_token(pair[0]), _canonicalize_evidence_token(pair[1]))))
for pair in contradictions
}
gt_ctr_norm = {tuple(sorted(pair)) for pair in gt_ctr}
entities_hit = len(gt_entities & extracted)
links_hit = len(gt_links & linked)
contras_hit = len(gt_ctr_norm & norm_ctr)
total_gt = len(gt_entities) + len(gt_links) + len(gt_ctr)
total_hit = entities_hit + links_hit + contras_hit
# Tier-scaled win condition: each non-empty bucket must clear the tier's
# coverage threshold. Empty buckets are vacuously satisfied so cases with
# only contradictions (no shell_link planted) can still be won.
threshold = _WIN_THRESHOLD_BY_TIER.get(case.tier, 0.6)
def _bucket_ok(hit: int, total: int) -> bool:
return total == 0 or (hit / total) >= threshold
won = (
_bucket_ok(entities_hit, len(gt_entities))
and _bucket_ok(links_hit, len(gt_links))
and _bucket_ok(contras_hit, len(gt_ctr_norm))
)
partial = not won and total_gt > 0 and total_hit / total_gt >= 0.5
return won, partial
def compute_agentic_recall(
queried_tables: set[str],
case: CaseHandle,
) -> float:
"""
Agentic Recall = tables/evidence queried / gold required sources.
Multi-modal: includes the filesystem evidence directories because the AKS
golden thread lives half in SQL (PDE spike, general_ledger, referral_payments)
and half in the filesystem (intercepted_comms smoking gun, scanned_claims PDFs).
"""
gt_kinds = {row[0] for row in case.conn.execute("SELECT DISTINCT kind FROM ground_truth")}
gold: set[str] = set()
for kind in gt_kinds:
gold.update(GT_KIND_SOURCES.get(kind, frozenset()))
# Add typology-specific sources by reading the contradiction payloads.
try:
rows = case.conn.execute(
"SELECT payload_json FROM ground_truth WHERE kind = 'contradiction'"
).fetchall()
for (raw,) in rows:
try:
payload = json.loads(raw)
except json.JSONDecodeError:
continue
kind = payload.get("kind", "")
gold.update(TYPOLOGY_SOURCES.get(kind, frozenset()))
except Exception:
pass
if not gold:
return 1.0
return len(queried_tables & gold) / len(gold)