arborist/scripts/backfill_concepts.py
russell@unturf.com 9860423dca
make: speed up tests + automate concept backfill (test 38s→11s)
Three dev-loop speedups:

(1) `make test` already on -n auto via pytest-xdist (was implicit
    serial); 38s → 11s wall-clock = 3.4× faster on the 641-test
    suite. Big inner-loop win.

(2) `make test-live` now also uses -n auto (live tests are
    independent against the Hermes endpoint; concurrency=4 doesn't
    overload it on the 17-test fixture set).

(3) `make backfill-concepts` (new) replaces the ad-hoc
    `python -c "from aborist.concepts.extract import …"` invocations
    fox was running by hand for the post-2026-05-02 concept-layer
    backfills. Parallelizes per-shard work via multiprocessing.Pool
    with CONCEPTS_WORKERS=4 (env-tunable).

    Driven by scripts/backfill_concepts.py — runs every registered
    extractor in EXTRACTORS (link_reciprocity, token_idf,
    documents_fts) across every numeric-stem shard. Skips qa.db /
    snapshots.db / crawl_*.db by default; --include-non-numeric
    opts in. Wall-clock 189s for 4 wiki shards × 3 extractors vs.
    ~260s serial estimate; modest 1.4× speedup because SQLite WAL
    + FTS5 vocab queries are I/O-bound on a single SSD (4 workers
    contend), but the unified UX & structured progress output are
    the real wins.

(4) `make bench-qa-quick` (new) — 5-question smoke fixture × all
    3 modes × 1 sample × concurrency 4. ~10s wall-clock. Sits
    between bench-qa-smoke (n=1, ~30s) and full bench-qa
    (~70min). Use as the inner-loop pre-commit signal.

Also: docs/concept-relations-design.md updated to point at the
new make target instead of the inline `python -c` block.

No behavior change in the test suite or LLM pipeline; pure tooling.
2026-05-02 10:11:16 -04:00

131 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ~/.aborist/shards
python scripts/backfill_concepts.py --shards-dir ~/.aborist/shards \
--extractors link_reciprocity,token_idf,documents_fts
python scripts/backfill_concepts.py --shards-dir ~/.aborist/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 aborist.concepts.extract import EXTRACTORS
from aborist.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() / ".aborist" / "shards",
help="Directory containing *.db shards.",
)
parser.add_argument(
"--extractors",
default=",".join(DEFAULT_EXTRACTORS),
help=(
"Comma-separated extractor names (registered in "
"aborist.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())