From cfa6c7cb17476d19ea896867406c80e8186a72ec Mon Sep 17 00:00:00 2001 From: sohamd22 <85427822+sohamd22@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:00:12 +0000 Subject: [PATCH] fix(memory-graph): distinguish document links from derives relations (#1701) Document-to-memory links and actual `derives` relations were both emitted as `derives`, so they shared the same color and legend entry. This separates structural document links into a `document` edge type, adds a dedicated theme color with `--graph-edge-document` support, and updates force-layout and level-of-detail handling to preserve existing structural behavior. The package and MCP widget legends/themes now distinguish document links from derived-memory relations. Adds regression coverage for edge classification and validates the package plus its MCP consumer. Open in Capy --- apps/mcp/src/widget/design/tokens.css | 3 ++ apps/mcp/src/widget/views/Graph.tsx | 17 +++---- packages/memory-graph/README.md | 2 + .../src/__tests__/edge-logic.test.ts | 44 +++++++++++++------ .../src/__tests__/renderer-utils.test.ts | 4 +- .../src/__tests__/simulation.test.ts | 2 +- packages/memory-graph/src/canvas/renderer.ts | 37 +++++++++------- .../memory-graph/src/canvas/simulation.ts | 7 +-- .../memory-graph/src/components/legend.tsx | 17 ++++++- packages/memory-graph/src/constants.ts | 1 + .../memory-graph/src/hooks/use-graph-data.ts | 6 +-- .../memory-graph/src/hooks/use-graph-theme.ts | 4 ++ packages/memory-graph/src/index.tsx | 1 + packages/memory-graph/src/types.ts | 5 ++- 14 files changed, 95 insertions(+), 55 deletions(-) diff --git a/apps/mcp/src/widget/design/tokens.css b/apps/mcp/src/widget/design/tokens.css index 0b1a1df4..e139230a 100644 --- a/apps/mcp/src/widget/design/tokens.css +++ b/apps/mcp/src/widget/design/tokens.css @@ -196,6 +196,7 @@ --graph-text-primary: #171717; --graph-text-secondary: #5f5f5f; --graph-text-muted: #737373; + --graph-edge-document: #d9d9d4; --graph-edge-derives: #b8b8b3; --graph-edge-updates: #2e6fe6; --graph-edge-extends: #0e9488; @@ -298,6 +299,7 @@ --graph-text-primary: #eef2f7; --graph-text-secondary: #a9b4c2; --graph-text-muted: #6f7d8f; + --graph-edge-document: #2b3a4e; --graph-edge-derives: #5b6675; --graph-edge-updates: #4ba0fa; --graph-edge-extends: #2dd4bf; @@ -400,6 +402,7 @@ --graph-text-primary: #eef2f7; --graph-text-secondary: #a9b4c2; --graph-text-muted: #6f7d8f; + --graph-edge-document: #2b3a4e; --graph-edge-derives: #5b6675; --graph-edge-updates: #4ba0fa; --graph-edge-extends: #2dd4bf; diff --git a/apps/mcp/src/widget/views/Graph.tsx b/apps/mcp/src/widget/views/Graph.tsx index 1c9631be..5c74f501 100644 --- a/apps/mcp/src/widget/views/Graph.tsx +++ b/apps/mcp/src/widget/views/Graph.tsx @@ -15,10 +15,7 @@ import { useApp } from "../hooks/useApp" import { useHostContext } from "../hooks/useHostContext" import { ArrowsIn, ArrowsOut } from "../lib/icons" -// GraphThemeColors key → the --graph-* CSS variable it resolves from. Same -// mapping as the package's internal useGraphTheme, but we drive it ourselves -// so we can re-read at a reliable time (see useGraphColors). -const GRAPH_VARS: Record = { +const GRAPH_VARS = { bg: "--graph-bg", docFill: "--graph-doc-fill", docStroke: "--graph-doc-stroke", @@ -30,6 +27,7 @@ const GRAPH_VARS: Record = { textPrimary: "--graph-text-primary", textSecondary: "--graph-text-secondary", textMuted: "--graph-text-muted", + edgeDocument: "--graph-edge-document", edgeDerives: "--graph-edge-derives", edgeUpdates: "--graph-edge-updates", edgeExtends: "--graph-edge-extends", @@ -45,23 +43,18 @@ const GRAPH_VARS: Record = { popoverTextMuted: "--graph-popover-text-muted", controlBg: "--graph-control-bg", controlBorder: "--graph-control-border", -} +} satisfies Record, string> function readGraphColors(): GraphThemeColors { const s = getComputedStyle(document.documentElement) const out = {} as GraphThemeColors - for (const key of Object.keys(GRAPH_VARS) as (keyof GraphThemeColors)[]) { + for (const key of Object.keys(GRAPH_VARS) as (keyof typeof GRAPH_VARS)[]) { out[key] = s.getPropertyValue(GRAPH_VARS[key]).trim() || DEFAULT_COLORS[key] } return out } -// Read the graph palette from CSS, and re-read whenever the document theme -// flips. We can't rely on the package's own useGraphTheme (it watches `class`), -// nor on the `theme` prop alone: the host themes via `data-theme`, and in -// standalone/Studio the attribute can change without the prop changing. So we -// observe `data-theme`/`class` on directly and re-read after the next -// paint (rAF), by which point the --graph-* values are current. +// Host themes can update CSS variables after render, so re-read after theme mutations. function useGraphColors(theme: string): GraphThemeColors { const [colors, setColors] = useState(readGraphColors) useEffect(() => { diff --git a/packages/memory-graph/README.md b/packages/memory-graph/README.md index e18c9be8..fe5bb828 100644 --- a/packages/memory-graph/README.md +++ b/packages/memory-graph/README.md @@ -71,6 +71,8 @@ Console mode uses the supplied theme colors for its surface, 16px dot grid, docu Set `colors.dotColor` or the `--graph-dot` CSS variable to style the dot grid independently of text. When neither is set, the grid uses `textMuted`. +Document-to-memory links use `colors.edgeDocument` or `--graph-edge-document`, while derived-memory relations use `colors.edgeDerives` or `--graph-edge-derives`. + For paginated initial loading, pass `hasMore` and `isLoadingMore` alongside `documents`. In both variants, new batches gradually warm the force layout from the existing node positions. Initial and appended nodes relax until their movement stays low, then cool automatically; a tick limit bounds settling for layouts that keep drifting. The initial view smoothly follows the changing bounds until loading and settling finish. Manual interaction immediately cancels automatic camera movement. Clicking a node selects it without restarting the forces; dragging warms the layout until release, including release outside the canvas. Changing the document selection starts a new fit; the Fit control remains available at any time. The existing static layout safeguard for more than 6,000 nodes remains in place. ## Documentation diff --git a/packages/memory-graph/src/__tests__/edge-logic.test.ts b/packages/memory-graph/src/__tests__/edge-logic.test.ts index 8426a7d3..9891ce24 100644 --- a/packages/memory-graph/src/__tests__/edge-logic.test.ts +++ b/packages/memory-graph/src/__tests__/edge-logic.test.ts @@ -20,7 +20,10 @@ import { describe, it, expect } from "vitest" import type { GraphApiDocument, GraphApiMemory } from "../types" -import { getEdgeVisualProps } from "../hooks/use-graph-data" +import { + computeEdges as computeGraphEdges, + getEdgeVisualProps, +} from "../hooks/use-graph-data" // --------------------------------------------------------------------------- // Pure extraction of edges useMemo from use-graph-data.ts @@ -48,14 +51,13 @@ function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] { 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", + edgeType: "document", }) } } @@ -574,12 +576,8 @@ describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () }) }) -// =========================================================================== -// (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", () => { +describe("use-graph-data edges: document edges always present for all doc->mem pairs", () => { + it("creates exactly one document edge per memory across multiple documents", () => { const docs = [ makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]), makeDoc("d2", [ @@ -589,10 +587,9 @@ describe("use-graph-data edges: derives edges always present for all doc->mem pa ]), ] 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([ + const documentEdges = edges.filter((e) => e.edgeType === "document") + expect(documentEdges.length).toBe(5) + expect(documentEdges.map((e) => e.target).sort()).toEqual([ "m1", "m2", "m3", @@ -618,6 +615,27 @@ describe("use-graph-data edges: derives edges always present for all doc->mem pa }) }) +describe("document and derives edge types", () => { + it("keeps document links separate from derives relations", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "source" }), + makeMem({ + id: "derived", + memoryRelations: { source: "derives" }, + }), + ]), + ] + + const edges = computeGraphEdges(docs) + + expect(edges.filter((edge) => edge.edgeType === "document")).toHaveLength(2) + expect( + edges.find((edge) => edge.id === "rel-source-derived")?.edgeType, + ).toBe("derives") + }) +}) + // =========================================================================== // (7) getEdgeVisualProps: the MemoryRelation type is the canonical source // =========================================================================== diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts index 02630037..795ee816 100644 --- a/packages/memory-graph/src/__tests__/renderer-utils.test.ts +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -80,8 +80,8 @@ describe("relation edge level-of-detail helpers", () => { expect(getRelationEdgeStride(1040, 0.1)).toBe(4) }) - test("always draws structural derives edges", () => { - expect(shouldDrawRelationEdge("edge-1", "derives", 10)).toBe(true) + test("always draws structural document edges", () => { + expect(shouldDrawRelationEdge("edge-1", "document", 10)).toBe(true) }) test("deterministically samples non-structural relation edges", () => { diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts index cd0035a2..f9179bd7 100644 --- a/packages/memory-graph/src/__tests__/simulation.test.ts +++ b/packages/memory-graph/src/__tests__/simulation.test.ts @@ -30,7 +30,7 @@ function makeEdge(source: string, target: string): GraphEdge { source, target, visualProps: { opacity: 0.5, thickness: 1.5 }, - edgeType: "derives", + edgeType: "document", } } diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts index 0ddfba0f..79b72577 100644 --- a/packages/memory-graph/src/canvas/renderer.ts +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -21,8 +21,8 @@ const edgeBatches = new Map() const RELATION_LOD_ZOOM = 0.5 const RELATION_LOD_MAX_BACKGROUND_EDGES = 260 const RELATION_LOD_DENSE_COUNT = 180 -const DERIVES_LOD_ZOOM = 0.38 -const DERIVES_LOD_MAX_BACKGROUND_EDGES = 3200 +const DOCUMENT_LOD_ZOOM = 0.38 +const DOCUMENT_LOD_MAX_BACKGROUND_EDGES = 3200 const DENSE_POINT_THRESHOLD = 25000 const DENSE_POINT_ZOOM = 0.42 @@ -83,6 +83,8 @@ function edgeStyle( edge: GraphEdge, colors: GraphThemeColors, ): { color: string; width: number; opacity: number } { + if (edge.edgeType === "document") + return { color: colors.edgeDocument, width: 1.2, opacity: 0.4 } if (edge.edgeType === "derives") return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 } if (edge.edgeType === "updates") @@ -104,14 +106,17 @@ export function getRelationEdgeStride( return Math.ceil(relationEdgeCount / RELATION_LOD_MAX_BACKGROUND_EDGES) } -function getDerivesEdgeStride(derivesEdgeCount: number, zoom: number): number { +function getDocumentEdgeStride( + documentEdgeCount: number, + zoom: number, +): number { if ( - zoom >= DERIVES_LOD_ZOOM || - derivesEdgeCount <= DERIVES_LOD_MAX_BACKGROUND_EDGES + zoom >= DOCUMENT_LOD_ZOOM || + documentEdgeCount <= DOCUMENT_LOD_MAX_BACKGROUND_EDGES ) { return 1 } - return Math.ceil(derivesEdgeCount / DERIVES_LOD_MAX_BACKGROUND_EDGES) + return Math.ceil(documentEdgeCount / DOCUMENT_LOD_MAX_BACKGROUND_EDGES) } export function shouldDrawRelationEdge( @@ -119,7 +124,7 @@ export function shouldDrawRelationEdge( edgeType: string, stride: number, ): boolean { - if (edgeType === "derives" || stride <= 1) return true + if (edgeType === "document" || stride <= 1) return true return hashString(edgeId) % stride === 0 } @@ -135,7 +140,7 @@ function applyRelationLevelOfDetail( hasFocus: boolean, hasActiveHover: boolean, ) { - if (edgeType === "derives") return { style, glow: true } + if (edgeType === "document") return { style, glow: true } if (hasFocus || hasActiveHover) { const isUpdate = edgeType === "updates" const minOpacity = hasActiveHover ? 0.9 : 0.76 @@ -213,17 +218,17 @@ function drawEdges( const margin = 100 const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 const relationEdgeCount = edges.reduce( - (count, edge) => count + (edge.edgeType === "derives" ? 0 : 1), + (count, edge) => count + (edge.edgeType === "document" ? 0 : 1), 0, ) - const derivesEdgeCount = edges.length - relationEdgeCount + const documentEdgeCount = edges.length - relationEdgeCount const relationStride = getRelationEdgeStride(relationEdgeCount, viewport.zoom) - const derivesStride = getDerivesEdgeStride(derivesEdgeCount, viewport.zoom) + const documentStride = getDocumentEdgeStride(documentEdgeCount, viewport.zoom) const prepared: PreparedEdge[] = [] for (const edge of edges) { - const edgeType = edge.edgeType ?? "derives" + const edgeType = edge.edgeType ?? "document" const srcId = typeof edge.source === "string" ? edge.source : edge.source.id const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id const hoverConnected = @@ -235,7 +240,7 @@ function drawEdges( const activeConnected = hoverConnected || selectedConnected const shouldAlwaysDrawActiveUpdate = edgeType === "updates" && activeConnected - const edgeStride = edgeType === "derives" ? derivesStride : relationStride + const edgeStride = edgeType === "document" ? documentStride : relationStride if ( !shouldAlwaysDrawActiveUpdate && !hasDim && @@ -248,7 +253,7 @@ function drawEdges( const tgt = nodeMap.get(tgtId) if (!src || !tgt) continue - if (edgeType === "derives") { + if (edgeType === "document") { const mem = src.type === "memory" ? src : tgt if (mem.size * viewport.zoom < 3) continue } @@ -297,9 +302,9 @@ function drawEdges( ) let style = edgeDetail.style let glow = edgeDetail.glow - if (edgeType === "derives" && derivesStride > 1 && !activeConnected) { + if (edgeType === "document" && documentStride > 1 && !activeConnected) { const zoomFactor = clampNumber( - viewport.zoom / DERIVES_LOD_ZOOM, + viewport.zoom / DOCUMENT_LOD_ZOOM, 0.08, 0.32, ) diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts index 3d424d88..01bca6b0 100644 --- a/packages/memory-graph/src/canvas/simulation.ts +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -12,9 +12,6 @@ export class ForceSimulation { this.destroy() try { - // Only use structural edges (derives, updates) for the force layout. - // "extends" edges are visual-only -- they connect documents sharing a - // spaceId but should not pull documents together into a single mass. const structuralEdges = edges.filter((e) => e.edgeType !== "extends") this.sim = d3 @@ -29,12 +26,12 @@ export class ForceSimulation { .forceLink(structuralEdges) .id((d) => d.id) .distance((link) => - link.edgeType === "derives" + link.edgeType === "document" ? getDocMemoryDistance(link) : FORCE_CONFIG.linkDistance, ) .strength((link) => { - if (link.edgeType === "derives") + if (link.edgeType === "document") return FORCE_CONFIG.linkStrength.docMemory if (link.edgeType === "updates") return FORCE_CONFIG.linkStrength.version diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index a133ce4f..c65073f4 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -309,6 +309,7 @@ export const Legend = memo(function Legend({ const memoryCount = nodes.filter((n) => n.type === "memory").length const documentCount = nodes.filter((n) => n.type === "document").length const connectionCount = edges.length + const documentEdgeCount = countEdgesByType(edges, "document") const derivesCount = countEdgesByType(edges, "derives") const updatesCount = countEdgesByType(edges, "updates") const extendsCount = countEdgesByType(edges, "extends") @@ -531,17 +532,29 @@ export const Legend = memo(function Legend({
- + {labels.documentSourceEdge}
- {derivesCount} + {documentEdgeCount}
{labels.documentToMemoryEdge}
+
+
+
+ + Derives +
+ {derivesCount} +
+
+ Memory derived from another memory +
+
diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index 57b6e05b..f3c539ff 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -73,6 +73,7 @@ export const DEFAULT_COLORS: GraphThemeColors = { textPrimary: "#ffffff", textSecondary: "#e2e8f0", textMuted: "#94a3b8", + edgeDocument: "#64748B", edgeDerives: "#FBBF24", edgeUpdates: "#9B8AE6", edgeExtends: "#94A3B8", diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index e305b053..e0a3b1b3 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -56,6 +56,7 @@ export function getMemoryBorderColor( export function getEdgeVisualProps(edgeType: string) { switch (edgeType) { + case "document": case "derives": return { opacity: 0.4, thickness: 1.2 } case "updates": @@ -413,15 +414,14 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { 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", + visualProps: getEdgeVisualProps("document"), + edgeType: "document", }) } } diff --git a/packages/memory-graph/src/hooks/use-graph-theme.ts b/packages/memory-graph/src/hooks/use-graph-theme.ts index fc7ae913..e31ff7f0 100644 --- a/packages/memory-graph/src/hooks/use-graph-theme.ts +++ b/packages/memory-graph/src/hooks/use-graph-theme.ts @@ -33,6 +33,10 @@ function resolveColors(): GraphThemeColors { DEFAULT_COLORS.textSecondary, ), textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted), + edgeDocument: readCssVar( + "--graph-edge-document", + readCssVar("--graph-text-muted", DEFAULT_COLORS.edgeDocument), + ), edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives), edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates), edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends), diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index edeeb31c..f87baaba 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -29,6 +29,7 @@ export type { ResolvedMemoryGraphLabels, GraphNode, GraphEdge, + GraphEdgeType, GraphThemeColors, GraphCanvasProps, GraphApiDocument, diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index eb89347d..1c66419e 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -42,6 +42,8 @@ export interface GraphApiEdge { edgeType: MemoryRelation } +export type GraphEdgeType = MemoryRelation | "document" + // Typed node data export interface DocumentNodeData { @@ -100,7 +102,7 @@ export interface GraphEdge { opacity: number thickness: number } - edgeType: MemoryRelation + edgeType: GraphEdgeType } export interface GraphThemeColors { @@ -116,6 +118,7 @@ export interface GraphThemeColors { textPrimary: string textSecondary: string textMuted: string + edgeDocument: string edgeDerives: string edgeUpdates: string edgeExtends: string