GitNexus/gitnexus/test/unit/hybrid-search.test.ts
Gergő Magyar cae60c2bd4
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(mcp): reject corrupt query identities and refresh index metadata (#3571)
* fix(mcp): reject corrupt query identities and refresh index metadata

* fix(mcp): preserve reader and search integrity

Keep replaced readers unknown until matching metadata is published.
Defer shared-handle refresh until other consumers release the old graph
to avoid rebuilding connections against the same stale generation.

Propagate identity corruption after both hybrid search lanes settle,
so the sibling cannot query after its database session is released.
Select the regressions for the OS matrix and retain Python coverage
without optional Swift grammars.

Validation: 393 focused tests, nine native generation tests and ten
race executions across five fresh processes pass. Build and source/test
typechecks pass. The early full run passed 26,096 tests and failed eight:
seven reproduce on baseline; the mixed-source publication case passes
in clean reruns. Windows/macOS native execution remains for CI.

Graph analysis maps all edited production symbols; coverage remains
partial for the anonymous FTS test callback, manually reviewed.

Refs #3571

* fix(lbug): bump @ladybugdb/core to 0.21.2 to stop doubled rows after delete and re-copy

LadybugDB 0.21.0 and 0.21.1 return every row of a table that spans more
than one native vector twice once the incremental writeback has DETACH
DELETEd it and a COPY into an earlier table has run. NodeGroup::scan let a
morsel that ended before the group's first live row wrap an unsigned
subtraction and rescan to the end of the group (LadybugDB #1085, fixed in
0.21.2). No primary key error fires. The identity guard from #3442 stops
the run with "unexpected or duplicate ID", so a Swift edit that pulls every
importer into the write set failed on repos with more than 2048 structs.
Only unreleased main was affected: 1.6.12 ships 0.18.3.

Pin 0.21.2 in package.json, update the six @ladybugdb/core entries in the
lockfile, and move coreVersion in the vendored FTS manifest. The FTS
extension stays at 0.21.0 because no v0.21.1 or v0.21.2 artifact is
published; the pairing assertion and its unit tests pass.

Add a regression to the delete-nodes-for-files suite: 2049 structs with
CONTAINS edges are loaded, every file is deleted, the graph is loaded
again, and each id and edge must appear exactly once.

Verification (Linux, Node 22.18):
- lbug-delete-nodes-for-files: 11/11 on 0.21.2. With the 0.21.1 native
  packages swapped in, the new test fails with 4098 rows instead of 2049.
- Reporter recipe end to end on the built CLI: incremental analyze exits 0
  with no duplicate ids on a Swift-heavy fixture and on a 70-file Python
  fixture, with and without FTS. On 0.21.1 the Swift fixture exits 1.
- A 0.21.1-written database opens and reads under 0.21.2.
- prettier, eslint and typecheck:tests are clean on the changed files.
- The full lbug-db project had not finished when this was committed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test(fts): say what the Windows warm-session branch covers

On Windows the repair step calls poolClose, which removes the pool entry
before the in-place writer opens. The step therefore checks the backend's
cached capability state, not a live pool connection. The comment now says so
instead of implying the pool stays warm. No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* revert: take the @ladybugdb/core 0.21.2 bump out of this PR

Reverts 0cdc3dfe3. The bump fixes LadybugDB 0.21.0 and 0.21.1 returning every
row of a large table twice after a delete and re-copy, but on Windows it
crashes the vendored FTS extension, which is still the v0.21.0 build: four of
eight Windows shards failed with exit code 3221225477 (0xC0000005) in tests
that load libfts and build an FTS index. All eight passed on 9d79092ab with
core 0.21.1. A Windows extension is only published for v0.21.0, not for
v0.21.1 or v0.21.2.

The bump moves to its own draft PR (#3578) until a matching Windows
extension exists. This PR returns to its original query-integrity and
index-freshness scope.

Restores gitnexus/package.json, the six @ladybugdb/core entries in
package-lock.json, the coreVersion in vendor/lbug-fts/manifest.json, and
removes the regression test that belongs with the bump. All four files are
identical to origin/main (1b4e3f0a8) after this commit.

Verification:
- git diff origin/main for the four files is empty.
- fts-artifact-coverage and vendored-extension-path unit tests pass (31),
  and assert-publish-fts-coverage reports core 0.21.1 paired with extension
  0.21.0.
- The graph check on the staged revert was partial, because it only deletes
  test code. The forward change was gated clean: 4 files, 6 symbols, all in
  that test, no affected processes.
- Windows CI has not run on this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:06:26 +01:00

286 lines
10 KiB
TypeScript

/**
* P1 Unit Tests: Hybrid Search (mergeWithRRF + hybridSearch)
*
* Tests: mergeWithRRF from hybrid-search.ts
* - BM25-only merge
* - Semantic-only merge
* - Combined ranking
* - Limit parameter
* - Empty inputs
* - Undefined/null inputs (#1489)
*
* Tests: hybridSearch fallback when FTS unavailable (#1489)
*/
import { describe, it, expect, vi } from 'vitest';
import { mergeWithRRF, hybridSearch } from '../../src/core/search/hybrid-search.js';
import type { BM25SearchResult } from '../../src/core/search/bm25-index.js';
import type { SemanticSearchResult } from '../../src/core/embeddings/types.js';
import { SymbolIdentityError } from '../../src/core/lbug/query-result-integrity.js';
vi.mock('../../src/core/search/bm25-index.js', async (importOriginal) => {
const actual = (await importOriginal()) as any;
return { ...actual, searchFTSFromLbug: vi.fn() };
});
let bm25Rank = 0;
function makeBM25(filePath: string, score: number): BM25SearchResult {
return { filePath, score, rank: ++bm25Rank };
}
function makeSemantic(filePath: string, distance: number): SemanticSearchResult {
return {
filePath,
distance,
nodeId: `node:${filePath}`,
name: filePath
.split('/')
.pop()!
.replace(/\.\w+$/, ''),
label: 'Function',
startLine: 1,
endLine: 10,
};
}
describe('mergeWithRRF', () => {
it('handles empty inputs', () => {
const result = mergeWithRRF([], []);
expect(result).toHaveLength(0);
});
it('handles BM25-only results', () => {
const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10), makeBM25('src/b.ts', 5)];
const result = mergeWithRRF(bm25, []);
expect(result).toHaveLength(2);
expect(result[0].filePath).toBe('src/a.ts');
expect(result[0].sources).toEqual(['bm25']);
expect(result[0].rank).toBe(1);
expect(result[1].rank).toBe(2);
});
it('handles semantic-only results', () => {
const semantic: SemanticSearchResult[] = [
makeSemantic('src/a.ts', 0.1),
makeSemantic('src/b.ts', 0.2),
];
const result = mergeWithRRF([], semantic);
expect(result).toHaveLength(2);
expect(result[0].filePath).toBe('src/a.ts');
expect(result[0].sources).toEqual(['semantic']);
});
it('combined: shared results get higher score', () => {
const bm25: BM25SearchResult[] = [
makeBM25('src/shared.ts', 10),
makeBM25('src/bm25-only.ts', 5),
];
const semantic: SemanticSearchResult[] = [
makeSemantic('src/shared.ts', 0.1),
makeSemantic('src/semantic-only.ts', 0.2),
];
const result = mergeWithRRF(bm25, semantic);
// Shared result should be ranked first (higher combined RRF score)
expect(result[0].filePath).toBe('src/shared.ts');
expect(result[0].sources).toContain('bm25');
expect(result[0].sources).toContain('semantic');
// Its score should be higher than any single-source result
expect(result[0].score).toBeGreaterThan(result[1].score);
});
it('respects limit parameter', () => {
const bm25: BM25SearchResult[] = Array.from({ length: 20 }, (_, i) =>
makeBM25(`src/${i}.ts`, 100 - i),
);
const result = mergeWithRRF(bm25, [], 5);
expect(result).toHaveLength(5);
});
it('default limit is 10', () => {
const bm25: BM25SearchResult[] = Array.from({ length: 20 }, (_, i) =>
makeBM25(`src/${i}.ts`, 100 - i),
);
const result = mergeWithRRF(bm25, []);
expect(result).toHaveLength(10);
});
it('assigns ranks starting from 1', () => {
const bm25: BM25SearchResult[] = [
makeBM25('src/a.ts', 10),
makeBM25('src/b.ts', 5),
makeBM25('src/c.ts', 1),
];
const result = mergeWithRRF(bm25, []);
expect(result.map((r) => r.rank)).toEqual([1, 2, 3]);
});
it('preserves semantic metadata on shared results', () => {
const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10)];
const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.1)];
const result = mergeWithRRF(bm25, semantic);
expect(result[0].nodeId).toBe('node:src/a.ts');
expect(result[0].name).toBe('a');
expect(result[0].label).toBe('Function');
});
it('stores original scores for debugging', () => {
const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 15)];
const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.3)];
const result = mergeWithRRF(bm25, semantic);
expect(result[0].bm25Score).toBe(15);
expect(result[0].semanticScore).toBeCloseTo(0.7); // 1 - distance
});
// Regression: #1489 — bm25Results is not iterable when FTS unavailable
it('does not crash when bm25Results is undefined (#1489)', () => {
const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.1)];
// Force undefined to simulate the crash path where FTS returns unexpected shape
const result = mergeWithRRF(undefined as any, semantic);
expect(result).toHaveLength(1);
expect(result[0].filePath).toBe('src/a.ts');
expect(result[0].sources).toEqual(['semantic']);
});
it('does not crash when semanticResults is undefined (#1489)', () => {
const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10)];
const result = mergeWithRRF(bm25, undefined as any);
expect(result).toHaveLength(1);
expect(result[0].filePath).toBe('src/a.ts');
expect(result[0].sources).toEqual(['bm25']);
});
it('does not crash when both inputs are undefined (#1489)', () => {
const result = mergeWithRRF(undefined as any, undefined as any);
expect(result).toHaveLength(0);
});
});
// Regression: #1489 — hybridSearch must not crash when FTS is unavailable
describe('hybridSearch — FTS failure fallback (#1489)', () => {
it.each(['keyword', 'semantic'] as const)(
'waits for the sibling lane before rejecting %s identity corruption',
async (failingLane) => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
const error = new SymbolIdentityError();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const semantic = vi.fn(async () => {
await gate;
return [];
});
if (failingLane === 'keyword') {
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(error);
} else {
vi.mocked(searchFTSFromLbug).mockImplementationOnce(async () => {
await gate;
return { results: [], ftsAvailable: true };
});
semantic.mockRejectedValueOnce(error);
}
let settled = false;
const outcome = hybridSearch('test query', 10, vi.fn().mockResolvedValue([]), semantic).then(
() => {
settled = true;
return null;
},
(failure) => {
settled = true;
return failure;
},
);
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
} finally {
release();
}
expect(await outcome).toBe(error);
},
);
it.each(['keyword', 'semantic'] as const)(
'rejects corrupt %s identities even when the other search lane succeeds',
async (lane) => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
const error = new SymbolIdentityError();
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
const mockSemanticSearch = vi.fn();
if (lane === 'keyword') {
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(error);
mockSemanticSearch.mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]);
} else {
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({
results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }],
ftsAvailable: true,
});
mockSemanticSearch.mockRejectedValue(error);
}
await expect(
hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch),
).rejects.toBe(error);
},
);
it('falls back to semantic-only when searchFTSFromLbug throws', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable'));
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
const mockSemanticSearch = vi
.fn()
.mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]);
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
expect(results).toHaveLength(1);
expect(results[0].filePath).toBe('src/semantic-hit.ts');
expect(results[0].sources).toEqual(['semantic']);
});
it('returns empty when both FTS and semantic return nothing', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('FTS unavailable'));
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
const mockSemanticSearch = vi.fn().mockResolvedValue([]);
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
expect(results).toHaveLength(0);
});
it('works normally when FTS succeeds', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({
results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }],
ftsAvailable: true,
});
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
const mockSemanticSearch = vi.fn().mockResolvedValue([]);
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
expect(results).toHaveLength(1);
expect(results[0].filePath).toBe('src/fts-hit.ts');
expect(results[0].sources).toEqual(['bm25']);
});
it('keeps BM25 results when semantic search throws', async () => {
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({
results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }],
ftsAvailable: true,
});
const mockExecuteQuery = vi.fn().mockResolvedValue([]);
const mockSemanticSearch = vi.fn().mockRejectedValue(new Error('sidecar aborted'));
const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch);
expect(results).toHaveLength(1);
expect(results[0].filePath).toBe('src/fts-hit.ts');
expect(results[0].sources).toEqual(['bm25']);
});
});