haiku.rag/haiku_rag_slim/haiku/rag/capabilities/evidence.py
Yiorgis Gozadinos e1dd8517f9
Refuse to compact evidence the host kept no record of
Both optional capabilities read what earlier questions retrieved and cited from
the capability's state, so a host that carries only the message history hands
every run an empty record. Compaction then replaced the earlier evidence with
receipts and retained nothing, and the loss was invisible: the citations the host
already displayed were still there. It now refuses when it finds evidence from an
earlier question and no record of what that question cited.

`state_carried` reaches the optional capabilities through discovery, so the
refusal distinguishes a host that never carries state from a question that simply
cited nothing.

The documentation taught the pattern that breaks: the compose example is now
stateful and the requirement is stated where each capability is introduced.

The app's browser storage was doing exactly this, keeping only the fields the UI
reads. It now persists the whole namespace map, so the citation policy's
violations survive a reload as well as the evidence record.
2026-08-13 15:46:23 +03:00

65 lines
2.4 KiB
Python

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, cast
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 = [
DiscoveredEvidence(
capability=capability.state_namespace,
record=cast(CapabilityEvidenceRecord, cast(Any, capability.state).evidence),
citations=cast(Any, capability.state).citation_index,
tool_names=frozenset(capability.evidence_tool_names()),
cite_available=capability.cite_available,
state_carried=capability.state_carried,
)
for capability in ctx.capabilities.values()
if isinstance(capability, RAGCapabilityBase)
]
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",
]