provinans / src /endopath /consensus.py
reversely's picture
Upload folder using huggingface_hub
eea689d verified
Raw
History Blame Contribute Delete
4.81 kB
"""Multi-modal consensus and disagreement gating between the LLM
extraction pass (llm_extraction.py, issue #7) and the ColPali visual
retrieval pass (colpali_retrieval.py, issue #8), per docs/prd.md section 7:
"Requiring the LLM extraction and an embedding-based check to agree
lowers the chance of a hallucinated value passing through unexamined."
What "agreement" means here, given the current data model: the LLM pass
produces a value with char-offset text evidence; ColPali's visual pass
produces no value at all, only a page-level match for a field's query
(sub-page localization is not built yet: #49, docs/prd.md section 4.6 --
so there is no character-level location to cross-check the LLM's evidence
offsets against). So this gate cannot ask "do both signals point to the same
value" or "the same location", only "did each signal find something at
all". That is a real, acknowledged limitation, not an oversight -- docs/prd.md
names this gate's blind spot directly: "It does not catch errors where
both fail for the same underlying reason, most likely on the worst
physically degraded scans... That specific case still needs a human, not
a second model vote." A field where the LLM confidently extracts a wrong
value AND ColPali confidently (wrongly) retrieves a page that happens to
look relevant will still gate as agree/high-confidence here. This module
narrows the more common failure -- one signal hallucinating while the
other finds nothing -- it does not and cannot catch a correlated failure.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Optional
from endopath.schema import ChecklistField, FieldStatus
# Margin threshold below which a visual retrieval is treated as "found
# nothing distinctive" rather than a real match. Provisional: chosen to be
# clearly below the smallest correct-match margin seen in
# notebooks/02_colpali_retrieval_exploration.ipynb's spot-checks, not
# calibrated against labeled data (issue #15 -- ground truth doesn't exist
# yet). Revisit once #15/#16 can measure this against real outcomes.
DEFAULT_VISUAL_MARGIN_THRESHOLD = 1.0
# Confidence assigned on agreement: the higher of the LLM's own confidence
# and this floor, so a cautious-but-correct LLM extraction still benefits
# from an independent visual signal agreeing with it.
AGREEMENT_CONFIDENCE_FLOOR = 0.85
# Confidence ceiling on disagreement -- forced down regardless of the LLM's
# own stated confidence (docs/prd.md section 7: this is *the gate*, not just a
# display signal, so a disagreement must not be overridable by a
# confidently-wrong LLM call).
DISAGREEMENT_CONFIDENCE_CEILING = 0.2
class ConsensusOutcome(str, Enum):
AGREE = "agree"
DISAGREE = "disagree"
BOTH_EMPTY = "both_empty"
def gate(
llm_value: Any,
llm_confidence: Optional[float],
visual_margin: Optional[float],
margin_threshold: float = DEFAULT_VISUAL_MARGIN_THRESHOLD,
) -> tuple[ConsensusOutcome, Optional[float]]:
"""Compares one field's LLM result against its visual-retrieval margin.
Returns (outcome, confidence). confidence is None only for BOTH_EMPTY,
where there is nothing for a confidence score to describe -- that's
the not-found path (issue #10's job to escalate further), not a low
number.
"""
llm_found = llm_value is not None
visual_found = visual_margin is not None and visual_margin >= margin_threshold
if llm_found and visual_found:
base = llm_confidence if llm_confidence is not None else AGREEMENT_CONFIDENCE_FLOOR
return ConsensusOutcome.AGREE, max(base, AGREEMENT_CONFIDENCE_FLOOR)
if not llm_found and not visual_found:
return ConsensusOutcome.BOTH_EMPTY, None
# Exactly one signal found something -- disagreement.
return ConsensusOutcome.DISAGREE, DISAGREEMENT_CONFIDENCE_CEILING
def apply_consensus_gate(
field: ChecklistField,
visual_margin: Optional[float],
margin_threshold: float = DEFAULT_VISUAL_MARGIN_THRESHOLD,
) -> ConsensusOutcome:
"""Runs the gate against one already-extracted ChecklistField and
mutates its confidence/search_log in place. Never sets status to
CONFIRMED -- every field still gets a human look regardless of
agreement (llm_extraction.py's own framing: "Not autonomous"); this
only changes how urgently it's flagged for review. Never sets FLAGGED
either -- that's issue #10's escalation state, earned only after the
full LLM/embedding/combined search comes back empty, not after a
single paired comparison.
"""
outcome, confidence = gate(field.value, field.confidence, visual_margin, margin_threshold)
field.confidence = confidence
field.status = FieldStatus.NEEDS_REVIEW
field.search_log.append(f"consensus gate: {outcome.value}")
return outcome