A content-addressed, Merkle-committed document store implementing the runtime spec from Merkle Providence Reverse RAG (April 2026 whitepaper) and Merkle-AGI v9.8 admissibility ledger. Ports proxy.unturf.com Go merkle conventions to Python: non-commutative HashCombine with 0x03 prefix, explicit IsLeft per sibling, self-duplicate odd elements. What's in: - merkle.py — proof generation/verification, JSON serialization - store.py — v9.8 SQLite schema: 8-dim providence_cache key, falsification_state, append-only audit chain, surface/core kind, hot/warm/cold tier, derivations, edges - ingest.py — Source -> normalize -> chunk -> merkle -> upsert, idempotent on document_root collision - search/ — SearchBackend ABC with explicit AuditMode (STRICT/HYBRID/ VISUAL), FTS5 backend returning VISUAL hits - sources/ — wikipedia.py (streaming bz2/MySQL extended-INSERT parser for 2003-era cur dumps); html_page.py (selectolax + httpx, robots.txt honored automatically) - distill/ — Distiller ABC + first-sentence-v1 stub. Runner generates per-contributing-chunk Merkle proofs binding cores back to source document_root. - evict.py — hot->cold demote (NULLs content, drops FTS row, retains leaf_hash). rehydrate() refetches via source pipeline; matching root restores content, mismatching root marks providence stale and writes rehydrate_drift event. Cores never evict. - cli.py — ingest / search / verify / stats / distill / evict / rehydrate - 31 tests covering merkle round-trip, ingest+audit, chunker version binding, html parse, distillation proof verification, evict+ rehydrate including drift detection. Smoke: 503 Wikipedia 2003-05-16 + 3 fox-owned HTML pages ingested, 478 cores produced (24 surface->core merkle dedups), 7 chunks evicted to cold and round-tripped via rehydrate, 987 audit events chained 0 breaks.
216 lines
7.2 KiB
Python
216 lines
7.2 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.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:
|
|
conn.execute(
|
|
"UPDATE chunks SET content=NULL, tier='cold' "
|
|
"WHERE document_root=? AND idx=?",
|
|
(r["document_root"], r["idx"]),
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM chunks_fts 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 = ?",
|
|
(new_chunk_strs[i], document_root, i),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO chunks_fts (document_root, idx, content) "
|
|
"VALUES (?, ?, ?)",
|
|
(document_root, i, 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}
|