18. NLI-grade entailment verifier behind the citation guard (cloud path only)
- Status: Accepted
- Date: 2026-07-08
- Author: Chelsea Kelly-Reif
- Deciders: Chelsea Kelly-Reif (maintainer)
Context
The model card (docs/cards/model-card.md) has stated since the cloud-path hardening pass:
"A production deployment should add an NLI-grade entailment verifier on the cloud path; the
guards are the safety boundary, not the model's instruction-following." No backlog item
implemented it (docs/ideation/03-expansions.md, EXP-04).
ADR-0003 established the citation guard as the load-bearing groundedness gate: a candidate sentence survives only if it is verbatim-contained in, or lexically covered by, its cited chunk, with a negation-polarity check added later to catch inversion attacks. That check is bag-of-tokens: it measures shared vocabulary, not entailment. The residual risk it cannot catch is a cloud generator's same-plant sentence recombination — swapping which attribute applies between two chunks about the same species (e.g. attributing chunk A's watering cadence to chunk B's light requirement) where each half, taken alone, still clears the coverage threshold against some retrieved chunk. Retrieval's species/topic scoping bounds how often this can happen (there is usually only one plant's chunks in play), but it does not eliminate it, and FIX-05 (property-based fuzzing of the guard) is expected to quantify a non-zero admit rate for exactly this pattern.
The offline ExtractiveGenerator cannot recombine — it copies sentences verbatim from a
single retrieved chunk, so this risk is specific to the cloud (Bedrock/Anthropic) path.
Decision
Add an optional, cloud-path-only NLI cross-encoder entailment verifier as a second gate after the existing lexical check, never a replacement for it.
guards.citation_guardtakes an optional fourth argument,entailment_verifier: EntailmentVerifier | None. A candidate must still pass the existing lexical_supported_bycheck first; if a verifier is supplied, it must also judge the cited chunk as entailing the sentence (entails(premise=chunk.text, hypothesis=sentence)).None(the default, and the only value the offline path ever passes) reproduces prior behavior exactly — bit-identical, per the excellence bar.verifiers.pydefines theEntailmentVerifierProtocol andNLIEntailmentVerifier(threshold over an injectedscore_fn), plusbuild_onnx_entailment_scorer— the real loader, using CPUonnxruntime+tokenizers(no torch/transformers) against a Hugging Face Hub-hosted ONNX cross-encoder NLI checkpoint. The loader downloads the pinned file and verifies its SHA-256 againstgeneration.nli.model_sha256before constructing an inference session, the same content-hash disciplinedeterminism.pyapplies to the eval dataset and the judge config — a hash mismatch refuses to load rather than running unpinned weights.- Config (
GenerationConfig.support_verifier: "lexical" | "nli", default"lexical";GenerationConfig.nli: NLIVerifierConfig) enforces, at load time, both risks the expansion doc flagged:support_verifier: "nli"is rejected whenprovider == "deterministic"(the offline path is grounded by construction and gains nothing from this, and must stay zero-ML-dependency), and rejected whennli.model_sha256is unset (never load unpinned weights). Both areValueErrorat config load — fail closed, not a silent no-op. sprout[nli]extra (onnxruntime,tokenizers,huggingface-hub,numpy) keepsonnxruntime/tokenizersout of the base install, mirroring howboto3is scoped tosprout[bedrock].providers.build_entailment_verifierlazily imports the loader only whensupport_verifier: "nli"is actually configured.- Eval fingerprint.
cli._target_nameappends the verifier's model id, revision, weight-hash prefix, and threshold (verifiers.config_identity, computed from config alone, no download) to the evaltargetstring wheneversupport_verifier: "nli"is set, so a run with the verifier enabled is never conflated with one without it inRunFingerprint.digestor a baseline diff. guards.pyis a CODEOWNERS-guarded file; this ADR is the required review artifact for that change.
Consequences
- Positive. Directly implements the model card's own stated production requirement; closes the specific residual risk it names. The verifier is strictly additive — it can only narrow what the lexical guard already accepts, never widen it (a chunk that fails lexical coverage is dropped before the verifier ever runs), so it cannot introduce a new way to admit an ungrounded sentence.
- Positive. The offline path is provably unaffected:
citation_guard's default parameter reproduces the old call signature's behavior, andAssistant.from_storeonly builds a non-Noneverifier whensupport_verifier: "nli"is configured, which config validation restricts to the cloud path. - Negative — new supply-chain artifact. Model weights are fetched from the Hugging Face Hub at runtime rather than vendored; mitigated by hash pinning (fail-closed on mismatch) and scoping the dependency to an optional extra so it is never pulled into the default install's SBOM.
- Negative — latency. The cloud path already makes a network call to the generator; one more CPU-bound local inference call is a bounded, acceptable addition on a path that is not latency-budgeted the way the offline path is.
- Known limit — EN/ES asymmetry. Off-the-shelf NLI checkpoints are typically
EN-strong/ES-weaker (per the expansion doc's own risk note). This ADR does not claim
parity for the NLI gate; per-language false-reject rates must be measured (an eval-suite
addition, tracked separately) before defaulting
support_verifier: "nli"to on for Spanish-language cloud traffic. Until measured, treatnlias EN-validated only. - Neutral.
entail_thresholdandentailment_label_indexare config, changing them is an ADR-class change per the house rule (the same rulesupport_overlapis already under, ADR-0003).
Excellence bar (from the expansion doc)
- Offline behavior bit-identical to before this change — covered by
test_citation_guard_default_unaffected_offline_path. - FIX-05's measured recombination admit-rate should drop materially on the cloud path once a real checkpoint is deployed and FIX-05's fuzz suite is run against it; that measurement is out of scope for this ADR (which lands the mechanism) and belongs to FIX-05's own report once both land.