/` with:
-
-- `input.json`
-- `elk-layout.json`
-- `layout.svg`
-- `layout-summary.json`
-
-The SVG renderer here is deliberately plain. The point is to test layout integration and algorithm availability, not to declare victory with decorative sludge.
diff --git a/web/elk-probe/package-lock.json b/web/elk-probe/package-lock.json
deleted file mode 100644
index c963ab8..0000000
--- a/web/elk-probe/package-lock.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "@epiphanygraph/elk-probe",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "@epiphanygraph/elk-probe",
- "version": "0.1.0",
- "dependencies": {
- "elkjs": "^0.11.0"
- }
- },
- "node_modules/elkjs": {
- "version": "0.11.1",
- "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
- "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
- "license": "EPL-2.0"
- }
- }
-}
diff --git a/web/elk-probe/package.json b/web/elk-probe/package.json
deleted file mode 100644
index c27811f..0000000
--- a/web/elk-probe/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "name": "@epiphanygraph/elk-probe",
- "version": "0.1.0",
- "private": true,
- "type": "module",
- "description": "Local elkjs probe for EpiphanyGraph layout experiments.",
- "scripts": {
- "probe": "node ./src/cli.mjs probe",
- "inspect": "node ./src/cli.mjs inspect"
- },
- "dependencies": {
- "elkjs": "^0.11.0"
- }
-}
diff --git a/web/elk-probe/src/cli.mjs b/web/elk-probe/src/cli.mjs
deleted file mode 100644
index 713371f..0000000
--- a/web/elk-probe/src/cli.mjs
+++ /dev/null
@@ -1,502 +0,0 @@
-import fs from "node:fs/promises";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import ELK from "elkjs/lib/elk.bundled.js";
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
-const defaultInspectDir = path.join(repoRoot, "out", "elk-probe", "inspect");
-
-const command = process.argv[2] ?? "inspect";
-const args = parseArgs(process.argv.slice(3));
-
-try {
- switch (command) {
- case "inspect":
- await runInspect(args);
- break;
- case "probe":
- await runProbe(args);
- break;
- default:
- throw new Error(`Unknown command '${command}'. Use 'inspect' or 'probe'.`);
- }
-} catch (error) {
- console.error(error instanceof Error ? error.message : error);
- process.exitCode = 1;
-}
-
-async function runInspect(args) {
- const outDir = resolveMaybeRelative(args["out-dir"] ?? defaultInspectDir);
- const elk = createElk();
- const algorithms = await elk.knownLayoutAlgorithms();
- const options = await elk.knownLayoutOptions();
-
- await fs.mkdir(outDir, { recursive: true });
- await writeJson(path.join(outDir, "known-layout-algorithms.json"), algorithms);
- await writeJson(path.join(outDir, "known-layout-options.json"), options);
-
- console.log(`Wrote algorithm inventory to ${outDir}`);
- console.log("");
- console.log("Algorithms:");
- for (const algorithm of algorithms) {
- console.log(`- ${algorithm.id}`);
- }
- console.log("");
- console.log(`Options: ${options.length}`);
-}
-
-async function runProbe(args) {
- const renderDir = args["render-dir"]
- ? resolveMaybeRelative(args["render-dir"])
- : await findLatestBenchmarkRenderDir();
- const outDir = resolveMaybeRelative(
- args["out-dir"] ?? path.join(repoRoot, "out", "elk-probe", timestampNow()),
- );
- const sourceTreeAlgorithm = args["source-tree-algorithm"] ?? "org.eclipse.elk.layered";
- const controlFlowAlgorithm = args["control-flow-algorithm"] ?? "org.eclipse.elk.stress";
-
- await assertFile(path.join(renderDir, "source-tree.json"));
- await assertFile(path.join(renderDir, "control-flow.json"));
-
- const elk = createElk();
- const algorithms = await elk.knownLayoutAlgorithms();
- const algorithmIds = new Set(algorithms.map((algorithm) => algorithm.id));
-
- ensureAlgorithmAvailable(sourceTreeAlgorithm, algorithmIds);
- ensureAlgorithmAvailable(controlFlowAlgorithm, algorithmIds);
-
- await fs.mkdir(outDir, { recursive: true });
- await writeJson(path.join(outDir, "known-layout-algorithms.json"), algorithms);
-
- const partitions = [
- {
- graphKey: "source-tree",
- algorithm: sourceTreeAlgorithm,
- },
- {
- graphKey: "control-flow",
- algorithm: controlFlowAlgorithm,
- },
- ];
-
- for (const partition of partitions) {
- const inputPath = path.join(renderDir, `${partition.graphKey}.json`);
- const report = JSON.parse(await fs.readFile(inputPath, "utf8"));
- const partitionOutDir = path.join(outDir, partition.graphKey);
- await fs.mkdir(partitionOutDir, { recursive: true });
-
- const elkGraph = buildElkGraph(report, partition.algorithm);
- const layout = await elk.layout(elkGraph);
- const summary = buildLayoutSummary(report, layout, partition.algorithm);
- const svg = renderSvg(layout, report, partition.algorithm);
-
- await writeJson(path.join(partitionOutDir, "input.json"), report);
- await writeJson(path.join(partitionOutDir, "elk-graph.json"), elkGraph);
- await writeJson(path.join(partitionOutDir, "elk-layout.json"), layout);
- await writeJson(path.join(partitionOutDir, "layout-summary.json"), summary);
- await fs.writeFile(path.join(partitionOutDir, "layout.svg"), svg, "utf8");
-
- console.log(
- `[${partition.graphKey}] ${partition.algorithm} -> ${path.join(partitionOutDir, "layout.svg")}`,
- );
- }
-
- console.log("");
- console.log(`Probe output: ${outDir}`);
-}
-
-async function findLatestBenchmarkRenderDir() {
- const benchmarkRoot = path.join(repoRoot, "out", "benchmarks", "aetheria-source-tree-map");
- const runEntries = await fs.readdir(benchmarkRoot, { withFileTypes: true });
- const runNames = runEntries
- .filter((entry) => entry.isDirectory())
- .map((entry) => entry.name)
- .sort()
- .reverse();
-
- for (const runName of runNames) {
- const candidateRoot = path.join(benchmarkRoot, runName, "candidates");
- try {
- const candidateEntries = await fs.readdir(candidateRoot, { withFileTypes: true });
- const candidateNames = candidateEntries
- .filter((entry) => entry.isDirectory())
- .map((entry) => entry.name)
- .sort((left, right) => {
- const leftScore = left.includes("rich") ? 0 : 1;
- const rightScore = right.includes("rich") ? 0 : 1;
- return leftScore - rightScore || left.localeCompare(right);
- });
-
- for (const candidateName of candidateNames) {
- const renderDir = path.join(candidateRoot, candidateName, "render");
- try {
- await assertFile(path.join(renderDir, "source-tree.json"));
- await assertFile(path.join(renderDir, "control-flow.json"));
- return renderDir;
- } catch {
- // Keep walking until we find a valid render directory.
- }
- }
- } catch {
- // Ignore missing candidate folders and keep scanning older runs.
- }
- }
-
- throw new Error(
- "Could not find a benchmark render directory automatically. Pass --render-dir explicitly.",
- );
-}
-
-function createElk() {
- return new ELK();
-}
-
-function buildElkGraph(report, algorithm) {
- return {
- id: report.GraphKey,
- layoutOptions: {
- "elk.algorithm": algorithm,
- ...baseGraphOptions(report.GraphKey, algorithm),
- },
- children: report.Nodes.map((node) => ({
- id: node.Id,
- width: normalizeSize(node.Bounds.Width, 68, 260),
- height: normalizeSize(node.Bounds.Height, 34, 110),
- labels: [
- {
- text: node.Title,
- },
- ],
- layoutOptions: {
- ...nodeOptions(node, report.GraphKey),
- },
- })),
- edges: report.Edges.map((edge, index) => ({
- id: `${report.GraphKey}-edge-${index}`,
- sources: [edge.SourceId],
- targets: [edge.TargetId],
- })),
- };
-}
-
-function baseGraphOptions(graphKey, algorithm) {
- const layered = algorithm === "org.eclipse.elk.layered";
- if (graphKey === "source-tree") {
- return layered
- ? {
- "elk.direction": "DOWN",
- "elk.layered.spacing.nodeNodeBetweenLayers": "92",
- "elk.spacing.nodeNode": "42",
- "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
- "elk.edgeRouting": "SPLINES",
- }
- : {
- "elk.spacing.nodeNode": "48",
- "elk.padding": "[top=40,left=40,bottom=40,right=40]",
- };
- }
-
- return layered
- ? {
- "elk.direction": "DOWN",
- "elk.layered.spacing.nodeNodeBetweenLayers": "124",
- "elk.spacing.nodeNode": "84",
- "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
- "elk.edgeRouting": "SPLINES",
- }
- : {
- "elk.spacing.nodeNode": "80",
- "elk.padding": "[top=60,left=60,bottom=60,right=60]",
- };
-}
-
-function nodeOptions(node, graphKey) {
- if (graphKey === "source-tree") {
- return {
- "elk.nodeSize.constraints": "MINIMUM_SIZE",
- };
- }
-
- return {
- "elk.nodeSize.constraints": "MINIMUM_SIZE",
- };
-}
-
-function buildLayoutSummary(report, layout, algorithm) {
- return {
- graphKey: report.GraphKey,
- graphTitle: report.GraphTitle,
- nodeCount: report.NodeCount,
- edgeCount: report.EdgeCount,
- algorithm,
- width: layout.width ?? 0,
- height: layout.height ?? 0,
- children: layout.children?.length ?? 0,
- edgesWithSections:
- layout.edges?.filter((edge) => Array.isArray(edge.sections) && edge.sections.length > 0).length ?? 0,
- };
-}
-
-function renderSvg(layout, report, algorithm) {
- const nodeLookup = new Map(report.Nodes.map((node) => [node.Id, node]));
- const width = Math.max(1280, Math.ceil((layout.width ?? 0) + 120));
- const height = Math.max(860, Math.ceil((layout.height ?? 0) + 140));
- const headerHeight = 92;
- const offsetX = 60;
- const offsetY = headerHeight;
-
- const children = [...(layout.children ?? [])];
- const edges = [...(layout.edges ?? [])];
-
- const svg = [];
- svg.push(``);
- svg.push(
- ``);
- return svg.join("\n");
-}
-
-function edgeSectionsToPoints(sections = [], offsetX, offsetY) {
- const points = [];
- for (const section of sections ?? []) {
- if (section.startPoint) {
- points.push({
- x: section.startPoint.x + offsetX,
- y: section.startPoint.y + offsetY,
- });
- }
- for (const bendPoint of section.bendPoints ?? []) {
- points.push({
- x: bendPoint.x + offsetX,
- y: bendPoint.y + offsetY,
- });
- }
- if (section.endPoint) {
- points.push({
- x: section.endPoint.x + offsetX,
- y: section.endPoint.y + offsetY,
- });
- }
- }
-
- return dedupeSequential(points);
-}
-
-function polylinePath(points) {
- const [first, ...rest] = points;
- return `M ${fmt(first.x)} ${fmt(first.y)} ${rest.map((point) => `L ${fmt(point.x)} ${fmt(point.y)}`).join(" ")}`;
-}
-
-function dedupeSequential(points) {
- const deduped = [];
- for (const point of points) {
- const previous = deduped[deduped.length - 1];
- if (!previous || previous.x !== point.x || previous.y !== point.y) {
- deduped.push(point);
- }
- }
-
- return deduped;
-}
-
-function nodeColor(node, graphKey) {
- if (node?.IsEntry) {
- return "#fde68a";
- }
- if (node?.Role === "anchor") {
- return "#94a3b8";
- }
- if (graphKey === "control-flow") {
- return node?.Title?.includes("Server") ? "#60a5fa" : "#fb7185";
- }
- if (node?.Title?.includes("Shared")) {
- return "#67e8f9";
- }
- if (node?.Title?.includes("UI") || node?.Title?.includes("Client")) {
- return "#4ade80";
- }
- if (node?.Title?.includes("Simulation") || node?.Title?.includes("Zone") || node?.Title?.includes("Galaxy")) {
- return "#c084fc";
- }
- return "#22d3ee";
-}
-
-function tokenFor(node) {
- if (!node) {
- return "N";
- }
- if (node.IsEntry) {
- return "E";
- }
- if (node.Role === "anchor") {
- return "A";
- }
- const words = node.Title.split(/[\s/_-]+/).filter(Boolean);
- if (words.length === 1) {
- return words[0].slice(0, 2).toUpperCase();
- }
- return `${words[0][0] ?? "N"}${words[1][0] ?? ""}`.toUpperCase();
-}
-
-function wrapLabel(label, maxChars, maxLines) {
- const words = label.split(/\s+/).filter(Boolean);
- const lines = [];
- let current = "";
- for (const word of words) {
- const candidate = current ? `${current} ${word}` : word;
- if (candidate.length > maxChars && current) {
- lines.push(current);
- current = word;
- if (lines.length === maxLines) {
- break;
- }
- } else {
- current = candidate;
- }
- }
- if (current && lines.length < maxLines) {
- lines.push(current);
- }
- if (lines.length === 0) {
- lines.push(label);
- }
- if (lines.length === maxLines && words.length > 0) {
- const last = lines[lines.length - 1];
- if (last.length > maxChars - 3) {
- lines[lines.length - 1] = `${last.slice(0, Math.max(1, maxChars - 3))}...`;
- }
- }
- return lines;
-}
-
-function ensureAlgorithmAvailable(algorithm, knownAlgorithms) {
- if (!knownAlgorithms.has(algorithm)) {
- throw new Error(
- `Algorithm '${algorithm}' is not available in this elkjs build.`,
- );
- }
-}
-
-async function assertFile(filePath) {
- await fs.access(filePath);
-}
-
-async function writeJson(filePath, value) {
- await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
-}
-
-function normalizeSize(value, min, max) {
- return Math.max(min, Math.min(max, Math.round(value)));
-}
-
-function parseArgs(argv) {
- const args = {};
- for (let index = 0; index < argv.length; index += 1) {
- const token = argv[index];
- if (!token.startsWith("--")) {
- continue;
- }
- const key = token.slice(2);
- const next = argv[index + 1];
- if (!next || next.startsWith("--")) {
- args[key] = true;
- continue;
- }
- args[key] = next;
- index += 1;
- }
- return args;
-}
-
-function resolveMaybeRelative(targetPath) {
- return path.isAbsolute(targetPath) ? targetPath : path.resolve(repoRoot, targetPath);
-}
-
-function timestampNow() {
- const now = new Date();
- const pad = (value) => String(value).padStart(2, "0");
- return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`;
-}
-
-function escapeXml(value) {
- return String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
-function fmt(value) {
- return Number(value).toFixed(2).replace(/\.00$/, "");
-}
diff --git a/web/epiphany-graph-viewer/README.md b/web/epiphany-graph-viewer/README.md
index ef24a9b..5ecf3f5 100644
--- a/web/epiphany-graph-viewer/README.md
+++ b/web/epiphany-graph-viewer/README.md
@@ -53,7 +53,8 @@ Regenerate it after changing `E:\Projects\epiphany-graph-rs`:
npm run build:solver:wasm
```
-ELK remains only as a fallback if the Rust solver fails to instantiate.
+If the Rust solver fails to instantiate, layout fails loudly. There is no shadow
+engine behind the component.
## Use In Another App
@@ -86,7 +87,7 @@ The package exports:
- `EpiphanyGraphViewer`
- state, selection, node, edge, layout mode, motion, performance, and event payload types
-The package does not export demo data, validation helpers, ELK probes, SVG renderers,
+The package does not export demo data, validation helpers, probes, SVG renderers,
or engine controls. Those are implementation furniture. Consumers get the couch, not
the warehouse inventory.
@@ -178,13 +179,12 @@ the rendered state closer to the current layout truth:
- high-error nodes: farthest from their base layout position
- kinetic nodes: nodes carrying the most residual velocity
-The base ELK layout is the current ground-truth anchor for browser motion. Combined
+The base Rust solver layout is the current ground-truth anchor for browser motion. Combined
force is a live presentation layer around that anchor, not a second source of graph
truth.
The existing benchmark harness under `benchmarks/` measures rendered graph usefulness
-against blind graph-truth probes. It does not yet prove realtime sampling optimality.
-The first realtime smoke baseline is generated by:
+against blind graph-truth probes. The realtime smoke baseline is generated by:
```powershell
npm run tune:simulation
@@ -192,6 +192,14 @@ npm run tune:simulation
That writes `benchmarks/realtime/simulation-profile-baseline.json` with cost,
budget-hit, anchor-error, and full-sample-baseline-error data for synthetic expansions
-of the mock graph. The viewer's default profile mirrors that baseline. Do not call
-the realtime heuristics "extensively tuned" until broader profiled budget sweeps
-exist for real client corpora and their results are checked in.
+of the mock graph.
+
+The Rust solver performance baseline is generated by:
+
+```powershell
+npm run profile:solver
+```
+
+That writes `benchmarks/realtime/rust-solver-performance-baseline.json`, CSV rows,
+and SVG charts covering hierarchy families, graph sizes, solver latency windows,
+meta-heuristic parameters, and convergence error against a high-iteration reference.
diff --git a/web/epiphany-graph-viewer/package-lock.json b/web/epiphany-graph-viewer/package-lock.json
index 1608644..db3ba85 100644
--- a/web/epiphany-graph-viewer/package-lock.json
+++ b/web/epiphany-graph-viewer/package-lock.json
@@ -7,9 +7,6 @@
"": {
"name": "@epiphanygraph/epiphany-graph-viewer",
"version": "0.1.0",
- "dependencies": {
- "elkjs": "^0.11.0"
- },
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
@@ -1484,12 +1481,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/elkjs": {
- "version": "0.11.1",
- "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
- "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
- "license": "EPL-2.0"
- },
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
diff --git a/web/epiphany-graph-viewer/package.json b/web/epiphany-graph-viewer/package.json
index ed3e10b..bff00c2 100644
--- a/web/epiphany-graph-viewer/package.json
+++ b/web/epiphany-graph-viewer/package.json
@@ -24,15 +24,13 @@
"build:solver:wasm": "powershell -ExecutionPolicy Bypass -File ./scripts/Build-RustSolverWasm.ps1",
"typecheck": "tsc --noEmit",
"tune:simulation": "node ./scripts/tune-simulation-profile.mjs",
+ "profile:solver": "node ./scripts/profile-rust-solver-performance.mjs",
"preview": "vite preview"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
- "dependencies": {
- "elkjs": "^0.11.0"
- },
"devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
diff --git a/web/epiphany-graph-viewer/scripts/profile-rust-solver-performance.mjs b/web/epiphany-graph-viewer/scripts/profile-rust-solver-performance.mjs
new file mode 100644
index 0000000..f2a9d4f
--- /dev/null
+++ b/web/epiphany-graph-viewer/scripts/profile-rust-solver-performance.mjs
@@ -0,0 +1,459 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { performance } from "node:perf_hooks";
+
+const repoRoot = path.resolve(import.meta.dirname, "..", "..", "..");
+const packageRoot = path.resolve(import.meta.dirname, "..");
+const wasmBytesPath = path.join(packageRoot, "src", "lib", "solver-wasm-bytes.ts");
+const outputDir = path.join(repoRoot, "benchmarks", "realtime");
+const outputJsonPath = path.join(outputDir, "rust-solver-performance-baseline.json");
+const outputCsvPath = path.join(outputDir, "rust-solver-performance-baseline.csv");
+const latencySvgPath = path.join(outputDir, "rust-solver-latency.svg");
+const convergenceSvgPath = path.join(outputDir, "rust-solver-convergence.svg");
+
+const solver = await loadSolver();
+
+const graphSizes = [24, 64, 128, 256, 512, 1024];
+const graphFamilies = [
+ "chain",
+ "layered-dag",
+ "binary-tree",
+ "hub-spoke",
+ "clustered-modules",
+];
+const latencyWindowsMs = [1.5, 3, 6, 12];
+const parameterProfiles = [
+ { id: "fast-flat", rankGap: 96, nodeGap: 76, edgeLength: 130 },
+ { id: "balanced-hierarchy", rankGap: 136, nodeGap: 92, edgeLength: 170 },
+ { id: "quality-spacious", rankGap: 180, nodeGap: 118, edgeLength: 230 },
+];
+const iterationBudgets = [32, 96, 224];
+
+const run = {
+ generatedAtUtc: new Date().toISOString(),
+ description:
+ "Smoke baseline for the Rust WASM graph solver used by the React viewer. Measures graph family, size, latency-window, meta-heuristic parameter, and convergence tradeoffs.",
+ method: {
+ graphSizes,
+ graphFamilies,
+ latencyWindowsMs,
+ parameterProfiles,
+ iterationBudgets,
+ samplesPerCandidate: 1,
+ warmupRuns: 0,
+ reference: "quality-spacious parameters at a size-capped high iteration count",
+ loss:
+ "Normalized RMS node displacement from the reference layout after aligning centroids. Lower is closer to the high-iteration solver result.",
+ },
+ rows: [],
+ latencyWindowFrontier: [],
+ summary: {},
+};
+
+for (const family of graphFamilies) {
+ for (const nodeCount of graphSizes) {
+ const graph = buildGraph(family, nodeCount);
+ const referenceIterations = nodeCount <= 256 ? 360 : nodeCount <= 512 ? 280 : 220;
+ const reference = layoutGraph(graph, {
+ ...parameterProfiles[2],
+ iterations: referenceIterations,
+ });
+
+ for (const profile of parameterProfiles) {
+ for (const iterations of iterationBudgets) {
+ const candidate = measureCandidate(graph, {
+ ...profile,
+ iterations,
+ });
+ run.rows.push({
+ family,
+ nodeCount,
+ edgeCount: graph.edgePairs.length / 2,
+ profileId: profile.id,
+ iterations,
+ rankGap: profile.rankGap,
+ nodeGap: profile.nodeGap,
+ edgeLength: profile.edgeLength,
+ latencyMs: round(candidate.latencyMs),
+ p95LatencyMs: round(candidate.p95LatencyMs),
+ loss: round(layoutLoss(candidate.coordinates, reference.coordinates)),
+ edgeStress: round(edgeStress(graph, candidate.coordinates, profile.edgeLength)),
+ referenceIterations,
+ });
+ }
+ }
+ }
+}
+
+for (const family of graphFamilies) {
+ for (const nodeCount of graphSizes) {
+ const rows = run.rows.filter((row) => row.family === family && row.nodeCount === nodeCount);
+ for (const budgetMs of latencyWindowsMs) {
+ const eligible = rows
+ .filter((row) => row.p95LatencyMs <= budgetMs)
+ .sort((left, right) => left.loss - right.loss || left.p95LatencyMs - right.p95LatencyMs);
+ const fallback = rows
+ .slice()
+ .sort((left, right) => left.p95LatencyMs - right.p95LatencyMs || left.loss - right.loss);
+ const best = eligible[0] ?? fallback[0];
+ run.latencyWindowFrontier.push({
+ family,
+ nodeCount,
+ budgetMs,
+ metBudget: Boolean(eligible[0]),
+ profileId: best.profileId,
+ iterations: best.iterations,
+ p95LatencyMs: best.p95LatencyMs,
+ loss: best.loss,
+ });
+ }
+ }
+}
+
+run.summary = summarize(run.rows, run.latencyWindowFrontier);
+
+await fs.mkdir(outputDir, { recursive: true });
+await fs.writeFile(`${outputJsonPath}.tmp`, `${JSON.stringify(run, null, 2)}\n`);
+await fs.rename(`${outputJsonPath}.tmp`, outputJsonPath);
+await fs.writeFile(`${outputCsvPath}.tmp`, csvRows(run.rows));
+await fs.rename(`${outputCsvPath}.tmp`, outputCsvPath);
+await fs.writeFile(`${latencySvgPath}.tmp`, latencySvg(run.rows));
+await fs.rename(`${latencySvgPath}.tmp`, latencySvgPath);
+await fs.writeFile(`${convergenceSvgPath}.tmp`, convergenceSvg(run.rows));
+await fs.rename(`${convergenceSvgPath}.tmp`, convergenceSvgPath);
+
+console.log(`Wrote ${outputJsonPath}`);
+console.log(`Wrote ${outputCsvPath}`);
+console.log(`Wrote ${latencySvgPath}`);
+console.log(`Wrote ${convergenceSvgPath}`);
+
+async function loadSolver() {
+ const source = await fs.readFile(wasmBytesPath, "utf8");
+ const match = source.match(/epiphanyGraphSolverWasmBase64\s*=\s*"([^"]+)"/);
+ if (!match) {
+ throw new Error(`Could not read embedded solver WASM from ${wasmBytesPath}`);
+ }
+ const bytes = Buffer.from(match[1], "base64");
+ const result = await WebAssembly.instantiate(bytes, {});
+ const exports = result.instance.exports;
+ for (const name of [
+ "memory",
+ "epiphany_graph_alloc_f32",
+ "epiphany_graph_dealloc_f32",
+ "epiphany_graph_alloc_u32",
+ "epiphany_graph_dealloc_u32",
+ "epiphany_graph_layout_2d",
+ ]) {
+ if (!(name in exports)) {
+ throw new Error(`Rust graph solver wasm is missing ${name}`);
+ }
+ }
+ return exports;
+}
+
+function measureCandidate(graph, config) {
+ const samples = [];
+ let coordinates = null;
+ for (let index = 0; index < 1; index += 1) {
+ const start = performance.now();
+ coordinates = layoutGraph(graph, config).coordinates;
+ samples.push(performance.now() - start);
+ }
+
+ return {
+ coordinates,
+ latencyMs: average(samples),
+ p95LatencyMs: percentile(samples, 0.95),
+ };
+}
+
+function layoutGraph(graph, config) {
+ const nodeCount = graph.nodeWeights.length;
+ const edgeCount = graph.edgePairs.length / 2;
+ const nodeWeightsPtr = solver.epiphany_graph_alloc_f32(nodeCount);
+ const edgePairsPtr = solver.epiphany_graph_alloc_u32(graph.edgePairs.length);
+ const outputPtr = solver.epiphany_graph_alloc_f32(nodeCount * 4);
+
+ try {
+ new Float32Array(solver.memory.buffer, nodeWeightsPtr, nodeCount).set(graph.nodeWeights);
+ new Uint32Array(solver.memory.buffer, edgePairsPtr, graph.edgePairs.length).set(graph.edgePairs);
+ const status = solver.epiphany_graph_layout_2d(
+ nodeWeightsPtr,
+ nodeCount,
+ edgePairsPtr,
+ edgeCount,
+ outputPtr,
+ config.iterations,
+ config.rankGap,
+ config.nodeGap,
+ config.edgeLength,
+ );
+ if (status !== 0) {
+ throw new Error(`Rust graph solver failed with status ${status}`);
+ }
+ const coordinates = new Float32Array(nodeCount * 2);
+ const raw = new Float32Array(solver.memory.buffer, outputPtr, nodeCount * 4);
+ for (let index = 0; index < nodeCount; index += 1) {
+ coordinates[index * 2] = raw[index * 4];
+ coordinates[index * 2 + 1] = raw[index * 4 + 1];
+ }
+ return { coordinates };
+ } finally {
+ solver.epiphany_graph_dealloc_f32(nodeWeightsPtr, nodeCount);
+ solver.epiphany_graph_dealloc_u32(edgePairsPtr, graph.edgePairs.length);
+ solver.epiphany_graph_dealloc_f32(outputPtr, nodeCount * 4);
+ }
+}
+
+function buildGraph(family, nodeCount) {
+ const edges = [];
+ if (family === "chain") {
+ for (let index = 1; index < nodeCount; index += 1) {
+ edges.push(index - 1, index);
+ }
+ } else if (family === "layered-dag") {
+ const width = Math.max(4, Math.round(Math.sqrt(nodeCount)));
+ for (let index = 0; index < nodeCount; index += 1) {
+ const nextLayer = index + width;
+ if (nextLayer < nodeCount) {
+ edges.push(index, nextLayer);
+ }
+ if (index % width !== width - 1 && nextLayer + 1 < nodeCount) {
+ edges.push(index, nextLayer + 1);
+ }
+ }
+ } else if (family === "binary-tree") {
+ for (let index = 1; index < nodeCount; index += 1) {
+ edges.push(Math.floor((index - 1) / 2), index);
+ }
+ } else if (family === "hub-spoke") {
+ const hubs = Math.max(2, Math.floor(Math.sqrt(nodeCount) / 2));
+ for (let index = hubs; index < nodeCount; index += 1) {
+ edges.push(index % hubs, index);
+ if (index + hubs < nodeCount && index % 3 === 0) {
+ edges.push(index, index + hubs);
+ }
+ }
+ } else if (family === "clustered-modules") {
+ const clusterSize = Math.max(6, Math.round(Math.sqrt(nodeCount)));
+ for (let index = 0; index < nodeCount; index += 1) {
+ const clusterStart = Math.floor(index / clusterSize) * clusterSize;
+ const next = clusterStart + ((index - clusterStart + 1) % clusterSize);
+ if (next < nodeCount && next !== index) {
+ edges.push(index, next);
+ }
+ if (index + clusterSize < nodeCount) {
+ edges.push(index, index + clusterSize);
+ }
+ }
+ } else {
+ throw new Error(`Unknown graph family ${family}`);
+ }
+
+ const degrees = new Uint32Array(nodeCount);
+ for (let index = 0; index < edges.length; index += 2) {
+ degrees[edges[index]] += 1;
+ degrees[edges[index + 1]] += 1;
+ }
+
+ const nodeWeights = new Float32Array(nodeCount);
+ for (let index = 0; index < nodeCount; index += 1) {
+ nodeWeights[index] = 1 + degrees[index] * 0.18 + deterministicNoise(family, index) * 0.24;
+ }
+
+ return {
+ nodeWeights,
+ edgePairs: new Uint32Array(edges),
+ };
+}
+
+function layoutLoss(candidate, reference) {
+ const count = candidate.length / 2;
+ const candidateCenter = centroid(candidate);
+ const referenceCenter = centroid(reference);
+ let sum = 0;
+ let maxReferenceRadius = 1;
+
+ for (let index = 0; index < count; index += 1) {
+ const candidateX = candidate[index * 2] - candidateCenter.x;
+ const candidateY = candidate[index * 2 + 1] - candidateCenter.y;
+ const referenceX = reference[index * 2] - referenceCenter.x;
+ const referenceY = reference[index * 2 + 1] - referenceCenter.y;
+ maxReferenceRadius = Math.max(maxReferenceRadius, Math.hypot(referenceX, referenceY));
+ sum += (candidateX - referenceX) ** 2 + (candidateY - referenceY) ** 2;
+ }
+
+ return Math.sqrt(sum / count) / maxReferenceRadius;
+}
+
+function edgeStress(graph, coordinates, targetLength) {
+ const stresses = [];
+ for (let index = 0; index < graph.edgePairs.length; index += 2) {
+ const source = graph.edgePairs[index];
+ const target = graph.edgePairs[index + 1];
+ const length = Math.hypot(
+ coordinates[source * 2] - coordinates[target * 2],
+ coordinates[source * 2 + 1] - coordinates[target * 2 + 1],
+ );
+ stresses.push(Math.abs(length - targetLength) / Math.max(1, targetLength));
+ }
+ return average(stresses);
+}
+
+function summarize(rows, frontier) {
+ const bySize = graphSizes.map((nodeCount) => {
+ const sizeRows = rows.filter((row) => row.nodeCount === nodeCount);
+ return {
+ nodeCount,
+ bestP95LatencyMs: round(Math.min(...sizeRows.map((row) => row.p95LatencyMs))),
+ bestLoss: round(Math.min(...sizeRows.map((row) => row.loss))),
+ medianP95LatencyMs: round(percentile(sizeRows.map((row) => row.p95LatencyMs), 0.5)),
+ };
+ });
+ const largestRealtime = [...frontier]
+ .filter((row) => row.metBudget)
+ .sort((left, right) => right.nodeCount - left.nodeCount || left.budgetMs - right.budgetMs)[0];
+
+ return {
+ bySize,
+ largestBudgetMeetingRun: largestRealtime ?? null,
+ bestOverallLoss: rows.slice().sort((left, right) => left.loss - right.loss)[0],
+ fastestOverall: rows.slice().sort((left, right) => left.p95LatencyMs - right.p95LatencyMs)[0],
+ };
+}
+
+function csvRows(rows) {
+ const header = [
+ "family",
+ "nodeCount",
+ "edgeCount",
+ "profileId",
+ "iterations",
+ "rankGap",
+ "nodeGap",
+ "edgeLength",
+ "latencyMs",
+ "p95LatencyMs",
+ "loss",
+ "edgeStress",
+ "referenceIterations",
+ ];
+ return [
+ header.join(","),
+ ...rows.map((row) => header.map((key) => row[key]).join(",")),
+ "",
+ ].join("\n");
+}
+
+function latencySvg(rows) {
+ const fastestBySize = graphSizes.map((nodeCount) => ({
+ x: nodeCount,
+ y: Math.min(...rows.filter((row) => row.nodeCount === nodeCount).map((row) => row.p95LatencyMs)),
+ }));
+ return chartSvg({
+ title: "Rust Solver P95 Latency By Graph Size",
+ xLabel: "nodes",
+ yLabel: "p95 ms",
+ series: [{ id: "fastest", color: "#22d3ee", points: fastestBySize }],
+ });
+}
+
+function convergenceSvg(rows) {
+ const byIteration = iterationBudgets.map((iterations) => ({
+ x: iterations,
+ y: average(rows.filter((row) => row.iterations === iterations).map((row) => row.loss)),
+ }));
+ return chartSvg({
+ title: "Rust Solver Convergence Loss By Iterations",
+ xLabel: "iterations",
+ yLabel: "normalized loss",
+ series: [{ id: "average loss", color: "#f472b6", points: byIteration }],
+ });
+}
+
+function chartSvg({ title, xLabel, yLabel, series }) {
+ const width = 920;
+ const height = 460;
+ const padding = { left: 74, right: 36, top: 58, bottom: 70 };
+ const allPoints = series.flatMap((item) => item.points);
+ const xMin = Math.min(...allPoints.map((point) => point.x));
+ const xMax = Math.max(...allPoints.map((point) => point.x));
+ const yMin = 0;
+ const yMax = Math.max(0.001, Math.max(...allPoints.map((point) => point.y)) * 1.12);
+ const plotWidth = width - padding.left - padding.right;
+ const plotHeight = height - padding.top - padding.bottom;
+ const sx = (value) => padding.left + ((value - xMin) / Math.max(1, xMax - xMin)) * plotWidth;
+ const sy = (value) => padding.top + plotHeight - ((value - yMin) / Math.max(0.001, yMax - yMin)) * plotHeight;
+ const paths = series.map((item) => {
+ const d = item.points
+ .map((point, index) => `${index === 0 ? "M" : "L"} ${sx(point.x).toFixed(2)} ${sy(point.y).toFixed(2)}`)
+ .join(" ");
+ const circles = item.points
+ .map((point) => ``)
+ .join("");
+ return `${circles}`;
+ }).join("");
+
+ return `
+`;
+}
+
+function centroid(coordinates) {
+ let x = 0;
+ let y = 0;
+ const count = coordinates.length / 2;
+ for (let index = 0; index < count; index += 1) {
+ x += coordinates[index * 2];
+ y += coordinates[index * 2 + 1];
+ }
+ return { x: x / count, y: y / count };
+}
+
+function deterministicNoise(family, index) {
+ let hash = 2166136261;
+ for (const char of `${family}:${index}`) {
+ hash ^= char.charCodeAt(0);
+ hash = Math.imul(hash, 16777619);
+ }
+ return ((hash >>> 0) % 1000) / 1000;
+}
+
+function average(values) {
+ const finite = values.filter(Number.isFinite);
+ if (finite.length === 0) {
+ return 0;
+ }
+ return finite.reduce((sum, value) => sum + value, 0) / finite.length;
+}
+
+function percentile(values, fraction) {
+ const sorted = values.filter(Number.isFinite).sort((left, right) => left - right);
+ if (sorted.length === 0) {
+ return 0;
+ }
+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))];
+}
+
+function round(value) {
+ return Number(value.toFixed(4));
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
diff --git a/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer.tsx b/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer.tsx
index 7ab8521..efbdbc0 100644
--- a/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer.tsx
+++ b/web/epiphany-graph-viewer/src/lib/EpiphanyGraphViewer.tsx
@@ -600,7 +600,7 @@ export function EpiphanyGraphViewer({
Wheel to zoom. Drag the void to pan. Click something interesting and the side panel stops being decorative.
- {status === "loading" && Laying out the graph. ELK is thinking noble thoughts.}
+ {status === "loading" && Laying out the graph. Rust solver is taking the hill.}
{status === "error" && {errorMessage ?? "Layout failed."}}
{status === "ready" && activeLayout && (