feat(namespace): conformance green 19/19 — data-aware did-you-mean on unindexed bare addresses, ordering contract on the column top-K path (never drop, nulls last, ties by id), shape-complete addressed reads (entity views AND raw storage shapes, shadow-proof both scopes), per-key source matching for dotted addresses; refusal classes unified under UnresolvableFieldError
This commit is contained in:
parent
7492b6cb59
commit
8e962dabda
4 changed files with 134 additions and 61 deletions
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { StorageAdapter, resolveEntityField, NounMetadata, VerbMetadata } from '../coreTypes.js'
|
||||
import { SYSTEM_ENTITY_SCALARS, parseFieldAddress } from '../db/fieldAddressing.js'
|
||||
import { SYSTEM_ENTITY_SCALARS, parseFieldAddress, UnresolvableFieldError } from '../db/fieldAddressing.js'
|
||||
import { ColumnStore } from '../indexes/columnStore/ColumnStore.js'
|
||||
import type { MetadataIndexProvider } from '../plugin.js'
|
||||
import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js'
|
||||
|
|
@ -2250,6 +2250,18 @@ export class MetadataIndexManager implements MetadataIndexProvider {
|
|||
const orderKey =
|
||||
orderAddress.scope === 'system' ? `system.${orderAddress.field}` : orderAddress.field
|
||||
|
||||
// DATA-AWARE REFUSAL (the did-you-mean): a bare address no user field
|
||||
// carries cannot mean anything as a sort key — and when the name collides
|
||||
// with a system scalar the caller almost certainly meant system.<field>.
|
||||
// Refusing loudly with both candidates beats silently sorting nothing.
|
||||
if (
|
||||
orderAddress.scope === 'metadata' &&
|
||||
!(this.columnStore && this.columnStore.hasField(orderKey)) &&
|
||||
!(await this.loadSparseIndex(orderKey))
|
||||
) {
|
||||
throw new UnresolvableFieldError(orderAddress.raw, 'entity')
|
||||
}
|
||||
|
||||
// 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(orderKey)) {
|
||||
|
|
@ -2283,9 +2295,30 @@ export class MetadataIndexManager implements MetadataIndexProvider {
|
|||
|
||||
// Convert int IDs back to UUIDs. Number() narrowing is lossless — the
|
||||
// shipped EntityIdSpaceExceeded guard caps the JS mapper at u32.
|
||||
return sortedIntIds
|
||||
const sortedUuids = sortedIntIds
|
||||
.map(intId => this.idMapper.getUuid(Number(intId)))
|
||||
.filter((uuid): uuid is string => uuid !== undefined)
|
||||
|
||||
// ORDERING CONTRACT (cross-engine, sealed): rows missing the field are
|
||||
// NEVER dropped — they sort LAST in both directions — and ties break by
|
||||
// id ascending. The column only contains rows that HAVE the field, so
|
||||
// (1) re-sort the page deterministically (value, then id) with K cheap
|
||||
// value reads, and (2) append the filtered rows the column omitted,
|
||||
// id-ascending, filling any remaining page budget.
|
||||
const page = await Promise.all(
|
||||
sortedUuids.map(async id => ({ id, value: await this.getFieldValueForEntity(id, orderKey) }))
|
||||
)
|
||||
page.sort((a, b) => this.compareAddressedValues(a.value, b.value, a.id, b.id, order))
|
||||
let result = page.map(p => p.id)
|
||||
|
||||
if (hasFilter) {
|
||||
const present = new Set(sortedUuids)
|
||||
if (topK === undefined || result.length < topK) {
|
||||
const missing = filteredIds.filter(id => !present.has(id)).sort()
|
||||
result = result.concat(missing)
|
||||
}
|
||||
}
|
||||
return topK !== undefined ? result.slice(0, topK) : result
|
||||
}
|
||||
|
||||
// Fallback: sparse index path (for fields not yet in column store).
|
||||
|
|
@ -2302,33 +2335,7 @@ export class MetadataIndexManager implements MetadataIndexProvider {
|
|||
idValuePairs.push({ id, value })
|
||||
}
|
||||
|
||||
idValuePairs.sort((a, b) => {
|
||||
// Ordering contract (cross-engine, ruled 2026-08-03): missing/null
|
||||
// values sort LAST in BOTH directions — the direction flip never moves
|
||||
// them to the front — and ties break by id ascending, so an ordered
|
||||
// read is deterministic and identical on both engines. Rows are never
|
||||
// dropped for lacking the field.
|
||||
const aNull = a.value == null
|
||||
const bNull = b.value == null
|
||||
if (aNull || bNull) {
|
||||
if (aNull && bNull) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||
return aNull ? 1 : -1
|
||||
}
|
||||
// Numbers compare numerically; everything else by code-point (UTF-8 byte) order.
|
||||
// This makes the JS fallback sort match cor's native column store exactly
|
||||
// (numeric i64/f64 vs code-point strings) and stay deterministic across
|
||||
// environments, unlike the `<` operator's UTF-16 ordering for strings.
|
||||
let comparison = 0
|
||||
if (a.value !== b.value) {
|
||||
if (typeof a.value === 'number' && typeof b.value === 'number') {
|
||||
comparison = a.value < b.value ? -1 : 1
|
||||
} else {
|
||||
comparison = compareCodePoints(String(a.value), String(b.value))
|
||||
}
|
||||
}
|
||||
if (comparison === 0) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||
return order === 'asc' ? comparison : -comparison
|
||||
})
|
||||
idValuePairs.sort((a, b) => this.compareAddressedValues(a.value, b.value, a.id, b.id, order))
|
||||
|
||||
const sorted = idValuePairs.map(p => p.id)
|
||||
return topK !== undefined ? sorted.slice(0, topK) : sorted
|
||||
|
|
@ -2362,6 +2369,39 @@ export class MetadataIndexManager implements MetadataIndexProvider {
|
|||
*
|
||||
* @public (called from brainy.ts for sorted queries)
|
||||
*/
|
||||
/**
|
||||
* The cross-engine ordering contract in one comparator (sealed 2026-08-03):
|
||||
* missing/null values sort LAST in BOTH directions — the direction flip
|
||||
* never moves them to the front — and ties break by id ascending, so an
|
||||
* ordered read is deterministic and identical on both engines. Numbers
|
||||
* compare numerically; everything else by code-point (UTF-8 byte) order,
|
||||
* matching the native column store exactly.
|
||||
*/
|
||||
private compareAddressedValues(
|
||||
aVal: any,
|
||||
bVal: any,
|
||||
aId: string,
|
||||
bId: string,
|
||||
order: 'asc' | 'desc'
|
||||
): number {
|
||||
const aNull = aVal == null
|
||||
const bNull = bVal == null
|
||||
if (aNull || bNull) {
|
||||
if (aNull && bNull) return aId < bId ? -1 : aId > bId ? 1 : 0
|
||||
return aNull ? 1 : -1
|
||||
}
|
||||
let comparison = 0
|
||||
if (aVal !== bVal) {
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
comparison = aVal < bVal ? -1 : 1
|
||||
} else {
|
||||
comparison = compareCodePoints(String(aVal), String(bVal))
|
||||
}
|
||||
}
|
||||
if (comparison === 0) return aId < bId ? -1 : aId > bId ? 1 : 0
|
||||
return order === 'asc' ? comparison : -comparison
|
||||
}
|
||||
|
||||
async getFieldValueForEntity(entityId: string, field: string): Promise<any> {
|
||||
// `field` arrives as a FROZEN INDEX KEY (bare = user metadata;
|
||||
// 'system.<field>' = engine scalar). Storage fallbacks read the matching
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue