diff --git a/package.json b/package.json index d234a04..f617251 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:generation-context": "node tests/generation-context.mjs", "test:generation-recall-transactions": "node tests/generation-recall-transaction-isolation.mjs", "test:graph-persistence": "node tests/graph-persistence.mjs", + "test:graph-renderer-guardrails": "node tests/graph-renderer-guardrails.mjs", "test:index-slicing-ratchet": "node tests/index-slicing-ratchet.mjs", "test:runtime-deps": "node tests/runtime-deps-completeness.mjs", "test:identity-resolver": "node tests/identity-resolver.mjs", diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs new file mode 100644 index 0000000..79169cc --- /dev/null +++ b/tests/graph-renderer-guardrails.mjs @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; + +globalThis.window = { + devicePixelRatio: 1, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), +}; +globalThis.performance ??= { now: () => Date.now() }; +globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(performance.now()), 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + disconnect() {} +}; + +function createNoopContext() { + const noop = () => {}; + return { + setTransform: noop, + clearRect: noop, + save: noop, + restore: noop, + translate: noop, + scale: noop, + beginPath: noop, + arc: noop, + arcTo: noop, + fill: noop, + stroke: noop, + moveTo: noop, + lineTo: noop, + quadraticCurveTo: noop, + fillText: noop, + closePath: noop, + rect: noop, + fillRect: noop, + strokeRect: noop, + measureText: (text = "") => ({ width: String(text).length * 6 }), + createRadialGradient: () => ({ addColorStop: noop }), + set fillStyle(_value) {}, + set strokeStyle(_value) {}, + set lineWidth(_value) {}, + set font(_value) {}, + set textAlign(_value) {}, + set textBaseline(_value) {}, + set globalAlpha(_value) {}, + set lineCap(_value) {}, + set lineJoin(_value) {}, + }; +} + +function createCanvas() { + return { + parentElement: { clientWidth: 640, clientHeight: 360 }, + width: 0, + height: 0, + style: {}, + addEventListener() {}, + removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 640, height: 360 }), + getContext: (type) => (type === "2d" ? createNoopContext() : null), + }; +} + +function createGraphFixture() { + return { + nodes: [ + { id: "objective-1", type: "event", name: "Objective", importance: 6, scope: { layer: "objective" } }, + { id: "user-1", type: "concept", name: "User POV", importance: 5, scope: { layer: "pov", ownerType: "user", ownerName: "Host" } }, + { id: "char-1", type: "character", name: "Character POV", importance: 7, scope: { layer: "pov", ownerType: "character", ownerName: "Alice" } }, + { id: "archived-1", type: "event", name: "Archived", archived: true, scope: { layer: "objective" } }, + ], + edges: [ + { fromId: "objective-1", toId: "user-1", relation: "related", strength: 0.7 }, + { fromId: "user-1", toId: "char-1", relation: "related", strength: 0.6 }, + { fromId: "objective-1", toId: "missing-node", relation: "invalid-target" }, + { fromId: "objective-1", toId: "char-1", relation: "invalidated", invalidAt: "2026-01-01T00:00:00.000Z" }, + { fromId: "char-1", toId: "user-1", relation: "expired", expiredAt: "2026-01-02T00:00:00.000Z" }, + { fromId: "archived-1", toId: "objective-1", relation: "archived-edge" }, + ], + }; +} + +function assertInputUnchanged(graph, beforeJson) { + assert.equal(JSON.stringify(graph), beforeJson); + for (const node of graph.nodes) { + for (const key of ["x", "y", "regionKey", "diagnostics", "highlight"]) { + assert.equal(Object.prototype.hasOwnProperty.call(node, key), false, `${node.id} gained ${key}`); + } + } +} + +const { GraphRenderer } = await import("../ui/graph-renderer.js"); + +{ + const graph = createGraphFixture(); + const before = JSON.stringify(graph); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true }, + layoutConfig: { neuralIterations: 8 }, + }); + + renderer.loadGraph(graph, { userPovAliases: ["Host"] }); + assertInputUnchanged(graph, before); + renderer.destroy(); +} + +{ + const graph = createGraphFixture(); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true }, + layoutConfig: { neuralIterations: 8 }, + }); + + renderer.loadGraph(graph, { userPovAliases: ["Host"] }); + const diagnostics = renderer.getLastLayoutDiagnostics(); + assert.ok(diagnostics); + assert.equal(diagnostics.rawNodeCount, 4); + assert.equal(diagnostics.archivedNodeCount, 1); + assert.equal(diagnostics.activeNodeCount, 3); + assert.equal(diagnostics.visibleNodeCount, 3); + assert.equal(diagnostics.nodeCount, 3); + assert.equal(diagnostics.rawEdgeCount, 6); + assert.equal(diagnostics.skippedEdgeCount, 4); + assert.equal(diagnostics.activeEdgeCount, 2); + assert.equal(diagnostics.visibleEdgeCount, 2); + assert.equal(diagnostics.edgeCount, 2); + assert.equal(diagnostics.objectiveNodeCount, 1); + assert.equal(diagnostics.userPovNodeCount, 1); + assert.equal(diagnostics.characterPovNodeCount, 1); + assert.equal(diagnostics.characterPovPanelCount, 1); + assert.equal(diagnostics.sampled, false); + assert.equal(diagnostics.capped, false); + assert.equal(diagnostics.renderOnly, true); + assert.equal(Number.isFinite(diagnostics.totalMs), true); + assert.ok(["js-main", "skipped"].includes(diagnostics.mode)); + renderer.destroy(); +} + +{ + const graph = createGraphFixture(); + const before = JSON.stringify(graph); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true }, + layoutConfig: { neuralIterations: 8 }, + }); + + renderer.setEnabled(false); + assert.doesNotThrow(() => renderer.loadGraph(graph)); + assertInputUnchanged(graph, before); + const diagnostics = renderer.getLastLayoutDiagnostics(); + assert.ok(diagnostics); + assert.equal(diagnostics.enabled, false); + assert.equal(diagnostics.renderOnly, true); + assert.equal(diagnostics.reason, "disabled"); + assert.equal(diagnostics.mode, "skipped"); + assert.equal(diagnostics.rawNodeCount, 4); + assert.equal(diagnostics.rawEdgeCount, 6); + assert.equal(diagnostics.activeNodeCount, 3); + assert.equal(diagnostics.activeEdgeCount, 2); + assert.equal(diagnostics.archivedNodeCount, 1); + assert.equal(diagnostics.skippedEdgeCount, 4); + renderer.destroy(); +} + +console.log("graph-renderer guardrail tests passed"); diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 921be61..ea1cf14 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -216,6 +216,41 @@ function partitionNodesByScope(nodes, userPovAliasSet = null) { return { objective, userPov, charMap }; } +function countRawNodesByScope(nodes, userPovAliasSet = null) { + const aliasSet = userPovAliasSet instanceof Set ? userPovAliasSet : new Set(); + let objectiveNodeCount = 0; + let userPovNodeCount = 0; + let characterPovNodeCount = 0; + const charKeys = new Set(); + + for (const node of Array.isArray(nodes) ? nodes : []) { + const scope = normalizeMemoryScope(node?.scope); + if (scope.layer !== 'pov') { + objectiveNodeCount += 1; + continue; + } + if (scopeMatchesHostUserAliases(scope, aliasSet) || scope.ownerType === 'user') { + userPovNodeCount += 1; + continue; + } + if (scope.ownerType === 'character') { + characterPovNodeCount += 1; + const nameKey = normalizeKeyForPartition(scope.ownerName); + const idKey = normalizeKeyForPartition(scope.ownerId); + charKeys.add(nameKey || idKey || '·'); + continue; + } + objectiveNodeCount += 1; + } + + return { + objectiveNodeCount, + userPovNodeCount, + characterPovNodeCount, + characterPovPanelCount: charKeys.size, + }; +} + export class GraphRenderer { /** * @param {HTMLCanvasElement} canvas @@ -305,13 +340,60 @@ export class GraphRenderer { this._lastLayoutHints = layoutHints && typeof layoutHints === 'object' ? { ...layoutHints } : {}; + const rawNodes = Array.isArray(graph?.nodes) ? graph.nodes : []; + const rawEdges = Array.isArray(graph?.edges) ? graph.edges : []; + const rawNodeCount = rawNodes.length; + const rawEdgeCount = rawEdges.length; + const diagnosticCanvasBase = { + canvasCssWidth: Number(this._lastCanvasCssWidth || 0), + canvasCssHeight: Number(this._lastCanvasCssHeight || 0), + devicePixelRatio: Number(window.devicePixelRatio || 1), + enabled: this.enabled !== false, + }; if (layoutHints && Object.prototype.hasOwnProperty.call(layoutHints, 'userPovAliases')) { this._userPovAliasSet = buildUserPovAliasNormalizedSet( layoutHints.userPovAliases, ); } + const activeRawNodes = rawNodes.filter(n => !n.archived); + const activeRawNodeIds = new Set(activeRawNodes.map((node) => node?.id).filter(Boolean)); + const activeRawEdges = rawEdges.filter(e => ( + !e?.invalidAt + && !e?.expiredAt + && activeRawNodeIds.has(e?.fromId) + && activeRawNodeIds.has(e?.toId) + )); + const rawPartitionCounts = countRawNodesByScope(activeRawNodes, this._userPovAliasSet); if (!this.enabled) { + this._setLastLayoutDiagnostics({ + mode: 'skipped', + nodeCount: 0, + edgeCount: 0, + rawNodeCount, + rawEdgeCount, + activeNodeCount: activeRawNodes.length, + activeEdgeCount: activeRawEdges.length, + visibleNodeCount: 0, + visibleEdgeCount: 0, + archivedNodeCount: Math.max(0, rawNodeCount - activeRawNodes.length), + skippedEdgeCount: Math.max(0, rawEdgeCount - activeRawEdges.length), + ...rawPartitionCounts, + prepareMs: 0, + layoutSeedMs: 0, + solveMs: 0, + totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: 0, + layoutReuseTotal: 0, + layoutReuseRatio: 0, + sampled: false, + capped: false, + renderOnly: true, + at: Date.now(), + ...diagnosticCanvasBase, + enabled: false, + reason: 'disabled', + }); return; } @@ -321,8 +403,7 @@ export class GraphRenderer { const W = this.canvas.width / dpr; const H = this.canvas.height / dpr; - const activeNodes = graph.nodes.filter(n => !n.archived); - this.nodes = activeNodes.map((n) => { + this.nodes = activeRawNodes.map((n) => { const node = { id: n.id, type: n.type || 'event', @@ -342,7 +423,7 @@ export class GraphRenderer { return node; }); - this.edges = graph.edges + this.edges = rawEdges .filter(e => !e.invalidAt && !e.expiredAt && this.nodeMap.has(e.fromId) && this.nodeMap.has(e.toId)) .map(e => ({ from: this.nodeMap.get(e.fromId), @@ -353,10 +434,32 @@ export class GraphRenderer { const prepareFinishedAt = performance.now(); const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet); + const characterPovNodeCount = [...parts.charMap.values()] + .reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0); this._regionPanels = this._computeRegionPanels(W, H, parts); const layoutReuse = this._applyPreviousLayoutSeed(previousLayoutSeedByNodeId); this._layoutAllPartitions(parts); const layoutFinishedAt = performance.now(); + const baseLayoutDiagnostics = { + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + rawNodeCount, + rawEdgeCount, + activeNodeCount: this.nodes.length, + activeEdgeCount: this.edges.length, + visibleNodeCount: this.nodes.length, + visibleEdgeCount: this.edges.length, + archivedNodeCount: Math.max(0, rawNodeCount - this.nodes.length), + skippedEdgeCount: Math.max(0, rawEdgeCount - this.edges.length), + objectiveNodeCount: parts.objective.length, + userPovNodeCount: parts.userPov.length, + characterPovNodeCount, + characterPovPanelCount: parts.charMap.size, + sampled: false, + capped: false, + renderOnly: true, + ...diagnosticCanvasBase, + }; const neuralPlan = this._resolveNeuralSimulationPlan(); const shouldTryNativeLayout = this._shouldTryNativeLayout( this.nodes.length, @@ -378,6 +481,7 @@ export class GraphRenderer { prepareFinishedAt, layoutFinishedAt, layoutReuse, + baseLayoutDiagnostics, }, ); } else { @@ -397,8 +501,7 @@ export class GraphRenderer { if (!nativeSolvePromise) { this._setLastLayoutDiagnostics({ mode: solvePath, - nodeCount: this.nodes.length, - edgeCount: this.edges.length, + ...baseLayoutDiagnostics, prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs, @@ -945,6 +1048,9 @@ export class GraphRenderer { const layoutReuse = timings.layoutReuse && typeof timings.layoutReuse === 'object' ? timings.layoutReuse : this._lastLayoutReuseStats; + const baseLayoutDiagnostics = timings.baseLayoutDiagnostics && typeof timings.baseLayoutDiagnostics === 'object' + ? timings.baseLayoutDiagnostics + : {}; const bridge = this._ensureNativeLayoutBridge(); const solveStartedAt = performance.now(); @@ -968,8 +1074,7 @@ export class GraphRenderer { applied: false, diagnostics: { mode: 'native-stale', - nodeCount: this.nodes.length, - edgeCount: this.edges.length, + ...baseLayoutDiagnostics, prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt), @@ -988,8 +1093,7 @@ export class GraphRenderer { applied: true, diagnostics: { mode: nativeResult.usedNative ? 'rust-wasm-worker' : 'js-worker', - nodeCount: this.nodes.length, - edgeCount: this.edges.length, + ...baseLayoutDiagnostics, prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt), @@ -1010,8 +1114,7 @@ export class GraphRenderer { applied: false, diagnostics: { mode: 'native-failed-hard', - nodeCount: this.nodes.length, - edgeCount: this.edges.length, + ...baseLayoutDiagnostics, prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt), @@ -1031,8 +1134,7 @@ export class GraphRenderer { applied: true, diagnostics: { mode: 'js-fallback', - nodeCount: this.nodes.length, - edgeCount: this.edges.length, + ...baseLayoutDiagnostics, prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs, diff --git a/ui/panel.js b/ui/panel.js index 643b8cc..e57d7f2 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -5621,18 +5621,32 @@ function _formatGraphLayoutDiagnosticsText(diagnostics = null) { const totalMs = Number( diagnostics.totalMs ?? diagnostics.solveMs ?? diagnostics.workerSolveMs, ); - const nodeCount = Number(diagnostics.nodeCount); - const edgeCount = Number(diagnostics.edgeCount); + const visibleNodeCount = Number( + diagnostics.visibleNodeCount ?? diagnostics.nodeCount, + ); + const visibleEdgeCount = Number( + diagnostics.visibleEdgeCount ?? diagnostics.edgeCount, + ); + const rawNodeCount = Number(diagnostics.rawNodeCount); + const rawEdgeCount = Number(diagnostics.rawEdgeCount); const parts = [`LAYOUT: ${modeLabel}`]; if (Number.isFinite(totalMs)) { parts.push(`${Math.max(0, Math.round(totalMs))}ms`); } - if (Number.isFinite(nodeCount) && Number.isFinite(edgeCount)) { + if (Number.isFinite(visibleNodeCount) && Number.isFinite(visibleEdgeCount)) { parts.push( - `${Math.max(0, Math.floor(nodeCount))}/${Math.max( + `v${Math.max(0, Math.floor(visibleNodeCount))}/${Math.max( 0, - Math.floor(edgeCount), + Math.floor(visibleEdgeCount), + )}`, + ); + } + if (Number.isFinite(rawNodeCount) && Number.isFinite(rawEdgeCount)) { + parts.push( + `raw${Math.max(0, Math.floor(rawNodeCount))}/${Math.max( + 0, + Math.floor(rawEdgeCount), )}`, ); } @@ -5640,6 +5654,48 @@ function _formatGraphLayoutDiagnosticsText(diagnostics = null) { return parts.join(" · "); } +function _formatGraphLayoutDiagnosticsTitle(diagnostics = null) { + if (!diagnostics || typeof diagnostics !== "object") return ""; + const formatCountPair = (left, right) => { + const a = Number(left); + const b = Number(right); + if (!Number.isFinite(a) || !Number.isFinite(b)) return "--"; + return `${Math.max(0, Math.floor(a))}/${Math.max(0, Math.floor(b))}`; + }; + const formatBool = (value) => (value === true ? "true" : value === false ? "false" : "--"); + const reuseCount = Number(diagnostics.layoutReuseCount); + const reuseTotal = Number(diagnostics.layoutReuseTotal); + const reuseRatio = Number(diagnostics.layoutReuseRatio); + const reuseParts = []; + if (Number.isFinite(reuseCount) && Number.isFinite(reuseTotal)) { + reuseParts.push(`${Math.max(0, Math.floor(reuseCount))}/${Math.max(0, Math.floor(reuseTotal))}`); + } + if (Number.isFinite(reuseRatio)) { + reuseParts.push(`${Math.round(Math.max(0, reuseRatio) * 100)}%`); + } + + const lines = [ + `visible nodes/edges: ${formatCountPair(diagnostics.visibleNodeCount ?? diagnostics.nodeCount, diagnostics.visibleEdgeCount ?? diagnostics.edgeCount)}`, + `raw nodes/edges: ${formatCountPair(diagnostics.rawNodeCount, diagnostics.rawEdgeCount)}`, + `active nodes/edges: ${formatCountPair(diagnostics.activeNodeCount, diagnostics.activeEdgeCount)}`, + `archived/skipped: ${formatCountPair(diagnostics.archivedNodeCount, diagnostics.skippedEdgeCount)}`, + `partitions objective/userPOV/characterPOV/panels: ${[ + diagnostics.objectiveNodeCount, + diagnostics.userPovNodeCount, + diagnostics.characterPovNodeCount, + diagnostics.characterPovPanelCount, + ].map((value) => { + const n = Number(value); + return Number.isFinite(n) ? String(Math.max(0, Math.floor(n))) : "--"; + }).join("/")}`, + `reuse count/ratio: ${reuseParts.join(" · ") || "--"}`, + `sampled/capped/renderOnly: ${formatBool(diagnostics.sampled)}/${formatBool(diagnostics.capped)}/${formatBool(diagnostics.renderOnly)}`, + ]; + const reason = String(diagnostics.reason || "").trim(); + if (reason) lines.push(`reason: ${reason}`); + return lines.join("\n"); +} + function _refreshGraphLayoutDiagnosticsUi() { const desktopMeta = document.getElementById("bme-graph-layout-meta"); const mobileMeta = document.getElementById("bme-mobile-graph-layout-meta"); @@ -5648,9 +5704,7 @@ function _refreshGraphLayoutDiagnosticsUi() { const renderer = _resolveVisibleGraphRenderer(); const diagnostics = renderer?.getLastLayoutDiagnostics?.() || null; const text = _formatGraphLayoutDiagnosticsText(diagnostics); - const title = diagnostics?.reason - ? `layout reason: ${String(diagnostics.reason).trim()}` - : ""; + const title = _formatGraphLayoutDiagnosticsTitle(diagnostics); if (desktopMeta) { desktopMeta.textContent = text;