486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
/**
|
|
* @module tests/conformance/namespace-law
|
|
* @description Conformance suite for the ruled field-addressing contract
|
|
* announced in RELEASES.md ("Coming next... one field-addressing law — bare
|
|
* names = user metadata, `system.<field>` for engine fields, typed refusals
|
|
* for unresolvable names"). This suite is the drift-proof shared by this
|
|
* engine and its native accelerator: both must satisfy every test here
|
|
* bit-for-bit, because they implement the SAME contract independently.
|
|
*
|
|
* The rule, in full:
|
|
* 1. A bare field name in `where` / `orderBy` / `groupBy` / aggregation
|
|
* `source.where` ALWAYS means the caller's own `metadata` field. No
|
|
* priority resolution, no engine fallback — ever.
|
|
* 2. `system.<field>` reaches an engine scalar, and ONLY an engine scalar,
|
|
* and ONLY when spelled explicitly. The addressable entity map is exactly
|
|
* ten names: id, type, subtype, createdAt, updatedAt, confidence, weight,
|
|
* visibility, service, createdBy. The relationship map is system.verb,
|
|
* system.sourceId, system.targetId, plus the eight scalars shared with
|
|
* entities.
|
|
* 3. Some names are invisible plumbing and are never addressable in either
|
|
* spelling: vector, connections, level, data, _rev. `system.level`,
|
|
* `system.vector`, and `system.data` all refuse — they are not in the
|
|
* system map. Bare `level` is a perfectly ordinary user field.
|
|
* 4. `metadata.<field>` is the explicit-user-scope spelling: identical
|
|
* semantics to the bare spelling, valid everywhere the bare spelling is.
|
|
* 5. Anything that resolves to neither a user field nor a system scalar is a
|
|
* typed refusal naming both candidates (`UnresolvableFieldError`).
|
|
* Unimplemented `find()` options (`cursor`, `includeRelations`,
|
|
* `writeOnly`) refuse with `UnsupportedFindOptionError` instead of being
|
|
* silently accepted and ignored.
|
|
* 6. Ordering is identical on both engines: rows missing/null on the
|
|
* `orderBy` field sort LAST in BOTH directions and are never dropped;
|
|
* ties break by id ascending.
|
|
*
|
|
* The motivating incident (told generically — see CLAUDE.md naming rule): an
|
|
* internal report from a production deployment showed a user metadata field
|
|
* literally named `level` silently shadowed by the engine's internal HNSW
|
|
* node layer, breaking sort order with zero errors raised. This contract
|
|
* makes that class of bug impossible, and testable forever.
|
|
*
|
|
* SELF-SKIP: the resolver this suite pins is being built in a parallel
|
|
* session and has not landed on every branch yet. Rather than going red on
|
|
* a branch that simply hasn't caught up, the suite detects whether the
|
|
* contract is live by the one thing any conformant implementation must
|
|
* export — `UnresolvableFieldError` from the package root — and skips
|
|
* loudly (never silently) until it does. This is the house pattern: a
|
|
* sibling engine's gate once went red because a test armed before its
|
|
* feature existed.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { Brainy } from '../../src/brainy.js'
|
|
import { NounType } from '../../src/types/graphTypes.js'
|
|
import * as brainyExports from '../../src/index.js'
|
|
|
|
const stubEmbedding = async (text: string): Promise<number[]> => {
|
|
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
|
|
}
|
|
|
|
// Detected purely by the exported error-class NAME — never by reaching into
|
|
// implementation internals. Both engines building this contract must export
|
|
// it from the package root, so this is a legitimate, implementation-agnostic
|
|
// readiness probe.
|
|
const lawActive = 'UnresolvableFieldError' in brainyExports
|
|
const UnresolvableFieldError = (brainyExports as Record<string, unknown>).UnresolvableFieldError as new (
|
|
...args: any[]
|
|
) => Error
|
|
const UnsupportedFindOptionError = (brainyExports as Record<string, unknown>)
|
|
.UnsupportedFindOptionError as new (...args: any[]) => Error
|
|
|
|
// Always runs, regardless of lawActive — the loud signal that the rest of
|
|
// this file was skipped, and why.
|
|
it('namespace law armed?', () => {
|
|
if (!lawActive) {
|
|
console.warn(
|
|
'[conformance] namespace-law suite SKIPPED — UnresolvableFieldError not exported yet; arms when the resolver lands'
|
|
)
|
|
}
|
|
expect(true).toBe(true)
|
|
})
|
|
|
|
/**
|
|
* Awaits `promise`, asserting it rejects with an instance of `ErrorClass`
|
|
* whose `.message` contains every string in `mustContain`. Fails loudly if
|
|
* the promise resolves instead of rejecting.
|
|
*/
|
|
async function expectRefusal(
|
|
promise: Promise<unknown>,
|
|
ErrorClass: new (...args: any[]) => Error,
|
|
...mustContain: string[]
|
|
): Promise<void> {
|
|
let threw = false
|
|
try {
|
|
await promise
|
|
} catch (err) {
|
|
threw = true
|
|
expect(err).toBeInstanceOf(ErrorClass)
|
|
for (const fragment of mustContain) {
|
|
expect((err as Error).message).toContain(fragment)
|
|
}
|
|
}
|
|
expect(threw).toBe(true)
|
|
}
|
|
|
|
describe.skipIf(!lawActive)('namespace law — bare/system/metadata field addressing', () => {
|
|
let brain: Brainy
|
|
|
|
beforeEach(async () => {
|
|
brain = new Brainy({
|
|
requireSubtype: false,
|
|
storage: { type: 'memory' as const },
|
|
embeddingFunction: stubEmbedding
|
|
})
|
|
await brain.init()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await brain.close()
|
|
})
|
|
|
|
/** The star case from the motivating incident: metadata.level 3/9/6. */
|
|
async function addLevelRows(): Promise<string[]> {
|
|
const ids: string[] = []
|
|
for (const level of [3, 9, 6]) {
|
|
ids.push(
|
|
await brain.add({
|
|
data: `probe level ${level}`,
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-level',
|
|
metadata: { name: `p-${level}`, level }
|
|
})
|
|
)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 1 — bare field name = the user's metadata field, always.
|
|
// -------------------------------------------------------------------
|
|
|
|
it("bare orderBy 'level' reads user metadata, desc and asc (the star case)", async () => {
|
|
await addLevelRows()
|
|
|
|
const desc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-level',
|
|
orderBy: 'level',
|
|
order: 'desc',
|
|
limit: 100
|
|
})
|
|
expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
|
|
|
|
const asc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-level',
|
|
orderBy: 'level',
|
|
order: 'asc',
|
|
limit: 100
|
|
})
|
|
expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9])
|
|
})
|
|
|
|
it("bare where { level: N } matches the user's field", async () => {
|
|
const ids = await addLevelRows()
|
|
const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-level', where: { level: 9 } })
|
|
expect(hit).toHaveLength(1)
|
|
expect(hit[0].id).toBe(ids[1])
|
|
expect(hit[0].metadata?.level).toBe(9)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 4 — metadata.<field> is the explicit-user-scope spelling,
|
|
// identical semantics to bare, valid on every path including orderBy.
|
|
// -------------------------------------------------------------------
|
|
|
|
it("'metadata.level' resolves identically to bare 'level'", async () => {
|
|
await addLevelRows()
|
|
const desc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-level',
|
|
orderBy: 'metadata.level',
|
|
order: 'desc',
|
|
limit: 100
|
|
})
|
|
expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 2 — system.<field> reaches an engine scalar explicitly.
|
|
// -------------------------------------------------------------------
|
|
|
|
it('system.createdAt sorts by entity age', async () => {
|
|
const ids: string[] = []
|
|
for (const name of ['first', 'second', 'third']) {
|
|
ids.push(
|
|
await brain.add({
|
|
data: `aged ${name}`,
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-aged',
|
|
metadata: { name }
|
|
})
|
|
)
|
|
// Guarantee distinct createdAt timestamps between adds.
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
|
|
const asc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-aged',
|
|
orderBy: 'system.createdAt',
|
|
order: 'asc',
|
|
limit: 100
|
|
})
|
|
expect(asc.map((r: any) => r.id)).toEqual(ids)
|
|
|
|
const desc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-aged',
|
|
orderBy: 'system.createdAt',
|
|
order: 'desc',
|
|
limit: 100
|
|
})
|
|
expect(desc.map((r: any) => r.id)).toEqual([...ids].reverse())
|
|
})
|
|
|
|
it('where on system.confidence filters by the engine scalar', async () => {
|
|
const highId = await brain.add({
|
|
data: 'high confidence row',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-confidence',
|
|
confidence: 0.95,
|
|
metadata: { name: 'hi' }
|
|
})
|
|
await brain.add({
|
|
data: 'low confidence row',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-confidence',
|
|
confidence: 0.4,
|
|
metadata: { name: 'lo' }
|
|
})
|
|
|
|
const hit = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-confidence',
|
|
where: { 'system.confidence': 0.95 }
|
|
})
|
|
expect(hit).toHaveLength(1)
|
|
expect(hit[0].id).toBe(highId)
|
|
})
|
|
|
|
it('groupBy on system.subtype groups by the engine scalar, not user metadata', async () => {
|
|
await brain.add({ data: 'i1', type: NounType.Document, subtype: 'invoice' })
|
|
await brain.add({ data: 'i2', type: NounType.Document, subtype: 'invoice' })
|
|
await brain.add({ data: 'r1', type: NounType.Document, subtype: 'receipt' })
|
|
|
|
brain.defineAggregate({
|
|
name: 'ns_law_by_subtype_system',
|
|
source: { type: NounType.Document },
|
|
groupBy: ['system.subtype'],
|
|
metrics: { count: { op: 'count' } }
|
|
})
|
|
|
|
const groups = await brain.queryAggregate('ns_law_by_subtype_system')
|
|
const invoiceGroup = groups.find((g) => Object.values(g.groupKey).includes('invoice'))
|
|
const receiptGroup = groups.find((g) => Object.values(g.groupKey).includes('receipt'))
|
|
expect(invoiceGroup?.metrics.count).toBe(2)
|
|
expect(receiptGroup?.metrics.count).toBe(1)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 1 (groupBy face) — bare groupBy dimensions read user metadata,
|
|
// never the engine's own notion of the same-sounding name.
|
|
// -------------------------------------------------------------------
|
|
|
|
it('groupBy on a bare user metadata field groups by that field', async () => {
|
|
await brain.add({
|
|
data: 'd1',
|
|
type: NounType.Document,
|
|
subtype: 'ns-law-group-bare',
|
|
metadata: { team: 'alpha' }
|
|
})
|
|
await brain.add({
|
|
data: 'd2',
|
|
type: NounType.Document,
|
|
subtype: 'ns-law-group-bare',
|
|
metadata: { team: 'alpha' }
|
|
})
|
|
await brain.add({
|
|
data: 'd3',
|
|
type: NounType.Document,
|
|
subtype: 'ns-law-group-bare',
|
|
metadata: { team: 'beta' }
|
|
})
|
|
|
|
brain.defineAggregate({
|
|
name: 'ns_law_by_team_bare',
|
|
// system.subtype — bare 'subtype' would address user metadata under the
|
|
// law (the exact migration every fleet consumer's aggregates make).
|
|
source: { type: NounType.Document, where: { 'system.subtype': 'ns-law-group-bare' } },
|
|
groupBy: ['team'],
|
|
metrics: { count: { op: 'count' } }
|
|
})
|
|
|
|
const groups = await brain.queryAggregate('ns_law_by_team_bare')
|
|
const alphaGroup = groups.find((g) => Object.values(g.groupKey).includes('alpha'))
|
|
const betaGroup = groups.find((g) => Object.values(g.groupKey).includes('beta'))
|
|
expect(alphaGroup?.metrics.count).toBe(2)
|
|
expect(betaGroup?.metrics.count).toBe(1)
|
|
})
|
|
|
|
it('where on a bare user metadata field filters normally (score, not a system name)', async () => {
|
|
await brain.add({
|
|
data: 'high score',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-score',
|
|
metadata: { score: 42 }
|
|
})
|
|
await brain.add({
|
|
data: 'low score',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-score',
|
|
metadata: { score: 7 }
|
|
})
|
|
|
|
const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-score', where: { score: 42 } })
|
|
expect(hit).toHaveLength(1)
|
|
expect(hit[0].metadata?.score).toBe(42)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 5 — typed refusals, naming both candidates.
|
|
// -------------------------------------------------------------------
|
|
|
|
it("bare orderBy 'createdAt' refuses when no such metadata field exists — names both candidates", async () => {
|
|
await brain.add({
|
|
data: 'no metadata.createdAt here',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-refuse-createdAt',
|
|
metadata: { name: 'x' }
|
|
})
|
|
|
|
await expectRefusal(
|
|
brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-refuse-createdAt',
|
|
orderBy: 'createdAt',
|
|
limit: 10
|
|
}),
|
|
UnresolvableFieldError,
|
|
'system.createdAt',
|
|
'metadata.createdAt'
|
|
)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 3 — invisible plumbing refuses in either spelling; system.<name>
|
|
// for a name that isn't in the ten-scalar map is unresolvable.
|
|
// -------------------------------------------------------------------
|
|
|
|
it('system.level refuses — level is invisible plumbing, never a system scalar', async () => {
|
|
await brain.add({
|
|
data: 'has a level metadata field',
|
|
type: NounType.Person,
|
|
metadata: { level: 5 }
|
|
})
|
|
await expectRefusal(brain.find({ orderBy: 'system.level', limit: 10 }), UnresolvableFieldError)
|
|
})
|
|
|
|
it('system.vector refuses — vector is invisible plumbing, never a system scalar', async () => {
|
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
|
await expectRefusal(brain.find({ orderBy: 'system.vector', limit: 10 }), UnresolvableFieldError)
|
|
})
|
|
|
|
it('system.data refuses — data is a payload container, never a system scalar', async () => {
|
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
|
await expectRefusal(brain.find({ orderBy: 'system.data', limit: 10 }), UnresolvableFieldError)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 6 — the ordering contract.
|
|
// -------------------------------------------------------------------
|
|
|
|
async function addOrderingProbeRows(): Promise<{ ranked: string[]; missing: string }> {
|
|
const low = await brain.add({
|
|
data: 'low score',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ordering',
|
|
metadata: { score: 5 }
|
|
})
|
|
const high = await brain.add({
|
|
data: 'high score',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ordering',
|
|
metadata: { score: 9 }
|
|
})
|
|
const missing = await brain.add({
|
|
data: 'no score field at all',
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ordering',
|
|
metadata: { name: 'no-score' }
|
|
})
|
|
return { ranked: [low, high], missing }
|
|
}
|
|
|
|
it('a row missing the orderBy field sorts LAST in desc — and is never dropped', async () => {
|
|
const { ranked, missing } = await addOrderingProbeRows()
|
|
const desc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ordering',
|
|
orderBy: 'score',
|
|
order: 'desc',
|
|
limit: 100
|
|
})
|
|
expect(desc).toHaveLength(3)
|
|
expect(desc.map((r: any) => r.id)).toEqual([ranked[1], ranked[0], missing])
|
|
})
|
|
|
|
it('a row missing the orderBy field sorts LAST in asc too — and is never dropped', async () => {
|
|
const { ranked, missing } = await addOrderingProbeRows()
|
|
const asc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ordering',
|
|
orderBy: 'score',
|
|
order: 'asc',
|
|
limit: 100
|
|
})
|
|
expect(asc).toHaveLength(3)
|
|
expect(asc.map((r: any) => r.id)).toEqual([ranked[0], ranked[1], missing])
|
|
})
|
|
|
|
it('ties on the orderBy field break by id ascending, in BOTH directions', async () => {
|
|
const tiedIds: string[] = []
|
|
for (let i = 0; i < 4; i++) {
|
|
tiedIds.push(
|
|
await brain.add({
|
|
data: `tied ${i}`,
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ties',
|
|
metadata: { score: 5 }
|
|
})
|
|
)
|
|
}
|
|
const expectedOrder = [...tiedIds].sort()
|
|
|
|
const asc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ties',
|
|
orderBy: 'score',
|
|
order: 'asc',
|
|
limit: 100
|
|
})
|
|
expect(asc.map((r: any) => r.id)).toEqual(expectedOrder)
|
|
|
|
const desc = await brain.find({
|
|
type: NounType.Person,
|
|
subtype: 'ns-law-ties',
|
|
orderBy: 'score',
|
|
order: 'desc',
|
|
limit: 100
|
|
})
|
|
// Same tie-break ordering regardless of the primary direction — the
|
|
// contract states one universal rule ("id ascending"), not "reverse of
|
|
// the primary order".
|
|
expect(desc.map((r: any) => r.id)).toEqual(expectedOrder)
|
|
})
|
|
|
|
// -------------------------------------------------------------------
|
|
// Rule 5 (options face) — unimplemented find() options refuse loudly
|
|
// instead of being accepted and silently ignored.
|
|
// -------------------------------------------------------------------
|
|
|
|
it('find({ cursor }) refuses with UnsupportedFindOptionError', async () => {
|
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
|
await expectRefusal(brain.find({ cursor: 'anything', limit: 10 }), UnsupportedFindOptionError)
|
|
})
|
|
|
|
it('find({ includeRelations }) refuses with UnsupportedFindOptionError', async () => {
|
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
|
await expectRefusal(brain.find({ includeRelations: true, limit: 10 }), UnsupportedFindOptionError)
|
|
})
|
|
|
|
it('find({ writeOnly }) refuses with UnsupportedFindOptionError', async () => {
|
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
|
await expectRefusal(brain.find({ writeOnly: true, limit: 10 }), UnsupportedFindOptionError)
|
|
})
|
|
})
|