arborist/aborist/qa/keys.py
russell@unturf.com f2bbe512db
qa(#000010): Phases 2-4 land — wired, governed, labeled, benched
Closes ticket #000010 (Meta-Cognition Preflight Guard). Mechanism
complete; defaults preserve the dry-run discipline pattern from
#000008.

Phase 2 — wire preflight into query() and runner.ask():
  - preflight_question() runs after policy resolution + quantifier
    classification, before retrieval.
  - QuestionState surfaces on miss path, cache-hit path, AND
    reject-broad early-return path of query() — schema column-
    aligned across all four returns.
  - runner.ask() carries the same fields for `aborist ask` parity.

Phase 3 — policy fields + governance hash + CLI flags:
  - 6 new policy fields, all default-on except
    metacognition_block_on_contradiction (default False — label-
    only by default; opt-in via --block-on-contradiction).
  - All 6 folded into _VERIFIER_POLICY_FIELDS so flipping any
    invalidates prior cache records on lookup.
  - 2 new CLI flags on `aborist query`:
      --no-preflight             Level 2 master kill
      --block-on-contradiction   strict mode (hard-block on
                                 lexical contradictions)

Phase 4 — audit-line labels + bench fields + tests:
  - _render_warrant_tail extended with 5 metacog tail tokens:
      · false premise
      · contradictory
      · stale risk
      · out of corpus
      · frame ambiguous
  - Bench rows in qa_sweep.py gain 7 new bounded-size projection
    fields (logical_statuses, question_shape, preflight_result,
    temporal_sensitivity, has_false_premise, has_contradiction,
    corpus_requirement). Full QuestionState stays on result dict
    for CLI render only.
  - tests/test_metacognition.py grew from 42 → 68 tests
    (16 new: 6 governance + 6 audit-line tail + 4 default-policy
    pinning).

Live verified end-to-end:

  $ make query-dry Q="Who is the current CEO of OpenAI?" BURN=1
    UNGROUNDED · via claim_lattice · stale risk
  $ make query-dry Q="When did Mr. Burns become Homer's biological
                      father?" BURN=1
    UNGROUNDED · via claim_lattice · false premise

978 tests passing; 36 skipped.

What's NOT shipped (deferred):
  - Run-DAG node binding for metacognition_preflight stage —
    joins ticket #000009 Phase 5 (same audit-replay gap; both
    nodes can land together).
  - Reference-frame plumbing — frame_detection runs post-retrieval,
    preflight here is pre-retrieval; deferred until two-pass
    or post-classification update lands.
  - SOFT_PREFLIGHT_HINT (model-assisted sidecar) — source doc §18
    reserves this label; hard rule preserved (no LLM in preflight
    hard path).
  - Bench A/B measuring preflight on vs off — quick to run once
    stack settles.

Ticket #000010 status: closed · landed 2026-05-03.
2026-05-03 18:22:18 -04:00

314 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The 8-dim Merkle-AGI v9.8 cache_key.
v9.8 invariant: no answer is reused unless all eight match and the
record is live (not failed/stale/quarantined):
1. source_root — content fingerprint of the document
2. question_hash — SHA-256 of normalized question text
3. model_profile_hash — model_id + revision + quantization
4. conversation_hash — full canonical OpenAI messages array
5. governance_policy_hash — sampling/policy parameters dict
6. schema_version — aborist DB schema version
7. canonicalization_version — text normalization rules
8. chunking_version — chunker name & parameters
Bumping ANY of these eight dimensions yields a distinct cache_key, so
prior records cannot be served. This is the runtime drift detection
the providence whitepaper compresses into "cache_key = source_root +
':' + question_hash" — that's a simplification; the rigorous form is
all eight dimensions hashed together.
"""
from __future__ import annotations
import hashlib
import json
from aborist.document import canonicalize
def _sha256(s: str) -> str:
# ``errors='surrogatepass'`` survives lone UTF-16 surrogates from
# model output; same rationale as ``aborist.qa.dag._sha256_hex``.
return hashlib.sha256(s.encode("utf-8", errors="surrogatepass")).hexdigest()
def _canonical_json(obj) -> str:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
QUESTION_DEDUP_MODES = ("strict", "equivalence_class")
DEFAULT_QUESTION_DEDUP = "equivalence_class"
# Lookup-time fidelity. Decoupled from write-time `question_dedup`:
# write determines under which `cache_key` a record lands; fidelity
# determines which `cache_key`s a lookup will check.
#
# strict only the cache_key matching the agent's policy
# is checked. No fallback. Audit-grade behavior.
# equivalence_class primary cache_key checked first; if miss AND the
# OTHER dedup mode produces a different cache_key,
# the alternate is also checked. Lets a fast-cache
# agent reuse records written under either mode.
FIDELITY_MODES = ("strict", "equivalence_class")
DEFAULT_FIDELITY = "equivalence_class"
def canonical_question(
question: str, *, mode: str = DEFAULT_QUESTION_DEDUP
) -> str:
"""Canonical form of `question` for the given dedup ``mode``.
Two modes:
- ``"equivalence_class"`` (default): four-step canonicalization —
``canonicalize()`` (NFC + ws-collapse + strip ends), then
lowercase, then trailing-punctuation strip, then standalone-article
filter (``the``, ``a``, ``an``). All variants of "Who is THE
Batman?" / "who is batman" / "who is X." collapse to one form.
The default for chat-style agents that prefer fast cache hits.
- ``"strict"``: only ``canonicalize()`` — NFC + ws-collapse + strip
ends. Case-sensitive, punctuation-sensitive, article-sensitive.
Maximum granularity. The choice for audit-grade agents that want
every distinct phrasing to get its own answer.
Exposed as a function so callers can dedup BEFORE hashing — e.g.
inject the canonical form into the user message used for
``conversation_hash``, while still sending the verbatim question to
the LLM. Without this split ``"who is batman"``, ``"who is
batman?"``, and ``"who is the batman?"`` collapse on
``question_hash`` (under equivalence_class) but each hits
``conversation_hash`` differently, missing cache.
The choice of mode flows through ``policy["question_dedup"]`` into
``governance_policy_hash`` so two agents under different modes
write records under different ``cache_key``s — they coexist in
parallel namespaces, never collide.
"""
if mode not in QUESTION_DEDUP_MODES:
raise ValueError(
f"question dedup mode must be one of {QUESTION_DEDUP_MODES}, got {mode!r}"
)
canon = canonicalize(question)
if mode == "strict":
return canon
canon = canon.lower().rstrip(_QUESTION_TRAILING_STRIP)
tokens = [t for t in canon.split() if t not in _QUESTION_ARTICLE_STRIP]
return " ".join(tokens)
def question_hash(
question: str, *, mode: str = DEFAULT_QUESTION_DEDUP
) -> str:
"""SHA-256 of the dedup-mode-canonicalized question.
See ``canonical_question`` for what each mode does. The hash is the
SHA-256 of the canonical form. Bumping
``_QUESTION_TRAILING_STRIP`` or ``_QUESTION_ARTICLE_STRIP`` (the
equivalence-class strip sets) orphans prior cache records whose
canonical question contained newly-stripped tokens; they live as
history but won't be re-hit on lookup.
Equivalence class examples (mode="equivalence_class")::
"who is X" |
"who is X?" |
"Who Is X." | -> same question_hash
"who is the X" |
"who is a X" |
"who is an X" | (CJK question mark)
Strict mode (mode="strict") distinguishes all of those.
What's IN the trailing-strip set: ``.?!,;:`` (ASCII), ``?!。、``
(CJK full-width), ``…`` (ellipsis). Pairs like ``"`` ``'`` ``)``
``]`` ``}`` are NOT — naive one-sided stripping breaks balance.
Apostrophes aren't either — ``X's`` is a different question from
``X``.
"""
return _sha256(canonical_question(question, mode=mode))
# Trailing punctuation that carries no semantic difference at the end
# of a question. Order doesn't matter (rstrip walks char-by-char from
# the right). Repeats handled trivially: ``X???`` → ``X``.
#
# ASCII: . ? ! , ; :
# CJK: U+FF1F full-width question mark
# U+FF01 full-width exclamation
# 。 U+3002 ideographic full stop
# 、 U+3001 ideographic comma
# Other: … U+2026 horizontal ellipsis
_QUESTION_TRAILING_STRIP = ".?!,;:?!。、…"
# English articles stripped as standalone tokens after lowercasing. The
# question equivalence class treats "the foo" and "foo" as the same
# question — fox's 2026-04-29 catch: `who is the batman` & `who is
# batman` produced different cache records under earlier rules. Tokens
# are matched as EXACT lowercase strings, so substrings like "thesis"
# (contains "the") stay untouched.
#
# Conservative on purpose: only ASCII English articles. "El", "la",
# "los", "le", "les", "der", "die", "das" etc. are not stripped today.
# Adding them when needed flows through the same equivalence-class
# expansion the trailing-punctuation set went through.
_QUESTION_ARTICLE_STRIP = frozenset({"the", "a", "an"})
def model_profile_hash(
model_id: str, revision: str = "", quantization: str = ""
) -> str:
"""SHA-256 of model identity. Bumping any field bumps the cache key."""
return _sha256(f"{model_id}|{revision}|{quantization}")
def conversation_hash(messages: list[dict]) -> str:
"""SHA-256 of canonical JSON of the full OpenAI messages array.
Order matters: a 6-turn dialogue arriving at the same final question
produces a different hash than a single-turn ask.
"""
return _sha256(_canonical_json(messages))
def governance_policy_hash(policy: dict) -> str:
"""SHA-256 of canonical JSON of the sampling/policy dict.
Includes temperature, top_p, max_tokens, and the system prompt — any
of those changing means the answer is governed differently and the
cache must miss.
"""
return _sha256(_canonical_json(policy))
# Verifier-policy fields — the subset of `policy` that names what
# the deterministic verifier does. Separate from the broader
# `governance_policy_hash` so an auditor can answer "did the verifier
# rules change?" with a single hash diff rather than scanning the
# whole policy. See docs/cti-architecture.md §6 + the de-novo
# synthesis (2026-05-01) on verifier-policy identity.
#
# Adding a field here bumps `verifier_policy_hash` for every cached
# record on next lookup. Removing a field does the same. Reordering
# does not (set membership, not list ordering).
_VERIFIER_POLICY_FIELDS = frozenset({
# Mode + parser identity
"answer_mode",
# Pointer-mode hard checks
"claim_lattice_max_pointers_per_claim",
"claim_lattice_min_citation_coverage",
"claim_lattice_min_claim_content_tokens",
"claim_lattice_lazy_anchor_demote_threshold",
"claim_lattice_lazy_anchor_demote_min_pairs",
"claim_lattice_allowed_source_roles",
# Retrieval-side knob with verifier consequences
"claim_lattice_max_chunks_per_source",
# JSON variant identity
"claim_lattice_use_guided_json",
"claim_lattice_json_stop_sequences",
# Warrant-lite (relation-question hard check, Ticket H, 2026-05-01)
"claim_lattice_warrant_check_enabled",
"claim_lattice_deflection_check_enabled",
"claim_lattice_format_collapse_check_enabled",
# Subject-tokens-absent / premise-parroting (Ticket #000006 amend
# 2026-05-02b, Rule 9). Threshold of question∩claim content tokens
# absent from cited evidence union that demotes STRICT → HYBRID.
"claim_lattice_subject_tokens_absent_threshold",
# Ticket #000008 Phase 2 — quantifier preflight guard. The four
# fields together control whether broad-quantifier classification
# affects the per-call claim cap and which modes are gated.
# Adding a model to model_profiles.py PROFILES doesn't bump
# governance_policy_hash on its own (the dict isn't policy);
# but flipping any of these knobs DOES bump the hash, which
# invalidates prior cache records on lookup — exactly the
# invalidation we want when a guard knob changes.
"quantifier_guard_enabled",
"quantifier_guard_apply_caps",
"quantifier_apply_caps_modes",
"quantifier_caps_by_intensity",
"quantifier_guard_modes",
"quantifier_reminder_enabled",
"quantifier_reject_broad",
# Ticket #000010 — Meta-Cognition Preflight Guard. The six
# fields together control whether preflight runs and which
# detectors fire. Flipping any of them invalidates prior cache
# records on lookup — same governance discipline as #000008.
"metacognition_enabled",
"metacognition_temporal_check",
"metacognition_contradiction_check",
"metacognition_false_premise_check",
"metacognition_out_of_corpus_check",
"metacognition_block_on_contradiction",
# Quote-mode entity policy
"entity_policy",
"entity_proximity_n",
"entity_proximity_window",
# Wikitext base-prose pinning (changes verifier surface)
"base_version",
})
def verifier_policy_hash(policy: dict) -> str:
"""SHA-256 of canonical JSON of the verifier-relevant subset of policy.
Pulls `_VERIFIER_POLICY_FIELDS` out of `policy` and hashes only
those. Empty dict → constant hash (`sha256("{}")`). Folded into
`cache_key` as a 9th dimension so a verifier-policy change is
observable from the cache_key alone, separate from
`governance_policy_hash` which folds in temperature / top_p /
prompts.
The two hashes overlap (verifier fields ARE in the broader policy
dict and so contribute to governance_policy_hash too). That's
intentional — bumping a verifier rule bumps BOTH dimensions.
Bumping a non-verifier field (e.g. temperature) bumps ONLY
governance_policy_hash. The asymmetry is what makes the audit
legible: which dimension changed answers a question that scanning
the whole policy dict cannot.
"""
subset = {k: v for k, v in policy.items() if k in _VERIFIER_POLICY_FIELDS}
return _sha256(_canonical_json(subset))
def cache_key(
source_root: str,
question_hash_value: str,
model_profile_hash_value: str,
conversation_hash_value: str,
governance_policy_hash_value: str,
schema_version: str,
canonicalization_version: str,
chunking_version: str,
verifier_policy_hash_value: str | None = None,
) -> str:
"""SHA-256 of the cache-identity dimensions joined with '|'.
8-dim form (legacy): omit `verifier_policy_hash_value` (or pass
None). The result matches pre-2026-05-01 cache identity and
keeps backward compatibility with cached records written before
the 9th dimension landed.
9-dim form: pass `verifier_policy_hash_value` explicitly. Records
written under the 9-dim form bind to the verifier-policy
identity; lookups with a different verifier_policy_hash miss.
The 9th dimension is the explicit "did the verifier rules
change?" gate.
Any drift in any dimension produces a distinct cache_key.
"""
parts = [
source_root,
question_hash_value,
model_profile_hash_value,
conversation_hash_value,
governance_policy_hash_value,
schema_version,
canonicalization_version,
chunking_version,
]
if verifier_policy_hash_value is not None:
parts.append(verifier_policy_hash_value)
return _sha256(
"|".join(parts)
)