perf(8.0): visibility-aware fast adjacency — related() stays O(degree) under default visibility
related({from/to}) routes through storage.getVerbs, which has O(degree)
GraphAdjacencyIndex fast paths (getVerbsBySource/Target/Type_internal). But
those fast paths were disqualified whenever excludeVisibility was set —
and every default related() sets it, because visibility defaults to excluding
internal+system. So the common per-node edge lookup fell through to a full O(E)
shard scan (multi-second per node on large graphs; a consumer measured
1.4-4.6s/node and an O(N^2) whole-graph walk).
Root cause two-parter, both fixed here:
- hydrateVerbWithMetadata dropped 'visibility' (mapped subtype but not the
reserved visibility field), so fast-path results couldn't be visibility-filtered
AND related({includeInternal}) couldn't even report which edges were internal
(latent correctness bug — verbsToRelations already mapped it). Now hydrated.
- getVerbs disqualified the fast paths on subtype/excludeVisibility. Now the
fast paths apply both filters on their already-hydrated O(degree) candidate set
via applyVerbMetadataFilters() — matching the full-scan fallback's semantics —
so default related() stays on the index instead of scanning the whole graph.
Filtering semantics are unchanged (same result set as the scan); only the path
that computes it changes. Test: related-visibility-fast-path.test.ts (default
excludes internal both directions, includeInternal surfaces + reports visibility,
subtype filter on the fast path).
This commit is contained in:
parent
4cc2088aed
commit
a914313e3a
2 changed files with 165 additions and 21 deletions
81
tests/unit/brainy/related-visibility-fast-path.test.ts
Normal file
81
tests/unit/brainy/related-visibility-fast-path.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/related-visibility-fast-path
|
||||
* @description Graph-perf #1 (8.0): the visibility-aware fast adjacency path.
|
||||
*
|
||||
* `related({ from })` / `related({ to })` route through the O(degree)
|
||||
* GraphAdjacencyIndex fast paths in `storage.getVerbs`. Before this fix, the
|
||||
* default visibility exclusion (`excludeVisibility: ['internal','system']`,
|
||||
* which every default `related()` sets) DISQUALIFIED those fast paths and forced
|
||||
* a full O(E) shard scan — the root cause of multi-second per-node edge lookups
|
||||
* on large graphs. The fast paths now hydrate each candidate's metadata and
|
||||
* apply the subtype / visibility filters on the small O(degree) candidate set.
|
||||
*
|
||||
* Two observable contracts are pinned here (the perf change itself is structural
|
||||
* — these prove the fast path produces the SAME correct results the scan did):
|
||||
* 1. default `related()` excludes `internal` / `system` edges; `includeInternal`
|
||||
* / `includeSystem` opt them back in.
|
||||
* 2. returned `Relation`s now carry `visibility` (previously dropped by
|
||||
* `hydrateVerbWithMetadata`, so `related({ includeInternal })` could not even
|
||||
* report which edges were internal).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
describe('related() visibility-aware fast adjacency (graph-perf #1)', () => {
|
||||
let brain: Brainy
|
||||
let a: string, b: string, c: string, d: string
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy(createTestConfig())
|
||||
await brain.init()
|
||||
a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
c = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'C' })
|
||||
d = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'D' })
|
||||
// A → B public, A → C internal, A → D public.
|
||||
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
|
||||
await brain.relate({ from: a, to: c, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
|
||||
await brain.relate({ from: a, to: d, type: VerbType.RelatedTo, subtype: 'colleague' })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('default related({ from }) excludes internal edges (out-direction fast path)', async () => {
|
||||
const edges = await brain.related({ from: a })
|
||||
const targets = edges.map((e) => e.to).sort()
|
||||
expect(targets).toEqual([b, d].sort())
|
||||
// None of the default results carry a non-public visibility.
|
||||
expect(edges.every((e) => e.visibility === undefined || e.visibility === 'public')).toBe(true)
|
||||
})
|
||||
|
||||
it('related({ from, includeInternal }) surfaces the internal edge AND reports its visibility', async () => {
|
||||
const edges = await brain.related({ from: a, includeInternal: true })
|
||||
expect(edges.map((e) => e.to).sort()).toEqual([b, c, d].sort())
|
||||
const internalEdge = edges.find((e) => e.to === c)
|
||||
// The latent-bug fix: visibility now flows through hydration to the Relation.
|
||||
expect(internalEdge?.visibility).toBe('internal')
|
||||
})
|
||||
|
||||
it('default related({ to }) excludes an internal edge (in-direction fast path)', async () => {
|
||||
// B has one public in-edge; C has one internal in-edge.
|
||||
const intoB = await brain.related({ to: b })
|
||||
expect(intoB.map((e) => e.from)).toEqual([a])
|
||||
|
||||
const intoC = await brain.related({ to: c })
|
||||
expect(intoC).toHaveLength(0) // internal edge hidden by default
|
||||
|
||||
const intoCAll = await brain.related({ to: c, includeInternal: true })
|
||||
expect(intoCAll.map((e) => e.from)).toEqual([a])
|
||||
expect(intoCAll[0]?.visibility).toBe('internal')
|
||||
})
|
||||
|
||||
it('subtype filter still works on the fast path (returns only matching, excludes internal)', async () => {
|
||||
const colleagues = await brain.related({ from: a, subtype: 'colleague' })
|
||||
expect(colleagues.map((e) => e.to).sort()).toEqual([b, d].sort())
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue