feat(8.0): graph analytics — brain.graph.rank / communities / path

Adds three intent-level graph reads to the `brain.graph` namespace, each
native-dispatched to the optional `@soulcraft/cor` 3.0 graph engine when present
and served from pure-TS kernels otherwise (identical public shapes, default
visibility filter respected on both paths):

- `rank(opts?)` → `{ id, score }[]` descending — importance / centrality.
  TS fallback: PageRank power-iteration with dangling-mass redistribution.
- `communities(opts?)` → `{ groups, count }` — connected grouping. TS fallback:
  union-find weakly-connected components, or iterative Tarjan SCC when
  `{ directed: true }`.
- `path(from, to, opts?)` → `{ nodes, relationships, cost } | null` — best route.
  TS fallback: BFS for fewest hops, Dijkstra (min-heap) for least summed edge
  weight (`by: 'weight'`); on-demand frontier expansion so short paths terminate
  early. `direction` / `type` / `maxDepth` filters apply.

These are intent contracts, not algorithm contracts — the question is the
promise, the algorithm is the engine's choice.

Pure kernels live in src/graph/analyticsFallback.ts (PageRank, connected
components, Tarjan SCC, MinHeap) — unit-tested in isolation. The full surface is
tested end-to-end through the TS fallback, and the native dispatch + int↔uuid
hydration paths are covered by a mock provider in graph-native-routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-06-23 12:32:03 -07:00
parent 4d0b64f455
commit 632d90aac5
7 changed files with 1164 additions and 11 deletions

View file

@ -50,6 +50,23 @@ no separate migration doc. **`8.0.0-rc.2` is live** — install with
> carry `visibility`; whole-graph/`getNouns` streaming no longer leaks `system`/`internal` entities; > carry `visibility`; whole-graph/`getNouns` streaming no longer leaks `system`/`internal` entities;
> and small-page cursor walks over nouns no longer loop. No API change — just correct behavior. > and small-page cursor walks over nouns no longer loop. No API change — just correct behavior.
> **rc.3 additions (2026-06-23):**
> - **Graph analytics on the `brain.graph` namespace.** Three intent-level reads that answer
> whole-graph questions in one call:
> - **`brain.graph.rank(opts?)`** → `{ id, score }[]` descending — "which entities matter most"
> (importance / centrality). `topK` to cap.
> - **`brain.graph.communities(opts?)`** → `{ groups: string[][], count }` — "which things group
> together". Weakly-connected components by default; `{ directed: true }` returns
> strongly-connected components.
> - **`brain.graph.path(from, to, opts?)`** → `{ nodes, relationships, cost } | null` — the best
> route between two entities. Fewest hops by default; `{ by: 'weight' }` minimizes summed edge
> weight (the 01 connection strength); `direction`, `type`, and `maxDepth` filters apply.
> These are **intent contracts, not algorithm contracts** — the question is the promise, the
> algorithm is the engine's choice. They transparently use the native `@soulcraft/cor` 3.0 graph
> engine when present, and fall back to pure-TS kernels (PageRank, connected components / Tarjan
> SCC, BFS / Dijkstra) otherwise. Both paths return identical shapes and respect the default
> visibility filter (opt in with `includeInternal` / `includeSystem`).
### Headline: Database as a Value ### Headline: Database as a Value
8.0 replaces Brainy's two overlapping version-control subsystems (copy-on-write 8.0 replaces Brainy's two overlapping version-control subsystems (copy-on-write

View file

@ -31,13 +31,26 @@ import { VirtualFileSystem } from './vfs/VirtualFileSystem.js'
import { MetadataIndexManager } from './utils/metadataIndex.js' import { MetadataIndexManager } from './utils/metadataIndex.js'
import { detectContentType, extractForHighlighting } from './utils/contentExtractor.js' import { detectContentType, extractForHighlighting } from './utils/contentExtractor.js'
import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js' import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js'
import {
connectedComponents,
stronglyConnectedComponents,
pageRank,
MinHeap
} from './graph/analyticsFallback.js'
import { createPipeline } from './streaming/pipeline.js' import { createPipeline } from './streaming/pipeline.js'
import { configureLogger, LogLevel, prodLog } from './utils/logger.js' import { configureLogger, LogLevel, prodLog } from './utils/logger.js'
import { setGlobalCache } from './utils/unifiedCache.js' import { setGlobalCache } from './utils/unifiedCache.js'
import type { UnifiedCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js'
import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js' import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js'
import { PluginRegistry, isVersionedIndexProvider, isGraphAccelerationProvider } from './plugin.js' import { PluginRegistry, isVersionedIndexProvider, isGraphAccelerationProvider } from './plugin.js'
import type { GraphAccelerationProvider, TraverseOptions, Subgraph } from './plugin.js' import type {
GraphAccelerationProvider,
TraverseOptions,
Subgraph,
RankOptions,
CommunitiesOptions,
PathOptions
} from './plugin.js'
import type { import type {
BrainyPlugin, BrainyPlugin,
BrainyPluginContext, BrainyPluginContext,
@ -94,6 +107,12 @@ import {
GraphNode, GraphNode,
SubgraphOptions, SubgraphOptions,
GraphExportOptions, GraphExportOptions,
GraphRankOptions,
GraphRankEntry,
GraphCommunitiesOptions,
GraphCommunitiesResult,
GraphPathOptions,
GraphPathResult,
GetOptions, GetOptions,
AddManyParams, AddManyParams,
RemoveManyParams, RemoveManyParams,
@ -3259,7 +3278,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return { return {
subgraph: (seeds: string | string[], options?: SubgraphOptions) => subgraph: (seeds: string | string[], options?: SubgraphOptions) =>
this.graphSubgraph(seeds, options), this.graphSubgraph(seeds, options),
export: (options?: GraphExportOptions) => this.graphExport(options) export: (options?: GraphExportOptions) => this.graphExport(options),
rank: (options?: GraphRankOptions) => this.graphRank(options),
communities: (options?: GraphCommunitiesOptions) => this.graphCommunities(options),
path: (from: string, to: string, options?: GraphPathOptions) =>
this.graphPath(from, to, options)
} }
} }
@ -3580,6 +3603,309 @@ export class Brainy<T = any> implements BrainyInterface<T> {
} }
} }
// ============= GRAPH ANALYTICS (rank / communities / path) =============
/**
* @description Load the whole visible graph into a dense integer adjacency for
* the rank / communities fallbacks: a node-id list, an idindex map, and
* `outAdj[i]` = the dense indices of node `i`'s out-edge targets (multiplicity
* preserved). Streamed via {@link graphExport} (cursor pagination O(N+E), no
* re-scan), so hidden tiers are excluded the same way every other read excludes
* them. Isolated nodes (no edges) are retained so they form singleton groups.
*/
private async loadAnalyticsGraph(opts: {
includeInternal?: boolean
includeSystem?: boolean
}): Promise<{ ids: string[]; outAdj: number[][] }> {
const ids: string[] = []
const index = new Map<string, number>()
const outAdj: number[][] = []
const idxOf = (id: string): number => {
let i = index.get(id)
if (i === undefined) {
i = ids.length
ids.push(id)
index.set(id, i)
outAdj.push([])
}
return i
}
for await (const chunk of this.graphExport({
includeInternal: opts.includeInternal,
includeSystem: opts.includeSystem
})) {
for (const node of chunk.nodes) idxOf(node.id)
for (const edge of chunk.edges) {
const s = idxOf(edge.from)
const t = idxOf(edge.to)
outAdj[s].push(t)
}
}
return { ids, outAdj }
}
/**
* @description {@link GraphApi.rank} dispatch: native ranking when a provider is
* present, else the TS PageRank fallback.
*/
private async graphRank(options?: GraphRankOptions): Promise<GraphRankEntry[]> {
await this.ensureInitialized()
const accel = this.graphAccelerationProvider()
return accel ? this.graphRankNative(accel, options) : this.graphRankFallback(options)
}
/** TS PageRank over the dense visible adjacency; sorted descending, `topK`-capped. */
private async graphRankFallback(options?: GraphRankOptions): Promise<GraphRankEntry[]> {
const { ids, outAdj } = await this.loadAnalyticsGraph(options ?? {})
if (ids.length === 0) return []
const scores = pageRank(outAdj)
const entries: GraphRankEntry[] = ids.map((id, i) => ({ id, score: scores[i] }))
entries.sort((a, b) => b.score - a.score)
return options?.topK !== undefined ? entries.slice(0, options.topK) : entries
}
/** Native ranking → hydrate node-ints to ids (descending order preserved). */
private async graphRankNative(
accel: GraphAccelerationProvider,
options?: GraphRankOptions
): Promise<GraphRankEntry[]> {
const excluded = this.excludedVisibilityTiers(options ?? {})
const opts: RankOptions = {
...(options?.topK !== undefined && { topK: options.topK }),
...(excluded && { excludeVisibility: excluded as unknown as string[] })
}
const result = await accel.rank(opts)
const idMapper = this.metadataIndex.getIdMapper()
const entries: GraphRankEntry[] = []
for (let i = 0; i < result.nodeInts.length; i++) {
const uuid = idMapper.getUuid(Number(result.nodeInts[i]))
if (uuid !== undefined) entries.push({ id: uuid, score: result.scores[i] })
}
return options?.topK !== undefined ? entries.slice(0, options.topK) : entries
}
/**
* @description {@link GraphApi.communities} dispatch: native grouping when a
* provider is present, else the TS connected-components fallback.
*/
private async graphCommunities(
options?: GraphCommunitiesOptions
): Promise<GraphCommunitiesResult> {
await this.ensureInitialized()
const accel = this.graphAccelerationProvider()
return accel
? this.graphCommunitiesNative(accel, options)
: this.graphCommunitiesFallback(options)
}
/**
* @description TS grouping: weakly-connected components by default (union-find
* over the undirected projection), or strongly-connected components when
* `directed: true` (Tarjan). Largest group first.
*/
private async graphCommunitiesFallback(
options?: GraphCommunitiesOptions
): Promise<GraphCommunitiesResult> {
const { ids, outAdj } = await this.loadAnalyticsGraph(options ?? {})
if (ids.length === 0) return { groups: [], count: 0 }
const labels = options?.directed
? stronglyConnectedComponents(outAdj)
: connectedComponents(outAdj)
return this.groupByLabel(ids, labels)
}
/** Collapse parallel `(id, label)` arrays into id-groups, largest first. */
private groupByLabel(ids: string[], labels: number[]): GraphCommunitiesResult {
const byLabel = new Map<number, string[]>()
for (let i = 0; i < ids.length; i++) {
const label = labels[i]
const existing = byLabel.get(label)
if (existing) existing.push(ids[i])
else byLabel.set(label, [ids[i]])
}
const groups = [...byLabel.values()].sort((a, b) => b.length - a.length)
return { groups, count: groups.length }
}
/** Native grouping → bucket node-ints by community label, hydrate to ids. */
private async graphCommunitiesNative(
accel: GraphAccelerationProvider,
options?: GraphCommunitiesOptions
): Promise<GraphCommunitiesResult> {
const excluded = this.excludedVisibilityTiers(options ?? {})
const opts: CommunitiesOptions = {
...(options?.directed !== undefined && { directed: options.directed }),
...(excluded && { excludeVisibility: excluded as unknown as string[] })
}
const result = await accel.communities(opts)
const idMapper = this.metadataIndex.getIdMapper()
const byCommunity = new Map<number, string[]>()
for (let i = 0; i < result.nodeInts.length; i++) {
const uuid = idMapper.getUuid(Number(result.nodeInts[i]))
if (uuid === undefined) continue
const community = result.communityIds[i]
const existing = byCommunity.get(community)
if (existing) existing.push(uuid)
else byCommunity.set(community, [uuid])
}
const groups = [...byCommunity.values()].sort((a, b) => b.length - a.length)
return { groups, count: groups.length }
}
/**
* @description {@link GraphApi.path} dispatch: native pathfinding when a provider
* is present, else the TS BFS (hops) / Dijkstra (weight) fallback. Endpoints are
* id-normalized so callers may pass natural keys.
*/
private async graphPath(
from: string,
to: string,
options?: GraphPathOptions
): Promise<GraphPathResult | null> {
await this.ensureInitialized()
const fromId = resolveEntityId(from)
const toId = resolveEntityId(to)
const accel = this.graphAccelerationProvider()
return accel
? this.graphPathNative(accel, fromId, toId, options)
: this.graphPathFallback(fromId, toId, options)
}
/**
* @description On-demand TS pathfinding expands frontiers through the O(degree)
* `related()` adjacency (visibility / type filters applied there) rather than
* loading the whole graph, so a short path terminates early. BFS for fewest hops
* (default); Dijkstra (min-heap) for least summed edge weight (`by: 'weight'`
* each edge costs its stored `weight`, the 01 connection strength, default 1.0).
*/
private async graphPathFallback(
fromId: string,
toId: string,
options?: GraphPathOptions
): Promise<GraphPathResult | null> {
if (fromId === toId) return { nodes: [fromId], relationships: [], cost: 0 }
const direction = options?.direction ?? 'both'
const maxDepth = options?.maxDepth ?? Number.POSITIVE_INFINITY
const subOptions: SubgraphOptions = {
...(options?.type !== undefined && { type: options.type }),
...(options?.includeInternal !== undefined && { includeInternal: options.includeInternal }),
...(options?.includeSystem !== undefined && { includeSystem: options.includeSystem })
}
const pred = new Map<string, { prev: string; edgeId: string }>()
if (options?.by === 'weight') {
const dist = new Map<string, number>([[fromId, 0]])
const hops = new Map<string, number>([[fromId, 0]])
const settled = new Set<string>()
const heap = new MinHeap<string>()
heap.push(fromId, 0)
while (heap.size > 0) {
const node = heap.pop() as string
if (settled.has(node)) continue
settled.add(node)
if (node === toId) break
const depth = hops.get(node) as number
if (depth >= maxDepth) continue
const baseCost = dist.get(node) as number
const edges = await this.incidentEdges(node, direction, subOptions)
for (const edge of edges) {
const neighbor = edge.from === node ? edge.to : edge.from
if (settled.has(neighbor)) continue
// Cost = the edge's stored weight (01 connection strength, default 1.0).
// Non-negative by construction, so Dijkstra stays valid.
const weight = edge.weight ?? 1
const candidate = baseCost + weight
if (!dist.has(neighbor) || candidate < (dist.get(neighbor) as number)) {
dist.set(neighbor, candidate)
hops.set(neighbor, depth + 1)
pred.set(neighbor, { prev: node, edgeId: edge.id })
heap.push(neighbor, candidate)
}
}
}
if (!settled.has(toId)) return null
return this.reconstructPath(fromId, toId, pred, dist.get(toId) as number)
}
// Fewest hops — breadth-first, each edge cost 1.
const visited = new Set<string>([fromId])
const queue: Array<{ id: string; depth: number }> = [{ id: fromId, depth: 0 }]
let head = 0
while (head < queue.length) {
const { id, depth } = queue[head++]
if (depth >= maxDepth) continue
const edges = await this.incidentEdges(id, direction, subOptions)
for (const edge of edges) {
const neighbor = edge.from === id ? edge.to : edge.from
if (visited.has(neighbor)) continue
visited.add(neighbor)
pred.set(neighbor, { prev: id, edgeId: edge.id })
if (neighbor === toId) return this.reconstructPath(fromId, toId, pred, depth + 1)
queue.push({ id: neighbor, depth: depth + 1 })
}
}
return null
}
/** Walk predecessor links from `toId` back to `fromId` into a forward route. */
private reconstructPath(
fromId: string,
toId: string,
pred: Map<string, { prev: string; edgeId: string }>,
cost: number
): GraphPathResult {
const nodes: string[] = []
const relationships: string[] = []
let cursor = toId
while (cursor !== fromId) {
nodes.push(cursor)
const step = pred.get(cursor) as { prev: string; edgeId: string }
relationships.push(step.edgeId)
cursor = step.prev
}
nodes.push(fromId)
nodes.reverse()
relationships.reverse()
return { nodes, relationships, cost }
}
/** Native pathfinding → resolve endpoints to ints, hydrate the returned route. */
private async graphPathNative(
accel: GraphAccelerationProvider,
fromId: string,
toId: string,
options?: GraphPathOptions
): Promise<GraphPathResult | null> {
const fromInt = this.graphEntityInt(fromId)
const toInt = this.graphEntityInt(toId)
if (fromInt === undefined || toInt === undefined) return null
const verbTypes = options?.type
? (Array.isArray(options.type) ? options.type : [options.type]).map((t) =>
TypeUtils.getVerbIndex(t)
)
: undefined
const excluded = this.excludedVisibilityTiers(options ?? {})
const pathOptions: PathOptions = {
...(options?.direction !== undefined && { direction: options.direction }),
...(options?.by !== undefined && { by: options.by }),
...(verbTypes && { verbTypes }),
...(excluded && { excludeVisibility: excluded as unknown as string[] }),
...(options?.maxDepth !== undefined && { maxDepth: options.maxDepth })
}
const result = await accel.path(fromInt, toInt, pathOptions)
if (!result) return null
const nodes = this.entityIntsToUuids(Array.from(result.nodeInts))
const relationships = (
await this.graphIndex.verbIntsToIds(Array.from(result.edgeVerbInts))
).filter((id): id is string => id !== null)
return { nodes, relationships, cost: result.cost }
}
// ============= SEARCH & DISCOVERY ============= // ============= SEARCH & DISCOVERY =============
/** /**

View file

@ -0,0 +1,244 @@
/**
* @module graph/analyticsFallback
* @description Pure-TS graph-analytics kernels the fallback implementations
* behind `brain.graph.rank` / `brain.graph.communities` when no native
* {@link import('../plugin.js').GraphAccelerationProvider} is registered. Each
* operates on a dense integer adjacency (`outAdj[i]` = the target indices of node
* `i`'s out-edges, multiplicity preserved) so callers can build it once from the
* UUID graph and run any kernel.
*
* These are INTENT-level fallbacks: the public API promises the QUESTION
* ("which nodes matter most", "which things group together") these answer it
* with the standard textbook algorithm (PageRank, connected components,
* Tarjan SCC). A native provider may answer the same intent with a different
* algorithm (personalized PageRank, Louvain, etc.); correctness of the INTENT,
* not the algorithm, is the contract.
*
* Correct at small/medium scale (the native provider is the at-scale path). All
* graph walks are iterative (explicit stacks/queues) so a deep or wide graph
* never blows the call stack.
*/
/** Adjacency where `outAdj[i]` lists the target node indices of `i`'s out-edges. */
export type DenseAdjacency = ReadonlyArray<ReadonlyArray<number>>
/**
* @description Label each node with the id of its WEAKLY-connected component
* (edges treated as undirected) via union-find with path compression + a single
* pass over the adjacency. Two nodes share a label iff one can reach the other
* ignoring edge direction.
* @param outAdj - Dense out-adjacency.
* @returns `labels[i]` = the component representative of node `i` (labels are
* stable per component but NOT necessarily contiguous group by equality).
*/
export function connectedComponents(outAdj: DenseAdjacency): number[] {
const n = outAdj.length
const parent = new Array<number>(n)
for (let i = 0; i < n; i++) parent[i] = i
const find = (x: number): number => {
let root = x
while (parent[root] !== root) root = parent[root]
// Path compression: point every node on the chain straight at the root.
while (parent[x] !== root) {
const next = parent[x]
parent[x] = root
x = next
}
return root
}
for (let i = 0; i < n; i++) {
const neighbors = outAdj[i]
for (let k = 0; k < neighbors.length; k++) {
const ra = find(i)
const rb = find(neighbors[k])
if (ra !== rb) parent[ra] = rb
}
}
const labels = new Array<number>(n)
for (let i = 0; i < n; i++) labels[i] = find(i)
return labels
}
/**
* @description Label each node with its STRONGLY-connected component (directed
* two nodes share a label iff each is reachable from the other following edge
* direction) via an iterative Tarjan's algorithm.
* @param outAdj - Dense out-adjacency.
* @returns `labels[i]` = the SCC index of node `i` (contiguous `0..count-1`).
*/
export function stronglyConnectedComponents(outAdj: DenseAdjacency): number[] {
const n = outAdj.length
const index = new Array<number>(n).fill(-1)
const low = new Array<number>(n).fill(0)
const onStack = new Array<boolean>(n).fill(false)
const comp = new Array<number>(n).fill(-1)
const sccStack: number[] = []
let nextIndex = 0
let compCount = 0
for (let start = 0; start < n; start++) {
if (index[start] !== -1) continue
// Explicit DFS stack of frames; `pi` is the next out-edge to visit for `node`.
const callStack: Array<{ node: number; pi: number }> = [{ node: start, pi: 0 }]
while (callStack.length > 0) {
const frame = callStack[callStack.length - 1]
const v = frame.node
if (frame.pi === 0) {
index[v] = low[v] = nextIndex++
sccStack.push(v)
onStack[v] = true
}
if (frame.pi < outAdj[v].length) {
const w = outAdj[v][frame.pi]
frame.pi++
if (index[w] === -1) {
callStack.push({ node: w, pi: 0 })
} else if (onStack[w]) {
if (index[w] < low[v]) low[v] = index[w]
}
} else {
// All edges of v explored — if v roots an SCC, pop it off the SCC stack.
if (low[v] === index[v]) {
for (;;) {
const w = sccStack.pop() as number
onStack[w] = false
comp[w] = compCount
if (w === v) break
}
compCount++
}
callStack.pop()
if (callStack.length > 0) {
const parent = callStack[callStack.length - 1].node
if (low[v] < low[parent]) low[parent] = low[v]
}
}
}
}
return comp
}
/** Tuning knobs for {@link pageRank}. */
export interface PageRankOptions {
/** Teleport/damping factor (default `0.85`). */
damping?: number
/** Max power-iterations before stopping (default `100`). */
maxIterations?: number
/** L1 convergence threshold on the score vector (default `1e-6`). */
tolerance?: number
}
/**
* @description PageRank importance score per node via power-iteration. Dangling
* nodes (no out-edges) redistribute their mass uniformly so the score vector
* stays a probability distribution (sums to 1). Edge multiplicity is honored
* (a node with two edges to the same target sends it twice the share).
* @param outAdj - Dense out-adjacency.
* @param options - Damping / iteration / tolerance knobs.
* @returns `scores[i]` = node `i`'s PageRank (higher = more central).
*/
export function pageRank(outAdj: DenseAdjacency, options?: PageRankOptions): Float64Array {
const n = outAdj.length
if (n === 0) return new Float64Array(0)
const damping = options?.damping ?? 0.85
const maxIterations = options?.maxIterations ?? 100
const tolerance = options?.tolerance ?? 1e-6
const outDegree = new Array<number>(n)
for (let i = 0; i < n; i++) outDegree[i] = outAdj[i].length
let rank = new Float64Array(n)
rank.fill(1 / n)
const teleport = (1 - damping) / n
for (let iter = 0; iter < maxIterations; iter++) {
// Mass stranded on dangling nodes this round, spread uniformly to everyone.
let danglingMass = 0
for (let i = 0; i < n; i++) if (outDegree[i] === 0) danglingMass += rank[i]
const danglingShare = (damping * danglingMass) / n
const next = new Float64Array(n)
next.fill(teleport + danglingShare)
for (let i = 0; i < n; i++) {
const degree = outDegree[i]
if (degree === 0) continue
const share = (damping * rank[i]) / degree
const neighbors = outAdj[i]
for (let k = 0; k < neighbors.length; k++) next[neighbors[k]] += share
}
let delta = 0
for (let i = 0; i < n; i++) delta += Math.abs(next[i] - rank[i])
rank = next
if (delta < tolerance) break
}
return rank
}
/**
* @description A minimal binary min-heap (priority queue) used by the weighted
* shortest-path (Dijkstra) fallback to pop the lowest-cost frontier node in
* O(log n). Stable enough for pathfinding; ties pop in unspecified order.
*/
export class MinHeap<T> {
private readonly items: Array<{ value: T; priority: number }> = []
/** Number of queued items. */
get size(): number {
return this.items.length
}
/**
* @description Insert `value` with the given `priority` (lower pops first).
* @param value - The payload.
* @param priority - Ordering key (ascending).
*/
push(value: T, priority: number): void {
const items = this.items
items.push({ value, priority })
let i = items.length - 1
while (i > 0) {
const parent = (i - 1) >> 1
if (items[parent].priority <= items[i].priority) break
const tmp = items[parent]
items[parent] = items[i]
items[i] = tmp
i = parent
}
}
/**
* @description Remove and return the lowest-priority value.
* @returns The min value, or `undefined` when empty.
*/
pop(): T | undefined {
const items = this.items
if (items.length === 0) return undefined
const top = items[0].value
const last = items.pop() as { value: T; priority: number }
if (items.length > 0) {
items[0] = last
let i = 0
for (;;) {
const left = 2 * i + 1
const right = 2 * i + 2
let smallest = i
if (left < items.length && items[left].priority < items[smallest].priority) smallest = left
if (right < items.length && items[right].priority < items[smallest].priority) smallest = right
if (smallest === i) break
const tmp = items[smallest]
items[smallest] = items[i]
items[i] = tmp
i = smallest
}
}
return top
}
}

View file

@ -837,14 +837,87 @@ export interface GraphExportOptions {
includeEdges?: boolean includeEdges?: boolean
} }
/**
* Options for {@link GraphApi.communities}.
*/
export interface GraphCommunitiesOptions {
/** Treat edges as directed when grouping (default `false` — direction-agnostic). */
directed?: boolean
/** Also group through `internal`-visibility nodes/edges (hidden by default). */
includeInternal?: boolean
/** Also group through `system`-visibility nodes/edges (hidden by default). */
includeSystem?: boolean
}
/**
* Result of {@link GraphApi.communities} the graph partitioned into connected
* groups of entity ids.
*/
export interface GraphCommunitiesResult {
/** Each group is the member entity ids; largest group first. */
groups: string[][]
/** Number of distinct groups (`= groups.length`). */
count: number
}
/**
* Options for {@link GraphApi.rank} (importance ranking). INTENT-level only the
* ranking ALGORITHM is the provider's choice (the TS fallback uses PageRank), so
* no algorithm-tuning knobs are exposed.
*/
export interface GraphRankOptions {
/** Return only the top-K nodes by score (default: all, descending). */
topK?: number
/** Also rank through `internal`-visibility nodes/edges (hidden by default). */
includeInternal?: boolean
/** Also rank through `system`-visibility nodes/edges (hidden by default). */
includeSystem?: boolean
}
/** One ranked entity from {@link GraphApi.rank}, descending by `score`. */
export interface GraphRankEntry {
/** The entity id. */
id: string
/** The importance score (relative; higher = more central). */
score: number
}
/**
* Options for {@link GraphApi.path} (best route between two entities).
*/
export interface GraphPathOptions {
/** Edge direction to follow (default `'both'`). */
direction?: 'in' | 'out' | 'both'
/** Optimize for fewest `'hops'` (default) or least summed edge `'weight'`. */
by?: 'hops' | 'weight'
/** Restrict the route to these relationship type(s). */
type?: VerbType | VerbType[]
/** Also route through `internal`-visibility nodes/edges (hidden by default). */
includeInternal?: boolean
/** Also route through `system`-visibility nodes/edges (hidden by default). */
includeSystem?: boolean
/** Abandon the search past this many hops. */
maxDepth?: number
}
/**
* Result of {@link GraphApi.path} the route from `from` to `to`, or `null` when
* unreachable.
*/
export interface GraphPathResult {
/** The entity ids along the route, `from` … `to` inclusive. */
nodes: string[]
/** The relationship (verb) ids traversed, one fewer than `nodes`. */
relationships: string[]
/** Route cost: number of hops, or summed edge weight when `by: 'weight'`. */
cost: number
}
/** /**
* The `brain.graph` namespace graph-shaped reads over the knowledge graph. * The `brain.graph` namespace graph-shaped reads over the knowledge graph.
* Routes to a native {@link import('../plugin.js').GraphAccelerationProvider} * Routes to a native {@link import('../plugin.js').GraphAccelerationProvider}
* when one is registered, otherwise serves the same results from Brainy's * when one is registered, otherwise serves the same results from Brainy's
* pure-TS adjacency (correct at small/medium scale). * pure-TS adjacency (correct at small/medium scale).
*
* (Grows with the engine: `subgraph` + `export` ship first; `rank` / `path` /
* `communities` follow.)
*/ */
export interface GraphApi<T = any> { export interface GraphApi<T = any> {
/** /**
@ -874,6 +947,42 @@ export interface GraphApi<T = any> {
* } * }
*/ */
export(options?: GraphExportOptions): AsyncIterable<GraphView<T>> export(options?: GraphExportOptions): AsyncIterable<GraphView<T>>
/**
* @description Rank entities by graph importance (influence / centrality) the
* one-call answer to "which nodes matter most". The TS fallback uses PageRank;
* a native provider may use personalized PageRank / eigenvector centrality.
* @param options - `topK`, visibility opt-ins.
* @returns Entities with scores, DESCENDING (most important first).
* @example
* const top = await brain.graph.rank({ topK: 10 })
* top[0] // { id, score } — the most central entity
*/
rank(options?: GraphRankOptions): Promise<GraphRankEntry[]>
/**
* @description Partition the graph into connected communities (clusters of
* related entities) "which things group together".
* @param options - `directed`, visibility opt-ins.
* @returns The groups (member ids) + their count.
* @example
* const { groups, count } = await brain.graph.communities()
*/
communities(options?: GraphCommunitiesOptions): Promise<GraphCommunitiesResult>
/**
* @description Find the best route between two entities fewest hops (default)
* or least summed edge weight (`by: 'weight'`). The pathfinding ALGORITHM is
* the provider's choice (TS fallback: BFS for hops, Dijkstra for weight).
* @param from - Start entity id (natural keys are resolved).
* @param to - End entity id.
* @param options - Direction, `by`, type filter, `maxDepth`, visibility opt-ins.
* @returns The route (`nodes` + `relationships` + `cost`), or `null` if unreachable.
* @example
* const route = await brain.graph.path(aliceId, bobId)
* route?.nodes // [aliceId, …, bobId]
*/
path(from: string, to: string, options?: GraphPathOptions): Promise<GraphPathResult | null>
} }
// ============= Batch Operations ============= // ============= Batch Operations =============

View file

@ -0,0 +1,211 @@
/**
* @module tests/unit/brainy/graph-analytics
* @description Graph analytics surface `brain.graph.rank()` (PageRank importance),
* `brain.graph.communities()` (connected-component grouping), and
* `brain.graph.path()` (best route). These exercise the pure-TS fallbacks (no native
* GraphAccelerationProvider is registered in CI); a native provider answers the same
* INTENT (which nodes matter most / which things group / best route) and returns the
* same public shapes, cross-layer-tested against the provider.
*/
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('brain.graph.communities()', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string, e: string, f: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// Two disjoint clusters + one isolated node:
// cluster 1: a → b → c
// cluster 2: d → e
// isolated: f (no edges)
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
d = await brain.add({ type: NounType.Person, data: 'D' })
e = await brain.add({ type: NounType.Person, data: 'E' })
f = await brain.add({ type: NounType.Person, data: 'F' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo })
await brain.relate({ from: d, to: e, type: VerbType.RelatedTo })
})
afterEach(async () => {
await brain.close()
})
it('partitions the graph into connected groups (isolated nodes are singletons)', async () => {
const { groups, count } = await brain.graph.communities()
expect(count).toBe(3)
const asSets = groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b, c]))
expect(asSets).toContainEqual(new Set([d, e]))
expect(asSets).toContainEqual(new Set([f]))
})
it('orders groups largest-first', async () => {
const { groups } = await brain.graph.communities()
for (let i = 1; i < groups.length; i++) {
expect(groups[i - 1].length).toBeGreaterThanOrEqual(groups[i].length)
}
expect(groups[0]).toHaveLength(3) // the a-b-c cluster
})
it('directed mode groups by strong connectivity (a→b→c is NOT mutually reachable)', async () => {
// Undirected: {a,b,c} is one group. Directed: no cycle, so each is its own SCC.
const undirected = await brain.graph.communities()
expect(undirected.groups).toContainEqual(expect.arrayContaining([a, b, c]))
const directed = await brain.graph.communities({ directed: true })
// a,b,c split into singletons; d,e split too; f stays singleton → 6 groups.
expect(directed.count).toBe(6)
expect(directed.groups.every((g) => g.length === 1)).toBe(true)
})
it('directed mode keeps a cycle together as one strongly-connected community', async () => {
// x → y → z → x is a cycle: mutually reachable → one SCC even when directed.
const x = await brain.add({ type: NounType.Concept, data: 'X' })
const y = await brain.add({ type: NounType.Concept, data: 'Y' })
const z = await brain.add({ type: NounType.Concept, data: 'Z' })
await brain.relate({ from: x, to: y, type: VerbType.RelatedTo })
await brain.relate({ from: y, to: z, type: VerbType.RelatedTo })
await brain.relate({ from: z, to: x, type: VerbType.RelatedTo })
const directed = await brain.graph.communities({ directed: true })
const asSets = directed.groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([x, y, z]))
})
it('excludes internal-visibility edges by default; includeInternal re-links', async () => {
// A hidden edge bridging the two clusters is invisible by default.
await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, visibility: 'internal' })
const hidden = await brain.graph.communities()
expect(hidden.count).toBe(3) // bridge hidden → still 3 groups
const surfaced = await brain.graph.communities({ includeInternal: true })
const asSets = surfaced.groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b, c, d, e])) // bridge merges the clusters
})
})
describe('brain.graph.rank()', () => {
let brain: Brainy
let hub: string, a: string, b: string, c: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// a, b, c all point at hub → hub is the most "important" node.
hub = await brain.add({ type: NounType.Person, data: 'HUB' })
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
await brain.relate({ from: a, to: hub, type: VerbType.RelatedTo })
await brain.relate({ from: b, to: hub, type: VerbType.RelatedTo })
await brain.relate({ from: c, to: hub, type: VerbType.RelatedTo })
})
afterEach(async () => {
await brain.close()
})
it('ranks the most-pointed-to node highest, descending', async () => {
const ranked = await brain.graph.rank()
expect(ranked).toHaveLength(4)
expect(ranked[0].id).toBe(hub)
for (let i = 1; i < ranked.length; i++) {
expect(ranked[i - 1].score).toBeGreaterThanOrEqual(ranked[i].score)
}
})
it('scores form a probability distribution (PageRank sums to ~1)', async () => {
const ranked = await brain.graph.rank()
const total = ranked.reduce((sum, r) => sum + r.score, 0)
expect(total).toBeCloseTo(1, 5)
})
it('topK returns only the K highest', async () => {
const top1 = await brain.graph.rank({ topK: 1 })
expect(top1).toHaveLength(1)
expect(top1[0].id).toBe(hub)
})
it('returns [] on an empty graph', async () => {
const empty = new Brainy(createTestConfig())
await empty.init()
expect(await empty.graph.rank()).toEqual([])
await empty.close()
})
})
describe('brain.graph.path()', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string, isolated: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
// Chain a → b → c → d (light edges), plus a heavy direct shortcut a → d.
// weight is a 01 connection strength; `by:'weight'` minimizes summed weight.
a = await brain.add({ type: NounType.Person, data: 'A' })
b = await brain.add({ type: NounType.Person, data: 'B' })
c = await brain.add({ type: NounType.Person, data: 'C' })
d = await brain.add({ type: NounType.Person, data: 'D' })
isolated = await brain.add({ type: NounType.Person, data: 'ISO' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, weight: 0.1 })
await brain.relate({ from: a, to: d, type: VerbType.RelatedTo, weight: 0.9 }) // direct but heavy
})
afterEach(async () => {
await brain.close()
})
it('finds the fewest-hops route by default', async () => {
const route = await brain.graph.path(a, d)
expect(route).not.toBeNull()
// a → d direct edge is 1 hop, the shortest.
expect(route?.nodes).toEqual([a, d])
expect(route?.relationships).toHaveLength(1)
expect(route?.cost).toBe(1)
})
it('by:"weight" prefers the lighter multi-hop route over the heavy shortcut', async () => {
const route = await brain.graph.path(a, d, { by: 'weight' })
expect(route?.nodes).toEqual([a, b, c, d]) // 0.1+0.1+0.1 = 0.3 < 0.9
expect(route?.relationships).toHaveLength(3)
expect(route?.cost).toBeCloseTo(0.3, 6)
})
it('returns a zero-length route from a node to itself', async () => {
const route = await brain.graph.path(a, a)
expect(route).toEqual({ nodes: [a], relationships: [], cost: 0 })
})
it('returns null when the target is unreachable', async () => {
expect(await brain.graph.path(a, isolated)).toBeNull()
})
it('direction:"out" cannot walk backwards up the chain', async () => {
// d has no outgoing edges, so d → a is unreachable following only out-edges.
expect(await brain.graph.path(d, a, { direction: 'out' })).toBeNull()
// …but 'both' finds it.
const both = await brain.graph.path(d, a, { direction: 'both' })
expect(both?.nodes[0]).toBe(d)
expect(both?.nodes[both.nodes.length - 1]).toBe(a)
})
it('maxDepth abandons routes that are too long', async () => {
// The light route is 3 hops; cap at 1 hop → only the heavy direct shortcut.
const capped = await brain.graph.path(a, d, { by: 'weight', maxDepth: 1 })
expect(capped?.nodes).toEqual([a, d])
expect(capped?.cost).toBeCloseTo(0.9, 6)
})
})

View file

@ -19,13 +19,28 @@ import { createTestConfig } from '../../helpers/test-factory.js'
/** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */ /** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */
function makeMockAccel() { function makeMockAccel() {
const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0 } const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 }
const state: { calls: typeof calls; traverseResult: any; cursorChunks: any[] } = { const state: {
calls: typeof calls
traverseResult: any
cursorChunks: any[]
rankResult: any
communitiesResult: any
pathResult: any
} = {
calls, calls,
traverseResult: null, traverseResult: null,
cursorChunks: [] cursorChunks: [],
rankResult: null,
communitiesResult: null,
pathResult: null
} }
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) } const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
const emptyCommunities = {
nodeInts: new BigInt64Array(0),
communityIds: new Uint32Array(0),
communityCount: 0
}
const provider = { const provider = {
isInitialized: true, isInitialized: true,
traverse: async () => { traverse: async () => {
@ -45,9 +60,18 @@ function makeMockAccel() {
graphCursorClose: async () => { graphCursorClose: async () => {
calls.cursorClose++ calls.cursorClose++
}, },
rank: async () => empty, rank: async () => {
communities: async () => ({ nodeInts: new BigInt64Array(0), communityIds: new Uint32Array(0), communityCount: 0 }), calls.rank++
path: async () => null, return state.rankResult ?? empty
},
communities: async () => {
calls.communities++
return state.communitiesResult ?? emptyCommunities
},
path: async () => {
calls.path++
return state.pathResult
},
sample: async () => state.traverseResult, sample: async () => state.traverseResult,
mostConnected: async () => empty mostConnected: async () => empty
} }
@ -178,4 +202,63 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () =
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([x, y])) expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([x, y]))
await fb.close() await fb.close()
}) })
it('rank() routes to the native provider and hydrates node ints -> ids, order preserved', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
// Provider returns DESCENDING scores: c, then a, then b.
mock.state.rankResult = {
nodeInts: BigInt64Array.from([gei(c), gei(a), gei(b)]),
scores: Float64Array.from([0.5, 0.3, 0.2])
}
const ranked = await brain.graph.rank()
expect(mock.state.calls.rank).toBe(1) // native path, not the TS PageRank fallback
expect(ranked.map((r) => r.id)).toEqual([c, a, b]) // int -> id, provider order kept
expect(ranked[0].score).toBe(0.5)
const top2 = await brain.graph.rank({ topK: 2 })
expect(top2.map((r) => r.id)).toEqual([c, a])
})
it('communities() routes to the native provider and buckets ids by community label', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
// a,b in community 0; c alone in community 1.
mock.state.communitiesResult = {
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
communityIds: Uint32Array.from([0, 0, 1]),
communityCount: 2
}
const { groups, count } = await brain.graph.communities()
expect(mock.state.calls.communities).toBe(1) // native path, not the TS fallback
expect(count).toBe(2)
const asSets = groups.map((g) => new Set(g))
expect(asSets).toContainEqual(new Set([a, b]))
expect(asSets).toContainEqual(new Set([c]))
expect(groups[0]).toHaveLength(2) // largest group first
})
it('path() routes to the native provider and hydrates node + verb ints', async () => {
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
const gi = (brain as any).graphIndex
const vAB = (await gi.getVerbIdsBySource(gei(a)))[0] as bigint
const vBC = (await gi.getVerbIdsBySource(gei(b)))[0] as bigint
mock.state.pathResult = {
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
edgeVerbInts: BigInt64Array.from([vAB, vBC]),
cost: 2
}
const route = await brain.graph.path(a, c)
expect(mock.state.calls.path).toBe(1) // native path, not the TS BFS/Dijkstra fallback
expect(route?.nodes).toEqual([a, b, c]) // node ints -> ids
expect(route?.relationships).toHaveLength(2) // verb ints -> verb-id strings
expect(route?.cost).toBe(2)
})
it('path() returns null when the native provider reports unreachable', async () => {
mock.state.pathResult = null
expect(await brain.graph.path(a, c)).toBeNull()
expect(mock.state.calls.path).toBe(1)
})
}) })

View file

@ -0,0 +1,163 @@
/**
* @module tests/unit/graph/analyticsFallback
* @description Direct unit tests for the pure-TS graph-analytics kernels
* (`connectedComponents`, `stronglyConnectedComponents`, `pageRank`, `MinHeap`).
* These cover the algorithmic edge cases empty graphs, dangling PageRank mass,
* nested SCCs, heap ordering without the cost of a full Brainy instance. The
* `brain.graph.*` surface is tested end-to-end in graph-analytics.test.ts.
*/
import { describe, it, expect } from 'vitest'
import {
connectedComponents,
stronglyConnectedComponents,
pageRank,
MinHeap
} from '../../../src/graph/analyticsFallback.js'
/** Group node indices that share a label into sorted sets for stable comparison. */
function groupsOf(labels: number[]): number[][] {
const byLabel = new Map<number, number[]>()
labels.forEach((label, i) => {
const arr = byLabel.get(label)
if (arr) arr.push(i)
else byLabel.set(label, [i])
})
return [...byLabel.values()].map((g) => g.sort((a, b) => a - b)).sort((a, b) => a[0] - b[0])
}
describe('connectedComponents (weakly-connected, undirected projection)', () => {
it('labels an empty graph with no components', () => {
expect(connectedComponents([])).toEqual([])
})
it('treats every isolated node as its own component', () => {
expect(groupsOf(connectedComponents([[], [], []]))).toEqual([[0], [1], [2]])
})
it('merges across edge direction (0→1, 2→1 ⇒ one group)', () => {
// 0 → 1, 2 → 1, 3 isolated
const labels = connectedComponents([[1], [], [1], []])
expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]])
})
it('keeps disjoint clusters separate', () => {
// 0↔1 , 2↔3
const labels = connectedComponents([[1], [0], [3], [2]])
expect(groupsOf(labels)).toEqual([
[0, 1],
[2, 3]
])
})
})
describe('stronglyConnectedComponents (directed, Tarjan)', () => {
it('labels an empty graph with no components', () => {
expect(stronglyConnectedComponents([])).toEqual([])
})
it('splits a directed chain into singletons (no mutual reachability)', () => {
// 0 → 1 → 2
expect(groupsOf(stronglyConnectedComponents([[1], [2], []]))).toEqual([[0], [1], [2]])
})
it('collapses a directed cycle into one SCC', () => {
// 0 → 1 → 2 → 0
expect(groupsOf(stronglyConnectedComponents([[1], [2], [0]]))).toEqual([[0, 1, 2]])
})
it('separates a cycle from the acyclic tail that feeds it', () => {
// 3 → 0 → 1 → 2 → 0 : {0,1,2} is an SCC, 3 is its own SCC
const labels = stronglyConnectedComponents([[1], [2], [0], [0]])
expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]])
})
it('handles two independent cycles', () => {
// 0↔1 cycle, 2↔3 cycle
const labels = stronglyConnectedComponents([
[1],
[0],
[3],
[2]
])
expect(groupsOf(labels)).toEqual([
[0, 1],
[2, 3]
])
})
it('produces contiguous community indices', () => {
const labels = stronglyConnectedComponents([[1], [2], [0], []])
const distinct = new Set(labels)
expect(Math.max(...labels)).toBe(distinct.size - 1)
})
})
describe('pageRank', () => {
it('returns an empty vector for an empty graph', () => {
expect(pageRank([]).length).toBe(0)
})
it('is uniform on a symmetric cycle', () => {
// 0 → 1 → 2 → 0 — every node identical by symmetry.
const scores = pageRank([[1], [2], [0]])
expect(scores[0]).toBeCloseTo(1 / 3, 6)
expect(scores[1]).toBeCloseTo(1 / 3, 6)
expect(scores[2]).toBeCloseTo(1 / 3, 6)
})
it('ranks a hub (high in-degree) above its sources', () => {
// 0 → 3, 1 → 3, 2 → 3 : node 3 is the hub.
const scores = pageRank([[3], [3], [3], []])
expect(scores[3]).toBeGreaterThan(scores[0])
expect(scores[3]).toBeGreaterThan(scores[1])
expect(scores[3]).toBeGreaterThan(scores[2])
})
it('redistributes dangling mass so scores sum to 1', () => {
// Node 3 is dangling (no out-edges) — its mass must not leak away.
const scores = pageRank([[3], [3], [3], []])
const total = scores.reduce((sum, s) => sum + s, 0)
expect(total).toBeCloseTo(1, 6)
})
it('sums to 1 even when every node is dangling (no edges)', () => {
const scores = pageRank([[], [], []])
const total = scores.reduce((sum, s) => sum + s, 0)
expect(total).toBeCloseTo(1, 6)
expect(scores[0]).toBeCloseTo(1 / 3, 6)
})
})
describe('MinHeap', () => {
it('pops in ascending priority order', () => {
const heap = new MinHeap<string>()
heap.push('c', 3)
heap.push('a', 1)
heap.push('b', 2)
heap.push('d', 4)
expect([heap.pop(), heap.pop(), heap.pop(), heap.pop()]).toEqual(['a', 'b', 'c', 'd'])
})
it('returns undefined when empty and tracks size', () => {
const heap = new MinHeap<number>()
expect(heap.size).toBe(0)
expect(heap.pop()).toBeUndefined()
heap.push(42, 0.5)
expect(heap.size).toBe(1)
expect(heap.pop()).toBe(42)
expect(heap.size).toBe(0)
})
it('handles interleaved push/pop correctly', () => {
const heap = new MinHeap<number>()
heap.push(5, 5)
heap.push(1, 1)
expect(heap.pop()).toBe(1)
heap.push(3, 3)
heap.push(2, 2)
expect(heap.pop()).toBe(2)
expect(heap.pop()).toBe(3)
expect(heap.pop()).toBe(5)
})
})