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:
David Snelling 2026-06-20 16:34:32 -07:00
parent 4cc2088aed
commit a914313e3a
2 changed files with 165 additions and 21 deletions

View 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())
})
})