arborist/tests/test_providence_source.py
russell@unturf.com 8de00442f1
qa: self-reference thought chains — STRICT-as-fact substrate
Closes the recursive-grounding gap fox surfaced today: aborist
tends Wikipedia trees but never grafts its own past Q&A records
into the forest. Each query starts from cold corpus retrieval;
prior providence_cache records sit unread until the same question
is re-asked (cache_key match). The "kindergarten thought chains"
framing names the gap — the system has a kindergarten of thoughts
(early STRICT records) that should mature into citable substrate
as they cool.

This commit lands the MVP — STRICT live providence records past
the kindergarten window become substrate via a new Source
subclass. Trust model per fox: "we trust strict statements as
fact unless a verifier falsifies it."

NEW
---
- docs/self-reference-thought-chains-design.md — full architecture
  doc covering the four iteration-time gates, the recursive Merkle
  proof story (Q2 → Q1 → Wikipedia chunk per v9.8's recursive-
  cores insight), the falsification trust model (state=live as the
  fail-closed gate), risks (lazy-anchor compounding, echo chambers,
  storage bloat), and bench-impact estimate.
- aborist/sources/providence.py — ProvidenceSource(Source) with
  four hard gates:
    1. audit_mode == 'STRICT' (HYBRID/UNGROUNDED stay opaque)
    2. falsification_state == 'live' (failed/stale/quarantined
       excluded — verifier-falsification mechanism per fox)
    3. now - created_at >= kindergarten_seconds (default 1h —
       fresh thoughts cool first; kills tight echo loops)
    4. anti-recursion: records whose answer text contains a
       self-reference URI are skipped — first-generation only
- tests/test_providence_source.py — 10 unit tests covering each
  gate plus the URI-scheme source-role classifier
- Makefile target `ingest-self-providence` (KG_SECONDS=3600
  default; iterates each shard and self-promotes its STRICT live
  records — cross-shard sharing happens via the existing
  shards-dir UNION at retrieval time)

WIRE-UP
-------
- aborist/qa/query.py
  - SOURCE_ROLE_BUDGET_WEIGHTS: self_reference_source = 1.0
    (same as background — Wikipedia stays canonical primary;
    self-reference is supplementary anchoring)
  - SOURCE_ROLE_RANK_WEIGHTS: self_reference_source = 0.9
  - _classify_source_role: short-circuits on aborist://providence/
    URI prefix → self_reference_source regardless of title shape
  - DEFAULT_QUERY_POLICY['claim_lattice_allowed_source_roles']
    += 'self_reference_source'
- aborist/qa/runner.py — same allowlist update for the
  per-document `ask` path
- aborist/cli.py — `aborist ingest --source providence` reads the
  providence_cache from the same shard it writes into;
  --kindergarten-seconds flag plumbed through

NOT IN THIS COMMIT
------------------
- Aggregation of multiple Q&A records into synthesized summary
  records (follow-on)
- Self-reference for HYBRID records (only STRICT is substrate
  today; HYBRID could land later as a soft-anchor role with
  lower trust)
- Live virtual sourcing (the design discusses it; MVP uses
  snapshot ingestion so existing FTS / chunker / Merkle apply
  with zero schema change)
- A live bench validating actual lift on self-reference questions
  (requires running ingest-self-providence then bench; deferred
  to follow-on commit on real data)

10 new unit tests pass; full suite at 482 passed / 21 skipped
(live fixtures gated).
2026-05-01 10:16:47 -04:00

228 lines
8.4 KiB
Python

"""Tests for ProvidenceSource — self-reference thought chains.
Covers the four iteration-time gates documented in
docs/self-reference-thought-chains-design.md:
1. audit_mode == 'STRICT' (HYBRID/UNGROUNDED excluded)
2. falsification_state == 'live' (failed/stale/quarantined excluded)
3. now - created_at >= kindergarten_seconds (fresh records cool first)
4. anti-recursion: records whose answer cites a self-reference URI
are excluded (first-generation only)
Plus the classifier-side: documents with `aborist://providence/`
URIs classify as `self_reference_source`.
"""
from __future__ import annotations
import sqlite3
import time
import pytest
from aborist.qa.query import _classify_source_role
from aborist.sources.providence import (
PROVIDENCE_URI_PREFIX,
ProvidenceSource,
)
from aborist.store import connect
def _seed(conn: sqlite3.Connection, **fields) -> str:
"""Insert one minimal providence_cache row. Returns the cache_key."""
defaults = {
"cache_key": fields.get("cache_key", "ck_" + str(int(time.time() * 1e6))),
"source_root": "00" * 32,
"document_uri": "test://doc",
"question_hash": "11" * 32,
"question_text": "stub question",
"answer_text": "stub answer",
"merkle_proof": '{"proofs": []}',
"model_profile_hash": "22" * 32,
"conversation_hash": "33" * 32,
"governance_policy_hash": "44" * 32,
"schema_version": "v9.8.0",
"canonicalization_version": "norm-v1",
"chunking_version": "tok-512-v1",
"falsification_state": "live",
"chain": "private",
"audit_event_hash": "55" * 32,
"created_at": time.time() - 7200, # 2h old by default — past kindergarten
"last_hit_at": None,
"hit_count": 0,
"audit_mode": "STRICT",
"n_quotes": 1,
"n_verified": 1,
"unverified_quotes": None,
"verifier_method": "claim_lattice",
"run_dag_root": "66" * 32,
"run_dag_blob": "{}",
}
defaults.update(fields)
cols = ", ".join(defaults.keys())
placeholders = ", ".join("?" * len(defaults))
conn.execute(
f"INSERT INTO providence_cache ({cols}) VALUES ({placeholders})",
tuple(defaults.values()),
)
conn.commit()
return defaults["cache_key"]
def test_yields_strict_live_record_past_kindergarten(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
ck = _seed(conn, cache_key="strict-cooled", question_text="who is X?",
answer_text="X is Y.")
# Source created with kindergarten=3600. Record is 2h old → eligible.
src = ProvidenceSource(conn, kindergarten_seconds=3600)
docs = list(src.iter_documents())
assert len(docs) == 1
d = docs[0]
assert d.uri == f"{PROVIDENCE_URI_PREFIX}{ck}"
assert d.source_type == "providence"
assert d.title == "who is X?"
assert "Q: who is X?" in d.content
assert "A: X is Y." in d.content
finally:
conn.close()
def test_excludes_hybrid_and_ungrounded(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
_seed(conn, cache_key="strict-1", audit_mode="STRICT")
_seed(conn, cache_key="hybrid-1", audit_mode="HYBRID")
_seed(conn, cache_key="ungrounded-1", audit_mode="UNGROUNDED")
docs = list(ProvidenceSource(conn).iter_documents())
uris = {d.uri for d in docs}
assert f"{PROVIDENCE_URI_PREFIX}strict-1" in uris
assert f"{PROVIDENCE_URI_PREFIX}hybrid-1" not in uris
assert f"{PROVIDENCE_URI_PREFIX}ungrounded-1" not in uris
finally:
conn.close()
def test_excludes_falsified_records(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
_seed(conn, cache_key="live-strict", audit_mode="STRICT", falsification_state="live")
_seed(conn, cache_key="failed-strict", audit_mode="STRICT", falsification_state="failed")
_seed(conn, cache_key="stale-strict", audit_mode="STRICT", falsification_state="stale")
_seed(conn, cache_key="quarantined-strict", audit_mode="STRICT",
falsification_state="quarantined")
docs = list(ProvidenceSource(conn).iter_documents())
uris = {d.uri for d in docs}
assert f"{PROVIDENCE_URI_PREFIX}live-strict" in uris
# Falsified rows excluded — verifier-falsification mechanism
# works exactly because state=live is the gate.
assert f"{PROVIDENCE_URI_PREFIX}failed-strict" not in uris
assert f"{PROVIDENCE_URI_PREFIX}stale-strict" not in uris
assert f"{PROVIDENCE_URI_PREFIX}quarantined-strict" not in uris
finally:
conn.close()
def test_kindergarten_window_excludes_fresh_records(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
now = time.time()
# 30s old — fresh, inside the 1h window.
_seed(conn, cache_key="fresh", created_at=now - 30)
# 2h old — past the window.
_seed(conn, cache_key="cooled", created_at=now - 7200)
src = ProvidenceSource(conn, kindergarten_seconds=3600, now_seconds=now)
docs = list(src.iter_documents())
uris = {d.uri for d in docs}
assert f"{PROVIDENCE_URI_PREFIX}cooled" in uris
assert f"{PROVIDENCE_URI_PREFIX}fresh" not in uris
finally:
conn.close()
def test_kindergarten_zero_admits_all_strict_live(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
now = time.time()
_seed(conn, cache_key="just-now", created_at=now - 1)
_seed(conn, cache_key="cooled", created_at=now - 86400)
src = ProvidenceSource(conn, kindergarten_seconds=0, now_seconds=now)
docs = list(src.iter_documents())
assert len(docs) == 2
finally:
conn.close()
def test_anti_recursion_excludes_self_referencing_records(tmp_path):
"""A STRICT record whose own answer text contains
`aborist://providence/...` is excluded — first-generation only.
Prevents echo-chamber chains where a wrong-but-STRICT record
keeps getting recompiled into deeper claims."""
db = tmp_path / "p.db"
conn = connect(db)
try:
_seed(conn, cache_key="first-gen",
answer_text="X is Y per primary source.")
_seed(conn, cache_key="second-gen",
answer_text=f"X is Y per {PROVIDENCE_URI_PREFIX}other-key cited record.")
docs = list(ProvidenceSource(conn).iter_documents())
uris = {d.uri for d in docs}
assert f"{PROVIDENCE_URI_PREFIX}first-gen" in uris
assert f"{PROVIDENCE_URI_PREFIX}second-gen" not in uris
finally:
conn.close()
def test_skips_records_with_empty_question_or_answer(tmp_path):
db = tmp_path / "p.db"
conn = connect(db)
try:
_seed(conn, cache_key="no-q", question_text="", answer_text="A")
_seed(conn, cache_key="no-a", question_text="Q", answer_text="")
_seed(conn, cache_key="both", question_text="Q", answer_text="A")
docs = list(ProvidenceSource(conn).iter_documents())
uris = {d.uri for d in docs}
assert f"{PROVIDENCE_URI_PREFIX}both" in uris
assert f"{PROVIDENCE_URI_PREFIX}no-q" not in uris
assert f"{PROVIDENCE_URI_PREFIX}no-a" not in uris
finally:
conn.close()
def test_classify_source_role_recognizes_providence_uri():
"""`_classify_source_role` short-circuits on the URI scheme
regardless of title shape. Trust model is URI-based, not
title-heuristic-based."""
qstem = {"foo", "bar"}
role = _classify_source_role(
"Some Title (film)",
qstem,
document_uri=f"{PROVIDENCE_URI_PREFIX}abc123",
)
assert role == "self_reference_source"
def test_classify_source_role_falls_through_for_non_providence_uri():
"""External URIs get the existing title-based classification."""
qstem = {"jurassic", "park", "film"}
role = _classify_source_role(
"Jurassic Park (film)",
qstem,
document_uri="https://en.wikipedia.org/wiki/Jurassic_Park_(film)",
)
# Title has 3 stems matching the 3-stem query → primary.
assert role == "primary_answer_source"
def test_classify_source_role_handles_missing_uri():
"""document_uri is optional; without it the function falls back
to the existing title-based classification (backward compat)."""
qstem = {"jurassic", "park"}
role = _classify_source_role("Jurassic Park (film)", qstem)
# Stems match, primary classification.
assert role in ("primary_answer_source", "background_source")