test(graph-ui): add render diagnostics guardrails

This commit is contained in:
youzini
2026-06-04 07:54:24 +00:00
parent 64276ba4d1
commit fe18a05147
4 changed files with 343 additions and 21 deletions

View File

@@ -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",

View File

@@ -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");

View File

@@ -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,

View File

@@ -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;