diff --git a/src/aggregation/AggregationIndex.ts b/src/aggregation/AggregationIndex.ts index 8b1c8b12..71dd9e41 100644 --- a/src/aggregation/AggregationIndex.ts +++ b/src/aggregation/AggregationIndex.ts @@ -13,7 +13,8 @@ * - Delta detection hashes definitions; only changed aggregates rebuild on restart */ -import type { StorageAdapter } from '../coreTypes.js' +import type { StorageAdapter, HNSWNounWithMetadata } from '../coreTypes.js' +import { resolveEntityField } from '../coreTypes.js' import type { AggregateDefinition, AggregateGroupState, @@ -96,22 +97,25 @@ function matchesSource(entity: Record, source: AggregateDefinit /** * Compute the group key for an entity given groupBy dimensions. + * + * Field lookups go through `resolveEntityField` to honor the + * HNSWNounWithMetadata shape contract (standard fields top-level, + * custom fields in metadata) in a single source of truth. */ function computeGroupKey( entity: Record, groupBy: GroupByDimension[] ): Record { const key: Record = {} - const metadata = (entity.metadata ?? {}) as Record + const e = entity as unknown as HNSWNounWithMetadata for (const dim of groupBy) { if (typeof dim === 'string') { - // Plain field lookup — check metadata first, then top-level - const val = metadata[dim] ?? entity[dim] + const val = resolveEntityField(e, dim) key[dim] = val !== undefined && val !== null ? String(val) : '__null__' } else { // Time-windowed field - const val = metadata[dim.field] ?? entity[dim.field] + const val = resolveEntityField(e, dim.field) if (typeof val === 'number') { key[dim.field] = bucketTimestamp(val, dim.window) } else { @@ -124,11 +128,14 @@ function computeGroupKey( } /** - * Get the numeric value of a field from entity metadata (for metric computation). + * Get the numeric value of a field from an entity (for metric computation). + * + * Routes through `resolveEntityField` so standard top-level numeric fields + * (weight, confidence, createdAt, updatedAt) and custom user numeric fields + * in metadata are both handled in one place. */ function getNumericField(entity: Record, field: string): number | undefined { - const metadata = (entity.metadata ?? {}) as Record - const val = metadata[field] ?? entity[field] + const val = resolveEntityField(entity as unknown as HNSWNounWithMetadata, field) if (typeof val === 'number' && !isNaN(val)) return val if (typeof val === 'string') { const num = parseFloat(val) diff --git a/src/neural/improvedNeuralAPI.ts b/src/neural/improvedNeuralAPI.ts index 839b3828..566b0a62 100644 --- a/src/neural/improvedNeuralAPI.ts +++ b/src/neural/improvedNeuralAPI.ts @@ -18,7 +18,7 @@ * Private methods are prefixed with _ and not exposed in public API */ -import { Vector } from '../coreTypes.js' +import { Vector, resolveEntityField, type HNSWNounWithMetadata } from '../coreTypes.js' import { cosineDistance, euclideanDistance } from '../utils/distance.js' import { SemanticCluster, @@ -2927,22 +2927,20 @@ export class ImprovedNeuralAPI { private _groupByDomain(items: any[], field: string): Map { const groups = new Map() for (const item of items) { - // Check multiple locations for the field value + // Resolve domain field value across legacy storage shapes. let domain: any = 'unknown' - // Special handling for type/nounType field + // Special handling for type/nounType — legacy storage compatibility + // where the type may live at item.nounType or metadata.noun/metadata.type + // rather than the current top-level item.type. if (field === 'type' || field === 'nounType') { domain = item.nounType || item.metadata?.noun || item.metadata?.type || 'unknown' } else { - // Check root level first - domain = item[field] + // Primary path: single-source-of-truth resolver (standard top-level, + // custom in metadata). + domain = resolveEntityField(item as HNSWNounWithMetadata, field) - // Then check metadata - if (domain == null) { - domain = item.metadata?.[field] - } - - // Then check data + // Legacy fallback: some producers stash data under item.data. if (domain == null) { domain = item.data?.[field] } @@ -3127,17 +3125,16 @@ export class ImprovedNeuralAPI { const entity = item.entity - // Get timestamp value from various possible locations - let timestamp: any = null - - // Check root level first - if (timeField === 'createdAt' || timeField === 'updatedAt') { - timestamp = entity[timeField] - } - - // Check metadata/data + // Resolve the timestamp field through the single-source-of-truth + // helper so standard top-level fields (createdAt, updatedAt) and + // custom fields in metadata are both handled uniformly. Fall back + // to entity.data for legacy producers that stash timestamps there. + let timestamp: any = resolveEntityField( + entity as HNSWNounWithMetadata, + timeField + ) if (timestamp == null) { - timestamp = entity.metadata?.[timeField] || entity.data?.[timeField] + timestamp = entity.data?.[timeField] } if (timestamp == null) {