"""Run every concept-layer extractor across every shard, in parallel. Replaces the ad-hoc `python -c "..."` invocations the README + design doc currently document. Wall-clock parallelism comes from a process pool over the shard set — each shard's work is self-contained (separate SQLite file, separate FTS5 index), so per-shard processes don't contend. Wired into `make backfill-concepts`. Usage: python scripts/backfill_concepts.py --shards-dir ~/.arborist/shards python scripts/backfill_concepts.py --shards-dir ~/.arborist/shards \ --extractors link_reciprocity,token_idf,documents_fts python scripts/backfill_concepts.py --shards-dir ~/.arborist/shards \ --workers 8 Skips shards that aren't numeric-stem (`000.db`, `001.db`, …) by default — the qa.db / snapshots.db / crawl_*.db shards rarely need concept-layer backfill, and including them confuses progress reporting. Pass --include-non-numeric to opt in. """ from __future__ import annotations import argparse import multiprocessing as mp import sys import time from pathlib import Path from arborist.concepts.extract import EXTRACTORS from arborist.store import connect, discover_shards # Default extractor order. link_reciprocity must run BEFORE token_idf # (idf indexes the token set that link_reciprocity populated). Order # matters when a shard has no concept_relations rows yet. DEFAULT_EXTRACTORS = ("link_reciprocity", "token_idf", "documents_fts") def _run_one_shard(args: tuple[Path, tuple[str, ...]]) -> dict: shard, extractors = args name = shard.stem t0 = time.time() results: dict[str, dict] = {} conn = connect(shard) try: for kind in extractors: fn = EXTRACTORS.get(kind) if fn is None: results[kind] = {"error": f"unknown extractor {kind!r}"} continue results[kind] = fn(conn, derived_from=f"backfill@{name}") finally: conn.close() return { "shard": str(shard), "name": name, "elapsed_s": round(time.time() - t0, 2), "results": results, } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) parser.add_argument( "--shards-dir", type=Path, default=Path.home() / ".arborist" / "shards", help="Directory containing *.db shards.", ) parser.add_argument( "--extractors", default=",".join(DEFAULT_EXTRACTORS), help=( "Comma-separated extractor names (registered in " "arborist.concepts.extract.EXTRACTORS). Default: " f"{','.join(DEFAULT_EXTRACTORS)}" ), ) parser.add_argument( "--workers", type=int, default=4, help="Parallel shard workers. Default 4.", ) parser.add_argument( "--include-non-numeric", action="store_true", help=( "Include shards whose stem isn't a digit run (qa.db, " "snapshots.db, crawl_*.db). Off by default." ), ) ns = parser.parse_args(argv) extractors = tuple(e.strip() for e in ns.extractors.split(",") if e.strip()) for e in extractors: if e not in EXTRACTORS: parser.error( f"unknown extractor {e!r}; available: " f"{sorted(EXTRACTORS)}" ) shards = [ s for s in discover_shards(ns.shards_dir) if ns.include_non_numeric or s.stem.isdigit() ] if not shards: parser.error(f"no shards found under {ns.shards_dir}") print( f">> {len(shards)} shard(s) × {len(extractors)} extractor(s) " f"× {ns.workers} workers", flush=True, ) grand_t0 = time.time() work = [(s, extractors) for s in shards] with mp.Pool(processes=ns.workers) as pool: for outcome in pool.imap_unordered(_run_one_shard, work): print(f"[{outcome['name']}] {outcome['elapsed_s']}s", flush=True) for kind, result in outcome["results"].items(): print(f" {kind}: {result}", flush=True) print(f"\n>> total wall-clock: {round(time.time() - grand_t0, 1)}s") return 0 if __name__ == "__main__": sys.exit(main())