Files
stonks-oracle/services/intelligence_pipeline_v3/verification/verifier.py
T
Celes Renata a72f336ad1 feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the
monolithic 9B model extraction pipeline. CPU-first specialist services
handle routine extraction while the 9B vLLM model is preserved for
semantic adjudication of ambiguous cases.

Key components:
- Capability-aware inference gateway (OpenAI-compatible + Ollama)
- Endpoint registry with DB migrations and REST API
- Sentence-aware document segmenter (property tests)
- Deterministic financial parsing with offset integrity
- Symbol resolution with ambiguity detection
- Specialist service (GLiNER2, dynamic batching, K8s deployment)
- Company-specific sentiment (FinBERT, calibration)
- Retrieval-based novelty and duplicate detection
- Confidence calibration pipeline
- Deterministic routing engine (property tests)
- 9B adjudication layer with VRAM gating
- Stock-specific impact model (features, labels, baseline, trained)
- Pipeline orchestrator (state machine, queues, leases, feature flags)
- Bounded parallelism (async workers, semaphore, load shedding)
- Observability (tracing, metrics, alerts)
- Compatibility adapter (v3→v2 golden mapping tests)
- Shadow/canary promotion framework
- Active learning and fine-tuning pipeline

Test results: 1,161 tests pass, ruff lint clean.
All 282 spec tasks completed.
2026-07-13 02:14:59 +00:00

398 lines
14 KiB
Python

"""Core evidence verifier for Intelligence Pipeline v3.
Validates offsets, entity-evidence associations, and numeric consistency
for extracted candidates against source text and evidence spans.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from services.intelligence_pipeline_v3.verification.models import (
AssociationVerification,
NumericVerification,
OffsetVerification,
RejectedCandidate,
RejectionReason,
VerificationReport,
)
@dataclass
class EvidenceSpan:
"""Minimal evidence span representation for verification."""
id: str
start_char: int
end_char: int
text: str
@dataclass
class Entity:
"""Minimal entity representation for verification."""
id: str
literal_text: str
evidence_ids: list[str]
@dataclass
class NumericFact:
"""Minimal numeric fact representation for verification."""
id: str
literal_value: str
normalized_value: float | None
evidence_ids: list[str]
@dataclass
class Candidate:
"""Generic candidate for full verification."""
candidate_type: str
candidate_id: str
candidate_data: dict = field(default_factory=dict)
evidence_ids: list[str] = field(default_factory=list)
literal_text: str | None = None
normalized_value: float | None = None
# Regex for extracting numbers from text (integers, decimals, negative)
_NUMBER_RE = re.compile(r"-?\d[\d,]*\.?\d*")
def _extract_numbers(text: str) -> list[float]:
"""Extract all numeric values from a string."""
results: list[float] = []
for match in _NUMBER_RE.finditer(text):
try:
# Remove commas before parsing
cleaned = match.group().replace(",", "")
results.append(float(cleaned))
except ValueError:
continue
return results
class EvidenceVerifier:
"""Verifies evidence spans, entity associations, and numeric consistency.
Implements requirement 8 (Evidence Verification and Grounding):
- Validates span offsets match source text
- Checks entity text appears in linked evidence spans
- Verifies numeric values can be found in evidence text
- Produces a full verification report with rejected candidates
"""
def __init__(self, numeric_tolerance: float = 0.01) -> None:
"""Initialize the verifier.
Args:
numeric_tolerance: Relative tolerance for numeric comparison (default 1%).
"""
self._numeric_tolerance = numeric_tolerance
self._rejected: list[RejectedCandidate] = []
@property
def rejected_candidates(self) -> list[RejectedCandidate]:
"""All rejected candidates accumulated during verification."""
return list(self._rejected)
def reset(self) -> None:
"""Clear accumulated rejected candidates."""
self._rejected = []
def verify_offsets(
self, spans: list[EvidenceSpan], source_text: str
) -> list[OffsetVerification]:
"""Check that each span's text matches the source at declared offsets.
Args:
spans: Evidence spans with start_char, end_char, and text.
source_text: The full original document text.
Returns:
A list of OffsetVerification results, one per span.
"""
results: list[OffsetVerification] = []
for span in spans:
# Bounds check
if span.start_char < 0 or span.end_char > len(source_text):
results.append(
OffsetVerification(
span_id=span.id,
valid=False,
reason=(
f"Offset out of bounds: start={span.start_char}, "
f"end={span.end_char}, source_length={len(source_text)}"
),
)
)
self._reject_span(span, RejectionReason.INVALID_OFFSET)
continue
if span.end_char <= span.start_char:
results.append(
OffsetVerification(
span_id=span.id,
valid=False,
reason=f"Invalid range: end_char ({span.end_char}) <= start_char ({span.start_char})",
)
)
self._reject_span(span, RejectionReason.INVALID_OFFSET)
continue
# Extract text at offsets and compare
actual_text = source_text[span.start_char : span.end_char]
if actual_text == span.text:
results.append(OffsetVerification(span_id=span.id, valid=True))
else:
results.append(
OffsetVerification(
span_id=span.id,
valid=False,
reason=(
f"Text mismatch at [{span.start_char}:{span.end_char}]: "
f"expected {span.text!r}, found {actual_text!r}"
),
)
)
self._reject_span(span, RejectionReason.TEXT_MISMATCH)
return results
def verify_entity_association(
self, entity: Entity, evidence_spans: list[EvidenceSpan]
) -> AssociationVerification:
"""Check that entity text appears in at least one linked evidence span.
The check is case-insensitive to handle variation in capitalization.
Args:
entity: Entity with literal_text and evidence_ids.
evidence_spans: All available evidence spans.
Returns:
AssociationVerification indicating whether the entity is supported.
"""
# Build a map of span_id -> span for quick lookup
span_map = {s.id: s for s in evidence_spans}
entity_lower = entity.literal_text.lower()
for eid in entity.evidence_ids:
span = span_map.get(eid)
if span and entity_lower in span.text.lower():
return AssociationVerification(entity_id=entity.id, valid=True)
reason = (
f"Entity text {entity.literal_text!r} not found in any linked evidence span "
f"(checked {len(entity.evidence_ids)} spans)"
)
self._rejected.append(
RejectedCandidate(
candidate_type="entity",
candidate_data={
"entity_id": entity.id,
"literal_text": entity.literal_text,
"evidence_ids": entity.evidence_ids,
},
rejection_reason=RejectionReason.ENTITY_NOT_IN_EVIDENCE,
stage="entity_association_verification",
timestamp=datetime.now(tz=timezone.utc),
)
)
return AssociationVerification(
entity_id=entity.id, valid=False, reason=reason
)
def verify_numeric_consistency(
self, fact: NumericFact, evidence_spans: list[EvidenceSpan]
) -> NumericVerification:
"""Check that a numeric fact's value can be found in its evidence text.
Looks for the literal value or the normalized value within the text of
linked evidence spans. Uses tolerance-aware comparison for normalized values.
Args:
fact: Numeric fact with literal_value, normalized_value, and evidence_ids.
evidence_spans: All available evidence spans.
Returns:
NumericVerification indicating whether the value was found.
"""
span_map = {s.id: s for s in evidence_spans}
expected_str = fact.literal_value
# First: check if literal value string appears in any linked span
for eid in fact.evidence_ids:
span = span_map.get(eid)
if span and fact.literal_value in span.text:
return NumericVerification(
fact_id=fact.id,
valid=True,
found_value=fact.literal_value,
expected_value=expected_str,
)
# Second: if we have a normalized value, look for numeric matches
if fact.normalized_value is not None:
for eid in fact.evidence_ids:
span = span_map.get(eid)
if not span:
continue
numbers = _extract_numbers(span.text)
for num in numbers:
if self._values_match(num, fact.normalized_value):
return NumericVerification(
fact_id=fact.id,
valid=True,
found_value=str(num),
expected_value=expected_str,
)
# Rejection
reason = (
f"Value {expected_str!r} (normalized={fact.normalized_value}) "
f"not found in evidence spans"
)
self._rejected.append(
RejectedCandidate(
candidate_type="fact",
candidate_data={
"fact_id": fact.id,
"literal_value": fact.literal_value,
"normalized_value": fact.normalized_value,
"evidence_ids": fact.evidence_ids,
},
rejection_reason=RejectionReason.NUMERIC_INCONSISTENCY,
stage="numeric_consistency_verification",
timestamp=datetime.now(tz=timezone.utc),
)
)
return NumericVerification(
fact_id=fact.id,
valid=False,
found_value=None,
expected_value=expected_str,
reason=reason,
)
def verify_all(
self,
candidates: list[Candidate],
evidence_spans: list[EvidenceSpan],
source_text: str,
) -> VerificationReport:
"""Run full verification across all candidates.
Performs offset verification on spans, then entity association and
numeric consistency for each candidate as appropriate.
Args:
candidates: All extraction candidates to verify.
evidence_spans: All evidence spans in the document.
source_text: Full original document text.
Returns:
A VerificationReport summarizing the verification results.
"""
self.reset()
# Step 1: Verify all span offsets
offset_results = self.verify_offsets(evidence_spans, source_text)
valid_span_ids = {r.span_id for r in offset_results if r.valid}
# Step 2: Filter spans to only valid ones for downstream checks
valid_spans = [s for s in evidence_spans if s.id in valid_span_ids]
total = len(candidates)
verified = 0
for candidate in candidates:
# Filter candidate's evidence_ids to only valid spans
linked_valid_spans = [
s for s in valid_spans if s.id in set(candidate.evidence_ids)
]
if not linked_valid_spans and candidate.evidence_ids:
# All linked spans were invalid
self._rejected.append(
RejectedCandidate(
candidate_type=candidate.candidate_type,
candidate_data=candidate.candidate_data,
rejection_reason=RejectionReason.INVALID_OFFSET,
stage="full_verification",
timestamp=datetime.now(tz=timezone.utc),
)
)
continue
# Entity association check
if candidate.literal_text is not None:
entity = Entity(
id=candidate.candidate_id,
literal_text=candidate.literal_text,
evidence_ids=candidate.evidence_ids,
)
assoc = self.verify_entity_association(entity, valid_spans)
if not assoc.valid:
continue
# Numeric consistency check
if candidate.normalized_value is not None:
fact = NumericFact(
id=candidate.candidate_id,
literal_value=candidate.literal_text or str(candidate.normalized_value),
normalized_value=candidate.normalized_value,
evidence_ids=candidate.evidence_ids,
)
num_check = self.verify_numeric_consistency(fact, valid_spans)
if not num_check.valid:
continue
verified += 1
rejected = total - verified
coverage_rate = verified / total if total > 0 else 1.0
# Build rejection breakdown
breakdown: dict[str, int] = {}
for rc in self._rejected:
key = rc.rejection_reason.value
breakdown[key] = breakdown.get(key, 0) + 1
return VerificationReport(
total_candidates=total,
verified=verified,
rejected=rejected,
coverage_rate=coverage_rate,
rejection_breakdown=breakdown,
)
def _values_match(self, found: float, expected: float) -> bool:
"""Compare two numeric values with tolerance."""
if expected == 0:
return abs(found) < self._numeric_tolerance
return abs(found - expected) / abs(expected) <= self._numeric_tolerance
def _reject_span(self, span: EvidenceSpan, reason: RejectionReason) -> None:
"""Record a span rejection."""
self._rejected.append(
RejectedCandidate(
candidate_type="evidence_span",
candidate_data={
"span_id": span.id,
"start_char": span.start_char,
"end_char": span.end_char,
"text": span.text[:100], # Truncate for storage
},
rejection_reason=reason,
stage="offset_verification",
timestamp=datetime.now(tz=timezone.utc),
)
)