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.
417 lines
14 KiB
Python
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()
|