arborist/tests/test_loss_report.py
russell@unturf.com 02c7e41ef8
loss_report: land ticket #000022 (adapter LossReport sidecar)
Typed loss ledger for adapter / canonicalizer drops, transforms, and
normalizations. Sidecar — never enters cache_key, document_root,
run_dag_root, or audit_events. Loss policy lives in its own
loss_report_policy_hash so toggling reporting does NOT invalidate
prior QA cache entries (corrected pre-land per GPT-5.5 review).

- adapter_loss_reports table: PK (chunk_id, stage, canonicalization_version,
  loss_kind); columns include loss_mode {pure_drop|transform|quarantine|
  normalize}, bytes_dropped, occurrence_count, input/output_length_bytes,
  sample_excerpt, sample_hash, adapter_name/version, loss_report_policy_hash
- arborist/sources/loss_report.py: LossEvent, LossCollector with
  add()/record_delta()/set_lengths()/events(), record_losses() batched
  idempotent insert, compute_loss_report_policy_hash() pure function
- wikitext.to_base() emits ref_tag, self_closing_ref_tag, file_link,
  image_link, category_link, strip_code_transform, whitespace_run.
  loss_collector=None default keeps verifier/runner/query path unchanged
- html_page parse_html / _normalize_text emit script_block, style_block,
  html_chrome, whitespace_run; HtmlPageSource gains loss_report_*
  __init__ flags. Document-scope events anchor to first chunk_id at
  ingest via Document.extra['loss_events']
- ingest.ingest_source: per-chunk to_base() with collector for
  wikipedia_* sources; persisted via record_losses inside the same
  transaction as chunk inserts. Default loss_report_enabled=True
- arborist losses CLI subcommand: --document-root / --chunk-id /
  --kind / --stage / --summary / --json. arborist ingest gains
  --no-loss-report / --no-loss-excerpts / --loss-excerpt-bytes
- tests/test_loss_report.py: 15 tests covering bit-identical
  regression, loss-kind taxonomy, byte-conservation property test
  (loss-mode-aware), idempotent persistence, document_root invariant
  under toggle, policy hash purity

1091 tests pass, 0 audit-chain breaks across all 7 shards.
2026-05-07 17:58:50 -04:00

417 lines
14 KiB
Python

"""Adapter LossReport tests (ticket #000022).
Three layers of coverage:
1. ``test_to_base_*`` — wikitext.to_base() with collector emits the
expected LossEvent set; without collector remains bit-identical to
the pre-#000022 implementation.
2. ``test_record_losses_*`` — persistence layer: idempotent INSERT OR
REPLACE on the (chunk_id, stage, canonicalization_version,
loss_kind) primary key, sample_hash populated in PII-safe mode,
policy hash decoupling.
3. ``test_ingest_*`` — end-to-end: ingesting a fake-wikipedia document
populates adapter_loss_reports; toggling loss_report_enabled does
NOT change cache_key shape (governance_policy_hash split per §3.7).
Loss-mode-aware byte-conservation property test (§3.8) lives at the
bottom of the file.
"""
from __future__ import annotations
from typing import Iterator
import pytest
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.source import Source
from arborist.sources.loss_report import (
LOSS_REPORT_VERSION,
LossCollector,
LossEvent,
compute_loss_report_policy_hash,
record_losses,
)
from arborist.store import connect
# ---------------------------------------------------------------------
# wikitext.to_base()
# ---------------------------------------------------------------------
def test_to_base_no_collector_is_bit_identical():
"""Regression guard: passing no collector must NOT change the prose
output. Every prior caller (verifier, runner, query) relies on this.
"""
from arborist.wikitext import to_base
src = (
"Cloud is great.<ref>Smith 2010, p. 5.</ref> "
"He wields a [[Buster Sword|sword]]. "
"[[File:cloud.jpg|thumb|250px|Cloud Strife]]"
"[[Category:Final Fantasy VII characters]]"
)
no_collector = to_base(src)
with_collector = to_base(src, loss_collector=LossCollector())
assert no_collector == with_collector
def test_to_base_emits_ref_tag_event():
from arborist.wikitext import BASE_VERSION, to_base
collector = LossCollector()
out = to_base(
"Hello.<ref>citation</ref> World.",
loss_collector=collector,
)
assert "citation" not in out
events = {e.loss_kind: e for e in collector.events()}
assert "ref_tag" in events
assert events["ref_tag"].loss_mode == "pure_drop"
assert events["ref_tag"].canonicalization_version == BASE_VERSION
assert events["ref_tag"].occurrence_count == 1
assert events["ref_tag"].bytes_dropped > 0
def test_to_base_emits_self_closing_ref_event():
from arborist.wikitext import to_base
collector = LossCollector()
to_base("Hello.<ref name='x'/> World.", loss_collector=collector)
kinds = {e.loss_kind for e in collector.events()}
assert "self_closing_ref_tag" in kinds
def test_to_base_emits_namespace_link_events():
from arborist.wikitext import to_base
collector = LossCollector()
to_base(
"Body. [[File:a.jpg|thumb]] [[Image:b.png|left]] "
"[[Category:Foo]]",
loss_collector=collector,
)
kinds = {e.loss_kind: e for e in collector.events()}
assert "file_link" in kinds
assert "image_link" in kinds
assert "category_link" in kinds
assert all(kinds[k].loss_mode == "pure_drop" for k in (
"file_link", "image_link", "category_link"
))
def test_to_base_lengths_recorded_when_collected():
from arborist.wikitext import to_base
collector = LossCollector()
raw = "Hello.<ref>cite</ref> World."
to_base(raw, loss_collector=collector)
expected_input = len(raw.encode("utf-8"))
events = collector.events()
assert events
assert all(e.input_length_bytes == expected_input for e in events)
assert all(e.output_length_bytes is not None for e in events)
assert all(
e.output_length_bytes <= e.input_length_bytes for e in events
)
def test_to_base_strip_code_emits_transform_event():
"""Surviving wikilinks rewrite to display text; that's a transform,
not a deletion. Verifies the 'transform' loss_mode is emitted with
a positive bytes_dropped delta."""
from arborist.wikitext import to_base
collector = LossCollector()
to_base("See [[Cloud Strife|Cloud]].", loss_collector=collector)
kinds = {e.loss_kind: e for e in collector.events()}
assert "strip_code_transform" in kinds
assert kinds["strip_code_transform"].loss_mode == "transform"
assert kinds["strip_code_transform"].bytes_dropped > 0
def test_to_base_excerpt_disabled_keeps_hash():
"""PII-safe mode: excerpt is None but sample_hash stays populated."""
from arborist.wikitext import to_base
collector = LossCollector(excerpts_enabled=False)
to_base("Hello.<ref>secret citation text</ref>", loss_collector=collector)
events = [e for e in collector.events() if e.loss_kind == "ref_tag"]
assert events
ev = events[0]
assert ev.sample_excerpt is None
assert ev.sample_hash is not None
assert len(ev.sample_hash) == 32 # sha256
def test_to_base_excerpt_truncates_at_byte_limit():
from arborist.wikitext import to_base
long_ref = "x" * 1000
collector = LossCollector(max_excerpt_bytes=50)
to_base(f"Body.<ref>{long_ref}</ref>", loss_collector=collector)
events = [e for e in collector.events() if e.loss_kind == "ref_tag"]
assert events
ev = events[0]
assert ev.sample_excerpt is not None
assert len(ev.sample_excerpt.encode("utf-8")) <= 50
# ---------------------------------------------------------------------
# Persistence layer
# ---------------------------------------------------------------------
def test_record_losses_idempotent(tmp_path):
"""Re-running the same normalizer over the same chunk_id is a no-op.
PK is (chunk_id, stage, canonicalization_version, loss_kind)."""
db_path = tmp_path / "loss.db"
conn = connect(db_path)
try:
# Seed a chunk row so document_root FK doesn't trip us up.
conn.execute("BEGIN")
conn.execute(
"INSERT INTO documents (document_root, document_uri, source_type, "
" title, chunking_version, canonicalization_version, schema_version, "
" ingest_ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("aa" * 32, "test://x", "fake", "x", "tok-512-v1", "norm-v1", "v9.8.0", 0),
)
conn.execute(
"INSERT INTO chunks (chunk_id, document_root, idx, leaf_hash) "
"VALUES (?, ?, ?, ?)",
(1, "aa" * 32, 0, "bb" * 32),
)
conn.execute("COMMIT")
ev = LossEvent(
stage="wikitext_base",
canonicalization_version="wikitext-base-v1",
loss_kind="ref_tag",
loss_mode="pure_drop",
bytes_dropped=42,
occurrence_count=3,
)
policy_hash = compute_loss_report_policy_hash(
enabled=True, excerpts=True, max_excerpt_bytes=200
)
conn.execute("BEGIN")
record_losses(
conn,
chunk_id=1,
document_root="aa" * 32,
events=[ev],
loss_report_policy_hash=policy_hash,
)
record_losses(
conn,
chunk_id=1,
document_root="aa" * 32,
events=[ev],
loss_report_policy_hash=policy_hash,
)
conn.execute("COMMIT")
n = conn.execute(
"SELECT COUNT(*) FROM adapter_loss_reports WHERE chunk_id = ?",
(1,),
).fetchone()[0]
assert n == 1 # INSERT OR REPLACE — exactly one row
finally:
conn.close()
def test_loss_report_policy_hash_is_pure():
"""Same inputs → same hash; one input flips → hash flips."""
h1 = compute_loss_report_policy_hash(
enabled=True, excerpts=True, max_excerpt_bytes=200
)
h2 = compute_loss_report_policy_hash(
enabled=True, excerpts=True, max_excerpt_bytes=200
)
h3 = compute_loss_report_policy_hash(
enabled=False, excerpts=True, max_excerpt_bytes=200
)
h4 = compute_loss_report_policy_hash(
enabled=True, excerpts=False, max_excerpt_bytes=200
)
h5 = compute_loss_report_policy_hash(
enabled=True, excerpts=True, max_excerpt_bytes=100
)
assert h1 == h2
assert h1 != h3
assert h1 != h4
assert h1 != h5
assert len(h1) == 64
def test_loss_report_policy_version_pinned():
"""Tripwire: bumping LOSS_REPORT_VERSION moves all loss policy
hashes — that's the intended semantics for a versioned canonicalizer
of policy. Fail loudly so an accidental rename is caught."""
assert LOSS_REPORT_VERSION == "loss-report-v1"
# ---------------------------------------------------------------------
# Ingest path integration
# ---------------------------------------------------------------------
class _FakeWikiSource(Source):
"""A wikipedia-shaped fake source so the ingest path runs to_base()
with a collector. source_type starts with 'wikipedia_' which is the
routing key in arborist.ingest."""
source_type = "wikipedia_test"
def __init__(self, docs: list[Document]):
self.docs = docs
def iter_documents(self) -> Iterator[Document]:
yield from self.docs
def _wikipedia_doc(uri: str, content: str) -> Document:
return Document(
uri=uri,
content=content,
source_type="wikipedia_test",
title=uri.rsplit("/", 1)[-1],
)
def test_ingest_populates_adapter_loss_reports(tmp_path):
src = _FakeWikiSource([
_wikipedia_doc(
"wiki://Cloud",
"Cloud is great.<ref>Smith 2010</ref> "
"He wields a [[Buster Sword|sword]]. "
"[[File:cloud.jpg|thumb]] "
"[[Category:Final Fantasy VII characters]] "
"additional body prose to make sure the chunk has substance",
),
])
db_path = tmp_path / "wiki.db"
conn = connect(db_path)
try:
ingest_source(conn, src, loss_report_enabled=True)
rows = conn.execute(
"SELECT loss_kind, loss_mode, bytes_dropped, occurrence_count "
"FROM adapter_loss_reports ORDER BY loss_kind"
).fetchall()
kinds = {r["loss_kind"] for r in rows}
# Must capture the four pure_drop classes from the fixture.
assert "ref_tag" in kinds
assert "file_link" in kinds
assert "category_link" in kinds
# Plus a transform delta from strip_code (rewriting wikilinks).
assert "strip_code_transform" in kinds
# All recorded rows have positive bytes_dropped.
for r in rows:
assert r["bytes_dropped"] > 0
finally:
conn.close()
def test_ingest_disable_loss_report_writes_no_rows(tmp_path):
"""With loss_report_enabled=False, adapter_loss_reports stays empty.
Sidecar — toggling MUST NOT alter ingest semantics elsewhere."""
src = _FakeWikiSource([
_wikipedia_doc(
"wiki://Tifa",
"Body.<ref>cite</ref> "
"[[File:tifa.jpg|thumb]] more prose words here for the chunk",
),
])
db_path = tmp_path / "wiki.db"
conn = connect(db_path)
try:
ingest_source(conn, src, loss_report_enabled=False)
n = conn.execute(
"SELECT COUNT(*) FROM adapter_loss_reports"
).fetchone()[0]
assert n == 0
finally:
conn.close()
def test_ingest_loss_toggle_does_not_change_chunks_content(tmp_path):
"""document_root MUST be byte-identical across loss-report toggle —
chunks.content stays raw wikitext, no chunking_version bump."""
src_a = _FakeWikiSource([
_wikipedia_doc(
"wiki://Aerith",
"Body.<ref>cite</ref> [[File:a.jpg]] additional words",
),
])
src_b = _FakeWikiSource([
_wikipedia_doc(
"wiki://Aerith",
"Body.<ref>cite</ref> [[File:a.jpg]] additional words",
),
])
db_a = tmp_path / "a.db"
db_b = tmp_path / "b.db"
conn_a = connect(db_a)
conn_b = connect(db_b)
try:
ingest_source(conn_a, src_a, loss_report_enabled=True)
ingest_source(conn_b, src_b, loss_report_enabled=False)
root_a = conn_a.execute(
"SELECT document_root FROM documents"
).fetchone()["document_root"]
root_b = conn_b.execute(
"SELECT document_root FROM documents"
).fetchone()["document_root"]
assert root_a == root_b
finally:
conn_a.close()
conn_b.close()
# ---------------------------------------------------------------------
# Loss-mode-aware byte conservation property test (ticket §3.8)
# ---------------------------------------------------------------------
def test_byte_conservation_pure_drop_only(tmp_path):
"""For each (chunk_id, stage), sum(bytes_dropped) over loss_mode
pure_drop / quarantine MUST be <= input_length_bytes. transform /
normalize rows are excluded — they record byte deltas, not
deletions (e.g. [[Velociraptor]] -> Velociraptor)."""
src = _FakeWikiSource([
_wikipedia_doc(
"wiki://Property",
(
"Body prose with markup. "
"<ref>cite one</ref> "
"<ref>cite two longer text</ref> "
"[[File:img.jpg|thumb|250px|caption]] "
"[[Image:other.png|left]] "
"[[Category:Things]] "
"See [[Cloud Strife|Cloud]]. "
"More body prose to give the chunk weight."
),
),
])
db_path = tmp_path / "property.db"
conn = connect(db_path)
try:
ingest_source(conn, src, loss_report_enabled=True)
rows = conn.execute(
"SELECT chunk_id, stage, "
" SUM(CASE WHEN loss_mode IN ('pure_drop','quarantine') "
" THEN bytes_dropped ELSE 0 END) AS pure_bytes, "
" MAX(input_length_bytes) AS input_bytes "
"FROM adapter_loss_reports "
"GROUP BY chunk_id, stage"
).fetchall()
assert rows
for r in rows:
assert r["pure_bytes"] <= r["input_bytes"], (
f"chunk={r['chunk_id']} stage={r['stage']} "
f"pure_bytes={r['pure_bytes']} > input_bytes={r['input_bytes']}"
)
finally:
conn.close()