feat(namespace): the index speaks the frozen keys — record-frame scalars index under literal 'system.<field>' (legacy 'noun' spelling folds into system.type; plumbing never indexed from a record frame), user fields stay bare in every shape; filter + sorted paths route every address through parseFieldAddress; storage fallbacks read the addressed side of the record
Some checks are pending
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Bun (latest) (push) Waiting to run

This commit is contained in:
David Snelling 2026-08-03 15:28:24 -07:00
parent fcb24ab627
commit 11c724bc86

View file

@ -5,6 +5,7 @@
*/ */
import { StorageAdapter, resolveEntityField, NounMetadata, VerbMetadata } from '../coreTypes.js' import { StorageAdapter, resolveEntityField, NounMetadata, VerbMetadata } from '../coreTypes.js'
import { SYSTEM_ENTITY_SCALARS, parseFieldAddress } from '../db/fieldAddressing.js'
import { ColumnStore } from '../indexes/columnStore/ColumnStore.js' import { ColumnStore } from '../indexes/columnStore/ColumnStore.js'
import type { MetadataIndexProvider } from '../plugin.js' import type { MetadataIndexProvider } from '../plugin.js'
import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js' import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js'
@ -43,8 +44,8 @@ import { BrainyError } from '../errors/brainyError.js'
* bucketed field is added (e.g. a compressed float), add it here too. * bucketed field is added (e.g. a compressed float), add it here too.
*/ */
const BUCKETED_INDEX_FIELDS: ReadonlySet<string> = new Set([ const BUCKETED_INDEX_FIELDS: ReadonlySet<string> = new Set([
'createdAt', 'system.createdAt',
'updatedAt' 'system.updatedAt'
]) ])
export interface MetadataIndexEntry { export interface MetadataIndexEntry {
@ -1218,12 +1219,56 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// the reserved entity-identity field, resolved specially by find().) // the reserved entity-identity field, resolved specially by find().)
const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'id']) const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'id'])
const extract = (obj: any, prefix = ''): void => { // THE FROZEN INDEX KEY FORMAT (cross-engine, sealed 2026-08-03; the native
// accelerator keys identically — epoch 3 rebuilds every brain onto it):
// user fields index under BARE keys exactly as the caller wrote them;
// the ten system scalars index under literal 'system.<field>' keys — the
// key IS the query address, so the two namespaces can never collide
// inside the index again. `origin` tracks which side of the record a key
// came from: 'record' = the entity/stored-record frame (system scalars,
// plumbing, and the metadata bag live here — the WRITE PATH's reserved-
// name remap guarantees a record-frame key matching a system name IS the
// system value); 'user' = inside the flattened metadata bag (everything
// is the user's, including natural names like `level` and `data`).
// Frame kinds: 'entity-record' = entityForIndexing shape (user fields
// nested under `metadata`; stray top-level keys are DROPPED, not guessed —
// epoch-3's rebuild-from-canonical normalizes historical shapes);
// 'flat-record' = the stored metadata-record shape (user fields FLAT
// beside the reserved ones — the write path's reserved-name remap
// guarantees a key matching a system name IS the system value, so
// non-system keys here are the user's and index bare); 'user' = inside
// the metadata bag (everything is the user's, including natural names
// like `level` and `data`).
type Frame = 'entity-record' | 'flat-record' | 'user'
const extract = (obj: any, prefix = '', frame: Frame = 'entity-record'): void => {
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key let fullKey = prefix ? `${prefix}.${key}` : key
// Skip fields in never-index list (CRITICAL: prevents vector indexing bug + HNSW fields) if (!prefix && frame !== 'user') {
if (!prefix && NEVER_INDEX.has(key)) continue if (key === 'metadata' && typeof value === 'object' && value !== null && !Array.isArray(value)) {
extract(value, '', 'user') // the user's namespace: bare keys
continue
}
if (key === 'type' || key === 'noun') {
fullKey = 'system.type' // legacy 'noun' spelling folds into the frozen key
} else if (SYSTEM_ENTITY_SCALARS.has(key) && key !== 'id') {
fullKey = `system.${key}`
} else if (
key === 'data' || key === '_rev' || key === 'level' || NEVER_INDEX.has(key)
) {
continue // plumbing / identity / bulk payloads — never indexed from a record frame
} else if (frame === 'entity-record') {
continue // stray entity-frame key: dropped, not guessed
}
// flat-record fallthrough: a non-system, non-plumbing key IS a user
// field (flat beside the reserved ones) — indexes bare via fullKey.
} else if (!prefix && NEVER_INDEX.has(key)) {
// User frame: only the bulk-payload guards apply — natural names
// like `level` and `data` are real user fields here. (`id` as a
// user metadata field remains un-indexed this train — documented
// limitation; system.id resolves via the id mapper, never a column.)
continue
}
// Skip purely numeric field names (array indices converted to object keys) // Skip purely numeric field names (array indices converted to object keys)
// Legitimate field names should never be purely numeric // Legitimate field names should never be purely numeric
@ -1233,21 +1278,12 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// Skip fields based on user configuration // Skip fields based on user configuration
if (!this.shouldIndexField(fullKey)) continue if (!this.shouldIndexField(fullKey)) continue
// Special handling for metadata field at top level
// Flatten metadata fields to top-level (no prefix) for cleaner queries
// Standard fields are already at top-level, custom fields go in metadata
// By flattening here, queries can use { category: 'B' } instead of { 'metadata.category': 'B' }
if (key === 'metadata' && !prefix && typeof value === 'object' && !Array.isArray(value)) {
extract(value, '') // Flatten to top-level, no prefix
continue
}
// Skip large arrays (> 10 elements) - likely vectors or bulk data // Skip large arrays (> 10 elements) - likely vectors or bulk data
if (Array.isArray(value) && value.length > 10) continue if (Array.isArray(value) && value.length > 10) continue
if (value && typeof value === 'object' && !Array.isArray(value)) { if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recurse into nested objects (but not arrays) // Recurse into nested objects (but not arrays), keeping the frame
extract(value, fullKey) extract(value, fullKey, frame)
} else if (Array.isArray(value) && value.length <= 10) { } else if (Array.isArray(value) && value.length <= 10) {
// Small arrays: index as multi-value field (all with same field name) // Small arrays: index as multi-value field (all with same field name)
// Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node" // Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node"
@ -1258,16 +1294,21 @@ export class MetadataIndexManager implements MetadataIndexProvider {
} }
} }
} else { } else {
// Primitive value: index it // Primitive value: index it under the frozen key computed above.
// Map 'type' → 'noun' for backward compatibility // (The legacy 'type'→'noun' remap is gone — 'noun' columns die at
const indexField = (!prefix && key === 'type') ? 'noun' : fullKey // the epoch-3 rebuild; system.type is the one spelling.)
fields.push({ field: indexField, value }) fields.push({ field: fullKey, value })
} }
} }
} }
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
extract(data) // Shape detection for the top frame: an object carrying a nested
// `metadata` bag is the entityForIndexing shape; anything else is the
// flat stored-record shape (user fields flat beside reserved ones).
const entityShaped =
'metadata' in data && typeof data.metadata === 'object' && data.metadata !== null
extract(data, '', entityShaped ? 'entity-record' : 'flat-record')
} }
// Extract words for hybrid text search // Extract words for hybrid text search
@ -1911,22 +1952,15 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// Skip logical operators // Skip logical operators
if (rawField === 'allOf' || rawField === 'anyOf' || rawField === 'not') continue if (rawField === 'allOf' || rawField === 'anyOf' || rawField === 'not') continue
// Metadata is FLATTENED at index time (metadata.entry.title indexes as // THE ONE ADDRESSING LAW (sealed 2026-08-03): every filter key routes
// entry.title), so a `metadata.`-prefixed where key is almost always // through parseFieldAddress — bare and 'metadata.'-prefixed spellings
// the caller spelling the STORAGE shape rather than the index shape. // address the user's fields (indexed under BARE keys), 'system.<field>'
// Accept both spellings: when the key as spelled is unindexed but its // addresses the ten engine scalars (indexed under their literal
// stripped spelling is, query the stripped one. A literal nested // 'system.<field>' keys). A malformed address (system.<not-in-map>,
// custom key named `metadata` still wins when indexed as spelled // plumbing in the system spelling) throws typed BEFORE any index read —
// (checked first), so that rare shape keeps working. // an accepted name either works or refuses.
let field = rawField const address = parseFieldAddress(rawField, 'entity')
if ( const field = address.scope === 'system' ? `system.${address.field}` : address.field
rawField.startsWith('metadata.') &&
this.columnStore &&
!this.columnStore.hasField(rawField) &&
this.columnStore.hasField(rawField.slice('metadata.'.length))
) {
field = rawField.slice('metadata.'.length)
}
let fieldResults: string[] = [] let fieldResults: string[] = []
@ -2207,9 +2241,18 @@ export class MetadataIndexManager implements MetadataIndexProvider {
order: 'asc' | 'desc' = 'asc', order: 'asc' | 'desc' = 'asc',
topK?: number topK?: number
): Promise<string[]> { ): Promise<string[]> {
// THE ONE ADDRESSING LAW — the orderBy address routes through the same
// parse the filter path uses (the historical asymmetry where the filter
// path understood 'metadata.' but the sorted path never did is dead).
// Bare / 'metadata.' → the user's bare index key; 'system.<field>' → the
// literal frozen key; malformed addresses throw typed before any read.
const orderAddress = parseFieldAddress(orderBy, 'entity')
const orderKey =
orderAddress.scope === 'system' ? `system.${orderAddress.field}` : orderAddress.field
// Column store path: O(K log S) sort via k-way merge across segments. // Column store path: O(K log S) sort via k-way merge across segments.
// No per-entity storage reads, no precision loss from bucketing. // No per-entity storage reads, no precision loss from bucketing.
if (this.columnStore && this.columnStore.hasField(orderBy)) { if (this.columnStore && this.columnStore.hasField(orderKey)) {
// Get filtered IDs from existing roaring bitmap path // Get filtered IDs from existing roaring bitmap path
const hasFilter = filter && Object.keys(filter).length > 0 const hasFilter = filter && Object.keys(filter).length > 0
const filteredIds = hasFilter ? await this.getIdsForFilter(filter) : [] const filteredIds = hasFilter ? await this.getIdsForFilter(filter) : []
@ -2229,12 +2272,12 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// log K) heap, not a full sort materialization. // log K) heap, not a full sort materialization.
const k = topK !== undefined ? Math.min(topK, filteredIds.length) : filteredIds.length const k = topK !== undefined ? Math.min(topK, filteredIds.length) : filteredIds.length
sortedIntIds = await this.columnStore.filteredSortTopK( sortedIntIds = await this.columnStore.filteredSortTopK(
filterBitmap, orderBy, order, k filterBitmap, orderKey, order, k
) )
} else { } else {
// Unfiltered sort — column store handles the full entity set efficiently // Unfiltered sort — column store handles the full entity set efficiently
sortedIntIds = await this.columnStore.sortTopK( sortedIntIds = await this.columnStore.sortTopK(
orderBy, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size orderKey, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size
) )
} }
@ -2255,7 +2298,7 @@ export class MetadataIndexManager implements MetadataIndexProvider {
const idValuePairs: Array<{ id: string, value: any }> = [] const idValuePairs: Array<{ id: string, value: any }> = []
for (const id of filteredIds) { for (const id of filteredIds) {
const value = await this.getFieldValueForEntity(id, orderBy) const value = await this.getFieldValueForEntity(id, orderKey)
idValuePairs.push({ id, value }) idValuePairs.push({ id, value })
} }
@ -2320,10 +2363,17 @@ export class MetadataIndexManager implements MetadataIndexProvider {
* @public (called from brainy.ts for sorted queries) * @public (called from brainy.ts for sorted queries)
*/ */
async getFieldValueForEntity(entityId: string, field: string): Promise<any> { async getFieldValueForEntity(entityId: string, field: string): Promise<any> {
// Path 1: Bucketed fields need the actual value from storage. // `field` arrives as a FROZEN INDEX KEY (bare = user metadata;
// 'system.<field>' = engine scalar). Storage fallbacks read the matching
// side of the record — a system key reads the record scalar, a bare key
// reads the user's metadata bag; the two can never shadow each other.
const systemInner = field.startsWith('system.') ? field.slice('system.'.length) : null
// Path 1: Bucketed fields need the actual (un-bucketed) value from storage.
if (BUCKETED_INDEX_FIELDS.has(field)) { if (BUCKETED_INDEX_FIELDS.has(field)) {
const noun = await this.storage.getNoun(entityId) const noun = await this.storage.getNoun(entityId)
return noun ? resolveEntityField(noun, field) : undefined if (!noun) return undefined
return (noun as unknown as Record<string, unknown>)[systemInner as string]
} }
// Path 3 precondition: entity must be in the id mapper for bitmap lookup. // Path 3 precondition: entity must be in the id mapper for bitmap lookup.
@ -2340,7 +2390,11 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// yet indexed. resolveEntityField handles the shape contract. // yet indexed. resolveEntityField handles the shape contract.
if (!sparseIndex) { if (!sparseIndex) {
const noun = await this.storage.getNoun(entityId) const noun = await this.storage.getNoun(entityId)
return noun ? resolveEntityField(noun, field) : undefined if (!noun) return undefined
if (systemInner !== null) {
return (noun as unknown as Record<string, unknown>)[systemInner]
}
return (noun as { metadata?: Record<string, unknown> }).metadata?.[field]
} }
// Path 3: Search sparse index chunks for this entity's value. // Path 3: Search sparse index chunks for this entity's value.