Skip to content

Commit 65c2bb4

Browse files
committed
merge: v2.5.4 — V-memory embeds once per refresh (perf)
2 parents e2fa1c9 + d366ccd commit 65c2bb4

5 files changed

Lines changed: 145 additions & 13 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "superpowers-v",
1111
"description": "Compound V for Superpowers: triple parallel pre-flight (code archaeology + domain-expert + Context7 library validator), disjoint partitioning, manifest-driven multi-backend dispatch (Claude + Codex + Antigravity + Cursor), git-diff scope enforcement, crash-resumable runs, adaptive tier-based routing, epic mode, V-memory local-first semantic+lexical recall over docs/superpowers (opt-in pure-python embeddings + a deterministic recall→action bridge), and batched parallel dispatch (Opus default, narrow Sonnet exception)",
12-
"version": "2.5.3",
12+
"version": "2.5.4",
1313
"source": "./",
1414
"author": {
1515
"name": "Oleg",

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "superpowers-v",
33
"description": "Compound V for Superpowers: triple parallel pre-flight (code archaeology + domain-expert advisor + library/doc validator via Context7), disjoint file partitioning, manifest-driven multi-backend dispatch (Claude + headless Codex + Antigravity + Cursor workers), git-diff scope enforcement, crash-resumable runs, adaptive tier-based routing, epic mode for multi-feature builds, V-memory local-first semantic+lexical recall over docs/superpowers (opt-in pure-python embeddings + a deterministic recall\u2192action bridge), and batched parallel dispatch (Opus default, Sonnet for narrow junior-task carve-out), plus /v:onboard — a project-onboarding command that builds a citation-verified knowledge base + AGENTS.md/CLAUDE.md bridge behind a human gate. Auto-intercepts brainstorming \u2192 writing-plans \u2192 execution transitions.",
4-
"version": "2.5.3",
4+
"version": "2.5.4",
55
"author": {
66
"name": "Oleg",
77
"email": "copeus@gmail.com"

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to **superpowers-v (Compound V)** are documented here.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses semantic versioning.
66

7+
## [2.5.4] — 2026-07-05
8+
9+
### Performance
10+
- **V-memory DENSE refresh now loads the embedding model *once*, not per file.** The refresh embedded per file — `reindex_file` invoked the isolated-venv embedder subprocess once per file, and each subprocess rebuilt the ONNX `InferenceSession`, so `N` files meant `N` model loads (the reason the first full pass over `docs/superpowers/**` was slow). `cmd_refresh` now uses a new `reindex_batch` that chunks all to-index files, flattens their chunks into **one** embedder call, and slices the vectors back per file — **one model load per refresh**. The FTS5-only (embeddings-off) path is unchanged and it stays **degrade-safe** (a failed batch persists `NULL` embeddings → FTS5-only; the CORE lexical lane is never affected). Selftest injects a **call-counting fake embedder** proving the single call + correct per-file vector slicing + degrade — no network/model needed. **Codex cross-model verification: ACCURATE** on all five claims with `file:line` evidence (single call, offset slicing with no off-by-one, empty-corpus skips the model load, degrade-safe `NULL` fallback, atomic persistence preserved).
11+
712
## [2.5.3] — 2026-07-05
813

914
### Added — `npx autoskills` recommender for `/v:onboard`
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Load the embedding model once per refresh (design, v2.5.4)
2+
3+
**Status:** approved for build (2026-07-05). A performance fix for the V-memory DENSE lane.
4+
5+
## 1. What it is
6+
The V-memory refresh currently embeds **per file**`reindex_file` calls the isolated-venv
7+
embedder subprocess once per file
8+
([`compound-v-memory.py:546-547`](../../scripts/compound-v-memory.py:546)), and each subprocess
9+
re-loads the ONNX model ([`:380`](../../scripts/compound-v-memory.py:380)). For `N` files that is
10+
`N` model loads — the whole reason the first full pass over `docs/superpowers/**` is slow. This
11+
change makes the refresh embed **all chunks of all files in a single embedder call****one model
12+
load per refresh**.
13+
14+
## 2. Why
15+
The embedder runs in an out-of-repo venv **as a subprocess** (dependency isolation + degrade-safe),
16+
so the ONNX `InferenceSession` is (re)built on every invocation. Batching all chunks into one call
17+
turns `N` model loads into **1** — a large, one-time speedup on the first full pass, with no change
18+
to isolation or degrade-safety. (A persistent embedder daemon was considered and rejected as
19+
overkill for a docs corpus — one call holds all chunk texts + 384-dim vectors in memory, trivially.)
20+
21+
## 3. Components
22+
23+
### 3.1 Extract the write path — `_persist_chunks(conn, root, rel, chunks, vecs)`
24+
Pull the atomic transaction out of `reindex_file` (delete old chunks → insert new chunks with
25+
`vecs[i]` blobs → upsert `indexed_files`) into a helper. `reindex_file` keeps its current behavior
26+
(chunk → per-file embed → `_persist_chunks`) and remains the **FTS5-only / no-embedder** path and a
27+
safe fallback. The existing per-chunk guard `if vecs is not None and i < len(vecs)` moves into the
28+
helper — a short/None vec list still degrades to `NULL` embeddings, never crashes.
29+
30+
### 3.2 Batched embed — `reindex_batch(conn, root, rels, embedder)`
31+
1. Chunk every `rel` (`chunk_file`), collecting `[(rel, chunks), …]`.
32+
2. Flatten all chunk texts into ONE list, preserving order.
33+
3. `all_vecs = embedder(flat_texts)`**exactly one** embedder call (⇒ one subprocess ⇒ one model
34+
load). `embedder` is the same `lambda texts: embed_texts(...)`.
35+
4. Slice `all_vecs` back per file by a running offset and call `_persist_chunks` for each.
36+
5. Degrade-safe: `all_vecs is None` (embed failed) ⇒ every file persists with `NULL` embeddings
37+
(FTS5-only) — the same fallback as today, batched.
38+
39+
### 3.3 Wire into `cmd_refresh`
40+
Where the refresh loops `to_index` ([`:631-633`](../../scripts/compound-v-memory.py:631)): when
41+
`embedder is not None`, call `reindex_batch(conn, root, to_index, embedder)`; when it is `None`
42+
(FTS5-only), keep the existing per-file `reindex_file` loop unchanged.
43+
44+
## 4. Invariants (non-negotiable)
45+
1. **Exactly one embedder call per refresh** when embedding (the whole point) — asserted by a
46+
call-counting fake embedder in the selftest.
47+
2. **Correct vector→file mapping** — each file's chunks get *their* vectors (offset slicing),
48+
verified against a fake embedder that returns index-encoding vectors.
49+
3. **Degrade-safe unchanged** — embedder `None` (off) uses the per-file path; a failed batch
50+
(`all_vecs is None`) persists `NULL` embeddings (FTS5-only). Never crash the refresh.
51+
4. **FTS5-only path untouched** — no behavior change when embeddings are off/unbootstrapped.
52+
5. **Incrementality preserved**`to_index` is still the changed/missing-vector set; batching only
53+
changes *how* those files are embedded, not *which*.
54+
55+
## 5. Verification
56+
- **`compound-v-memory.py --selftest`** gains cases with an **injected fake embedder**: a 3-file
57+
batch ⇒ the fake is called **once** with all texts; each file's stored vectors match its own
58+
chunks (offset slicing correct); a fake returning `None` ⇒ chunks stored with `NULL` embeddings.
59+
No real venv/model needed (the perf property is tested structurally).
60+
- **Existing selftests stay green** (FTS5 path unchanged).
61+
- **Codex cross-model verification** of the batching/slicing + degrade logic.
62+
- Full regression + lint + CI version-lockstep green.
63+
64+
## 6. Out of scope
65+
- A persistent embedder daemon / streaming protocol (overkill for a docs corpus).
66+
- Sub-batching a huge corpus into several subprocess calls (would defeat "one model load"; not
67+
needed at this scale).
68+
- Any change to the isolation model, the bootstrap step, or the FTS5 (CORE) lane.
69+
70+
## 7. Version
71+
**v2.5.4** (patch — a DENSE-lane performance fix; `plugin.json` + `marketplace.json` in lockstep).

scripts/compound-v-memory.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -538,19 +538,17 @@ def release_lock(fd):
538538
# --------------------------------------------------------------------------- #
539539
# refresh / indexing
540540
# --------------------------------------------------------------------------- #
541-
def reindex_file(conn, root, rel, embedder):
542-
"""Atomically replace one file's chunks (+ optional embeddings). Triggers sync FTS."""
541+
def _persist_chunks(conn, root, rel, chunks, vecs):
542+
"""Atomically replace one file's chunks (+ optional embeddings) and update indexed_files;
543+
triggers the sync FTS. A None/short `vecs` (or a None element) degrades that chunk to a NULL
544+
embedding — never crashes. Returns the chunk count."""
543545
abspath = os.path.join(root, rel)
544-
chunks = chunk_file(abspath, rel)
545-
vecs = None
546-
if embedder is not None and chunks:
547-
vecs = embedder([c["text"] for c in chunks])
548546
conn.execute("BEGIN IMMEDIATE")
549547
try:
550548
conn.execute("DELETE FROM chunks WHERE path=?", (rel,))
551549
for i, c in enumerate(chunks):
552550
blob = None
553-
if vecs is not None and i < len(vecs):
551+
if vecs is not None and i < len(vecs) and vecs[i] is not None:
554552
blob = json.dumps(vecs[i]).encode("utf-8")
555553
conn.execute(
556554
"INSERT INTO chunks(path,chunk_index,heading,text,doc_type,date,embedding) "
@@ -570,6 +568,36 @@ def reindex_file(conn, root, rel, embedder):
570568
return len(chunks)
571569

572570

571+
def reindex_file(conn, root, rel, embedder):
572+
"""Per-file (re)index: chunk -> optional per-file embed -> persist. Used when embeddings are
573+
OFF (FTS5-only) and as a fallback. When many files are embedded at once, cmd_refresh uses
574+
reindex_batch so the isolated-venv embedder loads the model ONCE, not once per file."""
575+
abspath = os.path.join(root, rel)
576+
chunks = chunk_file(abspath, rel)
577+
vecs = None
578+
if embedder is not None and chunks:
579+
vecs = embedder([c["text"] for c in chunks])
580+
return _persist_chunks(conn, root, rel, chunks, vecs)
581+
582+
583+
def reindex_batch(conn, root, rels, embedder):
584+
"""Re-index many files, embedding ALL their chunks in a SINGLE embedder call — so the isolated
585+
venv embedder loads the ONNX model ONCE per refresh instead of once per file. Chunks are
586+
flattened in order, embedded together, then the vectors are sliced back per file. Degrade-safe:
587+
a None result (embed failed) persists every file with NULL embeddings (FTS5-only). Returns the
588+
number of files processed."""
589+
per_file = [(rel, chunk_file(os.path.join(root, rel), rel)) for rel in rels]
590+
flat = [c["text"] for _, chunks in per_file for c in chunks]
591+
all_vecs = embedder(flat) if (embedder is not None and flat) else None
592+
offset = 0
593+
for rel, chunks in per_file:
594+
n = len(chunks)
595+
vecs = all_vecs[offset:offset + n] if all_vecs is not None else None
596+
offset += n
597+
_persist_chunks(conn, root, rel, chunks, vecs)
598+
return len(per_file)
599+
600+
573601
def _now() -> str:
574602
return time.strftime("%Y-%m-%dT%H:%M:%S")
575603

@@ -627,10 +655,15 @@ def cmd_refresh(args) -> int:
627655
if f in missing and f not in to_index:
628656
to_index.append(f)
629657

630-
n_idx = 0
631-
for f in to_index:
632-
n_idx += 1
633-
reindex_file(conn, root, f, embedder)
658+
# Embed ALL files' chunks in ONE embedder call (one model load per refresh); the per-file
659+
# path stays for the FTS5-only case (embedder is None).
660+
if embedder is not None:
661+
n_idx = reindex_batch(conn, root, to_index, embedder)
662+
else:
663+
n_idx = 0
664+
for f in to_index:
665+
n_idx += 1
666+
reindex_file(conn, root, f, embedder)
634667
for p in removed:
635668
conn.execute("BEGIN IMMEDIATE")
636669
conn.execute("DELETE FROM chunks WHERE path=?", (p,))
@@ -1027,6 +1060,29 @@ class A2:
10271060
cmd_refresh(A2())
10281061
check("incremental stable", conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] == before)
10291062

1063+
# --- v2.5.4: reindex_batch embeds ALL files' chunks in ONE embedder call (model loads once)
1064+
root = find_repo_root(tmp)
1065+
rels = [r[0] for r in conn.execute("SELECT path FROM indexed_files")]
1066+
1067+
def _enc(t): # content-dependent scalar so a mis-sliced vector would not match its chunk
1068+
return float(sum(t.encode("utf-8")) % 1000000)
1069+
_calls = {"n": 0}
1070+
1071+
def _fake_embed(texts):
1072+
_calls["n"] += 1
1073+
return [[_enc(t)] for t in texts]
1074+
reindex_batch(conn, root, rels, _fake_embed)
1075+
check("reindex_batch: ONE embedder call for many files (model loaded once)",
1076+
_calls["n"] == 1 and len(rels) >= 2)
1077+
_rows = list(conn.execute("SELECT text, embedding FROM chunks WHERE embedding IS NOT NULL"))
1078+
check("reindex_batch: each chunk's vector matches its own text (slicing correct)",
1079+
len(_rows) > 0 and all(json.loads(bytes(e).decode()) == [_enc(t)] for t, e in _rows))
1080+
reindex_batch(conn, root, rels, lambda texts: None) # failed embed -> degrade
1081+
_tot = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
1082+
_nul = conn.execute("SELECT COUNT(*) FROM chunks WHERE embedding IS NULL").fetchone()[0]
1083+
check("reindex_batch: failed embed degrades to NULL (FTS5-only), no crash",
1084+
_tot > 0 and _nul == _tot)
1085+
10301086
# lock: a held lock makes a second acquire a no-op (separate open file descriptions)
10311087
fd = acquire_lock(paths["lock"])
10321088
fd2 = acquire_lock(paths["lock"])

0 commit comments

Comments
 (0)