arborist/aborist/qa/evidence.py
russell@unturf.com bf8d93caba
qa: spotlight density rank — pick load-bearing slice, not leading match
Pre-fix: _spotlight_excerpt used first-match-of-longest-content-token.
For "who is homer simpson's boss?" the Homer Simpson chunk had
"homer" matching early in voice-actor / Castellaneta prose ("For
voicing Homer..."), so the spotlight centered there even though
the actual answer ("his boss Mr. Burns") was deeper in the chunk
where boss/burns/homer cluster together.

Post-fix: density rank. Find ALL match positions for ALL content
tokens, then pick the position whose ±half-window cluster contains
the MOST distinct tokens. Tie-break on smallest idx (deterministic,
reproducible).

Live verified:
  - "who is homer simpson's boss?" — spotlight now lands on
    "...At the plant, Homer is often ignored and completely
    forgotten by his boss Mr. Burns..." (boss/burns/homer/plant
    cluster). Pre-fix it landed on voice-actor prose with no
    Mr. Burns visible.
  - "name simpsons family members including pets?" — spotlight
    lands on "...the family's two pets, Santa's Little Helper
    and Snowball II..." (densest pets/family cluster). Already
    worked pre-fix; now even tighter.

Performance: O(|positions|^2) inner ranking. Typical chunks have
4-10 content tokens and 1-3 match positions each, so 8-30 positions
and ≤900 inner iterations — trivial.

Bracket format change (in render_claim_lattice) ships alongside:
   [E5: "..."]                          → before
   [E5 | <title> | <chunk_root[:8]>: "..."]  → after
Closes visual confusion where E# is a chunk pointer but readers
parsed it as a 1-indexed source rank from the sources list.

Tests: 523 passed.
2026-05-01 13:37:04 -04:00

481 lines
20 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.

"""Evidence map for claim-lattice-pointer (quote-by-pointer) answer mode.
Builds a deterministic table of evidence objects from already-retrieved
chunks. The model references each object by a short ``pointer_id``
("E1", "E2", ...) which the runtime maps back to a content-addressed
``evidence_id`` for the cache, run-DAG, and audit chain.
Design pinned by G0 ticket (2026-04-29) and the CTI / Clause Lattice
Intelligence reframe:
Models should not generate verbatim quotes.
They should point to evidence IDs extracted by deterministic code.
This kills the synthetic-elision class by construction — the model
never types the quote string, so it can't drop characters from one.
Two-layer id scheme:
- ``pointer_id`` short numeric tag the model sees in the prompt and
writes back in pointer-line answers. ``E`` + the
1-based position of the evidence object in the map
(``E1``, ``E2``, …, ``E37``). One BPE token per id
in standard tokenizers. Stays in the model's
in-distribution citation style.
- ``evidence_id`` content-addressed handle. ``E`` + first 8 hex of
``sha256(chunk_root:offset_start:offset_end)``.
Same chunk + same offsets in two runs → same id,
forever. The cache_key, run-DAG, and audit chain
all use this form so provenance is run-stable.
The verifier (``verify_claim_lattice``) maps each pointer_id the model
writes back to its content-addressed evidence_id before persistence.
The model's literal output is run-dependent (run #1's ``E1`` and run
#2's ``E1`` likely point at different chunks); the content-addressed
layer is what stays stable.
For the first cut every chunk produces exactly one evidence object
covering ``offset_start=0 .. offset_end=len(span)``. Sub-chunk
extraction (paragraph-level, sentence-level) is a future refinement;
the schema already accepts arbitrary offsets so adding finer-grained
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
def _sha256_hex(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class EvidenceObject:
"""One pinned span the model may reference by ``pointer_id``.
All fields are deterministic from inputs; same chunk + offsets yields
the same object byte-for-byte.
- ``pointer_id`` prompt-facing id ("E1", "E2", …) — what the
model sees & writes back. Position-derived;
run-dependent on purpose.
- ``evidence_id`` content-addressed handle ("E" + 8 hex of
sha256(chunk_root:start:end)) — what the
cache, run-DAG, and audit chain use.
Run-stable.
- ``source_root`` document_root the chunk belongs to
- ``document_uri`` human-readable URI (for the renderer)
- ``title`` doc title (for the renderer & prompt)
- ``chunk_idx`` chunk index within the document
- ``chunk_root`` leaf hash of the chunk
- ``offset_start`` byte offset within the chunk (0 for whole-chunk)
- ``offset_end`` end offset (exclusive)
- ``source_role`` role classification (primary_answer_source / ...)
- ``text_hash`` sha256 of the span (for tamper detection)
- ``span`` the literal text (what the renderer interpolates)
"""
pointer_id: str
evidence_id: str
source_root: str
document_uri: str
title: str | None
chunk_idx: int
chunk_root: str
offset_start: int
offset_end: int
source_role: str
text_hash: str
span: str
def to_dict(self) -> dict:
return asdict(self)
def _evidence_id_for(chunk_root: str, offset_start: int, offset_end: int) -> str:
"""Content-addressed handle. Same chunk + offsets = same ID, forever."""
h = _sha256_hex(f"{chunk_root}:{offset_start}:{offset_end}")
return f"E{h[:8]}"
def build_evidence_map(
chunks: list[dict],
) -> list[EvidenceObject]:
"""Build the evidence table from retrieved chunks.
``chunks`` is a list of dicts with keys:
source_root, document_uri, title (optional), chunk_idx,
chunk_root, span, source_role (optional, default 'unclassified')
Returns a list of ``EvidenceObject``s, one per chunk, in input order.
The 1-based position drives ``pointer_id`` (E1, E2, …); the chunk's
content drives ``evidence_id`` (sha256-derived). For the first cut
each chunk = one whole-span evidence object (offset 0 .. len(span)).
Sub-chunk splitting is a future refinement.
"""
out: list[EvidenceObject] = []
for i, c in enumerate(chunks):
span = c["span"]
offset_start = 0
offset_end = len(span)
chunk_root = c["chunk_root"]
evidence_id = _evidence_id_for(chunk_root, offset_start, offset_end)
out.append(
EvidenceObject(
pointer_id=f"E{i + 1}",
evidence_id=evidence_id,
source_root=c["source_root"],
document_uri=c["document_uri"],
title=c.get("title"),
chunk_idx=c["chunk_idx"],
chunk_root=chunk_root,
offset_start=offset_start,
offset_end=offset_end,
source_role=c.get("source_role", "unclassified"),
text_hash=_sha256_hex(span),
span=span,
)
)
return out
def evidence_map_root(evidence: list[EvidenceObject]) -> str:
"""Merkle root over the sorted evidence_id leaves.
Sorting makes the root order-independent — two retrieval runs that
return the same chunks in different orders produce the same root.
Use as the ``evidence_map`` stage hash in the run-DAG.
"""
if not evidence:
return "00" * 32
leaves_hex = sorted(_sha256_hex(e.evidence_id) for e in evidence)
if len(leaves_hex) == 1:
return leaves_hex[0]
leaves = [bytes.fromhex(h) for h in leaves_hex]
return MerkleTree.build(leaves).root.hex()
def render_evidence_block(e: EvidenceObject) -> str:
"""Format one evidence object for the LLM prompt.
Header carries the prompt-facing pointer_id, title (or URI tail),
and source role so the model has everything it needs to cite
without typing the span:
=== E1 (Jurassic_Park_(film) | primary_answer_source) ===
<literal span text>
The runtime maps E1 back to the content-addressed evidence_id
before persistence; the model never sees the hex form.
"""
label = (e.title or e.document_uri.rsplit("/", 1)[-1]) or "untitled"
return f"=== {e.pointer_id} ({label} | {e.source_role}) ===\n{e.span}"
def render_evidence_map(evidence: list[EvidenceObject]) -> str:
"""Concatenated evidence blocks, ready to drop into the prompt."""
return "\n\n".join(render_evidence_block(e) for e in evidence)
def render_evidence_block_for_json(e: EvidenceObject) -> str:
"""Format one evidence object for the JSON-mode LLM prompt.
2026-04-30: header uses the prompt-facing ``pointer_id`` (E1, E2,
…) — same as claim_lattice_pointer mode — instead of the
content-addressed ``evidence_id`` (long hex). The change closes a
real failure mode: small models (Hermes-3-8B observed) were
fabricating plausible-looking content-addressed IDs (e.g.
``E1b6e396`` when the runtime had ``Eed1b6e396``) → UNKNOWN_
EVIDENCE_ID → UNGROUNDED, even when the answer text was correct.
Pointer IDs (``E1``-``E10``) are short, enumerable, and fabrication-
obvious — the model can't invent ``E27`` if only ``E1``-``E10`` were
shown.
The runtime still stores content-addressed ``evidence_id`` in the
cache & run-DAG (resolved on-the-fly in ``verify_claim_lattice_json``);
only the prompt-facing string changes::
=== E1 (Jurassic_Park_(film) | primary_answer_source) ===
<literal span text>
"""
label = (e.title or e.document_uri.rsplit("/", 1)[-1]) or "untitled"
return f"=== {e.pointer_id} ({label} | {e.source_role}) ===\n{e.span}"
def render_evidence_map_for_json(evidence: list[EvidenceObject]) -> str:
"""Concatenated evidence blocks for JSON mode."""
return "\n\n".join(render_evidence_block_for_json(e) for e in evidence)
def evidence_map_by_pointer_id(
evidence: list[EvidenceObject],
) -> dict[str, EvidenceObject]:
"""Index by prompt-facing pointer_id (E1, E2, …)."""
return {e.pointer_id: e for e in evidence}
def evidence_map_by_evidence_id(
evidence: list[EvidenceObject],
) -> dict[str, EvidenceObject]:
"""Index by content-addressed evidence_id (E1f8e4c2a, …)."""
return {e.evidence_id: e for e in evidence}
def render_claim_lattice(
claims: list[dict],
by_id: dict[str, EvidenceObject],
*,
window: int = 200,
) -> str:
"""Convert structured claims to human-readable prose with literal spans.
Each claim becomes one bullet line followed by inlined evidence
excerpts. The model's claim text is rendered verbatim; each cited
pointer_id is followed by a **spotlight excerpt** of the literal
source span — a window of ``window`` chars centered on the first
content token from the claim that appears in the span. Falls back
to the leading window when no claim token matches.
Why spotlight over leading-N truncation: when the cited evidence is
a whole article and the model lazy-anchors every claim at the same
pointer, the leading-N strategy displayed the same article-intro
sentence under every claim. The spotlight finds the part of the
span the claim is *about* — "Brachiosaurus appears in the film" +
a 15 KB article span gets a window centered on the first
"brachiosaurus" mention, not the production-history opener. Same
cited evidence id, but the displayed text actually supports
different claims differently.
``by_id`` is the pointer_id → EvidenceObject index — what
``evidence_map_by_pointer_id`` returns. Determinism: same
(claims, by_id, window) → same prose, byte-for-byte. Unknown ids
render as ``[<id>: ?]`` so violations are visible at a glance.
"""
lines: list[str] = []
for c in claims:
text = (c.get("text") or "").strip()
if not text:
continue
lines.append(f"- {text}")
for eid in c.get("pointer_ids") or c.get("evidence_ids") or []:
obj = by_id.get(eid)
if obj is None:
lines.append(f' [{eid}: ?]')
continue
# Provenance-clear evidence pointer:
# [E5 | <source title> | <chunk_root prefix>: "<excerpt>"]
# Closes the visual confusion observed 2026-05-01 on the
# Orwell run where `[E5: "..."]` displayed alongside a
# source list whose `[5]` slot was a different document
# (E# is a chunk pointer, not a 1-indexed source rank).
# Title comes from the EvidenceObject (URI tail fallback);
# chunk_root prefix is the first 8 hex chars — enough for
# the operator to disambiguate while staying compact.
label = obj.title or obj.document_uri.rsplit("/", 1)[-1] or "untitled"
chunk_prefix = (obj.chunk_root or "")[:8]
excerpt = _spotlight_excerpt(text, obj.span, window=window)
lines.append(f' [{eid} | {label} | {chunk_prefix}: "{excerpt}"]')
return "\n".join(lines)
# Stopwords for spotlight content-token extraction. Smaller set than
# verify._ENGLISH_STOPWORDS — we only need to filter the words the
# model is most likely to share between claim text & every span. A
# handful of high-frequency 4+ char fillers is enough; the spotlight
# falls back to the leading window when no token matches anyway, so
# false negatives degrade gracefully.
_SPOTLIGHT_STOPWORDS = frozenset({
"from", "with", "into", "onto", "upon", "this", "that", "these",
"those", "have", "been", "being", "their", "there", "they",
"them", "your", "what", "when", "where", "while", "which",
"would", "could", "should", "might", "shall", "first", "last",
"also", "very", "much", "many", "more", "most", "less", "some",
"such", "than", "then", "back", "next", "after", "before",
"between", "through", "across", "above", "below", "during",
"without", "within", "until", "since", "about", "around",
"along", "among", "appear", "appears", "appeared", "feature",
"features", "include", "includes", "shown", "shows",
})
_TOKEN_PUNCT_STRIP_R = ".,;:!?\"'()[]{}—-"
def _content_tokens(text: str) -> list[str]:
"""Lowercase content tokens from ``text``, sorted by length desc.
Filters: drop ``< 4`` chars (function words), drop a small stopword
set, dedup. Sorted longest-first so the spotlight matches the most
specific topical token before generic ones — for "Brachiosaurus
appears in the film", that's ``brachiosaurus`` ahead of ``film``.
"""
seen: set[str] = set()
out: list[str] = []
for raw in text.lower().split():
t = raw.strip(_TOKEN_PUNCT_STRIP_R)
if len(t) < 4 or t in _SPOTLIGHT_STOPWORDS or t in seen:
continue
seen.add(t)
out.append(t)
out.sort(key=len, reverse=True)
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 sentence-bounded excerpt of ``span`` centered on the
first claim-token match.
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.
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
span_lower = span.lower()
tokens = _content_tokens(claim_text)
# Find ALL match positions for ALL content tokens, then pick the
# position whose ±half-window cluster contains the most distinct
# tokens. The Homer-Simpson-boss case (2026-05-01) showed why
# first-match-of-longest-token loses: "homer" matches early in
# voice-actor prose, but the actual answer ("Mr. Burns") is
# deeper in the chunk where boss/burns/homer all cluster
# together. Density picks the load-bearing slice; first-match
# picks whatever phrasing the chunk happens to lead with.
half = window // 2
positions: list[tuple[str, int]] = []
for tok in tokens:
i = 0
while True:
j = span_lower.find(tok, i)
if j < 0:
break
positions.append((tok, j))
i = j + len(tok)
if not positions:
# 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}"
# Density rank: for each candidate position, count distinct
# tokens whose match positions fall within ±half. Pick the
# position with max distinct count; tie-break on smallest idx
# (deterministic, reproducible).
best_score = -1
match_idx = positions[0][1]
for _tok, idx in positions:
distinct: set[str] = set()
lo, hi = idx - half, idx + half
for tok2, idx2 in positions:
if lo <= idx2 <= hi:
distinct.add(tok2)
score = len(distinct)
if score > best_score or (score == best_score and idx < match_idx):
best_score = score
match_idx = idx
raw_start = max(0, match_idx - half)
raw_end = min(len(span), match_idx + len(tokens[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