2025-11-14 10:26:23 -08:00
|
|
|
/**
|
|
|
|
|
* Duplicate Relationship Check Optimization Tests
|
|
|
|
|
*
|
|
|
|
|
* Tests for v5.8.0 optimization that uses GraphAdjacencyIndex
|
|
|
|
|
* for O(log n) duplicate detection instead of O(n) storage scan.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { Brainy } from '../../../src/brainy.js'
|
|
|
|
|
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
|
|
|
|
|
|
|
|
|
describe('Duplicate Check Optimization', () => {
|
|
|
|
|
let brain: Brainy
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
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
|
|
|
brain = new Brainy({ requireSubtype: false })
|
2025-11-14 10:26:23 -08:00
|
|
|
await brain.init()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
// Cleanup is automatic with memory storage
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should detect duplicate relationships using GraphAdjacencyIndex', async () => {
|
|
|
|
|
// Create two entities
|
|
|
|
|
const personId = await brain.add({
|
|
|
|
|
data: { name: 'Alice' },
|
|
|
|
|
type: NounType.Person
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const orgId = await brain.add({
|
|
|
|
|
data: { name: 'Acme Corp' },
|
|
|
|
|
type: NounType.Organization
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create first relationship
|
|
|
|
|
const relationId1 = await brain.relate({
|
|
|
|
|
from: personId,
|
|
|
|
|
to: orgId,
|
|
|
|
|
type: VerbType.ParticipatesIn
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Attempt to create duplicate relationship
|
|
|
|
|
const relationId2 = await brain.relate({
|
|
|
|
|
from: personId,
|
|
|
|
|
to: orgId,
|
|
|
|
|
type: VerbType.ParticipatesIn
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Should return the same ID (duplicate detected)
|
|
|
|
|
expect(relationId2).toBe(relationId1)
|
|
|
|
|
|
|
|
|
|
// Verify only one relationship exists
|
|
|
|
|
const relations = await brain.getRelations({ from: personId })
|
|
|
|
|
expect(relations).toHaveLength(1)
|
|
|
|
|
expect(relations[0].id).toBe(relationId1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should allow different relationship types between same entities', async () => {
|
|
|
|
|
const personId = await brain.add({
|
|
|
|
|
data: { name: 'Bob' },
|
|
|
|
|
type: NounType.Person
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const projectId = await brain.add({
|
|
|
|
|
data: { name: 'Project X' },
|
|
|
|
|
type: NounType.Thing
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create first relationship
|
|
|
|
|
const relationId1 = await brain.relate({
|
|
|
|
|
from: personId,
|
|
|
|
|
to: projectId,
|
|
|
|
|
type: VerbType.Creates
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create second relationship with different type (not a duplicate)
|
|
|
|
|
const relationId2 = await brain.relate({
|
|
|
|
|
from: personId,
|
|
|
|
|
to: projectId,
|
|
|
|
|
type: VerbType.Modifies
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Should be different IDs (different verb types)
|
|
|
|
|
expect(relationId2).not.toBe(relationId1)
|
|
|
|
|
|
|
|
|
|
// Verify both relationships exist
|
|
|
|
|
const relations = await brain.getRelations({ from: personId })
|
|
|
|
|
expect(relations).toHaveLength(2)
|
|
|
|
|
// Both relations should exist with different IDs
|
|
|
|
|
const relationIds = relations.map(r => r.id)
|
|
|
|
|
expect(relationIds).toContain(relationId1)
|
|
|
|
|
expect(relationIds).toContain(relationId2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle duplicate check with many relationships (performance)', async () => {
|
|
|
|
|
// Create source entity
|
|
|
|
|
const sourceId = await brain.add({
|
|
|
|
|
data: { name: 'Hub Entity' },
|
|
|
|
|
type: NounType.Thing
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create 50 target entities and relationships
|
|
|
|
|
const targetIds: string[] = []
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
const targetId = await brain.add({
|
|
|
|
|
data: { name: `Target ${i}` },
|
|
|
|
|
type: NounType.Thing
|
|
|
|
|
})
|
|
|
|
|
targetIds.push(targetId)
|
|
|
|
|
|
|
|
|
|
await brain.relate({
|
|
|
|
|
from: sourceId,
|
|
|
|
|
to: targetId,
|
|
|
|
|
type: VerbType.RelatesTo
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now attempt to create duplicate with first target (should be fast with GraphIndex)
|
|
|
|
|
const startTime = performance.now()
|
|
|
|
|
const duplicateId = await brain.relate({
|
|
|
|
|
from: sourceId,
|
|
|
|
|
to: targetIds[0],
|
|
|
|
|
type: VerbType.RelatesTo
|
|
|
|
|
})
|
|
|
|
|
const elapsed = performance.now() - startTime
|
|
|
|
|
|
|
|
|
|
// Should be fast with O(log n) GraphIndex lookup (< 10ms even with 50 relationships)
|
|
|
|
|
expect(elapsed).toBeLessThan(10)
|
|
|
|
|
|
|
|
|
|
// Verify duplicate was detected
|
|
|
|
|
const relations = await brain.getRelations({ from: sourceId })
|
|
|
|
|
expect(relations).toHaveLength(50) // No duplicate created
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should use cached verb data for duplicate check', async () => {
|
|
|
|
|
const entityA = await brain.add({
|
|
|
|
|
data: { name: 'Entity A' },
|
|
|
|
|
type: NounType.Thing
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const entityB = await brain.add({
|
|
|
|
|
data: { name: 'Entity B' },
|
|
|
|
|
type: NounType.Thing
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create relationship
|
|
|
|
|
const relationId1 = await brain.relate({
|
|
|
|
|
from: entityA,
|
|
|
|
|
to: entityB,
|
|
|
|
|
type: VerbType.RelatesTo
|
|
|
|
|
})
|
|
|
|
|
|
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).
2026-06-10 10:45:45 -07:00
|
|
|
// Access GraphIndex to ensure verb is cached (8.0 BigInt boundary:
|
|
|
|
|
// UUID → entity int in, verb ints out, resolved back via verbIntsToIds)
|
|
|
|
|
const idMapper = (brain as any).metadataIndex.getIdMapper()
|
|
|
|
|
const sourceInt = BigInt(idMapper.getInt(entityA)!)
|
|
|
|
|
const verbInts: bigint[] = await (brain as any).graphIndex.getVerbIdsBySource(sourceInt)
|
|
|
|
|
const verbIds = await (brain as any).graphIndex.verbIntsToIds(verbInts)
|
2025-11-14 10:26:23 -08:00
|
|
|
expect(verbIds).toContain(relationId1)
|
|
|
|
|
|
|
|
|
|
// Attempt duplicate (should use cached verb)
|
|
|
|
|
const startTime = performance.now()
|
|
|
|
|
const relationId2 = await brain.relate({
|
|
|
|
|
from: entityA,
|
|
|
|
|
to: entityB,
|
|
|
|
|
type: VerbType.RelatesTo
|
|
|
|
|
})
|
|
|
|
|
const elapsed = performance.now() - startTime
|
|
|
|
|
|
|
|
|
|
// Should be very fast with cached verb (< 5ms)
|
|
|
|
|
expect(elapsed).toBeLessThan(5)
|
|
|
|
|
expect(relationId2).toBe(relationId1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should handle duplicate check across multiple verb types efficiently', async () => {
|
|
|
|
|
const person = await brain.add({
|
|
|
|
|
data: { name: 'Charlie' },
|
|
|
|
|
type: NounType.Person
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const org = await brain.add({
|
|
|
|
|
data: { name: 'BigCorp' },
|
|
|
|
|
type: NounType.Organization
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Create different relationship types
|
|
|
|
|
const rel1 = await brain.relate({
|
|
|
|
|
from: person,
|
|
|
|
|
to: org,
|
|
|
|
|
type: VerbType.Affects
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const rel2 = await brain.relate({
|
|
|
|
|
from: person,
|
|
|
|
|
to: org,
|
|
|
|
|
type: VerbType.Owns
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify both relationships exist
|
|
|
|
|
let relations = await brain.getRelations({ from: person })
|
|
|
|
|
expect(relations).toHaveLength(2)
|
|
|
|
|
const relationIds = relations.map(r => r.id)
|
|
|
|
|
expect(relationIds).toContain(rel1)
|
|
|
|
|
expect(relationIds).toContain(rel2)
|
|
|
|
|
|
|
|
|
|
// Attempt duplicate of first relationship type
|
|
|
|
|
const startTime = performance.now()
|
|
|
|
|
const duplicate = await brain.relate({
|
|
|
|
|
from: person,
|
|
|
|
|
to: org,
|
|
|
|
|
type: VerbType.Affects
|
|
|
|
|
})
|
|
|
|
|
const elapsed = performance.now() - startTime
|
|
|
|
|
|
|
|
|
|
// Should detect duplicate efficiently (< 20ms)
|
|
|
|
|
expect(elapsed).toBeLessThan(20)
|
|
|
|
|
expect(duplicate).toBe(rel1)
|
|
|
|
|
|
|
|
|
|
// Verify still same number of relationships (no duplicate added)
|
|
|
|
|
const finalRelations = await brain.getRelations({ from: person })
|
|
|
|
|
expect(finalRelations.length).toBe(relations.length)
|
|
|
|
|
})
|
|
|
|
|
})
|