diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index b246f8b5..94fa4906 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -1434,13 +1434,31 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Write to column store — the single write path for all indexed data. // Converts extracted fields into a map and feeds the column store. + // + // extractIndexableFields emits ONE {field,value} entry per array element for + // a multi-valued field (e.g. tags:['a','b','c'] → three 'tags' entries). We + // must accumulate every repeated field into an array, not just __words__: + // columnStore.addEntity expands an array value to one indexed entry per + // element, so a scalar overwrite (last-value-wins) would index only the final + // element and `contains` would miss the rest. if (this.columnStore) { const entityIntId = this.idMapper.getOrAssign(id) const fieldsMap: Record = {} for (const { field, value } of fields) { if (field === '__words__') { + // Always an array (keeps the field's multiValue manifest flag set even + // for a single-word document). if (!fieldsMap.__words__) fieldsMap.__words__ = [] - ;(fieldsMap.__words__ as number[]).push(value) + ;(fieldsMap.__words__ as unknown[]).push(value) + } else if (field in fieldsMap) { + // Repeated field → multi-valued. Promote the scalar to an array on the + // second occurrence, then accumulate. + const existing = fieldsMap[field] + if (Array.isArray(existing)) { + ;(existing as unknown[]).push(value) + } else { + fieldsMap[field] = [existing, value] + } } else { fieldsMap[field] = value } diff --git a/tests/integration/find-unified-integration.test.ts b/tests/integration/find-unified-integration.test.ts index baeae0f6..f826aaa0 100644 --- a/tests/integration/find-unified-integration.test.ts +++ b/tests/integration/find-unified-integration.test.ts @@ -251,17 +251,19 @@ describe('Unified Find() Integration Tests', () => { }) // 8.0 uses the BFO `contains` operator for array membership (not Mongo `$contains`). - // Correct input: tags = ['test', 'integration', 'qa'], querying for 'test'. - // Expected: the entity is returned (its tags array contains 'test'). - const results = await brain.find({ - where: { tags: { contains: 'test' } } - }) - - expect(results.length).toBeGreaterThan(0) - results.forEach((result: any) => { - expect(Array.isArray(result.entity.metadata?.tags)).toBe(true) - expect(result.entity.metadata.tags).toContain('test') - }) + // A.4 regression guard: query EVERY element — first ('test'), middle + // ('integration'), and last ('qa'). Before the fix only the LAST element + // was indexed (fieldsMap last-value-wins), so 'test'/'integration' missed. + for (const tag of ['test', 'integration', 'qa']) { + const results = await brain.find({ + where: { tags: { contains: tag } } + }) + expect(results.length).toBeGreaterThan(0) + results.forEach((result: any) => { + expect(Array.isArray(result.entity.metadata?.tags)).toBe(true) + expect(result.entity.metadata.tags).toContain(tag) + }) + } }) it('should support complex logical operators', async () => { @@ -757,14 +759,24 @@ describe('Unified Find() Integration Tests', () => { describe('4. Edge Cases', () => { describe('Empty Results Handling', () => { - it('should handle empty vector search results', async () => { + it('should handle a nonsense vector query without error', async () => { + // A pure vector query has no notion of "no match": cosine similarity + // always ranks the corpus, so a nonsense string returns the nearest + // neighbours (≤ limit), not an empty set. Tier-1 asserts the structural + // contract (well-formed array, bounded by limit); whether a nonsense + // query SHOULD rank low is a Tier-2 semantic-relevance concern. The + // genuinely-empty cases are the graph/field tests below. const results = await brain.find({ query: 'nonexistent_content_xyz123', limit: 5 }) expect(Array.isArray(results)).toBe(true) - expect(results.length).toBe(0) + expect(results.length).toBeLessThanOrEqual(5) + results.forEach((r: any) => { + expect(r.id).toBeDefined() + expect(typeof r.score).toBe('number') + }) }) it('should handle empty graph search results', async () => { @@ -790,19 +802,23 @@ describe('Unified Find() Integration Tests', () => { expect(results.length).toBe(0) }) - it('should handle mixed empty and non-empty results', async () => { + it('hard-ANDs signals — an empty graph signal zeros the intersection', async () => { + // find() is a hard AND across its signals (vector ∩ graph ∩ metadata): + // the result is the INTERSECTION, not a union. Here the vector ('person') + // and field (age >= 25) signals match entities, but `connected.from` is a + // nonexistent entity → empty graph set → the whole intersection is empty. + // (For a soft RRF UNION ranking, use getTripleIntelligence().find().) const results = await brain.find({ - query: 'person', // Should find results + query: 'person', connected: { - from: 'nonexistent_entity' // Should find no results + from: 'nonexistent_entity' // empty graph signal }, - where: { age: { gte: 25 } }, // Should find results + where: { age: { gte: 25 } }, limit: 5 }) expect(Array.isArray(results)).toBe(true) - expect(results.length).toBeGreaterThan(0) - // Should still return results from vector and field searches + expect(results.length).toBe(0) }) }) @@ -950,25 +966,37 @@ describe('Unified Find() Integration Tests', () => { type: VerbType.RelatedTo }) - // Perform unified search + // Perform unified search. find() hard-ANDs its three signals (vector ∩ + // graph ∩ metadata), so the target entity must appear in ALL of them. + // The graph anchor is 'earth' (NOT complexEntityId): a `connected:{from:X}` + // filter returns X's NEIGHBOURS, never X itself, and we related the ML + // concept → earth above, so earth's bidirectional neighbours include it. const results = await brain.find({ query: 'artificial intelligence learning', connected: { - from: complexEntityId, + from: 'earth', direction: 'both' }, where: { category: 'AI', - tags: { $contains: 'AI' } + // 8.0 array-membership operator is `contains` (not Mongo `$contains`). + // A.4 fix: tags:['AI','ML','data science'] now indexes every element, + // so `contains:'AI'` matches (previously only the last element was kept). + tags: { contains: 'AI' } }, limit: 5 }) expect(results.length).toBeGreaterThan(0) - // Should find the complex entity with high relevance + // The ML concept satisfies all three signals, so it is in the result set. const mlResult = results.find((r: any) => r.id === complexEntityId) expect(mlResult).toBeDefined() - expect(mlResult!.score).toBeGreaterThan(0.5) + // Multi-signal find() scores via reciprocal-rank fusion (k≈60), so the + // score is a small fusion rank (~1/61), NOT a [0,1] cosine similarity — + // asserting a relevance magnitude here is a Tier-2 concern. Tier-1 checks + // only that a matched row carries a valid positive fusion score. + expect(typeof mlResult!.score).toBe('number') + expect(mlResult!.score).toBeGreaterThan(0) }) })