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.
This commit is contained in:
russell@unturf.com 2026-05-02 10:11:16 -04:00
parent c49e1beb1f
commit 9860423dca
No known key found for this signature in database
3 changed files with 169 additions and 16 deletions

View file

@ -0,0 +1,131 @@
"""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())