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.
This commit is contained in:
russell@unturf.com 2026-05-07 17:58:50 -04:00
parent 10d2db621c
commit 02c7e41ef8
No known key found for this signature in database
9 changed files with 1460 additions and 49 deletions

View file

@ -164,6 +164,9 @@ def _cmd_ingest(args: argparse.Namespace) -> int:
batch_size=args.batch_size,
resume=args.resume,
progress=progress,
loss_report_enabled=not args.no_loss_report,
loss_report_excerpts=not args.no_loss_excerpts,
loss_report_max_excerpt_bytes=args.loss_excerpt_bytes,
)
finally:
conn.close()
@ -1150,6 +1153,98 @@ def _short(s: str, n: int) -> str:
return s if len(s) <= n else s[: n - 3] + "..."
def _cmd_losses(args: argparse.Namespace) -> int:
"""Sidecar diagnostic: list adapter_loss_reports rows.
Read-only. Filters: --document-root, --chunk-id, --kind, --stage.
With --summary, aggregates per (stage, loss_kind) for a single
document. See ticket #000022 §3.6.
"""
conn = (
connect_query(args.db, shards_dir=args.global_shards_dir)
if args.global_shards_dir
else connect(args.db)
)
try:
where: list[str] = []
params: list = []
if args.document_root:
where.append("document_root = ?")
params.append(args.document_root)
if args.chunk_id is not None:
where.append("chunk_id = ?")
params.append(args.chunk_id)
if args.kind:
where.append("loss_kind = ?")
params.append(args.kind)
if args.stage:
where.append("stage = ?")
params.append(args.stage)
clause = (" WHERE " + " AND ".join(where)) if where else ""
if args.summary:
sql = (
"SELECT stage, loss_kind, loss_mode, "
" SUM(bytes_dropped) AS total_bytes, "
" SUM(occurrence_count) AS total_occ, "
" COUNT(*) AS row_count "
"FROM adapter_loss_reports"
+ clause +
" GROUP BY stage, loss_kind, loss_mode "
"ORDER BY stage, total_bytes DESC"
)
rows = conn.execute(sql, params).fetchall()
if args.json:
out = [dict(r) for r in rows]
print(json.dumps(out, indent=2, ensure_ascii=False))
else:
if not rows:
print("(no loss rows match)")
return 0
print(f"{'stage':<18} {'kind':<28} {'mode':<11} {'occ':>7} {'bytes':>10} {'rows':>6}")
for r in rows:
print(
f"{r['stage']:<18} {r['loss_kind']:<28} "
f"{r['loss_mode']:<11} {r['total_occ']:>7,} "
f"{r['total_bytes']:>10,} {r['row_count']:>6,}"
)
return 0
sql = (
"SELECT chunk_id, document_root, stage, canonicalization_version, "
" loss_kind, loss_mode, bytes_dropped, occurrence_count, "
" input_length_bytes, output_length_bytes, sample_excerpt, "
" sample_hash, adapter_name, adapter_version, "
" loss_report_policy_hash, created_at "
"FROM adapter_loss_reports"
+ clause +
" ORDER BY chunk_id, stage, loss_kind LIMIT ?"
)
params.append(args.limit)
rows = conn.execute(sql, params).fetchall()
if args.json:
out = [dict(r) for r in rows]
print(json.dumps(out, indent=2, ensure_ascii=False))
return 0
if not rows:
print("(no loss rows match)")
return 0
for r in rows:
print(
f"chunk={r['chunk_id']} doc={r['document_root'][:12]}.. "
f"stage={r['stage']} kind={r['loss_kind']} "
f"mode={r['loss_mode']} occ={r['occurrence_count']} "
f"bytes={r['bytes_dropped']}"
)
if r["sample_excerpt"]:
print(f" excerpt: {_short(r['sample_excerpt'], 120)!r}")
elif r["sample_hash"]:
print(f" sample_hash: {r['sample_hash'][:16]}..")
return 0
finally:
conn.close()
def _falsify_cache_key(
cache_key_value: str,
*,
@ -3677,6 +3772,32 @@ def build_parser() -> argparse.ArgumentParser:
"+ ETA in progress output"
),
)
ingest.add_argument(
"--no-loss-report",
dest="no_loss_report",
action="store_true",
help=(
"disable adapter LossReport sidecar (ticket #000022). "
"Default: enabled. Sidecar — toggling does NOT invalidate "
"QA cache_keys"
),
)
ingest.add_argument(
"--no-loss-excerpts",
dest="no_loss_excerpts",
action="store_true",
help=(
"drop sample_excerpt content from LossReport rows "
"(PII-paranoid mode). sample_hash stays populated"
),
)
ingest.add_argument(
"--loss-excerpt-bytes",
dest="loss_excerpt_bytes",
type=int,
default=200,
help="max excerpt byte length (default 200)",
)
ingest.set_defaults(func=_cmd_ingest)
search = sub.add_parser("search", help="keyword search (UNGROUNDED audit mode)")
@ -3995,6 +4116,43 @@ def build_parser() -> argparse.ArgumentParser:
)
inspect_cmd.set_defaults(func=_cmd_inspect)
losses_cmd = sub.add_parser(
"losses",
help=(
"list adapter LossReport sidecar rows (ticket #000022). "
"Read-only; never enters proof path"
),
)
losses_cmd.add_argument(
"--document-root", dest="document_root", default=None,
help="filter to one document_root (hex)",
)
losses_cmd.add_argument(
"--chunk-id", dest="chunk_id", type=int, default=None,
help="filter to one chunk_id",
)
losses_cmd.add_argument(
"--kind", default=None,
help="filter to one loss_kind (ref_tag, file_link, html_chrome, ...)",
)
losses_cmd.add_argument(
"--stage", default=None,
help="filter to one stage (wikitext_base, html_normalize, ingest)",
)
losses_cmd.add_argument(
"--summary", action="store_true",
help="aggregate by (stage, loss_kind, loss_mode)",
)
losses_cmd.add_argument(
"--limit", type=int, default=200,
help="row cap for non-summary mode (default 200)",
)
losses_cmd.add_argument(
"--json", action="store_true",
help="emit raw rows as JSON (default: human render)",
)
losses_cmd.set_defaults(func=_cmd_losses)
prov_cmd = sub.add_parser(
"providence",
help="list or falsify providence_cache records",

View file

@ -63,6 +63,9 @@ def ingest_source(
batch_size: int = DEFAULT_BATCH_SIZE,
resume: bool = False,
progress: Progress | None = None,
loss_report_enabled: bool = True,
loss_report_excerpts: bool = True,
loss_report_max_excerpt_bytes: int = 200,
) -> IngestStats:
"""Ingest every document the source yields. Returns counts.
@ -91,7 +94,14 @@ def ingest_source(
def flush() -> None:
if not batch:
return
inserted, skipped = _flush_batch(conn, batch, chunker.name)
inserted, skipped = _flush_batch(
conn,
batch,
chunker.name,
loss_report_enabled=loss_report_enabled,
loss_report_excerpts=loss_report_excerpts,
loss_report_max_excerpt_bytes=loss_report_max_excerpt_bytes,
)
stats.inserted += inserted
stats.skipped_duplicate += skipped
batch.clear()
@ -142,6 +152,10 @@ def _flush_batch(
conn: sqlite3.Connection,
batch: list[tuple[Document, _DocArtifacts]],
chunker_name: str,
*,
loss_report_enabled: bool = True,
loss_report_excerpts: bool = True,
loss_report_max_excerpt_bytes: int = 200,
) -> tuple[int, int]:
"""Bulk-insert the whole batch. Returns (inserted, skipped_duplicate).
@ -197,6 +211,19 @@ def _flush_batch(
edge_rows: list[tuple] = []
audit_events: list[dict] = []
edges_to_upsert: list[tuple[str, Document]] = []
# (chunk_id, document_root, [LossEvent...]) collected during the
# main loop, persisted at end-of-batch in one executemany. See
# ticket #000022 §3.5. Sidecar — does NOT enter audit_events.
loss_persist: list[tuple[int, str, list]] = []
loss_policy_hash = ""
if loss_report_enabled:
from arborist.sources.loss_report import compute_loss_report_policy_hash
loss_policy_hash = compute_loss_report_policy_hash(
enabled=loss_report_enabled,
excerpts=loss_report_excerpts,
max_excerpt_bytes=loss_report_max_excerpt_bytes,
)
for doc, art in batch:
if art.document_root in existing or art.document_root in inserted_this_batch:
@ -220,15 +247,62 @@ def _flush_batch(
ingest_ts,
)
)
is_wikitext_source = doc.source_type.startswith("wikipedia_")
first_chunk_id_for_doc: int | None = None
for i, c in enumerate(art.chunk_strs):
chunk_id = next_chunk_id
next_chunk_id += 1
if first_chunk_id_for_doc is None:
first_chunk_id_for_doc = chunk_id
chunk_rows.append(
(chunk_id, art.document_root, i, art.leaves[i].hex(), pack_chunk(c))
)
# Contentless FTS5 indexes the plaintext but stores no copy;
# rowid must equal chunks.chunk_id so search-time JOINs line up.
fts_rows.append((chunk_id, c))
# Per-chunk wikitext_base loss reporting. Run to_base()
# eagerly with a collector to capture what the LLM-side
# path would later drop. Output is discarded —
# chunks.content stays raw wikitext per the
# document_root invariant.
if loss_report_enabled and is_wikitext_source:
try:
from arborist.sources.loss_report import LossCollector
from arborist.wikitext import (
ADAPTER_NAME as _WT_ADAPTER,
BASE_VERSION as _WT_VERSION,
to_base as _wt_to_base,
)
collector = LossCollector(
excerpts_enabled=loss_report_excerpts,
max_excerpt_bytes=loss_report_max_excerpt_bytes,
adapter_name=_WT_ADAPTER,
adapter_version=_WT_VERSION,
)
_wt_to_base(c, loss_collector=collector)
events = collector.events()
if events:
loss_persist.append(
(chunk_id, art.document_root, events)
)
except ImportError:
# mwparserfromhell not installed — skip silently.
# LossReport is additive metadata, never a gate.
pass
# Document-scope losses (e.g. HTML normalize) anchor to the
# first chunk_id by convention. See ticket #000022 §3.4.
if (
loss_report_enabled
and first_chunk_id_for_doc is not None
and isinstance(doc.extra, dict)
):
doc_events = doc.extra.get("loss_events")
if doc_events:
loss_persist.append(
(first_chunk_id_for_doc, art.document_root, list(doc_events))
)
for layer_idx in range(1, len(art.tree.layers)):
for node_idx, h in enumerate(art.tree.layers[layer_idx]):
merkle_rows.append(
@ -286,6 +360,22 @@ def _flush_batch(
merkle_rows,
)
# 3.5) Adapter LossReport sidecar (ticket #000022). Persisted
# under the same transaction as chunks so an auditor never sees
# a chunk_id without its loss rows. Sidecar — does NOT enter
# audit_events / cache_key / document_root.
if loss_persist:
from arborist.sources.loss_report import record_losses
for ck, drt, evs in loss_persist:
record_losses(
conn,
chunk_id=ck,
document_root=drt,
events=evs,
loss_report_policy_hash=loss_policy_hash,
ts=ingest_ts,
)
# 4) Edges: collect all wikilinks across the batch and resolve in
# one SELECT. Then one executemany.
_flush_edges(conn, edges_to_upsert)

View file

@ -12,7 +12,7 @@ import re
import urllib.parse
import urllib.robotparser
from pathlib import Path
from typing import Iterable, Iterator
from typing import TYPE_CHECKING, Iterable, Iterator
try:
import httpx
@ -25,28 +25,93 @@ except ImportError as e: # pragma: no cover
from arborist.document import Document, Edge
from arborist.source import Source
if TYPE_CHECKING:
from arborist.sources.loss_report import LossCollector
USER_AGENT = "arborist/0.0.1 (+https://unturf.com)"
NOISE_SELECTORS = ("script", "style", "noscript", "nav", "header", "footer", "aside")
NORMALIZE_VERSION = "html-normalize-v1"
ADAPTER_NAME = "HtmlPageSource"
# Loss-kind taxonomy per ticket #000022 §2.1.1. Free-string in v0; promoted
# to enum after >=3 adapters. ``html_chrome`` is heuristic — selectolax may
# leave residual nav text on pages that don't tag with semantic elements;
# auditors should treat the kind as advisory, not authoritative.
_NOISE_LOSS_KINDS = {
"script": "script_block",
"style": "style_block",
"noscript": "html_chrome",
"nav": "html_chrome",
"header": "html_chrome",
"footer": "html_chrome",
"aside": "html_chrome",
}
def _normalize_text(text: str) -> str:
def _normalize_text(
text: str,
*,
loss_collector: "LossCollector | None" = None,
) -> str:
pre_len = (
len(text.encode("utf-8", errors="surrogatepass"))
if loss_collector is not None
else 0
)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
text = text.strip()
if loss_collector is not None:
post_len = len(text.encode("utf-8", errors="surrogatepass"))
loss_collector.record_delta(
stage="html_normalize",
canonicalization_version=NORMALIZE_VERSION,
loss_kind="whitespace_run",
loss_mode="normalize",
bytes_delta=pre_len - post_len,
)
return text
def parse_html(url: str, html: str, source_type: str = "html") -> Document | None:
"""Pure parse function. Separated so tests can run without network."""
def parse_html(
url: str,
html: str,
source_type: str = "html",
*,
loss_collector: "LossCollector | None" = None,
) -> Document | None:
"""Pure parse function. Separated so tests can run without network.
When ``loss_collector`` is provided, drops from noise-selector
decomposition (``<script>``, ``<style>``, ``<nav>``, etc.) and
whitespace normalization are recorded against the collector. Output
bytes remain bit-identical to the no-collector path. Default
``None`` keeps callers stable.
"""
tree = HTMLParser(html)
for sel in NOISE_SELECTORS:
for node in tree.css(sel):
if loss_collector is not None:
kind = _NOISE_LOSS_KINDS.get(sel, "html_chrome")
dropped_html = node.html or ""
if dropped_html:
loss_collector.add(
stage="html_normalize",
canonicalization_version=NORMALIZE_VERSION,
loss_kind=kind,
loss_mode="pure_drop",
dropped=dropped_html,
)
node.decompose()
body = tree.css_first("body") or tree.root
if body is None:
return None
text = _normalize_text(body.text(separator="\n", strip=True))
text = _normalize_text(
body.text(separator="\n", strip=True),
loss_collector=loss_collector,
)
if not text:
return None
@ -71,12 +136,22 @@ def parse_html(url: str, html: str, source_type: str = "html") -> Document | Non
seen.add(key)
edges.append(Edge(edge_type="hyperlink", dst_uri=dst_uri, anchor=anchor or None))
extra: dict = {}
if loss_collector is not None:
events = loss_collector.events()
if events:
# Document-scope losses anchor to the doc's first chunk at
# ingest time. We stash them in extra; ingest reads and
# persists. See ticket #000022 §3.4.
extra["loss_events"] = events
return Document(
uri=url,
content=text,
source_type=source_type,
title=title,
edges=edges,
extra=extra,
)
@ -91,10 +166,16 @@ class HtmlPageSource(Source):
*,
respect_robots: bool = True,
timeout: float = 30.0,
loss_report_enabled: bool = True,
loss_report_excerpts: bool = True,
loss_report_max_excerpt_bytes: int = 200,
):
self.urls = list(urls)
self.respect_robots = respect_robots
self.timeout = timeout
self.loss_report_enabled = loss_report_enabled
self.loss_report_excerpts = loss_report_excerpts
self.loss_report_max_excerpt_bytes = loss_report_max_excerpt_bytes
self._robots_cache: dict[str, urllib.robotparser.RobotFileParser] = {}
@classmethod
@ -123,7 +204,21 @@ class HtmlPageSource(Source):
ctype = resp.headers.get("content-type", "").lower()
if "html" not in ctype and "xml" not in ctype:
continue
doc = parse_html(str(resp.url), resp.text, self.source_type)
collector = None
if self.loss_report_enabled:
from arborist.sources.loss_report import LossCollector
collector = LossCollector(
excerpts_enabled=self.loss_report_excerpts,
max_excerpt_bytes=self.loss_report_max_excerpt_bytes,
adapter_name=ADAPTER_NAME,
adapter_version=NORMALIZE_VERSION,
)
doc = parse_html(
str(resp.url),
resp.text,
self.source_type,
loss_collector=collector,
)
if doc is not None:
yield doc

View file

@ -0,0 +1,329 @@
"""Adapter LossReport sidecar (ticket #000022).
Typed loss ledger for what bytes the adapter / canonicalizer dropped,
transformed, or normalized on the way from raw bytes to the prose the
model sees. SIDECAR ONLY: never enters ``cache_key``, ``document_root``,
``run_dag_root``, or the ``audit_events`` chain.
Loss policy lives in its own ``loss_report_policy_hash`` (computed
here) separate from the QA ``governance_policy_hash``, so toggling
reporting does NOT invalidate prior cache entries. See ticket §3.7.
Public surface:
LossEvent frozen dataclass, one per (loss_kind, chunk, stage).
LossCollector accumulates per-kind aggregates during a normalizer
pass; merging duplicate kinds inside one chunk.
record_losses batched, idempotent persistence.
compute_loss_report_policy_hash pure hash of the three policy fields.
The module has zero non-stdlib deps so callers in adapters / wikitext can
import it without dragging the wikitext / html extras.
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import time
from dataclasses import dataclass, field
from typing import Iterable, Optional
LOSS_REPORT_VERSION = "loss-report-v1"
DEFAULT_MAX_EXCERPT_BYTES = 200
VALID_LOSS_MODES = frozenset({"pure_drop", "transform", "quarantine", "normalize"})
@dataclass(frozen=True)
class LossEvent:
"""One aggregated loss event for a (chunk, stage, kind) tuple.
``bytes_dropped`` semantics depend on ``loss_mode``:
pure_drop / quarantine bytes removed from the prose stream;
participates in the byte-conservation
property test (§3.8).
transform / normalize byte delta only; excluded from the
property test because content was
rewritten, not deleted.
``sample_excerpt`` is None when the policy disables excerpts;
``sample_hash`` stays populated either way so loss-signature
recurrence remains detectable in PII-safe mode.
"""
stage: str
canonicalization_version: str
loss_kind: str
loss_mode: str
bytes_dropped: int
occurrence_count: int
input_length_bytes: Optional[int] = None
output_length_bytes: Optional[int] = None
sample_excerpt: Optional[str] = None
sample_hash: Optional[bytes] = None
adapter_name: Optional[str] = None
adapter_version: Optional[str] = None
def __post_init__(self) -> None:
if self.loss_mode not in VALID_LOSS_MODES:
raise ValueError(
f"loss_mode {self.loss_mode!r} not in {sorted(VALID_LOSS_MODES)}"
)
def _truncate(s: str, limit: int) -> str:
"""UTF-8-aware byte truncation. Caller passes a byte limit; we return
a string that encodes to <= limit bytes. Cuts on character boundary
so the excerpt remains decodable."""
encoded = s.encode("utf-8", errors="surrogatepass")
if len(encoded) <= limit:
return s
cut = encoded[:limit]
while cut:
try:
return cut.decode("utf-8")
except UnicodeDecodeError:
cut = cut[:-1]
return ""
def _sha256_bytes(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
@dataclass
class LossCollector:
"""Per-pass accumulator. One collector per (chunk, normalizer-pass).
``add()`` is the hot path; idempotent only at the (stage, kind) level
duplicate adds for the same kind merge by summing ``bytes_dropped``
and ``occurrence_count``. The first sample seen wins for excerpt /
hash (cheap; matches the §2.1 "representative excerpt" wording).
``events()`` returns the frozen `LossEvent` list once the pass
completes. Subsequent ``add()`` after ``events()`` is allowed
(collector stays mutable) useful when one collector spans multiple
micro-passes within a single canonicalizer.
"""
excerpts_enabled: bool = True
max_excerpt_bytes: int = DEFAULT_MAX_EXCERPT_BYTES
adapter_name: Optional[str] = None
adapter_version: Optional[str] = None
input_length_bytes: Optional[int] = None
output_length_bytes: Optional[int] = None
_agg: dict[tuple[str, str, str], dict] = field(default_factory=dict)
def add(
self,
*,
stage: str,
canonicalization_version: str,
loss_kind: str,
loss_mode: str,
dropped: str | bytes,
occurrences: int = 1,
) -> None:
if loss_mode not in VALID_LOSS_MODES:
raise ValueError(
f"loss_mode {loss_mode!r} not in {sorted(VALID_LOSS_MODES)}"
)
if isinstance(dropped, str):
dropped_bytes = dropped.encode("utf-8", errors="surrogatepass")
else:
dropped_bytes = dropped
n_bytes = len(dropped_bytes)
key = (stage, canonicalization_version, loss_kind)
slot = self._agg.get(key)
if slot is None:
sample_text: Optional[str]
try:
decoded = dropped_bytes.decode("utf-8", errors="replace")
except Exception:
decoded = ""
if self.excerpts_enabled:
sample_text = _truncate(decoded, self.max_excerpt_bytes)
else:
sample_text = None
slot = {
"stage": stage,
"canonicalization_version": canonicalization_version,
"loss_kind": loss_kind,
"loss_mode": loss_mode,
"bytes_dropped": 0,
"occurrence_count": 0,
"sample_excerpt": sample_text,
"sample_hash": _sha256_bytes(
dropped_bytes[: self.max_excerpt_bytes]
),
}
self._agg[key] = slot
elif slot["loss_mode"] != loss_mode:
raise ValueError(
f"loss_mode mismatch on duplicate add for {key!r}: "
f"existing={slot['loss_mode']!r} new={loss_mode!r}"
)
slot["bytes_dropped"] += n_bytes
slot["occurrence_count"] += occurrences
def record_delta(
self,
*,
stage: str,
canonicalization_version: str,
loss_kind: str,
loss_mode: str,
bytes_delta: int,
occurrences: int = 1,
) -> None:
"""Record a byte-length delta with no associated content sample.
Use for ``transform`` or ``normalize`` events where bytes were
rewritten in place (e.g. ``strip_code``, whitespace collapse) and
there is no single span to capture. ``bytes_delta`` aggregates
across multiple ``record_delta`` calls for the same kind.
"""
if loss_mode not in VALID_LOSS_MODES:
raise ValueError(
f"loss_mode {loss_mode!r} not in {sorted(VALID_LOSS_MODES)}"
)
if bytes_delta <= 0:
return
key = (stage, canonicalization_version, loss_kind)
slot = self._agg.get(key)
if slot is None:
slot = {
"stage": stage,
"canonicalization_version": canonicalization_version,
"loss_kind": loss_kind,
"loss_mode": loss_mode,
"bytes_dropped": 0,
"occurrence_count": 0,
"sample_excerpt": None,
"sample_hash": None,
}
self._agg[key] = slot
elif slot["loss_mode"] != loss_mode:
raise ValueError(
f"loss_mode mismatch on duplicate add for {key!r}: "
f"existing={slot['loss_mode']!r} new={loss_mode!r}"
)
slot["bytes_dropped"] += int(bytes_delta)
slot["occurrence_count"] += occurrences
def set_lengths(self, *, input_bytes: int, output_bytes: int) -> None:
"""Record the chunk-level input/output sizes for the byte-
conservation property test. Called once per pass after
normalization completes."""
self.input_length_bytes = input_bytes
self.output_length_bytes = output_bytes
def events(self) -> list[LossEvent]:
"""Snapshot the accumulator as a list of frozen `LossEvent`s."""
out: list[LossEvent] = []
for slot in self._agg.values():
out.append(
LossEvent(
stage=slot["stage"],
canonicalization_version=slot["canonicalization_version"],
loss_kind=slot["loss_kind"],
loss_mode=slot["loss_mode"],
bytes_dropped=slot["bytes_dropped"],
occurrence_count=slot["occurrence_count"],
input_length_bytes=self.input_length_bytes,
output_length_bytes=self.output_length_bytes,
sample_excerpt=slot["sample_excerpt"],
sample_hash=slot["sample_hash"],
adapter_name=self.adapter_name,
adapter_version=self.adapter_version,
)
)
return out
def compute_loss_report_policy_hash(
*,
enabled: bool,
excerpts: bool,
max_excerpt_bytes: int,
version: str = LOSS_REPORT_VERSION,
) -> str:
"""Hash the three loss-reporting policy fields. Pure function.
Returns 64-char hex sha256 over a canonical JSON. Stored on each
``adapter_loss_reports`` row so an auditor can correlate policy
changes with loss data. NOT folded into ``governance_policy_hash``.
"""
body = {
"version": version,
"enabled": bool(enabled),
"excerpts": bool(excerpts),
"max_excerpt_bytes": int(max_excerpt_bytes),
}
canonical = json.dumps(
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def record_losses(
conn: sqlite3.Connection,
*,
chunk_id: int,
document_root: str,
events: Iterable[LossEvent],
loss_report_policy_hash: str,
ts: int | None = None,
) -> int:
"""Batched, idempotent insert of `LossEvent`s for one chunk.
Idempotency: PK is (chunk_id, stage, canonicalization_version,
loss_kind). Re-running the same normalizer over the same chunk
is a no-op via INSERT OR REPLACE.
Caller wraps in a transaction (or this runs inside the ingest
batch's transaction). Returns the row count touched.
"""
if ts is None:
ts = int(time.time())
rows: list[tuple] = []
for ev in events:
rows.append(
(
chunk_id,
document_root,
ev.stage,
ev.canonicalization_version,
ev.loss_kind,
ev.loss_mode,
ev.bytes_dropped,
ev.occurrence_count,
ev.input_length_bytes,
ev.output_length_bytes,
ev.sample_excerpt,
ev.sample_hash.hex() if ev.sample_hash is not None else None,
ev.adapter_name,
ev.adapter_version,
loss_report_policy_hash,
ts,
)
)
if not rows:
return 0
conn.executemany(
"INSERT OR REPLACE INTO adapter_loss_reports "
"(chunk_id, document_root, stage, canonicalization_version, "
" loss_kind, loss_mode, bytes_dropped, occurrence_count, "
" input_length_bytes, output_length_bytes, sample_excerpt, "
" sample_hash, adapter_name, adapter_version, "
" loss_report_policy_hash, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rows,
)
return len(rows)

View file

@ -403,6 +403,63 @@ CREATE TABLE IF NOT EXISTS concept_token_idf (
derived_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_token_idf_freq ON concept_token_idf(doc_freq);
-- Adapter LossReport sidecar (ticket #000022). Per-chunk record of what
-- bytes the adapter / canonicalizer dropped, transformed, or normalized
-- on the way from raw bytes to the prose the model sees.
--
-- DESIGN: SIDECAR ONLY. This table never enters cache_key, document_root,
-- run_dag_root, or audit_events.event_hash preimage. Its policy lives in
-- ``loss_report_policy_hash`` (column below) separate from the QA
-- ``governance_policy_hash`` so toggling reporting does NOT invalidate
-- prior cache entries. See ticket §3.7.
--
-- ``loss_mode`` semantics (closed enum):
-- 'pure_drop' content removed entirely (e.g. <ref> blocks).
-- sum(bytes_dropped) MUST be <= input_length_bytes.
-- 'transform' content rewritten to different bytes (e.g.
-- [[Velociraptor]] -> Velociraptor). bytes_dropped
-- records a delta, not a deletion. Excluded from
-- the byte-conservation property test.
-- 'quarantine' content preserved elsewhere but withheld from prose.
-- Reserved; no v0 emitter.
-- 'normalize' semantically low-content formatting normalization
-- (whitespace runs, newline collapse, &nbsp; runs).
-- Excluded from byte-conservation accounting.
--
-- ``loss_kind`` is free-string in v0 (per ticket §4 out-of-scope) so
-- adapter authors can mint new kinds. Promotion to enum after >=3
-- adapters are in tree.
--
-- Document-scope losses (e.g. HTML _normalize_text drops that happen
-- before chunking) are anchored to chunk_id of the lowest-idx chunk
-- of the document by convention.
CREATE TABLE IF NOT EXISTS adapter_loss_reports (
chunk_id INTEGER NOT NULL,
document_root TEXT NOT NULL,
stage TEXT NOT NULL,
canonicalization_version TEXT NOT NULL,
loss_kind TEXT NOT NULL,
loss_mode TEXT NOT NULL
CHECK (loss_mode IN ('pure_drop','transform','quarantine','normalize')),
bytes_dropped INTEGER NOT NULL,
occurrence_count INTEGER NOT NULL,
input_length_bytes INTEGER,
output_length_bytes INTEGER,
sample_excerpt TEXT,
sample_hash TEXT,
adapter_name TEXT,
adapter_version TEXT,
loss_report_policy_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (chunk_id, stage, canonicalization_version, loss_kind)
);
CREATE INDEX IF NOT EXISTS idx_adapter_loss_doc
ON adapter_loss_reports(document_root, stage);
CREATE INDEX IF NOT EXISTS idx_adapter_loss_kind
ON adapter_loss_reports(loss_kind, stage);
CREATE INDEX IF NOT EXISTS idx_adapter_loss_chunk
ON adapter_loss_reports(chunk_id, stage);
"""
@ -427,6 +484,7 @@ def connect(db_path: Path | str = DEFAULT_DB_PATH) -> sqlite3.Connection:
_migrate_selfmodel_tables(conn)
_migrate_capital_ledger(conn)
_migrate_memory_root(conn)
_migrate_adapter_loss_reports(conn)
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA cache_size = -65536")
conn.execute("PRAGMA temp_store = MEMORY")
@ -704,6 +762,54 @@ def _migrate_memory_root(conn: sqlite3.Connection) -> None:
)
def _migrate_adapter_loss_reports(conn: sqlite3.Connection) -> None:
"""Forward-migrate to add adapter_loss_reports (ticket #000022).
Sidecar does NOT enter cache_key, document_root, run_dag_root,
audit_events.event_hash preimage. Idempotent table-existence probe,
then CREATE-if-missing, mirroring the established migration pattern.
"""
row = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='adapter_loss_reports'"
).fetchone()
if row is None:
conn.execute(
"CREATE TABLE adapter_loss_reports ("
" chunk_id INTEGER NOT NULL,"
" document_root TEXT NOT NULL,"
" stage TEXT NOT NULL,"
" canonicalization_version TEXT NOT NULL,"
" loss_kind TEXT NOT NULL,"
" loss_mode TEXT NOT NULL"
" CHECK (loss_mode IN ('pure_drop','transform','quarantine','normalize')),"
" bytes_dropped INTEGER NOT NULL,"
" occurrence_count INTEGER NOT NULL,"
" input_length_bytes INTEGER,"
" output_length_bytes INTEGER,"
" sample_excerpt TEXT,"
" sample_hash TEXT,"
" adapter_name TEXT,"
" adapter_version TEXT,"
" loss_report_policy_hash TEXT NOT NULL,"
" created_at INTEGER NOT NULL,"
" PRIMARY KEY (chunk_id, stage, canonicalization_version, loss_kind)"
")"
)
conn.execute(
"CREATE INDEX idx_adapter_loss_doc "
"ON adapter_loss_reports(document_root, stage)"
)
conn.execute(
"CREATE INDEX idx_adapter_loss_kind "
"ON adapter_loss_reports(loss_kind, stage)"
)
conn.execute(
"CREATE INDEX idx_adapter_loss_chunk "
"ON adapter_loss_reports(chunk_id, stage)"
)
def _migrate_mesh_peer_chains(conn: sqlite3.Connection) -> None:
"""Forward-migrate pre-mesh-fork-detection shards.
@ -966,6 +1072,7 @@ _SHARDABLE_TABLES = (
"falsifications",
"concept_relations",
"concept_token_idf",
"adapter_loss_reports",
)
# Per-table column lists for cross-shard UNION views. The `chunks` table

View file

@ -39,6 +39,7 @@ Optional dependency. Install with ``pip install arborist[wikitext]``.
from __future__ import annotations
import re
from typing import TYPE_CHECKING
try:
import mwparserfromhell as _mw
@ -48,8 +49,12 @@ except ImportError as e: # pragma: no cover
"pip install 'arborist[wikitext]'"
) from e
if TYPE_CHECKING:
from arborist.sources.loss_report import LossCollector
BASE_VERSION = "wikitext-base-v1"
ADAPTER_NAME = "WikitextBase"
# Namespaces whose links carry no prose. ``File`` and ``Image`` are the
# same target type (image inclusion); MediaWiki accepts both prefixes.
@ -62,12 +67,22 @@ _TRAILING_WS = re.compile(r" +\n")
_BLANK_LINES = re.compile(r"\n{3,}")
def to_base(raw: str) -> str:
def to_base(
raw: str,
*,
loss_collector: "LossCollector | None" = None,
) -> str:
"""Convert raw wikitext to base prose. Deterministic. Idempotent.
Empty / whitespace-only input returns ``""``. Non-wikitext input
(already-clean prose) round-trips unchanged modulo whitespace
collapsing.
When ``loss_collector`` is provided, every drop / transform /
normalize step records a ``LossEvent`` against the collector. The
output bytes remain bit-identical to the no-collector path
LossReport is additive metadata, never a gate. Default ``None``
keeps the LLM-call path (verifier + query + runner) unchanged.
"""
if not raw or not raw.strip():
return ""
@ -78,12 +93,22 @@ def to_base(raw: str) -> str:
# the tag name (case-insensitive) so that <REF>, <Ref>, etc. all go.
for tag in list(code.filter_tags()):
if str(tag.tag).strip().lower() == "ref":
tag_text = str(tag)
try:
code.remove(tag)
except ValueError:
# Tag was already removed via a parent node. mwparserfromhell
# raises rather than no-op'ing; we swallow it.
pass
continue
if loss_collector is not None:
kind = "self_closing_ref_tag" if getattr(tag, "self_closing", False) else "ref_tag"
loss_collector.add(
stage="wikitext_base",
canonicalization_version=BASE_VERSION,
loss_kind=kind,
loss_mode="pure_drop",
dropped=tag_text,
)
# Drop File: / Image: / Category: wikilinks. Image captions sometimes
# contain useful prose ("thumb|250px|<caption>") but the technical
@ -93,18 +118,69 @@ def to_base(raw: str) -> str:
if ":" in title:
ns = title.split(":", 1)[0].strip().lower()
if ns in _DROP_NAMESPACES:
link_text = str(link)
try:
code.remove(link)
except ValueError:
pass
continue
if loss_collector is not None:
kind = {
"file": "file_link",
"image": "image_link",
"category": "category_link",
}[ns]
loss_collector.add(
stage="wikitext_base",
canonicalization_version=BASE_VERSION,
loss_kind=kind,
loss_mode="pure_drop",
dropped=link_text,
)
pre_strip_len = (
len(str(code).encode("utf-8", errors="surrogatepass"))
if loss_collector is not None
else 0
)
base = code.strip_code(normalize=True, collapse=True)
if loss_collector is not None:
# strip_code rewrites surviving wikilinks/templates/HTML to their
# display text — bytes change, content is preserved as text.
# Recorded as 'transform' (excluded from byte-conservation
# property test).
post_strip_len = len(base.encode("utf-8", errors="surrogatepass"))
loss_collector.record_delta(
stage="wikitext_base",
canonicalization_version=BASE_VERSION,
loss_kind="strip_code_transform",
loss_mode="transform",
bytes_delta=pre_strip_len - post_strip_len,
)
# Whitespace normalization — keeps paragraph breaks, drops runs.
pre_ws_len = (
len(base.encode("utf-8", errors="surrogatepass"))
if loss_collector is not None
else 0
)
base = _WS_RUN.sub(" ", base)
base = _TRAILING_WS.sub("\n", base)
base = _BLANK_LINES.sub("\n\n", base)
return base.strip()
base = base.strip()
if loss_collector is not None:
post_ws_len = len(base.encode("utf-8", errors="surrogatepass"))
loss_collector.record_delta(
stage="wikitext_base",
canonicalization_version=BASE_VERSION,
loss_kind="whitespace_run",
loss_mode="normalize",
bytes_delta=pre_ws_len - post_ws_len,
)
loss_collector.set_lengths(
input_bytes=len(raw.encode("utf-8", errors="surrogatepass")),
output_bytes=len(base.encode("utf-8", errors="surrogatepass")),
)
return base
def extract_wikilinks(raw: str) -> list[tuple[str, str | None]]:

View file

@ -60,7 +60,7 @@ Newest first. Update on every open/close.
| #000025 | 5F battery (Function · Finetuning · Falsification · Formulate · Feedback Loop) | open · awaiting go/no-go | 2026-05-07 | — |
| #000024 | 5T Phase 1b + Dav1DPrometheus vocabulary alignment | open · awaiting go/no-go | 2026-05-07 | — |
| #000023 | 5S Phase 1b: Syllogism · Synthesis · Semiotics | open · awaiting go/no-go | 2026-05-07 | — |
| #000022 | Adapter LossReport (PRD I9 analogue) | open · awaiting go/no-go | 2026-05-07 | — |
| #000022 | Adapter LossReport (PRD I9 analogue) | closed · landed 2026-05-07 | 2026-05-07 | — |
| #000021 | 5S/5T/5R benchmark fixtures + harness | in progress · Phase 1a landed 2026-05-07 | 2026-05-07 | — |
| #000020 | Capital-cost ledger (8-capital queues) | closed · landed 2026-05-07 | 2026-05-07 | — |
| #000019 | Specification methodology for π* and V | closed · landed 2026-05-07 | 2026-05-07 | — |

View file

@ -1,7 +1,10 @@
# Ticket #000022 — Adapter LossReport (PRD I9 analogue)
**Status:** open · awaiting go/no-go
**Status:** closed · landed 2026-05-07
**Opened:** 2026-05-07
**Closed:** 2026-05-07 (amended pre-land per GPT-5.5 review:
schema columns added, policy split out of `governance_policy_hash`,
property test rewritten to be loss-mode-aware)
**Scope:** Design proposal for a typed loss ledger that every source
adapter emits during normalization, so an auditor can answer "what
content did we drop on the way from raw bytes to the prose we
@ -81,23 +84,61 @@ Operator queries it, verifier doesn't.
```sql
CREATE TABLE adapter_loss_reports (
chunk_id INTEGER NOT NULL,
document_root BLOB NOT NULL,
stage TEXT NOT NULL, -- 'ingest' | 'wikitext_base' | 'html_normalize'
canonicalization_version TEXT NOT NULL, -- pins the algorithm
loss_kind TEXT NOT NULL, -- 'ref_tag' | 'file_link' | 'category_link' |
-- 'template_residue' | 'whitespace_run' |
-- 'html_chrome' | 'script_block' | ...
bytes_dropped INTEGER NOT NULL, -- post-canonicalization byte count removed
occurrence_count INTEGER NOT NULL, -- how many drops of this kind in this chunk
sample_excerpt TEXT, -- truncated representative excerpt (PII risk;
-- truncate at 200 bytes max, opt-out via policy)
PRIMARY KEY (chunk_id, stage, loss_kind)
chunk_id INTEGER NOT NULL,
document_root BLOB NOT NULL,
stage TEXT NOT NULL, -- 'ingest' | 'wikitext_base' | 'html_normalize'
canonicalization_version TEXT NOT NULL, -- pins the algorithm (e.g. 'wikitext-base-v1')
loss_kind TEXT NOT NULL, -- 'ref_tag' | 'file_link' | 'category_link' |
-- 'template_residue' | 'whitespace_run' |
-- 'html_chrome' | 'script_block' | ...
loss_mode TEXT NOT NULL, -- 'pure_drop' | 'transform' | 'quarantine' |
-- 'normalize' (see §2.1.1 for semantics)
bytes_dropped INTEGER NOT NULL, -- post-canonicalization byte count removed
-- (only meaningful for pure_drop / quarantine;
-- transform/normalize record byte delta but
-- the property test excludes them)
occurrence_count INTEGER NOT NULL, -- how many events of this kind in this chunk
input_length_bytes INTEGER, -- chunk input byte count at this stage
output_length_bytes INTEGER, -- chunk output byte count at this stage
sample_excerpt TEXT, -- truncated representative excerpt (PII risk;
-- truncate at 200 bytes max, NULL when
-- loss_report_excerpts=False)
sample_hash BLOB, -- sha256(canonical(sample_bytes)) — populated
-- even in PII-safe mode so loss-signature
-- recurrence stays detectable without storing
-- raw bytes
adapter_name TEXT, -- e.g. 'WikipediaSqlDump', 'HtmlPageSource'
adapter_version TEXT, -- adapter package version (independent of
-- canonicalization_version — adapter taxonomy
-- expands faster than canonicalizer versions)
loss_report_policy_hash BLOB NOT NULL, -- pins the loss-reporting policy (see §3.7);
-- separate from governance_policy_hash so
-- toggling reporting does NOT invalidate QA
-- cache_keys
created_at TEXT NOT NULL,
PRIMARY KEY (chunk_id, stage, canonicalization_version, loss_kind)
);
CREATE INDEX idx_adapter_loss_doc ON adapter_loss_reports(document_root, stage);
CREATE INDEX idx_adapter_loss_kind ON adapter_loss_reports(loss_kind, stage);
CREATE INDEX idx_adapter_loss_doc ON adapter_loss_reports(document_root, stage);
CREATE INDEX idx_adapter_loss_kind ON adapter_loss_reports(loss_kind, stage);
CREATE INDEX idx_adapter_loss_chunk ON adapter_loss_reports(chunk_id, stage);
```
#### 2.1.1 `loss_mode` semantics
A loss event is one of four shapes. The shape determines whether
`bytes_dropped` participates in the property test (§3.8):
| `loss_mode` | Meaning | Counts toward bytes-dropped sanity check? |
|--------------|----------------------------------------------------------------|-------------------------------------------|
| `pure_drop` | Content removed entirely. `<ref>...</ref>` blocks, script/style blocks, `[[Category:...]]` links. | Yes — `sum(bytes_dropped) ≤ input_length_bytes`. |
| `transform` | Content rewritten to different bytes. `[[Velociraptor]]``Velociraptor` drops markup but keeps the link text. | No — bytes-dropped here is a delta, not a deletion. |
| `quarantine` | Content preserved elsewhere (e.g., a sidecar) but withheld from prose. Reserved for future adapters; no v0 emitter. | Yes — same shape as `pure_drop` for accounting. |
| `normalize` | Semantically low-content formatting normalization. Whitespace runs, newline collapse, `&nbsp;` runs. | No — emitted for visibility, not for byte-conservation accounting. |
Free-string `loss_kind` keeps the per-adapter naming flexibility from
the original §4 out-of-scope list; `loss_mode` is the closed enum that
keeps the property test sound across adapters.
**Pros:**
- Direct SQL queryability — "show me every chunk where wikitext-base
dropped a `<ref>` tag" is one statement.
@ -110,11 +151,14 @@ CREATE INDEX idx_adapter_loss_kind ON adapter_loss_reports(loss_kind, stage);
**Cons:**
- Storage cost. Wikipedia ingest at 6 GB shard scale: rough
back-of-envelope at 5 loss events/chunk * 64 bytes/row gives
~1-2% storage tax (consistent with `concept_relations`'s 1.6%).
- Anyone with shard write access could update the table without
breaking any merkle invariant (acceptable — it's a sidecar by
design).
back-of-envelope at 5 loss events/chunk * ~120 bytes/row (post
schema additions in §2.1.1) gives ~2-3% storage tax. Within
the same order as `concept_relations`'s 1.6%; not a blocker.
- Free-string `loss_kind` plus heuristic kinds (`html_chrome`)
mean two adapters could emit the same dropped bytes under
different names. Mitigated by §4 promotion-to-enum after three
adapters; loss data is descriptive, not prescriptive, so
inconsistent naming is a triage cost not a correctness cost.
### 2.2 Option B — Merkle-bound `loss_root` per chunk
@ -184,14 +228,23 @@ Same pattern as `arborist.qa.inspect`. Storage tax matches
stage: str
canonicalization_version: str
loss_kind: str
loss_mode: str # 'pure_drop' | 'transform' | 'quarantine' | 'normalize'
bytes_dropped: int
occurrence_count: int
sample_excerpt: str | None # truncated to 200 bytes; None when policy["loss_report_excerpts"] is False
input_length_bytes: int | None
output_length_bytes: int | None
sample_excerpt: str | None # truncated to 200 bytes; None when
# policy["loss_report_excerpts"] is False
sample_hash: bytes | None # sha256 of canonical sample bytes;
# populated even in PII-safe mode
adapter_name: str | None
adapter_version: str | None
```
Plus `record_losses(conn, chunk_id, document_root, events)`
batched INSERT, idempotent on the (chunk_id, stage, loss_kind)
primary key.
Plus `record_losses(conn, chunk_id, document_root, events,
loss_report_policy_hash)` — batched INSERT, idempotent on the
`(chunk_id, stage, canonicalization_version, loss_kind)` primary
key.
3. **`arborist/wikitext.py`** — `to_base()` gains an optional
`loss_collector` parameter. When provided, the existing drop
@ -215,30 +268,70 @@ Same pattern as `arborist.qa.inspect`. Storage tax matches
`arborist inspect` shape) renders the loss table for a given
document or chunk.
7. **Policy fields** (fold into `governance_policy_hash`):
7. **Policy fields — separate `loss_report_policy_hash`, NOT folded
into `governance_policy_hash`:**
- `loss_report_enabled` (bool, default `True`) — master switch.
- `loss_report_excerpts` (bool, default `True`) — when False,
`sample_excerpt` is always NULL (PII-paranoid mode).
`sample_excerpt` is always NULL (PII-paranoid mode);
`sample_hash` stays populated.
- `loss_report_max_excerpt_bytes` (int, default `200`) — hard cap.
Adding two policy fields invalidates every prior cache_key on
next lookup. Acceptable; loss reporting is a one-time bump.
These three fields canonicalize-then-hash into
`loss_report_policy_hash`, stored as a column on
`adapter_loss_reports` (per §2.1). They do **not** participate
in the QA-side `cache_key` because LossReport is sidecar
metadata that does not change normalized prose, retrieval,
prompt, verifier behavior, or answer text. Toggling
`loss_report_enabled` from True→False (or back) MUST NOT
invalidate prior QA cache entries — that would convert a
diagnostic toggle into answer-path churn, exactly the failure
mode §2.2 rejects Option B for.
Promotion path: if a future loss-reporting policy DOES change
normalized prose (e.g., a `loss_quarantine_strict` mode that
alters which bytes survive into the prose stream), it stops
being a LossReport policy and becomes a canonicalization policy.
At that point — and only at that point — it folds into
`governance_policy_hash` and gets its own
`canonicalization_version` bump. v0 reporting keeps prose
bit-identical to today, so no fold is needed.
8. **Tests:**
- Unit: `to_base()` with collector populates the expected
`LossEvent` set on a fixture wikitext blob with
`<ref>`, `[[File:]]`, `[[Category:]]`, and template residues.
`LossEvent` set on a fixture wikitext blob with `<ref>`,
`[[File:]]`, `[[Category:]]`, and template residues. Each
event carries the right `loss_mode`.
- Unit: `to_base()` without collector is bit-identical to the
current implementation (regression guard).
- Unit: `record_losses` is idempotent on duplicate emit.
- Unit: `sample_excerpt` truncates at
`loss_report_max_excerpt_bytes`; `sample_hash` stays
populated when `loss_report_excerpts=False`.
- Unit: toggling `loss_report_enabled` from True→False on the
same shard does NOT change any `cache_key` — confirms the
hash split (§3.7).
- Integration: ingest a small Wikipedia fixture and verify
`adapter_loss_reports` rows match expected loss kinds
per chunk.
- Property: `sum(bytes_dropped) <= len(raw) - len(prose)` on
every chunk (loss accounting can't exceed the actual length
delta).
`adapter_loss_reports` rows match expected loss kinds and
modes per chunk.
- Property (loss-mode-aware byte conservation):
- For each `(chunk_id, stage)`:
`sum(bytes_dropped) WHERE loss_mode IN ('pure_drop','quarantine')
<= input_length_bytes`.
- For each `(chunk_id, stage)`:
`output_length_bytes <= input_length_bytes` for every adapter
whose `canonicalization_version` advertises pure-strip semantics
(wikitext-base-v1 and html-normalize-v1 both qualify).
- `loss_mode='transform'` and `loss_mode='normalize'` rows are
excluded from the sum because their `bytes_dropped` records a
byte delta, not a deletion. The original ticket's
`sum(bytes_dropped) <= len(raw) - len(prose)` test fails on
transforms like `[[Velociraptor]] → Velociraptor` (markup
drops, link text survives) and is replaced by the above.
9. **No bumps to:**
- `cache_key` (8 dimensions unchanged).
- `cache_key` (8 dimensions unchanged; `governance_policy_hash`
unchanged because LossReport policy lives in its own
`loss_report_policy_hash` per §3.7).
- `document_root` (chunks.content stays raw).
- `audit_events` chain (sidecar, not state-changing).
- `run_dag_root` (verifier never sees loss reports).
@ -281,6 +374,52 @@ pin." Closing that gap with a sidecar table preserves arborist's
"one engineer can read it end-to-end" property while giving an
auditor a real answer to "what did the adapter drop?"
### 5.1 Amendment trail
**2026-05-07 (GPT-5.5 review pass).** Original draft contradicted
its own §2.2 "soft signals stay out of the proof path" reasoning by
folding `loss_report_enabled` and `loss_report_excerpts` into
`governance_policy_hash` in §3.7 — a diagnostic toggle that would
have invalidated every prior QA cache entry on landing. Amendments:
1. **§3.7** — split policy into a separate `loss_report_policy_hash`
stored on `adapter_loss_reports`. QA `cache_key` stays at 8
dimensions; `governance_policy_hash` unchanged. Promotion path
spelled out: if a future loss policy ever changes normalized
prose, it stops being a LossReport policy and gets a
`canonicalization_version` bump.
2. **§2.1** — schema gains `loss_mode`, `input_length_bytes`,
`output_length_bytes`, `sample_hash`, `adapter_name`,
`adapter_version`, `loss_report_policy_hash`, `created_at`.
PK extends to `(chunk_id, stage, canonicalization_version,
loss_kind)` so multiple normalizer versions can coexist for
the same chunk during canonicalization migrations.
3. **§2.1.1 (new)** — `loss_mode ∈ {pure_drop, transform,
quarantine, normalize}` semantics defined with a participation
table for the byte-conservation property test.
4. **§3.8** — replaced the naive
`sum(bytes_dropped) <= len(raw) - len(prose)` property test
with a loss-mode-aware version that excludes `transform` and
`normalize` rows (their `bytes_dropped` records a byte delta,
not a deletion — `[[Velociraptor]] → Velociraptor` is the
canonical breakage).
5. **§2.1 cons** — struck the "anyone with shard write access could
update the table without breaking any merkle invariant" line.
That's the design intent of a sidecar, not a downside. Replaced
with the real con: free-string `loss_kind` plus heuristic kinds
(`html_chrome`) admit naming drift across adapters.
Out-of-scope tightening (rejected for v0):
- LossReport as a generalized π* loss ledger — speculative bridge
to #000015. Defer until #000022 has shipped and at least three
adapters use it.
- Loss-aware UI warnings on `arborist query` output — blurs the
soft/hard line we just defended. CLI `arborist losses` covers
the auditor case.
- Per-occurrence `adapter_loss_occurrences` table — defer per
the existing aggregation-first discipline.
---
## 6. Source

417
tests/test_loss_report.py Normal file
View file

@ -0,0 +1,417 @@
"""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()