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:
parent
10d2db621c
commit
02c7e41ef8
9 changed files with 1460 additions and 49 deletions
158
arborist/cli.py
158
arborist/cli.py
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
329
arborist/sources/loss_report.py
Normal file
329
arborist/sources/loss_report.py
Normal 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)
|
||||
|
|
@ -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, 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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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 | — |
|
||||
|
|
|
|||
|
|
@ -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, ` ` 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
417
tests/test_loss_report.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue