Skip to content

Commit 4b10906

Browse files
committed
merge: v2.5.5 — dense-search query-vector cache (perf)
2 parents 65c2bb4 + 5d71a23 commit 4b10906

4 files changed

Lines changed: 85 additions & 5 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.4",
12+
"version": "2.5.5",
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.4",
4+
"version": "2.5.5",
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.5] — 2026-07-05
8+
9+
### Performance
10+
- **Dense search: repeated queries skip the model load.** Every dense search paid one isolated-venv subprocess = one ONNX model load per query (seconds). A new `query_cache` SQLite table (`sha256(query) + model → vector`, `IF NOT EXISTS` so no migration, bounded to the 500 most-recent rows) lets a repeated query — the common case for `/v:remember` and the recall→action bridge's templated queries — return in milliseconds. A model change misses by key; **identity drift (embedder revision change) clears the cache** alongside the corpus re-embed, so a stale-revision vector is never served; any cache error falls back to embedding (the cache is an optimization, never a failure mode). Selftest proves hit / miss / model-miss / failed-embed-not-cached with a counting fake embedder. Profiled first: the FTS5 lane (rebuild 0.7 s, search 0.28 s, hooks ≤0.25 s) was left untouched — already fast. **Codex cross-model verification caught the stale-vector hazard** — the `(query, model)` key alone can't see an embedder **revision** change (the same drift the corpus re-embed handles), independently confirming the author's own finding — fixed via `_invalidate_query_cache` on the drift branch, plus the extra coverage Codex asked for (different-query miss, cache bound, drift invalidation): 7 cache checks total, all green.
11+
712
## [2.5.4] — 2026-07-05
813

914
### Performance

scripts/compound-v-memory.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ def file_sha(abspath: str) -> str:
318318
INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
319319
END;
320320
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
321+
CREATE TABLE IF NOT EXISTS query_cache (
322+
qhash TEXT NOT NULL, model TEXT NOT NULL, vec TEXT NOT NULL, created_at TEXT NOT NULL,
323+
PRIMARY KEY (qhash, model)
324+
);
321325
"""
322326

323327

@@ -648,6 +652,7 @@ def cmd_refresh(args) -> int:
648652
if embedder is not None:
649653
if not identity_matches(conn, model):
650654
to_index = list(files) # identity drift ⇒ re-embed the whole corpus
655+
_invalidate_query_cache(conn) # …and drop stale query vectors (same drift)
651656
else:
652657
missing = {r[0] for r in conn.execute(
653658
"SELECT DISTINCT path FROM chunks WHERE embedding IS NULL")}
@@ -704,11 +709,56 @@ def bm25_search(conn, q, limit):
704709
return [dict(id=r[0], path=r[1], heading=r[2], text=r[3], doc_type=r[4], date=r[5]) for r in rows]
705710

706711

707-
def dense_search(conn, paths, model, q, limit):
708-
vecs = embed_texts(paths, model, "query", [q])
712+
QUERY_CACHE_MAX = 500
713+
714+
715+
def _invalidate_query_cache(conn):
716+
"""Drop every cached query vector. Called on embedder IDENTITY DRIFT (embedder_src /
717+
fingerprint changed while the model NAME stayed the same): the corpus is re-embedded by the
718+
new revision, so query vectors from the old revision must never be compared against it —
719+
the (qhash, model) key alone cannot see a revision change (Codex-caught)."""
720+
try:
721+
conn.execute("DELETE FROM query_cache")
722+
conn.commit()
723+
except sqlite3.Error:
724+
pass
725+
726+
727+
def _query_vec(conn, paths, model, q, embed=None):
728+
"""The query embedding, with a small SQLite cache. A repeated query skips the isolated-venv
729+
embedder subprocess entirely — otherwise EVERY dense search pays one ONNX model load
730+
(seconds). Keyed by (sha256(query), model), so a model change naturally misses; bounded to
731+
QUERY_CACHE_MAX most-recent rows. Degrade-safe: any cache problem falls back to embedding."""
732+
qh = hashlib.sha256(q.encode("utf-8")).hexdigest()
733+
try:
734+
row = conn.execute("SELECT vec FROM query_cache WHERE qhash=? AND model=?",
735+
(qh, model)).fetchone()
736+
if row:
737+
return json.loads(row[0])
738+
except (sqlite3.Error, ValueError, TypeError):
739+
pass
740+
embed = embed or (lambda texts: embed_texts(paths, model, "query", texts))
741+
vecs = embed([q])
709742
if not vecs:
743+
return None
744+
if vecs[0] is None:
745+
return None
746+
try:
747+
conn.execute("INSERT OR REPLACE INTO query_cache(qhash,model,vec,created_at) "
748+
"VALUES(?,?,?,?)", (qh, model, json.dumps(vecs[0]), _now()))
749+
conn.execute("DELETE FROM query_cache WHERE rowid NOT IN "
750+
"(SELECT rowid FROM query_cache ORDER BY created_at DESC, rowid DESC LIMIT ?)",
751+
(QUERY_CACHE_MAX,))
752+
conn.commit()
753+
except sqlite3.Error:
754+
pass # cache is an optimization, never a failure mode
755+
return vecs[0]
756+
757+
758+
def dense_search(conn, paths, model, q, limit):
759+
qv = _query_vec(conn, paths, model, q)
760+
if not qv:
710761
return []
711-
qv = vecs[0]
712762
rows = conn.execute(
713763
"SELECT id,path,heading,text,doc_type,date,embedding FROM chunks WHERE embedding IS NOT NULL"
714764
).fetchall()
@@ -1083,6 +1133,31 @@ def _fake_embed(texts):
10831133
check("reindex_batch: failed embed degrades to NULL (FTS5-only), no crash",
10841134
_tot > 0 and _nul == _tot)
10851135

1136+
# --- v2.5.5: query-vector cache — a repeated query skips the embedder (model load) ---
1137+
_qc = {"n": 0}
1138+
1139+
def _fake_q(texts):
1140+
_qc["n"] += 1
1141+
return [[1.0, 2.0]]
1142+
v1 = _query_vec(conn, None, "m1", "scope gate", embed=_fake_q)
1143+
v2 = _query_vec(conn, None, "m1", "scope gate", embed=_fake_q) # cache HIT
1144+
check("query cache: repeat query skips the embedder (1 call, same vec)",
1145+
_qc["n"] == 1 and v1 == v2 == [1.0, 2.0])
1146+
_query_vec(conn, None, "m2", "scope gate", embed=_fake_q) # model change -> MISS
1147+
check("query cache: model change misses (re-embeds)", _qc["n"] == 2)
1148+
check("query cache: failed embed returns None, nothing cached",
1149+
_query_vec(conn, None, "m3", "x", embed=lambda t: None) is None
1150+
and conn.execute("SELECT COUNT(*) FROM query_cache WHERE model='m3'").fetchone()[0] == 0)
1151+
_query_vec(conn, None, "m1", "different query", embed=_fake_q) # new query -> MISS
1152+
check("query cache: different query misses (re-embeds)", _qc["n"] == 3)
1153+
for i in range(QUERY_CACHE_MAX + 30): # bound holds
1154+
_query_vec(conn, None, "m1", "bulk-%d" % i, embed=_fake_q)
1155+
check("query cache: bounded to QUERY_CACHE_MAX rows",
1156+
conn.execute("SELECT COUNT(*) FROM query_cache").fetchone()[0] <= QUERY_CACHE_MAX)
1157+
_invalidate_query_cache(conn) # identity drift wipe
1158+
check("query cache: identity drift invalidation empties the cache",
1159+
conn.execute("SELECT COUNT(*) FROM query_cache").fetchone()[0] == 0)
1160+
10861161
# lock: a held lock makes a second acquire a no-op (separate open file descriptions)
10871162
fd = acquire_lock(paths["lock"])
10881163
fd2 = acquire_lock(paths["lock"])

0 commit comments

Comments
 (0)