Skip to content

fix(tools): validate cached tool-embedding dimension (stale-dim after model swap poisons routing)#453

Closed
alichherawalla wants to merge 1 commit into
mainfrom
fix/tool-embedding-dim-validation
Closed

fix(tools): validate cached tool-embedding dimension (stale-dim after model swap poisons routing)#453
alichherawalla wants to merge 1 commit into
mainfrom
fix/tool-embedding-dim-validation

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

The tool-routing embedding cache (toolEmbeddingRouter.ts) keys entries on the tool text (content hash), not the embedding model. If the embedding model's output dimension changes, the hash still matches and embedTool returns a stale-dimension vector. cosineSimilarity then loops on the query's length and reads garbage from the shorter vector — silently poisoning tool routing after a model swap. (Audit finding A12; the test fails on main.)

Fix

  • embedTool requires a cached vector to match the current model's dimension (the live query embedding's length) or re-embeds it.
  • cosineSimilarity returns 0 on a dimension mismatch (defense-in-depth).
  • Self-consistent: uses the live query-embedding dimension, no reliance on a separate getDimension().

Test (jest, fails-before / passes-after)

A persisted 4-dim vector against a 3-dim model is re-embedded (not served stale), and routing still yields a sane top-K. Fails on main (stale vector served, only query embedded), passes here.

Provit

Pending — this is exercised by the tools/MCP routing journeys (KUF: Remote/Tools); will run on-device once the vision oracle is back up. Self-audit below.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved tool selection reliability when embedding model dimensions change, ensuring cached results are refreshed instead of causing inaccurate routing.
    • Prevented mismatched vector sizes from affecting similarity scoring, helping keep top results stable.
  • Tests

    • Added coverage for cache invalidation and re-embedding behavior when stored embeddings are outdated.

…ent model

The tool-routing embedding cache keys entries on the tool TEXT (content hash), not the
embedding model. If the embedding model's output dimension changes (a different MiniLM
variant), the hash still matches and embedTool returned a STALE-DIMENSION vector.
cosineSimilarity then looped on the query's length and read garbage from the shorter
vector — silently poisoning tool routing after a model swap.

Fix: require a cached vector to match the current model's dimension (the live query
embedding's length) or re-embed it; and guard cosineSimilarity to return 0 on a
dimension mismatch (defense-in-depth). Self-consistent — no reliance on a separate
getDimension().

Test: fails-before/passes-after — a persisted 4-dim vector against a 3-dim model is
re-embedded (not served stale), and routing still yields a sane top-K.

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

Copy link
Copy Markdown
Collaborator Author

Self-audit

SOLID / abstraction

  • Seam: fix stays inside the owning module (toolEmbeddingRouter); no caller change, no concrete/Platform.OS branch introduced.
  • Single source of truth: the 'valid cached vector' rule now includes dimension in ONE place (embedTool), with cosineSimilarity guarding as defense-in-depth. Dimension is derived from the live query embedding — not duplicated from a separate constant/getter that could drift.
  • Verdict: clean.

Tests — no false green

  • Unit: drives the REAL selectToolsByEmbedding + embedTool + cosineSimilarity logic; mocks only the boundaries (embeddingService.embed/load, AsyncStorage). Deleting the dimension guard makes the test fail (stale vector served → embed called once, not twice).
  • Fails-before / passes-after: persisted 4-dim vector vs 3-dim model → re-embedded (2 embed calls) after, served stale (1) before.

Provit (on-device E2E)

  • Pending (oracle down). Covered by the tools/MCP routing journeys; will run when the vision gateway is reachable. Not merge-ready until then per CLAUDE.md.

Platform parity

  • Platform-agnostic (embedding router runs identically on iOS/Android). One test guards both.

Standards

  • No UI/copy change. N/A.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a dimension check to the tool embedding cache: cosineSimilarity now returns 0 for mismatched vector lengths, and embedTool validates cached embedding dimension against the current expected dimension before reuse, re-embedding when stale. A unit test covers this invalidation path.

Changes

Embedding Cache Dimension Validation

Layer / File(s) Summary
Dimension-aware cache lookup and similarity guard
src/services/toolEmbeddingRouter.ts
cosineSimilarity returns 0 for mismatched vector lengths; embedTool now requires expectedDim and only reuses cached embeddings whose stored vector length matches, otherwise re-embeds and updates cache; selectToolsByEmbedding passes queryVec.length into embedTool.
Stale-dimension cache invalidation test
__tests__/unit/services/toolEmbeddingRouter.test.ts
New test seeds a stale 4-D persisted embedding, clears in-memory cache, reruns selection, and asserts re-embedding occurs while topK results remain valid.

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

Sequence Diagram(s)

sequenceDiagram
  participant Router as selectToolsByEmbedding
  participant EmbedTool as embedTool
  participant Cache as AsyncStorage
  participant Model as EmbedModel

  Router->>EmbedTool: embedTool(tool, queryVec.length)
  EmbedTool->>Cache: read cached embedding
  Cache-->>EmbedTool: stale vector (wrong dimension)
  EmbedTool->>EmbedTool: compare cached length vs expectedDim
  alt dimension mismatch
    EmbedTool->>Model: re-embed tool text
    Model-->>EmbedTool: new embedding vector
    EmbedTool->>Cache: store updated embedding
  end
  EmbedTool-->>Router: valid embedding
Loading

Compact metadata

  • Related issues: None found
  • Related PRs: None found
  • Suggested labels: bug, tests
  • Suggested reviewers: None specified

Poem
A rabbit checked each vector's size,
found stale ones hiding in disguise,
re-embedded them with care,
matched dimensions everywhere,
now cosine scores don't tell us lies. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description uses custom sections and omits the template's required Summary, Type of Change, Checklist, Issues, and Notes sections. Rewrite the description to follow the repository template, including Summary, Type of Change, Checklist, Related Issues, and Additional Notes; screenshot sections may be omitted for non-UI work.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main fix to stale cached tool-embedding dimensions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 fix/tool-embedding-dim-validation

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

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix stale cached tool embeddings after model dimension changes

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Re-embed cached tool vectors when their dimension differs from the active embedding model.
• Guard cosine similarity against mismatched vector lengths to prevent corrupted routing scores.
• Add a regression test simulating a model swap with persisted stale-dimension vectors.
Diagram

graph TD
  Q["Query text"] --> R["ToolEmbeddingRouter"] --> E{{"EmbeddingService"}} --> R
  R --> C[("AsyncStorage cache")]
  R --> S["Score & rank"] --> O["Top-K tools"]

  subgraph Legend
    direction LR
    _p["Process"] ~~~ _db[("Storage")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Include embedding model ID/version in cache key
  • ➕ Deterministically separates caches per model, avoiding any cross-model reuse
  • ➕ Avoids re-embedding only when dimension matches but embedding space meaning changed
  • ➖ Requires a reliable model identifier exposed by embeddingService
  • ➖ Cache churn on minor model changes; more storage growth unless old entries are cleaned up
2. Persist vector dimension (and validate on hydration)
  • ➕ Fast fail during hydration; can drop/repair stale entries upfront
  • ➕ Keeps embedTool signature unchanged
  • ➖ Still needs a source-of-truth expected dimension (likely derived from a live embed anyway)
  • ➖ Doesn’t help if the cached dimension matches but the embedding space changed materially
3. Clear the entire tool embedding cache on model swap
  • ➕ Simple correctness story; no mixed-model vectors ever
  • ➕ Avoids subtle scoring shifts from partially stale caches
  • ➖ Worst-case performance regression after a model update (forces re-embedding everything)
  • ➖ Requires a model-swap detection mechanism

Recommendation: The PR’s approach is a good minimal fix: validating cached vectors against the live query embedding dimension is self-contained and avoids needing a separate dimension API or model ID, and the cosineSimilarity guard adds defense-in-depth. Longer-term, adding a model/version discriminator to the cache key would also protect against same-dimension but different-embedding-space model swaps.

Files changed (2) +36 / -3

Bug fix (1) +12 / -3
toolEmbeddingRouter.tsValidate cached embedding dimensions and harden cosine similarity +12/-3

Validate cached embedding dimensions and harden cosine similarity

• Updates tool embedding reuse to require cached vectors to match the current model’s embedding dimension (derived from the live query embedding length), otherwise re-embeds and persists the corrected vector. Adds a cosineSimilarity dimension mismatch guard returning 0 to prevent corrupted scores from mixed-dimension vectors.

src/services/toolEmbeddingRouter.ts

Tests (1) +24 / -0
toolEmbeddingRouter.test.tsAdd regression test for stale-dimension persisted embeddings +24/-0

Add regression test for stale-dimension persisted embeddings

• Adds a Jest test that persists a cached tool vector with a different dimension than the current embedding model. Verifies the router re-embeds the stale entry (instead of serving it from cache) and still returns a sane top-K result.

tests/unit/services/toolEmbeddingRouter.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/services/toolEmbeddingRouter.ts (1)

148-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Solid fix; consider a log line on cache invalidation.

The dimension check correctly forces re-embedding when a cached vector's length diverges from the live model's output (expectedDim), preventing the stale-vector poisoning described in the PR. Optionally, a debug log when cached && cached.h === hash && cached.v.length !== expectedDim would help diagnose future model-swap migrations, similar to the existing hydrateCache/schedulePersist warn logs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/toolEmbeddingRouter.ts` around lines 148 - 161, The
cache-dimension guard in embedTool correctly re-embeds stale vectors, but add a
debug log when the cached entry matches the hash and is rejected because
cached.v.length differs from expectedDim. Use the existing embedTool,
toolEmbeddingCache, and schedulePersist context so the log clearly indicates
cache invalidation during model swaps, similar in style to the current
hydrateCache/schedulePersist logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/services/toolEmbeddingRouter.ts`:
- Around line 148-161: The cache-dimension guard in embedTool correctly
re-embeds stale vectors, but add a debug log when the cached entry matches the
hash and is rejected because cached.v.length differs from expectedDim. Use the
existing embedTool, toolEmbeddingCache, and schedulePersist context so the log
clearly indicates cache invalidation during model swaps, similar in style to the
current hydrateCache/schedulePersist logging.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01275a72-3a09-4a60-b84a-ad99352d0530

📥 Commits

Reviewing files that changed from the base of the PR and between a0535cd and c9d42a0.

📒 Files selected for processing (2)
  • __tests__/unit/services/toolEmbeddingRouter.test.ts
  • src/services/toolEmbeddingRouter.ts

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 25 rules

Grey Divider


Remediation recommended

1. Real-time sleep in tests 🐞 Bug ☼ Reliability
Description
The new unit test uses a real 1100ms sleep to wait for the 1000ms debounced persist, which slows the
suite and can become timing-flaky under CI load. If the debounce callback runs late,
AsyncStorage.getItem() can still be null and JSON.parse(...) will throw due to the non-null
assertion.
Code

tests/unit/services/toolEmbeddingRouter.test.ts[R86-97]

+    await selectToolsByEmbedding('find my page', TOOLS, 4);
+    await new Promise(r => setTimeout(r, 1100)); // let the debounced persist fire
+
+    // Simulate an embedding-model swap: ONE persisted vector was produced by the OLD
+    // model and has a DIFFERENT dimension (4) than the current model (mock returns 3).
+    // The content hash keys on the tool text, not the model, so it still "matches" —
+    // without a dimension guard, embedTool returns the stale 4-dim vector and
+    // cosineSimilarity compares across dimensions, silently poisoning routing.
+    const stored = JSON.parse((await AsyncStorage.getItem('tool-embedding-cache-v1'))!);
+    const staleName = Object.keys(stored)[0];
+    stored[staleName] = { ...stored[staleName], v: [0.1, 0.2, 0.3, 0.4] }; // 4-dim; model is 3-dim
+    await AsyncStorage.setItem('tool-embedding-cache-v1', JSON.stringify(stored));
Relevance

⭐⭐ Medium

Team reduces brittle timing (PR#68) and replaced fixed sleep in service code (PR#170), but no direct
fake-timers precedent.

PR-#68
PR-#170

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The router persists embeddings via a 1000ms debounced timer, and the new test (as well as other
tests in the file) waits a hard-coded 1100ms to allow it to fire; this adds wall-clock latency and
can fail if the timer is delayed and storage is still unset when parsed.

src/services/toolEmbeddingRouter.ts[67-77]
tests/unit/services/toolEmbeddingRouter.test.ts[85-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Unit tests wait for debounced persistence using real-time `setTimeout(1100)`, which slows the test suite and risks flakiness if timers are delayed.

## Issue Context
`schedulePersist()` debounces persistence with a 1000ms timer; tests currently sleep 1100ms to "let the debounced persist fire".

## Fix Focus Areas
- __tests__/unit/services/toolEmbeddingRouter.test.ts[41-107]
- src/services/toolEmbeddingRouter.ts[67-77]

## Suggested fix
- Switch the test suite to Jest fake timers (`jest.useFakeTimers()`), and after invoking `selectToolsByEmbedding(...)` call `jest.advanceTimersByTime(1000)` (or `runOnlyPendingTimers`).
- Flush the microtask queue after advancing timers so the `AsyncStorage.setItem(...)` promise chain completes before reading from storage (e.g., `await new Promise(setImmediate)` or an equivalent flush helper).
- Remove/avoid real-time sleeps, and optionally add an explicit assertion that `stored` is non-null before `JSON.parse` to make failures clearer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +86 to +97
await selectToolsByEmbedding('find my page', TOOLS, 4);
await new Promise(r => setTimeout(r, 1100)); // let the debounced persist fire

// Simulate an embedding-model swap: ONE persisted vector was produced by the OLD
// model and has a DIFFERENT dimension (4) than the current model (mock returns 3).
// The content hash keys on the tool text, not the model, so it still "matches" —
// without a dimension guard, embedTool returns the stale 4-dim vector and
// cosineSimilarity compares across dimensions, silently poisoning routing.
const stored = JSON.parse((await AsyncStorage.getItem('tool-embedding-cache-v1'))!);
const staleName = Object.keys(stored)[0];
stored[staleName] = { ...stored[staleName], v: [0.1, 0.2, 0.3, 0.4] }; // 4-dim; model is 3-dim
await AsyncStorage.setItem('tool-embedding-cache-v1', JSON.stringify(stored));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Real-time sleep in tests 🐞 Bug ☼ Reliability

The new unit test uses a real 1100ms sleep to wait for the 1000ms debounced persist, which slows the
suite and can become timing-flaky under CI load. If the debounce callback runs late,
AsyncStorage.getItem() can still be null and JSON.parse(...) will throw due to the non-null
assertion.
Agent Prompt
## Issue description
Unit tests wait for debounced persistence using real-time `setTimeout(1100)`, which slows the test suite and risks flakiness if timers are delayed.

## Issue Context
`schedulePersist()` debounces persistence with a 1000ms timer; tests currently sleep 1100ms to "let the debounced persist fire".

## Fix Focus Areas
- __tests__/unit/services/toolEmbeddingRouter.test.ts[41-107]
- src/services/toolEmbeddingRouter.ts[67-77]

## Suggested fix
- Switch the test suite to Jest fake timers (`jest.useFakeTimers()`), and after invoking `selectToolsByEmbedding(...)` call `jest.advanceTimersByTime(1000)` (or `runOnlyPendingTimers`).
- Flush the microtask queue after advancing timers so the `AsyncStorage.setItem(...)` promise chain completes before reading from storage (e.g., `await new Promise(setImmediate)` or an equivalent flush helper).
- Remove/avoid real-time sleeps, and optionally add an explicit assertion that `stored` is non-null before `JSON.parse` to make failures clearer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Closing — bundling the fix into #510 (refactor/parse-once-boundary). A falsifying red-flow test for this bug is committed on that branch (integration-level, mocks only the native boundary; verified red on HEAD, flips green when the fix lands).

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.

1 participant