diff --git a/packages/memory-graph/src/__tests__/edge-logic.test.ts b/packages/memory-graph/src/__tests__/edge-logic.test.ts new file mode 100644 index 00000000..8f886ad7 --- /dev/null +++ b/packages/memory-graph/src/__tests__/edge-logic.test.ts @@ -0,0 +1,656 @@ +/** + * Adversarial tests for the three core changes in graph-perf-consolidation. + * + * Focuses on the exact logic that was changed, NOT on surrounding scaffolding: + * + * (1) use-graph-data.ts — edges useMemo now uses memoryRelations Record as + * primary source, falls back to parentMemoryId for legacy data. + * The old code expected an ARRAY [{relationType, targetMemoryId}]. + * The new code expects a Record. + * + * (2) MCP mcp-app.ts — transformData now pre-populates nodeIds before edge + * computation, fixing a forward-reference bug where edges to memories + * that appeared later in the iteration order were silently dropped. + * + * (3) Invalid relationType values now default to "updates" instead of blowing up. + * + * The edges useMemo is a pure function of `documents` — no React hook machinery + * is needed. We extract the identical logic here and test it directly. + */ + +import { describe, it, expect } from "vitest" +import type { GraphApiDocument, GraphApiMemory } from "../types" +import { getEdgeVisualProps } from "../hooks/use-graph-data" + +// --------------------------------------------------------------------------- +// Pure extraction of edges useMemo from use-graph-data.ts +// This is a verbatim copy of the logic so a regression in the source will +// cause this test to diverge — but more importantly, we can verify the exact +// semantics match the spec described in the commit messages. +// --------------------------------------------------------------------------- + +interface ComputedEdge { + id: string + source: string + target: string + edgeType: string +} + +function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] { + if (!documents || documents.length === 0) return [] + + const result: ComputedEdge[] = [] + + // Pre-populate all node IDs (the key step from the forward-reference fix) + const allNodeIds = new Set() + for (const doc of documents) { + allNodeIds.add(doc.id) + for (const mem of doc.memories) allNodeIds.add(mem.id) + } + + // 1. Derives edges: document -> memory (structural) + for (const doc of documents) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + edgeType: "derives", + }) + } + } + + // 2. Memory-to-memory relation edges from backend data. + // Uses memoryRelations Record as primary source, + // falls back to parentMemoryId for legacy data. + for (const doc of documents) { + for (const mem of doc.memories) { + let relations: Record = {} + + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + // Legacy fallback: parentMemoryId implies "updates" + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!allNodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + result.push({ + id: `rel-${targetId}-${mem.id}`, + source: targetId, + target: mem.id, + edgeType, + }) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// MCP transformData logic — pure extraction from mcp-app.ts transformData(). +// Includes the forward-reference fix (nodeIds pre-populated before edges). +// --------------------------------------------------------------------------- + +interface McpLink { + source: string + target: string + edgeType: "derives" | "updates" | "extends" +} + +function mcpComputeLinks( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + + // Pre-populate all node IDs (the forward-reference fix) + const nodeIds = new Set() + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) nodeIds.add(mem.id) + } + + for (const doc of documents) { + for (const mem of doc.memories) { + // Derives link (doc -> memory) + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory) + * rather than upfront. Used to prove the regression test would catch the bug. */ +function mcpComputeLinks_BUGGY( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + const nodeIds = new Set() // NOT pre-populated — reproduces the old bug + + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) { + nodeIds.add(mem.id) // only added as we reach this memory + + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue // forward refs silently dropped here! + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +// --------------------------------------------------------------------------- +// Helper factories +// --------------------------------------------------------------------------- + +function makeMem( + overrides: Partial & { id: string }, +): GraphApiMemory { + return { + memory: `Memory ${overrides.id}`, + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument { + return { + id, + title: `Doc ${id}`, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} + +// =========================================================================== +// (1) memoryRelations Record as PRIMARY source +// =========================================================================== + +describe("use-graph-data edges: memoryRelations Record as primary source", () => { + it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "extends" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("extends") + }) + + it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "updates" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "derives" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("derives") + }) + + it("creates multiple edges when memoryRelations has multiple targets", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2" }), + makeMem({ + id: "m3", + memoryRelations: { m1: "updates", m2: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdgesToM3 = edges.filter( + (e) => e.target === "m3" && e.id.startsWith("rel-"), + ) + expect(relEdgesToM3.length).toBe(2) + const types = new Set(relEdgesToM3.map((e) => e.edgeType)) + expect(types.has("updates")).toBe(true) + expect(types.has("extends")).toBe(true) + }) +}) + +// =========================================================================== +// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId +// =========================================================================== + +describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => { + /** + * Critical regression test: if a memory has BOTH memoryRelations and + * parentMemoryId, only memoryRelations should be used. The fallback + * parentMemoryId path must NOT fire when memoryRelations is present. + */ + it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "mParent" }), + makeMem({ id: "mOther" }), + makeMem({ + id: "m3", + // parentMemoryId would imply an "updates" edge to mParent + parentMemoryId: "mParent", + // memoryRelations says extends to mOther — this should win + memoryRelations: { mOther: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter( + (e) => e.id.startsWith("rel-") && e.target === "m3", + ) + + // Should be exactly ONE rel edge (extends to mOther), NOT two + expect(relEdges.length).toBe(1) + expect(relEdges[0]?.edgeType).toBe("extends") + expect(relEdges[0]?.source).toBe("mOther") + + // The parentMemoryId-implied updates edge to mParent must NOT exist + const badEdge = relEdges.find((e) => e.source === "mParent") + expect(badEdge).toBeUndefined() + }) +}) + +// =========================================================================== +// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty +// =========================================================================== + +describe("use-graph-data edges: parentMemoryId fallback", () => { + it("creates updates edge from parentMemoryId when memoryRelations is absent", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1" }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is null", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("drops the parentMemoryId edge when the parent is not in the dataset", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }), + ]), + ] + const edges = computeEdges(docs) + const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs") + expect(ghostEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (4) Invalid relationType defaults to "updates" +// =========================================================================== + +describe("use-graph-data edges: invalid relationType defaults to updates", () => { + it("maps an unknown relationType string to 'updates' edge type", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ + id: "m2", + // biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test + memoryRelations: { m1: "totally-bogus-type" as any }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) +}) + +// =========================================================================== +// (5) MCP forward-reference fix — THE CRITICAL BUG +// =========================================================================== + +describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => { + /** + * SETUP: Two memories in the same document. m1 is processed first. + * m1 has memoryRelations pointing at m2, which appears AFTER m1. + * + * OLD BUG: nodeIds was built lazily — when m1 was processed, m2 had not + * yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the + * edge was silently skipped. + * + * FIX: All node IDs are pre-populated before any edge is evaluated, so + * m2 is always in nodeIds when m1's relations are checked. + */ + it("creates edge for forward-referenced memory within same document", () => { + const docs = [ + { + id: "d1", + memories: [ + // m1 comes FIRST and references m2 which comes SECOND + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // Fixed version creates the edge + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("extends") + }) + + it("creates edge for forward-referenced memory in a LATER document", () => { + // m1 in doc1 references m2 in doc2. m2 is only encountered during + // doc2's iteration, AFTER m1's relations are evaluated. + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("updates") + }) + + /** + * REGRESSION PROOF: Run the same scenario through the BUGGY implementation. + * The buggy version MUST drop the edge. If this test fails (i.e. buggy code + * creates the edge too), the forward-reference scenario doesn't actually + * demonstrate the bug, and our fix tests prove nothing. + */ + it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // The buggy version MUST drop the m1->m2 edge (forward reference) + const buggyLinks = mcpComputeLinks_BUGGY(docs) + const buggyEdge = buggyLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(buggyEdge).toBeUndefined() // confirms the bug was real + + // The fixed version MUST create it + const fixedLinks = mcpComputeLinks(docs) + const fixedEdge = fixedLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(fixedEdge).toBeDefined() // confirms the fix works + }) + + it("proves the bug: cross-document forward reference also dropped by lazy code", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + // Buggy: m2 is not in nodeIds when m1 is processed — edge dropped + const buggyLinks = mcpComputeLinks_BUGGY(docs) + expect( + buggyLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeUndefined() + + // Fixed: m2 is pre-populated — edge created + const fixedLinks = mcpComputeLinks(docs) + expect( + fixedLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeDefined() + }) +}) + +// =========================================================================== +// (6) Structural correctness: derives edges always created, doc->mem +// =========================================================================== + +describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => { + it("creates exactly one derives edge per memory across multiple documents", () => { + const docs = [ + makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]), + makeDoc("d2", [ + makeMem({ id: "m3" }), + makeMem({ id: "m4" }), + makeMem({ id: "m5" }), + ]), + ] + const edges = computeEdges(docs) + const derivesEdges = edges.filter((e) => e.edgeType === "derives") + // 2 memories in d1 + 3 in d2 = 5 derives edges + expect(derivesEdges.length).toBe(5) + expect(derivesEdges.map((e) => e.target).sort()).toEqual([ + "m1", + "m2", + "m3", + "m4", + "m5", + ]) + }) + + it("returns empty array for empty documents input", () => { + expect(computeEdges([])).toEqual([]) + }) + + it("creates no relation edges when all memories are standalone", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations + makeMem({ id: "m2" }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter((e) => e.id.startsWith("rel-")) + expect(relEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source +// =========================================================================== + +describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => { + const relations = ["updates", "extends", "derives"] as const + + for (const rel of relations) { + it(`returns positive opacity and thickness for '${rel}'`, () => { + const props = getEdgeVisualProps(rel) + expect(props.opacity).toBeGreaterThan(0) + expect(props.thickness).toBeGreaterThan(0) + }) + } + + it("extends edges have lower opacity than derives edges (barely visible)", () => { + const ext = getEdgeVisualProps("extends") + const der = getEdgeVisualProps("derives") + expect(ext.opacity).toBeLessThan(der.opacity) + }) + + it("updates edges have higher opacity than derives edges (version chains are prominent)", () => { + const upd = getEdgeVisualProps("updates") + const der = getEdgeVisualProps("derives") + expect(upd.opacity).toBeGreaterThan(der.opacity) + }) + + it("unknown edge type returns default props (opacity 0.3, thickness 1)", () => { + // The default case returns { opacity: 0.3, thickness: 1 }. + // opacity matches derives (0.3), but thickness is intentionally 1 (not 1.5), + // a safe conservative fallback that doesn't match any specific known type. + const unknown = getEdgeVisualProps("nonexistent") + expect(unknown.opacity).toBeCloseTo(0.3) + expect(unknown.thickness).toBeCloseTo(1) + }) +}) diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts index 4f074a18..899c488d 100644 --- a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -61,19 +61,19 @@ describe("getMemoryBorderColor", () => { describe("getEdgeVisualProps", () => { it("returns correct props for derives edges", () => { const props = getEdgeVisualProps("derives") - expect(props.opacity).toBeCloseTo(0.3) - expect(props.thickness).toBeCloseTo(1.5) + expect(props.opacity).toBeCloseTo(0.35) + expect(props.thickness).toBeCloseTo(1) }) it("returns correct props for updates edges", () => { const props = getEdgeVisualProps("updates") expect(props.opacity).toBeCloseTo(0.6) - expect(props.thickness).toBeCloseTo(2) + expect(props.thickness).toBeCloseTo(1.5) }) it("returns correct props for extends edges", () => { const props = getEdgeVisualProps("extends") - expect(props.opacity).toBeCloseTo(0.15) + expect(props.opacity).toBeCloseTo(0.3) expect(props.thickness).toBeCloseTo(1) }) diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index ea23fb0a..a3fcf31a 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -59,11 +59,11 @@ function edgeStyle( colors: GraphThemeColors, ): { color: string; width: number; opacity: number } { if (edge.edgeType === "derives") - return { color: colors.edgeDerives, width: 0.8, opacity: 0.18 } + return { color: colors.edgeDerives, width: 1, opacity: 0.35 } if (edge.edgeType === "updates") - return { color: colors.edgeUpdates, width: 1.2, opacity: 0.45 } - // "extends" and any unknown edge types share the same subtle style - return { color: colors.edgeExtends, width: 0.6, opacity: 0.12 } + return { color: colors.edgeUpdates, width: 1.5, opacity: 0.6 } + // "extends" and any unknown edge types + return { color: colors.edgeExtends, width: 1, opacity: 0.3 } } function batchKey(style: { @@ -404,7 +404,7 @@ function drawNodes( // Draw dimmed (superseded) memory dots at reduced opacity if (dimmedDots.length > 0) { - ctx.globalAlpha = dimAlpha * 0.3 + ctx.globalAlpha = dimAlpha * 0.5 ctx.fillStyle = colors.memFill ctx.beginPath() for (const d of dimmedDots) { @@ -506,7 +506,7 @@ function drawMemoryNode( // Dim superseded (non-latest) memory nodes with strikethrough effect if (isSuperseded && !isSelected && !isHovered) { const prevAlpha = ctx.globalAlpha - ctx.globalAlpha = prevAlpha * 0.3 + ctx.globalAlpha = prevAlpha * 0.5 ctx.fillStyle = colors.memFill drawHexagon(ctx, sx, sy, radius) ctx.fill() diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 630a47c2..0a66487c 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -427,6 +427,8 @@ export function MemoryGraph({ } let lastIdx = -1 + let coolDownTimer: ReturnType | null = null + const pick = () => { const currentNodes = nodesRef.current if (currentNodes.length === 0) return @@ -445,12 +447,20 @@ export function MemoryGraph({ viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height) simulationRef.current?.reheat() onSlideshowNodeChangeRef.current?.(n.id) - setTimeout(() => simulationRef.current?.coolDown(), 1000) + // Clear any pending coolDown before scheduling a new one + if (coolDownTimer) clearTimeout(coolDownTimer) + coolDownTimer = setTimeout(() => { + simulationRef.current?.coolDown() + coolDownTimer = null + }, 1000) } pick() const interval = setInterval(pick, 3500) - return () => clearInterval(interval) + return () => { + clearInterval(interval) + if (coolDownTimer) clearTimeout(coolDownTimer) + } }, [isSlideshowActive, nodes.length]) // Active node: selected takes priority, then hovered diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index 037b989f..4d75eb48 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -30,11 +30,11 @@ export function getMemoryBorderColor( export function getEdgeVisualProps(edgeType: string) { switch (edgeType) { case "derives": - return { opacity: 0.3, thickness: 1.5 } + return { opacity: 0.35, thickness: 1 } case "updates": - return { opacity: 0.6, thickness: 2 } + return { opacity: 0.6, thickness: 1.5 } case "extends": - return { opacity: 0.15, thickness: 1 } + return { opacity: 0.3, thickness: 1 } default: return { opacity: 0.3, thickness: 1 } } @@ -53,6 +53,74 @@ function hashToUnit(str: string): number { return ((h >>> 0) % 10000) / 10000 } +/** + * Pure function that computes graph edges from documents. + * Extracted from the hook for testability. + */ +export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { + if (!documents || documents.length === 0) return [] + + const result: GraphEdge[] = [] + const allNodeIds = new Set() + for (const doc of documents) { + allNodeIds.add(doc.id) + for (const mem of doc.memories) allNodeIds.add(mem.id) + } + + // 1. Derives edges: document -> memory (structural) + for (const doc of documents) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + visualProps: getEdgeVisualProps("derives"), + edgeType: "derives", + }) + } + } + + // 2. Memory-to-memory relation edges from backend data. + // Uses memoryRelations (Record) as primary source, + // falls back to parentMemoryId for legacy data. + for (const doc of documents) { + for (const mem of doc.memories) { + let relations: Record = {} + + // Defensive: API may return unexpected types at runtime + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + // Legacy fallback: parentMemoryId implies "updates" + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!allNodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + result.push({ + id: `rel-${targetId}-${mem.id}`, + source: targetId, + target: mem.id, + visualProps: getEdgeVisualProps(edgeType), + edgeType, + }) + } + } + } + + return result +} + export function useGraphData( documents: GraphApiDocument[], draggingNodeId: string | null, @@ -170,69 +238,7 @@ export function useGraphData( return result }, [documents, canvasWidth, canvasHeight, draggingNodeId, colors]) - const edges = useMemo(() => { - if (!documents || documents.length === 0) return [] - - const result: GraphEdge[] = [] - const allNodeIds = new Set() - for (const doc of documents) { - allNodeIds.add(doc.id) - for (const mem of doc.memories) allNodeIds.add(mem.id) - } - - // 1. Derives edges: document -> memory (structural) - for (const doc of documents) { - for (const mem of doc.memories) { - result.push({ - id: `dm-${doc.id}-${mem.id}`, - source: doc.id, - target: mem.id, - visualProps: getEdgeVisualProps("derives"), - edgeType: "derives", - }) - } - } - - // 2. Memory-to-memory relation edges from backend data. - // Uses memoryRelations (Record) as primary source, - // falls back to parentMemoryId for legacy data. - for (const doc of documents) { - for (const mem of doc.memories) { - let relations: Record = {} - - // Defensive: API may return unexpected types at runtime - if ( - mem.memoryRelations && - typeof mem.memoryRelations === "object" && - Object.keys(mem.memoryRelations).length > 0 - ) { - relations = mem.memoryRelations - } else if (mem.parentMemoryId) { - // Legacy fallback: parentMemoryId implies "updates" - relations = { [mem.parentMemoryId]: "updates" } - } - - for (const [targetId, relationType] of Object.entries(relations)) { - if (!allNodeIds.has(targetId)) continue - const edgeType = - relationType === "updates" || - relationType === "extends" || - relationType === "derives" - ? relationType - : "updates" - result.push({ - id: `rel-${targetId}-${mem.id}`, - source: targetId, - target: mem.id, - visualProps: getEdgeVisualProps(edgeType), - edgeType, - }) - } - } - } - - return result - }, [documents]) + const edges = useMemo(() => computeEdges(documents), [documents]) return { nodes, edges } }