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
|
|
@ -1119,6 +1119,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
...verb,
|
...verb,
|
||||||
// Standard fields at top-level
|
// Standard fields at top-level
|
||||||
subtype: reserved.subtype as string | undefined,
|
subtype: reserved.subtype as string | undefined,
|
||||||
|
// visibility is a reserved top-level field (the verb mirror of Entity.visibility).
|
||||||
|
// Surfacing it here lets the graph-index fast paths apply visibility filtering on
|
||||||
|
// their already-hydrated results (so default related() stays O(degree) instead of
|
||||||
|
// falling through to a full scan), and lets related({ includeInternal }) results
|
||||||
|
// actually report which edges are internal.
|
||||||
|
visibility: reserved.visibility as HNSWVerbWithMetadata['visibility'],
|
||||||
createdAt: normalizeStoredTimestamp(reserved.createdAt),
|
createdAt: normalizeStoredTimestamp(reserved.createdAt),
|
||||||
updatedAt: normalizeStoredTimestamp(reserved.updatedAt),
|
updatedAt: normalizeStoredTimestamp(reserved.updatedAt),
|
||||||
confidence: reserved.confidence as number | undefined,
|
confidence: reserved.confidence as number | undefined,
|
||||||
|
|
@ -1131,6 +1137,45 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Apply the metadata-derived verb filters (`subtype`,
|
||||||
|
* `excludeVisibility`) that the graph-index fast paths can satisfy on their
|
||||||
|
* already-hydrated O(degree) / O(type) candidate set — matching the semantics
|
||||||
|
* of the full-scan fallback in {@link getVerbsWithPagination}. This is what
|
||||||
|
* keeps default `related({ from/to })` (which always sets the visibility
|
||||||
|
* exclusion) on the fast adjacency path instead of forcing a full O(E) scan.
|
||||||
|
*
|
||||||
|
* - `subtype`: keeps only verbs carrying a matching subtype; a verb with no
|
||||||
|
* subtype is excluded when a subtype filter is set (same as the fallback).
|
||||||
|
* - `excludeVisibility`: drops verbs whose stored visibility tier is in the
|
||||||
|
* excluded set; an absent tier is `'public'` and is always kept.
|
||||||
|
*
|
||||||
|
* @param verbs - Hydrated candidate verbs from a fast-path lookup.
|
||||||
|
* @param filter - The `getVerbs` filter (only `subtype` / `excludeVisibility`
|
||||||
|
* are read here; the structural match was already done by the caller).
|
||||||
|
* @returns The candidates with the metadata filters applied (input order preserved).
|
||||||
|
*/
|
||||||
|
protected applyVerbMetadataFilters(
|
||||||
|
verbs: HNSWVerbWithMetadata[],
|
||||||
|
filter?: {
|
||||||
|
subtype?: string | string[]
|
||||||
|
excludeVisibility?: string[]
|
||||||
|
}
|
||||||
|
): HNSWVerbWithMetadata[] {
|
||||||
|
let out = verbs
|
||||||
|
const subtypeFilter = filter?.subtype
|
||||||
|
if (subtypeFilter) {
|
||||||
|
const allowed = new Set(Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter])
|
||||||
|
out = out.filter((v) => v.subtype !== undefined && allowed.has(v.subtype))
|
||||||
|
}
|
||||||
|
const excludeVisibility = filter?.excludeVisibility
|
||||||
|
if (excludeVisibility && excludeVisibility.length > 0) {
|
||||||
|
const excluded = new Set(excludeVisibility)
|
||||||
|
out = out.filter((v) => !(v.visibility && excluded.has(v.visibility)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a noun from storage (returns combined HNSWNounWithMetadata)
|
* Get a noun from storage (returns combined HNSWNounWithMetadata)
|
||||||
* @param id Entity ID
|
* @param id Entity ID
|
||||||
|
|
@ -1851,17 +1896,15 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
const cursor = pagination.cursor
|
const cursor = pagination.cursor
|
||||||
|
|
||||||
// Optimize for common filter cases to avoid loading all verbs.
|
// Optimize for common filter cases to avoid loading all verbs.
|
||||||
// NOTE: subtype filter intentionally disqualifies the fast paths and forces
|
// The graph-index fast paths (getVerbsBySource/Target/Type_internal) hydrate
|
||||||
// fallthrough to the full shard scan (which loads metadata and applies the
|
// each candidate's metadata, so `subtype` and the 8.0 `excludeVisibility`
|
||||||
// subtype check after the load). Subtype is not stored on the raw HNSWVerb;
|
// filters — both metadata fields — are applied on the small O(degree) /
|
||||||
// it's a metadata field. The graph-index fast paths return verbs without
|
// O(type) candidate set via applyVerbMetadataFilters() BEFORE pagination.
|
||||||
// metadata, so they can't apply subtype filtering correctly. The 8.0
|
// (They used to disqualify the fast paths and force a full O(E) shard scan,
|
||||||
// `excludeVisibility` filter (also a metadata field) disqualifies them the same way.
|
// which made default related({ from/to }) — which always sets the visibility
|
||||||
if (
|
// exclusion — scan the entire graph per node.) An arbitrary `metadata` filter
|
||||||
options?.filter &&
|
// still falls through, since each block guards `!options.filter.metadata`.
|
||||||
!(options.filter as { verbType?: string | string[]; subtype?: string | string[] }).subtype &&
|
if (options?.filter) {
|
||||||
!(options.filter as { excludeVisibility?: string[] }).excludeVisibility
|
|
||||||
) {
|
|
||||||
// CRITICAL VFS FIX: If filtering by sourceId + verbType (most common VFS pattern!)
|
// CRITICAL VFS FIX: If filtering by sourceId + verbType (most common VFS pattern!)
|
||||||
// This is the query PathResolver.getChildren() uses: related({ from: dirId, type: VerbType.Contains })
|
// This is the query PathResolver.getChildren() uses: related({ from: dirId, type: VerbType.Contains })
|
||||||
if (
|
if (
|
||||||
|
|
@ -1879,9 +1922,13 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
? options.filter.verbType[0]
|
? options.filter.verbType[0]
|
||||||
: options.filter.verbType
|
: options.filter.verbType
|
||||||
|
|
||||||
// Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter)
|
// Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter),
|
||||||
|
// then apply the subtype / visibility metadata filters on the candidate set.
|
||||||
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
|
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
|
||||||
const filteredVerbs = verbsBySource.filter(v => v.verb === verbType)
|
const filteredVerbs = this.applyVerbMetadataFilters(
|
||||||
|
verbsBySource.filter(v => v.verb === verbType),
|
||||||
|
options.filter
|
||||||
|
)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = filteredVerbs.slice(offset, offset + limit)
|
const paginatedVerbs = filteredVerbs.slice(offset, offset + limit)
|
||||||
|
|
@ -1914,8 +1961,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
? options.filter.sourceId[0]
|
? options.filter.sourceId[0]
|
||||||
: options.filter.sourceId
|
: options.filter.sourceId
|
||||||
|
|
||||||
// Get verbs by source directly
|
// Get verbs by source directly (hydrated with metadata), then apply the
|
||||||
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
|
// subtype / visibility metadata filters on the O(degree) candidate set.
|
||||||
|
const verbsBySource = this.applyVerbMetadataFilters(
|
||||||
|
await this.getVerbsBySource_internal(sourceId),
|
||||||
|
options.filter
|
||||||
|
)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = verbsBySource.slice(offset, offset + limit)
|
const paginatedVerbs = verbsBySource.slice(offset, offset + limit)
|
||||||
|
|
@ -1948,8 +1999,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
? options.filter.targetId[0]
|
? options.filter.targetId[0]
|
||||||
: options.filter.targetId
|
: options.filter.targetId
|
||||||
|
|
||||||
// Get verbs by target directly
|
// Get verbs by target directly (hydrated with metadata), then apply the
|
||||||
const verbsByTarget = await this.getVerbsByTarget_internal(targetId)
|
// subtype / visibility metadata filters on the O(degree) candidate set.
|
||||||
|
const verbsByTarget = this.applyVerbMetadataFilters(
|
||||||
|
await this.getVerbsByTarget_internal(targetId),
|
||||||
|
options.filter
|
||||||
|
)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = verbsByTarget.slice(offset, offset + limit)
|
const paginatedVerbs = verbsByTarget.slice(offset, offset + limit)
|
||||||
|
|
@ -1982,8 +2037,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
? options.filter.verbType[0]
|
? options.filter.verbType[0]
|
||||||
: options.filter.verbType
|
: options.filter.verbType
|
||||||
|
|
||||||
// Get verbs by type directly
|
// Get verbs by type directly (hydrated with metadata), then apply the
|
||||||
const verbsByType = await this.getVerbsByType_internal(verbType)
|
// subtype / visibility metadata filters on the candidate set.
|
||||||
|
const verbsByType = this.applyVerbMetadataFilters(
|
||||||
|
await this.getVerbsByType_internal(verbType),
|
||||||
|
options.filter
|
||||||
|
)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = verbsByType.slice(offset, offset + limit)
|
const paginatedVerbs = verbsByType.slice(offset, offset + limit)
|
||||||
|
|
@ -2026,8 +2085,12 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
// Get verbs by source (uses GraphAdjacencyIndex if available)
|
// Get verbs by source (uses GraphAdjacencyIndex if available)
|
||||||
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
|
const verbsBySource = await this.getVerbsBySource_internal(sourceId)
|
||||||
|
|
||||||
// Filter by verbType in memory (fast - usually small number of verbs per source)
|
// Filter by verbType in memory (fast - usually small number of verbs per source),
|
||||||
const filtered = verbsBySource.filter(v => verbTypes.includes(v.verb))
|
// then apply the subtype / visibility metadata filters on the candidate set.
|
||||||
|
const filtered = this.applyVerbMetadataFilters(
|
||||||
|
verbsBySource.filter(v => verbTypes.includes(v.verb)),
|
||||||
|
options.filter
|
||||||
|
)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = filtered.slice(offset, offset + limit)
|
const paginatedVerbs = filtered.slice(offset, offset + limit)
|
||||||
|
|
|
||||||
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