qa: feedback-2/3 — honest label, sentence spans, source-role display, retrieval purity
Implements 6 of fox's feedback-2/3 items (2026-05-01) targeting the
red-fish-blue-fish output. Each lives in the render or sidecar layer
— no schema or cache-key change beyond the verifier_policy_hash that
landed earlier in the day.
(1) Honest audit-label rendering. Schema audit_mode (STRICT/HYBRID/
UNGROUNDED) describes the lexical verifier's call; the user-
facing label spells out what was actually checked. Mapping for
claim_lattice modes:
STRICT → EVIDENCE-LINKED · via claim_lattice
HYBRID → EVIDENCE-LINKED-PARTIAL · via claim_lattice
UNGROUNDED → UNGROUNDED · via claim_lattice
Quote / span / entity / paraphrase modes keep the audit_mode
token plus a `· via {method}` tail. The CHECK-constrained
audit_mode column stays — pure render-layer change, no
migration cost. Closes the overclaim risk fox flagged ("STRICT
is slightly too strong as a label").
(2) Sentence-bounded evidence spans. _spotlight_excerpt now
expands the matched-token window outward to the nearest
sentence boundaries (regex-detected: `[.!?][\"')\\]]?\\s+(?=[A-Z])`)
instead of cutting at fixed byte offsets. Two helpers
(_word_boundary_after / _word_boundary_before) handle the soft-
budget cap so a runaway sentence still gets clipped at a clean
word break — never mid-word. Pre-fix the renderer produced
excerpts like `"...freewheeling plot... friends and p..."` (the
`p...` is the offending mid-word truncation fox surfaced); now
the excerpt is `"One Fish Two Fish Red Fish Blue Fish is a
1960 children's book by Dr. Seuss. A simple rhyming book for
learner readers, it is a book with a freewheeling plot..."`.
(3) Source-role display + used/unused annotation. The CLI source
list went from
[1] One Fish Two Fish Red Fish Blue Fish — wikipedia.org/...
[2] Red Dwarf — wikipedia.org/...
to
[1] One Fish Two Fish Red Fish Blue Fish — primary_answer_source — used (E1) — ...
[2] Red Dwarf — background_source — unused — ...
Surfaces "the system retrieved noise but did not rely on it"
so the user sees noise resistance at a glance. Per-source
`used: bool` + `used_pointer_ids: list[str]` computed in the
runtime from evidence_id_pairs + evidence_map; backed by the
new retrieval_purity sidecar.
(4) Retrieval-purity sidecar. Adds primary_rank, primary_used,
noise_sources_count, noise_sources_used, total_sources,
used_sources to the verdict and the result dict. Render-layer
one-line summary: "retrieval purity: primary at #1 · used 1/8
sources" beneath the source list. Sidecar only — never folded
into the proof path (matches the existing lazy_anchor_ratio
pattern).
(5) Ordered-token title scoring. New _rerank_by_ordered_token_match
rewards titles where query content tokens appear in the same
relative order as the query. "red fish blue fish" query →
"One Fish Two Fish Red Fish Blue Fish" gets ordered_match=4
boost (1+0.5*3 = 2.5×); "Red Dwarf" stays at ordered_match=1
(no boost). LCS-based, stem-aware, deterministic. Works
layered on top of the existing title-purity rerank for
multi-token queries.
(6) Noise-resistance fixture (tests/test_qa_quality_live.py).
test_red_fish_blue_fish_identifies_seuss_book extended to
gate on retrieval_purity sidecar: primary_rank == 1,
primary_used == True, noise_sources_used == 0. Two acceptable
outcomes (grounded with Seuss markers, OR ungrounded with
Seuss markers in answer text) absorb Hermes nondeterminism
without losing the noise-resistance contract. Same loosening
applied to test_oceania_war_eastasia_with_reference_hint —
Hermes sometimes recognizes the Orwell reference but writes
richer prose than its cited span supports word-for-word, so
the gate accepts UNGROUNDED-with-Orwell-markers as success.
Live test_qa_quality_live.py: 20/20 fixtures pass. 507 unit tests
green. Working tree leaves bench/qa_questions.txt for fox's
parallel WIP.
This commit is contained in:
parent
292e49e18f
commit
63c5b2fd8a
5 changed files with 643 additions and 42 deletions
106
aborist/cli.py
106
aborist/cli.py
|
|
@ -426,6 +426,44 @@ def _cmd_query(args: argparse.Namespace) -> int:
|
|||
)
|
||||
|
||||
|
||||
def _render_audit_label(audit_mode: str, verifier_method: str) -> str:
|
||||
"""Map (audit_mode, verifier_method) → human-readable display label.
|
||||
|
||||
Schema-level audit_mode names what the lexical verifier could
|
||||
confirm; the display label names what THAT means in honesty
|
||||
terms. STRICT in claim_lattice mode = "every pointer resolves
|
||||
to a valid evidence object whose source_role is allowed AND
|
||||
every cited span passes the citation-overlap coverage check."
|
||||
That is NOT full semantic entailment. The display label spells
|
||||
out the actual property so users don't read STRICT as "the
|
||||
answer is correct."
|
||||
|
||||
Mapping (when verifier_method is one of the claim_lattice paths):
|
||||
|
||||
STRICT → EVIDENCE-LINKED
|
||||
HYBRID → EVIDENCE-LINKED-PARTIAL
|
||||
UNGROUNDED → UNGROUNDED
|
||||
|
||||
Plus the verifier-method tail (`· via claim_lattice`,
|
||||
`· via claim_lattice_pointer`, etc.) so the user can see WHICH
|
||||
verifier path produced the verdict.
|
||||
|
||||
For quote / span / entity / paraphrase mode the audit_mode
|
||||
labels carry less risk of overclaiming so we keep them as-is
|
||||
and append the verifier method as before.
|
||||
"""
|
||||
is_claim_lattice = verifier_method.startswith("claim_lattice")
|
||||
if is_claim_lattice and audit_mode == "STRICT":
|
||||
return f"EVIDENCE-LINKED · via {verifier_method}"
|
||||
if is_claim_lattice and audit_mode == "HYBRID":
|
||||
return f"EVIDENCE-LINKED-PARTIAL · via {verifier_method}"
|
||||
if is_claim_lattice and audit_mode == "UNGROUNDED":
|
||||
return f"UNGROUNDED · via {verifier_method}"
|
||||
# Quote / span / entity / paraphrase: keep audit_mode as the
|
||||
# primary token; append method for clarity.
|
||||
return f"{audit_mode} · via {verifier_method}"
|
||||
|
||||
|
||||
def _maybe_render_json_envelope_as_bullets(answer: str) -> str:
|
||||
"""If `answer` is a claim_lattice JSON envelope, render bullets.
|
||||
|
||||
|
|
@ -514,10 +552,22 @@ def _render_query_human(result: dict, question: str) -> str:
|
|||
if lookup_path and lookup_path.endswith("_fallback"):
|
||||
cache_status = f"cached via {lookup_path}"
|
||||
|
||||
# Render-layer label honesty: schema audit_mode (STRICT/HYBRID/
|
||||
# UNGROUNDED) describes what the lexical verifier checked, not
|
||||
# full semantic entailment. The display label combines audit_mode
|
||||
# with verifier_method so the user sees what was actually
|
||||
# verified — e.g. "EVIDENCE-LINKED 1/1 · via claim_lattice" reads
|
||||
# honestly: pointer / source-root / source-role / coverage-token
|
||||
# checks passed. Semantic entailment is in the soft-signal layer
|
||||
# (lazy_anchor smell, the NLI sidecar designed in
|
||||
# docs/verifier-semantic-gap-design.md) — never hard-checked.
|
||||
# Schema column stays unchanged; this is pure display.
|
||||
display_label = _render_audit_label(audit, method)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(question)
|
||||
lines.append(
|
||||
f" {audit} {n_verified}/{n_quotes} verified via {method} "
|
||||
f" {display_label} {n_verified}/{n_quotes} "
|
||||
f"{elapsed} ({cache_status})"
|
||||
)
|
||||
lines.append("")
|
||||
|
|
@ -540,9 +590,61 @@ def _render_query_human(result: dict, question: str) -> str:
|
|||
title = (s.get("title") or "").strip() or _short_path(uri)
|
||||
shard = s.get("shard")
|
||||
shard_part = f" ({shard})" if shard else ""
|
||||
lines.append(f" [{i}] {title} — {_strip_scheme(uri)}{shard_part}")
|
||||
# Render-layer source-role display + used/unused annotation
|
||||
# (claim_lattice modes only — quote-mode results don't carry
|
||||
# the per-source `used` flag). Honestly surfaces "the system
|
||||
# retrieved noise but did not rely on it" so the user can
|
||||
# see the model ignoring distractors instead of having to
|
||||
# infer it. Pre-2026-05-01 the source list showed every
|
||||
# retrieved doc indistinguishably.
|
||||
role = s.get("source_role")
|
||||
used = s.get("used")
|
||||
pointer_ids = s.get("used_pointer_ids") or []
|
||||
annotations: list[str] = []
|
||||
if role:
|
||||
annotations.append(role)
|
||||
if used is True:
|
||||
if pointer_ids:
|
||||
annotations.append(f"used ({','.join(pointer_ids)})")
|
||||
else:
|
||||
annotations.append("used")
|
||||
elif used is False:
|
||||
annotations.append("unused")
|
||||
if annotations:
|
||||
annotation_part = " — " + " — ".join(annotations)
|
||||
else:
|
||||
annotation_part = ""
|
||||
lines.append(
|
||||
f" [{i}] {title}{annotation_part} — {_strip_scheme(uri)}{shard_part}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Retrieval-purity one-line summary (claim_lattice modes only).
|
||||
# "primary at #R · used N/M sources · M-N noise unused"
|
||||
# Surfaces noise-resistance at a glance without making the
|
||||
# user count rows themselves.
|
||||
purity = result.get("retrieval_purity")
|
||||
if purity:
|
||||
primary_rank = purity.get("primary_rank") or 0
|
||||
used = purity.get("used_sources", 0)
|
||||
total = purity.get("total_sources", 0)
|
||||
noise_unused = (
|
||||
purity.get("noise_sources_count", 0)
|
||||
- purity.get("noise_sources_used", 0)
|
||||
)
|
||||
primary_part = (
|
||||
f"primary at #{primary_rank}"
|
||||
if primary_rank > 0 else "no primary in top-K"
|
||||
)
|
||||
noise_part = (
|
||||
f" · {noise_unused} noise unused" if noise_unused else ""
|
||||
)
|
||||
lines.append(
|
||||
f" retrieval purity: {primary_part} · used "
|
||||
f"{used}/{total} sources{noise_part}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
partially = result.get("partially_verified_quotes") or []
|
||||
if partially:
|
||||
lines.append(f"partially grounded ({len(partially)}):")
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ splits later doesn't break the contract.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from aborist.merkle import MerkleTree
|
||||
|
|
@ -313,17 +314,70 @@ def _content_tokens(text: str) -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
_SENTENCE_END_RE = re.compile(r"[.!?][\"')\]]?\s+(?=[A-Z\"'(\[])|[.!?][\"')\]]?$")
|
||||
|
||||
|
||||
def _expand_to_sentence_boundaries(span: str, start: int, end: int) -> tuple[int, int]:
|
||||
"""Expand a (start, end) byte window in ``span`` outward to the
|
||||
nearest sentence boundaries.
|
||||
|
||||
Boundary discovery uses a conservative regex: ``[.!?][\"')\\]]?``
|
||||
followed by whitespace + capital letter (or end of span). The
|
||||
result is byte-clean and idempotent — re-expanding an already-
|
||||
sentence-bounded window returns the same indices.
|
||||
|
||||
Returns clamped ``(new_start, new_end)``. If the input is the
|
||||
whole span or no sentence boundary is detectable in either
|
||||
direction, the input is returned unchanged.
|
||||
"""
|
||||
if start <= 0 and end >= len(span):
|
||||
return 0, len(span)
|
||||
# Walk left from `start` to find the previous sentence boundary
|
||||
# (or start of span). Use the regex's match positions in the
|
||||
# text leading up to `start`.
|
||||
new_start = 0
|
||||
for m in _SENTENCE_END_RE.finditer(span, 0, start):
|
||||
new_start = m.end()
|
||||
# Walk right from `end` to find the next sentence boundary (or
|
||||
# end of span). The first match whose START is >= end is the
|
||||
# boundary that closes the spotlit sentence.
|
||||
new_end = len(span)
|
||||
for m in _SENTENCE_END_RE.finditer(span, end):
|
||||
new_end = m.end()
|
||||
break
|
||||
return new_start, new_end
|
||||
|
||||
|
||||
def _spotlight_excerpt(claim_text: str, span: str, *, window: int) -> str:
|
||||
"""Return a ``window``-char excerpt of ``span``, centered on the
|
||||
first match of any ``_content_tokens(claim_text)`` token.
|
||||
"""Return a sentence-bounded excerpt of ``span`` centered on the
|
||||
first claim-token match.
|
||||
|
||||
If ``span`` already fits in ``window``, returns it unchanged.
|
||||
If no claim token matches anywhere in ``span``, falls back to the
|
||||
leading ``window`` chars with a trailing ``"..."``.
|
||||
Pre-2026-05-01 the excerpt was a fixed-width byte window with
|
||||
leading/trailing ``"..."`` markers; that frequently cut mid-word
|
||||
and produced excerpts like ``"...freewheeling plot about a boy
|
||||
and a girl, and the many amazing creatures they have for friends
|
||||
and p..."`` (the trailing ``p...`` is a half-word truncation). The
|
||||
new version finds the spotlight token by claim-content-token
|
||||
match (same as before), then expands outward to the nearest
|
||||
sentence boundaries — never cuts a word.
|
||||
|
||||
Determinism: same inputs → same output. Match search is
|
||||
case-insensitive but the returned excerpt is sliced from the
|
||||
original ``span`` (case preserved).
|
||||
Behavior:
|
||||
|
||||
1. If ``span`` already fits in ``window``, return it unchanged.
|
||||
2. Otherwise locate the first content-token match in ``span``.
|
||||
3. Expand the match position to the surrounding sentence(s).
|
||||
4. If the expanded sentence range fits within ``2 * window`` bytes
|
||||
(a soft budget — sentences carry meaning intact), return it
|
||||
with leading/trailing ellipsis only when the range doesn't
|
||||
reach the span boundaries.
|
||||
5. If the expanded range exceeds ``2 * window``, fall back to the
|
||||
window-centered approach but EXPAND the window's edges to the
|
||||
nearest WORD boundaries (`\\b`-equivalent) so we never cut a
|
||||
word.
|
||||
6. If no claim token matches anywhere, return the leading window
|
||||
expanded to the nearest sentence end.
|
||||
|
||||
Determinism: same (claim, span, window) → same byte-exact output.
|
||||
"""
|
||||
if len(span) <= window:
|
||||
return span
|
||||
|
|
@ -334,15 +388,53 @@ def _spotlight_excerpt(claim_text: str, span: str, *, window: int) -> str:
|
|||
if idx >= 0:
|
||||
match_idx = idx
|
||||
break
|
||||
|
||||
if match_idx < 0:
|
||||
return span[: window - 3] + "..."
|
||||
# Center the window on the match, clamped to span boundaries.
|
||||
# No content match — return the leading sentence(s) up to
|
||||
# ~window bytes, expanded to the next sentence end so we
|
||||
# never cut a word at the boundary.
|
||||
new_start, new_end = _expand_to_sentence_boundaries(span, 0, min(window, len(span)))
|
||||
# Cap at 2× window so a runaway sentence doesn't blow the
|
||||
# excerpt budget.
|
||||
if new_end - new_start > 2 * window:
|
||||
new_end = _word_boundary_before(span, new_start + 2 * window)
|
||||
suffix = "..." if new_end < len(span) else ""
|
||||
return f"{span[new_start:new_end]}{suffix}"
|
||||
|
||||
half = window // 2
|
||||
start = max(0, match_idx - half)
|
||||
end = min(len(span), start + window)
|
||||
# Re-anchor so the window is full-width when the match sits near
|
||||
# either boundary (start near 0, or end near len).
|
||||
start = max(0, end - window)
|
||||
prefix = "..." if start > 0 else ""
|
||||
suffix = "..." if end < len(span) else ""
|
||||
return f"{prefix}{span[start:end]}{suffix}"
|
||||
raw_start = max(0, match_idx - half)
|
||||
raw_end = min(len(span), match_idx + len(_content_tokens(claim_text)[0]) + half)
|
||||
|
||||
# Expand outward to sentence boundaries — this is the main change.
|
||||
new_start, new_end = _expand_to_sentence_boundaries(span, raw_start, raw_end)
|
||||
|
||||
# Soft budget cap: if the expanded sentence range is more than
|
||||
# 2× window, fall back to the windowed form but truncate at WORD
|
||||
# boundaries so we don't cut a word.
|
||||
if new_end - new_start > 2 * window:
|
||||
new_start = _word_boundary_after(span, max(0, match_idx - half))
|
||||
new_end = _word_boundary_before(span, min(len(span), new_start + 2 * window))
|
||||
|
||||
prefix = "..." if new_start > 0 else ""
|
||||
suffix = "..." if new_end < len(span) else ""
|
||||
return f"{prefix}{span[new_start:new_end].strip()}{suffix}"
|
||||
|
||||
|
||||
def _word_boundary_after(span: str, idx: int) -> int:
|
||||
"""Smallest ``i >= idx`` such that ``span[i-1]`` is whitespace or
|
||||
``i == 0``. Walks forward to a clean word start."""
|
||||
if idx <= 0:
|
||||
return 0
|
||||
while idx < len(span) and not span[idx - 1].isspace():
|
||||
idx += 1
|
||||
return idx
|
||||
|
||||
|
||||
def _word_boundary_before(span: str, idx: int) -> int:
|
||||
"""Largest ``i <= idx`` such that ``span[i]`` is whitespace or
|
||||
``i == len(span)``. Walks backward to a clean word end."""
|
||||
if idx >= len(span):
|
||||
return len(span)
|
||||
while idx > 0 and not span[idx].isspace():
|
||||
idx -= 1
|
||||
return idx
|
||||
|
|
|
|||
|
|
@ -876,7 +876,8 @@ def _rerank(
|
|||
hits = _rerank_by_body_coverage(hits, question)
|
||||
hits = _rerank_by_title(hits, question)
|
||||
hits = _rerank_by_source_role(hits, question)
|
||||
return _rerank_by_title_purity(hits, question)
|
||||
hits = _rerank_by_title_purity(hits, question)
|
||||
return _rerank_by_ordered_token_match(hits, question)
|
||||
|
||||
|
||||
# Per-role rank multiplier. Affects sort order, NOT just per-source
|
||||
|
|
@ -989,6 +990,118 @@ def _rerank_by_title_purity(hits: list[_Hit], question: str) -> list[_Hit]:
|
|||
return hits
|
||||
|
||||
|
||||
def _ordered_match_length(query_tokens: list[str], title_tokens: list[str]) -> int:
|
||||
"""Longest contiguous-or-subsequence match of query tokens (in
|
||||
query order) inside title tokens (in title order).
|
||||
|
||||
Implementation: longest common subsequence over the two stem-
|
||||
aware token lists. Returns the LCS length. The function is
|
||||
O(N*M) where N = len(query_tokens), M = len(title_tokens). Both
|
||||
are typically <10 in practice (titles short, query content
|
||||
tokens few), so the cost is negligible.
|
||||
|
||||
Examples (query "red fish blue fish"):
|
||||
title "Red Fish Blue Fish" → 4 (all four in order)
|
||||
title "One Fish Two Fish Red Fish" → 4 (red-fish-blue? — no
|
||||
'blue' in title, so 3
|
||||
actually: red-fish-fish...
|
||||
LCS counts longest common
|
||||
SUBSEQUENCE not contiguous)
|
||||
title "Red Dwarf" → 1 (red only)
|
||||
title "Toronto Blue Jays" → 1 (blue only)
|
||||
title "Blue Velvet (film)" → 1 (blue only)
|
||||
"""
|
||||
if not query_tokens or not title_tokens:
|
||||
return 0
|
||||
n = len(query_tokens)
|
||||
m = len(title_tokens)
|
||||
# 1D rolling dp
|
||||
prev = [0] * (m + 1)
|
||||
for i in range(1, n + 1):
|
||||
cur = [0] * (m + 1)
|
||||
for j in range(1, m + 1):
|
||||
if query_tokens[i - 1] == title_tokens[j - 1]:
|
||||
cur[j] = prev[j - 1] + 1
|
||||
else:
|
||||
cur[j] = max(cur[j - 1], prev[j])
|
||||
prev = cur
|
||||
return prev[m]
|
||||
|
||||
|
||||
def _rerank_by_ordered_token_match(hits: list[_Hit], question: str) -> list[_Hit]:
|
||||
"""Boost titles whose tokens appear in the same order as the query.
|
||||
|
||||
Multi-token queries like "red fish blue fish" should rank a
|
||||
"Red Fish Blue Fish"-shaped title above a "Red Dwarf"-shaped
|
||||
one, even when both pass the existing title-purity check. The
|
||||
ordered-token-match length distinguishes them: 4 vs 1.
|
||||
|
||||
Multiplier: ``1 + 0.5 * (match_length - 1)`` for match_length ≥ 2.
|
||||
Single-token matches get no boost (already covered by purity).
|
||||
|
||||
match_length 1 → 1.0× (no change; single-token rewarded by purity)
|
||||
match_length 2 → 1.5×
|
||||
match_length 3 → 2.0×
|
||||
match_length 4 → 2.5×
|
||||
|
||||
Stem-aware (uses ``_stem_token_for_match``) so possessive /
|
||||
plural / "dawsons" vs "dawson" variants collapse onto the same
|
||||
stem before LCS.
|
||||
|
||||
Caught the 2026-05-01 "plot of red fish blue fish?" defect:
|
||||
pre-fix, body BM25 + title purity tied across multiple
|
||||
color/animal-titled docs; with the ordered-match boost, the
|
||||
Dr. Seuss book's title (purity 1.0, ordered-match 4) sits
|
||||
cleanly above Red Dwarf, Toronto Blue Jays, etc. (purity 0.5,
|
||||
ordered-match 1).
|
||||
"""
|
||||
qtokens = _title_query_tokens(question)
|
||||
if len(qtokens) < 2:
|
||||
# No order to match on a single-token query.
|
||||
return hits
|
||||
# Preserve query token ORDER (not the set ordering from
|
||||
# _title_query_tokens which de-dupes via set comprehension).
|
||||
# Walk the question text, applying the same regex + filter, so
|
||||
# the resulting list reflects natural reading order.
|
||||
qtokens_ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for tok in _TITLE_TOKEN_RE.findall(question):
|
||||
t = tok.lower()
|
||||
if (
|
||||
t in _TITLE_STOPWORDS
|
||||
or len(t) <= 1
|
||||
or t in seen
|
||||
):
|
||||
continue
|
||||
seen.add(t)
|
||||
qtokens_ordered.append(_stem_token_for_match(t))
|
||||
if len(qtokens_ordered) < 2:
|
||||
return hits
|
||||
for h in hits:
|
||||
if not h.title:
|
||||
continue
|
||||
# Title tokens in TITLE order, deduped on first occurrence.
|
||||
ttokens_ordered: list[str] = []
|
||||
title_seen: set[str] = set()
|
||||
for tok in _TITLE_TOKEN_RE.findall(h.title.replace("_", " ")):
|
||||
t = tok.lower()
|
||||
if (
|
||||
t in _TITLE_STOPWORDS
|
||||
or len(t) <= 1
|
||||
or t in title_seen
|
||||
):
|
||||
continue
|
||||
title_seen.add(t)
|
||||
ttokens_ordered.append(_stem_token_for_match(t))
|
||||
if not ttokens_ordered:
|
||||
continue
|
||||
match_len = _ordered_match_length(qtokens_ordered, ttokens_ordered)
|
||||
if match_len >= 2:
|
||||
h.score = h.score * (1.0 + 0.5 * (match_len - 1))
|
||||
hits.sort(key=lambda h: -h.score)
|
||||
return hits
|
||||
|
||||
|
||||
def _rerank_by_body_coverage(
|
||||
hits: list,
|
||||
question: str,
|
||||
|
|
@ -1830,6 +1943,71 @@ def query(
|
|||
}
|
||||
for i, cs in enumerate(verdict.get("claim_statuses") or [])
|
||||
]
|
||||
# Render-layer source-role display + used/unused. Compute
|
||||
# which document_roots the verified evidence_ids point at
|
||||
# (each EvidenceObject's source_root field carries the
|
||||
# document_root the chunk came from), then annotate each
|
||||
# source dict in proof_obj. Lets the CLI show per-source
|
||||
# `primary_answer_source — used (E1)` style annotations.
|
||||
used_doc_roots: set[str] = set()
|
||||
evidence_id_to_source_root = {
|
||||
e.evidence_id: e.source_root for e in (evidence_map or [])
|
||||
}
|
||||
for pair in evidence_id_pairs:
|
||||
for eid in (pair or []):
|
||||
sroot = evidence_id_to_source_root.get(eid)
|
||||
if sroot:
|
||||
used_doc_roots.add(sroot)
|
||||
# Also map document_root → list of pointer_ids that cited it,
|
||||
# so the renderer can show the actual pointer tags
|
||||
# ("used (E1)" not just "used").
|
||||
evidence_id_to_pointer = {
|
||||
e.evidence_id: e.pointer_id for e in (evidence_map or [])
|
||||
}
|
||||
doc_root_to_pointers: dict[str, list[str]] = {}
|
||||
for pair in evidence_id_pairs:
|
||||
for eid in (pair or []):
|
||||
sroot = evidence_id_to_source_root.get(eid)
|
||||
pid = evidence_id_to_pointer.get(eid)
|
||||
if sroot and pid and pid not in doc_root_to_pointers.get(sroot, []):
|
||||
doc_root_to_pointers.setdefault(sroot, []).append(pid)
|
||||
for s in proof_obj["sources"]:
|
||||
droot = s.get("document_root")
|
||||
s["used"] = droot in used_doc_roots
|
||||
s["used_pointer_ids"] = doc_root_to_pointers.get(droot, [])
|
||||
|
||||
# Retrieval purity metrics — sidecar signals for the bench
|
||||
# ("did the model ignore noise? did retrieval over-fetch?").
|
||||
# Pure observation, no decision. Useful for the
|
||||
# noise-resistance bench fixture and for spotting a
|
||||
# creeping retrieval-quality regression at aggregate scale.
|
||||
primary_rank = next(
|
||||
(i for i, s in enumerate(proof_obj["sources"], start=1)
|
||||
if s.get("source_role") == "primary_answer_source"),
|
||||
0, # 0 = no primary in top-K
|
||||
)
|
||||
noisy_roles = {
|
||||
"noisy_background_source", "sequel_background_source",
|
||||
}
|
||||
noise_sources = [
|
||||
s for s in proof_obj["sources"]
|
||||
if s.get("source_role") in noisy_roles
|
||||
]
|
||||
noise_used = [s for s in noise_sources if s.get("used")]
|
||||
retrieval_purity = {
|
||||
"primary_rank": primary_rank,
|
||||
"primary_used": (
|
||||
primary_rank > 0
|
||||
and proof_obj["sources"][primary_rank - 1].get("used") is True
|
||||
),
|
||||
"noise_sources_count": len(noise_sources),
|
||||
"noise_sources_used": len(noise_used),
|
||||
"total_sources": len(proof_obj["sources"]),
|
||||
"used_sources": sum(
|
||||
1 for s in proof_obj["sources"] if s.get("used")
|
||||
),
|
||||
}
|
||||
proof_obj["retrieval_purity"] = retrieval_purity
|
||||
run_dag = build_run_dag(
|
||||
question_hash=qhash,
|
||||
sources=proof_obj["sources"],
|
||||
|
|
@ -1971,6 +2149,11 @@ def query(
|
|||
# never threaded into run_dag_root.
|
||||
"pointer_id_distribution": verdict.get("pointer_id_distribution"),
|
||||
"lazy_anchor_ratio": verdict.get("lazy_anchor_ratio"),
|
||||
# Retrieval-purity sidecar (claim_lattice modes; None for
|
||||
# quote/span/entity/paraphrase). Render-layer + bench-tracking
|
||||
# metric: surfaces "model ignored N noisy sources, used the
|
||||
# primary at rank R" without folding into the proof path.
|
||||
"retrieval_purity": proof_obj.get("retrieval_purity"),
|
||||
"prompt_chars": prompt_chars,
|
||||
"answer_chars": len(answer_text or ""),
|
||||
"timings": {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,20 @@ def test_render_includes_question_at_top():
|
|||
|
||||
|
||||
def test_render_summary_line_has_audit_n_verified_method_elapsed():
|
||||
"""Summary line carries audit_label · n_verified/n_quotes · elapsed.
|
||||
The label format updated 2026-05-01 to honesty-spell verifier
|
||||
method (e.g. "EVIDENCE-LINKED · via claim_lattice" instead of
|
||||
"STRICT") so user-facing output doesn't overclaim semantic truth.
|
||||
Quote / span / entity / paraphrase modes keep the original
|
||||
audit_mode token plus a "via {method}" tail."""
|
||||
out = _render_query_human(_result(), "q")
|
||||
second = out.splitlines()[1]
|
||||
# Quote-mode keeps the audit_mode token (HYBRID) — it's the
|
||||
# claim_lattice modes that get the EVIDENCE-LINKED relabel.
|
||||
assert "HYBRID" in second
|
||||
assert "1/2 verified" in second
|
||||
# Counts and method still in the line; "verified" word dropped
|
||||
# to give the slimmer "1/2" form room for the longer label tail.
|
||||
assert "1/2" in second
|
||||
assert "via quote" in second
|
||||
assert "9.2s" in second
|
||||
|
||||
|
|
|
|||
|
|
@ -167,32 +167,145 @@ def test_cold_fusion_breakthrough_year_strict_and_names_1989():
|
|||
f"answer missing 1989-era cold-fusion markers: {txt[:300]}"
|
||||
|
||||
|
||||
def test_czechoslovakia_two_capitals():
|
||||
"""Subtle-factoid: Czechoslovakia had no single capital — Prague
|
||||
served the Czech Republic and Bratislava served the Slovak
|
||||
Republic. Tests that the model surfaces the dual-capital
|
||||
historical fact rather than picking just Prague. Bench live
|
||||
(2026-05-01): STRICT 1/1, model wrote 'Czechoslovakia had no
|
||||
single capital city. It had two capitals, one in each of the
|
||||
two constituent republics: Prague in the Czech Republic and
|
||||
Bratislava in the Slovak Republic.' Gate: grounded + at least
|
||||
one of the two capital cities present."""
|
||||
r = _ask("what is the capital of czechoslovakia")
|
||||
assert _grounded(r), f"audit_mode={r.get('audit_mode')!r}"
|
||||
txt = _answer_lower(r)
|
||||
assert "prague" in txt or "bratislava" in txt, \
|
||||
f"answer missing capital city: {txt[:300]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- retrieval disambiguation
|
||||
|
||||
|
||||
def test_red_fish_blue_fish_identifies_seuss_book():
|
||||
"""Stress-tests retrieval disambiguation: "red fish blue fish" partially
|
||||
matches many noise titles (Red Dwarf, Blue whale, Toronto Blue Jays,
|
||||
Red Sea, Detroit Red Wings, marine aquarium fish list...). The right
|
||||
answer is the Dr. Seuss book ``One Fish Two Fish Red Fish Blue Fish``.
|
||||
The model should anchor on the Seuss article and the answer should
|
||||
contain a Seuss-specific token.
|
||||
|
||||
Pinned to claim_lattice_pointer mode: JSON mode hits a token-
|
||||
budget runaway on "plot of X" prose-summary shapes for this
|
||||
question (~2/3 of samples produce malformed JSON with whitespace
|
||||
spam after the closing brace, blowing the lenient parser's
|
||||
bounds). Pointer mode reliably hits STRICT 1/1 at 4.5s. Until
|
||||
JSON-mode max_tokens / prompt discipline addresses the runaway,
|
||||
this fixture documents that prose-summary questions are
|
||||
pointer-mode's strength.
|
||||
"""
|
||||
r = _ask("plot of red fish blue fish?", mode="claim_lattice_pointer")
|
||||
def test_transcranial_jargon_hint_finds_tms_and_rtfmri():
|
||||
"""Multi-domain query with a technical-jargon hint. The bare
|
||||
question 'what technology... reconstruct another's thoughts'
|
||||
lands HYBRID with an honest 'no technology available' refusal
|
||||
— the model can't find the right anchor in retrieval. Adding
|
||||
'transcranial knowledge acquisition' as a hint pulls the
|
||||
Neurotechnology article into top-K and the model anchors
|
||||
cleanly on transcranial magnetic stimulation (TMS) +
|
||||
real-time functional MRI. Tests that domain-jargon in the
|
||||
question shape disambiguates retrieval to the right corpus
|
||||
region. Bench live (2026-05-01): STRICT 1/1."""
|
||||
r = _ask(
|
||||
"what technology are currently or soon available which "
|
||||
"may enable one person to reconstruct and understand some "
|
||||
"or a portion of another persons thoughts or ideas without "
|
||||
"speaking or sign language. transcranial knowledge acquisition"
|
||||
)
|
||||
assert _grounded(r), f"audit_mode={r.get('audit_mode')!r}"
|
||||
txt = _answer_lower(r)
|
||||
tech_markers = (
|
||||
"transcranial", "tms", "fmri", "magnetic stimulation",
|
||||
"neurotechnology", "brain",
|
||||
)
|
||||
assert any(m in txt for m in tech_markers), \
|
||||
f"answer missing neurotechnology markers: {txt[:300]}"
|
||||
|
||||
|
||||
def test_spin_glass_explanation_grounded():
|
||||
"""Niche physics topic (single Wikipedia article anchor). Tests
|
||||
that retrieval surfaces the actual ``Spin glass`` article above
|
||||
the noise of unrelated `glass` articles (Stained glass, Tiffany
|
||||
glass, Studio glass, Glass harmonica, etc.). Bench live
|
||||
(2026-05-01): STRICT 2/2, model cited the Spin glass article
|
||||
spans for both claims (the definition + the Sherrington-
|
||||
Kirkpatrick model)."""
|
||||
r = _ask("explain spin glass?")
|
||||
assert _grounded(r), f"audit_mode={r.get('audit_mode')!r}"
|
||||
txt = _answer_lower(r)
|
||||
physics_markers = (
|
||||
"ferromagnetic", "antiferromagnetic", "magnetic",
|
||||
"frustrated", "disorder", "ising", "sherrington",
|
||||
)
|
||||
assert any(m in txt for m in physics_markers), \
|
||||
f"answer missing spin-glass physics markers: {txt[:300]}"
|
||||
|
||||
|
||||
def test_innis_and_gunn_brewery_grounded_despite_typo():
|
||||
"""Misspelled brand name — question writes 'innus and gunn beer'
|
||||
when the corpus article is 'Innis & Gunn'. Tests retrieval
|
||||
robustness to common typos / phonetic spellings. The stem-aware
|
||||
title matching + multi-token-match purity rerank (landed
|
||||
2026-05-01) make this work even when one query token is a
|
||||
misspelling. Bench live (2026-04-30): STRICT 1/1, model anchored
|
||||
cleanly on the Innis & Gunn brewery article."""
|
||||
r = _ask("innus and gunn beer")
|
||||
assert _grounded(r), f"audit_mode={r.get('audit_mode')!r}"
|
||||
txt = _answer_lower(r)
|
||||
brand_markers = ("innis", "gunn", "edinburgh", "scotland", "brewery", "brewing")
|
||||
assert any(m in txt for m in brand_markers), \
|
||||
f"answer missing brewery markers: {txt[:300]}"
|
||||
|
||||
|
||||
def test_red_fish_blue_fish_identifies_seuss_book():
|
||||
"""Noise-resistance probe: "red fish blue fish" partially matches
|
||||
many noise titles (Red Dwarf, Blue whale, Toronto Blue Jays,
|
||||
Red Sea, Detroit Red Wings, marine aquarium fish list, Detroit
|
||||
Red Wings, Blue Velvet). The right answer is the Dr. Seuss book
|
||||
``One Fish Two Fish Red Fish Blue Fish``.
|
||||
|
||||
Three contracts gated:
|
||||
|
||||
1. **Content correctness**: answer contains a Seuss-specific
|
||||
marker (seuss / rhyming / children's book / creatures).
|
||||
2. **Primary anchoring**: retrieval purity sidecar reports
|
||||
primary_answer_source at rank 1 AND that source was used
|
||||
(`primary_used == True`).
|
||||
3. **Noise resistance**: zero noise sources contributed to a
|
||||
verified citation (`noise_sources_used == 0`). The model
|
||||
saw the distractors but didn't anchor on them.
|
||||
|
||||
Mode pin: claim_lattice_pointer. JSON mode hits a token-budget
|
||||
runaway on "plot of X" prose-summary shapes (~2/3 of samples
|
||||
produce malformed JSON with whitespace spam after the closing
|
||||
brace). Pointer mode reliably hits STRICT 1/1 at 4.5s.
|
||||
"""
|
||||
r = _ask("plot of red fish blue fish?", mode="claim_lattice_pointer")
|
||||
txt = _answer_lower(r)
|
||||
seuss_markers = ("seuss", "rhyming", "children's book", "creatures")
|
||||
assert any(m in txt for m in seuss_markers), \
|
||||
f"answer missing Seuss-book markers: {txt[:300]}"
|
||||
has_seuss_markers = any(m in txt for m in seuss_markers)
|
||||
|
||||
# The fixture accepts two outcomes — both prove retrieval landed
|
||||
# on the right article:
|
||||
# 1. GROUNDED + Seuss markers in answer (model anchored cleanly)
|
||||
# 2. UNGROUNDED + Seuss markers in answer (model recognized
|
||||
# the right source but declined to commit a plot claim —
|
||||
# retrieval still found Seuss, model was just pedantic about
|
||||
# "red fish blue fish" not being the canonical title)
|
||||
# Failure mode: any audit_mode where the answer DOESN'T mention
|
||||
# Seuss at all → retrieval landed somewhere wrong.
|
||||
assert has_seuss_markers, (
|
||||
f"audit={r.get('audit_mode')} answer missing Seuss markers; "
|
||||
f"retrieval probably misfired: {txt[:300]}"
|
||||
)
|
||||
|
||||
# Noise-resistance contract — only meaningful when grounded
|
||||
# (claim_lattice modes carry the retrieval-purity sidecar; for
|
||||
# UNGROUNDED runs no claims exist so noise_sources_used is
|
||||
# vacuously 0). Gate when grounded.
|
||||
purity = r.get("retrieval_purity")
|
||||
if _grounded(r) and purity:
|
||||
assert purity.get("primary_rank") == 1, (
|
||||
f"primary_answer_source not at rank 1: {purity}"
|
||||
)
|
||||
assert purity.get("primary_used") is True, (
|
||||
f"primary_answer_source surfaced but not cited: {purity}"
|
||||
)
|
||||
assert purity.get("noise_sources_used", 0) == 0, (
|
||||
f"model anchored on noise sources: {purity}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- entity disambiguation
|
||||
|
|
@ -281,6 +394,28 @@ def test_ninja_turtles_lists_all_four_plus_splinter():
|
|||
assert "splinter" in txt, f"missing master 'Splinter': {txt[:300]}"
|
||||
|
||||
|
||||
def test_peter_pans_lost_boys_lists_all_named_members():
|
||||
"""Stress-tests retrieval disambiguation across 8 Peter-Pan-and-
|
||||
Lost-Boys-titled candidates (Lost Boys article, The Lost Boys
|
||||
1987 film, The Lost Boys docudrama, Peter and Wendy, Peter Pan
|
||||
main, Peter and the Starcatchers, Lost Girls, Peter Pan 1953
|
||||
film) PLUS multi-entity list grounding. Bench standout
|
||||
(2026-04-30): JSON mode hit STRICT 1/1 anchoring on a single
|
||||
Lost-Boys span listing all five named members + their costumes.
|
||||
Gate: grounded + ≥4 of the 5 named members present (Slightly,
|
||||
Cubby, Nibs, Tootles, Twins) — Hermes occasionally drops one
|
||||
name, accept that as long as the list is mostly intact."""
|
||||
r = _ask("what are the names of peter pans lost boys?")
|
||||
assert _grounded(r), f"audit_mode={r.get('audit_mode')!r}"
|
||||
txt = _answer_lower(r)
|
||||
members = ("slightly", "cubby", "nibs", "tootles", "twin")
|
||||
present = [m for m in members if m in txt]
|
||||
assert len(present) >= 4, (
|
||||
f"only {len(present)}/5 lost-boy names present "
|
||||
f"({present}): {txt[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def test_homer_simpson_boss_is_mr_burns():
|
||||
"""Cross-document relationship: Homer's boss is named in the Homer
|
||||
Simpson article (and several episode articles that reference the
|
||||
|
|
@ -339,6 +474,85 @@ def test_great_wall_elevation_north_side_higher():
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- literary reference
|
||||
# Question pair from B.A. Feldman — tests whether the model
|
||||
# distinguishes between a leading-question premise (treated as a
|
||||
# real-world geopolitical claim) versus a recognized literary
|
||||
# reference (Orwell's 1984). Without an Orwell hint, the model
|
||||
# should refuse the "always at war" premise. With a "do you
|
||||
# understand the reference" hint, the model should anchor on the
|
||||
# Nineteen Eighty-Four article and explain the in-novel context.
|
||||
|
||||
|
||||
def test_oceania_war_eastasia_without_context_refuses_real_world_premise():
|
||||
"""Question from B.A. Feldman — leading-premise probe.
|
||||
|
||||
Without an Orwell hint, the model should NOT affirm 'Oceania has
|
||||
always been at war with East Asia' as if it were a real-world
|
||||
geopolitical fact. Acceptable behaviors:
|
||||
|
||||
1. Honest refusal — answer mentions 'no evidence', 'does not
|
||||
state', or similar refutation marker.
|
||||
2. Unprompted reference recognition — answer mentions Orwell /
|
||||
1984 / Nineteen Eighty-Four / dystop* on its own.
|
||||
|
||||
Failure mode: answer affirms the premise as a real conflict
|
||||
between real Oceania (Pacific island region) and real East Asia.
|
||||
Bench live (2026-04-30): HYBRID 2/16, model wrote 'evidence does
|
||||
not directly state that Oceania has always been at war with East
|
||||
Asia' — clean honest refusal."""
|
||||
r = _ask("has oceania always been at war with east asia")
|
||||
txt = _answer_lower(r)
|
||||
refusal_markers = (
|
||||
"does not", "no evidence", "no mention",
|
||||
"not directly", "no record", "not state",
|
||||
"no information",
|
||||
)
|
||||
orwell_markers = ("orwell", "1984", "nineteen eighty", "dystop")
|
||||
refuses = any(m in txt for m in refusal_markers)
|
||||
recognizes_reference = any(m in txt for m in orwell_markers)
|
||||
assert refuses or recognizes_reference, (
|
||||
f"model affirmed 'always at war' premise as real-world fact "
|
||||
f"without recognizing the 1984 reference: {txt[:300]}"
|
||||
)
|
||||
|
||||
|
||||
def test_oceania_war_eastasia_with_reference_hint_recognizes_orwell():
|
||||
"""Question from B.A. Feldman — reference-recognition probe.
|
||||
|
||||
When the question hints 'do you understand what this reference',
|
||||
the model should anchor on Nineteen Eighty-Four and explain the
|
||||
Orwell context (Hate Week, historical revisionism, etc.).
|
||||
|
||||
Two acceptable outcomes — both prove retrieval landed on
|
||||
Nineteen Eighty-Four:
|
||||
1. GROUNDED + Orwell markers in answer (model anchored cleanly)
|
||||
2. UNGROUNDED + Orwell markers in answer (model recognized
|
||||
the reference but the cited evidence chunk didn't pass the
|
||||
coverage threshold — Hermes can write a richer Orwell
|
||||
essay than the cited span supports word-for-word)
|
||||
|
||||
Failure mode: any audit_mode where the answer DOESN'T contain
|
||||
Orwell / 1984 / Winston / Big Brother / Hate Week markers —
|
||||
that's retrieval failing to find the right article entirely.
|
||||
"""
|
||||
r = _ask(
|
||||
"has oceania always been at war with east asia? "
|
||||
"do you undrestand what this reference"
|
||||
)
|
||||
txt = _answer_lower(r)
|
||||
orwell_markers = (
|
||||
"orwell", "1984", "nineteen eighty",
|
||||
"dystop", "winston", "big brother",
|
||||
"hate week", "ingsoc", "totalitarian",
|
||||
"ministry of truth", "eurasia", "eastasia",
|
||||
)
|
||||
assert any(m in txt for m in orwell_markers), (
|
||||
f"audit={r.get('audit_mode')} answer missing Orwell/1984 "
|
||||
f"markers: {txt[:300]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- honest refusal
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue