arborist/aborist/qa/parse_claims.py
russell@unturf.com 2337b7705c
qa: G0 claim-lattice-pointer answer mode — CTI quote-by-pointer
New policy["answer_mode"] = "claim_lattice_pointer" (default "quote").
The runtime builds an evidence map with two-layer ids — pointer-id
(E1, E2, ...) shown to the model, sha256-derived evidence-id used by
the cache & run-DAG — and the model emits pointer-line prose
("Claim. [E12]") referencing them. Renderer interpolates literal
source spans at display time. Synthetic-elision-by-construction-
impossible: the model never types the quote string.

Pieces:

- aborist/qa/evidence.py (new): EvidenceObject + spotlight excerpt
  (claim-token-centered window into the cited span, falls back to
  leading window when no token matches).

- aborist/qa/parse_claims.py (new): pointer-line parser walks
  lines, pulls [E\d+] / [E\d+,E\d+,...] tags, returns ParsedClaim
  with PARSED / NO_EVIDENCE_POINTER status. Strict regex refuses
  fuzzy alternatives so honest UNGROUNDED beats lax acceptance.

- aborist/qa/verify.py: verify_claim_lattice. Hard checks only —
  parser succeeded, evidence_id resolves, source_role allowed, no
  manual quotes (any " char violates), claim text non-empty.
  Returns evidence_id_pairs (content-addressed, run-stable) for the
  run-DAG. Soft signals (entailment, completeness, predicate
  compatibility) stay sidecar.

- aborist/qa/dag.py: 9-stage CTI shape when evidence_map_root is
  supplied — question / retrieval / evidence_map / prompt /
  raw_answer / parsed_claim_lattice / verify / render /
  final_label. Quote mode keeps the original 7-stage shape so
  pre-G0 run_dag_root values stay valid.

- aborist/qa/query.py: per-chunk evidence-map build with role-
  weighted budget AND query-relevance ordering. Within each source,
  chunks are ranked by (distinct_query_tokens_present,
  total_mentions, doc_order_asc) so the chunk most likely to
  textually support the question gets the lowest pointer id. Without
  this re-rank Hermes-3-8B lazy-anchored on doc-order-first chunks
  regardless of relevance.

- aborist/qa/runner.py: same answer_mode branch for the single-doc
  ask() path. No iterative repair in pointer mode (one-shot
  benchmark discipline).

- aborist/store.py: verifier_method CHECK extended with
  'claim_lattice'. New _rebuild_providence_cache_claim_lattice
  migration preserves run_dag_root / run_dag_blob across the
  rebuild — older rebuilds dropped them.

- aborist/cli.py: --answer-mode {quote,claim_lattice_pointer} on
  query and ask.

- Makefile: ANSWER_MODE knob; defaults to claim_lattice_pointer for
  `make query` so the testing harness exercises the new path.
  Library DEFAULT_POLICY / DEFAULT_QUERY_POLICY stay "quote" so
  Python callers and unit tests aren't surprised.

Prompt: one-shot worked example (Apple founders) plus strict
no-quotes / no-JSON / no-markdown / plain-prose-only rules. Without
the worked example Hermes-3-8B drops the bracket protocol on roughly
half of runs; with it, JP dinosaurs benchmark went 0/13 -> 10/11 ->
16/17 verified across the iterations that hardened the pipeline.

Tests: +48 covering parser, two-layer ids, verifier failure modes
(UNKNOWN_EVIDENCE_ID / SOURCE_ROLE_BLOCKED / MANUAL_QUOTE_VIOLATION
/ NO_EVIDENCE_POINTER / SCHEMA_INVALID), spotlight rendering with
buried-term fixture, per-chunk evidence map, query-relevance
ordering, schema migration round-trip, 9-stage DAG shape divergence.
Total 438 passed, all 7 production shards report 0 chain breaks.

Known limitation: chunk boundaries can cut wikitext mid-template, so
mwparserfromhell-backed to_base() leaves orphan </ref> tags and
leading list markers in the visible spotlight excerpts. Verifier and
CTI architecture are unaffected; the leak is cosmetic. Proper fixes
are template-aware chunking (chunker bump invalidates prior records)
or an orphan-marker post-strip in aborist/wikitext.py — both out of
G0 scope.
2026-04-29 21:19:19 -04:00

142 lines
5.5 KiB
Python

"""Pointer-line parser for ``answer_mode="claim_lattice_pointer"``.
The CTI / Clause Lattice Intelligence runtime accepts weak natural-language
pointer clauses from the model and compiles them into structured claim
nodes. Hermes does NOT produce JSON, does NOT produce a formal graph —
Hermes only writes:
Claim text here. [E12]
Another claim. [E13,E14]
The runtime owns the structure. The model owns the prose. This stays
inside the model's prose-generation distribution (citation-style is
heavily represented in training; random hex tokens are not), so one-
shot SCHEMA_INVALID failures from JSON drift disappear.
Two-layer id discipline:
pointer_id E1, E2, E3, ... short numeric tag the model writes
evidence_id E1f8e4c2a, ... content-addressed sha256, run-stable
The model's prompt only ever shows pointer ids. The runtime maps each
pointer id back to its content-addressed evidence_id and stores the
content-addressed form in the cache, run-DAG, and audit chain so
provenance is run-stable.
Parser status taxonomy:
PARSED line had at least one ``[E\\d+...]`` tag and
surviving claim text after the strip
NO_EVIDENCE_POINTER line had non-empty prose but no pointer tag —
model produced a claim without citing
EMPTY line was bullet/whitespace only (skipped)
"""
from __future__ import annotations
import re
from dataclasses import asdict, dataclass
# Match a bracketed pointer-id list. Examples:
# [E12]
# [E12,E13]
# [E12, E13 ,E14]
#
# The outer brackets are required so the parser refuses fuzzy alternatives
# like (E12) or {E12}. The inner content is a comma-separated list of
# E\d+ tokens with optional whitespace. Pointer ids carry one capture
# group across the WHOLE bracket so the strip is clean.
_BRACKET_RE = re.compile(r"\[\s*(E\d+(?:\s*,\s*E\d+)*)\s*\]")
# Pointer id within a captured bracket payload.
_POINTER_RE = re.compile(r"E\d+")
# Bullet markers (subset of verify._BULLET_RE — kept narrower here so
# this module has no cross-import to verify.py): -, *, +, •, "1.", "2)".
_BULLET_RE = re.compile(r"^\s*(?:[-*+•]|\d+[.)])\s+")
# Trailing connector punctuation often left attached when the parser
# strips the trailing bracket. We keep terminal sentence punctuation
# (".", "!", "?", ":") because that's part of the claim text.
_TRAILING_STRIP = " ,;-—\t"
@dataclass(frozen=True)
class ParsedClaim:
"""One line of model output, parsed.
- ``claim_text`` line with bullet marker stripped and pointer
tags removed; the natural-language claim
surviving for verification & rendering.
- ``pointer_ids`` short ids the model wrote, in the order they
appeared. Decimal-suffixed E-prefixed
(``E1``, ``E12``). Verifier maps each back
to the content-addressed evidence_id.
- ``parse_status`` PARSED / NO_EVIDENCE_POINTER. Empty/whitespace
lines are filtered by the parser and never
surface as claims.
- ``raw_line`` the line as the model wrote it, pre-strip.
Useful for the audit trail and for the
render stage hash.
"""
claim_text: str
pointer_ids: list[str]
parse_status: str
raw_line: str
def to_dict(self) -> dict:
return asdict(self)
def parse_pointer_claims(text: str) -> list[ParsedClaim]:
"""Walk ``text`` line by line; emit one ``ParsedClaim`` per non-empty
line.
Skips empty / whitespace-only lines. Lines with at least one
``[E\\d+...]`` bracket get ``parse_status="PARSED"``; lines with prose
but no bracket get ``parse_status="NO_EVIDENCE_POINTER"``. The
verifier owns the policy on what to do with a NO_EVIDENCE_POINTER
claim — typically it counts toward the denominator and downgrades
the verdict to HYBRID/UNGROUNDED.
Pointer-id ordering is preserved as the model wrote them; duplicates
are NOT deduped here so the verifier can flag repeated cites.
"""
out: list[ParsedClaim] = []
for raw_line in text.splitlines():
# Skip lines that are pure whitespace or only a bullet glyph.
stripped_for_emptiness = _BULLET_RE.sub("", raw_line).strip()
if not stripped_for_emptiness:
continue
# Pull every bracket-list match; collect all pointer ids inside.
bracket_matches = list(_BRACKET_RE.finditer(raw_line))
if not bracket_matches:
out.append(ParsedClaim(
claim_text=stripped_for_emptiness,
pointer_ids=[],
parse_status="NO_EVIDENCE_POINTER",
raw_line=raw_line,
))
continue
pointer_ids: list[str] = []
for m in bracket_matches:
pointer_ids.extend(_POINTER_RE.findall(m.group(1)))
# Claim text = line minus every bracket match, then bullet- and
# trailing-punct-stripped. Preserves prose word order even when
# the model interleaves tags mid-sentence.
claim_text = _BRACKET_RE.sub("", raw_line)
claim_text = _BULLET_RE.sub("", claim_text).strip()
claim_text = claim_text.rstrip(_TRAILING_STRIP)
out.append(ParsedClaim(
claim_text=claim_text,
pointer_ids=pointer_ids,
parse_status="PARSED",
raw_line=raw_line,
))
return out