From 2282cdfe7449ba9d96cb9bcc5599037a25e2a2b0 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:00:21 -0400 Subject: [PATCH 01/18] Add files via upload --- apps/api/src/memory-graph-routes.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/api/src/memory-graph-routes.ts b/apps/api/src/memory-graph-routes.ts index 396e533da..fc9f7f5fd 100644 --- a/apps/api/src/memory-graph-routes.ts +++ b/apps/api/src/memory-graph-routes.ts @@ -312,9 +312,12 @@ function memoryIssuesRevisionHash(snapshotRevision: string, states: MemoryIssueS return digest(JSON.stringify({ snapshotRevision, stateRevisions })); } +export const MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT = 2000; +export const MEMORY_GRAPH_OVERVIEW_MAX_LIMIT = 50000; + // Keep the same payload budget while retaining the taxonomy needed to interpret a large archive. -export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): MemoryGraphNode[] { - if (nodes.length <= 2000) return nodes; +export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], limit: number = MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT): MemoryGraphNode[] { + if (nodes.length <= limit) return nodes; const members = new Map>(); const explicit = new Set(); const byId = new Map(nodes.map(node => [node.id, node])); @@ -336,7 +339,7 @@ export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraph if (first) selected.set(first, byId.get(first)!); } for (const node of nodes) { - if (selected.size >= 2000) break; + if (selected.size >= limit) break; selected.set(node.id, node); } return [...selected.values()]; @@ -385,12 +388,15 @@ export function registerMemoryGraphRoutes( matchingNodeIds.has(edge.target) && (query.relationMode !== "RELATIONS" || anchorIds.has(edge.source) || anchorIds.has(edge.target)) ); - const selectedNodes = selectOverviewNodes(matchingNodes, matchingEdges); + const nodeLimit = Math.min(query.limit ?? MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT, MEMORY_GRAPH_OVERVIEW_MAX_LIMIT); + const selectedNodes = selectOverviewNodes(matchingNodes, matchingEdges, nodeLimit); const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)); + // Edges scale with the node budget: three edges per node keeps the + // historical 2,000-node / 6,000-edge ratio up to 150,000 at the maximum. const selectedEdges = matchingEdges .filter((edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)) .sort((a, b) => Number(b.type === "CONTAINS" || b.type === "DERIVED_FROM") - Number(a.type === "CONTAINS" || a.type === "DERIVED_FROM")) - .slice(0, 6000); + .slice(0, Math.min(150000, nodeLimit * 3)); const data = memoryGraphOverviewPayloadSchema.parse({ version: snapshot.version, layoutVersion: snapshot.layoutVersion, From 8a7e64f6ca38c42b58eaefa6aa35c3892ad43752 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:00:57 -0400 Subject: [PATCH 02/18] Add files via upload --- apps/api/tests/memory-graph-api.test.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/api/tests/memory-graph-api.test.ts b/apps/api/tests/memory-graph-api.test.ts index 6e8fb08ff..bf5d30368 100644 --- a/apps/api/tests/memory-graph-api.test.ts +++ b/apps/api/tests/memory-graph-api.test.ts @@ -434,6 +434,43 @@ describe("Memory graph API", () => { await app.close(); }); + it("honors the overview limit up to the hard maximum and keeps the default unchanged", async () => { + const graphSnapshot = snapshot() as MemoryGraphSnapshot; + graphSnapshot.nodes = Array.from({ length: 2600 }, (_, index) => ({ + id: `memory:limit-${index}`, + type: "MEMORY" as const, + label: `Limit memory ${index}`, + sourcePath: null, + recordId: null + })); + graphSnapshot.edges = []; + graphSnapshot.summary = { + ...graphSnapshot.summary, + nodeCount: graphSnapshot.nodes.length, + edgeCount: 0 + }; + const app = await memoryGraphApp(true, new InMemorySpaceStore(), false, undefined, { graphSnapshot }); + + const fallback = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview", headers: { cookie } }); + expect(fallback.statusCode).toBe(200); + expect(fallback.json().data.nodes).toHaveLength(2000); + expect(fallback.json().data.truncated).toBe(true); + expect(fallback.json().data.totalMatchingNodes).toBe(2600); + + const raised = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=2600", headers: { cookie } }); + expect(raised.statusCode).toBe(200); + expect(raised.json().data.nodes).toHaveLength(2600); + expect(raised.json().data.truncated).toBe(false); + + const maximum = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=50000", headers: { cookie } }); + expect(maximum.statusCode).toBe(200); + expect(maximum.json().data.nodes).toHaveLength(2600); + + const beyond = await app.inject({ method: "GET", url: "/api/admin/memory/graph/overview?limit=50001", headers: { cookie } }); + expect(beyond.statusCode).toBe(422); + await app.close(); + }); + it("serves the all-months archive and single-month filtering through the month query param", async () => { const graphSnapshot = snapshot() as MemoryGraphSnapshot; const archiveSnapshot = snapshot() as MemoryGraphSnapshot; From 89ecef814824188dc6777196a9ab60db381f8ae8 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:01:24 -0400 Subject: [PATCH 03/18] Add files via upload --- apps/web/src/demo/DemoStore.ts | 35 +++++++++++++-- apps/web/src/demo/synthetic-memory-graph.ts | 50 +++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/demo/synthetic-memory-graph.ts diff --git a/apps/web/src/demo/DemoStore.ts b/apps/web/src/demo/DemoStore.ts index 0a408bad1..3d4f1b314 100644 --- a/apps/web/src/demo/DemoStore.ts +++ b/apps/web/src/demo/DemoStore.ts @@ -52,6 +52,7 @@ import type { SpaceApiClient } from "../runtime/SpaceRuntime.js"; import { DEMO_LOCAL_REPLY, SpaceApiError } from "../runtime/SpaceRuntime.js"; import { CLI_RUNTIME_PRESENTATIONS, cliRuntimeLabel } from "../cli-runtime-presentation.js"; import { createDemoFixture, DEMO_FIXED_AT, type DemoFixture } from "./demo-fixture.js"; +import { generateSyntheticMemoryGraph } from "./synthetic-memory-graph.js"; import { MockServices } from "./mock-services.js"; import { getCliMockTranscript, getCliMockModel, getCliMockCwd, getCliMockDefinition } from "./mock-cli-transcripts.js"; @@ -148,8 +149,12 @@ type DemoMemoryGraphQuery = { relationMode?: "CLUSTERED" | "RELATIONS"; page?: number; pageSize?: number; + limit?: number; + graphSize?: number; }; +export { generateSyntheticMemoryGraph }; + function memoryChangeSetSummary(changeSet: MemoryChangeSet): MemoryChangeSetSummary { const { beforeSnapshot: _before, afterSnapshot: _after, ...summary } = changeSet; return summary; @@ -668,8 +673,22 @@ export class DemoStore { }; } + private demoGraphSizeFromUrl(): number | undefined { + try { + if (typeof window === "undefined") return undefined; + const value = Number(new URLSearchParams(window.location.search).get("demoGraphSize")); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; + } catch { + return undefined; + } + } + private memoryGraph(query: DemoMemoryGraphQuery = {}): MemoryGraphPayload { - const base = this.fixture.memoryWorkspace.graph; + const graphSize = query.graphSize ?? this.demoGraphSizeFromUrl(); + const synthetic = graphSize ? generateSyntheticMemoryGraph(graphSize) : null; + const base = synthetic + ? { ...this.fixture.memoryWorkspace.graph, nodes: synthetic.nodes, edges: synthetic.edges } + : this.fixture.memoryWorkspace.graph; const needle = query.q?.trim().toLowerCase(); const nodes = base.nodes.filter((node) => { const record = node.recordId ? this.fixture.memoryWorkspace.nodeDetails[node.id]?.record : null; @@ -1496,8 +1515,18 @@ export class DemoStore { }); } case "memoryGraphOverview": { - const graph = this.memoryGraph((args[0] ?? {}) as DemoMemoryGraphQuery); - return Promise.resolve({ data: { ...graph, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: false } }); + const query = (args[0] ?? {}) as DemoMemoryGraphQuery; + const graph = this.memoryGraph(query); + const limit = query.limit; + if (!limit || graph.nodes.length <= limit) { + return Promise.resolve({ data: { ...graph, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: false } }); + } + const nodes = graph.nodes.slice(0, limit); + const visibleIds = new Set(nodes.map((node) => node.id)); + const edges = graph.edges + .filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)) + .slice(0, Math.min(150000, limit * 3)); + return Promise.resolve({ data: { ...graph, nodes, edges, totalMatchingNodes: graph.nodes.length, totalMatchingEdges: graph.edges.length, truncated: true } }); } case "memoryCalendar": { const query = (args[0] ?? {}) as { year?: number; month?: string; date?: string }; diff --git a/apps/web/src/demo/synthetic-memory-graph.ts b/apps/web/src/demo/synthetic-memory-graph.ts new file mode 100644 index 000000000..b7f81cc70 --- /dev/null +++ b/apps/web/src/demo/synthetic-memory-graph.ts @@ -0,0 +1,50 @@ +import type { MemoryGraphEdge, MemoryGraphNode } from "@space/contracts"; + +/** + * Deterministic synthetic memory graph for scale benchmarks: a core source, + * a ring of source files, topics, and memories with contains / tagged / + * semantic edges at roughly three edges per node (50,000 nodes ≈ 150,000 + * edges). Anyone can reproduce the 3D renderer's benchmark in demo mode + * with ?demoGraphSize=50000 — no backend or embeddings provider needed. + */ +export function generateSyntheticMemoryGraph(nodeCount: number): { nodes: MemoryGraphNode[]; edges: MemoryGraphEdge[] } { + const total = Math.max(1, Math.floor(nodeCount)); + const nodes: MemoryGraphNode[] = [{ id: "source:space.md", type: "SOURCE", label: "space.md", sourcePath: "/demo/memory/space.md", recordId: null }]; + const edges: MemoryGraphEdge[] = []; + const sourceCount = Math.max(1, Math.min(400, Math.round(total / 250))); + const sources: string[] = []; + for (let s = 0; s < sourceCount && nodes.length < total; s++) { + const id = `source:file-${s}`; + sources.push(id); + nodes.push({ id, type: "SOURCE", label: `file-${s}.md`, sourcePath: `/demo/memory/file-${s}.md`, recordId: null }); + edges.push({ id: `edge:derived-${s}`, type: "DERIVED_FROM", source: id, target: "source:space.md" }); + } + const topicCount = Math.max(2, Math.min(64, Math.round(total / 1500))); + const topics: string[] = []; + for (let t = 0; t < topicCount && nodes.length < total; t++) { + const id = `topic:topic-${t}`; + topics.push(id); + nodes.push({ id, type: "TOPIC", label: `Topic ${t}`, sourcePath: null, recordId: null }); + } + let memoryIndex = 0; + while (nodes.length < total) { + const id = `memory:synthetic-${memoryIndex}`; + const source = sources[memoryIndex % sources.length]!; + nodes.push({ id, type: "MEMORY", label: `Synthetic memory ${memoryIndex}`, sourcePath: `/demo/memory/file-${memoryIndex % sources.length}.md`, recordId: id }); + edges.push({ id: `edge:contains-${memoryIndex}`, type: "CONTAINS", source, target: id }); + if (memoryIndex % 3 === 0) { + edges.push({ id: `edge:tagged-${memoryIndex}`, type: "TAGGED_WITH", source: id, target: topics[memoryIndex % topics.length]!, origin: "EXPLICIT_TAG", confidence: 1, evidence: "Synthetic demo tag." }); + } + if (memoryIndex > 0) { + edges.push({ id: `edge:related-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: id, target: `memory:synthetic-${memoryIndex - 1}`, origin: "DETERMINISTIC_TFIDF", confidence: 0.8, evidence: "Synthetic demo relation." }); + } + if (memoryIndex >= 7 && memoryIndex % 5 === 0) { + edges.push({ id: `edge:related-skip-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: `memory:synthetic-${memoryIndex - 7}`, target: id, origin: "DETERMINISTIC_TFIDF", confidence: 0.6, evidence: "Synthetic demo relation." }); + } + if (memoryIndex >= 2 && memoryIndex % 2 === 0) { + edges.push({ id: `edge:related-pair-${memoryIndex}`, type: "SEMANTICALLY_RELATED", source: `memory:synthetic-${memoryIndex - 2}`, target: id, origin: "DETERMINISTIC_TFIDF", confidence: 0.7, evidence: "Synthetic demo relation." }); + } + memoryIndex++; + } + return { nodes, edges }; +} From 9a1c6b1434ab7955db72a21051d5209097dc06c1 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:02:02 -0400 Subject: [PATCH 04/18] Add files via upload --- .../memory-workspace/MemoryGraph3D.tsx | 242 ++++++++++- .../memory-workspace/MemoryWorkspace.tsx | 33 +- .../features/memory-workspace/memory-3d.ts | 267 ++++++++++-- .../memory-workspace/memory-graph-webgl.ts | 380 ++++++++++++++++++ .../memory-network-layout.worker.ts | 17 +- 5 files changed, 888 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/features/memory-workspace/memory-graph-webgl.ts diff --git a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx index 34a99f3d5..f8bce85dc 100644 --- a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx +++ b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx @@ -2,7 +2,73 @@ import { useEffect, useMemo, useRef } from "react"; import type { MemoryGraphNode, MemoryGraphEdge } from "@space/contracts"; import type { MemoryAtlasPosition } from "./memory-atlas.js"; import { isMemoryCoreLabel } from "./memory-graph-visuals.js"; -import { DEFAULT_MEMORY_CAMERA, memory3DVolumeRatio, projectMemoryPoint, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; +import { DEFAULT_MEMORY_CAMERA, memory3DVolumeRatio, projectMemoryPoint, selectMemoryLabels, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; +import { MemoryGraphWebGLRenderer, hexToRgb, type MemoryWebGLScene } from "./memory-graph-webgl.js"; + +/** The Canvas 2D fallback keeps the historical cap from the overview endpoint. */ +const CANVAS_2D_NODE_CAP = 2000; + +interface SceneData { + scene: MemoryWebGLScene; + ids: string[]; + indexById: Map; + neighborsByIndex: Array>; +} + +function buildScene(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], coordinates: Map, positions: Map): SceneData | null { + const ids: string[] = []; + const indexById = new Map(); + for (const node of nodes) { + if (!coordinates.has(node.id)) continue; + indexById.set(node.id, ids.length); + ids.push(node.id); + } + if (!ids.length) return null; + const nodeById = new Map(nodes.map(node => [node.id, node])); + const scenePositions = new Float32Array(ids.length * 3); + const colors = new Float32Array(ids.length * 3); + const baseSizes = new Float32Array(ids.length); + let extent = 60; + ids.forEach((id, index) => { + const point = coordinates.get(id)!; + scenePositions[index * 3] = point.x; + scenePositions[index * 3 + 1] = point.y; + scenePositions[index * 3 + 2] = point.z; + extent = Math.max(extent, Math.hypot(point.x, point.y, point.z)); + const [r, g, b] = hexToRgb(positions.get(id)?.color ?? "#7395ad"); + colors[index * 3] = r; + colors[index * 3 + 1] = g; + colors[index * 3 + 2] = b; + const node = nodeById.get(id)!; + baseSizes[index] = node.type === "SOURCE" && isMemoryCoreLabel(node.label) ? 10 : node.type === "SOURCE" ? 4.6 : 2.1; + }); + const visibleEdges = edges.filter(edge => indexById.has(edge.source) && indexById.has(edge.target)); + const edgePositions = new Float32Array(visibleEdges.length * 6); + const edgeColors = new Float32Array(visibleEdges.length * 6); + const edgeNodeIndices = new Float32Array(visibleEdges.length * 2); + const neighborsByIndex: Array> = ids.map(() => new Set()); + visibleEdges.forEach((edge, edgeIndex) => { + const a = indexById.get(edge.source)!, b = indexById.get(edge.target)!; + neighborsByIndex[a]!.add(b); + neighborsByIndex[b]!.add(a); + const [r, g, bl] = hexToRgb(edge.type === "DERIVED_FROM" ? "#385068" : "#203343"); + for (const [slot, index] of [[0, a], [1, b]] as const) { + edgePositions[edgeIndex * 6 + slot * 3] = scenePositions[index * 3]!; + edgePositions[edgeIndex * 6 + slot * 3 + 1] = scenePositions[index * 3 + 1]!; + edgePositions[edgeIndex * 6 + slot * 3 + 2] = scenePositions[index * 3 + 2]!; + edgeColors[edgeIndex * 6 + slot * 3] = r; + edgeColors[edgeIndex * 6 + slot * 3 + 1] = g; + edgeColors[edgeIndex * 6 + slot * 3 + 2] = bl; + edgeNodeIndices[edgeIndex * 2 + slot] = index; + } + }); + return { + scene: { positions: scenePositions, colors, baseSizes, edgePositions, edgeColors, edgeNodeIndices, extent }, + ids, + indexById, + neighborsByIndex + }; +} export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles, selectedNodeId, onSelectNode, loading, resetToken }: { nodes: MemoryGraphNode[]; edges: MemoryGraphEdge[]; @@ -12,6 +78,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles }) { const volumeRatio = useMemo(() => memory3DVolumeRatio([...coordinates.values()]), [coordinates]); const canvasRef = useRef(null); + const labelCanvasRef = useRef(null); const cameraRef = useRef({ ...DEFAULT_MEMORY_CAMERA }); const redrawRef = useRef<() => void>(() => {}); const current = useRef({ nodes, edges, positions, coordinates, showTitles, selectedNodeId, onSelectNode }); @@ -22,17 +89,124 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles }, [resetToken]); useEffect(() => { const canvas = canvasRef.current; - if (!canvas) return; - const context = canvas.getContext("2d"); - if (!context) return; - let frame = 0, width = 1, height = 1; + const labelCanvas = labelCanvasRef.current; + if (!canvas || !labelCanvas) return; + let frame = 0, width = 1, height = 1, dpr = 1; let hover: string | null = null; let points: Array<{ id: string; x: number; y: number; z: number; radius: number }> = []; const pointers = new Map(); let dragged = false, downX = 0, downY = 0; - const render = () => { - frame = 0; - const { nodes, edges, positions, coordinates, showTitles, selectedNodeId } = current.current; + + // --- Renderer selection: WebGL2 first, Canvas 2D as the fallback. --- + let renderer: MemoryGraphWebGLRenderer | null = null; + let sceneData: SceneData | null = null; + let sceneNodes: MemoryGraphNode[] | null = null; + let sceneEdges: MemoryGraphEdge[] | null = null; + let scenePositions: Map | null = null; + let sceneCoordinates: Map | null = null; + let focusIndex = -2; // -2 = focus uniforms not yet pushed + if (MemoryGraphWebGLRenderer.isSupported()) { + try { + renderer = new MemoryGraphWebGLRenderer(canvas); + } catch { + renderer = null; + } + } + const fallbackContext = (): CanvasRenderingContext2D | null => { + if (renderer) return labelCanvas.getContext("2d"); + return canvas.getContext("2d") ?? labelCanvas.getContext("2d"); + }; + let labelContext: CanvasRenderingContext2D | null = null; + + const syncScene = () => { + if (!renderer) return; + const { nodes, edges, positions, coordinates } = current.current; + if (sceneData && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions && coordinates === sceneCoordinates) return; + const next = buildScene(nodes, edges, coordinates, positions); + if (!next) { + sceneData = null; + return; + } + const sameShape = sceneData && next.ids.length === sceneData.ids.length && next.ids.every((id, index) => id === sceneData!.ids[index]); + if (sameShape && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions) { + // Layout streaming: only the coordinates moved, so push the new + // position array into the existing GPU buffers instead of a rebuild. + renderer.updatePositions(next.scene.positions); + sceneData = next; + } else { + renderer.setScene(next.scene); + sceneData = next; + focusIndex = -2; + } + sceneNodes = nodes; + sceneEdges = edges; + scenePositions = positions; + sceneCoordinates = coordinates; + }; + + const applyFocus = () => { + if (!renderer || !sceneData) return; + const focusId = hover ?? current.current.selectedNodeId; + const index = focusId ? sceneData.indexById.get(focusId) ?? -1 : -1; + if (index === focusIndex) return; + focusIndex = index; + renderer.setFocus(index, index >= 0 ? sceneData.neighborsByIndex[index]! : null); + }; + + const drawLabels = (projected: Float32Array) => { + if (!labelContext) labelContext = labelCanvas.getContext("2d"); + if (!labelContext) return; + const { nodes, showTitles } = current.current; + labelContext.setTransform(dpr, 0, 0, dpr, 0, 0); + labelContext.clearRect(0, 0, width, height); + if (!showTitles || !sceneData) return; + const nodeById = new Map(nodes.map(node => [node.id, node])); + const candidates = sceneData.ids.flatMap((id, index) => { + const node = nodeById.get(id); + if (!node) return []; + return [{ + id, + label: node.label, + type: node.type, + core: isMemoryCoreLabel(node.label), + x: projected[index * 4]!, + y: projected[index * 4 + 1]! + }]; + }); + labelContext.font = "11px system-ui, sans-serif"; + const placements = selectMemoryLabels(candidates, { + focusId: hover ?? current.current.selectedNodeId, + zoom: cameraRef.current.zoom, + width, + height, + measure: text => labelContext!.measureText(text).width + }); + for (const placement of placements) { + labelContext.fillStyle = "#09111bea"; + labelContext.fillRect(placement.x - 3, placement.y - 11, placement.width + 6, 16); + labelContext.fillStyle = placement.core ? "#fff0b0" : "#c6dfed"; + labelContext.fillText(placement.text, placement.x, placement.y + 1); + } + }; + + const renderWebGL = () => { + if (!renderer || !sceneData) return; + const scale = Math.min(width, height) * 0.4 / sceneData.scene.extent; + applyFocus(); + renderer.render(cameraRef.current, scale, width, height); + drawLabels(renderer.projectedPoints()); + canvas.dataset.rotation = `${cameraRef.current.yaw.toFixed(3)},${cameraRef.current.pitch.toFixed(3)}`; + }; + + const renderCanvas2D = () => { + const context = fallbackContext(); + if (!context) return; + const all = current.current; + const cappedNodes = all.nodes.length > CANVAS_2D_NODE_CAP ? all.nodes.slice(0, CANVAS_2D_NODE_CAP) : all.nodes; + const cappedIds = new Set(cappedNodes.map(node => node.id)); + const nodes = cappedNodes; + const edges = all.nodes.length > CANVAS_2D_NODE_CAP ? all.edges.filter(edge => cappedIds.has(edge.source) && cappedIds.has(edge.target)) : all.edges; + const { positions, coordinates, showTitles, selectedNodeId } = all; context.clearRect(0, 0, width, height); const extent = Math.max(60, ...[...coordinates.values()].map(p => Math.hypot(p.x, p.y, p.z))); const scale = Math.min(width, height) * 0.4 / extent; @@ -83,15 +257,32 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles } canvas.dataset.rotation = `${cameraRef.current.yaw.toFixed(3)},${cameraRef.current.pitch.toFixed(3)}`; }; + + const render = () => { + frame = 0; + syncScene(); + if (renderer && sceneData) renderWebGL(); + else renderCanvas2D(); + }; const schedule = () => { if (!frame) frame = requestAnimationFrame(render); }; redrawRef.current = schedule; const resize = () => { const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); - context.setTransform(dpr, 0, 0, dpr, 0, 0); schedule(); + dpr = Math.min(window.devicePixelRatio || 1, 2); + for (const target of [canvas, labelCanvas]) { + target.width = Math.round(width * dpr); target.height = Math.round(height * dpr); + } + labelContext?.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!renderer) canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); + schedule(); + }; + const hit = (x: number, y: number) => { + if (renderer && sceneData) { + const index = renderer.pick(x, y); + return index >= 0 ? sceneData.ids[index] ?? null : null; + } + return [...points].reverse().find(p => Math.hypot(p.x - x, p.y - y) < Math.max(7, p.radius + 3))?.id ?? null; }; - const hit = (x: number, y: number) => [...points].reverse().find(p => Math.hypot(p.x - x, p.y - y) < Math.max(7, p.radius + 3))?.id ?? null; const local = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; const down = (event: PointerEvent) => { canvas.focus(); canvas.setPointerCapture(event.pointerId); @@ -124,10 +315,33 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles cameraRef.current.pitch += event.key === "ArrowUp" ? -0.15 : event.key === "ArrowDown" ? 0.15 : 0; schedule(); } else if (event.key === "Enter") { const node = current.current.nodes.find(node => node.id === current.current.selectedNodeId) ?? current.current.nodes.find(node => isMemoryCoreLabel(node.label)); if (node) current.current.onSelectNode(node.id); } }; + const contextLost = (event: Event) => { + // Keep the view alive on the Canvas 2D fallback; allow the browser to restore. + event.preventDefault(); + renderer?.dispose(); + renderer = null; + sceneData = null; + labelContext = null; + schedule(); + }; + const contextRestored = () => { + if (!renderer && MemoryGraphWebGLRenderer.isSupported()) { + try { + renderer = new MemoryGraphWebGLRenderer(canvas); + sceneData = null; + sceneNodes = null; + focusIndex = -2; + } catch { + renderer = null; + } + } + schedule(); + }; const observer = new ResizeObserver(resize); observer.observe(canvas); resize(); canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move); canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", cancel); canvas.addEventListener("wheel", wheel, { passive: false }); canvas.addEventListener("keydown", key); - return () => { observer.disconnect(); cancelAnimationFrame(frame); redrawRef.current = () => {}; canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move); canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", cancel); canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("keydown", key); }; + canvas.addEventListener("webglcontextlost", contextLost); canvas.addEventListener("webglcontextrestored", contextRestored); + return () => { observer.disconnect(); cancelAnimationFrame(frame); redrawRef.current = () => {}; renderer?.dispose(); canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move); canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", cancel); canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("keydown", key); canvas.removeEventListener("webglcontextlost", contextLost); canvas.removeEventListener("webglcontextrestored", contextRestored); }; }, []); useEffect(() => { redrawRef.current(); }, [nodes, edges, positions, coordinates, showTitles, selectedNodeId]); - return
Drag to rotate 360° · Scroll or pinch to zoom · Click to explore
; + return
; } diff --git a/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx b/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx index 0cbadd543..712a43391 100644 --- a/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx +++ b/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx @@ -95,15 +95,38 @@ export function MemoryWorkspace({ shellMode, onClose, initialMode = "calendar" } .finally(() => { if (request === detailRequest.current) setDetailLoading(false); }); }, []); - const atlas = useMemo(() => createMemoryAtlas(graph?.data.nodes ?? [], graph?.data.edges ?? [], false), [graph]); + // Entering the 3D view on a truncated overview loads the full graph (up to + // the endpoint's 50,000-node hard maximum) so the 3D view is not limited + // to the 2,000-node sample the 2D overview uses. + const [fullGraph, setFullGraph] = useState(null); + const [fullGraphQuery, setFullGraphQuery] = useState(null); + useEffect(() => { + if (!view3D || !graph?.data.truncated || fullGraphQuery === query) return; + let active = true; + void api.memoryGraphOverview({ q: query || undefined, month: "all", relationMode: "RELATIONS", limit: 50_000 }) + .then(payload => { if (active) { setFullGraph(payload); setFullGraphQuery(query); } }) + .catch(() => { /* keep the sampled graph; the 3D view still works */ }); + return () => { active = false; }; + }, [view3D, graph, query, fullGraphQuery]); + const activeGraph = view3D && fullGraph && fullGraphQuery === query ? fullGraph : graph; + const atlas = useMemo(() => createMemoryAtlas(activeGraph?.data.nodes ?? [], activeGraph?.data.edges ?? [], false), [activeGraph]); const [layout, setLayout] = useState<{ atlas: typeof atlas; positions: Map; depth: Map } | null>(null); useEffect(() => { if (workspaceMode !== "atlas" || !atlas.nodes.length || typeof Worker === "undefined") return; const worker = new Worker(new URL("./memory-network-layout.worker.ts", import.meta.url), { type: "module" }); - worker.onmessage = (event: MessageEvent<{ positions: Array<[string, { x: number; y: number }]>; depth: Array<[string, MemoryPoint3D]> }>) => { - const coordinates = new Map(event.data.positions); - setLayout({ atlas, depth: new Map(event.data.depth), positions: new Map([...atlas.positions].map(([id, position]) => [id, { ...position, ...coordinates.get(id) }])) }); - worker.terminate(); + worker.onmessage = (event: MessageEvent<{ positions: Array<[string, { x: number; y: number }]> | null; depthIds: string[]; depth: Float32Array; depthDone: boolean }>) => { + const depth = new Map(); + event.data.depthIds.forEach((id, index) => { + depth.set(id, { x: event.data.depth[index * 3]!, y: event.data.depth[index * 3 + 1]!, z: event.data.depth[index * 3 + 2]! }); + }); + setLayout(previous => { + const coordinates = event.data.positions ? new Map(event.data.positions) : null; + const positions = coordinates + ? new Map([...atlas.positions].map(([id, position]) => [id, { ...position, ...coordinates.get(id) }])) + : previous?.atlas === atlas ? previous.positions : atlas.positions; + return { atlas, depth, positions }; + }); + if (event.data.depthDone) worker.terminate(); }; worker.onerror = event => { setError(`Memory layout could not be calculated: ${event.message || "worker unavailable"}`); worker.terminate(); }; worker.postMessage({ nodes: atlas.nodes, edges: atlas.edges }); diff --git a/apps/web/src/features/memory-workspace/memory-3d.ts b/apps/web/src/features/memory-workspace/memory-3d.ts index 512bdb7a3..32b9f6436 100644 --- a/apps/web/src/features/memory-workspace/memory-3d.ts +++ b/apps/web/src/features/memory-workspace/memory-3d.ts @@ -13,36 +13,80 @@ const unit = (seed: string) => { return ((hash ^ (hash >>> 16)) >>> 0) / 4294967295; }; -/** Independent XYZ force layout. No coordinates are inherited from the 2D map. */ -export function layoutMemoryNetwork3D(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): Map { - const ordered = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); - const index = new Map(ordered.map((node, i) => [node.id, i])); - const root = ordered.findIndex(node => node.type === "SOURCE" && isMemoryCoreLabel(node.label)); - const count = ordered.length; - const points = ordered.map(node => ({ - x: (unit(`${node.id}:x`) - 0.5) * 260, - y: (unit(`${node.id}:y`) - 0.5) * 260, - z: (unit(`${node.id}:z`) - 0.5) * 260 - })); - if (root >= 0) points[root] = { x: 0, y: 0, z: 0 }; - const links = [...edges].sort((a, b) => a.id.localeCompare(b.id)).flatMap(edge => { - const a = index.get(edge.source), b = index.get(edge.target); - if (a === undefined || b === undefined || a === b) return []; - const fileLink = ordered[a]!.type === "SOURCE" && ordered[b]!.type === "SOURCE"; - return [{ a, b, length: fileLink ? 110 : edge.type === "CONTAINS" ? 34 : 55, - strength: fileLink ? 0.035 : edge.type === "CONTAINS" ? 0.028 : 0.004 }]; - }); - // Initialize records around their actual file, in a volume rather than a plane. - for (const edge of edges) { - const a = index.get(edge.source), b = index.get(edge.target); - if (edge.type !== "CONTAINS" || a === undefined || b === undefined || ordered[a]!.type !== "SOURCE" || ordered[b]!.type !== "MEMORY") continue; - const parent = points[a]!, id = ordered[b]!.id; - points[b] = { x: parent.x + (unit(`${id}:dx`) - 0.5) * 70, - y: parent.y + (unit(`${id}:dy`) - 0.5) * 70, z: parent.z + (unit(`${id}:dz`) - 0.5) * 70 }; +export const MEMORY_LAYOUT_3D_STEPS = 180; + +/** + * The 3D force layout as a steppable simulation. The worker advances it in + * slices and streams snapshots to the main thread, so the view stays usable + * while a large graph converges. Running it to completion produces exactly + * the same positions as the original one-shot layout: the per-step math is + * unchanged and the output stays deterministic for the same input. + */ +export class MemoryLayout3DSimulation { + readonly totalSteps = MEMORY_LAYOUT_3D_STEPS; + stepCount = 0; + private readonly ordered: MemoryGraphNode[]; + private readonly points: MemoryPoint3D[]; + private readonly links: Array<{ a: number; b: number; length: number; strength: number }>; + private readonly root: number; + private readonly fx: Float64Array; + private readonly fy: Float64Array; + private readonly fz: Float64Array; + + constructor(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]) { + const ordered = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); + const index = new Map(ordered.map((node, i) => [node.id, i])); + const root = ordered.findIndex(node => node.type === "SOURCE" && isMemoryCoreLabel(node.label)); + const count = ordered.length; + const points = ordered.map(node => ({ + x: (unit(`${node.id}:x`) - 0.5) * 260, + y: (unit(`${node.id}:y`) - 0.5) * 260, + z: (unit(`${node.id}:z`) - 0.5) * 260 + })); + if (root >= 0) points[root] = { x: 0, y: 0, z: 0 }; + const links = [...edges].sort((a, b) => a.id.localeCompare(b.id)).flatMap(edge => { + const a = index.get(edge.source), b = index.get(edge.target); + if (a === undefined || b === undefined || a === b) return []; + const fileLink = ordered[a]!.type === "SOURCE" && ordered[b]!.type === "SOURCE"; + return [{ a, b, length: fileLink ? 110 : edge.type === "CONTAINS" ? 34 : 55, + strength: fileLink ? 0.035 : edge.type === "CONTAINS" ? 0.028 : 0.004 }]; + }); + // Initialize records around their actual file, in a volume rather than a plane. + for (const edge of edges) { + const a = index.get(edge.source), b = index.get(edge.target); + if (edge.type !== "CONTAINS" || a === undefined || b === undefined || ordered[a]!.type !== "SOURCE" || ordered[b]!.type !== "MEMORY") continue; + const parent = points[a]!, id = ordered[b]!.id; + points[b] = { x: parent.x + (unit(`${id}:dx`) - 0.5) * 70, + y: parent.y + (unit(`${id}:dy`) - 0.5) * 70, z: parent.z + (unit(`${id}:dz`) - 0.5) * 70 }; + } + this.ordered = ordered; + this.points = points; + this.links = links; + this.root = root; + this.fx = new Float64Array(count); + this.fy = new Float64Array(count); + this.fz = new Float64Array(count); + } + + get done(): boolean { + return this.stepCount >= this.totalSteps; + } + + /** Node IDs in simulation order; snapshots and position arrays follow it. */ + get nodeIds(): string[] { + return this.ordered.map(node => node.id); } - const fx = new Float64Array(count), fy = new Float64Array(count), fz = new Float64Array(count); - // Bounded spatial-grid repulsion and link springs act equally on all three axes. - for (let step = 0; step < 180; step++) { + + /** Advance up to `steps` simulation steps. Returns true when converged. */ + step(steps = 1): boolean { + for (let count = 0; count < steps && !this.done; count++) this.stepOnce(); + return this.done; + } + + private stepOnce(): void { + const { points, links, ordered, root, fx, fy, fz } = this; + const count = points.length; + const step = this.stepCount; fx.fill(0); fy.fill(0); fz.fill(0); const grid = new Map(); for (let i = 0; i < count; i++) { @@ -78,8 +122,30 @@ export function layoutMemoryNetwork3D(nodes: MemoryGraphNode[], edges: MemoryGra const stepScale = Math.min(1, 4 / Math.max(0.01, Math.hypot(dx, dy, dz))) * cooling; p.x += dx * stepScale; p.y += dy * stepScale; p.z += dz * stepScale; } + this.stepCount++; + } + + /** Positions as a flat XYZ array in `nodeIds` order, for transfer to the main thread. */ + positionsArray(): Float32Array { + const out = new Float32Array(this.points.length * 3); + this.points.forEach((point, i) => { + out[i * 3] = point.x; + out[i * 3 + 1] = point.y; + out[i * 3 + 2] = point.z; + }); + return out; + } + + snapshot(): Map { + return new Map(this.ordered.map((node, i) => [node.id, { ...this.points[i]! }])); } - return new Map(ordered.map((node, i) => [node.id, points[i]!])); +} + +/** Independent XYZ force layout. No coordinates are inherited from the 2D map. */ +export function layoutMemoryNetwork3D(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]): Map { + const simulation = new MemoryLayout3DSimulation(nodes, edges); + simulation.step(Number.POSITIVE_INFINITY); + return simulation.snapshot(); } export function projectMemoryPoint(point: MemoryPoint3D, camera: MemoryCamera3D, scale: number, width: number, height: number) { @@ -91,6 +157,147 @@ export function projectMemoryPoint(point: MemoryPoint3D, camera: MemoryCamera3D, return { x: width / 2 + x * scale * camera.zoom * perspective, y: height / 2 - y * scale * camera.zoom * perspective, z, perspective }; } +/** + * Batch version of projectMemoryPoint: flat XYZ positions in, flat + * [screenX, screenY, depth, perspective] quads out. Same math, no per-point + * allocation, so a 50,000-node frame costs one pass over typed arrays. + */ +export function projectMemoryPoints(positions: Float32Array, camera: MemoryCamera3D, scale: number, width: number, height: number): Float32Array { + const count = Math.floor(positions.length / 3); + const out = new Float32Array(count * 4); + const cosYaw = Math.cos(camera.yaw), sinYaw = Math.sin(camera.yaw); + const cosPitch = Math.cos(camera.pitch), sinPitch = Math.sin(camera.pitch); + for (let i = 0; i < count; i++) { + const px = positions[i * 3]!, py = positions[i * 3 + 1]!, pz = positions[i * 3 + 2]!; + const x = px * cosYaw + pz * sinYaw; + const depth = -px * sinYaw + pz * cosYaw; + const y = py * cosPitch - depth * sinPitch; + const z = py * sinPitch + depth * cosPitch; + const perspective = 650 / Math.max(120, 650 + z * scale); + out[i * 4] = width / 2 + x * scale * camera.zoom * perspective; + out[i * 4 + 1] = height / 2 - y * scale * camera.zoom * perspective; + out[i * 4 + 2] = z; + out[i * 4 + 3] = perspective; + } + return out; +} + +/** On-screen node radius in CSS pixels, matching the Canvas 2D renderer's sizing. */ +export function memoryNodeRadius(baseSize: number, perspective: number, zoom: number, highlighted: boolean): number { + return Math.max(1, baseSize * perspective * Math.sqrt(zoom)) * (highlighted ? 1.5 : 1); +} + +/** + * Screen-space picking over the last projected frame. A uniform grid keeps + * hover/click lookups at O(1)-ish instead of a linear scan over every node, + * and the winner matches the Canvas 2D behavior: among the points whose hit + * radius covers the pointer, the nearest one (smallest depth) wins. + */ +export class MemoryPickIndex { + private cells = new Map(); + private maxRadius = 0; + constructor(private readonly cellSize = 64) {} + + rebuild(projected: Float32Array, radii: Float32Array): void { + this.cells.clear(); + this.maxRadius = 0; + const count = Math.floor(projected.length / 4); + for (let i = 0; i < count; i++) { + const x = projected[i * 4]!, y = projected[i * 4 + 1]!; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + if (radii[i]! > this.maxRadius) this.maxRadius = radii[i]!; + const key = `${Math.floor(x / this.cellSize)},${Math.floor(y / this.cellSize)}`; + const cell = this.cells.get(key); + if (cell) cell.push(i); + else this.cells.set(key, [i]); + } + } + + /** Index of the picked point, or -1. Depth ties break toward the closest center. */ + pick(x: number, y: number, projected: Float32Array, radii: Float32Array): number { + const reach = Math.max(7, this.maxRadius + 3); + const ring = Math.min(6, Math.ceil(reach / this.cellSize)); + const cx = Math.floor(x / this.cellSize), cy = Math.floor(y / this.cellSize); + let best = -1, bestDepth = Number.POSITIVE_INFINITY, bestDistance = Number.POSITIVE_INFINITY; + for (let gx = cx - ring; gx <= cx + ring; gx++) for (let gy = cy - ring; gy <= cy + ring; gy++) { + for (const i of this.cells.get(`${gx},${gy}`) ?? []) { + const px = projected[i * 4]!, py = projected[i * 4 + 1]!, depth = projected[i * 4 + 2]!; + const distance = Math.hypot(px - x, py - y); + if (distance >= Math.max(7, radii[i]! + 3)) continue; + if (depth < bestDepth || (depth === bestDepth && distance < bestDistance)) { + best = i; bestDepth = depth; bestDistance = distance; + } + } + } + return best; + } +} + +export interface MemoryLabelCandidate { + id: string; + label: string; + type: MemoryGraphNode["type"]; + core: boolean; + x: number; + y: number; +} + +export interface MemoryLabelPlacement { + id: string; + text: string; + x: number; + y: number; + width: number; + core: boolean; +} + +export const MEMORY_LABEL_MAX_PER_FRAME = 48; +export const MEMORY_LABEL_MAX_MEASURED_PER_FRAME = 64; + +/** + * Level-of-detail label selection with the Canvas 2D rules preserved: focus + * first, then core files, then sources; non-sources stay hidden below zoom + * 1.6; collisions are avoided against already-placed boxes. Whatever the + * graph size, at most maxMeasured labels are measured and maxLabels drawn + * per frame, so label work is bounded. + */ +export function selectMemoryLabels( + candidates: MemoryLabelCandidate[], + options: { + focusId?: string | null; + zoom: number; + width: number; + height: number; + maxLabels?: number; + maxMeasured?: number; + measure: (text: string) => number; + } +): MemoryLabelPlacement[] { + const maxLabels = options.maxLabels ?? MEMORY_LABEL_MAX_PER_FRAME; + const maxMeasured = options.maxMeasured ?? MEMORY_LABEL_MAX_MEASURED_PER_FRAME; + const focusId = options.focusId ?? null; + const ordered = [...candidates].sort((a, b) => + Number(b.id === focusId) - Number(a.id === focusId) || + Number(b.core) - Number(a.core) || + Number(b.type === "SOURCE") - Number(a.type === "SOURCE")); + const placements: MemoryLabelPlacement[] = []; + const boxes: Array<{ x: number; y: number; w: number }> = []; + let measured = 0; + for (const candidate of ordered) { + if (placements.length >= maxLabels || measured >= maxMeasured) break; + if (candidate.type !== "SOURCE" && candidate.id !== focusId && options.zoom < 1.6) continue; + const text = candidate.label.length > 48 ? `${candidate.label.slice(0, 45)}…` : candidate.label; + const width = options.measure(text); + measured++; + const x = candidate.x + 9, y = candidate.y; + if (x + width > options.width || y < 14 || y > options.height - 5) continue; + if (boxes.some(box => Math.abs(box.y - y) < 16 && x < box.x + box.w + 5 && x + width + 5 > box.x)) continue; + boxes.push({ x, y, w: width }); + placements.push({ id: candidate.id, text, x, y, width, core: candidate.core }); + } + return placements; +} + /** Normalized covariance determinant: 0 for a plane, 1 for equal spread in XYZ. */ export function memory3DVolumeRatio(points: MemoryPoint3D[]): number { if (points.length < 4) return 0; diff --git a/apps/web/src/features/memory-workspace/memory-graph-webgl.ts b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts new file mode 100644 index 000000000..d3f20fc46 --- /dev/null +++ b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts @@ -0,0 +1,380 @@ +import { MemoryPickIndex, memoryNodeRadius, projectMemoryPoints, type MemoryCamera3D } from "./memory-3d.js"; + +/** + * WebGL2 renderer for the 3D memory graph. Nodes are one instanced draw call + * (billboard quads), edges are one batched line draw call, and depth is + * resolved by the GPU — no per-frame CPU sort. Projection math mirrors + * projectMemoryPoint exactly, so the WebGL view matches the Canvas 2D look: + * same camera, same sizes, same colors, same focus dimming. + */ + +export interface MemoryWebGLScene { + /** Flat XYZ world positions, one triple per node. */ + positions: Float32Array; + /** Flat RGB triples per node, components in 0..1. */ + colors: Float32Array; + /** Base radius per node before perspective/zoom scaling. */ + baseSizes: Float32Array; + /** Flat XYZ positions, two endpoints per edge. */ + edgePositions: Float32Array; + /** Flat RGB triples, two per edge (base color when nothing is focused). */ + edgeColors: Float32Array; + /** Node index per edge vertex, two per edge; drives focus highlighting. */ + edgeNodeIndices: Float32Array; + /** Largest world-space distance from the origin; sets depth range. */ + extent: number; +} + +export function hexToRgb(hex: string): [number, number, number] { + const value = Number.parseInt(hex.replace("#", ""), 16); + if (!Number.isFinite(value)) return [0.45, 0.58, 0.68]; + return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]; +} + +const NODE_VERTEX = `#version 300 es +in vec2 aCorner; +in vec3 aPos; +in vec3 aColor; +in float aSize; +in float aDim; +in float aHighlight; +uniform float uCosYaw; +uniform float uSinYaw; +uniform float uCosPitch; +uniform float uSinPitch; +uniform float uScale; +uniform float uZoom; +uniform vec2 uCenter; +uniform vec2 uViewport; +uniform float uDepthScale; +out vec3 vColor; +out float vAlpha; +out vec2 vLocal; +out float vHighlight; +void main() { + float x = aPos.x * uCosYaw + aPos.z * uSinYaw; + float depth = -aPos.x * uSinYaw + aPos.z * uCosYaw; + float y = aPos.y * uCosPitch - depth * uSinPitch; + float z = aPos.y * uSinPitch + depth * uCosPitch; + float perspective = 650.0 / max(120.0, 650.0 + z * uScale); + float cx = uCenter.x + x * uScale * uZoom * perspective; + float cy = uCenter.y - y * uScale * uZoom * perspective; + float radius = max(1.0, aSize * perspective * sqrt(uZoom)) * (aHighlight > 0.5 ? 1.5 : 1.0); + float pad = aHighlight > 0.5 ? radius * 0.9 : radius * 0.3; + float halfSize = radius + pad; + vec2 pixel = vec2(cx + aCorner.x * halfSize, cy + aCorner.y * halfSize); + gl_Position = vec4(pixel.x / uViewport.x * 2.0 - 1.0, 1.0 - pixel.y / uViewport.y * 2.0, clamp(z * uDepthScale, -1.0, 1.0), 1.0); + vColor = aColor; + vAlpha = aDim > 0.5 ? 0.2 : clamp(perspective, 0.4, 1.0); + vLocal = aCorner * (halfSize / radius); + vHighlight = aHighlight; +}`; + +const NODE_FRAGMENT = `#version 300 es +precision mediump float; +in vec3 vColor; +in float vAlpha; +in vec2 vLocal; +in float vHighlight; +out vec4 outColor; +void main() { + float distance = length(vLocal); + float alpha = vAlpha; + if (distance <= 1.0) { + alpha *= smoothstep(1.0, 0.82, distance); + } else if (vHighlight > 0.5) { + alpha *= max(0.0, 1.0 - (distance - 1.0) / 0.9) * 0.55; + } else { + alpha = 0.0; + } + if (alpha <= 0.004) discard; + outColor = vec4(vColor, alpha); +}`; + +const EDGE_VERTEX = `#version 300 es +in vec3 aPos; +in vec3 aColor; +in float aNodeIndex; +uniform float uCosYaw; +uniform float uSinYaw; +uniform float uCosPitch; +uniform float uSinPitch; +uniform float uScale; +uniform float uZoom; +uniform vec2 uCenter; +uniform vec2 uViewport; +uniform float uDepthScale; +uniform float uFocus; +uniform vec3 uIncidentColor; +uniform vec3 uDimColor; +out vec3 vColor; +void main() { + float x = aPos.x * uCosYaw + aPos.z * uSinYaw; + float depth = -aPos.x * uSinYaw + aPos.z * uCosYaw; + float y = aPos.y * uCosPitch - depth * uSinPitch; + float z = aPos.y * uSinPitch + depth * uCosPitch; + float perspective = 650.0 / max(120.0, 650.0 + z * uScale); + float cx = uCenter.x + x * uScale * uZoom * perspective; + float cy = uCenter.y - y * uScale * uZoom * perspective; + gl_Position = vec4(cx / uViewport.x * 2.0 - 1.0, 1.0 - cy / uViewport.y * 2.0, clamp(z * uDepthScale, -1.0, 1.0), 1.0); + vColor = uFocus < -0.5 ? aColor : (abs(aNodeIndex - uFocus) < 0.5 ? uIncidentColor : uDimColor); +}`; + +const EDGE_FRAGMENT = `#version 300 es +precision mediump float; +in vec3 vColor; +out vec4 outColor; +void main() { + outColor = vec4(vColor, 1.0); +}`; + +function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { + const shader = gl.createShader(type); + if (!shader) throw new Error("WebGL2 shader allocation failed"); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`WebGL2 shader failed to compile: ${log ?? "unknown error"}`); + } + return shader; +} + +function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string): WebGLProgram { + const program = gl.createProgram(); + if (!program) throw new Error("WebGL2 program allocation failed"); + gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, vertex)); + gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, fragment)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`WebGL2 program failed to link: ${gl.getProgramInfoLog(program) ?? "unknown error"}`); + } + return program; +} + +const QUAD_CORNERS = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]); + +export class MemoryGraphWebGLRenderer { + private readonly gl: WebGL2RenderingContext; + private readonly nodeProgram: WebGLProgram; + private readonly edgeProgram: WebGLProgram; + private readonly nodeVao: WebGLVertexArrayObject; + private readonly edgeVao: WebGLVertexArrayObject; + private readonly buffers: WebGLBuffer[] = []; + private readonly nodeUniforms: Record = {}; + private readonly edgeUniforms: Record = {}; + private scene: MemoryWebGLScene | null = null; + private nodeCount = 0; + private edgeVertexCount = 0; + private dimBuffer: WebGLBuffer | null = null; + private highlightBuffer: WebGLBuffer | null = null; + private pickIndex = new MemoryPickIndex(); + private projected: Float32Array = new Float32Array(0); + private radii: Float32Array = new Float32Array(0); + + static isSupported(): boolean { + if (typeof document === "undefined") return false; + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2"); + if (!gl) return false; + gl.getExtension("WEBGL_lose_context")?.loseContext(); + return true; + } catch { + return false; + } + } + + constructor(private readonly canvas: HTMLCanvasElement) { + const gl = canvas.getContext("webgl2", { antialias: true, alpha: true, powerPreference: "high-performance" }); + if (!gl) throw new Error("WebGL2 context unavailable"); + this.gl = gl; + this.nodeProgram = createProgram(gl, NODE_VERTEX, NODE_FRAGMENT); + this.edgeProgram = createProgram(gl, EDGE_VERTEX, EDGE_FRAGMENT); + for (const name of ["uCosYaw", "uSinYaw", "uCosPitch", "uSinPitch", "uScale", "uZoom", "uCenter", "uViewport", "uDepthScale"]) { + this.nodeUniforms[name] = gl.getUniformLocation(this.nodeProgram, name); + this.edgeUniforms[name] = gl.getUniformLocation(this.edgeProgram, name); + } + this.edgeUniforms.uFocus = gl.getUniformLocation(this.edgeProgram, "uFocus"); + this.edgeUniforms.uIncidentColor = gl.getUniformLocation(this.edgeProgram, "uIncidentColor"); + this.edgeUniforms.uDimColor = gl.getUniformLocation(this.edgeProgram, "uDimColor"); + const nodeVao = gl.createVertexArray(); + const edgeVao = gl.createVertexArray(); + if (!nodeVao || !edgeVao) throw new Error("WebGL2 VAO allocation failed"); + this.nodeVao = nodeVao; + this.edgeVao = edgeVao; + // Static quad corners, instanced once per node. + gl.bindVertexArray(this.nodeVao); + const cornerBuffer = this.createBuffer(new Float32Array(QUAD_CORNERS), gl.STATIC_DRAW); + const cornerLocation = gl.getAttribLocation(this.nodeProgram, "aCorner"); + gl.enableVertexAttribArray(cornerLocation); + gl.vertexAttribPointer(cornerLocation, 2, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(cornerLocation, 0); + gl.bindVertexArray(null); + } + + private createBuffer(data: Float32Array, usage: number): WebGLBuffer { + const gl = this.gl; + const buffer = gl.createBuffer(); + if (!buffer) throw new Error("WebGL2 buffer allocation failed"); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, data, usage); + this.buffers.push(buffer); + return buffer; + } + + private bindInstanced(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number, usage: number): WebGLBuffer { + const gl = this.gl; + gl.bindVertexArray(vao); + const buffer = this.createBuffer(data, usage); + const location = gl.getAttribLocation(program, name); + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(location, 1); + gl.bindVertexArray(null); + return buffer; + } + + private bindVertex(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number): void { + const gl = this.gl; + gl.bindVertexArray(vao); + const buffer = this.createBuffer(data, gl.STATIC_DRAW); + const location = gl.getAttribLocation(program, name); + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + setScene(scene: MemoryWebGLScene): void { + const gl = this.gl; + // Retire the previous scene's dynamic buffers. + for (const buffer of this.buffers.splice(0)) gl.deleteBuffer(buffer); + this.scene = scene; + this.nodeCount = Math.floor(scene.positions.length / 3); + this.edgeVertexCount = Math.floor(scene.edgePositions.length / 3); + this.bindInstanced(this.nodeProgram, this.nodeVao, "aPos", scene.positions, 3, gl.DYNAMIC_DRAW); + this.bindInstanced(this.nodeProgram, this.nodeVao, "aColor", scene.colors, 3, gl.STATIC_DRAW); + this.bindInstanced(this.nodeProgram, this.nodeVao, "aSize", scene.baseSizes, 1, gl.STATIC_DRAW); + this.dimBuffer = this.bindInstanced(this.nodeProgram, this.nodeVao, "aDim", new Float32Array(this.nodeCount), 1, gl.DYNAMIC_DRAW); + this.highlightBuffer = this.bindInstanced(this.nodeProgram, this.nodeVao, "aHighlight", new Float32Array(this.nodeCount), 1, gl.DYNAMIC_DRAW); + // Rebind the static corners after the buffer reset. + gl.bindVertexArray(this.nodeVao); + const cornerBuffer = this.createBuffer(new Float32Array(QUAD_CORNERS), gl.STATIC_DRAW); + const cornerLocation = gl.getAttribLocation(this.nodeProgram, "aCorner"); + gl.enableVertexAttribArray(cornerLocation); + gl.vertexAttribPointer(cornerLocation, 2, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(cornerLocation, 0); + gl.bindVertexArray(null); + this.bindVertex(this.edgeProgram, this.edgeVao, "aPos", scene.edgePositions, 3); + this.bindVertex(this.edgeProgram, this.edgeVao, "aColor", scene.edgeColors, 3); + this.bindVertex(this.edgeProgram, this.edgeVao, "aNodeIndex", scene.edgeNodeIndices, 1); + this.radii = new Float32Array(this.nodeCount); + } + + /** Update node positions in place while the layout streams in. */ + updatePositions(positions: Float32Array): void { + if (!this.scene) return; + this.scene = { ...this.scene, positions }; + const gl = this.gl; + gl.bindVertexArray(this.nodeVao); + // The aPos buffer is the first dynamic buffer created for the scene. + const buffer = this.buffers[0]; + if (buffer) { + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW); + } + gl.bindVertexArray(null); + } + + /** Focus a node index (or -1 for none); neighbors stay lit, the rest dim. */ + setFocus(focusIndex: number, neighbors: ReadonlySet | null): void { + this.focusIndexValue = focusIndex; + if (!this.dimBuffer || !this.highlightBuffer) return; + const gl = this.gl; + const dim = new Float32Array(this.nodeCount); + const highlight = new Float32Array(this.nodeCount); + if (focusIndex >= 0) { + for (let i = 0; i < this.nodeCount; i++) { + if (i !== focusIndex && !(neighbors?.has(i) ?? false)) dim[i] = 1; + } + if (focusIndex < this.nodeCount) highlight[focusIndex] = 1; + } + gl.bindBuffer(gl.ARRAY_BUFFER, this.dimBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, dim); + gl.bindBuffer(gl.ARRAY_BUFFER, this.highlightBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, highlight); + } + + render(camera: MemoryCamera3D, scale: number, width: number, height: number): void { + const gl = this.gl; + if (!this.scene) return; + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.enable(gl.DEPTH_TEST); + gl.depthFunc(gl.LEQUAL); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + const uniforms = { + uCosYaw: Math.cos(camera.yaw), uSinYaw: Math.sin(camera.yaw), + uCosPitch: Math.cos(camera.pitch), uSinPitch: Math.sin(camera.pitch), + uScale: scale, uZoom: camera.zoom + }; + const depthScale = 1 / Math.max(1, this.scene.extent * 2); + // Edges first, without depth writes, so nodes sit cleanly on top. + gl.depthMask(false); + gl.useProgram(this.edgeProgram); + for (const [name, value] of Object.entries(uniforms)) gl.uniform1f(this.edgeUniforms[name] ?? null, value); + gl.uniform2f(this.edgeUniforms.uCenter ?? null, width / 2, height / 2); + gl.uniform2f(this.edgeUniforms.uViewport ?? null, width, height); + gl.uniform1f(this.edgeUniforms.uDepthScale ?? null, depthScale); + gl.uniform1f(this.edgeUniforms.uFocus ?? null, this.focusIndexValue); + const [ir, ig, ib] = hexToRgb("#98dafa"); + gl.uniform3f(this.edgeUniforms.uIncidentColor ?? null, ir, ig, ib); + const [dr, dg, db] = hexToRgb("#14202b"); + gl.uniform3f(this.edgeUniforms.uDimColor ?? null, dr, dg, db); + gl.bindVertexArray(this.edgeVao); + gl.drawArrays(gl.LINES, 0, this.edgeVertexCount); + gl.bindVertexArray(null); + // Nodes: one instanced draw call for the whole graph. + gl.depthMask(true); + gl.useProgram(this.nodeProgram); + for (const [name, value] of Object.entries(uniforms)) gl.uniform1f(this.nodeUniforms[name] ?? null, value); + gl.uniform2f(this.nodeUniforms.uCenter ?? null, width / 2, height / 2); + gl.uniform2f(this.nodeUniforms.uViewport ?? null, width, height); + gl.uniform1f(this.nodeUniforms.uDepthScale ?? null, depthScale); + gl.bindVertexArray(this.nodeVao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.nodeCount); + gl.bindVertexArray(null); + // CPU projection of the same frame powers picking and label placement. + this.projected = projectMemoryPoints(this.scene.positions, camera, scale, width, height); + for (let i = 0; i < this.nodeCount; i++) { + this.radii[i] = memoryNodeRadius(this.scene.baseSizes[i]!, this.projected[i * 4 + 3]!, camera.zoom, false); + } + this.pickIndex.rebuild(this.projected, this.radii); + } + + private focusIndexValue = -1; + + /** Node index at a CSS-pixel position from the last rendered frame, or -1. */ + pick(x: number, y: number): number { + if (!this.scene || this.projected.length === 0) return -1; + return this.pickIndex.pick(x, y, this.projected, this.radii); + } + + /** Projected [x, y, depth, perspective] quads from the last render. */ + projectedPoints(): Float32Array { + return this.projected; + } + + dispose(): void { + const gl = this.gl; + for (const buffer of this.buffers.splice(0)) gl.deleteBuffer(buffer); + gl.deleteVertexArray(this.nodeVao); + gl.deleteVertexArray(this.edgeVao); + gl.deleteProgram(this.nodeProgram); + gl.deleteProgram(this.edgeProgram); + this.scene = null; + } +} diff --git a/apps/web/src/features/memory-workspace/memory-network-layout.worker.ts b/apps/web/src/features/memory-workspace/memory-network-layout.worker.ts index f46e50612..0e2bd388d 100644 --- a/apps/web/src/features/memory-workspace/memory-network-layout.worker.ts +++ b/apps/web/src/features/memory-workspace/memory-network-layout.worker.ts @@ -1,7 +1,20 @@ -import { layoutMemoryNetwork3D } from "./memory-3d.js"; +import { MemoryLayout3DSimulation } from "./memory-3d.js"; import { layoutMemoryNetwork } from "./memory-network-layout.js"; import type { MemoryGraphNode, MemoryGraphEdge } from "@space/contracts"; + +/** Depth snapshots stream every few steps so the 3D view is usable while a + * large layout converges; positions arrays are transferred, not copied. */ +const DEPTH_STEPS_PER_CHUNK = 6; + self.onmessage = (event: MessageEvent<{ nodes: MemoryGraphNode[]; edges: MemoryGraphEdge[] }>) => { const positions = layoutMemoryNetwork(event.data.nodes, event.data.edges); - self.postMessage({ positions: [...positions], depth: [...layoutMemoryNetwork3D(event.data.nodes, event.data.edges)] }); + const simulation = new MemoryLayout3DSimulation(event.data.nodes, event.data.edges); + const ids = simulation.nodeIds; + const initial = simulation.positionsArray(); + self.postMessage({ positions: [...positions], depthIds: ids, depth: initial, depthDone: false }, { transfer: [initial.buffer as ArrayBuffer] }); + while (!simulation.done) { + simulation.step(DEPTH_STEPS_PER_CHUNK); + const snapshot = simulation.positionsArray(); + self.postMessage({ positions: null, depthIds: ids, depth: snapshot, depthDone: simulation.done }, { transfer: [snapshot.buffer as ArrayBuffer] }); + } }; From 1d64cf89148affdb6fcf0ce73ee30f27c12894e4 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:02:24 -0400 Subject: [PATCH 05/18] Add files via upload --- apps/web/src/live-api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/live-api.ts b/apps/web/src/live-api.ts index 1c4f10573..68b2f066e 100644 --- a/apps/web/src/live-api.ts +++ b/apps/web/src/live-api.ts @@ -3153,6 +3153,7 @@ export const api = { lifecycleStatus?: MemoryLifecycleStatus; month?: string; relationMode?: "CLUSTERED" | "RELATIONS"; + limit?: number; } = {}) => { const params = new URLSearchParams(); if (query.q) params.set("q", query.q); @@ -3163,6 +3164,7 @@ export const api = { if (query.lifecycleStatus) params.set("lifecycleStatus", query.lifecycleStatus); if (query.month) params.set("month", query.month); if (query.relationMode) params.set("relationMode", query.relationMode); + if (query.limit) params.set("limit", String(query.limit)); const suffix = params.toString(); return request(`/api/admin/memory/graph/overview${suffix ? `?${suffix}` : ""}`); }, From a6ab28707468c1c94b825213d153da41bef75752 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:04:16 -0400 Subject: [PATCH 06/18] Add files via upload --- apps/web/tests/memory-3d-scale.test.ts | 229 +++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 apps/web/tests/memory-3d-scale.test.ts diff --git a/apps/web/tests/memory-3d-scale.test.ts b/apps/web/tests/memory-3d-scale.test.ts new file mode 100644 index 000000000..879cfa556 --- /dev/null +++ b/apps/web/tests/memory-3d-scale.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + MemoryLayout3DSimulation, + MemoryPickIndex, + layoutMemoryNetwork3D, + memoryNodeRadius, + projectMemoryPoint, + projectMemoryPoints, + selectMemoryLabels, + DEFAULT_MEMORY_CAMERA, + type MemoryLabelCandidate +} from "../src/features/memory-workspace/memory-3d.js"; +import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph.js"; +import { memoryGraphEdgeSchema, memoryGraphNodeSchema, type MemoryGraphEdge, type MemoryGraphNode } from "@space/contracts"; + +function fixture(nodeCount = 127) { + const nodes: MemoryGraphNode[] = [{ id: "root", label: "gemini.md", type: "SOURCE", sourcePath: "/opt/spaceapp/docs/gemini.md", recordId: null }]; + const edges: MemoryGraphEdge[] = []; + const perSource = Math.ceil((nodeCount - 1) / 6); + for (let file = 0; file < 6 && nodes.length < nodeCount; file++) { + const id = `source-${file}`; + nodes.push({ id, label: `${id}.md`, type: "SOURCE", sourcePath: `/opt/spaceapp/docs/${id}.md`, recordId: null }); + edges.push({ id: `file-${file}`, type: "DERIVED_FROM", source: "root", target: id }); + for (let item = 0; item < perSource && nodes.length < nodeCount; item++) { + const memory = `${id}-memory-${item}`; + nodes.push({ id: memory, label: memory, type: "MEMORY", sourcePath: `/opt/spaceapp/docs/${id}.md`, recordId: memory }); + edges.push({ id: memory, type: "CONTAINS", source: id, target: memory }); + } + } + return { nodes, edges }; +} + +// Deterministic pseudo-random values in [0, 1). +const rand = (seed: number) => { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 4294967295; + }; +}; + +describe("streaming 3D layout simulation", () => { + it("matches the one-shot layout exactly when run to completion", () => { + const { nodes, edges } = fixture(); + const simulation = new MemoryLayout3DSimulation(nodes, edges); + expect(simulation.done).toBe(false); + simulation.step(Number.POSITIVE_INFINITY); + expect(simulation.done).toBe(true); + expect(simulation.stepCount).toBe(180); + expect(simulation.snapshot()).toEqual(layoutMemoryNetwork3D(nodes, edges)); + }); + + it("streams intermediate snapshots that converge to the final layout", () => { + const { nodes, edges } = fixture(); + const simulation = new MemoryLayout3DSimulation(nodes, edges); + simulation.step(10); + const early = simulation.snapshot(); + expect(simulation.done).toBe(false); + simulation.step(Number.POSITIVE_INFINITY); + const final = simulation.snapshot(); + expect(final).toEqual(layoutMemoryNetwork3D(nodes, edges)); + expect(early.get("source-0-memory-0")).not.toEqual(final.get("source-0-memory-0")); + // Position arrays follow nodeIds order and carry one XYZ triple per node. + const positions = simulation.positionsArray(); + expect(positions).toHaveLength(nodes.length * 3); + expect(simulation.nodeIds[0]).toBe("root"); + expect(positions[0]).toBe(0); + expect(positions[1]).toBe(0); + expect(positions[2]).toBe(0); + }); +}); + +describe("batch projection", () => { + it("matches projectMemoryPoint component for component", () => { + const random = rand(42); + const count = 500; + const positions = new Float32Array(count * 3); + for (let i = 0; i < count * 3; i++) positions[i] = (random() - 0.5) * 520; + const camera = { yaw: 0.7, pitch: -0.3, zoom: 1.4 }; + const projected = projectMemoryPoints(positions, camera, 2.5, 1200, 800); + for (let i = 0; i < count; i += 37) { + const expected = projectMemoryPoint( + { x: positions[i * 3]!, y: positions[i * 3 + 1]!, z: positions[i * 3 + 2]! }, + camera, 2.5, 1200, 800 + ); + // The batch path stores float32, so agreement is to sub-pixel precision. + expect(Math.abs(projected[i * 4]! - expected.x)).toBeLessThan(1e-3); + expect(Math.abs(projected[i * 4 + 1]! - expected.y)).toBeLessThan(1e-3); + expect(Math.abs(projected[i * 4 + 2]! - expected.z)).toBeLessThan(1e-3); + expect(Math.abs(projected[i * 4 + 3]! - expected.perspective)).toBeLessThan(1e-5); + } + }); +}); + +describe("spatial pick index", () => { + const build = (count: number) => { + const random = rand(7); + const positions = new Float32Array(count * 3); + for (let i = 0; i < count * 3; i++) positions[i] = (random() - 0.5) * 520; + const projected = projectMemoryPoints(positions, DEFAULT_MEMORY_CAMERA, 2.2, 1200, 800); + const radii = new Float32Array(count); + for (let i = 0; i < count; i++) radii[i] = memoryNodeRadius(2.1, projected[i * 4 + 3]!, DEFAULT_MEMORY_CAMERA.zoom, false); + const index = new MemoryPickIndex(); + index.rebuild(projected, radii); + return { projected, radii, index }; + }; + const bruteForce = (x: number, y: number, projected: Float32Array, radii: Float32Array) => { + let best = -1, bestDepth = Number.POSITIVE_INFINITY, bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < radii.length; i++) { + const distance = Math.hypot(projected[i * 4]! - x, projected[i * 4 + 1]! - y); + if (distance >= Math.max(7, radii[i]! + 3)) continue; + const depth = projected[i * 4 + 2]!; + if (depth < bestDepth || (depth === bestDepth && distance < bestDistance)) { + best = i; bestDepth = depth; bestDistance = distance; + } + } + return best; + }; + + it("agrees with a brute-force scan on every probe", () => { + const { projected, radii, index } = build(3000); + const random = rand(99); + for (let probe = 0; probe < 300; probe++) { + const x = random() * 1200, y = random() * 800; + expect(index.pick(x, y, projected, radii)).toBe(bruteForce(x, y, projected, radii)); + } + }); + + it("picks 50,000 nodes far faster than the 4 ms budget", () => { + const { projected, radii, index } = build(50_000); + const random = rand(1234); + const samples: number[] = []; + for (let probe = 0; probe < 200; probe++) { + const start = performance.now(); + index.pick(random() * 1200, random() * 800, projected, radii); + samples.push(performance.now() - start); + } + samples.sort((a, b) => a - b); + const p95 = samples[Math.floor(samples.length * 0.95)]!; + expect(p95).toBeLessThan(4); + }); +}); + +describe("label level-of-detail selection", () => { + const candidates = (count: number): MemoryLabelCandidate[] => { + const random = rand(5); + return Array.from({ length: count }, (_, i) => ({ + id: `node-${i}`, + label: `Label ${i}`, + type: i % 10 === 0 ? "SOURCE" as const : "MEMORY" as const, + core: i === 3, + x: random() * 1100, + y: 20 + random() * 700 + })); + }; + + it("bounds measured and drawn labels whatever the graph size", () => { + let measured = 0; + const placements = selectMemoryLabels(candidates(5000), { + zoom: 2, + width: 1200, + height: 800, + measure: text => { measured++; return text.length * 6; } + }); + expect(placements.length).toBeLessThanOrEqual(48); + expect(measured).toBeLessThanOrEqual(64); + }); + + it("keeps the priority and zoom rules of the 2D renderer", () => { + const list: MemoryLabelCandidate[] = [ + { id: "memory-a", label: "Memory A", type: "MEMORY", core: false, x: 100, y: 100 }, + { id: "source-a", label: "Source A", type: "SOURCE", core: false, x: 400, y: 100 }, + { id: "core", label: "gemini.md", type: "SOURCE", core: true, x: 700, y: 100 }, + { id: "focused", label: "Focused memory", type: "MEMORY", core: false, x: 1000, y: 100 } + ]; + const lowZoom = selectMemoryLabels(list, { + focusId: "focused", zoom: 1, width: 1400, height: 800, measure: text => text.length * 6 + }); + // Below zoom 1.6 only sources and the focused node are labeled. + expect(lowZoom.map(p => p.id).sort()).toEqual(["core", "focused", "source-a"]); + expect(lowZoom[0]!.id).toBe("focused"); + const highZoom = selectMemoryLabels(list, { + zoom: 2, width: 1400, height: 800, measure: text => text.length * 6 + }); + expect(highZoom.map(p => p.id)).toEqual(["core", "source-a", "memory-a", "focused"]); + }); + + it("never places colliding labels", () => { + const list = candidates(800); + const placements = selectMemoryLabels(list, { + zoom: 2, width: 1200, height: 800, measure: text => text.length * 6 + }); + for (let i = 0; i < placements.length; i++) { + for (let j = i + 1; j < placements.length; j++) { + const a = placements[i]!, b = placements[j]!; + const collides = Math.abs(a.y - b.y) < 16 && b.x < a.x + a.width + 5 && b.x + b.width + 5 > a.x; + expect(collides).toBe(false); + } + } + }); +}); + +describe("synthetic demo graph", () => { + it("generates the requested size deterministically at roughly 3 edges per node", () => { + const first = generateSyntheticMemoryGraph(50_000); + const second = generateSyntheticMemoryGraph(50_000); + expect(first.nodes).toHaveLength(50_000); + expect(first.edges.length).toBeGreaterThan(100_000); + expect(first.edges.length).toBeLessThan(170_000); + expect(first).toEqual(second); + const ids = new Set(first.nodes.map(node => node.id)); + expect(first.nodes[0]).toMatchObject({ id: "source:space.md", type: "SOURCE", label: "space.md" }); + for (const edge of first.edges) { + expect(ids.has(edge.source)).toBe(true); + expect(ids.has(edge.target)).toBe(true); + } + }); + + it("handles small sizes exactly", () => { + expect(generateSyntheticMemoryGraph(10).nodes).toHaveLength(10); + expect(generateSyntheticMemoryGraph(1).nodes).toHaveLength(1); + }); + + it("produces nodes and edges that satisfy the contracts schemas", () => { + const { nodes, edges } = generateSyntheticMemoryGraph(500); + for (const node of nodes) expect(memoryGraphNodeSchema.safeParse(node).success).toBe(true); + for (const edge of edges) expect(memoryGraphEdgeSchema.safeParse(edge).success).toBe(true); + }); +}); From 087653e80711df6385e33e513a6ed2e244d83176 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:04:56 -0400 Subject: [PATCH 07/18] Add files via upload --- packages/contracts/src/schemas.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 696eb6d84..7d35e6d49 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -7256,8 +7256,8 @@ export const memoryGraphPayloadSchema = z.object({ export const memoryGraphOverviewPayloadSchema = memoryGraphPayloadSchema .omit({ nodes: true, edges: true }) .extend({ - nodes: z.array(memoryGraphNodeSchema).max(2000), - edges: z.array(memoryGraphEdgeSchema).max(6000), + nodes: z.array(memoryGraphNodeSchema).max(50000), + edges: z.array(memoryGraphEdgeSchema).max(150000), totalMatchingNodes: z.number().int().min(0), totalMatchingEdges: z.number().int().min(0), truncated: z.boolean() @@ -7288,6 +7288,10 @@ export const listMemoryGraphOverviewQuerySchema = listMemoryGraphQuerySchema.omi pageSize: true, sortBy: true, sortOrder: true +}).extend({ + // The overview defaults to the historical 2,000-node sample; callers (the + // 3D view) may ask for the full graph up to a hard server maximum. + limit: z.coerce.number().int().min(1).max(50000).optional() }); export const memoryCalendarQuerySchema = z.object({ From 6f4c672639f0bcf181eb28c3b17d5be5e6411c03 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:10:11 -0400 Subject: [PATCH 08/18] =?UTF-8?q?feat(web):=20WebGL2=20renderer=20for=20th?= =?UTF-8?q?e=203D=20memory=20graph=20=E2=80=94=2050,000=20nodes=20at=20int?= =?UTF-8?q?eractive=20frame=20rates=20(closes=20#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/tests/memory-3d-benchmark.test.ts | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 apps/web/tests/memory-3d-benchmark.test.ts diff --git a/apps/web/tests/memory-3d-benchmark.test.ts b/apps/web/tests/memory-3d-benchmark.test.ts new file mode 100644 index 000000000..b32be0a06 --- /dev/null +++ b/apps/web/tests/memory-3d-benchmark.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + MemoryLayout3DSimulation, + MemoryPickIndex, + memoryNodeRadius, + projectMemoryPoints, + selectMemoryLabels, + DEFAULT_MEMORY_CAMERA, + type MemoryLabelCandidate +} from "../src/features/memory-workspace/memory-3d.js"; +import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph.js"; + +/** + * Frame-time harness for the 3D memory graph acceptance criteria. It + * measures the renderer's full CPU-side share of a frame — batch projection, + * radius computation, pick-index rebuild, and label LOD selection — over + * simulated rotating/zooming frames, plus picking latency and layout time. + * GPU rasterization itself is two draw calls (instanced nodes, batched + * edges); on an integrated-GPU laptop the CPU share measured here is the + * part that must stay well inside the 33 ms p95 budget. + * + * Run with: npm test -w @space/web -- memory-3d-benchmark + * Hardware: whatever machine runs the suite — recorded in the PR. + */ + +const SIZES = [2_000, 10_000, 50_000]; +const WIDTH = 1440; +const HEIGHT = 900; + +function percentile(samples: number[], ratio: number): number { + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))]!; +} + +describe("3D memory graph frame-time harness", () => { + for (const size of SIZES) { + it(`measures CPU frame cost and picking at ${size.toLocaleString()} nodes`, () => { + const { nodes, edges } = generateSyntheticMemoryGraph(size); + const layoutStart = performance.now(); + const simulation = new MemoryLayout3DSimulation(nodes, edges); + simulation.step(Number.POSITIVE_INFINITY); + const layoutMs = performance.now() - layoutStart; + const positions = simulation.positionsArray(); + const extent = Math.max(60, ...Array.from({ length: size }, (_, i) => + Math.hypot(positions[i * 3]!, positions[i * 3 + 1]!, positions[i * 3 + 2]!))); + const scale = Math.min(WIDTH, HEIGHT) * 0.4 / extent; + const frameSamples: number[] = []; + const pickSamples: number[] = []; + const frames = 40; + let projected: Float32Array = new Float32Array(0); + const pickIndex = new MemoryPickIndex(); + for (let frame = 0; frame < frames; frame++) { + const camera = { + yaw: DEFAULT_MEMORY_CAMERA.yaw + frame * 0.05, + pitch: DEFAULT_MEMORY_CAMERA.pitch + Math.sin(frame * 0.1) * 0.1, + zoom: 1 + (frame % 10) * 0.08 + }; + const start = performance.now(); + projected = projectMemoryPoints(positions, camera, scale, WIDTH, HEIGHT); + const radii = new Float32Array(size); + for (let i = 0; i < size; i++) { + radii[i] = memoryNodeRadius(2.1, projected[i * 4 + 3]!, camera.zoom, false); + } + pickIndex.rebuild(projected, radii); + const labelCandidates: MemoryLabelCandidate[] = []; + for (let i = 0; i < size; i += 97) { + labelCandidates.push({ + id: nodes[i]!.id, + label: nodes[i]!.label, + type: nodes[i]!.type, + core: nodes[i]!.label === "space.md", + x: projected[i * 4]!, + y: projected[i * 4 + 1]! + }); + } + selectMemoryLabels(labelCandidates, { + zoom: camera.zoom, width: WIDTH, height: HEIGHT, measure: text => text.length * 6 + }); + frameSamples.push(performance.now() - start); + const pickStart = performance.now(); + pickIndex.pick(WIDTH / 2 + (frame % 7) * 13, HEIGHT / 2 + (frame % 5) * 17, projected, radii); + pickSamples.push(performance.now() - pickStart); + } + const frameP95 = percentile(frameSamples, 0.95); + const pickP95 = percentile(pickSamples, 0.95); + console.info( + `[memory-3d-benchmark] nodes=${size} edges=${edges.length} layoutMs=${layoutMs.toFixed(1)} ` + + `cpuFrameP95Ms=${frameP95.toFixed(3)} pickP95Ms=${pickP95.toFixed(3)}` + ); + // Acceptance: picking at most 4 ms; frame budget 33 ms p95 at 50k + // (16.7 ms at 2k) for the full frame — the CPU share must leave the + // GPU ample headroom inside it. + expect(pickP95).toBeLessThan(4); + if (size === 50_000) expect(frameP95).toBeLessThan(33); + if (size === 2_000) expect(frameP95).toBeLessThan(16.7); + }, 300_000); + } +}); From 8235579c7c359147cb8498554f86e702fe6cb277 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 02:10:56 -0400 Subject: [PATCH 09/18] =?UTF-8?q?feat(web):=20WebGL2=20renderer=20for=20th?= =?UTF-8?q?e=203D=20memory=20graph=20=E2=80=94=2050,000=20nodes=20at=20int?= =?UTF-8?q?eractive=20frame=20rates=20(closes=20#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 06f77b08761a06741658c40e33f4e148f0a7a16b Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 09:40:21 -0400 Subject: [PATCH 10/18] fix(web): address CodeRabbit review on the WebGL memory graph (PR #263) --- .../memory-workspace/MemoryGraph3D.tsx | 30 ++++++------- .../memory-workspace/MemoryWorkspace.tsx | 6 ++- .../features/memory-workspace/memory-3d.ts | 44 +++++++++++++++++++ .../memory-workspace/memory-graph-webgl.ts | 21 ++++++--- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx index f8bce85dc..87357b1c3 100644 --- a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx +++ b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import type { MemoryGraphNode, MemoryGraphEdge } from "@space/contracts"; import type { MemoryAtlasPosition } from "./memory-atlas.js"; import { isMemoryCoreLabel } from "./memory-graph-visuals.js"; -import { DEFAULT_MEMORY_CAMERA, memory3DVolumeRatio, projectMemoryPoint, selectMemoryLabels, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; +import { DEFAULT_MEMORY_CAMERA, collectMemoryLabelCandidates, memory3DVolumeRatio, projectMemoryPoint, selectMemoryLabels, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; import { MemoryGraphWebGLRenderer, hexToRgb, type MemoryWebGLScene } from "./memory-graph-webgl.js"; /** The Canvas 2D fallback keeps the historical cap from the overview endpoint. */ @@ -11,6 +11,8 @@ const CANVAS_2D_NODE_CAP = 2000; interface SceneData { scene: MemoryWebGLScene; ids: string[]; + /** Node objects aligned with `ids` (and with the projected points). */ + orderedNodes: MemoryGraphNode[]; indexById: Map; neighborsByIndex: Array>; } @@ -65,6 +67,7 @@ function buildScene(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], coordina return { scene: { positions: scenePositions, colors, baseSizes, edgePositions, edgeColors, edgeNodeIndices, extent }, ids, + orderedNodes: ids.map(id => nodeById.get(id)!), indexById, neighborsByIndex }; @@ -130,8 +133,8 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles const sameShape = sceneData && next.ids.length === sceneData.ids.length && next.ids.every((id, index) => id === sceneData!.ids[index]); if (sameShape && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions) { // Layout streaming: only the coordinates moved, so push the new - // position array into the existing GPU buffers instead of a rebuild. - renderer.updatePositions(next.scene.positions); + // position arrays into the existing GPU buffers instead of a rebuild. + renderer.updatePositions(next.scene.positions, next.scene.edgePositions); sceneData = next; } else { renderer.setScene(next.scene); @@ -156,22 +159,13 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles const drawLabels = (projected: Float32Array) => { if (!labelContext) labelContext = labelCanvas.getContext("2d"); if (!labelContext) return; - const { nodes, showTitles } = current.current; + const { showTitles } = current.current; labelContext.setTransform(dpr, 0, 0, dpr, 0, 0); labelContext.clearRect(0, 0, width, height); if (!showTitles || !sceneData) return; - const nodeById = new Map(nodes.map(node => [node.id, node])); - const candidates = sceneData.ids.flatMap((id, index) => { - const node = nodeById.get(id); - if (!node) return []; - return [{ - id, - label: node.label, - type: node.type, - core: isMemoryCoreLabel(node.label), - x: projected[index * 4]!, - y: projected[index * 4 + 1]! - }]; + const candidates = collectMemoryLabelCandidates(sceneData.orderedNodes, projected, { + focusId: hover ?? current.current.selectedNodeId, + zoom: cameraRef.current.zoom }); labelContext.font = "11px system-ui, sans-serif"; const placements = selectMemoryLabels(candidates, { @@ -201,6 +195,10 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles const renderCanvas2D = () => { const context = fallbackContext(); if (!context) return; + // A resize resets the context transform, and after a WebGL context + // loss nothing reapplies it to the fallback context — set it on every + // draw so the fallback renders at CSS-pixel scale on any display. + context.setTransform(dpr, 0, 0, dpr, 0, 0); const all = current.current; const cappedNodes = all.nodes.length > CANVAS_2D_NODE_CAP ? all.nodes.slice(0, CANVAS_2D_NODE_CAP) : all.nodes; const cappedIds = new Set(cappedNodes.map(node => node.id)); diff --git a/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx b/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx index 712a43391..4f0f0604d 100644 --- a/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx +++ b/apps/web/src/features/memory-workspace/MemoryWorkspace.tsx @@ -110,6 +110,10 @@ export function MemoryWorkspace({ shellMode, onClose, initialMode = "calendar" } }, [view3D, graph, query, fullGraphQuery]); const activeGraph = view3D && fullGraph && fullGraphQuery === query ? fullGraph : graph; const atlas = useMemo(() => createMemoryAtlas(activeGraph?.data.nodes ?? [], activeGraph?.data.edges ?? [], false), [activeGraph]); + // The 2D map stays on the sampled overview atlas: only the 3D view + // upgrades to the full graph, so the 2D renderer never builds a map with + // tens of thousands of nodes while the 3D view is open. + const sampledAtlas = useMemo(() => createMemoryAtlas(graph?.data.nodes ?? [], graph?.data.edges ?? [], false), [graph]); const [layout, setLayout] = useState<{ atlas: typeof atlas; positions: Map; depth: Map } | null>(null); useEffect(() => { if (workspaceMode !== "atlas" || !atlas.nodes.length || typeof Worker === "undefined") return; @@ -213,7 +217,7 @@ export function MemoryWorkspace({ shellMode, onClose, initialMode = "calendar" }
node.type === "SOURCE").length} data-source-link-count={atlas.edges.filter(edge => edge.type === "CONTAINS" || edge.type === "DERIVED_FROM").length} data-root-visible={atlas.nodes.some(node => node.type === "SOURCE" && isMemoryCoreLabel(node.label))} data-raw-node-count={graph?.data.nodes.length ?? 0} data-visible-node-count={atlas.nodes.length} data-visible-edge-count={atlas.edges.length}> }> - +
{view3D ? : null} diff --git a/apps/web/src/features/memory-workspace/memory-3d.ts b/apps/web/src/features/memory-workspace/memory-3d.ts index 32b9f6436..a9010b875 100644 --- a/apps/web/src/features/memory-workspace/memory-3d.ts +++ b/apps/web/src/features/memory-workspace/memory-3d.ts @@ -298,6 +298,50 @@ export function selectMemoryLabels( return placements; } +export const MEMORY_LABEL_CANDIDATE_CAP = 512; + +/** + * Collect one frame's label candidates from the scene's nodes in projected + * order, without allocating for nodes that cannot be labeled. The zoom gate + * from selectMemoryLabels is applied before a candidate object exists, and + * the survivors are grouped in selection priority (focus, core, sources, + * rest) and capped. selectMemoryLabels measures at most + * MEMORY_LABEL_MAX_MEASURED_PER_FRAME candidates before it stops, so a + * working set of 512 in priority order cannot change which labels are + * placed — but the per-frame cost stays bounded at any graph size instead + * of an allocation and a sort over every node. + */ +export function collectMemoryLabelCandidates( + nodes: MemoryGraphNode[], + projected: Float32Array, + options: { focusId?: string | null; zoom: number } +): MemoryLabelCandidate[] { + const focusId = options.focusId ?? null; + const focus: MemoryLabelCandidate[] = []; + const core: MemoryLabelCandidate[] = []; + const sources: MemoryLabelCandidate[] = []; + const rest: MemoryLabelCandidate[] = []; + const count = Math.min(nodes.length, Math.floor(projected.length / 4)); + for (let index = 0; index < count; index++) { + const node = nodes[index]!; + const isFocus = node.id === focusId; + if (!isFocus && node.type !== "SOURCE" && options.zoom < 1.6) continue; + const candidate: MemoryLabelCandidate = { + id: node.id, + label: node.label, + type: node.type, + core: isMemoryCoreLabel(node.label), + x: projected[index * 4]!, + y: projected[index * 4 + 1]! + }; + if (isFocus) focus.push(candidate); + else if (candidate.core) core.push(candidate); + else if (node.type === "SOURCE") sources.push(candidate); + else rest.push(candidate); + } + return [...focus, ...core, ...sources, ...rest].slice(0, MEMORY_LABEL_CANDIDATE_CAP); +} + /** Normalized covariance determinant: 0 for a plane, 1 for equal spread in XYZ. */ export function memory3DVolumeRatio(points: MemoryPoint3D[]): number { if (points.length < 4) return 0; diff --git a/apps/web/src/features/memory-workspace/memory-graph-webgl.ts b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts index d3f20fc46..d072aa827 100644 --- a/apps/web/src/features/memory-workspace/memory-graph-webgl.ts +++ b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts @@ -169,6 +169,7 @@ export class MemoryGraphWebGLRenderer { private edgeVertexCount = 0; private dimBuffer: WebGLBuffer | null = null; private highlightBuffer: WebGLBuffer | null = null; + private edgePositionBuffer: WebGLBuffer | null = null; private pickIndex = new MemoryPickIndex(); private projected: Float32Array = new Float32Array(0); private radii: Float32Array = new Float32Array(0); @@ -236,14 +237,15 @@ export class MemoryGraphWebGLRenderer { return buffer; } - private bindVertex(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number): void { + private bindVertex(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number, usage: number = this.gl.STATIC_DRAW): WebGLBuffer { const gl = this.gl; gl.bindVertexArray(vao); - const buffer = this.createBuffer(data, gl.STATIC_DRAW); + const buffer = this.createBuffer(data, usage); const location = gl.getAttribLocation(program, name); gl.enableVertexAttribArray(location); gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null); + return buffer; } setScene(scene: MemoryWebGLScene): void { @@ -266,16 +268,16 @@ export class MemoryGraphWebGLRenderer { gl.vertexAttribPointer(cornerLocation, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribDivisor(cornerLocation, 0); gl.bindVertexArray(null); - this.bindVertex(this.edgeProgram, this.edgeVao, "aPos", scene.edgePositions, 3); + this.edgePositionBuffer = this.bindVertex(this.edgeProgram, this.edgeVao, "aPos", scene.edgePositions, 3, gl.DYNAMIC_DRAW); this.bindVertex(this.edgeProgram, this.edgeVao, "aColor", scene.edgeColors, 3); this.bindVertex(this.edgeProgram, this.edgeVao, "aNodeIndex", scene.edgeNodeIndices, 1); this.radii = new Float32Array(this.nodeCount); } - /** Update node positions in place while the layout streams in. */ - updatePositions(positions: Float32Array): void { + /** Update node and edge positions in place while the layout streams in. */ + updatePositions(positions: Float32Array, edgePositions: Float32Array): void { if (!this.scene) return; - this.scene = { ...this.scene, positions }; + this.scene = { ...this.scene, positions, edgePositions }; const gl = this.gl; gl.bindVertexArray(this.nodeVao); // The aPos buffer is the first dynamic buffer created for the scene. @@ -285,6 +287,13 @@ export class MemoryGraphWebGLRenderer { gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW); } gl.bindVertexArray(null); + // Edges follow the same snapshots: without this upload they would stay + // at the layout captured by setScene while the nodes move on. + if (this.edgePositionBuffer) { + gl.bindBuffer(gl.ARRAY_BUFFER, this.edgePositionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, edgePositions, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } } /** Focus a node index (or -1 for none); neighbors stay lit, the rest dim. */ From 841dab9c548274ef4f39c2ad23f09fdb2d355c4f Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 09:41:22 -0400 Subject: [PATCH 11/18] fix(web): address CodeRabbit review on the WebGL memory graph (PR #263) --- apps/web/tests/memory-3d-benchmark.test.ts | 23 ++++++++--------- apps/web/tests/memory-3d-scale.test.ts | 30 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/web/tests/memory-3d-benchmark.test.ts b/apps/web/tests/memory-3d-benchmark.test.ts index b32be0a06..5a1e5ebbc 100644 --- a/apps/web/tests/memory-3d-benchmark.test.ts +++ b/apps/web/tests/memory-3d-benchmark.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { MemoryLayout3DSimulation, MemoryPickIndex, + collectMemoryLabelCandidates, memoryNodeRadius, projectMemoryPoints, selectMemoryLabels, - DEFAULT_MEMORY_CAMERA, - type MemoryLabelCandidate + DEFAULT_MEMORY_CAMERA } from "../src/features/memory-workspace/memory-3d.js"; import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph.js"; @@ -15,6 +15,9 @@ import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph * measures the renderer's full CPU-side share of a frame — batch projection, * radius computation, pick-index rebuild, and label LOD selection — over * simulated rotating/zooming frames, plus picking latency and layout time. + * The label step runs the component's real path: collectMemoryLabelCandidates + * scans every node of the frame (the same helper drawLabels uses), so the + * numbers cover candidate construction as well as selection. * GPU rasterization itself is two draw calls (instanced nodes, batched * edges); on an integrated-GPU laptop the CPU share measured here is the * part that must stay well inside the 33 ms p95 budget. @@ -41,6 +44,10 @@ describe("3D memory graph frame-time harness", () => { simulation.step(Number.POSITIVE_INFINITY); const layoutMs = performance.now() - layoutStart; const positions = simulation.positionsArray(); + // Label candidates are collected from the nodes in simulation order, + // aligned with the projected points, exactly as the component does. + const nodeById = new Map(nodes.map(node => [node.id, node])); + const alignedNodes = simulation.nodeIds.map(id => nodeById.get(id)!); const extent = Math.max(60, ...Array.from({ length: size }, (_, i) => Math.hypot(positions[i * 3]!, positions[i * 3 + 1]!, positions[i * 3 + 2]!))); const scale = Math.min(WIDTH, HEIGHT) * 0.4 / extent; @@ -62,17 +69,7 @@ describe("3D memory graph frame-time harness", () => { radii[i] = memoryNodeRadius(2.1, projected[i * 4 + 3]!, camera.zoom, false); } pickIndex.rebuild(projected, radii); - const labelCandidates: MemoryLabelCandidate[] = []; - for (let i = 0; i < size; i += 97) { - labelCandidates.push({ - id: nodes[i]!.id, - label: nodes[i]!.label, - type: nodes[i]!.type, - core: nodes[i]!.label === "space.md", - x: projected[i * 4]!, - y: projected[i * 4 + 1]! - }); - } + const labelCandidates = collectMemoryLabelCandidates(alignedNodes, projected, { zoom: camera.zoom }); selectMemoryLabels(labelCandidates, { zoom: camera.zoom, width: WIDTH, height: HEIGHT, measure: text => text.length * 6 }); diff --git a/apps/web/tests/memory-3d-scale.test.ts b/apps/web/tests/memory-3d-scale.test.ts index 879cfa556..10f89da5d 100644 --- a/apps/web/tests/memory-3d-scale.test.ts +++ b/apps/web/tests/memory-3d-scale.test.ts @@ -2,15 +2,18 @@ import { describe, expect, it } from "vitest"; import { MemoryLayout3DSimulation, MemoryPickIndex, + collectMemoryLabelCandidates, layoutMemoryNetwork3D, memoryNodeRadius, projectMemoryPoint, projectMemoryPoints, selectMemoryLabels, DEFAULT_MEMORY_CAMERA, + MEMORY_LABEL_CANDIDATE_CAP, type MemoryLabelCandidate } from "../src/features/memory-workspace/memory-3d.js"; import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph.js"; +import { isMemoryCoreLabel } from "../src/features/memory-workspace/memory-graph-visuals.js"; import { memoryGraphEdgeSchema, memoryGraphNodeSchema, type MemoryGraphEdge, type MemoryGraphNode } from "@space/contracts"; function fixture(nodeCount = 127) { @@ -198,6 +201,33 @@ describe("label level-of-detail selection", () => { } } }); + + it("collects a bounded candidate set that selects exactly the same labels", () => { + const { nodes, edges } = fixture(4000); + const simulation = new MemoryLayout3DSimulation(nodes, edges); + simulation.step(Number.POSITIVE_INFINITY); + const projected = projectMemoryPoints(simulation.positionsArray(), DEFAULT_MEMORY_CAMERA, 2, 1200, 800); + const nodeById = new Map(nodes.map(node => [node.id, node])); + const aligned = simulation.nodeIds.map(id => nodeById.get(id)!); + const options = { focusId: aligned[10]!.id, zoom: 1, width: 1200, height: 800, measure: (text: string) => text.length * 6 }; + // The naive per-frame path: one candidate object for every node. + const naive: MemoryLabelCandidate[] = aligned.map((node, index) => ({ + id: node.id, + label: node.label, + type: node.type, + core: isMemoryCoreLabel(node.label), + x: projected[index * 4]!, + y: projected[index * 4 + 1]! + })); + const collected = collectMemoryLabelCandidates(aligned, projected, options); + expect(collected.length).toBeLessThanOrEqual(MEMORY_LABEL_CANDIDATE_CAP); + expect(collected[0]!.id).toBe(options.focusId); + expect(selectMemoryLabels(collected, options)).toEqual(selectMemoryLabels(naive, options)); + const highZoom = { ...options, zoom: 2.5 }; + const collectedHigh = collectMemoryLabelCandidates(aligned, projected, highZoom); + expect(collectedHigh.length).toBe(MEMORY_LABEL_CANDIDATE_CAP); + expect(selectMemoryLabels(collectedHigh, highZoom)).toEqual(selectMemoryLabels(naive, highZoom)); + }); }); describe("synthetic demo graph", () => { From cd92ba979f811dea5fc6383ec0bb2e87e20cc276 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:40:07 -0400 Subject: [PATCH 12/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- .../memory-workspace/MemoryGraph3D.tsx | 88 ++++++++++++++++--- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx index 87357b1c3..8ac57e20a 100644 --- a/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx +++ b/apps/web/src/features/memory-workspace/MemoryGraph3D.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import type { MemoryGraphNode, MemoryGraphEdge } from "@space/contracts"; import type { MemoryAtlasPosition } from "./memory-atlas.js"; import { isMemoryCoreLabel } from "./memory-graph-visuals.js"; -import { DEFAULT_MEMORY_CAMERA, collectMemoryLabelCandidates, memory3DVolumeRatio, projectMemoryPoint, selectMemoryLabels, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; +import { DEFAULT_MEMORY_CAMERA, buildStreamedMemoryPositions, collectMemoryLabelCandidates, memory3DVolumeRatio, projectMemoryPoint, selectMemoryLabels, type MemoryCamera3D, type MemoryPoint3D } from "./memory-3d.js"; import { MemoryGraphWebGLRenderer, hexToRgb, type MemoryWebGLScene } from "./memory-graph-webgl.js"; /** The Canvas 2D fallback keeps the historical cap from the overview endpoint. */ @@ -17,6 +17,15 @@ interface SceneData { neighborsByIndex: Array>; } +/** + * Pack nodes, edges, and their positions into the flat typed-array scene the + * WebGL renderer consumes. Returns null until layout coordinates exist. + * @param nodes All graph nodes. + * @param edges All graph edges. + * @param coordinates 3D layout coordinates keyed by node id. + * @param positions 2D atlas positions keyed by node id (source of colors/sizes). + * @returns The packed scene, or null when no coordinates are ready. + */ function buildScene(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], coordinates: Map, positions: Map): SceneData | null { const ids: string[] = []; const indexById = new Map(); @@ -73,6 +82,10 @@ function buildScene(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], coordina }; } +/** + * Interactive 3D memory graph: WebGL2 instanced rendering with a Canvas 2D + * fallback, rotation/zoom/pan, hover and click picking, and node labels. + */ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles, selectedNodeId, onSelectNode, loading, resetToken }: { nodes: MemoryGraphNode[]; edges: MemoryGraphEdge[]; positions: Map; coordinates: Map; @@ -115,38 +128,59 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles renderer = null; } } + /** 2D context for drawing; when WebGL is active labels go on the overlay, otherwise the canvas itself. */ const fallbackContext = (): CanvasRenderingContext2D | null => { if (renderer) return labelCanvas.getContext("2d"); return canvas.getContext("2d") ?? labelCanvas.getContext("2d"); }; let labelContext: CanvasRenderingContext2D | null = null; + /** Rebuild or stream-update the GPU scene when nodes, edges, or coordinates change. */ const syncScene = () => { if (!renderer) return; const { nodes, edges, positions, coordinates } = current.current; if (sceneData && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions && coordinates === sceneCoordinates) return; + if (sceneData && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions) { + // Layout streaming: only the coordinates moved. Repacking the full + // scene per snapshot (colors, sizes, neighbor sets, edge colors) + // costs ~200 ms at 50,000 nodes on the main thread, so rebuild only + // the position arrays and push them into the existing GPU buffers. + const streamed = buildStreamedMemoryPositions(sceneData.ids, sceneData.scene.edgeNodeIndices, coordinates); + if (streamed) { + renderer.updatePositions(streamed.positions, streamed.edgePositions, streamed.extent); + sceneData = { ...sceneData, scene: { ...sceneData.scene, positions: streamed.positions, edgePositions: streamed.edgePositions, extent: streamed.extent } }; + sceneCoordinates = coordinates; + return; + } + } const next = buildScene(nodes, edges, coordinates, positions); if (!next) { - sceneData = null; + if (sceneData) { + // The scene became empty (e.g. an empty search): clear the WebGL + // canvas and the picking/focus state. The fallback path draws on + // the label canvas only, so without this the last WebGL frame + // would stay visible underneath it indefinitely. + renderer.clear(); + sceneData = null; + focusIndex = -2; + hover = null; + points = []; + } return; } - const sameShape = sceneData && next.ids.length === sceneData.ids.length && next.ids.every((id, index) => id === sceneData!.ids[index]); - if (sameShape && nodes === sceneNodes && edges === sceneEdges && positions === scenePositions) { - // Layout streaming: only the coordinates moved, so push the new - // position arrays into the existing GPU buffers instead of a rebuild. - renderer.updatePositions(next.scene.positions, next.scene.edgePositions); - sceneData = next; - } else { - renderer.setScene(next.scene); - sceneData = next; - focusIndex = -2; - } + // Anything reaching here changed more than the coordinates (the pure + // streaming case is handled above), so derived arrays such as colors + // and neighbor sets may differ — upload the full scene. + renderer.setScene(next.scene); + sceneData = next; + focusIndex = -2; sceneNodes = nodes; sceneEdges = edges; scenePositions = positions; sceneCoordinates = coordinates; }; + /** Dim non-neighbors and highlight the hovered/selected node. */ const applyFocus = () => { if (!renderer || !sceneData) return; const focusId = hover ?? current.current.selectedNodeId; @@ -156,6 +190,10 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles renderer.setFocus(index, index >= 0 ? sceneData.neighborsByIndex[index]! : null); }; + /** + * Draw node labels on the overlay from the last projected frame. + * @param projected [x, y, depth, perspective] quads from the renderer. + */ const drawLabels = (projected: Float32Array) => { if (!labelContext) labelContext = labelCanvas.getContext("2d"); if (!labelContext) return; @@ -183,6 +221,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles } }; + /** One WebGL frame: focus, GPU draw, labels, and rotation state for tests. */ const renderWebGL = () => { if (!renderer || !sceneData) return; const scale = Math.min(width, height) * 0.4 / sceneData.scene.extent; @@ -192,6 +231,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles canvas.dataset.rotation = `${cameraRef.current.yaw.toFixed(3)},${cameraRef.current.pitch.toFixed(3)}`; }; + /** One Canvas 2D frame (fallback for no-WebGL / lost context), capped at CANVAS_2D_NODE_CAP nodes. */ const renderCanvas2D = () => { const context = fallbackContext(); if (!context) return; @@ -206,7 +246,14 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles const edges = all.nodes.length > CANVAS_2D_NODE_CAP ? all.edges.filter(edge => cappedIds.has(edge.source) && cappedIds.has(edge.target)) : all.edges; const { positions, coordinates, showTitles, selectedNodeId } = all; context.clearRect(0, 0, width, height); - const extent = Math.max(60, ...[...coordinates.values()].map(p => Math.hypot(p.x, p.y, p.z))); + // Extent over the drawn (capped) nodes only: spreading the full + // coordinates map into Math.max would walk all 50,000 entries and + // allocate an array on every fallback frame. + let extent = 60; + for (const node of nodes) { + const point = coordinates.get(node.id); + if (point) extent = Math.max(extent, Math.hypot(point.x, point.y, point.z)); + } const scale = Math.min(width, height) * 0.4 / extent; const projected = new Map(nodes.flatMap(node => { const p = coordinates.get(node.id); @@ -256,14 +303,17 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles canvas.dataset.rotation = `${cameraRef.current.yaw.toFixed(3)},${cameraRef.current.pitch.toFixed(3)}`; }; + /** Sync the scene, then draw the next frame on whichever renderer is live. */ const render = () => { frame = 0; syncScene(); if (renderer && sceneData) renderWebGL(); else renderCanvas2D(); }; + /** Request one animation frame of rendering, coalescing duplicate requests. */ const schedule = () => { if (!frame) frame = requestAnimationFrame(render); }; redrawRef.current = schedule; + /** Resize both canvases to the CSS box (capped at 2x DPR) and redraw. */ const resize = () => { const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height; dpr = Math.min(window.devicePixelRatio || 1, 2); @@ -274,6 +324,13 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles if (!renderer) canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); schedule(); }; + /** + * Node id under a CSS-pixel pointer position, via GPU pick index or the + * Canvas 2D fallback's reverse z-order scan. + * @param x Pointer x in CSS pixels. + * @param y Pointer y in CSS pixels. + * @returns The hit node id, or null. + */ const hit = (x: number, y: number) => { if (renderer && sceneData) { const index = renderer.pick(x, y); @@ -281,6 +338,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles } return [...points].reverse().find(p => Math.hypot(p.x - x, p.y - y) < Math.max(7, p.radius + 3))?.id ?? null; }; + /** Pointer position in canvas-local CSS pixels. */ const local = (event: PointerEvent) => { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }; const down = (event: PointerEvent) => { canvas.focus(); canvas.setPointerCapture(event.pointerId); @@ -313,6 +371,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles cameraRef.current.pitch += event.key === "ArrowUp" ? -0.15 : event.key === "ArrowDown" ? 0.15 : 0; schedule(); } else if (event.key === "Enter") { const node = current.current.nodes.find(node => node.id === current.current.selectedNodeId) ?? current.current.nodes.find(node => isMemoryCoreLabel(node.label)); if (node) current.current.onSelectNode(node.id); } }; + /** Fall back to Canvas 2D on WebGL context loss, keeping the view alive. */ const contextLost = (event: Event) => { // Keep the view alive on the Canvas 2D fallback; allow the browser to restore. event.preventDefault(); @@ -322,6 +381,7 @@ export function MemoryGraph3D({ nodes, edges, positions, coordinates, showTitles labelContext = null; schedule(); }; + /** Re-create the WebGL renderer when the browser restores the context. */ const contextRestored = () => { if (!renderer && MemoryGraphWebGLRenderer.isSupported()) { try { From bb8a9b05e92284afda5079976dadbc5fe6fce97b Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:41:08 -0400 Subject: [PATCH 13/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- .../features/memory-workspace/memory-3d.ts | 85 +++++++++++++++++-- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/apps/web/src/features/memory-workspace/memory-3d.ts b/apps/web/src/features/memory-workspace/memory-3d.ts index a9010b875..aead81975 100644 --- a/apps/web/src/features/memory-workspace/memory-3d.ts +++ b/apps/web/src/features/memory-workspace/memory-3d.ts @@ -33,6 +33,7 @@ export class MemoryLayout3DSimulation { private readonly fy: Float64Array; private readonly fz: Float64Array; + /** Seed the simulation from deterministic per-node offsets so the same input always converges to the same layout. */ constructor(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[]) { const ordered = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); const index = new Map(ordered.map((node, i) => [node.id, i])); @@ -136,6 +137,7 @@ export class MemoryLayout3DSimulation { return out; } + /** A defensive copy of the current simulation positions, keyed by node id. */ snapshot(): Map { return new Map(this.ordered.map((node, i) => [node.id, { ...this.points[i]! }])); } @@ -148,6 +150,16 @@ export function layoutMemoryNetwork3D(nodes: MemoryGraphNode[], edges: MemoryGra return simulation.snapshot(); } +/** + * Project one world point to screen space with the shared yaw/pitch camera. + * Same math as the batch and WebGL paths, so all three renderers agree. + * @param point World-space point to project. + * @param camera Camera orientation (yaw/pitch) and zoom. + * @param scale World-units to CSS pixels at zoom 1. + * @param width Viewport width in CSS pixels. + * @param height Viewport height in CSS pixels. + * @returns Screen x/y, depth z, and the perspective divisor. + */ export function projectMemoryPoint(point: MemoryPoint3D, camera: MemoryCamera3D, scale: number, width: number, height: number) { const x = point.x * Math.cos(camera.yaw) + point.z * Math.sin(camera.yaw); const depth = -point.x * Math.sin(camera.yaw) + point.z * Math.cos(camera.yaw); @@ -198,6 +210,11 @@ export class MemoryPickIndex { private maxRadius = 0; constructor(private readonly cellSize = 64) {} + /** + * Fill the grid cells from one rendered frame. + * @param projected [x, y, depth, perspective] quads from the last render. + * @param radii On-screen node radii in CSS pixels. + */ rebuild(projected: Float32Array, radii: Float32Array): void { this.cells.clear(); this.maxRadius = 0; @@ -326,26 +343,78 @@ export function collectMemoryLabelCandidates( const node = nodes[index]!; const isFocus = node.id === focusId; if (!isFocus && node.type !== "SOURCE" && options.zoom < 1.6) continue; - const candidate: MemoryLabelCandidate = { + const isCore = isMemoryCoreLabel(node.label); + // Choose the priority group before allocating: the returned list keeps + // the first MEMORY_LABEL_CANDIDATE_CAP candidates in group order, and a + // node sits behind its own group plus every higher-priority group, so + // once that lower bound reaches the cap the candidate would only be + // sliced away below — don't allocate an object for it. + let bucket: MemoryLabelCandidate[]; + let ahead: number; + if (isFocus) { bucket = focus; ahead = 0; } + else if (isCore) { bucket = core; ahead = focus.length; } + else if (node.type === "SOURCE") { bucket = sources; ahead = focus.length + core.length; } + else { bucket = rest; ahead = focus.length + core.length + sources.length; } + if (ahead + bucket.length >= MEMORY_LABEL_CANDIDATE_CAP) continue; + bucket.push({ id: node.id, label: node.label, type: node.type, - core: isMemoryCoreLabel(node.label), + core: isCore, x: projected[index * 4]!, y: projected[index * 4 + 1]! - }; - if (isFocus) focus.push(candidate); - else if (candidate.core) core.push(candidate); - else if (node.type === "SOURCE") sources.push(candidate); - else rest.push(candidate); + }); } return [...focus, ...core, ...sources, ...rest].slice(0, MEMORY_LABEL_CANDIDATE_CAP); } +/** + * Streaming fast path for scene sync: when only the layout coordinates + * changed, rebuild just the flat position arrays from the previous scene's + * node order and edge index table instead of repacking the whole scene + * (colors, sizes, neighbor sets) on every snapshot. The derived edge + * positions and the world extent are rebuilt alongside the node positions, + * so nothing rendered from the old snapshot survives. Returns null when a + * scene node has no coordinate yet — the caller then does a full rebuild, + * which handles partial coordinates. + * @param ids Node ids in scene order. + * @param edgeNodeIndices Node index per edge vertex, two per edge. + * @param coordinates The new layout coordinates keyed by node id. + * @returns New position/edge-position arrays and the new extent, or null. + */ +export function buildStreamedMemoryPositions( + ids: string[], + edgeNodeIndices: Float32Array, + coordinates: Map +): { positions: Float32Array; edgePositions: Float32Array; extent: number } | null { + const positions = new Float32Array(ids.length * 3); + let extent = 60; + for (let index = 0; index < ids.length; index++) { + const point = coordinates.get(ids[index]!); + if (!point) return null; + positions[index * 3] = point.x; + positions[index * 3 + 1] = point.y; + positions[index * 3 + 2] = point.z; + extent = Math.max(extent, Math.hypot(point.x, point.y, point.z)); + } + const edgePositions = new Float32Array(edgeNodeIndices.length * 3); + for (let vertex = 0; vertex < edgeNodeIndices.length; vertex++) { + const nodeIndex = edgeNodeIndices[vertex]!; + edgePositions[vertex * 3] = positions[nodeIndex * 3]!; + edgePositions[vertex * 3 + 1] = positions[nodeIndex * 3 + 1]!; + edgePositions[vertex * 3 + 2] = positions[nodeIndex * 3 + 2]!; + } + return { positions, edgePositions, extent }; +} + /** Normalized covariance determinant: 0 for a plane, 1 for equal spread in XYZ. */ export function memory3DVolumeRatio(points: MemoryPoint3D[]): number { if (points.length < 4) return 0; - const center = points.reduce((sum, p) => ({ x: sum.x + p.x / points.length, y: sum.y + p.y / points.length, z: sum.z + p.z / points.length }), { x: 0, y: 0, z: 0 }); + // Scalar accumulation: a per-point object-building reduce allocates one + // object per node on every call, and this runs per streamed snapshot. + let sumX = 0, sumY = 0, sumZ = 0; + for (const p of points) { sumX += p.x; sumY += p.y; sumZ += p.z; } + const center = { x: sumX / points.length, y: sumY / points.length, z: sumZ / points.length }; let xx = 0, yy = 0, zz = 0, xy = 0, xz = 0, yz = 0; for (const p of points) { const x = p.x - center.x, y = p.y - center.y, z = p.z - center.z; From c5a14414c917f560970d9f4ee3ef153ff0df3f39 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:41:56 -0400 Subject: [PATCH 14/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- .../memory-workspace/memory-graph-webgl.ts | 97 +++++++++++++++++-- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/apps/web/src/features/memory-workspace/memory-graph-webgl.ts b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts index d072aa827..19201019f 100644 --- a/apps/web/src/features/memory-workspace/memory-graph-webgl.ts +++ b/apps/web/src/features/memory-workspace/memory-graph-webgl.ts @@ -25,6 +25,11 @@ export interface MemoryWebGLScene { extent: number; } +/** + * Parse a #rrggbb CSS color into 0..1 RGB components; a muted slate on bad input. + * @param hex CSS hex color, with or without the leading "#". + * @returns [r, g, b] triple in 0..1. + */ export function hexToRgb(hex: string): [number, number, number] { const value = Number.parseInt(hex.replace("#", ""), 16); if (!Number.isFinite(value)) return [0.45, 0.58, 0.68]; @@ -128,6 +133,13 @@ void main() { outColor = vec4(vColor, 1.0); }`; +/** + * Compile one GLSL shader, throwing with the driver log on failure. + * @param gl The WebGL2 context. + * @param type gl.VERTEX_SHADER or gl.FRAGMENT_SHADER. + * @param source GLSL 300 es source. + * @returns The compiled shader. + */ function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { const shader = gl.createShader(type); if (!shader) throw new Error("WebGL2 shader allocation failed"); @@ -141,14 +153,29 @@ function compileShader(gl: WebGL2RenderingContext, type: number, source: string) return shader; } +/** + * Link a vertex and fragment shader into a program, throwing on failure. + * @param gl The WebGL2 context. + * @param vertex Vertex shader GLSL source. + * @param fragment Fragment shader GLSL source. + * @returns The linked program. + */ function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string): WebGLProgram { const program = gl.createProgram(); if (!program) throw new Error("WebGL2 program allocation failed"); - gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, vertex)); - gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, fragment)); + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertex); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragment); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); gl.linkProgram(program); + // The shaders are linked into the program now; release the shader objects + // so repeated renderer creation (context restore) does not leak them. + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - throw new Error(`WebGL2 program failed to link: ${gl.getProgramInfoLog(program) ?? "unknown error"}`); + const log = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(`WebGL2 program failed to link: ${log ?? "unknown error"}`); } return program; } @@ -215,6 +242,12 @@ export class MemoryGraphWebGLRenderer { gl.bindVertexArray(null); } + /** + * Allocate a GPU buffer, upload `data`, and track it for disposal. + * @param data Float32 data to upload. + * @param usage gl.STATIC_DRAW or gl.DYNAMIC_DRAW hint. + * @returns The created buffer. + */ private createBuffer(data: Float32Array, usage: number): WebGLBuffer { const gl = this.gl; const buffer = gl.createBuffer(); @@ -225,6 +258,16 @@ export class MemoryGraphWebGLRenderer { return buffer; } + /** + * Bind one per-node attribute as an instanced divisor-1 stream. + * @param program The shader program owning the attribute. + * @param vao Vertex array to attach the buffer to. + * @param name Attribute name in the shader. + * @param data Per-node data, one `size`-tuple per node. + * @param size Components per attribute (1, 2, or 3). + * @param usage gl.STATIC_DRAW or gl.DYNAMIC_DRAW hint. + * @returns The created buffer. + */ private bindInstanced(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number, usage: number): WebGLBuffer { const gl = this.gl; gl.bindVertexArray(vao); @@ -237,6 +280,16 @@ export class MemoryGraphWebGLRenderer { return buffer; } + /** + * Bind one per-vertex attribute (the batched edge lines), non-instanced. + * @param program The shader program owning the attribute. + * @param vao Vertex array to attach the buffer to. + * @param name Attribute name in the shader. + * @param data Per-vertex data, one `size`-tuple per vertex. + * @param size Components per attribute (1 or 3). + * @param usage Upload hint, defaults to gl.STATIC_DRAW. + * @returns The created buffer. + */ private bindVertex(program: WebGLProgram, vao: WebGLVertexArrayObject, name: string, data: Float32Array, size: number, usage: number = this.gl.STATIC_DRAW): WebGLBuffer { const gl = this.gl; gl.bindVertexArray(vao); @@ -248,6 +301,10 @@ export class MemoryGraphWebGLRenderer { return buffer; } + /** + * Upload a full scene, retiring the previous scene's GPU buffers. + * @param scene Packed node/edge data; arrays are uploaded as-is. + */ setScene(scene: MemoryWebGLScene): void { const gl = this.gl; // Retire the previous scene's dynamic buffers. @@ -274,10 +331,16 @@ export class MemoryGraphWebGLRenderer { this.radii = new Float32Array(this.nodeCount); } - /** Update node and edge positions in place while the layout streams in. */ - updatePositions(positions: Float32Array, edgePositions: Float32Array): void { + /** + * Update node and edge positions in place while the layout streams in. + * @param positions New flat XYZ node positions. + * @param edgePositions New flat XYZ edge endpoint positions. + * @param extent New world-space extent; the depth range derives from it, + * so it must follow the streamed positions, not the first snapshot's. + */ + updatePositions(positions: Float32Array, edgePositions: Float32Array, extent?: number): void { if (!this.scene) return; - this.scene = { ...this.scene, positions, edgePositions }; + this.scene = { ...this.scene, positions, edgePositions, ...(extent !== undefined ? { extent } : {}) }; const gl = this.gl; gl.bindVertexArray(this.nodeVao); // The aPos buffer is the first dynamic buffer created for the scene. @@ -296,6 +359,15 @@ export class MemoryGraphWebGLRenderer { } } + /** Drop the scene and clear the frame, e.g. when the graph becomes empty. */ + clear(): void { + this.scene = null; + this.projected = new Float32Array(0); + const gl = this.gl; + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + } + /** Focus a node index (or -1 for none); neighbors stay lit, the rest dim. */ setFocus(focusIndex: number, neighbors: ReadonlySet | null): void { this.focusIndexValue = focusIndex; @@ -315,6 +387,14 @@ export class MemoryGraphWebGLRenderer { gl.bufferSubData(gl.ARRAY_BUFFER, 0, highlight); } + /** + * Draw edges (depth-write off) then the instanced node billboards, and + * refresh the CPU-side projection used for picking and labels. + * @param camera Camera orientation and zoom. + * @param scale World-units to CSS pixels at zoom 1. + * @param width Viewport width in CSS pixels. + * @param height Viewport height in CSS pixels. + */ render(camera: MemoryCamera3D, scale: number, width: number, height: number): void { const gl = this.gl; if (!this.scene) return; @@ -359,7 +439,9 @@ export class MemoryGraphWebGLRenderer { // CPU projection of the same frame powers picking and label placement. this.projected = projectMemoryPoints(this.scene.positions, camera, scale, width, height); for (let i = 0; i < this.nodeCount; i++) { - this.radii[i] = memoryNodeRadius(this.scene.baseSizes[i]!, this.projected[i * 4 + 3]!, camera.zoom, false); + // Pick radii must match the drawn radii, including the focused node's + // 1.5x highlight, or clicks on its rim would miss the pick index. + this.radii[i] = memoryNodeRadius(this.scene.baseSizes[i]!, this.projected[i * 4 + 3]!, camera.zoom, i === this.focusIndexValue); } this.pickIndex.rebuild(this.projected, this.radii); } @@ -377,6 +459,7 @@ export class MemoryGraphWebGLRenderer { return this.projected; } + /** Delete every GPU resource this renderer allocated. */ dispose(): void { const gl = this.gl; for (const buffer of this.buffers.splice(0)) gl.deleteBuffer(buffer); From 8bbd220e01af7b9ea64ce0e24e25dc30a52c7da3 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:42:53 -0400 Subject: [PATCH 15/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- apps/web/tests/memory-3d-benchmark.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/tests/memory-3d-benchmark.test.ts b/apps/web/tests/memory-3d-benchmark.test.ts index 5a1e5ebbc..68402e90c 100644 --- a/apps/web/tests/memory-3d-benchmark.test.ts +++ b/apps/web/tests/memory-3d-benchmark.test.ts @@ -30,6 +30,12 @@ const SIZES = [2_000, 10_000, 50_000]; const WIDTH = 1440; const HEIGHT = 900; +/** + * Nearest-rank percentile of a sorted sample set (p50/p95 frame-time math). + * @param samples Frame-time samples in milliseconds. + * @param ratio Percentile as a 0..1 fraction. + * @returns The sample at that percentile. + */ function percentile(samples: number[], ratio: number): number { const sorted = [...samples].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))]!; From 6ca613d61ab6048634bd76a39956a3121958c389 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:43:47 -0400 Subject: [PATCH 16/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- apps/web/tests/memory-3d-scale.test.ts | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/web/tests/memory-3d-scale.test.ts b/apps/web/tests/memory-3d-scale.test.ts index 10f89da5d..f9a5d1cb9 100644 --- a/apps/web/tests/memory-3d-scale.test.ts +++ b/apps/web/tests/memory-3d-scale.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { MemoryLayout3DSimulation, MemoryPickIndex, + buildStreamedMemoryPositions, collectMemoryLabelCandidates, layoutMemoryNetwork3D, memoryNodeRadius, @@ -16,6 +17,7 @@ import { generateSyntheticMemoryGraph } from "../src/demo/synthetic-memory-graph import { isMemoryCoreLabel } from "../src/features/memory-workspace/memory-graph-visuals.js"; import { memoryGraphEdgeSchema, memoryGraphNodeSchema, type MemoryGraphEdge, type MemoryGraphNode } from "@space/contracts"; +/** Small deterministic fixture graph for the 3D layout and projection tests. */ function fixture(nodeCount = 127) { const nodes: MemoryGraphNode[] = [{ id: "root", label: "gemini.md", type: "SOURCE", sourcePath: "/opt/spaceapp/docs/gemini.md", recordId: null }]; const edges: MemoryGraphEdge[] = []; @@ -34,6 +36,7 @@ function fixture(nodeCount = 127) { } // Deterministic pseudo-random values in [0, 1). +/** Deterministic pseudo-random generator (mulberry32) for repeatable fixtures. */ const rand = (seed: number) => { let state = seed >>> 0; return () => { @@ -73,6 +76,38 @@ describe("streaming 3D layout simulation", () => { }); }); +describe("streamed scene position rebuild", () => { + it("matches a full repack exactly, including derived edge positions and extent", () => { + const { nodes, edges } = fixture(400); + const simulation = new MemoryLayout3DSimulation(nodes, edges); + const ids = simulation.nodeIds; + const indexById = new Map(ids.map((id, index) => [id, index])); + const visibleEdges = edges.filter(edge => indexById.has(edge.source) && indexById.has(edge.target)); + const edgeNodeIndices = new Float32Array(visibleEdges.flatMap(edge => [indexById.get(edge.source)!, indexById.get(edge.target)!])); + simulation.step(12); + const coordinates = simulation.snapshot(); + const streamed = buildStreamedMemoryPositions(ids, edgeNodeIndices, coordinates); + expect(streamed).not.toBeNull(); + // Node positions are exactly the simulation's flat array for this snapshot. + expect(streamed!.positions).toEqual(simulation.positionsArray()); + // Every derived edge endpoint equals its node's new position. + for (let vertex = 0; vertex < edgeNodeIndices.length; vertex++) { + const nodeIndex = edgeNodeIndices[vertex]!; + expect(streamed!.edgePositions[vertex * 3]).toBe(streamed!.positions[nodeIndex * 3]); + expect(streamed!.edgePositions[vertex * 3 + 1]).toBe(streamed!.positions[nodeIndex * 3 + 1]); + expect(streamed!.edgePositions[vertex * 3 + 2]).toBe(streamed!.positions[nodeIndex * 3 + 2]); + } + // The extent follows the new snapshot (it grows as the layout expands), + // never the first snapshot's. + const expectedExtent = Math.max(60, ...ids.map(id => { const p = coordinates.get(id)!; return Math.hypot(p.x, p.y, p.z); })); + expect(streamed!.extent).toBe(expectedExtent); + // A scene node without a coordinate yet aborts to the full rebuild. + const partial = new Map(coordinates); + partial.delete(ids[0]!); + expect(buildStreamedMemoryPositions(ids, edgeNodeIndices, partial)).toBeNull(); + }); +}); + describe("batch projection", () => { it("matches projectMemoryPoint component for component", () => { const random = rand(42); @@ -96,6 +131,7 @@ describe("batch projection", () => { }); describe("spatial pick index", () => { + /** Build a pick index plus random projections for the probe tests. */ const build = (count: number) => { const random = rand(7); const positions = new Float32Array(count * 3); @@ -107,6 +143,7 @@ describe("spatial pick index", () => { index.rebuild(projected, radii); return { projected, radii, index }; }; + /** O(n) reference implementation the spatial pick index is verified against. */ const bruteForce = (x: number, y: number, projected: Float32Array, radii: Float32Array) => { let best = -1, bestDepth = Number.POSITIVE_INFINITY, bestDistance = Number.POSITIVE_INFINITY; for (let i = 0; i < radii.length; i++) { From 817c759cbdd48ec8d1a327b202d47d860fbbeb4c Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:44:38 -0400 Subject: [PATCH 17/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- apps/web/src/demo/DemoStore.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/demo/DemoStore.ts b/apps/web/src/demo/DemoStore.ts index 3d4f1b314..9316ccbe7 100644 --- a/apps/web/src/demo/DemoStore.ts +++ b/apps/web/src/demo/DemoStore.ts @@ -673,6 +673,11 @@ export class DemoStore { }; } + /** + * Demo URL override for scale benchmarks: ?demoGraphSize=50000 renders a + * synthetic graph of that size instead of the fixture graph. + * @returns The requested node count, or undefined for a missing/invalid value. + */ private demoGraphSizeFromUrl(): number | undefined { try { if (typeof window === "undefined") return undefined; From 0ba9e53afc9869755ea3b4492a61f35d03102c36 Mon Sep 17 00:00:00 2001 From: elevanta21 Date: Fri, 23 Oct 2026 13:45:21 -0400 Subject: [PATCH 18/18] fix(web): address CodeRabbit re-review + hostile-review pass on the WebGL memory graph (PR #263) --- apps/api/src/memory-graph-routes.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/api/src/memory-graph-routes.ts b/apps/api/src/memory-graph-routes.ts index fc9f7f5fd..df427e922 100644 --- a/apps/api/src/memory-graph-routes.ts +++ b/apps/api/src/memory-graph-routes.ts @@ -316,6 +316,15 @@ export const MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT = 2000; export const MEMORY_GRAPH_OVERVIEW_MAX_LIMIT = 50000; // Keep the same payload budget while retaining the taxonomy needed to interpret a large archive. +/** + * Choose which nodes the overview endpoint returns. Small graphs pass through + * untouched; large ones keep taxonomy-bearing nodes first (sources, topics, + * tagged memories, recent records) and fill the remainder in input order. + * @param nodes All matching nodes, pre-filtered by the endpoint. + * @param edges All matching edges; used to identify tagged and connected nodes. + * @param limit Maximum nodes to select; defaults to MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT. + * @returns The selected nodes, in selection order. + */ export function selectOverviewNodes(nodes: MemoryGraphNode[], edges: MemoryGraphEdge[], limit: number = MEMORY_GRAPH_OVERVIEW_DEFAULT_LIMIT): MemoryGraphNode[] { if (nodes.length <= limit) return nodes; const members = new Map>();