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.
142 lines
5.5 KiB
Python
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
|