arborist/aborist/sources/wikipedia.py
russell@unturf.com 856b3116d7
phase 0 explore: aborist core + sources + distill + evict
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.
2026-04-27 07:53:18 -04:00

223 lines
6.9 KiB
Python

"""MediaWiki 'cur' table SQL dump source.
Handles 2003-era SQL dumps in bz2 format (e.g. 20030516_cur_tablesql.bz2).
Yields one Document per non-redirect main-namespace article, with
[[wikilinks]] extracted as outbound edges.
Implements a stream parser for MySQL extended INSERT syntax. The cur table
schema for that era starts: cur_id, cur_namespace, cur_title, cur_text, ...
We rely on positional access for the first four columns.
"""
from __future__ import annotations
import bz2
import re
from pathlib import Path
from typing import IO, Iterator
from aborist.document import Document, Edge
from aborist.source import Source
# Match [[Target]], [[Target|display]], [[Target#anchor]] forms.
WIKILINK_RE = re.compile(r"\[\[([^\]\|#\n\r]+)(?:#[^\]\|\n\r]*)?(?:\|[^\]\n\r]*)?\]\]")
NAMESPACE_MAIN = 0
def _decode_mysql_string(s: str) -> str:
"""Decode MySQL-escaped string content (outer quotes already stripped)."""
out: list[str] = []
i = 0
n = len(s)
while i < n:
c = s[i]
if c == "\\" and i + 1 < n:
nxt = s[i + 1]
mapped = {
"n": "\n",
"r": "\r",
"t": "\t",
"0": "\0",
"\\": "\\",
"'": "'",
'"': '"',
"Z": "\x1a",
}.get(nxt, nxt)
out.append(mapped)
i += 2
else:
out.append(c)
i += 1
return "".join(out)
def _split_values_tuples(payload: str) -> list[list[str | None]]:
"""Parse `(v1,v2,...),(...),...` into tuples of decoded strings or None."""
rows: list[list[str | None]] = []
i = 0
n = len(payload)
while i < n:
while i < n and payload[i] in " \t\n\r,":
i += 1
if i >= n or payload[i] != "(":
break
i += 1
values: list[str | None] = []
while True:
while i < n and payload[i] in " \t":
i += 1
if i >= n:
break
if payload[i] == "'":
# Quoted string
i += 1
buf: list[str] = []
while i < n:
c = payload[i]
if c == "\\" and i + 1 < n:
# Preserve escape pair; decode later in one pass.
buf.append(c)
buf.append(payload[i + 1])
i += 2
elif c == "'":
i += 1
break
else:
buf.append(c)
i += 1
values.append(_decode_mysql_string("".join(buf)))
else:
# NULL or number
start = i
while i < n and payload[i] not in ",)":
i += 1
v = payload[start:i].strip()
values.append(None if v.upper() == "NULL" else v)
while i < n and payload[i] in " \t":
i += 1
if i < n and payload[i] == ",":
i += 1
continue
if i < n and payload[i] == ")":
i += 1
break
break
rows.append(values)
return rows
def _iter_insert_statements(file_obj: IO[str]) -> Iterator[str]:
"""Yield complete SQL statements (text up to ; outside a quoted string)."""
buf: list[str] = []
in_string = False
escape = False
while True:
chunk = file_obj.read(1 << 17)
if not chunk:
break
for c in chunk:
buf.append(c)
if escape:
escape = False
continue
if in_string:
if c == "\\":
escape = True
elif c == "'":
in_string = False
continue
if c == "'":
in_string = True
elif c == ";":
yield "".join(buf)
buf = []
if buf:
tail = "".join(buf).strip()
if tail:
yield tail
def _extract_wikilinks(text: str, base_uri: str) -> list[Edge]:
seen: set[str] = set()
edges: list[Edge] = []
for m in WIKILINK_RE.finditer(text):
target = m.group(1).strip()
if not target or target.startswith(":"):
continue
# Skip image / file / category interlinks (they often start "Image:" etc.)
if ":" in target:
continue
uri = base_uri + target.replace(" ", "_")
if uri in seen:
continue
seen.add(uri)
edges.append(Edge(edge_type="wikilink", dst_uri=uri))
return edges
class WikipediaCurDump(Source):
"""Iterates a MediaWiki 'cur' table SQL dump (bz2-compressed)."""
source_type = "wikipedia_cur"
def __init__(
self,
path: str | Path,
namespace: int = NAMESPACE_MAIN,
base_uri: str = "https://en.wikipedia.org/wiki/",
):
self.path = Path(path)
self.namespace = namespace
self.base_uri = base_uri
def iter_documents(self) -> Iterator[Document]:
opener = bz2.open if str(self.path).endswith(".bz2") else open
with opener(self.path, "rt", encoding="utf-8", errors="replace") as f:
for stmt in _iter_insert_statements(f):
head = stmt.lstrip()
if not head.upper().startswith("INSERT INTO"):
continue
up = head.upper()
vidx = up.find("VALUES")
if vidx < 0:
continue
table_clause = head[:vidx].lower()
if "cur" not in table_clause:
continue
payload = head[vidx + len("VALUES"):]
payload = payload.rstrip().rstrip(";").rstrip()
for row in _split_values_tuples(payload):
yield from self._row_to_doc(row)
def _row_to_doc(self, row: list[str | None]) -> Iterator[Document]:
if len(row) < 4:
return
try:
ns = int(row[1]) if row[1] is not None else None
except (ValueError, TypeError):
return
if ns != self.namespace:
return
title = row[2] or ""
text = row[3] or ""
if not title or not text:
return
# is_redirect lives at position 10 in the standard 2003 schema; be defensive.
is_redirect = False
if len(row) > 10 and row[10] is not None:
try:
is_redirect = bool(int(row[10]))
except (ValueError, TypeError):
is_redirect = False
if is_redirect:
return
edges = _extract_wikilinks(text, self.base_uri)
uri = self.base_uri + title.replace(" ", "_")
yield Document(
uri=uri,
content=text,
source_type=self.source_type,
title=title,
edges=edges,
)