diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs index 71770f4..4a2e063 100644 --- a/tests/graph-renderer-guardrails.mjs +++ b/tests/graph-renderer-guardrails.mjs @@ -138,6 +138,31 @@ function createGraphFixture() { }; } +function createStarSeedGraph({ includeFragment = false } = {}) { + const graph = { + nodes: [ + { id: "star-core", type: "character", name: "Core", importance: 10, scope: { layer: "objective" } }, + { id: "star-topic", type: "event", name: "Topic", importance: 7, scope: { layer: "objective" } }, + { id: "star-topic-2", type: "thread", name: "Topic 2", importance: 6, scope: { layer: "objective" } }, + ], + edges: [ + { fromId: "star-core", toId: "star-topic", relation: "related", strength: 0.9 }, + { fromId: "star-core", toId: "star-topic-2", relation: "related", strength: 0.7 }, + ], + }; + if (includeFragment) { + graph.nodes.push({ + id: "star-fragment", + type: "concept", + name: "Fragment", + importance: 2, + scope: { layer: "objective" }, + }); + graph.edges.push({ fromId: "star-topic", toId: "star-fragment", relation: "related", strength: 0.95 }); + } + return graph; +} + function assertInputUnchanged(graph, beforeJson) { assert.equal(JSON.stringify(graph), beforeJson); for (const node of graph.nodes) { @@ -153,6 +178,17 @@ function resetCanvasStats() { canvasMockStats.strokeCalls = 0; } +function assertRendererNodesInsideRegions(renderer) { + for (const node of renderer.nodes) { + assert.equal(Number.isFinite(node.x), true, `${node.id} x is finite`); + assert.equal(Number.isFinite(node.y), true, `${node.id} y is finite`); + assert.ok(node.regionRect, `${node.id} has regionRect`); + const r = node.regionRect; + assert.ok(node.x >= r.x - 0.001 && node.x <= r.x + r.w + 0.001, `${node.id} x inside region`); + assert.ok(node.y >= r.y - 0.001 && node.y <= r.y + r.h + 0.001, `${node.id} y inside region`); + } +} + const { GraphRenderer } = await import("../ui/graph-renderer.js"); { @@ -344,4 +380,49 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js"); globalThis.window.matchMedia = previousMatchMedia; } +{ + const graph = createStarSeedGraph(); + 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); + assertRendererNodesInsideRegions(renderer); + let diagnostics = renderer.getLastLayoutDiagnostics(); + assert.equal(diagnostics.layoutSeedModeCounts.core, 1); + assert.equal(diagnostics.layoutSeedModeCounts.topic, 2); + assert.equal(diagnostics.layoutSeedModeCounts.reused, 0); + + renderer.loadGraph(graph, { userPovAliases: ["Host"] }); + assertInputUnchanged(graph, before); + assertRendererNodesInsideRegions(renderer); + diagnostics = renderer.getLastLayoutDiagnostics(); + assert.equal(diagnostics.layoutReuseCount, diagnostics.visibleNodeCount); + assert.equal(diagnostics.layoutSeedModeCounts.reused, diagnostics.visibleNodeCount); + renderer.destroy(); +} + +{ + const initialGraph = createStarSeedGraph(); + const nextGraph = createStarSeedGraph({ includeFragment: true }); + const before = JSON.stringify(nextGraph); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true }, + layoutConfig: { neuralIterations: 8 }, + }); + + renderer.loadGraph(initialGraph, { userPovAliases: ["Host"] }); + renderer.loadGraph(nextGraph, { userPovAliases: ["Host"] }); + assertInputUnchanged(nextGraph, before); + assertRendererNodesInsideRegions(renderer); + const diagnostics = renderer.getLastLayoutDiagnostics(); + assert.equal(diagnostics.layoutSeedModeCounts.anchoredFragment, 1); + assert.equal(diagnostics.layoutSeedModeCounts.fallbackFragment, 0); + assert.equal(diagnostics.layoutSeedModeCounts.reused, 3); + renderer.destroy(); +} + console.log("graph-renderer guardrail tests passed"); diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 99482f3..78d88dc 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -334,6 +334,13 @@ export class GraphRenderer { this._layoutSolveRevision = 0; this._lastLayoutDiagnostics = null; this._lastLayoutReuseStats = { reused: 0, total: 0, ratio: 0 }; + this._lastLayoutSeedModeCounts = { + core: 0, + topic: 0, + anchoredFragment: 0, + fallbackFragment: 0, + reused: 0, + }; this._regionPanels = []; this._lastGraph = null; @@ -492,6 +499,13 @@ export class GraphRenderer { .reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0); this._regionPanels = this._computeRegionPanels(W, H, parts); const layoutReuse = this._applyPreviousLayoutSeed(previousLayoutSeedByNodeId); + this._lastLayoutSeedModeCounts = { + core: 0, + topic: 0, + anchoredFragment: 0, + fallbackFragment: 0, + reused: Number(layoutReuse?.reused || 0), + }; this._layoutAllPartitions(parts); const layoutFinishedAt = performance.now(); const baseLayoutDiagnostics = { @@ -509,6 +523,7 @@ export class GraphRenderer { userPovNodeCount: parts.userPov.length, characterPovNodeCount, characterPovPanelCount: parts.charMap.size, + layoutSeedModeCounts: { ...this._lastLayoutSeedModeCounts }, sampled: false, capped: false, renderOnly: true, @@ -865,20 +880,24 @@ export class GraphRenderer { } _layoutAllPartitions({ objective, userPov, charMap }) { + const layoutAdjacencyIndex = this._buildLayoutAdjacencyIndex(); this._seedNeuralCloudInRect( objective.filter((node) => node._layoutSeedReused !== true), objective[0]?.regionRect, + layoutAdjacencyIndex, ); if (userPov.length) { this._seedNeuralCloudInRect( userPov.filter((node) => node._layoutSeedReused !== true), userPov[0]?.regionRect, + layoutAdjacencyIndex, ); } for (const [, arr] of charMap) { this._seedNeuralCloudInRect( arr.filter((node) => node._layoutSeedReused !== true), arr[0]?.regionRect, + layoutAdjacencyIndex, ); } } @@ -971,28 +990,158 @@ export class GraphRenderer { } } + _getMemoryLayoutRole(node) { + const importance = Number(node?.importance || 0); + const type = String(node?.type || '').trim().toLowerCase(); + const raw = node?.raw || {}; + const accessCount = Number(raw.accessCount ?? raw.recallCount ?? raw.fields?.accessCount ?? raw.fields?.recallCount); + const kind = String(raw.fields?.kind ?? raw.kind ?? '').trim().toLowerCase(); + + if ( + importance >= 9 + || ['character', 'rule', 'synopsis'].includes(type) + || (Number.isFinite(accessCount) && accessCount >= 5) + || /\b(core|anchor|central|main|primary)\b/.test(kind) + ) { + return 'core'; + } + if ( + importance >= 6 + || ['event', 'thread', 'location', 'reflection'].includes(type) + ) { + return 'topic'; + } + return 'fragment'; + } + + _buildLayoutAdjacencyIndex() { + const adjacency = new Map(); + const add = (node, neighbor, strength) => { + if (!node?.id || !neighbor) return; + const key = String(node.id); + if (!adjacency.has(key)) adjacency.set(key, []); + adjacency.get(key).push({ neighbor, strength }); + }; + + for (const edge of Array.isArray(this.edges) ? this.edges : []) { + if (!edge?.from?.id || !edge?.to?.id) continue; + const strength = Math.max(0, Number(edge.strength) || 0); + add(edge.from, edge.to, strength); + add(edge.to, edge.from, strength); + } + + return adjacency; + } + + _findLayoutAnchorForNode(node, adjacencyIndex = null, roleCache = null) { + if (!node?.regionKey) return null; + const adjacent = adjacencyIndex instanceof Map + ? adjacencyIndex.get(String(node.id)) || [] + : []; + let best = null; + const resolveRole = (neighbor) => { + const key = String(neighbor?.id || ''); + if (roleCache instanceof Map && roleCache.has(key)) return roleCache.get(key); + const role = this._getMemoryLayoutRole(neighbor); + if (roleCache instanceof Map && key) roleCache.set(key, role); + return role; + }; + + for (const entry of adjacent) { + const neighbor = entry?.neighbor || null; + if (!neighbor || neighbor.regionKey !== node.regionKey) continue; + if (!Number.isFinite(neighbor.x) || !Number.isFinite(neighbor.y)) continue; + const role = resolveRole(neighbor); + const strength = Math.max(0, Number(entry.strength) || 0); + const roleScore = role === 'core' ? 2 : (role === 'topic' ? 1 : 0); + const score = roleScore * 1000 + strength; + if (!best || score > best.score) { + best = { node: neighbor, strength, score }; + } + } + return best; + } + + _markLayoutSeedMode(mode) { + if (!this._lastLayoutSeedModeCounts || typeof this._lastLayoutSeedModeCounts !== 'object') return; + this._lastLayoutSeedModeCounts[mode] = Number(this._lastLayoutSeedModeCounts[mode] || 0) + 1; + } + /** - * 椭圆 Vogel 螺旋初值:有机疏密,Deterministic,无网格感 + * 记忆星系初值:core 居中,topic 环绕,fragment 靠近已定位锚点;Deterministic,无持久写入 */ - _seedNeuralCloudInRect(nodes, rect) { - if (!rect || !nodes.length) return; + _seedNeuralCloudInRect(nodes, rect, adjacencyIndex = null) { + const candidates = Array.isArray(nodes) + ? nodes.filter((node) => node && node._layoutSeedReused !== true) + : []; + if (!rect || !candidates.length) return; const pad = Math.max(10, this.config.neuralMinGap); const cx = rect.x + rect.w / 2; const cy = rect.y + rect.h / 2; const rx = Math.max(14, rect.w / 2 - pad); const ry = Math.max(14, rect.h / 2 - pad); - const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); - const n = sorted.length; const golden = Math.PI * (3 - Math.sqrt(5)); - sorted.forEach((node, i) => { - const t = (i + 0.5) / Math.max(n, 1); - const radScale = Math.sqrt(t) * 0.9; - const phase = ((hashId(node.id) & 0x3ff) / 1024) * 0.62; - const theta = i * golden + phase; - node.x = cx + Math.cos(theta) * radScale * rx; - node.y = cy + Math.sin(theta) * radScale * ry; + const sorted = [...candidates].sort((a, b) => String(a.id).localeCompare(String(b.id))); + const byRole = { core: [], topic: [], fragment: [] }; + const roleCache = new Map(); + for (const node of sorted) { + const role = this._getMemoryLayoutRole(node); + roleCache.set(String(node.id), role); + byRole[role].push(node); + } + + const placeNode = (node, x, y) => { + node.x = Number.isFinite(x) ? x : cx; + node.y = Number.isFinite(y) ? y : cy; node.vx = 0; node.vy = 0; + this._clampNodeToRegion(node); + }; + + byRole.core.forEach((node, i) => { + const h = hashId(node.id); + const theta = i * golden + ((h & 0x3ff) / 1024) * Math.PI * 2; + const radial = Math.min(rx, ry) * (0.035 + ((h >>> 10) & 0xff) / 255 * 0.14); + placeNode(node, cx + Math.cos(theta) * radial, cy + Math.sin(theta) * radial); + this._markLayoutSeedMode('core'); + }); + + const topicCount = Math.max(1, byRole.topic.length); + byRole.topic.forEach((node, i) => { + const h = hashId(node.id); + const theta = i * golden + ((h & 0x3ff) / 1024) * 0.8; + const band = topicCount <= 1 ? 0.42 : 0.32 + (i % 5) * 0.08; + const jitter = (((h >>> 10) & 0xff) / 255 - 0.5) * 0.08; + const scale = Math.max(0.22, Math.min(0.74, band + jitter)); + placeNode(node, cx + Math.cos(theta) * rx * scale, cy + Math.sin(theta) * ry * scale); + this._markLayoutSeedMode('topic'); + }); + + const fragmentCount = Math.max(1, byRole.fragment.length); + byRole.fragment.forEach((node, i) => { + const anchor = this._findLayoutAnchorForNode(node, adjacencyIndex, roleCache); + const h = hashId(node.id); + if (anchor?.node) { + const theta = ((h >>> 1) / 0x7fffffff) * Math.PI * 2 + golden; + const maxLocalRadius = Math.max(14, Math.min(rx, ry) * 0.38); + const strength = Math.max(0, Math.min(1, Number(anchor.strength) || 0)); + const rawRadius = 24 + (Math.abs(h) % 28) * (1.08 - strength * 0.28); + const radius = Math.min(maxLocalRadius, rawRadius); + placeNode( + node, + anchor.node.x + Math.cos(theta) * radius, + anchor.node.y + Math.sin(theta) * radius, + ); + this._markLayoutSeedMode('anchoredFragment'); + return; + } + + const t = (i + 0.5) / fragmentCount; + const radScale = Math.sqrt(t) * 0.9; + const phase = ((h & 0x3ff) / 1024) * 0.62; + const theta = i * golden + phase; + placeNode(node, cx + Math.cos(theta) * radScale * rx, cy + Math.sin(theta) * radScale * ry); + this._markLayoutSeedMode('fallbackFragment'); }); }