feat(8.0): u64 BigInt graph provider contract — punch list a-d,g,h
GraphIndexProvider now speaks BigInt at the boundary (D.2 mirror): - getNeighbors/getVerbIdsBySource/getVerbIdsByTarget take entity ints and return entity/verb ints as bigint[] - new REQUIRED verbIntsToIds(bigint[]) batch reverse resolver (L.7 identity-fingerprint design — verb ids are UUIDs by contract, so the provider-side interning is losslessly reversible) - addVerb(verb, sourceInt, targetInt) returns the interned verb int; removeVerb(verbId) joins the contract Coordinator (brainy.ts) owns ALL UUID <-> int conversion: getOrAssign on writes, getInt on reads (unmapped UUID -> empty result without calling the provider), getUuid / verbIntsToIds on returns, plus a bounded ~100k-entry insertion-order warm cache for verb-int -> verb-id pairs fed by addVerb returns and resolver results. GraphVerb gains derived sourceInt/targetInt (populated at add time, never persisted). findConnectedSubtype gains a native fast path that routes single-type single-subtype outgoing BFS through the provider when available. JS GraphAdjacencyIndex satisfies the contract while staying string/u32-keyed internally: entity ints resolve through the shared entity-id mapper (threaded in by the coordinator on init/fork/checkout), verb ints come from an in-process append-only interning map re-derived from storage on rebuild/cold-start. ColumnStoreProvider widens the same way: addEntity/ removeEntity take bigint, sortTopK/filteredSortTopK return bigint[]. relate() now rejects a caller-supplied id with a teaching error — verb ids are brainy-generated UUIDs by contract in 8.0 (previously a passed id was silently ignored). No Roaring64 provider-boundary decode site exists yet; the JS-internal column store stays Roaring32 and the Treemap decoder lands with the first consumer of provider-returned filter buffers. Public brain API unchanged. 1413 tests green (+10 new BigInt contract tests).
This commit is contained in:
parent
62f6472fa0
commit
2427bb7960
17 changed files with 1164 additions and 328 deletions
230
tests/unit/graph/bigint-contract.test.ts
Normal file
230
tests/unit/graph/bigint-contract.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* @module bigint-contract.test
|
||||
* @description 8.0 u64 provider contract — BigInt boundary of the JS
|
||||
* GraphAdjacencyIndex and the coordinator that drives it.
|
||||
*
|
||||
* Covers:
|
||||
* - bigint round-trip through the JS graph index: `addVerb` returns a stable
|
||||
* verb int, `getVerbIdsBySource`/`getVerbIdsByTarget` return that int, and
|
||||
* `verbIntsToIds` maps it back to the verb-id string (null for unknown ints)
|
||||
* - `getNeighbors` speaks entity ints both ways via the shared idMapper
|
||||
* - unmapped entity ints / UUIDs produce empty reads, never errors
|
||||
* - a missing idMapper fails loudly (wiring bug, not a silent fallback)
|
||||
* - coordinator-level behavior: relate → getRelations/neighbors → unrelate
|
||||
* works end-to-end over the BigInt boundary (public API unchanged)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { GraphAdjacencyIndex } from '../../../src/graph/graphAdjacencyIndex.js'
|
||||
import { EntityIdMapper } from '../../../src/utils/entityIdMapper.js'
|
||||
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import type { GraphVerb } from '../../../src/coreTypes.js'
|
||||
|
||||
// Verb ids are UUID-shaped by contract in 8.0 (storage enforces the shape on
|
||||
// every save; cortex keys verb-int interning on the raw UUID bytes).
|
||||
const VERB_UUID_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const VERB_UUID_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const VERB_UUID_3 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
/** Build a minimal GraphVerb for direct index-level tests. */
|
||||
function makeVerb(id: string, sourceId: string, targetId: string): GraphVerb {
|
||||
return {
|
||||
id,
|
||||
sourceId,
|
||||
targetId,
|
||||
vector: [],
|
||||
type: VerbType.RelatedTo,
|
||||
verb: VerbType.RelatedTo
|
||||
}
|
||||
}
|
||||
|
||||
describe('GraphAdjacencyIndex — BigInt boundary (JS implementation)', () => {
|
||||
let storage: MemoryStorage
|
||||
let idMapper: EntityIdMapper
|
||||
let index: GraphAdjacencyIndex
|
||||
|
||||
beforeEach(async () => {
|
||||
storage = new MemoryStorage()
|
||||
await storage.init()
|
||||
idMapper = new EntityIdMapper({ storage, storageKey: 'test:graph:idMapper' })
|
||||
await idMapper.init()
|
||||
index = new GraphAdjacencyIndex(storage, {}, idMapper)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await index.close()
|
||||
})
|
||||
|
||||
it('addVerb returns a stable verb int; reads return it; verbIntsToIds maps back', async () => {
|
||||
const sourceInt = BigInt(idMapper.getOrAssign('uuid-source'))
|
||||
const targetInt = BigInt(idMapper.getOrAssign('uuid-target'))
|
||||
const verb = makeVerb(VERB_UUID_1, 'uuid-source', 'uuid-target')
|
||||
verb.sourceInt = sourceInt
|
||||
verb.targetInt = targetInt
|
||||
|
||||
const verbInt = await index.addVerb(verb, sourceInt, targetInt)
|
||||
expect(typeof verbInt).toBe('bigint')
|
||||
|
||||
// Re-adding the same verb id returns the SAME interned int (stable).
|
||||
const verbIntAgain = await index.addVerb(verb, sourceInt, targetInt)
|
||||
expect(verbIntAgain).toBe(verbInt)
|
||||
|
||||
// Both directed verb-int reads surface the int from addVerb.
|
||||
const bySource = await index.getVerbIdsBySource(sourceInt)
|
||||
expect(bySource).toContain(verbInt)
|
||||
|
||||
const byTarget = await index.getVerbIdsByTarget(targetInt)
|
||||
expect(byTarget).toContain(verbInt)
|
||||
|
||||
// Batch reverse resolution is order-preserving with null for unknowns.
|
||||
const resolved = await index.verbIntsToIds([verbInt, 999_999n])
|
||||
expect(resolved).toEqual([VERB_UUID_1, null])
|
||||
})
|
||||
|
||||
it('getNeighbors speaks entity ints in both directions', async () => {
|
||||
const aInt = BigInt(idMapper.getOrAssign('uuid-a'))
|
||||
const bInt = BigInt(idMapper.getOrAssign('uuid-b'))
|
||||
const verb = makeVerb(VERB_UUID_2, 'uuid-a', 'uuid-b')
|
||||
|
||||
await index.addVerb(verb, aInt, bInt)
|
||||
|
||||
const out = await index.getNeighbors(aInt, { direction: 'out' })
|
||||
expect(out).toEqual([bInt])
|
||||
|
||||
const incoming = await index.getNeighbors(bInt, { direction: 'in' })
|
||||
expect(incoming).toEqual([aInt])
|
||||
|
||||
const both = await index.getNeighbors(aInt)
|
||||
expect(both).toContain(bInt)
|
||||
})
|
||||
|
||||
it('reads for unmapped entity ints return empty without error', async () => {
|
||||
// 424242 was never assigned by the mapper — no UUID behind it.
|
||||
const ghost = 424_242n
|
||||
expect(await index.getNeighbors(ghost)).toEqual([])
|
||||
expect(await index.getVerbIdsBySource(ghost)).toEqual([])
|
||||
expect(await index.getVerbIdsByTarget(ghost)).toEqual([])
|
||||
})
|
||||
|
||||
it('verb ints stay resolvable after removeVerb (interning is append-only)', async () => {
|
||||
const sInt = BigInt(idMapper.getOrAssign('uuid-s'))
|
||||
const tInt = BigInt(idMapper.getOrAssign('uuid-t'))
|
||||
const verb = makeVerb(VERB_UUID_3, 'uuid-s', 'uuid-t')
|
||||
|
||||
const verbInt = await index.addVerb(verb, sInt, tInt)
|
||||
// Persist the verb (vector + metadata — getVerb requires both) so
|
||||
// removeVerb can resolve its type for count updates.
|
||||
await storage.saveVerb({
|
||||
id: verb.id,
|
||||
vector: [],
|
||||
connections: new Map<number, Set<string>>(),
|
||||
verb: VerbType.RelatedTo,
|
||||
sourceId: verb.sourceId,
|
||||
targetId: verb.targetId
|
||||
})
|
||||
await storage.saveVerbMetadata(verb.id, {
|
||||
verb: VerbType.RelatedTo,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
await index.removeVerb(verb.id)
|
||||
|
||||
// Removed verb no longer appears in reads…
|
||||
expect(await index.getVerbIdsBySource(sInt)).toEqual([])
|
||||
// …but its int still reverse-resolves (append-only interning).
|
||||
expect(await index.verbIntsToIds([verbInt])).toEqual([VERB_UUID_3])
|
||||
})
|
||||
|
||||
it('fails loudly when the entityIdMapper is not wired', async () => {
|
||||
const bare = new GraphAdjacencyIndex(storage)
|
||||
await expect(bare.getNeighbors(1n)).rejects.toThrow(/entityIdMapper not wired/)
|
||||
await bare.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Coordinator — relate/related end-to-end over the BigInt boundary', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('relate → getRelations → neighbors round-trips (public API unchanged)', async () => {
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Ada' },
|
||||
type: NounType.Person
|
||||
})
|
||||
const projectId = await brain.add({
|
||||
data: { name: 'Analytical Engine' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const relId = await brain.relate({
|
||||
from: personId,
|
||||
to: projectId,
|
||||
type: VerbType.WorksWith
|
||||
})
|
||||
|
||||
// getRelations resolves verb ints back to verb-id strings internally.
|
||||
const relations = await brain.getRelations({ from: personId })
|
||||
expect(relations).toHaveLength(1)
|
||||
expect(relations[0].id).toBe(relId)
|
||||
expect(relations[0].to).toBe(projectId)
|
||||
|
||||
// neighbors() routes through getNeighbors(bigint) + int → UUID mapping.
|
||||
const out = await brain.neighbors(personId, { direction: 'outgoing' })
|
||||
expect(out).toContain(projectId)
|
||||
|
||||
const incoming = await brain.neighbors(projectId, { direction: 'incoming' })
|
||||
expect(incoming).toContain(personId)
|
||||
})
|
||||
|
||||
it('relate feeds the verb-int warm cache; duplicate detection still works', async () => {
|
||||
const a = await brain.add({ data: { name: 'A' }, type: NounType.Thing })
|
||||
const b = await brain.add({ data: { name: 'B' }, type: NounType.Thing })
|
||||
|
||||
const first = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
|
||||
|
||||
// The AddToGraphIndexOperation callback recorded the verb int → id pair.
|
||||
const warmCache: Map<bigint, string> = (brain as any).verbIntWarmCache
|
||||
expect([...warmCache.values()]).toContain(first)
|
||||
|
||||
// Duplicate check runs through getVerbIdsBySource(bigint) + resolution.
|
||||
const duplicate = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
|
||||
expect(duplicate).toBe(first)
|
||||
})
|
||||
|
||||
it('unrelate removes the edge from BigInt-boundary reads', async () => {
|
||||
const a = await brain.add({ data: { name: 'A' }, type: NounType.Thing })
|
||||
const b = await brain.add({ data: { name: 'B' }, type: NounType.Thing })
|
||||
|
||||
const relId = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
|
||||
await brain.unrelate(relId)
|
||||
|
||||
const relations = await brain.getRelations({ from: a })
|
||||
expect(relations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('neighbors() of an unknown UUID returns empty without error', async () => {
|
||||
const result = await brain.neighbors('00000000-0000-4000-8000-000000000000')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('relate() rejects a caller-supplied verb id with a teaching error (8.0 contract)', async () => {
|
||||
const a = await brain.add({ data: { name: 'A' }, type: NounType.Thing })
|
||||
const b = await brain.add({ data: { name: 'B' }, type: NounType.Thing })
|
||||
|
||||
// RelateParams has no `id` field — untyped callers passing one used to be
|
||||
// silently ignored. 8.0 teaches: verb ids are brainy-generated UUIDs.
|
||||
await expect(
|
||||
brain.relate({ from: a, to: b, type: VerbType.RelatedTo, id: 'my-custom-id' } as any)
|
||||
).rejects.toThrow(/Verb ids are UUIDs by contract in 8\.0/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue