mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
b5e2a1f485
Security fixes: - Add explicit permissions to migration-validation.yml (fixes #37, #38, #39) - analyze-duplicates job: contents:read, issues:write, pull-requests:write - validate-migration & check-migration-compliance: contents:read Issue #109 - RuVector & AgentDB v2 Integration: - Add ReflexionMemoryAdapter for flaky test prediction (410 lines) - Add SparseVectorSearch for hybrid BM25/vector search (174 lines) - Add TieredCompression for 85% memory reduction (328 lines) - Add comprehensive test suites for all memory adapters Issue #108 - CI Pipeline fixes: - Configure jest-junit reporter for CI integration - Fix FleetManager.database.test.ts flaky tests - Add mcp-tools-test workflow permissions Also includes: - Hackathon TV5 issue report and reliability analysis - Security scan report - Code complexity analysis script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
/**
|
|
* Unit Tests for SparseVectorSearch
|
|
*
|
|
* Tests BM25 scoring, hybrid search, and reciprocal rank fusion
|
|
* with real implementations (no mocks).
|
|
*/
|
|
|
|
import {
|
|
BM25Scorer,
|
|
HybridSearcher,
|
|
reciprocalRankFusion,
|
|
SparseVector,
|
|
BM25Config,
|
|
HybridResult,
|
|
} from '../../../src/core/memory/SparseVectorSearch';
|
|
|
|
describe('BM25Scorer', () => {
|
|
describe('tokenize()', () => {
|
|
it('should tokenize text into lowercase terms', () => {
|
|
const scorer = new BM25Scorer();
|
|
const tokens = scorer.tokenize('Hello World Test');
|
|
|
|
expect(tokens).toEqual(['hello', 'world', 'test']);
|
|
});
|
|
|
|
it('should remove punctuation and special characters', () => {
|
|
const scorer = new BM25Scorer();
|
|
const tokens = scorer.tokenize('Hello, World! This is a test.');
|
|
|
|
expect(tokens).toEqual(['hello', 'world', 'this', 'test']);
|
|
});
|
|
|
|
it('should filter out short terms (length <= 2)', () => {
|
|
const scorer = new BM25Scorer();
|
|
const tokens = scorer.tokenize('a is at the go big test');
|
|
|
|
expect(tokens).toEqual(['the', 'big', 'test']);
|
|
});
|
|
|
|
it('should handle empty string', () => {
|
|
const scorer = new BM25Scorer();
|
|
const tokens = scorer.tokenize('');
|
|
|
|
expect(tokens).toEqual([]);
|
|
});
|
|
|
|
it('should handle multiple whitespace', () => {
|
|
const scorer = new BM25Scorer();
|
|
const tokens = scorer.tokenize('hello world test');
|
|
|
|
expect(tokens).toEqual(['hello', 'world', 'test']);
|
|
});
|
|
});
|
|
|
|
describe('buildSparseVector()', () => {
|
|
it('should build sparse vector with term frequencies', () => {
|
|
const scorer = new BM25Scorer();
|
|
const vector = scorer.buildSparseVector('test test hello world');
|
|
|
|
expect(vector.terms.get('test')).toBe(2);
|
|
expect(vector.terms.get('hello')).toBe(1);
|
|
expect(vector.terms.get('world')).toBe(1);
|
|
});
|
|
|
|
it('should calculate correct L2 norm', () => {
|
|
const scorer = new BM25Scorer();
|
|
const vector = scorer.buildSparseVector('test test hello');
|
|
|
|
// Expected norm: sqrt(2^2 + 1^2) = sqrt(5) ≈ 2.236
|
|
expect(vector.norm).toBeCloseTo(Math.sqrt(5), 2);
|
|
});
|
|
|
|
it('should handle single term', () => {
|
|
const scorer = new BM25Scorer();
|
|
const vector = scorer.buildSparseVector('hello');
|
|
|
|
expect(vector.terms.get('hello')).toBe(1);
|
|
expect(vector.norm).toBe(1);
|
|
});
|
|
|
|
it('should return empty vector for empty text', () => {
|
|
const scorer = new BM25Scorer();
|
|
const vector = scorer.buildSparseVector('');
|
|
|
|
expect(vector.terms.size).toBe(0);
|
|
expect(vector.norm).toBe(0);
|
|
});
|
|
|
|
it('should count repeated terms correctly', () => {
|
|
const scorer = new BM25Scorer();
|
|
const vector = scorer.buildSparseVector('the the the test test hello');
|
|
|
|
expect(vector.terms.get('the')).toBe(3);
|
|
expect(vector.terms.get('test')).toBe(2);
|
|
expect(vector.terms.get('hello')).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('indexDocument()', () => {
|
|
it('should update term document frequencies', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'hello world test');
|
|
scorer.indexDocument('doc2', 'hello universe test');
|
|
scorer.indexDocument('doc3', 'goodbye world');
|
|
|
|
// 'hello' appears in 2 docs, 'world' in 2 docs, 'test' in 2 docs
|
|
// We can verify by scoring - terms in more docs get lower IDF
|
|
const query = scorer.buildSparseVector('hello');
|
|
const doc = scorer.buildSparseVector('hello world');
|
|
|
|
const score = scorer.score(query, doc, 11);
|
|
expect(score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should track document count', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'test document one');
|
|
scorer.indexDocument('doc2', 'test document two');
|
|
scorer.indexDocument('doc3', 'test document three');
|
|
|
|
// Document count affects IDF calculation
|
|
const query = scorer.buildSparseVector('test');
|
|
const doc = scorer.buildSparseVector('test');
|
|
|
|
const score = scorer.score(query, doc, 4);
|
|
expect(score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should update average document length', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'short');
|
|
scorer.indexDocument('doc2', 'this is a much longer document');
|
|
|
|
// Average length affects length normalization in BM25
|
|
const query = scorer.buildSparseVector('test');
|
|
const doc = scorer.buildSparseVector('test');
|
|
|
|
const shortScore = scorer.score(query, doc, 5);
|
|
const longScore = scorer.score(query, doc, 30);
|
|
|
|
// Longer documents get penalized
|
|
expect(shortScore).toBeGreaterThan(longScore);
|
|
});
|
|
});
|
|
|
|
describe('score()', () => {
|
|
it('should calculate BM25 score for matching terms', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'machine learning artificial intelligence');
|
|
scorer.indexDocument('doc2', 'natural language processing');
|
|
scorer.indexDocument('doc3', 'deep learning neural networks');
|
|
|
|
const query = scorer.buildSparseVector('machine learning');
|
|
const doc = scorer.buildSparseVector('machine learning deep learning');
|
|
|
|
const score = scorer.score(query, doc, 34);
|
|
expect(score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return zero for non-matching terms', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'hello world');
|
|
|
|
const query = scorer.buildSparseVector('python programming');
|
|
const doc = scorer.buildSparseVector('hello world');
|
|
|
|
const score = scorer.score(query, doc, 11);
|
|
expect(score).toBe(0);
|
|
});
|
|
|
|
it('should rank exact matches higher than partial matches', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
scorer.indexDocument('doc1', 'test document');
|
|
scorer.indexDocument('doc2', 'another document');
|
|
scorer.indexDocument('doc3', 'final document');
|
|
|
|
const query = scorer.buildSparseVector('test document');
|
|
const exactMatch = scorer.buildSparseVector('test document');
|
|
const partialMatch = scorer.buildSparseVector('test other');
|
|
|
|
const exactScore = scorer.score(query, exactMatch, 13);
|
|
const partialScore = scorer.score(query, partialMatch, 10);
|
|
|
|
expect(exactScore).toBeGreaterThan(partialScore);
|
|
});
|
|
|
|
it('should handle custom BM25 parameters', () => {
|
|
const scorer = new BM25Scorer({ k1: 2.0, b: 0.5 });
|
|
|
|
scorer.indexDocument('doc1', 'test document');
|
|
|
|
const query = scorer.buildSparseVector('test');
|
|
const doc = scorer.buildSparseVector('test test test');
|
|
|
|
const score = scorer.score(query, doc, 14);
|
|
expect(score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should penalize very common terms through IDF', () => {
|
|
const scorer = new BM25Scorer();
|
|
|
|
// Index 'common' in many documents
|
|
for (let i = 0; i < 10; i++) {
|
|
scorer.indexDocument(`doc${i}`, 'common term document');
|
|
}
|
|
|
|
// Index 'rare' in only one document
|
|
scorer.indexDocument('doc10', 'rare unique term');
|
|
|
|
const commonQuery = scorer.buildSparseVector('common');
|
|
const rareQuery = scorer.buildSparseVector('rare');
|
|
const doc = scorer.buildSparseVector('common rare term');
|
|
|
|
const commonScore = scorer.score(commonQuery, doc, 16);
|
|
const rareScore = scorer.score(rareQuery, doc, 16);
|
|
|
|
// Rare terms should score higher due to higher IDF
|
|
expect(rareScore).toBeGreaterThan(commonScore);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('HybridSearcher', () => {
|
|
describe('indexPattern()', () => {
|
|
it('should index pattern for sparse search', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('pattern1', 'machine learning algorithms');
|
|
searcher.indexPattern('pattern2', 'natural language processing');
|
|
|
|
const results = searcher.searchSparse('machine learning', 10);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0].id).toBe('pattern1');
|
|
});
|
|
|
|
it('should allow multiple patterns with same terms', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('p1', 'test pattern one');
|
|
searcher.indexPattern('p2', 'test pattern two');
|
|
searcher.indexPattern('p3', 'test pattern three');
|
|
|
|
const results = searcher.searchSparse('test pattern', 10);
|
|
|
|
expect(results.length).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('searchSparse()', () => {
|
|
it('should return top-k results sorted by BM25 score', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('p1', 'machine learning deep neural networks');
|
|
searcher.indexPattern('p2', 'machine learning algorithms');
|
|
searcher.indexPattern('p3', 'natural language processing');
|
|
searcher.indexPattern('p4', 'computer vision image recognition');
|
|
|
|
const results = searcher.searchSparse('machine learning', 3);
|
|
|
|
expect(results.length).toBeLessThanOrEqual(3);
|
|
expect(results[0].score).toBeGreaterThanOrEqual(results[1]?.score || 0);
|
|
|
|
// Should rank documents with exact matches higher
|
|
const topIds = results.map(r => r.id);
|
|
expect(topIds).toContain('p1');
|
|
expect(topIds).toContain('p2');
|
|
});
|
|
|
|
it('should return empty array when no matches found', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('p1', 'hello world');
|
|
searcher.indexPattern('p2', 'goodbye universe');
|
|
|
|
const results = searcher.searchSparse('python programming', 10);
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it('should limit results to k items', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
searcher.indexPattern(`p${i}`, `test pattern number ${i}`);
|
|
}
|
|
|
|
const results = searcher.searchSparse('test pattern', 5);
|
|
|
|
expect(results.length).toBe(5);
|
|
});
|
|
|
|
it('should rank by relevance not just term presence', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('exact', 'machine learning models');
|
|
searcher.indexPattern('partial', 'machine and learning are important');
|
|
searcher.indexPattern('single', 'machine tools');
|
|
|
|
const results = searcher.searchSparse('machine learning models', 10);
|
|
|
|
expect(results[0].id).toBe('exact');
|
|
});
|
|
|
|
it('should handle empty query', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('p1', 'test document');
|
|
|
|
const results = searcher.searchSparse('', 10);
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('hybridSearch()', () => {
|
|
it('should combine dense and sparse results using RRF', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('doc1', 'machine learning algorithms');
|
|
searcher.indexPattern('doc2', 'deep learning neural networks');
|
|
searcher.indexPattern('doc3', 'natural language processing');
|
|
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.95 },
|
|
{ id: 'doc3', score: 0.85 },
|
|
];
|
|
|
|
const results = searcher.hybridSearch('machine learning', denseResults, 3);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0]).toHaveProperty('denseScore');
|
|
expect(results[0]).toHaveProperty('sparseScore');
|
|
expect(results[0]).toHaveProperty('fusedScore');
|
|
});
|
|
|
|
it('should boost items appearing in both dense and sparse results', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
searcher.indexPattern('doc1', 'machine learning optimization');
|
|
searcher.indexPattern('doc2', 'random unrelated content');
|
|
searcher.indexPattern('doc3', 'machine learning basics');
|
|
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.90 },
|
|
{ id: 'doc2', score: 0.85 },
|
|
];
|
|
|
|
const results = searcher.hybridSearch('machine learning', denseResults, 3);
|
|
|
|
// doc1 should rank highest as it's in both results
|
|
const doc1Result = results.find(r => r.id === 'doc1');
|
|
expect(doc1Result).toBeDefined();
|
|
expect(doc1Result!.denseScore).toBeGreaterThan(0);
|
|
expect(doc1Result!.sparseScore).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should respect k limit', () => {
|
|
const searcher = new HybridSearcher();
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
searcher.indexPattern(`doc${i}`, `test document ${i}`);
|
|
}
|
|
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.9 },
|
|
{ id: 'doc2', score: 0.8 },
|
|
];
|
|
|
|
const results = searcher.hybridSearch('test document', denseResults, 5);
|
|
|
|
expect(results.length).toBeLessThanOrEqual(5);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('reciprocalRankFusion()', () => {
|
|
it('should merge overlapping results with combined RRF scores', () => {
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.95 },
|
|
{ id: 'doc2', score: 0.85 },
|
|
{ id: 'doc3', score: 0.75 },
|
|
];
|
|
|
|
const sparseResults = [
|
|
{ id: 'doc2', score: 12.5 },
|
|
{ id: 'doc3', score: 10.0 },
|
|
{ id: 'doc4', score: 8.5 },
|
|
];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
// doc2 and doc3 appear in both, should have higher fused scores
|
|
const doc2Result = results.find(r => r.id === 'doc2')!;
|
|
const doc1Result = results.find(r => r.id === 'doc1')!;
|
|
|
|
expect(doc2Result.fusedScore).toBeGreaterThan(doc1Result.fusedScore);
|
|
expect(doc2Result.denseScore).toBe(0.85);
|
|
expect(doc2Result.sparseScore).toBe(12.5);
|
|
});
|
|
|
|
it('should handle non-overlapping results', () => {
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.95 },
|
|
{ id: 'doc2', score: 0.85 },
|
|
];
|
|
|
|
const sparseResults = [
|
|
{ id: 'doc3', score: 12.5 },
|
|
{ id: 'doc4', score: 10.0 },
|
|
];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
expect(results.length).toBe(4);
|
|
|
|
const doc1 = results.find(r => r.id === 'doc1')!;
|
|
const doc3 = results.find(r => r.id === 'doc3')!;
|
|
|
|
expect(doc1.denseScore).toBe(0.95);
|
|
expect(doc1.sparseScore).toBe(0);
|
|
expect(doc3.denseScore).toBe(0);
|
|
expect(doc3.sparseScore).toBe(12.5);
|
|
});
|
|
|
|
it('should calculate RRF scores correctly', () => {
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 1.0 },
|
|
];
|
|
|
|
const sparseResults = [
|
|
{ id: 'doc2', score: 1.0 },
|
|
];
|
|
|
|
const k = 60;
|
|
const results = reciprocalRankFusion(denseResults, sparseResults, k);
|
|
|
|
// RRF score = 1/(k + rank + 1) = 1/61 for rank 0
|
|
expect(results[0].fusedScore).toBeCloseTo(1/61, 5);
|
|
});
|
|
|
|
it('should rank by fused score descending', () => {
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.5 },
|
|
{ id: 'doc2', score: 0.4 },
|
|
];
|
|
|
|
const sparseResults = [
|
|
{ id: 'doc2', score: 10.0 },
|
|
{ id: 'doc3', score: 8.0 },
|
|
];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
// Verify descending order
|
|
for (let i = 0; i < results.length - 1; i++) {
|
|
expect(results[i].fusedScore).toBeGreaterThanOrEqual(results[i + 1].fusedScore);
|
|
}
|
|
|
|
// doc2 appears in both, should rank first
|
|
expect(results[0].id).toBe('doc2');
|
|
});
|
|
|
|
it('should use custom k parameter', () => {
|
|
const denseResults = [{ id: 'doc1', score: 1.0 }];
|
|
const sparseResults: Array<{ id: string; score: number }> = [];
|
|
|
|
const k = 100;
|
|
const results = reciprocalRankFusion(denseResults, sparseResults, k);
|
|
|
|
// RRF score = 1/(k + rank + 1) = 1/101
|
|
expect(results[0].fusedScore).toBeCloseTo(1/101, 5);
|
|
});
|
|
|
|
it('should handle empty dense results', () => {
|
|
const denseResults: Array<{ id: string; score: number }> = [];
|
|
const sparseResults = [
|
|
{ id: 'doc1', score: 10.0 },
|
|
{ id: 'doc2', score: 8.0 },
|
|
];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
expect(results.length).toBe(2);
|
|
expect(results[0].denseScore).toBe(0);
|
|
expect(results[0].sparseScore).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle empty sparse results', () => {
|
|
const denseResults = [
|
|
{ id: 'doc1', score: 0.95 },
|
|
{ id: 'doc2', score: 0.85 },
|
|
];
|
|
const sparseResults: Array<{ id: string; score: number }> = [];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
expect(results.length).toBe(2);
|
|
expect(results[0].sparseScore).toBe(0);
|
|
expect(results[0].denseScore).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle both empty results', () => {
|
|
const denseResults: Array<{ id: string; score: number }> = [];
|
|
const sparseResults: Array<{ id: string; score: number }> = [];
|
|
|
|
const results = reciprocalRankFusion(denseResults, sparseResults);
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|