haiku.rag/haiku_rag_slim/haiku/rag/capabilities/evidence.py
Yiorgis Gozadinos cab510d0d5
Give the evidence capabilities one typed state
RAGState and AnalysisState each declared the same five fields, so the generic
base could not name them: StateT was bound to BaseModel, and every access
went through cast(Any, state), a getattr by string, or a loop clearing fields
by name so it could skip the one only AnalysisState has.

EvidenceState declares them once. RAGState adds nothing, AnalysisState adds
executions and overrides begin_invocation to clear them. StateT binds to
EvidenceState, which removes all ten casts and both state-shape getattrs; the
three getattr(ctx.deps, "state") probes stay, since those check a
host-supplied object rather than our own state.

discover_evidence reached into capability.state for two fields. It now asks
through evidence_record() and citation_index(), alongside the
evidence_tool_names() and cite_available accessors it already used. The eval
runner's _RagLikeState protocol and the chat app's getattr reads described
this shape from outside and are gone.

Compatibility is semantic JSON-object equivalence, not bytes: field names and
nesting are unchanged, so a dict stored by 0.75.0 loads and re-dumps equal,
but deriving from a shared base reorders AnalysisState's keys. Nothing
serializes, hashes or string-compares this state — every carry point
re-validates by key.
2026-08-19 17:08:44 +03:00

67 lines
2.4 KiB
Python

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from pydantic_ai import RunContext
from haiku.rag.capabilities._base import RAGCapabilityBase
from haiku.rag.capabilities.ledger import CapabilityEvidenceRecord
from haiku.rag.store.models.citation import Citation
@dataclass(frozen=True)
class DiscoveredEvidence:
"""One evidence capability's records, as another capability found them.
Read-only and rebuilt per request: whoever discovers these merges them into a
view and persists nothing about evidence itself.
"""
capability: str
record: CapabilityEvidenceRecord
citations: Mapping[str, Citation]
tool_names: frozenset[str]
cite_available: bool
state_carried: bool
def discover_evidence(ctx: RunContext[Any]) -> list[DiscoveredEvidence]:
"""Read what each evidence capability recorded, without writing anything.
Discovery runs one way through the run's capability registry, so no capability
holds a reference to another, and a host running one, both, or neither needs no
wiring change. The registry holds the per-run instances, which are the ones
carrying state; the registered objects never do. That includes a deferred
capability the model has not loaded, whose record is simply empty.
"""
discovered = []
for capability in ctx.capabilities.values():
if not isinstance(capability, RAGCapabilityBase):
continue
discovered.append(
DiscoveredEvidence(
capability=capability.state_namespace,
record=capability.evidence_record(),
citations=capability.citation_index(),
tool_names=frozenset(capability.evidence_tool_names()),
cite_available=capability.cite_available,
state_carried=capability.state_carried,
)
)
return sorted(discovered, key=lambda evidence: evidence.capability)
def question_in_progress(evidence: list[DiscoveredEvidence]) -> int:
"""The identity every evidence capability agrees this question has.
They all derive it from the same history, so they agree; taking the maximum
rather than a first entry keeps the result independent of ordering.
"""
return max((found.record.question or 0 for found in evidence), default=0)
__all__ = [
"DiscoveredEvidence",
"discover_evidence",
"question_in_progress",
]