2026-05-27 13:13:47 -07:00
/ * *
* @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 } > = [ ]
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
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)
2026-06-09 14:58:25 -07:00
const brain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
2026-05-27 13:13:47 -07:00
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 } > = [ ]
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
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)
2026-06-09 14:58:25 -07:00
const brain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
2026-05-27 13:13:47 -07:00
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 } > = [ ]
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
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)
2026-06-09 14:58:25 -07:00
const brain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
2026-05-27 13:13:47 -07:00
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 ( )
} )
} )