diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index cf5d521e..bfde5fe9 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -5,6 +5,7 @@ */ 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 type { MetadataIndexProvider } from '../plugin.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. */ const BUCKETED_INDEX_FIELDS: ReadonlySet = new Set([ - 'createdAt', - 'updatedAt' + 'system.createdAt', + 'system.updatedAt' ]) export interface MetadataIndexEntry { @@ -1218,12 +1219,56 @@ export class MetadataIndexManager implements MetadataIndexProvider { // the reserved entity-identity field, resolved specially by find().) 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.' 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)) { - 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 && NEVER_INDEX.has(key)) continue + if (!prefix && frame !== 'user') { + 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) // Legitimate field names should never be purely numeric @@ -1233,21 +1278,12 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Skip fields based on user configuration 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 if (Array.isArray(value) && value.length > 10) continue if (value && typeof value === 'object' && !Array.isArray(value)) { - // Recurse into nested objects (but not arrays) - extract(value, fullKey) + // Recurse into nested objects (but not arrays), keeping the frame + extract(value, fullKey, frame) } else if (Array.isArray(value) && value.length <= 10) { // Small arrays: index as multi-value field (all with same field name) // Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node" @@ -1258,16 +1294,21 @@ export class MetadataIndexManager implements MetadataIndexProvider { } } } else { - // Primitive value: index it - // Map 'type' → 'noun' for backward compatibility - const indexField = (!prefix && key === 'type') ? 'noun' : fullKey - fields.push({ field: indexField, value }) + // Primitive value: index it under the frozen key computed above. + // (The legacy 'type'→'noun' remap is gone — 'noun' columns die at + // the epoch-3 rebuild; system.type is the one spelling.) + fields.push({ field: fullKey, value }) } } } 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 @@ -1911,22 +1952,15 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Skip logical operators if (rawField === 'allOf' || rawField === 'anyOf' || rawField === 'not') continue - // Metadata is FLATTENED at index time (metadata.entry.title indexes as - // entry.title), so a `metadata.`-prefixed where key is almost always - // the caller spelling the STORAGE shape rather than the index shape. - // Accept both spellings: when the key as spelled is unindexed but its - // stripped spelling is, query the stripped one. A literal nested - // custom key named `metadata` still wins when indexed as spelled - // (checked first), so that rare shape keeps working. - let field = rawField - if ( - rawField.startsWith('metadata.') && - this.columnStore && - !this.columnStore.hasField(rawField) && - this.columnStore.hasField(rawField.slice('metadata.'.length)) - ) { - field = rawField.slice('metadata.'.length) - } + // THE ONE ADDRESSING LAW (sealed 2026-08-03): every filter key routes + // through parseFieldAddress — bare and 'metadata.'-prefixed spellings + // address the user's fields (indexed under BARE keys), 'system.' + // addresses the ten engine scalars (indexed under their literal + // 'system.' keys). A malformed address (system., + // plumbing in the system spelling) throws typed BEFORE any index read — + // an accepted name either works or refuses. + const address = parseFieldAddress(rawField, 'entity') + const field = address.scope === 'system' ? `system.${address.field}` : address.field let fieldResults: string[] = [] @@ -2207,9 +2241,18 @@ export class MetadataIndexManager implements MetadataIndexProvider { order: 'asc' | 'desc' = 'asc', topK?: number ): Promise { + // 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.' → 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. // 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 const hasFilter = filter && Object.keys(filter).length > 0 const filteredIds = hasFilter ? await this.getIdsForFilter(filter) : [] @@ -2229,12 +2272,12 @@ export class MetadataIndexManager implements MetadataIndexProvider { // log K) heap, not a full sort materialization. const k = topK !== undefined ? Math.min(topK, filteredIds.length) : filteredIds.length sortedIntIds = await this.columnStore.filteredSortTopK( - filterBitmap, orderBy, order, k + filterBitmap, orderKey, order, k ) } else { // Unfiltered sort — column store handles the full entity set efficiently 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 }> = [] for (const id of filteredIds) { - const value = await this.getFieldValueForEntity(id, orderBy) + const value = await this.getFieldValueForEntity(id, orderKey) idValuePairs.push({ id, value }) } @@ -2320,10 +2363,17 @@ export class MetadataIndexManager implements MetadataIndexProvider { * @public (called from brainy.ts for sorted queries) */ async getFieldValueForEntity(entityId: string, field: string): Promise { - // Path 1: Bucketed fields need the actual value from storage. + // `field` arrives as a FROZEN INDEX KEY (bare = user metadata; + // 'system.' = 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)) { const noun = await this.storage.getNoun(entityId) - return noun ? resolveEntityField(noun, field) : undefined + if (!noun) return undefined + return (noun as unknown as Record)[systemInner as string] } // 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. if (!sparseIndex) { 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)[systemInner] + } + return (noun as { metadata?: Record }).metadata?.[field] } // Path 3: Search sparse index chunks for this entity's value.