Skip to content

zettelkasten.synapse.grounding_router

zettelkasten.synapse.grounding_router

The grounding + answer-gate layer of enforced epistemic honesty.

This is the layer that turns the deterministic status engine (:mod:zettelkasten.synapse.epistemics) into an actual ANSWER-vs-ABSTAIN verdict. The LLM reasons freely and returns a cited reasoning-DAG (:class:~zettelkasten.synapse.memdsl.schema.ReasoningDAG); this router owns the verdict, so fabrication is structurally impossible and abstention is automatic when grounding or coverage breaks. It has four responsibilities:

  1. An extensible verifier registry (:class:VerifierRegistry) keyed by a verifier-type string, each mapping a store-native grounding check (quote / expr-recompute / equation / figure / decision_tree) onto ONE normalized :class:VerifierResult (ok / failed / unverifiable + an optional status contribution). A new verifier-type (SQL-exec, temporal-freshness) is a new registry entry — the normalized contract never has to be re-cut.

  2. A citation-anchored prose↔DAG traceability gate (:func:check_traceability): every non-trivial prose assertion must be licensed by a SINGLE cited verified claim under a FULL-CONTENT MATCH — the prose sentence's content-word sequence must EQUAL that ONE claim's whole content-word sequence (order preserved; the droppable-word set is EMPTY, so this is a VERBATIM content match modulo case/punctuation/whitespace), its numbers placed in that claim's own clause. This gate is EXTRACTIVE and sound with NO word classification at all; its ATOMIC-CLAIM PRECONDITION is that a claim is a single-fact assertion. A COMPOUND claim is DETERMINISTICALLY atomized into its sub-propositions (reusing the same clause boundaries the number gate uses), so prose that faithfully restates ONE fact of a compound claim GROUNDS by full-matching that fact's sub-clause, while a full restatement still matches the whole. Paraphrastic restatement (a non-verbatim rewrite of a fact) still OVER-ABSTAINS by design (delegated to the LLM as INFERRED, never GROUNDED). Licensing never pools across claims, so an ORPHAN assertion (uncited, novel, a non-matching partial restatement, or a re-contextualized number) is a coverage break, never allowed. An additive, in-memory :class:~zettelkasten.synapse.memdsl.schema.ProseCitation binding supplies the per-assertion→claim link; absent it (the production path today, where the wire @answer carries no prose binding) the gate falls back to the strictest whole-prose-stream check.

  3. Coverage-gated abstention — when grounding or coverage breaks, the router emits a FIRST-CLASS :class:~zettelkasten.synapse.memdsl.schema.Abstention (the contract type, reused rather than reinvented) carrying reason tokens from :data:~zettelkasten.synapse.memdsl.schema.ABSTAIN_REASONS (missing-hop / stale / false-premise / low-coverage). Coverage is read from the deterministic :mod:zettelkasten.coverage primitives' blocking_count / _coverage_matrix shapes (accepted as an injected report so the router stays pure and store-free).

  4. A closed-book-vs-open-book confabulation hook (:data:ConfabulationDetector) — a real, injected, CALLED seam that flags when an answer could be produced without the retrieved substrate (a confabulation signal). The v1 default is deliberately simple; a fuller closed-book probe slots in without a signature change.

Design invariants, mirrored from the epistemics layer:

  • Grounding is structural, never the LLM's say-so. The router NEVER trusts an agent-asserted status: it consumes :func:~zettelkasten.synapse.epistemics.check_honesty verdicts and floors any agent upgrade to the deterministically-derived status.
  • Claim decomposition is contract-side. The reasoning-DAG is the decomposition — there is no post-hoc claim extractor. Imperfect atomicity is SAFE by construction: a weaker/mixed conjunct lowers the propagated status (weakest-conjunct → lower status → over-abstain), and can never fabricate.
  • Pure and deterministic. Verifier callables, the confabulation detector, and the coverage report are all injected, so the router runs in tests with no LLM, no git, and no live stores. Output ordering is deterministic.

VerifierStatus

Bases: str, Enum

The normalized outcome every wrapped verifier maps its native return into.

  • OK — the verifier confirms the claim's grounding (the tether/quote/ recomputation/clause holds). Contributes a VERIFIED status.
  • FAILED — the verifier DEFINITIVELY refutes the grounding (a fabricated quote absent from the source, a data value that did not reproduce, invalid LaTeX, an excluded decision-tree clause). This is a FALSE PREMISE.
  • UNVERIFIABLE — the verifier cannot decide here (source text unavailable, derivation not registered in this environment, an unresolved clause, a missing figure snapshot). Treated CONSERVATIVELY — it never grounds, but it is not a definitive refutation either.
Source code in zettelkasten/synapse/grounding_router.py
class VerifierStatus(str, Enum):
    """The normalized outcome every wrapped verifier maps its native return into.

    * ``OK`` — the verifier confirms the claim's grounding (the tether/quote/
      recomputation/clause holds). Contributes a VERIFIED status.
    * ``FAILED`` — the verifier DEFINITIVELY refutes the grounding (a fabricated
      quote absent from the source, a data value that did not reproduce, invalid
      LaTeX, an excluded decision-tree clause). This is a FALSE PREMISE.
    * ``UNVERIFIABLE`` — the verifier cannot decide here (source text unavailable,
      derivation not registered in this environment, an unresolved clause, a
      missing figure snapshot). Treated CONSERVATIVELY — it never grounds, but it
      is not a definitive refutation either.
    """

    OK = "ok"
    FAILED = "failed"
    UNVERIFIABLE = "unverifiable"

VerifierResult dataclass

One verifier's outcome, normalized across every verifier-type.

status is the tri-state verdict; detail a short human note (usually the native verifier's method/reason); verdict is the OPTIONAL status contribution the verifier proposes for the substrate node it grounds — a :class:~zettelkasten.synapse.epistemics.StatusVerdict the router feeds into the epistemics propagation as the cited node's per-node status. When None the router derives a conservative default from status (see :func:verdict_for_status), so every verifier need not spell out a verdict.

method is the OPTIONAL machine-readable match method the native verifier reported (e.g. verify_quote's "exact" | "fuzzy" | "annotation"). It is purely ADDITIVE (default None) and never changes what verifies or grounds; it exists so the router can decide licensing separately from grounding — an "exact" needle or an "annotation" highlight is provably a substring of the SOURCE and so may confer an extractive restatement license (see :func:_confirmed_verifier_text). A fuzzy match still VERIFIES and GROUNDS the node, but its accepted window may diverge from the supplied body, so it confers NO licensing text.

confirmed_source_text is the OPTIONAL text the verifier actually PROVED is present in the SOURCE — the matched span, NOT any caller-supplied body. For a quote it is the reader-note-stripped, normalized needle that verify_quote confirmed was a substring of the source (for an exact match), or the real Zotero highlight span the body overlaps (for an annotation match); None for fuzzy/absent. It is purely ADDITIVE (default None) and is the ONLY text the restatement floor licenses off a verifier — :func:_confirmed_verifier_text returns it (never the caller's payload body), which structurally excludes reader-note tails and any other never-matched caller text.

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class VerifierResult:
    """One verifier's outcome, normalized across every verifier-type.

    ``status`` is the tri-state verdict; ``detail`` a short human note (usually the
    native verifier's ``method``/``reason``); ``verdict`` is the OPTIONAL status
    contribution the verifier proposes for the substrate node it grounds — a
    :class:`~zettelkasten.synapse.epistemics.StatusVerdict` the router feeds into
    the epistemics propagation as the cited node's per-node status. When ``None``
    the router derives a conservative default from ``status`` (see
    :func:`verdict_for_status`), so every verifier need not spell out a verdict.

    ``method`` is the OPTIONAL machine-readable match method the native verifier
    reported (e.g. ``verify_quote``'s ``"exact" | "fuzzy" | "annotation"``). It is
    purely ADDITIVE (default ``None``) and never changes what verifies or grounds;
    it exists so the router can decide *licensing* separately from grounding — an
    ``"exact"`` needle or an ``"annotation"`` highlight is provably a substring of
    the SOURCE and so may confer an extractive restatement license (see
    :func:`_confirmed_verifier_text`). A ``fuzzy`` match still VERIFIES and GROUNDS
    the node, but its accepted window may diverge from the supplied body, so it
    confers NO licensing text.

    ``confirmed_source_text`` is the OPTIONAL text the verifier actually PROVED is
    present in the SOURCE — the matched span, NOT any caller-supplied body. For a
    quote it is the reader-note-stripped, normalized needle that ``verify_quote``
    confirmed was a substring of the source (for an ``exact`` match), or the real
    Zotero highlight span the body overlaps (for an ``annotation`` match); ``None``
    for fuzzy/absent. It is purely ADDITIVE (default ``None``) and is the ONLY text
    the restatement floor licenses off a verifier —
    :func:`_confirmed_verifier_text` returns it (never the caller's payload body),
    which structurally excludes reader-note tails and any other never-matched
    caller text.
    """

    status: VerifierStatus
    detail: str = ""
    verdict: StatusVerdict | None = None
    method: str | None = None
    confirmed_source_text: str | None = None

    @property
    def ok(self) -> bool:
        return self.status is VerifierStatus.OK

    @property
    def failed(self) -> bool:
        return self.status is VerifierStatus.FAILED

VerifierRegistry dataclass

An extensible dispatch table mapping verifier-type → :class:Verifier.

New verifier-types (SQL-exec, temporal-freshness, …) are added with :meth:register WITHOUT changing the normalized :class:VerifierResult contract — that is the whole point of keying on a type string. Dispatch is total and safe: an UNKNOWN verifier-type resolves to a conservative UNVERIFIABLE result rather than raising, so a mis-typed grounding can never crash a reasoning turn (it simply fails to ground).

Source code in zettelkasten/synapse/grounding_router.py
@dataclass
class VerifierRegistry:
    """An extensible dispatch table mapping verifier-type → :class:`Verifier`.

    New verifier-types (SQL-exec, temporal-freshness, …) are added with
    :meth:`register` WITHOUT changing the normalized :class:`VerifierResult`
    contract — that is the whole point of keying on a type string. Dispatch is
    total and safe: an UNKNOWN verifier-type resolves to a conservative
    ``UNVERIFIABLE`` result rather than raising, so a mis-typed grounding can
    never crash a reasoning turn (it simply fails to ground).
    """

    _verifiers: dict[str, Verifier] = field(default_factory=dict)

    def register(self, verifier_type: str, fn: Verifier) -> None:
        """Register (or override) the verifier for ``verifier_type``."""
        self._verifiers[verifier_type] = fn

    def get(self, verifier_type: str) -> "Verifier | None":
        """Return the verifier for ``verifier_type``, or ``None`` if unregistered."""
        return self._verifiers.get(verifier_type)

    def types(self) -> list[str]:
        """The registered verifier-types, sorted for deterministic display."""
        return sorted(self._verifiers)

    def verify(self, verifier_type: str, payload: Mapping[str, Any] | None) -> VerifierResult:
        """Dispatch ``payload`` to the verifier for ``verifier_type``.

        An unregistered type yields a conservative ``UNVERIFIABLE`` result (the
        grounding simply does not resolve) so dispatch never raises.
        """
        fn = self._verifiers.get(verifier_type)
        if fn is None:
            return VerifierResult(
                VerifierStatus.UNVERIFIABLE,
                detail=f"no verifier registered for type {verifier_type!r}",
                verdict=_UNVERIFIABLE_VERDICT,
            )
        return fn(payload or {})

register

register(verifier_type: str, fn: Verifier) -> None

Register (or override) the verifier for verifier_type.

Source code in zettelkasten/synapse/grounding_router.py
def register(self, verifier_type: str, fn: Verifier) -> None:
    """Register (or override) the verifier for ``verifier_type``."""
    self._verifiers[verifier_type] = fn

get

get(verifier_type: str) -> 'Verifier | None'

Return the verifier for verifier_type, or None if unregistered.

Source code in zettelkasten/synapse/grounding_router.py
def get(self, verifier_type: str) -> "Verifier | None":
    """Return the verifier for ``verifier_type``, or ``None`` if unregistered."""
    return self._verifiers.get(verifier_type)

types

types() -> list[str]

The registered verifier-types, sorted for deterministic display.

Source code in zettelkasten/synapse/grounding_router.py
def types(self) -> list[str]:
    """The registered verifier-types, sorted for deterministic display."""
    return sorted(self._verifiers)

verify

verify(verifier_type: str, payload: Mapping[str, Any] | None) -> VerifierResult

Dispatch payload to the verifier for verifier_type.

An unregistered type yields a conservative UNVERIFIABLE result (the grounding simply does not resolve) so dispatch never raises.

Source code in zettelkasten/synapse/grounding_router.py
def verify(self, verifier_type: str, payload: Mapping[str, Any] | None) -> VerifierResult:
    """Dispatch ``payload`` to the verifier for ``verifier_type``.

    An unregistered type yields a conservative ``UNVERIFIABLE`` result (the
    grounding simply does not resolve) so dispatch never raises.
    """
    fn = self._verifiers.get(verifier_type)
    if fn is None:
        return VerifierResult(
            VerifierStatus.UNVERIFIABLE,
            detail=f"no verifier registered for type {verifier_type!r}",
            verdict=_UNVERIFIABLE_VERDICT,
        )
    return fn(payload or {})

GroundingSpec dataclass

How one cited substrate ref is grounded: a verifier-type + its payload.

The router runs registry.verify(verifier_type, payload) and feeds the resulting per-node verdict into the epistemics propagation as the cited node's status. payload is opaque to the router — its keys are the native verifier's arguments (see the default wrappers in :func:default_registry).

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class GroundingSpec:
    """How one cited substrate ref is grounded: a verifier-type + its payload.

    The router runs ``registry.verify(verifier_type, payload)`` and feeds the
    resulting per-node verdict into the epistemics propagation as the cited node's
    status. ``payload`` is opaque to the router — its keys are the native
    verifier's arguments (see the default wrappers in :func:`default_registry`).
    """

    verifier_type: str
    payload: Mapping[str, Any] = field(default_factory=dict)

TraceabilityResult dataclass

The verdict of the citation-anchored prose↔DAG traceability gate.

ok is True when every non-trivial prose assertion is licensed by a single cited verified claim (no orphan). orphans lists the offending assertions — unlicensed clauses/spans, uncited:<word> tokens outside every citation span, and number:<tok> fabricated / re-contextualized numbers — deterministically ordered for a caller to surface.

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class TraceabilityResult:
    """The verdict of the citation-anchored prose↔DAG traceability gate.

    ``ok`` is ``True`` when every non-trivial prose assertion is licensed by a
    single cited verified claim (no orphan). ``orphans`` lists the offending
    assertions — unlicensed clauses/spans, ``uncited:<word>`` tokens outside every
    citation span, and ``number:<tok>`` fabricated / re-contextualized numbers —
    deterministically ordered for a caller to surface.
    """

    ok: bool
    orphans: tuple[str, ...] = ()
    detail: str = ""

ConfabulationSignal dataclass

Whether an answer could be produced WITHOUT the retrieved substrate.

closed_book True means the answer is not anchored in any verified retrieved ground — a confabulation risk (the model could have produced it from parametric memory alone). detail is a short human note. Advisory in v1: reported always, and it only tips a NON-verified conclusion further toward abstention; it never overrides an otherwise-verified, grounded answer.

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class ConfabulationSignal:
    """Whether an answer could be produced WITHOUT the retrieved substrate.

    ``closed_book`` True means the answer is not anchored in any verified retrieved
    ground — a confabulation risk (the model could have produced it from parametric
    memory alone). ``detail`` is a short human note. Advisory in v1: reported
    always, and it only tips a NON-verified conclusion further toward abstention;
    it never overrides an otherwise-verified, grounded answer.
    """

    closed_book: bool
    detail: str = ""

GroundingContext dataclass

The bundle the confabulation detector reasons over.

Carries the DAG, the prose answer, the merged per-node verdicts, the re-derived per-claim verdicts, the verifier results, and the resolved conclusion id — everything a closed-book probe needs, so a richer detector can replace the v1 default without a signature change.

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class GroundingContext:
    """The bundle the confabulation detector reasons over.

    Carries the DAG, the prose answer, the merged per-node verdicts, the
    re-derived per-claim verdicts, the verifier results, and the resolved
    conclusion id — everything a closed-book probe needs, so a richer detector can
    replace the v1 default without a signature change.
    """

    dag: ReasoningDAG
    prose: str
    node_verdicts: Mapping[str, StatusVerdict]
    claim_verdicts: Mapping[str, StatusVerdict]
    verifier_results: Mapping[str, VerifierResult]
    conclusion: str

RouterDecision dataclass

The first-class ANSWER-vs-ABSTAIN verdict the router owns.

verdict is :data:ANSWER or :data:ABSTAIN. On ANSWER, status is the conclusion's deterministically-derived WIRE status and abstention is None; on ABSTAIN, reasons carries the ordered reason tokens (a subset of :data:ABSTAIN_REASONS) and abstention is the reused contract-type :class:~zettelkasten.synapse.memdsl.schema.Abstention. The remaining fields expose the full derivation (honesty report, per-node and per-claim verdicts, verifier results, traceability, confabulation) for inspection.

outdated is the PRIMARY, inseparable committed-drift disclosure: it is True when the conclusion's verdict is outdated (grounded as of a superseded pin — the file has since changed). It is carried on the decision itself so a consumer of an ANSWER can never present an outdated-grounded conclusion as fresh-grounded; it is NOT an abstain reason (an outdated conclusion still ANSWERS).

Source code in zettelkasten/synapse/grounding_router.py
@dataclass(frozen=True)
class RouterDecision:
    """The first-class ANSWER-vs-ABSTAIN verdict the router owns.

    ``verdict`` is :data:`ANSWER` or :data:`ABSTAIN`. On ANSWER, ``status`` is the
    conclusion's deterministically-derived WIRE status and ``abstention`` is
    ``None``; on ABSTAIN, ``reasons`` carries the ordered reason tokens (a subset
    of :data:`ABSTAIN_REASONS`) and ``abstention`` is the reused contract-type
    :class:`~zettelkasten.synapse.memdsl.schema.Abstention`. The remaining fields
    expose the full derivation (``honesty`` report, per-node and per-claim
    verdicts, verifier results, traceability, confabulation) for inspection.

    ``outdated`` is the PRIMARY, inseparable committed-drift disclosure: it is
    ``True`` when the conclusion's verdict is ``outdated`` (grounded as of a
    superseded pin — the file has since changed). It is carried on the decision
    itself so a consumer of an ANSWER can never present an outdated-grounded
    conclusion as fresh-grounded; it is NOT an abstain reason (an outdated
    conclusion still ANSWERS).
    """

    verdict: str
    status: EpistemicStatus | None = None
    reasons: tuple[str, ...] = ()
    abstention: Abstention | None = None
    honesty: HonestyReport | None = None
    node_verdicts: dict[str, StatusVerdict] = field(default_factory=dict)
    claim_verdicts: dict[str, StatusVerdict] = field(default_factory=dict)
    verifier_results: dict[str, VerifierResult] = field(default_factory=dict)
    traceability: TraceabilityResult | None = None
    confabulation: ConfabulationSignal | None = None
    conclusion: str = ""
    detail: str = ""
    outdated: bool = False

    @property
    def answered(self) -> bool:
        return self.verdict == ANSWER

verdict_for_status

verdict_for_status(status: VerifierStatus) -> StatusVerdict

The conservative default :class:StatusVerdict for a normalized status.

OKgrounded (a plain verified tier; a verifier that means measured — e.g. data recomputation — attaches its own richer verdict); FAILEDinferred (the ground did not hold); UNVERIFIABLEinferred + unresolved taint (cannot confirm, never clean).

Source code in zettelkasten/synapse/grounding_router.py
def verdict_for_status(status: VerifierStatus) -> StatusVerdict:
    """The conservative default :class:`StatusVerdict` for a normalized status.

    ``OK`` → ``grounded`` (a plain verified tier; a verifier that means
    ``measured`` — e.g. data recomputation — attaches its own richer verdict);
    ``FAILED`` → ``inferred`` (the ground did not hold); ``UNVERIFIABLE`` →
    ``inferred`` + ``unresolved`` taint (cannot confirm, never clean).
    """
    if status is VerifierStatus.OK:
        return _OK_VERDICT
    if status is VerifierStatus.FAILED:
        return _FAILED_VERDICT
    return _UNVERIFIABLE_VERDICT

normalize_quote_result

normalize_quote_result(res: Mapping[str, Any]) -> VerifierResult

Normalize :func:zettelkasten.grounding.verify_quote's return.

Native shape: {verified, method, score, page, near_miss}. A verified quote is grounded; a quote that is simply not present in the source (method == "none") is a FALSE PREMISE (a fabricated/mis-remembered quote); an unavailable source (method == "unavailable") is UNVERIFIABLE (cannot check here, never a refutation).

The native method ("exact" | "fuzzy" | "annotation" | ...) is carried verbatim onto :attr:VerifierResult.method — an ADDITIVE surface that leaves what verifies/grounds unchanged but lets the restatement floor gate licensing on a source-derived match (see :func:_confirmed_verifier_text). The native confirmed_source_text (the source-matched span, populated for an exact needle or an annotation highlight, None for fuzzy) is likewise carried onto :attr:VerifierResult.confirmed_source_text — the only text the floor may license off this verifier, never the caller body.

Source code in zettelkasten/synapse/grounding_router.py
def normalize_quote_result(res: Mapping[str, Any]) -> VerifierResult:
    """Normalize :func:`zettelkasten.grounding.verify_quote`'s return.

    Native shape: ``{verified, method, score, page, near_miss}``. A verified quote
    is ``grounded``; a quote that is simply not present in the source
    (``method == "none"``) is a FALSE PREMISE (a fabricated/mis-remembered quote);
    an unavailable source (``method == "unavailable"``) is ``UNVERIFIABLE`` (cannot
    check here, never a refutation).

    The native ``method`` (``"exact" | "fuzzy" | "annotation" | ...``) is carried
    verbatim onto :attr:`VerifierResult.method` — an ADDITIVE surface that leaves
    what verifies/grounds unchanged but lets the restatement floor gate *licensing*
    on a source-derived match (see :func:`_confirmed_verifier_text`). The native
    ``confirmed_source_text`` (the source-matched span, populated for an ``exact``
    needle or an ``annotation`` highlight, ``None`` for fuzzy) is likewise carried
    onto :attr:`VerifierResult.confirmed_source_text` — the only text the floor may
    license off this verifier, never the caller body.
    """
    method = res.get("method")
    method_str = str(method) if method is not None else None
    confirmed = res.get("confirmed_source_text")
    confirmed_str = str(confirmed) if isinstance(confirmed, str) and confirmed else None
    if res.get("verified"):
        return VerifierResult(
            VerifierStatus.OK,
            detail=f"quote {res.get('method', '')}".strip(),
            verdict=_OK_VERDICT,
            method=method_str,
            confirmed_source_text=confirmed_str,
        )
    if method == "unavailable":
        return VerifierResult(
            VerifierStatus.UNVERIFIABLE,
            detail="source text unavailable",
            verdict=_UNVERIFIABLE_VERDICT,
            method=method_str,
        )
    near = res.get("near_miss")
    return VerifierResult(
        VerifierStatus.FAILED,
        detail="quote not found in source" + (f"; near_miss={near!r}" if near else ""),
        verdict=_FAILED_VERDICT,
        method=method_str,
    )

normalize_data_result

normalize_data_result(res: Mapping[str, Any]) -> VerifierResult

Normalize :func:zettelkasten.data_grounding.verify_data_grounding's return.

Native shape carries a severity tier: ok (clean reproduce) → measured; hard (a violation angelo could re-run and it disagreed) → FALSE PREMISE; soft (cannot re-run here) → UNVERIFIABLE (retains its prior stamp, never a refutation) — exactly the split :func:zettelkasten.coverage._data_claim_grounded treats as blocking vs not.

Source code in zettelkasten/synapse/grounding_router.py
def normalize_data_result(res: Mapping[str, Any]) -> VerifierResult:
    """Normalize :func:`zettelkasten.data_grounding.verify_data_grounding`'s return.

    Native shape carries a ``severity`` tier: ``ok`` (clean reproduce) →
    ``measured``; ``hard`` (a violation angelo could re-run and it disagreed) →
    FALSE PREMISE; ``soft`` (cannot re-run here) → ``UNVERIFIABLE`` (retains its
    prior stamp, never a refutation) — exactly the split
    :func:`zettelkasten.coverage._data_claim_grounded` treats as blocking vs not.
    """
    severity = res.get("severity")
    if severity == "ok":
        return VerifierResult(
            VerifierStatus.OK,
            detail="data re-computation reproduces",
            verdict=StatusVerdict(tier=EpistemicStatus.MEASURED),
        )
    if severity == "soft":
        return VerifierResult(
            VerifierStatus.UNVERIFIABLE,
            detail=f"data unavailable here: {res.get('failure_mode', '')}",
            verdict=_UNVERIFIABLE_VERDICT,
        )
    return VerifierResult(
        VerifierStatus.FAILED,
        detail=f"data not reproduced: {res.get('failure_mode', '')}",
        verdict=_FAILED_VERDICT,
    )

normalize_equation_result

normalize_equation_result(res: Mapping[str, Any]) -> VerifierResult

Normalize :func:zettelkasten.grounding.verify_equation's return.

Native shape: {valid, method, reason, page, locator}. Render-valid LaTeX is grounded; structurally-broken LaTeX (unbalanced braces/environments, empty, mojibake) is a FALSE PREMISE.

Source code in zettelkasten/synapse/grounding_router.py
def normalize_equation_result(res: Mapping[str, Any]) -> VerifierResult:
    """Normalize :func:`zettelkasten.grounding.verify_equation`'s return.

    Native shape: ``{valid, method, reason, page, locator}``. Render-valid LaTeX
    is ``grounded``; structurally-broken LaTeX (unbalanced braces/environments,
    empty, mojibake) is a FALSE PREMISE.
    """
    if res.get("valid"):
        return VerifierResult(
            VerifierStatus.OK, detail=str(res.get("method", "")), verdict=_OK_VERDICT
        )
    return VerifierResult(
        VerifierStatus.FAILED,
        detail=str(res.get("reason") or res.get("method") or "invalid equation"),
        verdict=_FAILED_VERDICT,
    )

normalize_figure_result

normalize_figure_result(res: Mapping[str, Any]) -> VerifierResult

Normalize :func:zettelkasten.grounding.verify_figure's return.

Native shape: {grounded, method, reason, page, locator}. A resolvable snapshot is grounded; a missing/unresolvable snapshot is UNVERIFIABLE (per verify_figure's ADVISORY contract — a figure has no source text to reproduce verbatim, so a snapshot miss lowers grounding but is NEVER a refutation).

Source code in zettelkasten/synapse/grounding_router.py
def normalize_figure_result(res: Mapping[str, Any]) -> VerifierResult:
    """Normalize :func:`zettelkasten.grounding.verify_figure`'s return.

    Native shape: ``{grounded, method, reason, page, locator}``. A resolvable
    snapshot is ``grounded``; a missing/unresolvable snapshot is ``UNVERIFIABLE``
    (per ``verify_figure``'s ADVISORY contract — a figure has no source text to
    reproduce verbatim, so a snapshot miss lowers grounding but is NEVER a
    refutation).
    """
    if res.get("grounded"):
        return VerifierResult(
            VerifierStatus.OK, detail=str(res.get("method", "")), verdict=_OK_VERDICT
        )
    return VerifierResult(
        VerifierStatus.UNVERIFIABLE,
        detail=str(res.get("reason") or res.get("method") or "figure not grounded"),
        verdict=_UNVERIFIABLE_VERDICT,
    )

normalize_decision_walk

normalize_decision_walk(walk_result: Mapping[str, Any], target: str) -> VerifierResult

Normalize a :func:zettelkasten.decision_tree.walk bundle for one node.

REUSES the walk's per-node applies / excluded / unresolved classification (never re-evaluating a predicate), mirroring :func:zettelkasten.synapse.epistemics.resolver_from_walk. target may be a namespaced graph::id uid or a bare id. An applies clause is grounded; an excluded clause is a FALSE PREMISE (the clause definitively does not hold); an unresolved or unreached node is UNVERIFIABLE.

Source code in zettelkasten/synapse/grounding_router.py
def normalize_decision_walk(walk_result: Mapping[str, Any], target: str) -> VerifierResult:
    """Normalize a :func:`zettelkasten.decision_tree.walk` bundle for one node.

    REUSES the walk's per-node ``applies`` / ``excluded`` / ``unresolved``
    classification (never re-evaluating a predicate), mirroring
    :func:`zettelkasten.synapse.epistemics.resolver_from_walk`. ``target`` may be a
    namespaced ``graph::id`` uid or a bare id. An ``applies`` clause is
    ``grounded``; an ``excluded`` clause is a FALSE PREMISE (the clause
    definitively does not hold); an ``unresolved`` or unreached node is
    ``UNVERIFIABLE``.
    """
    nodes = (walk_result or {}).get("nodes") or {}
    status = ""
    rec = nodes.get(target)
    if rec is None:
        # Fall back to a bare-id match (the walk namespaces uids as ``graph::id``).
        for uid, node_rec in nodes.items():
            bare = str(node_rec.get("id") or uid.split("::")[-1])
            if bare == target:
                rec = node_rec
                break
    if rec is not None:
        status = str(rec.get("status") or "")

    from zettelkasten import decision_tree

    if status == decision_tree.STATUS_APPLIES:
        return VerifierResult(VerifierStatus.OK, detail="clause applies", verdict=_OK_VERDICT)
    if status == decision_tree.STATUS_EXCLUDED:
        return VerifierResult(
            VerifierStatus.FAILED, detail="clause excluded", verdict=_FAILED_VERDICT
        )
    return VerifierResult(
        VerifierStatus.UNVERIFIABLE,
        detail="clause unresolved or unreached",
        verdict=_UNVERIFIABLE_VERDICT,
    )

default_registry

default_registry() -> VerifierRegistry

Build the v1 registry wiring the five shipped verifier-types.

Each wrapper lazily imports its target so importing this module stays cheap and store-free; the payload keys are the native verifier's own arguments:

  • quote → :func:zettelkasten.grounding.verify_quote (body, fulltext, annotations, optional fuzzy_floor);
  • expr-recompute → :func:zettelkasten.data_grounding.verify_data_grounding (note, graph_dir);
  • equation → :func:zettelkasten.grounding.verify_equation (body, optional page / fulltext / context);
  • figure → :func:zettelkasten.grounding.verify_figure (snapshot, optional page / fulltext / caption);
  • decision_tree → :func:zettelkasten.decision_tree.walk (a pre-computed walk bundle + a target node id, or graph / params / injected get_graph / load_project to compute one).
Source code in zettelkasten/synapse/grounding_router.py
def default_registry() -> VerifierRegistry:
    """Build the v1 registry wiring the five shipped verifier-types.

    Each wrapper lazily imports its target so importing this module stays cheap
    and store-free; the payload keys are the native verifier's own arguments:

    * ``quote`` → :func:`zettelkasten.grounding.verify_quote`
      (``body``, ``fulltext``, ``annotations``, optional ``fuzzy_floor``);
    * ``expr-recompute`` → :func:`zettelkasten.data_grounding.verify_data_grounding`
      (``note``, ``graph_dir``);
    * ``equation`` → :func:`zettelkasten.grounding.verify_equation`
      (``body``, optional ``page`` / ``fulltext`` / ``context``);
    * ``figure`` → :func:`zettelkasten.grounding.verify_figure`
      (``snapshot``, optional ``page`` / ``fulltext`` / ``caption``);
    * ``decision_tree`` → :func:`zettelkasten.decision_tree.walk`
      (a pre-computed ``walk`` bundle + a ``target`` node id, or ``graph`` /
      ``params`` / injected ``get_graph`` / ``load_project`` to compute one).
    """

    def _quote(payload: Mapping[str, Any]) -> VerifierResult:
        from zettelkasten.grounding import DEFAULT_FUZZY_FLOOR, verify_quote

        res = verify_quote(
            payload.get("body", ""),
            payload.get("fulltext"),
            payload.get("annotations"),
            fuzzy_floor=float(payload.get("fuzzy_floor", DEFAULT_FUZZY_FLOOR)),
        )
        return normalize_quote_result(res)

    def _data(payload: Mapping[str, Any]) -> VerifierResult:
        from zettelkasten.data_grounding import verify_data_grounding

        res = verify_data_grounding(payload["note"], payload["graph_dir"])
        return normalize_data_result(res)

    def _equation(payload: Mapping[str, Any]) -> VerifierResult:
        from zettelkasten.grounding import verify_equation

        res = verify_equation(
            payload.get("body", ""),
            page=payload.get("page"),
            fulltext=payload.get("fulltext"),
            context=payload.get("context"),
        )
        return normalize_equation_result(res)

    def _figure(payload: Mapping[str, Any]) -> VerifierResult:
        from zettelkasten.grounding import verify_figure

        res = verify_figure(
            payload.get("snapshot"),
            page=payload.get("page"),
            fulltext=payload.get("fulltext"),
            caption=payload.get("caption"),
        )
        return normalize_figure_result(res)

    def _decision_tree(payload: Mapping[str, Any]) -> VerifierResult:
        walk_result = payload.get("walk")
        if walk_result is None:
            from zettelkasten import decision_tree

            walk_result = decision_tree.walk(
                payload.get("graph", ""),
                payload.get("params"),
                project=payload.get("project", ""),
                get_graph=payload.get("get_graph"),
                load_project=payload.get("load_project"),
            )
        return normalize_decision_walk(walk_result, str(payload.get("target", "")))

    registry = VerifierRegistry()
    registry.register("quote", _quote)
    registry.register("expr-recompute", _data)
    registry.register("equation", _equation)
    registry.register("figure", _figure)
    registry.register("decision_tree", _decision_tree)
    return registry

dag_vocabulary

dag_vocabulary(dag: ReasoningDAG) -> set[str]

The union of content words over a DAG's claim texts + derivations.

Source code in zettelkasten/synapse/grounding_router.py
def dag_vocabulary(dag: ReasoningDAG) -> set[str]:
    """The union of content words over a DAG's claim texts + derivations."""
    return _vocab_over(dag.claims)

dag_numbers

dag_numbers(dag: ReasoningDAG) -> set[str]

The union of numeric tokens over a DAG's claim texts + derivations.

Source code in zettelkasten/synapse/grounding_router.py
def dag_numbers(dag: ReasoningDAG) -> set[str]:
    """The union of numeric tokens over a DAG's claim texts + derivations."""
    return _numbers_over(dag.claims)

check_traceability

check_traceability(prose: str, dag: ReasoningDAG, *, floor: float = DEFAULT_TRACE_FLOOR, extra_vocab: Sequence[str] = (), claim_verdicts: Mapping[str, StatusVerdict] | None = None, conclusion: str | None = None) -> TraceabilityResult

Gate the prose answer by POSITIVE per-assertion citation-anchoring.

Every non-trivial prose assertion must be licensed by a SINGLE cited verified claim under a FULL-CONTENT MATCH: its content-word sequence must EQUAL that ONE claim's whole content-word sequence — or the whole sequence of one of that claim's ATOMIC SUB-PROPOSITIONS (:func:_claim_source_clauses) — order preserved; the droppable-word set is EMPTY, so this is a VERBATIM content match modulo case/punctuation/whitespace (no leading/trailing/interior content-word drop); its content tokens all lie in that ONE claim's closed vocabulary (novelty is BINARY — any novel content word disqualifies the assertion, there is no untraced-word budget), and every number it carries must sit in that claim's OWN clause beside the words that accompanied it there. The gate is EXTRACTIVE and its ATOMIC-CLAIM PRECONDITION is that a claim is a single-fact assertion or a CONJUNCTION of them: a COMPOUND claim is deterministically atomized, so a faithful restatement of ONE of its facts GROUNDS while a connective-drop / conjunct-reorder / non-verbatim paraphrase over-abstains (delegated to the LLM as INFERRED). Licensing never pools across claims, which is what structurally ends the N-word whack-a-mole the old coverage heuristic could not escape.

The set of claims that may license prose is chosen exactly as before: with claim_verdicts (the answer-gate path) it is :func:_verified_closure_claims — claims whose RE-DERIVED wire status is VERIFIED and that lie in the conclusion's transitive premise closure, so an inferred / assumption / tainted claim's text and numbers can never license prose; without verdicts (a bare structural check) it is every claim. extra_vocab (e.g. cited node titles) augments every candidate claim's vocabulary.

Two modes share the per-claim licensing core (WHOLE-RUN SINGLE-CLAIM FULL-CONTENT MATCH):

  • CITATION mode (dag.prose_citations present) — the prose is partitioned into SENTENCES on strong terminators (a comma is not a break) and each SENTENCE's whole content run must FULL-MATCH a SINGLE claim cited by one of that sentence's spans (so a sentence cannot span two claims); any prose token outside every span is an UNCITED orphan (:func:_cited_orphans). Spans index into prose as passed.
  • FALLBACK mode (no binding — the production path today, where the wire @answer carries no prose binding and callers pass prose='') — the WHOLE reconstructed prose content-word stream must FULL-MATCH SOME single verified in-closure claim (:func:_fallback_orphans); a genuine multi-claim un-cited synthesis intentionally OVER-ABSTAINS.

floor is retained for signature stability only (the gate is a binary per-assertion novelty + full-content-match check, not a ratio). Truly EMPTY prose (whitespace only) trivially passes — there is no assertion to ground; but propositionally-EMPTY prose (non-empty text with zero content words and zero numbers — e.g. a punctuation-only sentence) is an orphan, never a vacuous GROUND. When no claim FULL-MATCHes prose, every non-trivial assertion is an orphan.

Source code in zettelkasten/synapse/grounding_router.py
def check_traceability(
    prose: str,
    dag: ReasoningDAG,
    *,
    floor: float = DEFAULT_TRACE_FLOOR,
    extra_vocab: Sequence[str] = (),
    claim_verdicts: Mapping[str, StatusVerdict] | None = None,
    conclusion: str | None = None,
) -> TraceabilityResult:
    """Gate the prose answer by POSITIVE per-assertion citation-anchoring.

    Every non-trivial prose assertion must be licensed by a SINGLE cited verified
    claim under a FULL-CONTENT MATCH: its content-word sequence must EQUAL that ONE
    claim's whole content-word sequence — or the whole sequence of one of that claim's
    ATOMIC SUB-PROPOSITIONS (:func:`_claim_source_clauses`) — order preserved; the
    droppable-word set is EMPTY, so this is a VERBATIM content match modulo
    case/punctuation/whitespace (no leading/trailing/interior content-word drop); its
    content tokens all lie in that ONE claim's closed vocabulary (novelty is BINARY —
    any novel content word disqualifies the assertion, there is no untraced-word
    budget), and every number it carries must sit in that claim's OWN clause beside the
    words that accompanied it there. The gate is EXTRACTIVE and its ATOMIC-CLAIM
    PRECONDITION is that a claim is a single-fact assertion or a CONJUNCTION of them: a
    COMPOUND claim is deterministically atomized, so a faithful restatement of ONE of
    its facts GROUNDS while a connective-drop / conjunct-reorder / non-verbatim
    paraphrase over-abstains (delegated to the LLM as INFERRED). Licensing never
    pools across claims, which is what structurally ends the N-word whack-a-mole
    the old coverage heuristic could not escape.

    The set of claims that may license prose is chosen exactly as before: with
    ``claim_verdicts`` (the answer-gate path) it is
    :func:`_verified_closure_claims` — claims whose RE-DERIVED wire status is
    VERIFIED and that lie in the ``conclusion``'s transitive premise closure, so an
    inferred / assumption / tainted claim's text and numbers can never license
    prose; without verdicts (a bare structural check) it is every claim.
    ``extra_vocab`` (e.g. cited node titles) augments every candidate claim's
    vocabulary.

    Two modes share the per-claim licensing core (WHOLE-RUN SINGLE-CLAIM FULL-CONTENT
    MATCH):

    * CITATION mode (``dag.prose_citations`` present) — the prose is partitioned into
      SENTENCES on strong terminators (a comma is not a break) and each SENTENCE's whole
      content run must FULL-MATCH a SINGLE claim cited by one of that sentence's spans
      (so a sentence cannot span two claims); any prose token outside every span is an
      UNCITED orphan (:func:`_cited_orphans`). Spans index into ``prose`` as passed.
    * FALLBACK mode (no binding — the production path today, where the wire
      ``@answer`` carries no prose binding and callers pass ``prose=''``) — the WHOLE
      reconstructed prose content-word stream must FULL-MATCH SOME single verified
      in-closure claim (:func:`_fallback_orphans`); a genuine multi-claim un-cited
      synthesis intentionally OVER-ABSTAINS.

    ``floor`` is retained for signature stability only (the gate is a binary
    per-assertion novelty + full-content-match check, not a ratio). Truly EMPTY prose
    (whitespace only) trivially passes — there is no assertion to ground; but
    propositionally-EMPTY prose (non-empty text with zero content words and zero
    numbers — e.g. a punctuation-only sentence) is an orphan, never a vacuous GROUND.
    When no claim FULL-MATCHes prose, every non-trivial assertion is an orphan.
    """
    raw = prose or ""
    if not raw.strip():
        return TraceabilityResult(ok=True)

    if claim_verdicts is not None:
        licensing = _verified_closure_claims(
            dag, conclusion if conclusion is not None else _conclusion_id(dag), claim_verdicts
        )
    else:
        licensing = list(dag.claims)

    extra_words = frozenset(
        w for token in extra_vocab for w in _content_words(str(token))
    )
    license_by_id: dict[str, _ClaimLicense] = {}
    for claim in licensing:
        own_vocab = frozenset(_vocab_over([claim]))
        license_by_id[claim.id] = _ClaimLicense(
            vocab=own_vocab | extra_words,
            own_vocab=own_vocab,
            source_clauses=tuple(_claim_source_clauses(claim)),
            number_clauses=tuple(_claim_number_clauses(claim)),
        )

    if dag.prose_citations:
        orphans = _cited_orphans(raw, dag.prose_citations, license_by_id)
    else:
        orphans = _fallback_orphans(raw.strip(), list(license_by_id), license_by_id)

    if orphans:
        return TraceabilityResult(
            ok=False,
            orphans=tuple(orphans),
            detail="prose assertion(s) not licensed by a single cited verified claim",
        )
    return TraceabilityResult(ok=True)

default_confabulation_detector

default_confabulation_detector(ctx: GroundingContext) -> ConfabulationSignal

The v1 closed-book detector: is the answer anchored in retrieved ground?

Flags closed_book when NEITHER any substrate node cited in the conclusion's premise closure resolves to a VERIFIED wire status, NOR any verifier confirmed a grounding OK. In that case nothing retrieved actually grounds the answer, so it could have been produced closed-book — a confabulation signal. This is a genuine, CALLED interface; a fuller closed-book/open-book contrast probe (run the model without the substrate and compare) slots in here later.

Source code in zettelkasten/synapse/grounding_router.py
def default_confabulation_detector(ctx: GroundingContext) -> ConfabulationSignal:
    """The v1 closed-book detector: is the answer anchored in retrieved ground?

    Flags ``closed_book`` when NEITHER any substrate node cited in the conclusion's
    premise closure resolves to a VERIFIED wire status, NOR any verifier confirmed
    a grounding OK. In that case nothing retrieved actually grounds the answer, so
    it could have been produced closed-book — a confabulation signal. This is a
    genuine, CALLED interface; a fuller closed-book/open-book contrast probe (run
    the model without the substrate and compare) slots in here later.
    """
    cited = _closure_cites(ctx.dag, ctx.conclusion)
    grounded_node = any(
        (ctx.node_verdicts.get(ref) is not None)
        and (ctx.node_verdicts[ref].wire() in _VERIFIED_WIRE)
        for ref in cited
    )
    verifier_ok = any(r.ok for r in ctx.verifier_results.values())
    closed = not (grounded_node or verifier_ok)
    return ConfabulationSignal(
        closed_book=closed,
        detail=(
            "no verified retrieved ground anchors the answer"
            if closed
            else "answer anchored in verified retrieved substrate"
        ),
    )

route

route(dag: ReasoningDAG, *, prose: str = '', node_bodies: Mapping[str, str] | None = None, node_status: Mapping[str, StatusVerdict] | None = None, groundings: Mapping[str, GroundingSpec] | None = None, registry: VerifierRegistry | None = None, asserted: Mapping[str, EpistemicStatus] | None = None, edges: Sequence[Edge] = (), coverage_report: Mapping[str, Any] | None = None, confabulation: ConfabulationDetector | None = None, trace_floor: float = DEFAULT_TRACE_FLOOR, derivation_available: Callable[[Any], bool] | None = None, validate: bool = True) -> RouterDecision

Route a cited reasoning-DAG to a first-class ANSWER or ABSTAIN verdict.

The router NEVER trusts the LLM's say-so: it derives every claim's status deterministically from the substrate and the DAG's structure, and only answers when that derivation grounds the conclusion and coverage holds.

Pipeline (deterministic, no LLM / IO of its own):

  1. Ground each cited ref. For every ref in groundings, dispatch its :class:GroundingSpec to the registry (default :func:default_registry) and take the resulting per-node :class:StatusVerdict. An explicit node_status (e.g. tether verdicts from :func:~zettelkasten.synapse.epistemics.apply_node_statuses) OVERRIDES a verifier verdict for the same ref — EXCEPT it can never UPGRADE a ref whose verifier returned UNVERIFIABLE or FAILED (those stay capped at the conservative verifier verdict, so an unchecked/refuted ground cannot be laundered into a grounded answer by an injected status).
  2. Re-derive + honesty. Run :func:~zettelkasten.synapse.epistemics.check_honesty over the merged node statuses, which validates the DAG, re-derives every claim's status (weakest-conjunct propagation → imperfect atomicity over-abstains, never fabricates), and REJECTS any agent-asserted upgrade in asserted. Then ALWAYS (a missing node_bodies is treated as an empty mapping — the public contract is FAIL-CLOSED, not fail-open), floor any RESTATEMENT claim whose text is not EXTRACTIVELY LICENSED by an authoritative source of a cited ref (:func:_floor_drifted_restatements) — a drifted restatement of a grounded node can never remain grounded (closes F4). The floor is FAIL-CLOSED and CONFIRMED-TEXT LICENSED: a restatement stays grounded only when its text is licensed by a NON-EMPTY cited node body OR by the verifier-CONFIRMED text of a ref whose verifier returned OK (a partial quote whose confirmed text licenses the claim). A verifier OK alone is NOT a blanket exemption — it validated the payload, not the claim text — so a fabricated restatement citing a verifier-OK node whose confirmed text does not license it is floored. The floor is RESTATEMENT-only; deduction/inference TEXT is not checked here (grounded-by-design). The honesty report is then rebound to the floored verdicts (FIX B) so an asserted upgrade above a floored status is a violation.
  3. Traceability. Gate the prose answer against the DAG (:func:check_traceability); an orphan assertion is a coverage break.
  4. Confabulation. Call the confabulation detector (default :func:default_confabulation_detector) — a closed-book signal on a non-verified conclusion tips toward abstention.
  5. Decide. Collect reason tokens (missing-hop for a cited ref with no resolved verdict, stale when the conclusion rests on a drifted tether, false-premise when a verifier definitively refuted a ground, low-coverage for a traceability/coverage break, a contested/unresolved conclusion, or an otherwise-unverified conclusion). ANSWER (status=verified) iff there are no reasons, honesty holds, traceability holds, and the conclusion's wire status is verified. A genuinely INFERRED conclusion that PROVABLY RESTS ON ≥1 verified premise CLAIM in its transitive premise closure (following Claim.premises), is honest, traceable, coverage-clean, and has no structural break (missing-hop / stale / false-premise / contested / unresolved) is instead returned as a HEDGED ANSWER (verdict=ANSWER, status=inferred) rather than a low-coverage abstain — an honestly-labeled extrapolation off a verified anchor, never a fabrication (see the guards at the decision site). A conclusion that merely cites a grounded node without deriving from a verified premise claim, or rests on a bare/floored restatement, ABSTAINS. Otherwise ABSTAIN with the ordered reasons and a first-class :class:Abstention.

node_bodies maps a cited ref (substrate token or envelope ref) to the RAW, un-elided node body used by the restatement floor; None (the default) is treated IDENTICALLY to an empty mapping {} — the floor still runs FAIL-CLOSED, so a RESTATEMENT claim with no licensing body floors to INFERRED rather than grounding off node status alone. A DAG with no RESTATEMENT claims is unaffected (nothing to floor).

Returns a :class:RouterDecision.

Source code in zettelkasten/synapse/grounding_router.py
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
def route(
    dag: ReasoningDAG,
    *,
    prose: str = "",
    node_bodies: Mapping[str, str] | None = None,
    node_status: Mapping[str, StatusVerdict] | None = None,
    groundings: Mapping[str, GroundingSpec] | None = None,
    registry: VerifierRegistry | None = None,
    asserted: Mapping[str, EpistemicStatus] | None = None,
    edges: Sequence[Edge] = (),
    coverage_report: Mapping[str, Any] | None = None,
    confabulation: ConfabulationDetector | None = None,
    trace_floor: float = DEFAULT_TRACE_FLOOR,
    derivation_available: Callable[[Any], bool] | None = None,
    validate: bool = True,
) -> RouterDecision:
    """Route a cited reasoning-DAG to a first-class ANSWER or ABSTAIN verdict.

    The router NEVER trusts the LLM's say-so: it derives every claim's status
    deterministically from the substrate and the DAG's structure, and only answers
    when that derivation grounds the conclusion and coverage holds.

    Pipeline (deterministic, no LLM / IO of its own):

    1. **Ground each cited ref.** For every ref in ``groundings``, dispatch its
       :class:`GroundingSpec` to the ``registry`` (default :func:`default_registry`)
       and take the resulting per-node :class:`StatusVerdict`. An explicit
       ``node_status`` (e.g. tether verdicts from
       :func:`~zettelkasten.synapse.epistemics.apply_node_statuses`) OVERRIDES a
       verifier verdict for the same ref — EXCEPT it can never UPGRADE a ref whose
       verifier returned ``UNVERIFIABLE`` or ``FAILED`` (those stay capped at the
       conservative verifier verdict, so an unchecked/refuted ground cannot be
       laundered into a grounded answer by an injected status).
    2. **Re-derive + honesty.** Run :func:`~zettelkasten.synapse.epistemics.check_honesty`
       over the merged node statuses, which validates the DAG, re-derives every
       claim's status (weakest-conjunct propagation → imperfect atomicity
       over-abstains, never fabricates), and REJECTS any agent-asserted upgrade in
       ``asserted``. Then ALWAYS (a missing ``node_bodies`` is treated as an empty
       mapping — the public contract is FAIL-CLOSED, not fail-open),
       floor any RESTATEMENT claim whose text is not EXTRACTIVELY LICENSED by an
       authoritative source of a cited ref (:func:`_floor_drifted_restatements`) — a
       drifted restatement of a grounded node can never remain grounded (closes F4).
       The floor is FAIL-CLOSED and CONFIRMED-TEXT LICENSED: a restatement stays
       grounded only when its text is licensed by a NON-EMPTY cited node body OR by
       the verifier-CONFIRMED text of a ref whose verifier returned OK (a partial
       quote whose confirmed text licenses the claim). A verifier OK alone is NOT a
       blanket exemption — it validated the payload, not the claim text — so a
       fabricated restatement citing a verifier-OK node whose confirmed text does not
       license it is floored. The floor is RESTATEMENT-only; deduction/inference TEXT
       is not checked here (grounded-by-design). The honesty report is then rebound
       to the floored verdicts (FIX B) so an asserted upgrade above a floored status
       is a violation.
    3. **Traceability.** Gate the ``prose`` answer against the DAG
       (:func:`check_traceability`); an orphan assertion is a coverage break.
    4. **Confabulation.** Call the ``confabulation`` detector (default
       :func:`default_confabulation_detector`) — a closed-book signal on a
       non-verified conclusion tips toward abstention.
    5. **Decide.** Collect reason tokens (``missing-hop`` for a cited ref with no
       resolved verdict, ``stale`` when the conclusion rests on a drifted tether,
       ``false-premise`` when a verifier definitively refuted a ground,
       ``low-coverage`` for a traceability/coverage break, a contested/unresolved
       conclusion, or an otherwise-unverified conclusion). ANSWER (status=verified)
       iff there are no reasons, honesty holds, traceability holds, and the
       conclusion's wire status is verified. A genuinely INFERRED conclusion that
       PROVABLY RESTS ON ≥1 verified premise CLAIM in its transitive premise closure
       (following ``Claim.premises``), is honest, traceable, coverage-clean, and has
       no structural break (missing-hop / stale / false-premise / contested /
       unresolved) is instead returned as a HEDGED ANSWER (verdict=ANSWER,
       status=inferred) rather than a ``low-coverage`` abstain — an honestly-labeled
       extrapolation off a verified anchor, never a fabrication (see the guards at
       the decision site). A conclusion that merely cites a grounded node without
       deriving from a verified premise claim, or rests on a bare/floored
       restatement, ABSTAINS. Otherwise ABSTAIN with the ordered reasons and a
       first-class :class:`Abstention`.

    ``node_bodies`` maps a cited ref (substrate token or envelope ref) to the RAW,
    un-elided node body used by the restatement floor; ``None`` (the default) is treated
    IDENTICALLY to an empty mapping ``{}`` — the floor still runs FAIL-CLOSED, so a
    RESTATEMENT claim with no licensing body floors to INFERRED rather than grounding off
    node status alone. A DAG with no RESTATEMENT claims is unaffected (nothing to floor).

    Returns a :class:`RouterDecision`.
    """
    registry = registry or default_registry()
    groundings = groundings or {}

    # 1. Ground each cited ref through the registry; explicit node_status wins.
    verifier_results: dict[str, VerifierResult] = {}
    verifier_verdicts: dict[str, StatusVerdict] = {}
    for ref, spec in groundings.items():
        result = registry.verify(spec.verifier_type, spec.payload)
        verifier_results[ref] = result
        verifier_verdicts[ref] = result.verdict or verdict_for_status(result.status)

    merged_status: dict[str, StatusVerdict] = dict(verifier_verdicts)
    if node_status:
        for ref, verdict in node_status.items():
            result = verifier_results.get(ref)
            # A registered verifier that could NOT confirm (UNVERIFIABLE) or that
            # definitively REFUTED (FAILED) a ground caps it at the conservative
            # verifier verdict: an injected node_status must never UPGRADE such a
            # ref back to verified, or an unchecked/refuted ground would be
            # laundered into a grounded answer (both verifier verdicts are already
            # inferred-tier, so keeping them is always the conservative choice).
            if result is not None and result.status in (
                VerifierStatus.UNVERIFIABLE,
                VerifierStatus.FAILED,
            ):
                continue
            merged_status[ref] = verdict

    # 2. Re-derive every claim's status + enforce the honesty invariant. This also
    # validates the DAG (a malformed DAG raises MemDSLParseError — a contract
    # violation, not a routable outcome).
    honesty = check_honesty(
        dag,
        asserted or {},
        merged_status,
        edges=edges,
        derivation_available=derivation_available,
        validate=validate,
    )
    claim_verdicts = honesty.derived

    # 2b. Restatement body-match floor (closes F4). BEFORE the conclusion status is
    # read for the decision, floor any RESTATEMENT claim whose text is not an
    # extractive restatement of its cited node body/bodies, then re-propagate so a
    # floored premise lowers the conclusion through the existing weakest-link cap.
    # FAIL-CLOSED public contract: the missing map (``node_bodies is None``, the public
    # default) is treated IDENTICALLY to an empty mapping ``{}``, so a direct ``route()``
    # caller that omits bodies still runs the fail-CLOSED floor. A RESTATEMENT claim with
    # no licensing body therefore floors to INFERRED instead of grounding off node status
    # alone (closes the reopened F4 hole where the public default fail-OPEN). A DAG with NO
    # RESTATEMENT claims is UNAFFECTED: the floor finds nothing to lower, re-propagation
    # returns the verdicts verbatim, and the reconciled honesty matches the pre-floor
    # report (exactly as an explicit ``{}`` already behaves today).
    bodies = node_bodies if node_bodies is not None else {}
    claim_verdicts = _floor_drifted_restatements(
        dag,
        claim_verdicts,
        bodies,
        merged_status,
        edges=edges,
        derivation_available=derivation_available,
        groundings=groundings,
        verifier_results=verifier_results,
    )
    # FIX B: the honesty report was computed PRE-floor, so for a drifted restatement it
    # would still report the pre-floor (grounded) status. Rebind it to the FINAL floored
    # verdicts so the returned contract is self-consistent — ``honesty.derived`` reflects
    # the floored statuses and an asserted upgrade above a floored status is a violation
    # (``honesty.ok`` becomes False). When nothing floored, this reproduces the same
    # ``derived`` / ``violations`` the pre-floor ``check_honesty`` computed.
    honesty = _reconcile_honesty(claim_verdicts, asserted or {})

    conclusion = _conclusion_id(dag)
    conclusion_verdict = claim_verdicts.get(conclusion)
    conclusion_wire = (
        conclusion_verdict.wire() if conclusion_verdict is not None else EpistemicStatus.INFERRED
    )

    # 3. Prose↔DAG traceability. Restricted to the VERIFIED claims in the
    # conclusion's premise closure, so prose can only trace to claims that
    # actually license a grounded answer (an inferred/assumption/tainted claim's
    # text and numbers must not license prose — see check_traceability).
    traceability = check_traceability(
        prose,
        dag,
        floor=trace_floor,
        claim_verdicts=claim_verdicts,
        conclusion=conclusion,
    )

    # 4. Confabulation hook (closed-book vs open-book), always called.
    ctx = GroundingContext(
        dag=dag,
        prose=prose,
        node_verdicts=merged_status,
        claim_verdicts=claim_verdicts,
        verifier_results=verifier_results,
        conclusion=conclusion,
    )
    confab = (confabulation or default_confabulation_detector)(ctx)

    # 5. Collect abstention reasons deterministically.
    closure_cites = _closure_cites(dag, conclusion)
    missing_refs = sorted(ref for ref in closure_cites if ref not in merged_status)
    has_missing = bool(missing_refs)
    is_stale = bool(conclusion_verdict is not None and conclusion_verdict.stale)
    # ``outdated`` is the committed-drift DISCLOSURE, not an abstain reason: it is
    # read SEPARATELY from ``stale`` (a purely-outdated conclusion has stale=False),
    # so it never adds REASON_STALE and never blocks the ANSWER. It is surfaced
    # INSEPARABLY on the returned decision so a consumer cannot present an
    # outdated-grounded answer as fresh-grounded.
    is_outdated = bool(conclusion_verdict is not None and conclusion_verdict.outdated)
    has_false = any(r.failed for r in verifier_results.values())
    # FIX 4: a contested (contradicting/qualifying cites) or unresolved (dangling/
    # unverifiable reference) conclusion has ``wire()==inferred`` with no structural
    # reason of its own, so — like ``stale`` — it must be treated as a coverage break
    # that BLOCKS the hedge and ABSTAINS. Read from the public StatusVerdict flags.
    is_contested = bool(conclusion_verdict is not None and conclusion_verdict.contested)
    is_unresolved = bool(conclusion_verdict is not None and conclusion_verdict.unresolved)

    reasons: set[str] = set()
    if has_missing:
        reasons.add(REASON_MISSING_HOP)
    if is_stale:
        reasons.add(REASON_STALE)
    if has_false:
        reasons.add(REASON_FALSE_PREMISE)

    # low-coverage: a prose/DAG traceability break, an injected coverage-report
    # break, or an unverified conclusion not already explained by a more specific
    # reason. The last case is the weakest-conjunct over-abstain (a mixed/weak
    # conjunct lowers the propagated status → the conclusion is not verified → we
    # abstain rather than fabricate) and subsumes the closed-book confab tip (a
    # closed-book answer is, by construction, not verified). The confabulation
    # signal itself stays ADVISORY in v1 — surfaced on the decision, never the
    # sole cause of an abstain.
    low = False
    if not traceability.ok:
        low = True
    if _coverage_reasons(coverage_report):
        low = True
    conclusion_verified = conclusion_wire in _VERIFIED_WIRE
    if not conclusion_verified and not (has_missing or is_stale or has_false):
        low = True
    if low:
        reasons.add(REASON_LOW_COVERAGE)

    ordered_reasons = tuple(r for r in _REASON_ORDER if r in reasons)

    # Decide. ANSWER only when nothing objects AND the conclusion is verified AND
    # the agent asserted no upgrade AND the prose traces.
    answerable = (
        not ordered_reasons
        and conclusion_verified
        and honesty.ok
        and traceability.ok
    )
    if answerable:
        return RouterDecision(
            verdict=ANSWER,
            status=conclusion_wire,
            reasons=(),
            abstention=None,
            honesty=honesty,
            node_verdicts=merged_status,
            claim_verdicts=dict(claim_verdicts),
            verifier_results=verifier_results,
            traceability=traceability,
            confabulation=confab,
            conclusion=conclusion,
            outdated=is_outdated,
            # The committed-drift disclosure suffix reuses the SINGLE shared
            # constant ``render._DETAIL_OUTDATED`` so the router's primary
            # (forwarded-into-``run_navigate``-JSON) disclosure and the renderer's
            # OUTDATED gap can never drift apart in wording again.
            detail=(
                f"grounded conclusion {conclusion!r} ({conclusion_wire.value})"
                + (f"; {_DETAIL_OUTDATED}" if is_outdated else "")
            ),
        )

    # Hedged-where-extrapolated ANSWER (status=inferred): a genuine INFERRED
    # derivation that extrapolates FROM grounded material is returned as an
    # honestly-labeled hedge INSTEAD of a low-coverage abstain — but ONLY when every
    # anti-fabrication guard holds, so the hedge can never launder a fabrication:
    #   * the conclusion is genuinely at the INFERRED tier (never a verified one);
    #   * NO structural break — missing-hop / stale / false-premise / CONTESTED /
    #     UNRESOLVED all absent. A stale, contested, or unresolved conclusion is a
    #     coverage break, not a sound extrapolation, and MUST abstain (FIX 4).
    #   * the reasoning is internally honest AND the prose traces;
    #   * the injected coverage report signals no break;
    #   * STRUCTURALLY ANCHORED IN A VERIFIED PREMISE CLAIM (FIX 3): the
    #     conclusion's transitive PREMISE-CLAIM closure (following ``Claim.premises``,
    #     NOT node cites) is non-empty and contains ≥1 premise claim whose re-derived
    #     wire status is grounded/measured. The inference must PROVABLY rest on a
    #     verified premise in its derivation chain — a bare restatement with no
    #     premises, a conclusion that merely cites a grounded node without deriving
    #     from it, and a global verifier OK from elsewhere in the loop are all
    #     structurally excluded. ``confab.closed_book`` is kept as an ADVISORY extra
    #     necessary condition, never the anchor itself.
    #
    # This anchor is STRUCTURAL, not semantic: it proves the inference RESTS ON a
    # verified premise, NOT that the extrapolation is RELEVANT to or soundly supported
    # by it. Relevance/soundness of a novel inference is contract-delegated to the LLM
    # (the same boundary as deduction TEXT — see the parked deduction-text limitation
    # in ``_floor_drifted_restatements``), backstopped by traceability when prose is
    # present and by the confabulation signal. A deterministic relevance verifier is
    # deliberately NOT attempted — it would kill the intended grounded-deduction and
    # hedge paths. The answer is labeled ``inferred``, NEVER grounded/measured, so the
    # "never fabricates" guarantee is preserved by the LABEL, not by this anchor.
    #
    # TRACKED FOLLOW-UP (hedge semantic relevance): tighten the hedge from a purely
    # structural anchor toward premise↔conclusion RELEVANCE without collapsing the
    # legitimate grounded-deduction/hedge paths — parked alongside the deduction-text
    # boundary above.
    hedgeable = (
        conclusion_wire is EpistemicStatus.INFERRED
        and not (has_missing or is_stale or has_false or is_contested or is_unresolved)
        and honesty.ok
        and traceability.ok
        and not _coverage_reasons(coverage_report)
        and not confab.closed_book
        and _has_grounded_premise_claim(dag, conclusion, claim_verdicts)
    )
    if hedgeable:
        return RouterDecision(
            verdict=ANSWER,
            status=EpistemicStatus.INFERRED,
            reasons=(),
            abstention=None,
            honesty=honesty,
            node_verdicts=merged_status,
            claim_verdicts=dict(claim_verdicts),
            verifier_results=verifier_results,
            traceability=traceability,
            confabulation=confab,
            conclusion=conclusion,
            outdated=is_outdated,
            # The disclosure must be INSEPARABLE on EVERY reachable ANSWER path, not
            # just the grounded one: a hedged answer derived from an outdated-grounded
            # restatement would otherwise present without the marker. Append the SAME
            # shared ``render._DETAIL_OUTDATED`` constant used by the grounded path.
            detail=(
                f"hedged: conclusion {conclusion!r} extrapolated beyond grounded "
                "premises (inferred)"
                + (f"; {_DETAIL_OUTDATED}" if is_outdated else "")
            ),
        )

    # An honesty violation or a stubborn unverified conclusion with no explicit
    # reason still must abstain — floor to low-coverage so ABSTAIN is never
    # emitted with an empty reason set.
    if not ordered_reasons:
        ordered_reasons = (REASON_LOW_COVERAGE,)

    detail_bits: list[str] = []
    if has_missing:
        detail_bits.append(f"missing refs {missing_refs}")
    if has_false:
        detail_bits.append("verifier refuted a ground")
    if not traceability.ok:
        detail_bits.append(traceability.detail)
    if not honesty.ok:
        detail_bits.append(
            "rejected agent-asserted upgrade(s): "
            + ", ".join(sorted(v.claim_id for v in honesty.violations))
        )
    detail = "; ".join(b for b in detail_bits if b)

    abstention = Abstention(reason=ordered_reasons[0], detail=detail)
    return RouterDecision(
        verdict=ABSTAIN,
        status=None,
        reasons=ordered_reasons,
        abstention=abstention,
        honesty=honesty,
        node_verdicts=merged_status,
        claim_verdicts=dict(claim_verdicts),
        verifier_results=verifier_results,
        traceability=traceability,
        confabulation=confab,
        conclusion=conclusion,
        outdated=is_outdated,
        detail=detail,
    )