This repository has been archived on 2026-09-03. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
open-brainy/tests/integration/metadata-vector-exclusion.test.ts

341 lines
12 KiB
TypeScript
Raw Normal View History

/**
* Integration test for metadata explosion fix
*
* Validates that vector embeddings (and array-index-as-object-key payloads) are
* NEVER indexed as metadata fields, while legitimate small arrays and scalar
* fields ARE registered for `where` filtering.
*
* Original bug class: 825,924 chunk files created for 1,144 entities (721 files
* per entity) because each of the 384 vector dimensions was being indexed as its
* own numeric metadata field.
*
* 8.0 architecture note: the per-vector-dimension chunk explosion is structurally
* impossible now the metadata index is a column store + sparse field index
* (`_column_index/<field>/…` and `_system/idx/**`), not one `__chunk__*` file per
* field-value. So this suite no longer counts `_system/__chunk__*` files (there
* are none). Instead it asserts the real invariant directly via the public
* `getAvailableFields()` surface: no numeric (vector-dimension) field names, no
* `vector`/`embedding` field, but the legitimate semantic fields ARE indexed.
*
* Field placement (8.0 contract see AddParams JSDoc): `data` is the content that
* gets embedded; `metadata` holds the structured, queryable fields used in `where`
* filters. Queryable fields therefore live under `metadata` here.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
import { existsSync, rmSync } from 'fs'
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../src/errors/brainyError.js'
describe('Metadata Vector Exclusion Fix', () => {
let brainy: Brainy
const testDir = '/tmp/brainy-metadata-vector-test'
beforeEach(async () => {
// Clean test directory
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
}
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1) Brainy 8.0 makes subtype required by default on every public write path (`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`, import). Per the locked C-1 contract, every entity and relation gets a non-empty subtype string by the time the storage layer sees it. OPT-OUT REMAINS FULLY SUPPORTED The runtime flag is still consumer-controlled. Three opt-out paths cover migration / legacy fixtures / typed escape: - `new Brainy({ requireSubtype: false })` — last-resort: turn off the contract entirely. Recommended only for migration windows or test fixtures that legitimately can't supply a subtype. - `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` — per-type allowlist: strict everywhere except the listed types. - `brain.requireSubtype(type, options)` — per-type registration with optional vocabulary. Composes with the brain-wide flag. Default is now `true`. Opt-out is explicit and documented; nothing silently degrades. TEST SWEEP Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call site across 120 test files. Three sed patterns covered the shapes: - `new Brainy({` → `new Brainy({ requireSubtype: false,` - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,` - `new Brainy()` → `new Brainy({ requireSubtype: false })` tests/helpers/test-factory.ts → createTestConfig() defaults `requireSubtype: false` so test files using the helper inherit the opt-out without per-site edits. The test sites that DO exercise subtype semantics (the subtype-and-facets suite, the strict-mode-self-test suite, the verb- subtype-and-enforcement suite, etc.) already pass real subtypes — they were the 7.30.x acceptance tests for this contract. Those tests continue to pass unchanged. CHANGES src/brainy.ts - normalizeConfig() — `requireSubtype` default `false` → `true`. Comment refreshed to document the three opt-out paths. tests/* (120 files) - Bulk-edited brain construction sites. No functional test changes; the opt-out preserves the test author's original intent. tests/helpers/test-factory.ts - createTestConfig() base config gains `requireSubtype: false`. NO-OP for consumers who were already passing subtype on every write. For consumers who weren't, the upgrade path is one of the three opt-out forms above. Migration recipe documented in 8.0 release notes (next commit). VERIFICATION - npx tsc --noEmit: clean - npm test: 1408 / 1409 (same pre-existing race-condition outstanding; no other regressions from the flip)
2026-06-09 14:58:25 -07:00
brainy = new Brainy({ requireSubtype: false,
storage: { type: 'filesystem', path: testDir },
ai: { provider: 'mock' }
})
await brainy.init()
})
afterEach(async () => {
if (brainy) {
await brainy.clear()
await brainy.close()
}
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true })
}
})
it('should NOT index vector embeddings as metadata fields', async () => {
// Add entity with queryable metadata + a small array (tags SHOULD be indexed)
await brainy.add({
type: NounType.Person,
data: 'Alice the developer',
metadata: {
name: 'Alice',
email: 'alice@example.com',
tags: ['developer', 'typescript'] // Small array - SHOULD be indexed
}
})
// The indexed-field set is the canonical record of what the metadata index
// actually tracks. The vector explosion bug manifested as hundreds of numeric
// field names (one per dimension) appearing here.
const fields = await brainy.getAvailableFields()
// CRITICAL: no purely-numeric field names (vector dimension indices like
// "0", "1", "54716") and no nested numeric dimension keys.
const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f))
expect(numericFields).toEqual([])
// CRITICAL: the raw vector must never be indexed as a metadata field.
expect(fields).not.toContain('vector')
expect(fields).not.toContain('embedding')
expect(fields).not.toContain('embeddings')
// The legitimate semantic fields ARE indexed.
expect(fields).toContain('name')
expect(fields).toContain('email')
expect(fields).toContain('tags')
// Field count stays sane (semantic fields + Brainy's own system/VFS fields),
// nowhere near the hundreds a per-dimension explosion would produce.
expect(fields.length).toBeLessThan(50)
})
it('should NOT index objects with numeric keys (array-as-object guard)', async () => {
// Add entity with an object that has numeric keys (simulates a vector
// accidentally serialized as { "0": 0.1, "1": 0.2, ... }).
await brainy.add({
type: NounType.Person,
data: 'NumericTest entity',
metadata: {
name: 'NumericTest',
numericObject: {
'0': 0.1,
'1': 0.2,
'2': 0.3,
'100': 1.0
}
}
})
const fields = await brainy.getAvailableFields()
// CRITICAL: numeric keys (whether top-level or nested under a field like
// "numericObject.0") must NOT become indexed fields. This is the guard that
// prevents vectors-as-objects from exploding the index.
const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f))
expect(numericFields).toEqual([])
// The legitimate scalar field IS indexed.
expect(fields).toContain('name')
})
it('should still index small arrays (tags, categories)', async () => {
// Add entity with a small (<= 10 element) array of tags.
await brainy.add({
type: NounType.Person,
data: 'Bob the engineer',
metadata: {
name: 'Bob',
tags: ['javascript', 'react', 'nodejs']
}
})
// Small arrays must be registered as a multi-value field (not skipped, not
// exploded). 'tags' should appear in the indexed-field set.
const fields = await brainy.getAvailableFields()
expect(fields).toContain('tags')
// Behavioral check: filtering by a tag value should return the entity. The
// array was ['javascript', 'react', 'nodejs'] — matching ANY member must work.
//
// REAL LIBRARY BUG (left failing on purpose): the ColumnStore ingest path in
// src/utils/metadataIndex.ts (addToIndex, ~line 1407) does
// `fieldsMap[field] = value` for every extracted pair, so for a multi-value
// field it keeps only the LAST array element ('nodejs') and drops the rest.
// Only the '__words__' field gets the array-accumulation treatment. As a
// result `where: { tags: 'react' }` (and 'javascript') returns [], while
// `where: { tags: 'nodejs' }` returns 1. Correct input → wrong output, so
// this assertion stays as-is to keep the bug visible.
const results = await brainy.find({
where: { tags: 'react' }
})
expect(results.length).toBeGreaterThan(0)
expect(results[0].entity.metadata?.name).toBe('Bob')
})
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
it('should REFUSE an array over the indexing bound, by name', async () => {
// A large array (not a vector, just bulk data). This used to be SKIPPED in
// silence at a bound of 10 — the field simply vanished from the index and
// the row dropped out of every `where` on it, indistinguishably from "no
// row matches". The bound is now MAX_INDEXED_ARRAY_LENGTH and it REFUSES.
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
const largeArray = Array.from({ length: overTheBound }, (_, i) => `item${i}`)
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
const err = await brainy
.add({
type: NounType.Document,
data: 'Doc with large array',
metadata: {
name: 'Doc with large array',
items: largeArray
}
})
.catch((e: any) => e)
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
expect(err.field).toBe('items')
expect(err.length).toBe(overTheBound)
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
// Nothing was indexed from the refused write — no 'items' field, and above
// all no per-element numeric fields (the original explosion class).
const fields = await brainy.getAvailableFields()
expect(fields).not.toContain('items')
const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f))
expect(numericFields).toEqual([])
})
it('should index an array UP TO the bound — the old limit of 10 was the bug', async () => {
await brainy.add({
type: NounType.Document,
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
data: 'Doc with a long-but-legitimate tag list',
metadata: {
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
name: 'Doc with many tags',
items: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`)
}
})
const fields = await brainy.getAvailableFields()
fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, and the caller had no way to tell that from "no row matches". Eleven tags is not an exotic shape; the eleventh tag made the row invisible. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths.
2026-09-02 13:43:14 -07:00
// The field IS indexed now, and still without per-element numeric fields.
expect(fields).toContain('items')
expect(fields.filter(f => /(^|\.)\d+$/.test(f))).toEqual([])
// And the eleventh element — the one the old bound silently dropped the
// whole field for — really is searchable.
const hits = await brainy.find({ where: { items: 'item10' } })
expect(hits.length).toBeGreaterThan(0)
})
it('should preserve HNSW vector search functionality', async () => {
// Add entities with semantic content
const id1 = await brainy.add({
type: NounType.Concept,
data: 'AI algorithms that learn from data',
metadata: {
name: 'Machine Learning',
description: 'AI algorithms that learn from data'
}
})
const id2 = await brainy.add({
type: NounType.Concept,
data: 'Neural networks with multiple layers',
metadata: {
name: 'Deep Learning',
description: 'Neural networks with multiple layers'
}
})
// Vectors are metadata-only by default; request them explicitly to verify the
// embedding pipeline actually produced a stored vector for each entity.
const entity1 = await brainy.get(id1, { includeVectors: true })
const entity2 = await brainy.get(id2, { includeVectors: true })
expect(entity1).toBeDefined()
expect(entity2).toBeDefined()
expect(Array.isArray(entity1?.vector)).toBe(true)
expect(Array.isArray(entity2?.vector)).toBe(true)
expect(entity1!.vector!.length).toBe(384)
expect(entity2!.vector!.length).toBe(384)
// Self-retrieval confirms the HNSW index is wired: querying an entity by its
// OWN content text returns it (deterministic embedder → cosine 1.0).
const selfHits = await brainy.find({ query: 'AI algorithms that learn from data', limit: 5 })
expect(selfHits.length).toBeGreaterThan(0)
expect(selfHits.some(r => r.id === id1)).toBe(true)
})
it('should preserve metadata field filtering', async () => {
// Add entities with various queryable metadata
await brainy.add({
type: NounType.Person,
data: 'Charlie the engineer',
metadata: {
name: 'Charlie',
email: 'charlie@example.com',
role: 'engineer'
}
})
await brainy.add({
type: NounType.Person,
data: 'Dana the designer',
metadata: {
name: 'Dana',
email: 'dana@example.com',
role: 'designer'
}
})
// Verify scalar metadata filtering works
const engineers = await brainy.find({
where: { role: 'engineer' }
})
expect(engineers.length).toBe(1)
expect(engineers[0].entity.metadata?.name).toBe('Charlie')
const designers = await brainy.find({
where: { role: 'designer' }
})
expect(designers.length).toBe(1)
expect(designers[0].entity.metadata?.name).toBe('Dana')
})
it('should handle nested object metadata correctly', async () => {
// Add entity with nested metadata
await brainy.add({
type: NounType.Person,
data: 'Eve in New York',
metadata: {
name: 'Eve',
address: {
city: 'New York',
state: 'NY'
}
}
})
// Nested fields are flattened to dot-notation field names; filter by the
// flattened path.
const results = await brainy.find({
where: { 'address.city': 'New York' }
})
expect(results.length).toBeGreaterThan(0)
expect(results[0].entity.metadata?.name).toBe('Eve')
})
it('should NOT create exponential index fields for multiple entities', async () => {
// Add 10 entities (each with a vector embedding + a few metadata fields)
for (let i = 0; i < 10; i++) {
await brainy.add({
type: NounType.Person,
data: `Person ${i}`,
metadata: {
name: `Person ${i}`,
email: `person${i}@example.com`,
tags: ['user']
}
})
}
// The indexed-field set is keyed by field NAME, not by entity — so it must
// stay bounded regardless of entity count. The original bug turned this into
// ~721 fields per entity (one per vector dimension); here it must remain a
// small handful of semantic fields plus Brainy's own system/VFS fields.
const fields = await brainy.getAvailableFields()
const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f))
expect(numericFields).toEqual([])
expect(fields).toContain('name')
expect(fields).toContain('email')
expect(fields).toContain('tags')
// Nowhere near the thousands a per-dimension explosion would produce.
expect(fields.length).toBeLessThan(50)
})
})