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.
229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
"""Reversible eviction + rehydrate.
|
|
|
|
Implements the systematic-forgetting mechanic from the design philosophy:
|
|
|
|
- evict_to_cold: surface chunks demote from `hot` to `cold`; content set to
|
|
NULL, FTS5 row deleted. leaf_hash retained — identity preserved.
|
|
- rehydrate: refetch URI through the same source pipeline, re-chunk with the
|
|
original chunking_version, compare leaves and root. Match -> content
|
|
restored, tier hot. Mismatch -> drift event in audit chain, providence
|
|
records flipped to falsification_state='stale'. No content restored.
|
|
|
|
Cores never evict.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import time
|
|
from typing import Callable, Iterable
|
|
|
|
from aborist.compress import pack_chunk
|
|
from aborist.document import canonicalize, get_chunker
|
|
from aborist.merkle import MerkleTree, hash_leaf
|
|
from aborist.store import append_audit, transaction
|
|
|
|
|
|
# Re-fetcher signature: takes a URI, returns parsed/canonicalized text or None.
|
|
Fetcher = Callable[[str], str | None]
|
|
|
|
|
|
def _default_html_fetcher(uri: str) -> str | None:
|
|
"""Reuse HtmlPageSource so rehydrate runs the exact same pipeline as ingest."""
|
|
try:
|
|
from aborist.sources.html_page import HtmlPageSource
|
|
except ImportError:
|
|
return None
|
|
src = HtmlPageSource([uri])
|
|
for doc in src.iter_documents():
|
|
return doc.content
|
|
return None
|
|
|
|
|
|
_FETCHERS: dict[str, Fetcher] = {
|
|
"html": _default_html_fetcher,
|
|
}
|
|
|
|
|
|
def evict_to_cold(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
source_type: str | None = None,
|
|
older_than_days: int | None = None,
|
|
document_roots: Iterable[str] | None = None,
|
|
) -> dict:
|
|
"""Demote matching surface chunks from hot to cold.
|
|
|
|
Cores are never evicted. Content is NULLed; FTS row removed.
|
|
"""
|
|
where = ["d.kind = 'surface'", "c.tier = 'hot'"]
|
|
params: list = []
|
|
if source_type:
|
|
where.append("d.source_type = ?")
|
|
params.append(source_type)
|
|
if older_than_days is not None:
|
|
cutoff = int(time.time()) - older_than_days * 86400
|
|
where.append("d.ingest_ts < ?")
|
|
params.append(cutoff)
|
|
if document_roots is not None:
|
|
roots = list(document_roots)
|
|
if not roots:
|
|
return {"evicted_chunks": 0, "documents_affected": 0}
|
|
placeholders = ",".join("?" for _ in roots)
|
|
where.append(f"c.document_root IN ({placeholders})")
|
|
params.extend(roots)
|
|
|
|
sql = (
|
|
"SELECT c.document_root, c.idx FROM chunks c "
|
|
"JOIN documents d ON d.document_root = c.document_root "
|
|
"WHERE " + " AND ".join(where)
|
|
)
|
|
candidates = conn.execute(sql, params).fetchall()
|
|
if not candidates:
|
|
return {"evicted_chunks": 0, "documents_affected": 0}
|
|
|
|
per_doc: dict[str, int] = {}
|
|
with transaction(conn):
|
|
for r in candidates:
|
|
# Delete from FTS5 first so we can resolve chunk_id via the same
|
|
# row before its content goes away. Contentless FTS5 deletions
|
|
# are addressed by rowid (== chunks.chunk_id).
|
|
chunk_id_row = conn.execute(
|
|
"SELECT chunk_id FROM chunks WHERE document_root=? AND idx=?",
|
|
(r["document_root"], r["idx"]),
|
|
).fetchone()
|
|
if chunk_id_row is not None:
|
|
conn.execute(
|
|
"DELETE FROM chunks_fts WHERE rowid=?",
|
|
(chunk_id_row["chunk_id"],),
|
|
)
|
|
conn.execute(
|
|
"UPDATE chunks SET content=NULL, tier='cold' "
|
|
"WHERE document_root=? AND idx=?",
|
|
(r["document_root"], r["idx"]),
|
|
)
|
|
per_doc[r["document_root"]] = per_doc.get(r["document_root"], 0) + 1
|
|
|
|
for doc_root, n in per_doc.items():
|
|
append_audit(
|
|
conn,
|
|
event_type="evict_cold",
|
|
subject_root=doc_root,
|
|
body={"chunks_evicted": n},
|
|
)
|
|
|
|
return {
|
|
"evicted_chunks": len(candidates),
|
|
"documents_affected": len(per_doc),
|
|
}
|
|
|
|
|
|
def rehydrate(
|
|
conn: sqlite3.Connection,
|
|
document_root: str,
|
|
*,
|
|
fetcher: Fetcher | None = None,
|
|
) -> dict:
|
|
"""Refetch URI, verify leaves, restore content if and only if root matches.
|
|
|
|
Returns a dict with `status` ∈ {
|
|
unknown_document, nothing_to_do, source_not_rehydratable,
|
|
fetch_failed, drift_detected, rehydrated
|
|
}.
|
|
"""
|
|
doc_row = conn.execute(
|
|
"SELECT document_uri, source_type, chunking_version "
|
|
"FROM documents WHERE document_root = ?",
|
|
(document_root,),
|
|
).fetchone()
|
|
if doc_row is None:
|
|
return {"status": "unknown_document"}
|
|
|
|
cold_chunks = conn.execute(
|
|
"SELECT idx, leaf_hash FROM chunks "
|
|
"WHERE document_root = ? AND tier = 'cold' ORDER BY idx",
|
|
(document_root,),
|
|
).fetchall()
|
|
if not cold_chunks:
|
|
return {"status": "nothing_to_do", "cold_chunks": 0}
|
|
|
|
# Pick fetcher by source_type unless caller supplies one.
|
|
use_fetcher = fetcher or _FETCHERS.get(doc_row["source_type"])
|
|
if use_fetcher is None:
|
|
return {
|
|
"status": "source_not_rehydratable",
|
|
"source_type": doc_row["source_type"],
|
|
}
|
|
|
|
try:
|
|
text = use_fetcher(doc_row["document_uri"])
|
|
except Exception as e: # noqa: BLE001 — surface any error in status
|
|
return {"status": "fetch_failed", "error": repr(e)}
|
|
if text is None:
|
|
return {"status": "fetch_failed", "error": "fetcher returned None"}
|
|
|
|
chunker = get_chunker(doc_row["chunking_version"])
|
|
new_text = canonicalize(text)
|
|
new_chunk_strs = chunker.split(new_text)
|
|
new_leaves = [hash_leaf(c.encode("utf-8")) for c in new_chunk_strs]
|
|
new_root = MerkleTree.build(new_leaves).root.hex()
|
|
|
|
if new_root != document_root:
|
|
with transaction(conn):
|
|
append_audit(
|
|
conn,
|
|
event_type="rehydrate_drift",
|
|
subject_root=document_root,
|
|
body={
|
|
"expected_root": document_root,
|
|
"actual_root": new_root,
|
|
"uri": doc_row["document_uri"],
|
|
},
|
|
)
|
|
# v9.8 falsification: any cached providence record from this
|
|
# source is now stale.
|
|
conn.execute(
|
|
"UPDATE providence_cache SET falsification_state = 'stale' "
|
|
"WHERE source_root = ? AND falsification_state = 'live'",
|
|
(document_root,),
|
|
)
|
|
return {
|
|
"status": "drift_detected",
|
|
"expected_root": document_root,
|
|
"actual_root": new_root,
|
|
}
|
|
|
|
# Roots match. Restore content for every cold chunk.
|
|
restored = 0
|
|
with transaction(conn):
|
|
for c in cold_chunks:
|
|
i = c["idx"]
|
|
if i >= len(new_chunk_strs):
|
|
continue
|
|
recomputed = hash_leaf(new_chunk_strs[i].encode("utf-8")).hex()
|
|
if recomputed != c["leaf_hash"]:
|
|
# Defensive: shouldn't happen if roots match, but bail safely.
|
|
continue
|
|
conn.execute(
|
|
"UPDATE chunks SET content = ?, tier = 'hot' "
|
|
"WHERE document_root = ? AND idx = ?",
|
|
(pack_chunk(new_chunk_strs[i]), document_root, i),
|
|
)
|
|
chunk_id_row = conn.execute(
|
|
"SELECT chunk_id FROM chunks WHERE document_root = ? AND idx = ?",
|
|
(document_root, i),
|
|
).fetchone()
|
|
if chunk_id_row is not None:
|
|
conn.execute(
|
|
"INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)",
|
|
(chunk_id_row["chunk_id"], new_chunk_strs[i]),
|
|
)
|
|
restored += 1
|
|
append_audit(
|
|
conn,
|
|
event_type="rehydrate_success",
|
|
subject_root=document_root,
|
|
body={"chunks_restored": restored, "uri": doc_row["document_uri"]},
|
|
)
|
|
|
|
return {"status": "rehydrated", "chunks_restored": restored}
|