feat(graph-ui): seed memory star-system layout

This commit is contained in:
youzini
2026-06-04 08:26:51 +00:00
parent 26c1b92a3d
commit d114fbd949
2 changed files with 242 additions and 12 deletions

View File

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

View File

@@ -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');
});
}