Skip to content

fix: append to the vector cache on embed instead of clearing it - #94

Merged
saucam merged 1 commit into
mainfrom
perf/incremental-vector-cache
Jul 3, 2026
Merged

fix: append to the vector cache on embed instead of clearing it#94
saucam merged 1 commit into
mainfrom
perf/incremental-vector-cache

Conversation

@saucam

@saucam saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem (#86)

setEmbedding cleared the entire per-workspace vector cache on every background embed batch. Since batches fire continuously during active work, the next recall() re-read every embedding BLOB from SQLite and re-decoded the full matrix before the cosine loop even started — O(N) I/O + allocation per query that should be O(1), all on the shared daemon event loop.

Fix

Both the id and the vector are already known at write time, so the cached matrix is extended in place instead of being invalidated:

  • setEmbedding now takes the workspaceId (the engine has the episode loaded) and upserts the row into the cached matrix.
  • Cache entries carry an id → index map, so the re-embed path replaces in O(1) instead of duplicating.
  • insert-with-embedding gets the same append treatment (it used to delete the workspace entry).
  • If the matrix hasn't been built yet, the write is a no-op on the cache — the next loadVectorMatrix() picks the row up from SQLite with everything else.
  • uint8ToFloat32 allocates the Float32Array first and blits bytes into its buffer: one allocation per row instead of two (halves the transient garbage of the remaining cold build).

The cached vector is copied on upsert so later caller mutation can't desync cache from the persisted BLOB.

Complexity

recall() after an embed batch before after
SQLite reads full BLOB table scan (N rows) 0
allocations 2 per row × N 0 (1 small copy per embed write)
per-write cost O(1) cache clear, deferred O(N) on read O(1) amortized append

Micro-bench

20,000 episodes × 384-dim embeddings (the issue's ~50k scenario scaled down; scaling is linear), 5 rounds each:

OLD path — full rebuild after embed batch: avg 73.40 ms  (150.4, 50.2, 96.3, 44.6, 25.5)
NEW path — upsert + memoized load:         avg 0.06 ms  (0.19, 0.04, 0.03, 0.02, 0.03)

The old cost recurred on every recall that followed an embed batch — i.e. more or less every recall during active work. It's now paid once per process per workspace (first recall), and never again.

Tests

New describe block pins the behavior via object identity (loadVectorMatrix returns the same object after writes) plus value checks: append on setEmbedding, append on insert-with-embedding, O(1) replace on re-embed without duplication, no-op before first build, and defensive copy semantics. Verified the cached rows match a cold rebuild from a fresh connection.

bun x tsc --noEmit ✓ · bun run lint ✓ · bun run test 756 pass / 0 fail ✓

Related but separate: #71 tracks replacing the brute-force cosine scan itself with an ANN backend.

Fixes #86

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Newly generated embeddings are now normalized before being saved, improving consistency in vector-based features.
    • Vector cache updates are more reliable: newly added or updated embeddings now appear correctly without duplicating entries or falling out of sync.
    • Changes to an embedding input after saving no longer affect stored results.

setEmbedding cleared the whole per-workspace vector cache on every
background embed batch, so the next recall() re-read and re-decoded every
embedding BLOB from SQLite before scoring — O(N) I/O + allocation per
query during active work, when batches fire continuously.

Both the id and the vector are known at write time, so extend the cached
matrix in place instead: appends for new episodes, O(1) replacement via an
id → index map for re-embeds. insert-with-embedding gets the same
treatment, and uint8ToFloat32 now allocates once per row instead of twice.

At 20k episodes × 384 dims, the first recall after an embed batch drops
from ~73 ms avg (spikes to 150 ms) to ~0.06 ms.

Fixes #86

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fb9d9e1a-47de-4c1b-91ac-cca53cb8843b

📥 Commits

Reviewing files that changed from the base of the PR and between f7487bc and f3dd57a.

📒 Files selected for processing (3)
  • src/daemon/memory/engine.ts
  • src/daemon/memory/store.ts
  • src/tests/memory.test.ts

📝 Walkthrough

Walkthrough

This PR normalizes generated embedding vectors before persisting them in the daemon's embedding worker and adds a test suite validating incremental vector-cache behavior in SqliteEpisodeStore, covering insert, setEmbedding, lazy cache initialization, and vector copy immutability.

Changes

Embedding normalization and vector cache tests

Layer / File(s) Summary
Normalize embeddings before persisting
src/daemon/memory/engine.ts
The background embedding worker now normalizes each generated vector before calling #store.setEmbedding, with id, model name, and workspace id passed through unchanged.
Vector cache incrementality tests
src/tests/memory.test.ts
New tests verify setEmbedding updates the loaded vector matrix in place, insertions with embeddings append rows, re-embedding replaces rows without duplication, the cache lazily initializes on first loadVectorMatrix, and vectors are copied to prevent external mutation from affecting the cache.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The new embedding normalization in engine.ts is unrelated to #86's cache-invalidation fix. Move the normalization change to a separate PR or explain why it is required for the cache fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: appending to the vector cache instead of clearing it.
Linked Issues check ✅ Passed The cache incrementality changes and tests address #86's goal of avoiding full vector-matrix reloads.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/incremental-vector-cache

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.12%. Comparing base (f7487bc) to head (f3dd57a).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #94      +/-   ##
==========================================
+ Coverage   73.06%   73.12%   +0.06%     
==========================================
  Files          65       65              
  Lines       11032    11057      +25     
==========================================
+ Hits         8060     8085      +25     
  Misses       2972     2972              
Flag Coverage Δ
daemon 73.12% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/memory/engine.ts 99.52% <100.00%> (+0.01%) ⬆️
src/daemon/memory/store.ts 91.02% <ø> (+0.38%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@saucam
saucam merged commit 0bde49a into main Jul 3, 2026
5 checks passed
saucam added a commit that referenced this pull request Jul 5, 2026
… 21.6% -> 35.1%)

feat/conductor was 22 commits behind main; rebased onto origin/main (conflict-free — P0 adds only new files). Re-ran the baseline on the current base: cross-workspace P@1 rose 21.6% -> 35.1% (MRR 0.45 -> 0.54) because main's #94 (append-to-vector-cache) improves vector coverage. Within-workspace unchanged at 89.2%; R@5 still 81%; same small-workspace-domination failure mode. 35.1% is the refreshed P1 target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saucam added a commit that referenced this pull request Jul 6, 2026
…log (#117)

The 0.2.0 entry only covered the protocol/packages train (#100-#116) and
missed ten PRs that also ship in this release: the untrusted-content
sanitization and cross-tenant memory fixes (#91, #93 — now under a
proper Security heading), the performance run (#94-#99), and the model
catalog work (#78, #79).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saucam added a commit that referenced this pull request Jul 6, 2026
… 21.6% -> 35.1%)

feat/conductor was 22 commits behind main; rebased onto origin/main (conflict-free — P0 adds only new files). Re-ran the baseline on the current base: cross-workspace P@1 rose 21.6% -> 35.1% (MRR 0.45 -> 0.54) because main's #94 (append-to-vector-cache) improves vector coverage. Within-workspace unchanged at 89.2%; R@5 still 81%; same small-workspace-domination failure mode. 35.1% is the refreshed P1 target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saucam added a commit that referenced this pull request Jul 6, 2026
…ner-delegated identity (P2) (#51)

* docs: add conductor design, session-resolution architecture, and build plan

Spec set for the codeoid conductor: a single global, identity-native supervisor session that resolves fuzzy natural-language references to the right coding session across all workspaces, coordinates the existing session fleet, and never goes out of context.

- conductor-design.md: architecture + locked decisions (owner-delegated privileged identity, durable conductor / disposable children, confirm before send-class acts, approval-gated egress, metrics-only cost guard)
- conductor-session-resolution.md: SOTA retrieval architecture (BGE-M3 hybrid + bge-reranker-v2-m3 + LFM2-350M-Extract cards + bi-temporal state), backed by a 9-agent research fan-out
- conductor-build-plan.md: phased plan P0-P8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: P0 conductor foundations — session-card store + eval metrics

First P0 slice of the conductor (see docs/conductor-build-plan.md).

- SessionCardStore (src/daemon/memory/cards.ts): per-session digest cards with a standalone FTS5 mirror for keyword/identifier recall, plus a bi-temporal fact log (Zep/Graphiti pattern) — state changes are invalidated-not-deleted (valid_at/invalid_at event time + created_at/expired_at system time), giving time-travel + a lossless audit trail.
- Eval harness metrics (src/daemon/eval/metrics.ts): precision@1 / MRR / recall@k + latency percentiles for known-item session resolution — the go/no-go gate for the whole feature.

13/13 unit tests pass; tsc + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: firstmate prior-art analysis + reconcile conductor plan with mobile app

- conductor-prior-art-firstmate.md: analysis of firstmate (a shipped bash+prompt conductor, codeoid's architectural inverse). Borrows ranked: read-only-by-construction, zero-token event-driven supervision, ship/scout task shapes, per-project autonomy modes, /afk + /stow, harness dispatch profiles, secondmates (nested-conductor scaling). Where codeoid is already better: cryptographic identity, semantic session resolution, determinism (code vs 122KB prompt), daemon-native events.
- build-plan: added 'Informed by firstmate' refinements and 'Reconciliation with the mobile app plan'. Key reconciliation: the conductor needs ZERO new client wire types (mobile doc §8), so the P3 protocol-level fleet.find is downgraded to optional; shared @codeoid/core extraction up front; sequencing = conductor backend (P1 session resolution) first as the risk-retiring gate, mobile app in parallel on today's protocol, converging at mobile-P5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: hermes prior-art + upgrade P4 (durable queue/roles/hardening) + add P4.5 routines

- conductor-prior-art-hermes.md: analysis of NousResearch/hermes-agent, the most complete personal-assistant prior art (multi-platform gateway, cron routines, autonomous skills, delegate + Kanban). Borrows: routines (cron+webhooks+script-injection [SILENT]), durable Kanban work-queue, leaf/orchestrator delegate role model, session-lifecycle hardening, multi-platform gateway shape, zero-context-cost tool-RPC scripts, Curator safe-autonomy invariants, ACP + serverless-persistence notes. Where codeoid stays ahead: cryptographic identity, rerank+bi-temporal retrieval, typed modular daemon (hermes is a Python monolith).
- build-plan: upgraded P4 dispatch (durable Kanban-style queue + leaf/orchestrator roles enforced via ZeroID scopes + session-lifecycle hardening: resume_pending/stuck-loop/clean-shutdown/burst-collapse queue) and added P4.5 Routines (scheduled + webhook-triggered autonomy, [SILENT] monitors, cron hardening). Updated phase table, dependency graph, and added the 'Informed by hermes' section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: P0 baseline — session-resolution eval runner + fixture + measured number

Wires a baseline resolver over the current workspace-scoped searchSessions and measures it against the real Hetzner corpus (16 sessions / 11 workspaces / 11,938 episodes) with 37 hand-labeled fuzzy references.

Result: within-workspace P@1 = 89.2% (primitives sound), cross-workspace P@1 = 21.6% with R@5 = 81% — a ranking/fusion problem, not recall: searchSessions normalizes BM25 batch-relative PER workspace, so a small workspace's inflated scores dominate the naive cross-workspace merge (~22 misses return the same wrong small-workspace session at #1). 21.6% is the number P1 must beat; global normalized fusion + cross-encoder rerank should convert the 81% R@5 into P@1. See src/daemon/eval/BASELINE.md. memory.db is NOT committed (real session content); only the derived fuzzy-reference fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: refresh P0 baseline after rebase onto main (cross-workspace P@1 21.6% -> 35.1%)

feat/conductor was 22 commits behind main; rebased onto origin/main (conflict-free — P0 adds only new files). Re-ran the baseline on the current base: cross-workspace P@1 rose 21.6% -> 35.1% (MRR 0.45 -> 0.54) because main's #94 (append-to-vector-cache) improves vector coverage. Within-workspace unchanged at 89.2%; R@5 still 81%; same small-workspace-domination failure mode. 35.1% is the refreshed P1 target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: P1 slice 1 — cross-workspace global fusion for session resolution

The conductor resolves a fuzzy reference across ALL workspaces, which the current workspace-scoped searchSessions can't do. Adds engine.recallGlobal(): unions FTS + vector candidates across every workspace and ranks in ONE batch, so the ranker's BM25 min-max normalization is GLOBAL (fixes the small-workspace-domination failure a naive per-workspace merge has). searchSessions() goes global when no workspaceId is passed. New store primitives: listWorkspaceIds, ftsSearchGlobal, episodesByIds.

Measured on the real Hetzner corpus (37 labeled refs), vs the naive-merge baseline:
- latency 4224 -> 24ms p95 (~200x: one search vs 11 per-workspace)
- R@5 81% -> 92%, R@3 76% -> 81%, MRR 0.54 -> 0.61
- P@1 35.1% -> 37.8% (modest; remaining misses are rank 2-3 as big verbose sessions fill #1 — slice 2 cross-encoder rerank targets exactly this)

4 new deterministic cross-workspace tests (17 total green); tsc + biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: P1 slice 2 — cross-encoder rerank clears the resolution gate

Adds a Reranker interface + transformers.js cross-encoder impl (Xenova/ms-marco-MiniLM-L-6-v2, swappable for bge-reranker-v2-m3). MemoryEngine reranks the top-8 candidate sessions by (query, evidence) when a reranker is present; searchSessions({rerank}) gates it (defaults on when ready) and degrades to fusion-only if the model fails to load.

Measured on the real Hetzner corpus (37 refs): cross-workspace P@1 37.8% -> 86.5% (MRR 0.61 -> 0.91, R@3 81% -> 95%), latency +~30ms (88ms p95). Converts slice 1's 92% R@5 into precision@1 — essentially the within-workspace ceiling (97.3% with rerank on).

P1 go/no-go gate CLEARED: cross-workspace P@1 35.1% (baseline) -> 86.5%, p95 < 100ms vs the 2s budget. 18 tests green (added a deterministic rerank test); tsc + biome clean. Remaining P1 slices (BGE-M3, identifier-aware lexical, session cards) are now optional polish. See src/daemon/eval/BASELINE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add session-resolution how-it-works explainer

First-class explainer of the shipped cross-workspace session-resolution capability (two-stage: global fusion -> cross-encoder rerank). Distinct from the design/plan doc (conductor-session-resolution.md) and the eval writeup (BASELINE.md): covers the problem, the pipeline, why two stages, measured results (P@1 35.1% -> 86.5%), the local models, a code map, repro, and limitations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: genericize eval fixture references + refresh numbers

codeoid is public; the eval fixture named internal project specifics. Genericized all 37 reference strings to neutral software-work descriptions (gold labels are opaque UUIDs — unchanged) and scrubbed an internal name from BASELINE.md.

Re-ran: pure-conceptual references (no exact identifiers) are a harder, conservative eval — cross-workspace P@1 21.6% (naive) -> 35.1% (fusion) -> 73.0% (rerank), R@5 92%, <100ms. Two-stage story unchanged; identifier-bearing references resolve higher still. Refreshed numbers in BASELINE.md, docs/session-resolution.md, and the build plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: P2 conductor identity foundation — durable owner-delegated identity

Conductor scope profile (R1): session:read / session:dispatch join the
protocol scopes; CONDUCTOR_SCOPES deliberately omits tools:write and
tools:execute, so ZeroID's per-hop scope intersection makes the conductor's
whole delegation subtree read-only-by-construction on targets.

Durable identity (R2): registerConductor(ownerSub) registers a ZeroID
orchestrator identity and persists {identityId, wimseUri, apiKey} to the
Store; resumeSessions reloads it on daemon restart — one stable WIMSE URI
across process lifetimes, with the actor keypair regenerated (never at
rest) and re-registered per boot. mintConductorToken exchanges the owner's
subject token for the conductor's working token (RFC 8693), and
deactivateConductor cascade-revokes the subtree via ZeroID's parent_jti
walk.

Integration test (bun run test:integration, live ZeroID required): mints
the owner → conductor → child → sub-agent chain at delegation_depth 3 with
a verified act chain per hop, proves tools:write can't be minted below the
conductor, and asserts deactivation kills conductor/child/sub-agent tokens
while the owner's token stays active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: unit-test the conductor lifecycle and reranker backend

Fetch-stubbed ZeroID covers register/resume/mint/deactivate — including the
stale-row drop, key rotation on resume, and the actor-assertion wire
contract — plus Store persistence per tenant. The cross-encoder reranker is
covered with @xenova/transformers mocked (no model download): batching
shape, single-logit vs two-class score extraction, and re-init after close.

The live-ZeroID integration test remains the depth-3 / cascade-revocation
proof; these keep the CI patch-coverage gate honest without a server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address CodeRabbit review — write ordering, transactions, bounded init

- registerConductor persists the identity BEFORE exposing it in memory, so a
  Store failure reads as registration failure, not a phantom durable identity.
- deactivateConductor keeps the persisted row when the remote deactivation
  fails — it's the only durable record of a still-live identity, and the next
  call retries against it (test added).
- Card upsert + FTS mirror refresh and assertFact's supersede + insert each
  commit in one transaction, so a crash can't leave the FTS drifted or a
  (subject, predicate) with no open fact.
- assertFact rejects out-of-order validAt instead of closing the open fact
  with invalid_at < valid_at, which made the row unsatisfiable for every
  factsAsOf() read (test added).
- reranker.init() is bounded by a 120s timeout — a stalled model download
  degrades to fusion-only instead of wedging daemon startup; close() disposes
  the ONNX model to actually free WASM memory.
- conductor-design.md: fleet MCP allowlist gotcha noted for P3, §4 updated to
  the CONDUCTOR_SCOPES profile as implemented, §9 Shield marked later-phase
  (v1 = owner approval only, per R4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: setEmbedding invalidates the whole vector cache every batch → full O(N) matrix reload per recall

1 participant