Quellcode durchsuchen

fix(embed): re-select documents whose later chunks failed to embed (i-xeekgx6h)

"Does this document need embedding?" was asked as "is there a vector at seq 0?",
while chunk inserts are per-chunk best-effort ("so a single bad chunk doesn't
drag down the rest"). Compose the two and a document whose chunk 0 landed and
whose chunk 3 did not is complete FOREVER: the 30-minute embed cron never selects
it again and the missing chunks are unreachable. Nothing errors — semantic search
just answers from a document with holes in it.

The trigger is any partial-failure window. Split out of i-7yayruey, where the
embeddings upstream vanished in five bursts; four were 100% failures, which is
the SAFE shape (nothing inserts, the document stays pending, the next run heals
it). The dangerous one is the mixed burst — 3505 requests at 26% failure.

Fix: record how many chunks a document was split into, at chunk time, and treat
"fewer vectors than chunks" as pending alongside "no chunk 0". Recording BEFORE
embedding rather than after is deliberate — a run that dies mid-document still
leaves the expectation behind for the next run to compare against.

The predicate now lives in ONE place (PENDING_EMBEDDING_JOINS/PREDICATE) used by
both getPendingEmbeddingDocs and getHashesNeedingEmbedding. Those two each
carrying their own copy of the rule is the shape that let this hole exist, so a
test asserts the filtered and unfiltered counts agree.

Documents embedded before the table existed have no recorded count, so they are
NOT re-selected: a whole-corpus re-embed on upgrade would cost far more than the
holes it heals. That limitation is deliberate, tested, and repaired by --force or
the next re-chunk.

Tests (5, all passing): chunk 0 embedded + later chunks dropped leaves the
document pending; a later healthy pass fills it and the count returns to 0. Two
CONTROLS carry the weight — a fully embedded document is NOT re-selected
(docsProcessed 0, chunksEmbedded 0, i.e. no corpus re-embed every tick), and a
legacy document with no recorded count is left alone. Mutation-checked: reverting
the predicate to the old "no chunk 0" turns 3 of the 5 red.

Scoped runs: embed-partial-chunks 5/5, embed-collection-filter 9/9 (same
predicate), store + embedding-store-integration 193 passed / 13 skipped.

dist/ rebuilt and committed with src/ per CLAUDE.md — the fleet bundler ships
this repo's dist/ as-is.

Refs: i-xeekgx6h
Refs: i-7yayruey
Session-Id: 7a7ae5c5
Claude vor 2 Wochen
Ursprung
Commit
ab6440dc0e
4 geänderte Dateien mit 344 neuen und 24 gelöschten Zeilen
  1. 7 0
      dist/store.d.ts
  2. 77 12
      dist/store.js
  3. 84 12
      src/store.ts
  4. 176 0
      test/embed-partial-chunks.test.ts

+ 7 - 0
dist/store.d.ts

@@ -370,6 +370,13 @@ export type EmbedOptions = {
      */
     collection?: string;
 };
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export declare function recordDocumentChunkCount(db: Database, hash: string, chunks: number, chunkedAt: string): void;
 /**
  * Generate vector embeddings for documents that need them.
  * Pure function — no console output, no db lifecycle management.

+ 77 - 12
dist/store.js

@@ -709,6 +709,25 @@ function initializeDatabase(db) {
       embedded_at TEXT NOT NULL,
       PRIMARY KEY (hash, seq)
     )
+  `);
+    // How many chunks a document was split into, recorded at chunk time (i-xeekgx6h).
+    //
+    // Without it "is this document embedded?" can only be asked as "does chunk 0
+    // exist?", and chunk inserts are per-chunk best-effort — so a document whose
+    // chunk 0 embedded and whose chunk 3 did not looked complete forever, and the
+    // 30-minute embed cron never picked it up again. A partially embedded document
+    // does not fail loudly: it answers semantic search with plausible-but-incomplete
+    // results.
+    //
+    // Written BEFORE the chunks are embedded, not after, so that a run which dies
+    // mid-document still leaves the expectation behind for the next run to compare
+    // against.
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS document_chunk_counts (
+      hash TEXT PRIMARY KEY,
+      chunks INTEGER NOT NULL,
+      chunked_at TEXT NOT NULL
+    )
   `);
     // Store collections — makes the DB self-contained (no external config needed)
     db.exec(`
@@ -1029,6 +1048,49 @@ function resolveEmbedOptions(options) {
         maxBatchBytes: validatePositiveIntegerOption("maxBatchBytes", options?.maxBatchBytes, DEFAULT_EMBED_MAX_BATCH_BYTES),
     };
 }
+/**
+ * What "still needs embedding" means, in ONE place (i-xeekgx6h).
+ *
+ * Two conditions, and the second is the one that was missing:
+ *
+ *   1. no vector at seq 0 — the document was never embedded at all. A run that
+ *      fails outright leaves this true, which is why whole-document failures
+ *      always self-healed on the next pass.
+ *   2. fewer vectors than the document has chunks — it was embedded PARTIALLY.
+ *      Chunk inserts are per-chunk best-effort ("so a single bad chunk doesn't
+ *      drag down the rest"), so an upstream that fails mid-document leaves
+ *      chunk 0 present and later chunks missing. Under condition 1 alone that
+ *      document is complete forever and its missing chunks are unreachable.
+ *
+ * Documents embedded BEFORE this table existed have no `document_chunk_counts`
+ * row, so condition 2 cannot fire for them and they are NOT re-embedded — a
+ * whole-corpus re-embed on upgrade would cost more than the holes it heals.
+ * They are repaired the next time they are re-chunked, or by `--force`.
+ *
+ * Requires the caller's FROM clause to alias documents as `d`, and to LEFT JOIN
+ * both `content_vectors v ... AND v.seq = 0` and `document_chunk_counts dcc`.
+ * Keeping the predicate here rather than inline is deliberate: this bug existed
+ * because the list query and the count query each carried their own copy.
+ */
+const PENDING_EMBEDDING_JOINS = `
+      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+      LEFT JOIN document_chunk_counts dcc ON dcc.hash = d.hash`;
+const PENDING_EMBEDDING_PREDICATE = `(
+        v.hash IS NULL
+        OR (
+          dcc.chunks IS NOT NULL
+          AND (SELECT COUNT(*) FROM content_vectors cv WHERE cv.hash = d.hash) < dcc.chunks
+        )
+      )`;
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export function recordDocumentChunkCount(db, hash, chunks, chunkedAt) {
+    db.prepare(`INSERT OR REPLACE INTO document_chunk_counts (hash, chunks, chunked_at) VALUES (?, ?, ?)`).run(hash, chunks, chunkedAt);
+}
 function getPendingEmbeddingDocs(db, collection) {
     // `MIN(d.collection)` deterministically picks one collection per hash when
     // the same content is indexed in multiple collections (SQLite tie-breaks
@@ -1045,9 +1107,8 @@ function getPendingEmbeddingDocs(db, collection) {
         return db.prepare(`
       SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
       FROM documents d
-      JOIN content c ON d.hash = c.hash
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
       GROUP BY d.hash
       ORDER BY MIN(d.path)
     `).all(collection);
@@ -1055,9 +1116,8 @@ function getPendingEmbeddingDocs(db, collection) {
     return db.prepare(`
     SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
     FROM documents d
-    JOIN content c ON d.hash = c.hash
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
     GROUP BY d.hash
     ORDER BY MIN(d.path)
   `).all();
@@ -1287,6 +1347,10 @@ export async function generateEmbeddings(store, options) {
                 const perCollectionStrategy = collectionStrategies.get(doc.collection);
                 const chunkStrategy = perCollectionStrategy ?? options?.chunkStrategy;
                 const chunks = await chunkDocumentByTokens(doc.body, undefined, undefined, undefined, doc.path, chunkStrategy, session.signal, chunkTokenizer);
+                // Record the expectation BEFORE embedding anything (i-xeekgx6h). If this
+                // run only gets through chunk 0 of 4, the next run compares 1 < 4 and
+                // re-selects the document instead of reading a present chunk 0 as "done".
+                recordDocumentChunkCount(db, doc.hash, chunks.length, now);
                 for (let seq = 0; seq < chunks.length; seq++) {
                     batchChunks.push({
                         hash: doc.hash,
@@ -1608,20 +1672,21 @@ export function handelize(path) {
 export function getHashesNeedingEmbedding(db, collection) {
     // i-ofojj7dy — optional collection filter. Restricts the count to hashes
     // whose documents are in the named collection.
+    // Same predicate as getPendingEmbeddingDocs, from the same constants — the
+    // two carrying independent copies is how the partial-embedding hole survived
+    // (i-xeekgx6h).
     if (collection !== undefined) {
         const result = db.prepare(`
       SELECT COUNT(DISTINCT d.hash) as count
-      FROM documents d
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      FROM documents d${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
     `).get(collection);
         return result.count;
     }
     const result = db.prepare(`
     SELECT COUNT(DISTINCT d.hash) as count
-    FROM documents d
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    FROM documents d${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
   `).get();
     return result.count;
 }

+ 84 - 12
src/store.ts

@@ -878,6 +878,26 @@ function initializeDatabase(db: Database): void {
     )
   `);
 
+  // How many chunks a document was split into, recorded at chunk time (i-xeekgx6h).
+  //
+  // Without it "is this document embedded?" can only be asked as "does chunk 0
+  // exist?", and chunk inserts are per-chunk best-effort — so a document whose
+  // chunk 0 embedded and whose chunk 3 did not looked complete forever, and the
+  // 30-minute embed cron never picked it up again. A partially embedded document
+  // does not fail loudly: it answers semantic search with plausible-but-incomplete
+  // results.
+  //
+  // Written BEFORE the chunks are embedded, not after, so that a run which dies
+  // mid-document still leaves the expectation behind for the next run to compare
+  // against.
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS document_chunk_counts (
+      hash TEXT PRIMARY KEY,
+      chunks INTEGER NOT NULL,
+      chunked_at TEXT NOT NULL
+    )
+  `);
+
   // Store collections — makes the DB self-contained (no external config needed)
   db.exec(`
     CREATE TABLE IF NOT EXISTS store_collections (
@@ -1435,6 +1455,54 @@ function resolveEmbedOptions(options?: EmbedOptions): Required<Pick<EmbedOptions
   };
 }
 
+/**
+ * What "still needs embedding" means, in ONE place (i-xeekgx6h).
+ *
+ * Two conditions, and the second is the one that was missing:
+ *
+ *   1. no vector at seq 0 — the document was never embedded at all. A run that
+ *      fails outright leaves this true, which is why whole-document failures
+ *      always self-healed on the next pass.
+ *   2. fewer vectors than the document has chunks — it was embedded PARTIALLY.
+ *      Chunk inserts are per-chunk best-effort ("so a single bad chunk doesn't
+ *      drag down the rest"), so an upstream that fails mid-document leaves
+ *      chunk 0 present and later chunks missing. Under condition 1 alone that
+ *      document is complete forever and its missing chunks are unreachable.
+ *
+ * Documents embedded BEFORE this table existed have no `document_chunk_counts`
+ * row, so condition 2 cannot fire for them and they are NOT re-embedded — a
+ * whole-corpus re-embed on upgrade would cost more than the holes it heals.
+ * They are repaired the next time they are re-chunked, or by `--force`.
+ *
+ * Requires the caller's FROM clause to alias documents as `d`, and to LEFT JOIN
+ * both `content_vectors v ... AND v.seq = 0` and `document_chunk_counts dcc`.
+ * Keeping the predicate here rather than inline is deliberate: this bug existed
+ * because the list query and the count query each carried their own copy.
+ */
+const PENDING_EMBEDDING_JOINS = `
+      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+      LEFT JOIN document_chunk_counts dcc ON dcc.hash = d.hash`;
+
+const PENDING_EMBEDDING_PREDICATE = `(
+        v.hash IS NULL
+        OR (
+          dcc.chunks IS NOT NULL
+          AND (SELECT COUNT(*) FROM content_vectors cv WHERE cv.hash = d.hash) < dcc.chunks
+        )
+      )`;
+
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export function recordDocumentChunkCount(db: Database, hash: string, chunks: number, chunkedAt: string): void {
+  db.prepare(
+    `INSERT OR REPLACE INTO document_chunk_counts (hash, chunks, chunked_at) VALUES (?, ?, ?)`,
+  ).run(hash, chunks, chunkedAt);
+}
+
 function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbeddingDoc[] {
   // `MIN(d.collection)` deterministically picks one collection per hash when
   // the same content is indexed in multiple collections (SQLite tie-breaks
@@ -1451,9 +1519,8 @@ function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbe
     return db.prepare(`
       SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
       FROM documents d
-      JOIN content c ON d.hash = c.hash
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
       GROUP BY d.hash
       ORDER BY MIN(d.path)
     `).all(collection) as PendingEmbeddingDoc[];
@@ -1461,9 +1528,8 @@ function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbe
   return db.prepare(`
     SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
     FROM documents d
-    JOIN content c ON d.hash = c.hash
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
     GROUP BY d.hash
     ORDER BY MIN(d.path)
   `).all() as PendingEmbeddingDoc[];
@@ -1740,6 +1806,11 @@ export async function generateEmbeddings(
           chunkTokenizer,
         );
 
+        // Record the expectation BEFORE embedding anything (i-xeekgx6h). If this
+        // run only gets through chunk 0 of 4, the next run compares 1 < 4 and
+        // re-selects the document instead of reading a present chunk 0 as "done".
+        recordDocumentChunkCount(db, doc.hash, chunks.length, now);
+
         for (let seq = 0; seq < chunks.length; seq++) {
           batchChunks.push({
             hash: doc.hash,
@@ -2214,20 +2285,21 @@ export type IndexStatus = {
 export function getHashesNeedingEmbedding(db: Database, collection?: string): number {
   // i-ofojj7dy — optional collection filter. Restricts the count to hashes
   // whose documents are in the named collection.
+  // Same predicate as getPendingEmbeddingDocs, from the same constants — the
+  // two carrying independent copies is how the partial-embedding hole survived
+  // (i-xeekgx6h).
   if (collection !== undefined) {
     const result = db.prepare(`
       SELECT COUNT(DISTINCT d.hash) as count
-      FROM documents d
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      FROM documents d${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
     `).get(collection) as { count: number };
     return result.count;
   }
   const result = db.prepare(`
     SELECT COUNT(DISTINCT d.hash) as count
-    FROM documents d
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    FROM documents d${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
   `).get() as { count: number };
   return result.count;
 }

+ 176 - 0
test/embed-partial-chunks.test.ts

@@ -0,0 +1,176 @@
+/**
+ * embed-partial-chunks.test.ts — a document that embedded chunk 0 and lost its
+ * later chunks must be re-selected on the next pass (i-xeekgx6h).
+ *
+ * Before the fix, "does this document need embedding?" was asked as "is there a
+ * vector at seq 0?", while chunk inserts are per-chunk best-effort. Compose the
+ * two and a document whose first chunk landed during an upstream outage is
+ * complete FOREVER: the 30-minute embed cron never selects it again, and its
+ * missing chunks are unreachable. Nothing errors — semantic search just answers
+ * from a document with holes in it.
+ *
+ * The negative control matters as much as the fix here: a fully embedded corpus
+ * must NOT be re-selected, or the repair costs a full re-embed every pass.
+ *
+ * In-memory SQLite + stub provider — no node-llama-cpp.
+ */
+
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import {
+  createStore,
+  generateEmbeddings,
+  getHashesNeedingEmbedding,
+  type Store,
+} from "../src/store.js";
+import type {
+  EmbeddingProvider,
+  ProviderEmbedding,
+  ProviderHealth,
+} from "../src/embedding/provider.js";
+
+/**
+ * Stub provider that can drop chunks the way an unreachable upstream does.
+ *
+ * `failBatchTextsFrom` is an index into the texts handed to `embedBatch` across
+ * the whole run: everything at or after it comes back `null`, which is exactly
+ * the contract `generateEmbeddings` treats as "this chunk has no embedding" and
+ * skips inserting. `embed()` (the single-text dimension probe) always succeeds —
+ * a failing probe aborts the run before any chunk is written, which is a
+ * different, already-safe path.
+ */
+class DroppingProvider implements EmbeddingProvider {
+  readonly kind = "openai" as const;
+  readonly modelId = "stub-embed";
+  readonly dim = 4;
+  batchTextsSeen = 0;
+  constructor(private readonly failBatchTextsFrom = Number.POSITIVE_INFINITY) {}
+  getModelId(): string { return this.modelId; }
+  getDimensions(): number | undefined { return this.dim; }
+  async healthcheck(): Promise<ProviderHealth> {
+    return { ok: true, model: this.modelId, dimensions: this.dim };
+  }
+  async embed(text: string): Promise<ProviderEmbedding | null> {
+    return { embedding: this.fakeEmbed(text), model: this.modelId };
+  }
+  async embedBatch(texts: string[]): Promise<(ProviderEmbedding | null)[]> {
+    return texts.map((t) => {
+      const index = this.batchTextsSeen++;
+      if (index >= this.failBatchTextsFrom) return null;
+      return { embedding: this.fakeEmbed(t), model: this.modelId };
+    });
+  }
+  async dispose(): Promise<void> {}
+  private fakeEmbed(text: string): number[] {
+    return Array.from({ length: this.dim }, (_, i) => (text.length + i) * 0.01);
+  }
+}
+
+let workDir: string;
+let store: Store;
+
+/** Long enough to chunk into several pieces (~900 tokens/chunk, ~3 chars/token). */
+function multiChunkBody(marker: string): string {
+  const paragraph = `${marker} paragraph about indexing, retrieval and embeddings. `.repeat(40);
+  return Array.from({ length: 8 }, (_, i) => `## Section ${i}\n\n${paragraph}`).join("\n\n");
+}
+
+function vectorCount(hash: string): number {
+  return (store.db.prepare(`SELECT COUNT(*) as n FROM content_vectors WHERE hash = ?`).get(hash) as { n: number }).n;
+}
+
+function recordedChunkCount(hash: string): number | undefined {
+  const row = store.db.prepare(`SELECT chunks FROM document_chunk_counts WHERE hash = ?`).get(hash) as { chunks: number } | undefined;
+  return row?.chunks;
+}
+
+beforeEach(() => {
+  workDir = mkdtempSync(join(tmpdir(), "qmd-partial-chunks-test-"));
+  process.env.INDEX_PATH = join(workDir, "index.sqlite");
+  store = createStore(process.env.INDEX_PATH);
+
+  const now = "2026-08-15T00:00:00Z";
+  store.db
+    .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
+    .run("hashPartial", multiChunkBody("alpha"), now);
+  store.db
+    .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
+    .run("hashPartial", "alpha", "a.md", "a.md", now, now, 1);
+});
+
+afterEach(() => {
+  try { store.close(); } catch { /* ignore */ }
+  delete process.env.INDEX_PATH;
+  rmSync(workDir, { recursive: true, force: true });
+});
+
+describe("partially embedded documents are re-selected (i-xeekgx6h)", () => {
+  test("chunk 0 embedded + later chunks dropped => document is STILL pending", async () => {
+    // Upstream dies after the first chunk of the document.
+    const dropping = new DroppingProvider(1);
+    await generateEmbeddings(store, { embedProvider: dropping });
+
+    const expected = recordedChunkCount("hashPartial");
+    expect(expected).toBeDefined();
+    expect(expected!).toBeGreaterThan(1); // the body really did chunk into several pieces
+    expect(vectorCount("hashPartial")).toBe(1); // ... and only chunk 0 survived
+
+    // THE assertion. Before the fix this was 0: chunk 0 exists, so the document
+    // read as complete and its missing chunks were unreachable forever.
+    expect(getHashesNeedingEmbedding(store.db)).toBe(1);
+  });
+
+  test("a later pass with a healthy upstream fills the missing chunks", async () => {
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
+    const expected = recordedChunkCount("hashPartial")!;
+    expect(vectorCount("hashPartial")).toBe(1);
+
+    const healed = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+
+    expect(healed.docsProcessed).toBe(1);
+    expect(vectorCount("hashPartial")).toBe(expected);
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+  });
+
+  test("CONTROL: a fully embedded document is NOT re-selected", async () => {
+    const first = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+    expect(first.docsProcessed).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+
+    // The whole point of the control: without it, "re-select partial documents"
+    // could be satisfied by re-selecting EVERYTHING, i.e. re-embedding the corpus
+    // on every cron tick.
+    const second = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+    expect(second.docsProcessed).toBe(0);
+    expect(second.chunksEmbedded).toBe(0);
+  });
+
+  test("CONTROL: a legacy document with no recorded chunk count is left alone", async () => {
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+
+    // Simulate a document embedded before document_chunk_counts existed, whose
+    // later chunks are missing: no expectation is recorded, so it cannot be
+    // detected as partial. It is deliberately NOT re-selected — a whole-corpus
+    // re-embed on upgrade would cost more than the holes it heals; `--force`
+    // (or the next re-chunk) is the repair path.
+    store.db.prepare(`DELETE FROM document_chunk_counts WHERE hash = ?`).run("hashPartial");
+    store.db.prepare(`DELETE FROM content_vectors WHERE hash = ? AND seq > 0`).run("hashPartial");
+
+    expect(vectorCount("hashPartial")).toBe(1);
+    expect(recordedChunkCount("hashPartial")).toBeUndefined();
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+  });
+
+  test("the collection-filtered count agrees with the unfiltered one", async () => {
+    // The two queries drifting apart is what produced this bug: they each
+    // carried their own copy of the pending predicate.
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
+
+    expect(getHashesNeedingEmbedding(store.db)).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db, "alpha")).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db, "nonexistent")).toBe(0);
+  });
+});