arborist/aborist/distill/runner.py
russell@unturf.com d25c0fe66f
storage cheats + TF-IDF retrieval fix
Three cheats stack to drop on-disk store from ~21 KB to ~6.7 KB per doc on
the 2003 enwiki cur corpus (-67% measured, apples-to-apples reingest with
identical document/edge counts; Merkle proofs round-trip 30/30):

1. zstd-compressed chunks.content (level 3). Magic-byte detection on read
   means legacy plaintext rows pass through unchanged. Cores stay plaintext
   so qa.query._docs_with_core_keyword_match's SQL LOWER+LIKE keeps working.

2. edges WITHOUT ROWID. The composite PK (src_root, edge_type, dst_root,
   dst_uri, anchor) covers every column, so a default rowid-based table
   near-doubles row data in the PK index. WITHOUT ROWID makes the table
   itself the B-tree. Drops idx_edges_dst_uri too — the only query that
   filters on dst_uri alone is gravity_top_inbound, a one-shot analytic.

3. contentless FTS5 (content='', contentless_delete=1) eliminates the
   28 MB / 1000 docs of duplicated chunk text the old chunks_fts stored.
   chunks gets an explicit chunk_id INTEGER PRIMARY KEY so the FTS5
   rowid maps back to chunks.chunk_id at search time. Snippets are
   built in Python (search/fts5.py:_build_snippet) since SQL snippet()
   returns empty in contentless mode.

TF-IDF retrieval also fixed: the prior LIKE '%intel%' substring match
let "intelligence", "intellectual", "intellivision" drown real hits like
Pentium_4 (whose TF-IDF core has "intel" as an exact keyword). Now uses
word-boundary `LIKE '%, intel, %'` patterns plus a match_count over the
distinct query tokens — multi-token coverage outranks single-token title
boosts. Pentium_4 surfaces #1 for "what is the fastest intel CPU?" with
the canonical 2003 answer (Pentium 4 3.20 GHz) instead of an empty
"insufficient sources" reply.

Schema-level changes affect new DBs only; existing v9.8 DBs keep
working at the old layout. Cross-shard UNION views explicitly list the
intersection of columns so a mixed cluster (legacy + new schema shards
in one --shards-dir) still unions cleanly.
2026-04-27 17:24:51 -04:00

300 lines
10 KiB
Python

"""Distill existing surface (or core) docs into deeper cores.
Per source row, the runner:
1. Loads the source's chunks (must be hot tier — content present).
2. Runs the Distiller (pure function: source -> core artifact).
3. Computes the core's own Merkle tree.
4. Generates a Merkle proof for every contributing source chunk against the
source's document_root and packs them into proof_blob (JSON).
These are read-only / pure steps. Writes are accumulated into batches and
flushed in a single transaction per batch — same fsync-amortization approach
as ingest. With WAL+synchronous=NORMAL this gives ~10x throughput.
Idempotent: re-running with the same distiller skips existing core_root.
"""
from __future__ import annotations
import json
import sqlite3
import time
from dataclasses import dataclass
from aborist import (
CANONICALIZATION_VERSION,
SCHEMA_VERSION,
)
from aborist.compress import unpack_chunk
from aborist.distill.base import Distiller
from aborist.document import Document, canonicalize, get_chunker
from aborist.merkle import MerkleTree, hash_leaf, proof_to_dict
from aborist.store import append_audit, transaction
DEFAULT_BATCH_SIZE = 200
@dataclass
class _Pending:
"""Pre-computed distillation artifacts ready for a batched DB write."""
src_root: str
src_uri: str
src_compression_depth: int
core_uri: str
core_source_type: str
core_title: str | None
core_root: str
core_chunk_strs: list[str]
core_leaves: list[bytes]
core_tree: MerkleTree
proof_blob: str
src_chars: int
core_chars: int
contributing_count: int
def distill_existing(
conn: sqlite3.Connection,
distiller: Distiller,
*,
kind: str = "surface",
source_type: str | None = None,
limit: int | None = None,
chunker_name: str | None = None,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> dict:
"""Distill documents of `kind` already in the store. Returns counts.
`kind='surface'` (default) compresses ingested docs into depth=1 cores.
`kind='core'` runs recursive distillation: a depth=N core compresses
into a depth=N+1 core.
"""
if kind not in ("surface", "core"):
raise ValueError("kind must be 'surface' or 'core'")
chunker = get_chunker(chunker_name)
where_clauses = ["kind = ?"]
params: list = [kind]
if source_type:
where_clauses.append("source_type = ?")
params.append(source_type)
sql = (
"SELECT document_root, document_uri, title, source_type, compression_depth "
"FROM documents WHERE " + " AND ".join(where_clauses) + " ORDER BY ingest_ts ASC"
)
if limit:
sql += f" LIMIT {int(limit)}"
src_rows = conn.execute(sql, params).fetchall()
counters = {
"scanned": len(src_rows),
"distilled": 0,
"skipped_existing": 0,
"skipped_cold": 0,
"skipped_empty": 0,
}
batch: list[_Pending] = []
def flush() -> None:
if not batch:
return
with transaction(conn):
for entry in batch:
outcome = _persist_no_tx(conn, entry, distiller.name, chunker.name, kind)
counters[outcome] += 1
batch.clear()
for s in src_rows:
src_root = s["document_root"]
chunk_rows = conn.execute(
"SELECT idx, leaf_hash, content FROM chunks "
"WHERE document_root = ? ORDER BY idx ASC",
(src_root,),
).fetchall()
if any(r["content"] is None for r in chunk_rows):
counters["skipped_cold"] += 1
continue
# Surface chunks may be zstd-compressed (post April 2026); cores
# are stored plaintext so SQL LOWER+LIKE keeps working over them.
# Unpack here so the distiller sees clean strings either way.
chunk_strs = [unpack_chunk(r["content"]) or "" for r in chunk_rows]
src_doc = Document(
uri=s["document_uri"],
content="",
source_type=s["source_type"],
title=s["title"],
)
result = distiller.distill(src_doc, chunk_strs)
core_text = canonicalize(result.core.content)
core_chunk_strs = chunker.split(core_text)
if not core_chunk_strs:
counters["skipped_empty"] += 1
continue
core_leaves = [hash_leaf(c.encode("utf-8")) for c in core_chunk_strs]
core_tree = MerkleTree.build(core_leaves)
core_root = core_tree.root.hex()
src_leaves = [bytes.fromhex(r["leaf_hash"]) for r in chunk_rows]
src_tree = MerkleTree.build(src_leaves)
contributing: list[dict] = []
for idx in result.contributing_chunk_indices:
if 0 <= idx < len(src_leaves):
p = src_tree.proof(idx)
contributing.append(
{"src_chunk_idx": idx, "proof": proof_to_dict(p)}
)
proof_blob = json.dumps(
{
"process_id": distiller.name,
"core_root": core_root,
"src_root": src_root,
"contributing": contributing,
},
separators=(",", ":"),
sort_keys=True,
)
batch.append(
_Pending(
src_root=src_root,
src_uri=s["document_uri"],
src_compression_depth=s["compression_depth"] or 0,
core_uri=result.core.uri,
core_source_type=result.core.source_type,
core_title=result.core.title,
core_root=core_root,
core_chunk_strs=core_chunk_strs,
core_leaves=core_leaves,
core_tree=core_tree,
proof_blob=proof_blob,
src_chars=sum(len(c) for c in chunk_strs),
core_chars=sum(len(c) for c in core_chunk_strs),
contributing_count=len(contributing),
)
)
if len(batch) >= batch_size:
flush()
flush()
return counters
def _persist_no_tx(
conn: sqlite3.Connection,
p: _Pending,
process_id: str,
chunker_name: str,
src_kind: str,
) -> str:
"""Write one pending distillation. Caller wraps the batch in a transaction.
Returns the counter key to bump: 'distilled' or 'skipped_existing'.
"""
existing = conn.execute(
"SELECT 1 FROM documents WHERE document_root = ?", (p.core_root,)
).fetchone()
if existing:
# Same core_root from another source — record the (core, src) edge
# and the alternative derivation row, but don't re-insert the doc.
now = int(time.time())
conn.execute(
"INSERT OR IGNORE INTO derivations "
"(core_root, src_root, proof_blob, process_id, distilled_at) "
"VALUES (?, ?, ?, ?, ?)",
(p.core_root, p.src_root, p.proof_blob, process_id, now),
)
conn.execute(
"INSERT OR IGNORE INTO edges "
"(src_root, dst_root, dst_uri, edge_type, anchor) "
"VALUES (?, ?, ?, 'derived_from', '')",
(p.core_root, p.src_root, p.src_uri),
)
return "skipped_existing"
now = int(time.time())
new_depth = p.src_compression_depth + 1
conn.execute(
"INSERT INTO documents "
"(document_root, document_uri, source_type, kind, compression_depth, "
" title, chunking_version, canonicalization_version, schema_version, "
" ingest_ts) "
"VALUES (?, ?, ?, 'core', ?, ?, ?, ?, ?, ?)",
(
p.core_root,
p.core_uri,
p.core_source_type,
new_depth,
p.core_title,
chunker_name,
CANONICALIZATION_VERSION,
SCHEMA_VERSION,
now,
),
)
# Reserve a contiguous chunk_id range for this core's chunks; the
# FTS5 rowid must equal chunks.chunk_id so the search-time JOIN works.
next_id = (
conn.execute("SELECT COALESCE(MAX(chunk_id), 0) FROM chunks")
.fetchone()[0]
+ 1
)
chunk_ids = list(range(next_id, next_id + len(p.core_chunk_strs)))
# Cores stay plaintext (small comma-separated keyword strings); SQL
# LOWER+LIKE in qa.query._docs_with_core_keyword_match relies on this.
conn.executemany(
"INSERT INTO chunks (chunk_id, document_root, idx, leaf_hash, content) VALUES (?, ?, ?, ?, ?)",
[
(chunk_ids[i], p.core_root, i, p.core_leaves[i].hex(), p.core_chunk_strs[i])
for i in range(len(p.core_chunk_strs))
],
)
conn.executemany(
"INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)",
[(chunk_ids[i], p.core_chunk_strs[i]) for i in range(len(p.core_chunk_strs))],
)
interior: list[tuple] = []
for layer_idx in range(1, len(p.core_tree.layers)):
for node_idx, h in enumerate(p.core_tree.layers[layer_idx]):
interior.append((p.core_root, layer_idx, node_idx, h.hex()))
if interior:
conn.executemany(
"INSERT INTO merkle_nodes (document_root, layer, idx, hash) VALUES (?, ?, ?, ?)",
interior,
)
conn.execute(
"INSERT INTO derivations "
"(core_root, src_root, proof_blob, process_id, distilled_at) "
"VALUES (?, ?, ?, ?, ?)",
(p.core_root, p.src_root, p.proof_blob, process_id, now),
)
conn.execute(
"INSERT OR IGNORE INTO edges "
"(src_root, dst_root, dst_uri, edge_type, anchor) "
"VALUES (?, ?, ?, 'derived_from', '')",
(p.core_root, p.src_root, p.src_uri),
)
append_audit(
conn,
event_type="derive",
subject_root=p.core_root,
body={
"src_root": p.src_root,
"process_id": process_id,
"src_kind": src_kind,
"compression_depth": new_depth,
"core_chunks": len(p.core_chunk_strs),
"src_chunks_used": p.contributing_count,
"src_chars": p.src_chars,
"core_chars": p.core_chars,
"compression_ratio": (
round(p.core_chars / p.src_chars, 4) if p.src_chars else 0.0
),
},
ts=now,
)
return "distilled"