Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
/**
|
|
* @module tests/unit/utils/resultRanking
|
|
* @description Tests for the `sort:topK` result-ranking hook.
|
|
*
|
|
* Two layers:
|
|
* 1. Unit — the swappable dispatcher (`rankIndicesByScore` / `setSortTopKImplementation`):
|
|
* JS default ordering matches `Array.prototype.sort`, a native provider is routed
|
|
* through, and a misbehaving provider falls back to JS.
|
|
* 2. Integration — `brain.find()` ranking routes through a registered `sort:topK`
|
|
* provider, AND the JS fallback (no provider) produces identical results/order.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import {
|
|
rankIndicesByScore,
|
|
reorderByIndices,
|
|
sortTopKIndicesJs,
|
|
setSortTopKImplementation,
|
|
type SortTopKFn
|
|
} from '../../../src/utils/resultRanking.js'
|
|
import { Brainy, NounType } from '../../../src/index.js'
|
|
import type { BrainyPlugin } from '../../../src/plugin.js'
|
|
|
|
/**
|
|
* Reference ranking: exactly what the old `results.sort((a, b) => b.score - a.score)`
|
|
* followed by `slice(0, k)` produced. Used to assert the hook never changes ordering.
|
|
*/
|
|
function referenceTopK(scores: number[], k: number, descending: boolean): number[] {
|
|
const idx = scores.map((_, i) => i)
|
|
idx.sort((a, b) => (descending ? scores[b] - scores[a] : scores[a] - scores[b]) || a - b)
|
|
return idx.slice(0, Math.min(k, scores.length))
|
|
}
|
|
|
|
describe('resultRanking — sortTopKIndicesJs (canonical JS ranking)', () => {
|
|
it('orders descending by score and truncates to k', () => {
|
|
const scores = [0.1, 0.9, 0.5, 0.7, 0.3]
|
|
expect(sortTopKIndicesJs(scores, 3, true)).toEqual([1, 3, 2]) // 0.9, 0.7, 0.5
|
|
})
|
|
|
|
it('orders ascending when descending=false', () => {
|
|
const scores = [0.1, 0.9, 0.5]
|
|
expect(sortTopKIndicesJs(scores, 3, false)).toEqual([0, 2, 1]) // 0.1, 0.5, 0.9
|
|
})
|
|
|
|
it('breaks ties by original index (stable), matching Array.sort', () => {
|
|
// Three equal scores plus a higher one. Stable order keeps 0,2,3 in input order.
|
|
const scores = [0.5, 0.9, 0.5, 0.5]
|
|
expect(sortTopKIndicesJs(scores, 4, true)).toEqual([1, 0, 2, 3])
|
|
})
|
|
|
|
it('returns [] for empty input or non-positive k', () => {
|
|
expect(sortTopKIndicesJs([], 5, true)).toEqual([])
|
|
expect(sortTopKIndicesJs([0.1, 0.2], 0, true)).toEqual([])
|
|
expect(sortTopKIndicesJs([0.1, 0.2], -1, true)).toEqual([])
|
|
})
|
|
|
|
it('returns all indices when k exceeds length', () => {
|
|
expect(sortTopKIndicesJs([0.2, 0.1], 100, true)).toEqual([0, 1])
|
|
})
|
|
|
|
it('matches the reference Array.sort ordering across random inputs', () => {
|
|
for (let trial = 0; trial < 200; trial++) {
|
|
const n = 1 + Math.floor(Math.random() * 40)
|
|
// Include duplicates so tie-stability is exercised.
|
|
const scores = Array.from({ length: n }, () => Math.floor(Math.random() * 5) / 5)
|
|
const k = 1 + Math.floor(Math.random() * (n + 2))
|
|
expect(sortTopKIndicesJs(scores, k, true)).toEqual(referenceTopK(scores, k, true))
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('resultRanking — reorderByIndices', () => {
|
|
it('reorders elements according to the index order', () => {
|
|
const items = ['a', 'b', 'c', 'd']
|
|
expect(reorderByIndices(items, [2, 0, 3])).toEqual(['c', 'a', 'd'])
|
|
})
|
|
|
|
it('returns an empty array for an empty order', () => {
|
|
expect(reorderByIndices(['a', 'b'], [])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('resultRanking — rankIndicesByScore dispatcher + provider hook', () => {
|
|
// Always restore the JS default so a swapped impl never leaks into other tests.
|
|
afterEach(() => setSortTopKImplementation(sortTopKIndicesJs))
|
|
|
|
it('uses the JS implementation by default (identical to reference)', () => {
|
|
const scores = [0.4, 0.8, 0.1, 0.8]
|
|
expect(rankIndicesByScore(scores, 3, true)).toEqual(referenceTopK(scores, 3, true))
|
|
})
|
|
|
|
it('routes through a registered sort:topK provider with the documented signature', () => {
|
|
const calls: Array<[number[], number, boolean]> = []
|
|
const provider: SortTopKFn = (scores, k, descending) => {
|
|
calls.push([scores, k, descending])
|
|
// Return a valid (correct) ranking so the dispatcher accepts it.
|
|
return sortTopKIndicesJs(scores, k, descending)
|
|
}
|
|
setSortTopKImplementation(provider)
|
|
|
|
const scores = [0.2, 0.9, 0.5]
|
|
const order = rankIndicesByScore(scores, 2, true)
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0][0]).toBe(scores) // exact scores array
|
|
expect(calls[0][1]).toBe(2) // k
|
|
expect(calls[0][2]).toBe(true) // descending
|
|
expect(order).toEqual([1, 2]) // 0.9, 0.5
|
|
})
|
|
|
|
it('falls back to JS when a provider returns the wrong length', () => {
|
|
setSortTopKImplementation(() => [0]) // wrong length (expected 2)
|
|
const scores = [0.2, 0.9, 0.5]
|
|
expect(rankIndicesByScore(scores, 2, true)).toEqual(referenceTopK(scores, 2, true))
|
|
})
|
|
|
|
it('falls back to JS when a provider returns out-of-range or duplicate indices', () => {
|
|
setSortTopKImplementation(() => [0, 99]) // 99 out of range
|
|
expect(rankIndicesByScore([0.2, 0.9, 0.5], 2, true)).toEqual([1, 2])
|
|
|
|
setSortTopKImplementation(() => [1, 1]) // duplicate index
|
|
expect(rankIndicesByScore([0.2, 0.9, 0.5], 2, true)).toEqual([1, 2])
|
|
})
|
|
|
|
it('falls back to JS when a provider throws', () => {
|
|
setSortTopKImplementation(() => {
|
|
throw new Error('native boom')
|
|
})
|
|
expect(rankIndicesByScore([0.2, 0.9, 0.5], 2, true)).toEqual([1, 2])
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Plugin that registers a recording `sort:topK` provider. The provider delegates to the
|
|
* correct JS ranking so results are valid, while recording that it was invoked — proving
|
|
* find()'s ranking routes through the hook.
|
|
*/
|
|
function makeSortTopKPlugin(calls: Array<{ k: number; descending: boolean; n: number }>): BrainyPlugin {
|
|
return {
|
|
name: 'test-sort-topk',
|
|
activate: async (ctx) => {
|
|
ctx.registerProvider('sort:topK', (scores: number[], k: number, descending: boolean) => {
|
|
calls.push({ k, descending, n: scores.length })
|
|
return sortTopKIndicesJs(scores, k, descending)
|
|
})
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('resultRanking — Brainy.find() integration', () => {
|
|
// Restore JS default after every test: brain.init() installs the provider process-wide
|
|
// via setSortTopKImplementation, so we must reset it to avoid leaking into other suites.
|
|
afterEach(() => setSortTopKImplementation(sortTopKIndicesJs))
|
|
|
|
/** Seed a brain with deterministic data covering several types. */
|
|
async function seed(brain: Brainy): Promise<void> {
|
|
const docs = [
|
|
{ data: 'red apple fruit', type: NounType.Concept, metadata: { name: 'apple' } },
|
|
{ data: 'green pear fruit', type: NounType.Concept, metadata: { name: 'pear' } },
|
|
{ data: 'yellow banana fruit', type: NounType.Concept, metadata: { name: 'banana' } },
|
|
{ data: 'orange citrus fruit', type: NounType.Concept, metadata: { name: 'orange' } },
|
|
{ data: 'purple grape fruit', type: NounType.Concept, metadata: { name: 'grape' } },
|
|
{ data: 'blue car vehicle', type: NounType.Thing, metadata: { name: 'car' } },
|
|
{ data: 'fast train vehicle', type: NounType.Thing, metadata: { name: 'train' } }
|
|
]
|
|
for (const d of docs) await brain.add(d)
|
|
}
|
|
|
|
it('routes find() relevance ranking through a registered sort:topK provider', async () => {
|
|
const calls: Array<{ k: number; descending: boolean; n: number }> = []
|
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
|
brain.use(makeSortTopKPlugin(calls))
|
|
await brain.init()
|
|
await seed(brain)
|
|
|
|
const results = await brain.find({ query: 'fruit', limit: 3 })
|
|
|
|
// The provider must have been invoked for relevance ranking, with descending order
|
|
// and k == offset(0) + limit(3).
|
|
expect(calls.length).toBeGreaterThan(0)
|
|
const ranking = calls.find(c => c.descending && c.k === 3)
|
|
expect(ranking).toBeDefined()
|
|
expect(results.length).toBeGreaterThan(0)
|
|
expect(results.length).toBeLessThanOrEqual(3)
|
|
|
|
await brain.close()
|
|
})
|
|
|
|
it('produces identical results via the provider and the JS fallback (same brain, same data)', async () => {
|
|
// The active sort:topK implementation is a process-global, so the only sound way to
|
|
// compare provider-vs-JS is on ONE brain over identical persisted data: run with the
|
|
// provider active, then reset to JS and re-run the same query. The candidate set and
|
|
// scores are identical across runs — only the ranking code path differs — so ids,
|
|
// order, and scores must match exactly. (Two separate brains would differ due to
|
|
// independent approximate-NN candidate selection, not the ranking hook.)
|
|
const calls: Array<{ k: number; descending: boolean; n: number }> = []
|
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
|
brain.use(makeSortTopKPlugin(calls))
|
|
await brain.init()
|
|
await seed(brain)
|
|
|
|
const query = { query: 'fruit', limit: 4 }
|
|
const providerResults = await brain.find({ ...query })
|
|
expect(calls.length).toBeGreaterThan(0)
|
|
|
|
// Now disable the provider (restore JS) and re-run the identical query on the same brain.
|
|
setSortTopKImplementation(sortTopKIndicesJs)
|
|
const jsResults = await brain.find({ ...query })
|
|
|
|
expect(providerResults.map(r => r.id)).toEqual(jsResults.map(r => r.id))
|
|
expect(providerResults.map(r => r.score)).toEqual(jsResults.map(r => r.score))
|
|
|
|
await brain.close()
|
|
})
|
|
|
|
it('returns results in non-increasing score order and requests k = offset + limit', async () => {
|
|
// Ordering correctness is the hook's contract; assert it directly on find() output
|
|
// rather than relying on cross-query ANN stability. Also assert the provider is asked
|
|
// for the full page (offset + limit), which is what makes pagination correct.
|
|
const calls: Array<{ k: number; descending: boolean; n: number }> = []
|
|
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true })
|
|
brain.use(makeSortTopKPlugin(calls))
|
|
await brain.init()
|
|
await seed(brain)
|
|
|
|
const page = await brain.find({ query: 'fruit', limit: 2, offset: 2 })
|
|
|
|
// Scores within the returned page must be non-increasing (descending ranking).
|
|
for (let i = 1; i < page.length; i++) {
|
|
expect(page[i].score).toBeLessThanOrEqual(page[i - 1].score)
|
|
}
|
|
// The offset page must request k = offset(2) + limit(2) = 4 from the provider.
|
|
expect(calls.some(c => c.descending && c.k === 4)).toBe(true)
|
|
|
|
await brain.close()
|
|
})
|
|
})
|