Compare commits
2 commits
75aefa0c49
...
77ba550fdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ba550fdc | ||
|
|
3f727e4743 |
19 changed files with 2636 additions and 301 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -3,3 +3,7 @@
|
|||
.vscode/
|
||||
**/.venv
|
||||
__pycache__
|
||||
|
||||
# Analysis artifacts (depgraph + spectral)
|
||||
**/deps.dot
|
||||
**/deps.html
|
||||
|
|
|
|||
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -534,6 +534,14 @@ dependencies = [
|
|||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-gossip"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"swactor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swactor-python"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = [".", "crates/swactor-python", "crates/swactor-wasm"]
|
||||
members = [".", "crates/swactor-python", "crates/swactor-wasm", "crates/swactor-gossip"]
|
||||
exclude = ["tools/depgraph"]
|
||||
|
||||
[package]
|
||||
|
|
|
|||
8
crates/swactor-gossip/Cargo.toml
Normal file
8
crates/swactor-gossip/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "swactor-gossip"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../.." }
|
||||
getrandom = "0.2"
|
||||
568
crates/swactor-gossip/docs/connectome/connectome_dashboard.html
Normal file
568
crates/swactor-gossip/docs/connectome/connectome_dashboard.html
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<title>swactor — dependency analysis</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:#1a1a2e; color:#e0e0e0; font-family:system-ui,-apple-system,sans-serif; overflow:hidden; }
|
||||
|
||||
/* ─── Tab bar ───────────────────────────────────────────────────────────── */
|
||||
.tab-bar { display:flex; align-items:center; height:42px; background:#12122a;
|
||||
border-bottom:1px solid #2a2a5a; padding:0 16px; gap:8px; }
|
||||
.tab-bar .title { font-size:14px; font-weight:700; letter-spacing:0.5px; margin-right:18px;
|
||||
color:#8ab4f8; white-space:nowrap; }
|
||||
.tab { background:none; border:none; color:#888; font-size:13px; padding:8px 16px;
|
||||
cursor:pointer; border-bottom:2px solid transparent; transition:color 0.15s; }
|
||||
.tab:hover { color:#ccc; }
|
||||
.tab.active { color:#e0e0e0; border-bottom-color:#4fc3f7; }
|
||||
|
||||
/* ─── Tab content ───────────────────────────────────────────────────────── */
|
||||
.tab-content { display:none; }
|
||||
.tab-content.active { display:block; }
|
||||
|
||||
/* ─── DAG tab ───────────────────────────────────────────────────────────── */
|
||||
#tab-dag { height:calc(100vh - 42px); overflow:hidden; position:relative; }
|
||||
#dag-viewport { width:100%; height:100%; cursor:grab; }
|
||||
#dag-viewport:active { cursor:grabbing; }
|
||||
#dag-viewport svg { display:block; }
|
||||
#dag-controls { position:absolute; top:12px; left:12px; z-index:10;
|
||||
background:rgba(30,30,60,0.9); border-radius:8px; padding:10px 14px;
|
||||
color:#ccc; font-size:13px; backdrop-filter:blur(8px); }
|
||||
#dag-controls button { background:#333; color:#fff; border:1px solid #555;
|
||||
border-radius:4px; padding:4px 10px; cursor:pointer; margin:0 3px; }
|
||||
#dag-controls button:hover { background:#555; }
|
||||
#dag-loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||
color:#ccc; font-size:18px; }
|
||||
|
||||
/* ─── Spectral tab ──────────────────────────────────────────────────────── */
|
||||
#tab-spectral { overflow-y:auto; max-height:calc(100vh - 42px); }
|
||||
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:auto auto;
|
||||
gap:16px; padding:16px 20px 20px; max-width:1600px; margin:0 auto; }
|
||||
|
||||
.panel { background:#16213e; border-radius:10px; border:1px solid #2a2a5a;
|
||||
padding:16px; position:relative; min-height:100px; }
|
||||
.panel h2 { font-size:14px; font-weight:600; margin-bottom:10px; color:#8ab4f8;
|
||||
display:flex; align-items:center; gap:8px; }
|
||||
.panel h2 .icon { font-size:16px; }
|
||||
.panel svg { width:100%; display:block; }
|
||||
|
||||
.tooltip { position:fixed; background:rgba(22,33,62,0.96); border:1px solid #4fc3f7;
|
||||
border-radius:6px; padding:8px 12px; font-size:12px; pointer-events:none;
|
||||
z-index:100; backdrop-filter:blur(8px); max-width:300px;
|
||||
box-shadow:0 4px 20px rgba(0,0,0,0.4); display:none; }
|
||||
.tooltip .tt-label { font-weight:600; color:#4fc3f7; }
|
||||
.tooltip .tt-val { color:#e0e0e0; }
|
||||
|
||||
svg text { user-select:none; }
|
||||
|
||||
/* Metrics panel */
|
||||
.metrics-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px 20px; }
|
||||
.metric-item { display:flex; justify-content:space-between; font-size:12px;
|
||||
padding:4px 8px; border-radius:4px; }
|
||||
.metric-item:hover { background:rgba(79,195,247,0.08); }
|
||||
.metric-label { opacity:0.7; }
|
||||
.metric-value { font-weight:600; font-family:'SF Mono',monospace; }
|
||||
.cci-box { grid-column:1/-1; text-align:center; margin-top:10px; padding:14px;
|
||||
border-radius:8px; background:rgba(0,0,0,0.25); border:1px solid #333; }
|
||||
.cci-score { font-size:32px; font-weight:700; }
|
||||
.cci-label { font-size:14px; margin-top:2px; }
|
||||
.cci-desc { font-size:11px; opacity:0.6; margin-top:4px; }
|
||||
|
||||
.sub-header { font-size:11px; font-weight:600; text-transform:uppercase;
|
||||
letter-spacing:1px; opacity:0.4; margin:8px 0 4px; grid-column:1/-1; }
|
||||
|
||||
/* Heatmap */
|
||||
.hm-cell { cursor:pointer; transition:opacity 0.15s; }
|
||||
.hm-cell:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; }
|
||||
|
||||
/* Cohesion / heatmap bars */
|
||||
.fi-bar { cursor:pointer; transition:opacity 0.15s; }
|
||||
.fi-bar:hover { opacity:0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="tab-bar">
|
||||
<div class="title">swactor — dependency analysis</div>
|
||||
<button class="tab active" data-tab="spectral">Spectral Analysis</button>
|
||||
<button class="tab" data-tab="dag">Dependency DAG</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-dag">
|
||||
<div id="dag-controls">
|
||||
<button onclick="zoomIn()">+</button>
|
||||
<button onclick="zoomOut()">−</button>
|
||||
<button onclick="resetView()">fit</button>
|
||||
<span style="margin-left:8px;opacity:0.6">scroll to zoom · drag to pan · click node to focus</span>
|
||||
</div>
|
||||
<div id="dag-viewport"></div>
|
||||
<div id="dag-loading">Loading Graphviz…</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="tab-spectral">
|
||||
<div class="grid">
|
||||
<div class="panel" id="panel-structural">
|
||||
<h2><span class="icon">◉</span> Structural Properties</h2>
|
||||
<div id="structural-content"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-cohesion">
|
||||
<h2><span class="icon">▨</span> Module Cohesion</h2>
|
||||
<svg id="svg-cohesion"></svg>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-heatmap">
|
||||
<h2><span class="icon">▦</span> Module Coupling (directed edge counts)</h2>
|
||||
<svg id="svg-heatmap"></svg>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-metrics">
|
||||
<h2><span class="icon">∑</span> Complexity Metrics</h2>
|
||||
<div id="metrics-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
|
||||
<!-- ─── Script 1: synchronous — data + tab switching + spectral panels ─── -->
|
||||
<script>
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────
|
||||
const DATA = {"structural": {"avg_degree": 1.08, "max_fan_in": 2, "max_fan_in_node": "GossipState", "max_fan_out": 4, "max_fan_out_node": "GossipActor", "dag_depth": 5, "clustering_coeff": 0.1364, "avg_module_cohesion": 0.317, "avg_module_size": 3.0}, "cohesion": [{"module": "protocol", "cohesion": 0.2, "size": 5}, {"module": "trace", "cohesion": 0.25, "size": 5}, {"module": "sim", "cohesion": 0.5, "size": 2}], "coupling": {"modules": ["protocol", "trace", "report", "sim"], "matrix": [[4.0, 2.0, 0.0, 0.0], [1.0, 5.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]]}, "metrics": {"n_nodes": 12, "n_edges": 13, "n_modules": 4, "connected_components": 3, "algebraic_connectivity": 0.0, "normalized_algebraic_connectivity": 0.0, "spectral_entropy": 2.9643, "normalized_spectral_entropy": 0.9351, "edge_density": 0.0985, "cross_module_ratio": 0.2308, "spectral_radius": 2.8046, "normalized_spectral_radius": 0.255, "cci": 0.333, "cci_label": "MODERATE", "cci_color": "#ff9800", "cci_desc": "typical well-structured codebase"}, "module_colors": {"protocol": "#1565c0", "trace": "#c62828", "sim": "#7b1fa2"}};
|
||||
const { structural, cohesion, coupling, metrics, module_colors } = DATA;
|
||||
|
||||
// ─── Tab switching ─────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||
if (btn.dataset.tab === 'dag') {
|
||||
window.dispatchEvent(new Event('dag-visible'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tooltip ───────────────────────────────────────────────────────────────
|
||||
const TT = document.getElementById('tooltip');
|
||||
function showTip(evt, html) {
|
||||
TT.innerHTML = html;
|
||||
TT.style.display = 'block';
|
||||
const x = evt.clientX + 14, y = evt.clientY - 10;
|
||||
TT.style.left = Math.min(x, window.innerWidth - TT.offsetWidth - 20) + 'px';
|
||||
TT.style.top = Math.min(y, window.innerHeight - TT.offsetHeight - 20) + 'px';
|
||||
}
|
||||
function hideTip() { TT.style.display = 'none'; }
|
||||
|
||||
function modColor(mod) { return module_colors[mod] || '#9e9e9e'; }
|
||||
|
||||
// ─── Structural Properties ────────────────────────────────────────────────
|
||||
(function() {
|
||||
const c = document.getElementById('structural-content');
|
||||
const s = structural;
|
||||
c.innerHTML = `
|
||||
<div class="metrics-grid">
|
||||
<div class="sub-header">Density & Depth</div>
|
||||
<div class="metric-item"><span class="metric-label">Edges/node (avg degree)</span><span class="metric-value">${s.avg_degree}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">DAG depth</span><span class="metric-value">${s.dag_depth}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Clustering coefficient</span><span class="metric-value">${s.clustering_coeff}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Avg module size</span><span class="metric-value">${s.avg_module_size}</span></div>
|
||||
|
||||
<div class="sub-header">Dependency Hotspots</div>
|
||||
<div class="metric-item"><span class="metric-label">Max fan-in</span><span class="metric-value">${s.max_fan_in} ← ${s.max_fan_in_node}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Max fan-out</span><span class="metric-value">${s.max_fan_out} → ${s.max_fan_out_node}</span></div>
|
||||
|
||||
<div class="sub-header">Cohesion</div>
|
||||
<div class="metric-item"><span class="metric-label">Avg module cohesion</span><span class="metric-value">${s.avg_module_cohesion}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(metrics.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
|
||||
// ─── Module Cohesion ──────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const svg = document.getElementById('svg-cohesion');
|
||||
const n = cohesion.length;
|
||||
if (n === 0) return;
|
||||
const barH = Math.max(20, Math.min(36, 300/n));
|
||||
const W = 560, H = Math.max(200, n*barH + 60), M = {t:10,r:30,b:30,l:120};
|
||||
const w = W-M.l-M.r, h = H-M.t-M.b;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
const xScale = v => M.l + v * w;
|
||||
const yScale = i => M.t + (i/n) * h + barH/2;
|
||||
|
||||
// Background grid
|
||||
for (const tick of [0.25, 0.5, 0.75, 1.0]) {
|
||||
const x = xScale(tick);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:x,x2:x,y1:M.t,y2:M.t+h,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||
svg.appendChild(line);
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
txt.setAttribute('x', x); txt.setAttribute('y', H-8);
|
||||
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#666'); txt.setAttribute('font-size','10');
|
||||
txt.textContent = (tick*100).toFixed(0) + '%';
|
||||
svg.appendChild(txt);
|
||||
}
|
||||
|
||||
// Average line
|
||||
const avgX = xScale(structural.avg_module_cohesion);
|
||||
const avgLine = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:avgX,x2:avgX,y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>avgLine.setAttribute(k,v));
|
||||
svg.appendChild(avgLine);
|
||||
const avgLbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
avgLbl.setAttribute('x', avgX+4); avgLbl.setAttribute('y', M.t+10);
|
||||
avgLbl.setAttribute('fill','#ff4444'); avgLbl.setAttribute('font-size','9'); avgLbl.setAttribute('opacity','0.8');
|
||||
avgLbl.textContent = 'avg';
|
||||
svg.appendChild(avgLbl);
|
||||
|
||||
cohesion.forEach((d, i) => {
|
||||
const barW = Math.max(d.cohesion * w, 2);
|
||||
const y = yScale(i) - barH*0.35;
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||
rect.setAttribute('x', M.l); rect.setAttribute('y', y);
|
||||
rect.setAttribute('width', barW); rect.setAttribute('height', barH*0.7);
|
||||
rect.setAttribute('rx', 3);
|
||||
rect.setAttribute('fill', modColor(d.module));
|
||||
rect.setAttribute('opacity', 0.85);
|
||||
rect.classList.add('fi-bar');
|
||||
rect.addEventListener('mousemove', e => showTip(e,
|
||||
`<span class="tt-label">${d.module}</span><br>` +
|
||||
`Types: <span class="tt-val">${d.size}</span><br>` +
|
||||
`Cohesion: <span class="tt-val">${(d.cohesion*100).toFixed(1)}%</span>`
|
||||
));
|
||||
rect.addEventListener('mouseleave', hideTip);
|
||||
svg.appendChild(rect);
|
||||
|
||||
// Value label on bar
|
||||
const valTxt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
valTxt.setAttribute('x', M.l + barW + 6); valTxt.setAttribute('y', yScale(i)+4);
|
||||
valTxt.setAttribute('fill','#ccc'); valTxt.setAttribute('font-size','10'); valTxt.setAttribute('font-weight','600');
|
||||
valTxt.textContent = (d.cohesion*100).toFixed(0) + '%';
|
||||
svg.appendChild(valTxt);
|
||||
|
||||
// Module label
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
txt.setAttribute('x', M.l-8); txt.setAttribute('y', yScale(i)+4);
|
||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill', modColor(d.module));
|
||||
txt.setAttribute('font-size','11'); txt.setAttribute('font-weight','600');
|
||||
txt.textContent = `${d.module} (${d.size})`;
|
||||
svg.appendChild(txt);
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── Module Coupling Heatmap ───────────────────────────────────────────────
|
||||
(function() {
|
||||
const mods = coupling.modules;
|
||||
const mat = coupling.matrix;
|
||||
const n = mods.length;
|
||||
const svg = document.getElementById('svg-heatmap');
|
||||
const cellSz = Math.min(55, 400/n);
|
||||
const M = {t:10,r:60,b:80,l:100};
|
||||
const W = M.l + n*cellSz + M.r, H = M.t + n*cellSz + M.b;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
const maxVal = Math.max(...mat.flat(), 1);
|
||||
|
||||
// Color scale: 0=transparent dark, max=deep red
|
||||
function heatColor(v) {
|
||||
if (v === 0) return '#1a1a2e';
|
||||
const t = v / maxVal;
|
||||
const r = Math.round(40 + 215*t);
|
||||
const g = Math.round(30 + 40*(1-t));
|
||||
const b = Math.round(50*(1-t));
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Row labels
|
||||
const rl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
rl.setAttribute('x', M.l-8); rl.setAttribute('y', M.t + i*cellSz + cellSz/2 + 4);
|
||||
rl.setAttribute('text-anchor','end'); rl.setAttribute('fill', modColor(mods[i]));
|
||||
rl.setAttribute('font-size','11'); rl.setAttribute('font-weight','600');
|
||||
rl.textContent = mods[i];
|
||||
svg.appendChild(rl);
|
||||
|
||||
// Column labels
|
||||
const cl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
cl.setAttribute('x', M.l + i*cellSz + cellSz/2);
|
||||
cl.setAttribute('y', M.t + n*cellSz + 16);
|
||||
cl.setAttribute('text-anchor','end'); cl.setAttribute('fill', modColor(mods[i]));
|
||||
cl.setAttribute('font-size','11'); cl.setAttribute('font-weight','600');
|
||||
cl.setAttribute('transform', `rotate(-45, ${M.l + i*cellSz + cellSz/2}, ${M.t + n*cellSz + 16})`);
|
||||
cl.textContent = mods[i];
|
||||
svg.appendChild(cl);
|
||||
|
||||
for (let j = 0; j < n; j++) {
|
||||
const v = mat[i][j];
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||
rect.setAttribute('x', M.l + j*cellSz + 1);
|
||||
rect.setAttribute('y', M.t + i*cellSz + 1);
|
||||
rect.setAttribute('width', cellSz-2); rect.setAttribute('height', cellSz-2);
|
||||
rect.setAttribute('rx', 3);
|
||||
rect.setAttribute('fill', heatColor(v));
|
||||
rect.classList.add('hm-cell');
|
||||
rect.addEventListener('mousemove', e => showTip(e,
|
||||
`<span class="tt-label">${mods[i]} → ${mods[j]}</span><br>` +
|
||||
`Edges: <span class="tt-val">${v}</span>` +
|
||||
(i !== j ? '<br><span style="opacity:0.6">cross-module</span>' : '<br><span style="opacity:0.6">intra-module</span>')
|
||||
));
|
||||
rect.addEventListener('mouseleave', hideTip);
|
||||
svg.appendChild(rect);
|
||||
|
||||
// Cell text
|
||||
if (v > 0) {
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
txt.setAttribute('x', M.l + j*cellSz + cellSz/2);
|
||||
txt.setAttribute('y', M.t + i*cellSz + cellSz/2 + 4);
|
||||
txt.setAttribute('text-anchor','middle'); txt.setAttribute('font-size','11');
|
||||
txt.setAttribute('font-weight','700'); txt.setAttribute('pointer-events','none');
|
||||
txt.setAttribute('fill', v > maxVal*0.5 ? '#fff' : '#ccc');
|
||||
txt.textContent = v;
|
||||
svg.appendChild(txt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axis labels
|
||||
const srcL = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
srcL.setAttribute('x', 10); srcL.setAttribute('y', M.t + n*cellSz/2);
|
||||
srcL.setAttribute('text-anchor','middle'); srcL.setAttribute('fill','#666');
|
||||
srcL.setAttribute('font-size','10');
|
||||
srcL.setAttribute('transform', `rotate(-90,10,${M.t + n*cellSz/2})`);
|
||||
srcL.textContent = 'source module';
|
||||
svg.appendChild(srcL);
|
||||
})();
|
||||
|
||||
// ─── Metrics Panel ─────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const c = document.getElementById('metrics-content');
|
||||
const mm = metrics;
|
||||
c.innerHTML = `
|
||||
<div class="metrics-grid">
|
||||
<div class="sub-header">Graph</div>
|
||||
<div class="metric-item"><span class="metric-label">Nodes</span><span class="metric-value">${mm.n_nodes}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Directed edges</span><span class="metric-value">${mm.n_edges}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Modules</span><span class="metric-value">${mm.n_modules}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Components</span><span class="metric-value">${mm.connected_components}</span></div>
|
||||
|
||||
<div class="sub-header">Spectral</div>
|
||||
<div class="metric-item"><span class="metric-label">λ<sub>2</sub> (alg. connectivity)</span><span class="metric-value">${mm.algebraic_connectivity}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">λ<sub>2</sub>/n (normalized)</span><span class="metric-value">${mm.normalized_algebraic_connectivity}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Spectral entropy</span><span class="metric-value">${mm.spectral_entropy}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Norm. entropy</span><span class="metric-value">${mm.normalized_spectral_entropy}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Spectral radius</span><span class="metric-value">${mm.spectral_radius}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Norm. radius</span><span class="metric-value">${mm.normalized_spectral_radius}</span></div>
|
||||
|
||||
<div class="sub-header">Coupling</div>
|
||||
<div class="metric-item"><span class="metric-label">Edge density</span><span class="metric-value">${mm.edge_density}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(mm.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||
|
||||
<div class="cci-box">
|
||||
<div class="cci-score" style="color:${mm.cci_color}">CCI = ${mm.cci}</div>
|
||||
<div class="cci-label" style="color:${mm.cci_color}">${mm.cci_label}</div>
|
||||
<div class="cci-desc">${mm.cci_desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ─── Script 2: module — viz-js DAG rendering (async) ─────────────────── -->
|
||||
<script type="module">
|
||||
import { instance } from 'https://cdn.jsdelivr.net/npm/@viz-js/viz@3.11.0/lib/viz-standalone.mjs';
|
||||
|
||||
const DOT_SOURCE = `digraph swactor {
|
||||
rankdir=LR;
|
||||
fontname="Helvetica";
|
||||
fontsize=14;
|
||||
node [fontname="Helvetica", fontsize=11, style=filled, shape=record];
|
||||
edge [fontname="Helvetica", fontsize=9];
|
||||
label="swactor — internal dependency DAG";
|
||||
labelloc=t;
|
||||
compound=true;
|
||||
newrank=true;
|
||||
splines=ortho;
|
||||
|
||||
subgraph cluster_protocol {
|
||||
label="protocol";
|
||||
style="rounded,filled"; fillcolor="#e3f2fd"; color="#1565c0";
|
||||
VersionedValue [label="{VersionedValue|value: Vec\\<u8\\>\\nversion: u64}", fillcolor="#bbdefb"];
|
||||
GossipState [label="{GossipState|entries: HashMap\\<String, VersionedValue\\>}", fillcolor="#bbdefb"];
|
||||
GossipQueryResponse [label="{GossipQueryResponse|key: String\\nvalue: Option\\<Vec\\<u8\\>\\>\\nversion: Option\\<u64\\>}", fillcolor="#bbdefb"];
|
||||
GossipMessage [label="{«enum» GossipMessage|AddPeer (ActorAddress)\\nRemovePeer (ActorAddress)\\nSet \\{ key: String, value: Vec\\<u8\\> \\}\\nDoGossipRound\\nPush \\{ from: ActorAddress, state: GossipState \\}\\nQuery \\{ key: String, reply_to: ActorAddress \\}\\nTakeSnapshot}", fillcolor="#bbdefb"];
|
||||
GossipActor [label="{GossipActor|state: GossipState\\npeers: Vec\\<ActorAddress\\>\\ntrace: Option\\<TraceContext\\>}", fillcolor="#bbdefb"];
|
||||
}
|
||||
subgraph cluster_trace {
|
||||
label="trace";
|
||||
style="rounded,filled"; fillcolor="#fce4ec"; color="#c62828";
|
||||
TraceContext [label="{TraceContext|event_log: EventLog\\ntick_counter: TickCounter\\nname_registry: NameRegistry}", fillcolor="#ffcdd2"];
|
||||
GossipEvent [label="{GossipEvent|tick: u64\\nnode_name: String\\nnode_addr: ActorAddress\\nkind: GossipEventKind}", fillcolor="#ffcdd2"];
|
||||
GossipEventKind [label="{«enum» GossipEventKind|LocalSet \\{ key: String \\}\\nGossipRoundStarted \\{ target_name: String \\}\\nGossipRoundNoPeers\\nPushReceived \\{ from_name: String, keys_updated: usize \\}\\nQueryReceived \\{ key: String \\}\\nPeerAdded \\{ peer_name: String \\}\\nPeerRemoved \\{ peer_name: String \\}\\nStateSnapshot \\{ snapshot: NodeSnapshot \\}}", fillcolor="#ffcdd2"];
|
||||
NodeSnapshot [label="{NodeSnapshot|entries: HashMap\\<String, VersionedValue\\>\\npeer_count: usize}", fillcolor="#ffcdd2"];
|
||||
SimulationTrace [label="{SimulationTrace|name: String\\nnode_names: Vec\\<String\\>\\nnode_addrs: Vec\\<ActorAddress\\>\\ntopology_edges: Vec\\<(String, String)\\>\\nevents: Vec\\<GossipEvent\\>\\nsnapshots_per_round: Vec\\<Vec\\<(String, NodeSnapshot)\\>\\>\\nnum_rounds: usize\\ntotal_keys: usize}", fillcolor="#ffcdd2"];
|
||||
}
|
||||
subgraph cluster_report {
|
||||
label="report";
|
||||
style="rounded,filled"; fillcolor="#fff3e0"; color="#e65100";
|
||||
}
|
||||
subgraph cluster_sim {
|
||||
label="sim";
|
||||
style="rounded,filled"; fillcolor="#f3e5f5"; color="#7b1fa2";
|
||||
Topology [label="{«enum» Topology|Ring\\nStar\\nFullMesh\\nChain\\nPartitioned}", fillcolor="#e1bee7"];
|
||||
SimConfig [label="{SimConfig|name: String\\ntopology: Topology\\nnum_nodes: usize\\ninitial_data: Vec\\<(String, Vec\\<u8\\>)\\>\\nnum_rounds: usize\\nticks_per_round: usize\\nheal_after_round: Option\\<usize\\>}", fillcolor="#e1bee7"];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// INTRA-MODULE EDGES (within same cluster)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
GossipState -> VersionedValue [label="entries", style=dashed, color="#1565c0", penwidth=1];
|
||||
GossipMessage -> GossipState [label="Push", style=dashed, color="#1565c0", penwidth=1];
|
||||
GossipActor -> GossipState [label="state", style=dashed, color="#1565c0", penwidth=1];
|
||||
GossipActor -> GossipMessage [label="handle() param", style=dashed, color="#1565c0", penwidth=1];
|
||||
GossipEvent -> GossipEventKind [label="kind", style=dashed, color="#c62828", penwidth=1];
|
||||
GossipEventKind -> NodeSnapshot [label="StateSnapshot", style=dashed, color="#c62828", penwidth=1];
|
||||
SimulationTrace -> GossipEvent [label="events", style=dashed, color="#c62828", penwidth=1];
|
||||
SimulationTrace -> NodeSnapshot [label="snapshots_per_round", style=dashed, color="#c62828", penwidth=1];
|
||||
TraceContext -> GossipEvent [label="record_event() param", style=dashed, color="#c62828", penwidth=1];
|
||||
SimConfig -> Topology [label="topology", style=dashed, color="#7b1fa2", penwidth=1];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// CROSS-MODULE EDGES (the real dependency DAG)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// --- protocol depends on trace ---
|
||||
GossipActor -> TraceContext [label="trace", style=solid, color="#1565c0", penwidth=1.5];
|
||||
GossipActor -> GossipEventKind [label="record() param", style=solid, color="#1565c0", penwidth=1.5];
|
||||
|
||||
// --- trace depends on protocol ---
|
||||
NodeSnapshot -> VersionedValue [label="entries", style=solid, color="#c62828", penwidth=1.5];
|
||||
}
|
||||
`;
|
||||
|
||||
const viz = await instance();
|
||||
const svg = viz.renderSVGElement(DOT_SOURCE);
|
||||
document.getElementById('dag-loading').remove();
|
||||
|
||||
const vp = document.getElementById('dag-viewport');
|
||||
vp.appendChild(svg);
|
||||
|
||||
// ─── Dark-mode SVG recoloring ──────────────────────────────────────────────
|
||||
svg.querySelectorAll('polygon[fill="white"]').forEach(el => el.setAttribute('fill','#1a1a2e'));
|
||||
svg.querySelectorAll('.graph > text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
||||
svg.querySelectorAll('.cluster > text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||
svg.querySelectorAll('.edge text').forEach(el => el.setAttribute('fill','#ffb74d'));
|
||||
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||
|
||||
// ─── Click-to-focus ────────────────────────────────────────────────────────
|
||||
const edges = svg.querySelectorAll('.edge');
|
||||
const nodes = svg.querySelectorAll('.node');
|
||||
const clusterChrome = [];
|
||||
svg.querySelectorAll('.cluster').forEach(c => {
|
||||
c.querySelectorAll(':scope > path, :scope > polygon, :scope > text').forEach(el => clusterChrome.push(el));
|
||||
});
|
||||
|
||||
const nodeByTitle = new Map();
|
||||
nodes.forEach(n => {
|
||||
const t = n.querySelector('title');
|
||||
if (t) nodeByTitle.set(t.textContent.trim(), n);
|
||||
});
|
||||
|
||||
const nodeToClusterEls = new Map();
|
||||
svg.querySelectorAll('.cluster').forEach(cluster => {
|
||||
const chrome = [...cluster.querySelectorAll(':scope > path, :scope > polygon, :scope > text')];
|
||||
cluster.querySelectorAll('.node title').forEach(t => {
|
||||
nodeToClusterEls.set(t.textContent.trim(), chrome);
|
||||
});
|
||||
});
|
||||
|
||||
const adj = new Map();
|
||||
edges.forEach(edge => {
|
||||
const t = edge.querySelector('title');
|
||||
if (!t) return;
|
||||
const parts = t.textContent.trim().split('->').map(s => s.trim());
|
||||
if (parts.length !== 2) return;
|
||||
const [src, dst] = parts;
|
||||
if (!adj.has(src)) adj.set(src, { edges: [], neighbors: new Set() });
|
||||
if (!adj.has(dst)) adj.set(dst, { edges: [], neighbors: new Set() });
|
||||
adj.get(src).edges.push(edge);
|
||||
adj.get(src).neighbors.add(dst);
|
||||
adj.get(dst).edges.push(edge);
|
||||
adj.get(dst).neighbors.add(src);
|
||||
});
|
||||
|
||||
const DIM = 0.08;
|
||||
let focused = null;
|
||||
|
||||
function clearFocus() {
|
||||
focused = null;
|
||||
nodes.forEach(n => n.style.opacity = '');
|
||||
edges.forEach(e => e.style.opacity = '');
|
||||
clusterChrome.forEach(el => el.style.opacity = '');
|
||||
}
|
||||
|
||||
function focusNode(title) {
|
||||
if (focused === title) { clearFocus(); return; }
|
||||
focused = title;
|
||||
const info = adj.get(title) || { edges: [], neighbors: new Set() };
|
||||
const connected = new Set([title, ...info.neighbors]);
|
||||
|
||||
nodes.forEach(n => n.style.opacity = DIM);
|
||||
edges.forEach(e => e.style.opacity = DIM);
|
||||
clusterChrome.forEach(el => el.style.opacity = DIM);
|
||||
|
||||
connected.forEach(name => {
|
||||
const el = nodeByTitle.get(name);
|
||||
if (el) el.style.opacity = 1;
|
||||
});
|
||||
|
||||
info.edges.forEach(e => e.style.opacity = 1);
|
||||
|
||||
const seen = new Set();
|
||||
connected.forEach(name => {
|
||||
const chrome = nodeToClusterEls.get(name);
|
||||
if (chrome) chrome.forEach(el => {
|
||||
if (!seen.has(el)) { seen.add(el); el.style.opacity = 1; }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
nodes.forEach(node => {
|
||||
node.style.cursor = 'pointer';
|
||||
node.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const t = node.querySelector('title');
|
||||
if (t) focusNode(t.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Pan & zoom ────────────────────────────────────────────────────────────
|
||||
let scale = 1, tx = 0, ty = 0, dragging = false, didDrag = false, sx = 0, sy = 0;
|
||||
function applyTransform() { svg.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`; svg.style.transformOrigin = '0 0'; }
|
||||
|
||||
window.resetView = function() {
|
||||
const vw = vp.clientWidth, vh = vp.clientHeight;
|
||||
const bb = svg.getBBox();
|
||||
scale = Math.min(vw / bb.width, vh / bb.height) * 0.92;
|
||||
tx = (vw - bb.width * scale) / 2;
|
||||
ty = (vh - bb.height * scale) / 2;
|
||||
applyTransform();
|
||||
};
|
||||
let dagFitted = false;
|
||||
window.addEventListener('dag-visible', () => {
|
||||
if (!dagFitted) { dagFitted = true; requestAnimationFrame(resetView); }
|
||||
});
|
||||
|
||||
window.zoomIn = function() { scale *= 1.3; applyTransform(); };
|
||||
window.zoomOut = function() { scale *= 0.7; applyTransform(); };
|
||||
|
||||
vp.addEventListener('wheel', e => { e.preventDefault(); const f = e.deltaY < 0 ? 1.12 : 0.89; const rect = vp.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; tx = mx - f * (mx - tx); ty = my - f * (my - ty); scale *= f; applyTransform(); }, { passive:false });
|
||||
vp.addEventListener('pointerdown', e => { dragging=true; didDrag=false; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); });
|
||||
vp.addEventListener('pointermove', e => { if(!dragging) return; didDrag=true; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); });
|
||||
vp.addEventListener('pointerup', () => dragging=false);
|
||||
vp.addEventListener('click', e => { if (!didDrag && !e.target.closest('.node')) clearFocus(); });
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"graph": {
|
||||
"n_nodes": 12,
|
||||
"n_edges": 13,
|
||||
"n_modules": 4,
|
||||
"connected_components": 3,
|
||||
"modules": [
|
||||
"protocol",
|
||||
"trace",
|
||||
"report",
|
||||
"sim"
|
||||
]
|
||||
},
|
||||
"structural": {
|
||||
"avg_degree": 1.0833333333333333,
|
||||
"max_fan_in": {
|
||||
"count": 2,
|
||||
"node": "GossipState"
|
||||
},
|
||||
"max_fan_out": {
|
||||
"count": 4,
|
||||
"node": "GossipActor"
|
||||
},
|
||||
"dag_depth": 5,
|
||||
"clustering_coefficient": 0.13636363636363635,
|
||||
"avg_module_size": 3.0
|
||||
},
|
||||
"module_coupling": {
|
||||
"module_names": [
|
||||
"protocol",
|
||||
"trace",
|
||||
"report",
|
||||
"sim"
|
||||
],
|
||||
"coupling_matrix": [
|
||||
[
|
||||
4.0,
|
||||
2.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
1.0,
|
||||
5.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"cross_module_edges": 3,
|
||||
"total_edges": 13
|
||||
},
|
||||
"module_cohesion": {
|
||||
"protocol": 0.2,
|
||||
"trace": 0.25,
|
||||
"report": null,
|
||||
"sim": 0.5
|
||||
},
|
||||
"metrics": {
|
||||
"algebraic_connectivity": 0.0,
|
||||
"spectral_entropy": 2.9642609519436975,
|
||||
"edge_density": 0.09848484848484848,
|
||||
"cross_module_ratio": 0.23076923076923078,
|
||||
"spectral_radius": 2.8045993435494494,
|
||||
"avg_module_cohesion": 0.31666666666666665,
|
||||
"cci": 0.3329511639209368
|
||||
}
|
||||
}
|
||||
56
crates/swactor-gossip/docs/connectome/connectome_report.txt
Normal file
56
crates/swactor-gossip/docs/connectome/connectome_report.txt
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
========================================================================
|
||||
SPECTRAL ANALYSIS REPORT — Dependency DAG
|
||||
========================================================================
|
||||
|
||||
GRAPH SUMMARY
|
||||
----------------------------------------
|
||||
Nodes: 12
|
||||
Directed edges: 13
|
||||
Modules: 4
|
||||
Connected components: 3
|
||||
Modules: protocol, trace, report, sim
|
||||
|
||||
STRUCTURAL PROPERTIES
|
||||
----------------------------------------
|
||||
Edges/node (avg degree): 1.08
|
||||
Max fan-in: 2 (GossipState)
|
||||
Max fan-out: 4 (GossipActor)
|
||||
DAG depth: 5
|
||||
Clustering coefficient: 0.1364
|
||||
|
||||
MODULE COHESION
|
||||
----------------------------------------
|
||||
Module Size Cohesion
|
||||
protocol 5 0.200
|
||||
trace 5 0.250
|
||||
report 0 —
|
||||
sim 2 0.500
|
||||
────────────────────────────────
|
||||
Average cohesion: 0.317
|
||||
Avg module size: 3.0
|
||||
|
||||
MODULE COUPLING MATRIX (directed edge counts)
|
||||
----------------------------------------
|
||||
protocol trace report sim
|
||||
protocol 4 2 0 0
|
||||
trace 1 5 0 0
|
||||
report 0 0 0 0
|
||||
sim 0 0 0 1
|
||||
|
||||
Cross-module edges: 3 / 13 (23.1%)
|
||||
|
||||
CONNECTOME COMPLEXITY INDEX (CCI)
|
||||
----------------------------------------
|
||||
Sub-metric Raw Normalized Weight Contrib
|
||||
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||
Algebraic connectivity (lambda_2/n) 0.0000 0.0000 0.25 0.0000
|
||||
Spectral entropy (H/log2(k)) 2.9643 0.9351 0.25 0.2338
|
||||
Edge density (|E|/n(n-1)) 0.0985 0.0985 0.15 0.0148
|
||||
Cross-module coupling ratio 0.2308 0.2308 0.20 0.0462
|
||||
Spectral radius (rho/(n-1)) 2.8046 0.2550 0.15 0.0382
|
||||
──────────────────────────────────────── ────────── ────────── ──────── ────────
|
||||
CCI (weighted sum) 1.00 0.3330
|
||||
|
||||
Interpretation: MODERATE complexity — typical well-structured codebase
|
||||
|
||||
========================================================================
|
||||
73
crates/swactor-gossip/examples/gossip_sim.rs
Normal file
73
crates/swactor-gossip/examples/gossip_sim.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::fs;
|
||||
|
||||
use swactor_gossip::report::generate_html_report;
|
||||
use swactor_gossip::sim::{run_simulation, SimConfig, Topology};
|
||||
|
||||
fn main() {
|
||||
let scenarios = vec![
|
||||
SimConfig {
|
||||
name: "Ring (5 nodes)".into(),
|
||||
topology: Topology::Ring,
|
||||
num_nodes: 5,
|
||||
initial_data: test_data(3),
|
||||
num_rounds: 15,
|
||||
ticks_per_round: 4,
|
||||
heal_after_round: None,
|
||||
},
|
||||
SimConfig {
|
||||
name: "Star (7 nodes)".into(),
|
||||
topology: Topology::Star,
|
||||
num_nodes: 7,
|
||||
initial_data: test_data(3),
|
||||
num_rounds: 10,
|
||||
ticks_per_round: 4,
|
||||
heal_after_round: None,
|
||||
},
|
||||
SimConfig {
|
||||
name: "Full Mesh (5 nodes)".into(),
|
||||
topology: Topology::FullMesh,
|
||||
num_nodes: 5,
|
||||
initial_data: test_data(3),
|
||||
num_rounds: 8,
|
||||
ticks_per_round: 4,
|
||||
heal_after_round: None,
|
||||
},
|
||||
SimConfig {
|
||||
name: "Chain (8 nodes)".into(),
|
||||
topology: Topology::Chain,
|
||||
num_nodes: 8,
|
||||
initial_data: test_data(3),
|
||||
num_rounds: 20,
|
||||
ticks_per_round: 4,
|
||||
heal_after_round: None,
|
||||
},
|
||||
SimConfig {
|
||||
name: "Partition & Heal (6 nodes)".into(),
|
||||
topology: Topology::Partitioned,
|
||||
num_nodes: 6,
|
||||
initial_data: test_data(3),
|
||||
num_rounds: 20,
|
||||
ticks_per_round: 4,
|
||||
heal_after_round: Some(10),
|
||||
},
|
||||
];
|
||||
|
||||
for config in scenarios {
|
||||
let filename = format!(
|
||||
"gossip_report_{}.html",
|
||||
config.name.to_lowercase().replace(' ', "_").replace(['(', ')'], "")
|
||||
);
|
||||
println!("Running scenario: {} ...", config.name);
|
||||
let trace = run_simulation(config);
|
||||
let html = generate_html_report(&trace);
|
||||
fs::write(&filename, &html).expect("failed to write report");
|
||||
println!(" -> wrote {filename} ({} bytes)", html.len());
|
||||
}
|
||||
println!("Done.");
|
||||
}
|
||||
|
||||
fn test_data(n: usize) -> Vec<(String, Vec<u8>)> {
|
||||
(0..n)
|
||||
.map(|i| (format!("key-{i}"), format!("value-{i}").into_bytes()))
|
||||
.collect()
|
||||
}
|
||||
7
crates/swactor-gossip/src/lib.rs
Normal file
7
crates/swactor-gossip/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod protocol;
|
||||
pub mod trace;
|
||||
|
||||
pub mod report;
|
||||
pub mod sim;
|
||||
|
||||
pub use protocol::{GossipActor, GossipMessage, GossipQueryResponse};
|
||||
277
crates/swactor-gossip/src/protocol.rs
Normal file
277
crates/swactor-gossip/src/protocol.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorInterface, Ctx};
|
||||
|
||||
use crate::trace::{GossipEvent, GossipEventKind, NodeSnapshot, TraceContext};
|
||||
|
||||
// ── VersionedValue ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VersionedValue {
|
||||
pub value: Vec<u8>,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
// ── GossipState ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GossipState {
|
||||
entries: HashMap<String, VersionedValue>,
|
||||
}
|
||||
|
||||
impl GossipState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Insert or update a key. Auto-increments the version for that key.
|
||||
/// Returns the new version number.
|
||||
pub fn set(&mut self, key: String, value: Vec<u8>) -> u64 {
|
||||
let new_version = self
|
||||
.entries
|
||||
.get(&key)
|
||||
.map_or(1, |existing| existing.version + 1);
|
||||
self.entries.insert(
|
||||
key,
|
||||
VersionedValue {
|
||||
value,
|
||||
version: new_version,
|
||||
},
|
||||
);
|
||||
new_version
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&VersionedValue> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &HashMap<String, VersionedValue> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Merge a remote state into this one. For each key, keep the entry
|
||||
/// with the higher version (last-writer-wins). Returns the number of
|
||||
/// entries that were updated.
|
||||
pub fn merge(&mut self, remote: &GossipState) -> usize {
|
||||
let mut updated = 0;
|
||||
for (key, remote_val) in &remote.entries {
|
||||
let dominated = match self.entries.get(key) {
|
||||
Some(local_val) => remote_val.version > local_val.version,
|
||||
None => true,
|
||||
};
|
||||
if dominated {
|
||||
self.entries.insert(key.clone(), remote_val.clone());
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
// ── GossipQueryResponse ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GossipQueryResponse {
|
||||
pub key: String,
|
||||
pub value: Option<Vec<u8>>,
|
||||
pub version: Option<u64>,
|
||||
}
|
||||
|
||||
// ── GossipMessage ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GossipMessage {
|
||||
/// Register a peer to gossip with.
|
||||
AddPeer(ActorAddress),
|
||||
/// Remove a peer from the gossip set.
|
||||
RemovePeer(ActorAddress),
|
||||
/// Set a key-value pair in this node's local state.
|
||||
Set { key: String, value: Vec<u8> },
|
||||
/// Trigger a gossip round: pick a random peer and push our full state.
|
||||
DoGossipRound,
|
||||
/// Incoming state push from a peer.
|
||||
Push {
|
||||
from: ActorAddress,
|
||||
state: GossipState,
|
||||
},
|
||||
/// Query the current value for a key; response sent to `reply_to`.
|
||||
Query {
|
||||
key: String,
|
||||
reply_to: ActorAddress,
|
||||
},
|
||||
/// Ask the actor to dump its current state into the event log (tracing only).
|
||||
TakeSnapshot,
|
||||
}
|
||||
|
||||
// ── GossipActor ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct GossipActor {
|
||||
state: GossipState,
|
||||
peers: Vec<ActorAddress>,
|
||||
trace: Option<TraceContext>,
|
||||
}
|
||||
|
||||
impl GossipActor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: GossipState::new(),
|
||||
peers: Vec::new(),
|
||||
trace: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a traced actor that records events into the shared log.
|
||||
pub fn traced(
|
||||
log: crate::trace::EventLog,
|
||||
tick: crate::trace::TickCounter,
|
||||
names: crate::trace::NameRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: GossipState::new(),
|
||||
peers: Vec::new(),
|
||||
trace: Some(TraceContext {
|
||||
event_log: log,
|
||||
tick_counter: tick,
|
||||
name_registry: names,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_random_peer(&self) -> Option<ActorAddress> {
|
||||
if self.peers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut buf = [0u8; 8];
|
||||
getrandom::getrandom(&mut buf).unwrap();
|
||||
let idx = usize::from_ne_bytes(buf) % self.peers.len();
|
||||
Some(self.peers[idx])
|
||||
}
|
||||
|
||||
fn record(&self, addr: ActorAddress, kind: GossipEventKind) {
|
||||
if let Some(trace) = &self.trace {
|
||||
let event = GossipEvent {
|
||||
tick: trace.current_tick(),
|
||||
node_name: trace.resolve_name(addr),
|
||||
node_addr: addr,
|
||||
kind,
|
||||
};
|
||||
trace.record_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GossipActor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActorInterface for GossipActor {
|
||||
type Incoming = GossipMessage;
|
||||
type Response = ();
|
||||
|
||||
fn handle(&mut self, ctx: &Ctx, msg: GossipMessage) {
|
||||
let self_addr = ctx.self_addr();
|
||||
match msg {
|
||||
GossipMessage::AddPeer(addr) => {
|
||||
if !self.peers.contains(&addr) {
|
||||
self.peers.push(addr);
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::PeerAdded {
|
||||
peer_name: self
|
||||
.trace
|
||||
.as_ref()
|
||||
.map(|t| t.resolve_name(addr))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
GossipMessage::RemovePeer(addr) => {
|
||||
let before = self.peers.len();
|
||||
self.peers.retain(|a| *a != addr);
|
||||
if self.peers.len() < before {
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::PeerRemoved {
|
||||
peer_name: self
|
||||
.trace
|
||||
.as_ref()
|
||||
.map(|t| t.resolve_name(addr))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
GossipMessage::Set { key, value } => {
|
||||
self.state.set(key.clone(), value);
|
||||
self.record(self_addr, GossipEventKind::LocalSet { key });
|
||||
}
|
||||
GossipMessage::DoGossipRound => {
|
||||
if let Some(peer) = self.pick_random_peer() {
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::GossipRoundStarted {
|
||||
target_name: self
|
||||
.trace
|
||||
.as_ref()
|
||||
.map(|t| t.resolve_name(peer))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
);
|
||||
let _ = ctx.send(
|
||||
peer,
|
||||
GossipMessage::Push {
|
||||
from: self_addr,
|
||||
state: self.state.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
self.record(self_addr, GossipEventKind::GossipRoundNoPeers);
|
||||
}
|
||||
}
|
||||
GossipMessage::Push {
|
||||
from,
|
||||
state: remote,
|
||||
} => {
|
||||
let keys_updated = self.state.merge(&remote);
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::PushReceived {
|
||||
from_name: self
|
||||
.trace
|
||||
.as_ref()
|
||||
.map(|t| t.resolve_name(from))
|
||||
.unwrap_or_default(),
|
||||
keys_updated,
|
||||
},
|
||||
);
|
||||
}
|
||||
GossipMessage::TakeSnapshot => {
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::StateSnapshot {
|
||||
snapshot: NodeSnapshot {
|
||||
entries: self.state.entries().clone(),
|
||||
peer_count: self.peers.len(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
GossipMessage::Query { key, reply_to } => {
|
||||
self.record(
|
||||
self_addr,
|
||||
GossipEventKind::QueryReceived { key: key.clone() },
|
||||
);
|
||||
let entry = self.state.get(&key);
|
||||
let resp = GossipQueryResponse {
|
||||
key,
|
||||
value: entry.map(|e| e.value.clone()),
|
||||
version: entry.map(|e| e.version),
|
||||
};
|
||||
let _ = ctx.send(reply_to, resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
550
crates/swactor-gossip/src/report.rs
Normal file
550
crates/swactor-gossip/src/report.rs
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
use std::collections::HashMap;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::trace::{GossipEventKind, SimulationTrace};
|
||||
|
||||
/// Generate a self-contained HTML report from a simulation trace.
|
||||
pub fn generate_html_report(trace: &SimulationTrace) -> String {
|
||||
let mut html = String::with_capacity(32_000);
|
||||
|
||||
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
|
||||
html.push_str(&format!(
|
||||
"<title>Gossip Simulation: {}</title>\n",
|
||||
escape_html(&trace.name)
|
||||
));
|
||||
html.push_str("<style>\n");
|
||||
html.push_str(CSS);
|
||||
html.push_str("</style>\n</head>\n<body>\n");
|
||||
|
||||
html.push_str(&format!(
|
||||
"<h1>Gossip Simulation: {}</h1>\n",
|
||||
escape_html(&trace.name)
|
||||
));
|
||||
|
||||
// Summary metrics
|
||||
render_summary(&mut html, trace);
|
||||
|
||||
// Network topology
|
||||
render_topology_svg(&mut html, trace);
|
||||
|
||||
// Propagation heatmap
|
||||
render_heatmap_svg(&mut html, trace);
|
||||
|
||||
// Convergence curve
|
||||
render_convergence_svg(&mut html, trace);
|
||||
|
||||
// Message flow timeline
|
||||
render_message_flow_svg(&mut html, trace);
|
||||
|
||||
// Event log table
|
||||
render_event_table(&mut html, trace);
|
||||
|
||||
html.push_str("</body>\n</html>\n");
|
||||
html
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CSS: &str = r#"
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 1200px; margin: 0 auto; padding: 20px;
|
||||
background: #fafafa; color: #222;
|
||||
}
|
||||
h1 { border-bottom: 3px solid #333; padding-bottom: 8px; }
|
||||
h2 { margin-top: 32px; color: #444; }
|
||||
.metrics { display: flex; flex-wrap: wrap; gap: 16px; margin: 16px 0; }
|
||||
.metric {
|
||||
background: #fff; border: 1px solid #ddd; border-radius: 8px;
|
||||
padding: 12px 20px; min-width: 140px;
|
||||
}
|
||||
.metric .label { font-size: 0.85em; color: #666; }
|
||||
.metric .value { font-size: 1.5em; font-weight: bold; }
|
||||
svg { display: block; margin: 12px 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 0.85em; }
|
||||
th { background: #f0f0f0; }
|
||||
tr:nth-child(even) { background: #fafafa; }
|
||||
.capped { color: #999; font-style: italic; margin: 4px 0; }
|
||||
"#;
|
||||
|
||||
// ── Summary metrics ──────────────────────────────────────────────────────
|
||||
|
||||
fn render_summary(html: &mut String, trace: &SimulationTrace) {
|
||||
let num_nodes = trace.node_names.len();
|
||||
let total_pushes = trace
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, GossipEventKind::GossipRoundStarted { .. }))
|
||||
.count();
|
||||
let redundant_pushes = trace
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, GossipEventKind::PushReceived { keys_updated: 0, .. }))
|
||||
.count();
|
||||
let convergence_round = find_convergence_round(trace);
|
||||
|
||||
html.push_str("<h2>Summary</h2>\n<div class=\"metrics\">\n");
|
||||
metric(html, "Nodes", &num_nodes.to_string());
|
||||
metric(html, "Keys", &trace.total_keys.to_string());
|
||||
metric(html, "Rounds", &trace.num_rounds.to_string());
|
||||
metric(html, "Pushes", &total_pushes.to_string());
|
||||
metric(html, "Redundant", &redundant_pushes.to_string());
|
||||
metric(
|
||||
html,
|
||||
"Converged at",
|
||||
&convergence_round
|
||||
.map(|r| format!("round {r}"))
|
||||
.unwrap_or_else(|| "never".into()),
|
||||
);
|
||||
if total_pushes > 0 {
|
||||
let efficiency = 100.0 * (1.0 - redundant_pushes as f64 / total_pushes as f64);
|
||||
metric(html, "Efficiency", &format!("{efficiency:.0}%"));
|
||||
}
|
||||
html.push_str("</div>\n");
|
||||
}
|
||||
|
||||
fn metric(html: &mut String, label: &str, value: &str) {
|
||||
html.push_str(&format!(
|
||||
"<div class=\"metric\"><div class=\"label\">{label}</div><div class=\"value\">{value}</div></div>\n"
|
||||
));
|
||||
}
|
||||
|
||||
fn find_convergence_round(trace: &SimulationTrace) -> Option<usize> {
|
||||
if trace.total_keys == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
for (round_idx, snapshots) in trace.snapshots_per_round.iter().enumerate() {
|
||||
let all_converged = snapshots
|
||||
.iter()
|
||||
.all(|(_, snap)| snap.entries.len() >= trace.total_keys);
|
||||
if all_converged {
|
||||
return Some(round_idx + 1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Topology SVG ─────────────────────────────────────────────────────────
|
||||
|
||||
fn render_topology_svg(html: &mut String, trace: &SimulationTrace) {
|
||||
html.push_str("<h2>Network Topology</h2>\n");
|
||||
|
||||
let n = trace.node_names.len();
|
||||
let size = 400.0_f64;
|
||||
let cx = size / 2.0;
|
||||
let cy = size / 2.0;
|
||||
let radius = size / 2.0 - 50.0;
|
||||
|
||||
// Compute node positions in circular layout.
|
||||
let positions: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let angle = 2.0 * PI * (i as f64) / (n as f64) - PI / 2.0;
|
||||
(cx + radius * angle.cos(), cy + radius * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let name_to_idx: HashMap<&str, usize> = trace
|
||||
.node_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.as_str(), i))
|
||||
.collect();
|
||||
|
||||
html.push_str(&format!(
|
||||
"<svg width=\"{size}\" height=\"{size}\" viewBox=\"0 0 {size} {size}\">\n"
|
||||
));
|
||||
html.push_str("<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6\" fill=\"#888\"/></marker></defs>\n");
|
||||
|
||||
// Draw edges.
|
||||
for (from_name, to_name) in &trace.topology_edges {
|
||||
if let (Some(&fi), Some(&ti)) = (name_to_idx.get(from_name.as_str()), name_to_idx.get(to_name.as_str())) {
|
||||
let (x1, y1) = positions[fi];
|
||||
let (x2, y2) = positions[ti];
|
||||
// Shorten line to not overlap circle.
|
||||
let dx = x2 - x1;
|
||||
let dy = y2 - y1;
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len > 0.0 {
|
||||
let nx = dx / len;
|
||||
let ny = dy / len;
|
||||
let sx = x1 + nx * 18.0;
|
||||
let sy = y1 + ny * 18.0;
|
||||
let ex = x2 - nx * 18.0;
|
||||
let ey = y2 - ny * 18.0;
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{sx:.1}\" y1=\"{sy:.1}\" x2=\"{ex:.1}\" y2=\"{ey:.1}\" stroke=\"#aaa\" stroke-width=\"1\" marker-end=\"url(#arrow)\"/>\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes.
|
||||
for (i, name) in trace.node_names.iter().enumerate() {
|
||||
let (x, y) = positions[i];
|
||||
html.push_str(&format!(
|
||||
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"16\" fill=\"#4a90d9\" stroke=\"#2a5a9d\" stroke-width=\"2\"/>\n"
|
||||
));
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" fill=\"#fff\" font-size=\"10\" font-weight=\"bold\">{name}</text>\n",
|
||||
ty = y + 4.0,
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</svg>\n");
|
||||
}
|
||||
|
||||
// ── Propagation heatmap ──────────────────────────────────────────────────
|
||||
|
||||
fn render_heatmap_svg(html: &mut String, trace: &SimulationTrace) {
|
||||
html.push_str("<h2>Propagation Heatmap</h2>\n");
|
||||
html.push_str("<p>Rows = nodes, columns = rounds. Color intensity = fraction of total keys held.</p>\n");
|
||||
|
||||
let n = trace.node_names.len();
|
||||
let rounds = trace.snapshots_per_round.len();
|
||||
if rounds == 0 || n == 0 {
|
||||
html.push_str("<p>No data.</p>\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let cell_w = 36.0_f64;
|
||||
let cell_h = 28.0_f64;
|
||||
let label_w = 80.0_f64;
|
||||
let header_h = 28.0_f64;
|
||||
let w = label_w + cell_w * rounds as f64 + 10.0;
|
||||
let h = header_h + cell_h * n as f64 + 10.0;
|
||||
|
||||
html.push_str(&format!(
|
||||
"<svg width=\"{w:.0}\" height=\"{h:.0}\" viewBox=\"0 0 {w:.0} {h:.0}\">\n"
|
||||
));
|
||||
|
||||
// Column headers.
|
||||
for r in 0..rounds {
|
||||
let x = label_w + r as f64 * cell_w + cell_w / 2.0;
|
||||
let ty = header_h - 6.0;
|
||||
let label = r + 1;
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{ty}\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">R{label}</text>\n"
|
||||
));
|
||||
}
|
||||
|
||||
// Build a name→row index for stable ordering.
|
||||
let name_to_row: HashMap<&str, usize> = trace
|
||||
.node_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.as_str(), i))
|
||||
.collect();
|
||||
|
||||
for (r, round_snaps) in trace.snapshots_per_round.iter().enumerate() {
|
||||
for (name, snap) in round_snaps {
|
||||
if let Some(&row) = name_to_row.get(name.as_str()) {
|
||||
let frac = if trace.total_keys > 0 {
|
||||
snap.entries.len() as f64 / trace.total_keys as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = label_w + r as f64 * cell_w;
|
||||
let y = header_h + row as f64 * cell_h;
|
||||
let color = heatmap_color(frac);
|
||||
html.push_str(&format!(
|
||||
"<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{cell_w}\" height=\"{cell_h}\" fill=\"{color}\" stroke=\"#fff\" stroke-width=\"1\"/>\n"
|
||||
));
|
||||
// Show count inside cell.
|
||||
let text_color = if frac > 0.5 { "#fff" } else { "#333" };
|
||||
let tx = x + cell_w / 2.0;
|
||||
let ty = y + cell_h / 2.0 + 3.0;
|
||||
let count = snap.entries.len();
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{tx:.1}\" y=\"{ty:.1}\" text-anchor=\"middle\" font-size=\"10\" fill=\"{text_color}\">{count}</text>\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row labels.
|
||||
for (i, name) in trace.node_names.iter().enumerate() {
|
||||
let y = header_h + i as f64 * cell_h + cell_h / 2.0 + 4.0;
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{x}\" y=\"{y:.1}\" font-size=\"11\" fill=\"#333\">{name}</text>\n",
|
||||
x = 4.0,
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</svg>\n");
|
||||
}
|
||||
|
||||
fn heatmap_color(frac: f64) -> String {
|
||||
// Interpolate from light (#e8f4e8) to deep green (#1a7a1a).
|
||||
let f = frac.clamp(0.0, 1.0);
|
||||
let r = (232.0 + f * (26.0 - 232.0)) as u8;
|
||||
let g = (244.0 + f * (122.0 - 244.0)) as u8;
|
||||
let b = (232.0 + f * (26.0 - 232.0)) as u8;
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
// ── Convergence curve ────────────────────────────────────────────────────
|
||||
|
||||
fn render_convergence_svg(html: &mut String, trace: &SimulationTrace) {
|
||||
html.push_str("<h2>Convergence Curve</h2>\n");
|
||||
html.push_str("<p>Percentage of nodes that hold all keys vs. round number.</p>\n");
|
||||
|
||||
let rounds = trace.snapshots_per_round.len();
|
||||
if rounds == 0 {
|
||||
html.push_str("<p>No data.</p>\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let chart_w = 600.0_f64;
|
||||
let chart_h = 300.0_f64;
|
||||
let margin_l = 50.0_f64;
|
||||
let margin_b = 40.0_f64;
|
||||
let margin_t = 20.0_f64;
|
||||
let margin_r = 20.0_f64;
|
||||
let w = chart_w + margin_l + margin_r;
|
||||
let h = chart_h + margin_t + margin_b;
|
||||
|
||||
html.push_str(&format!(
|
||||
"<svg width=\"{w:.0}\" height=\"{h:.0}\" viewBox=\"0 0 {w:.0} {h:.0}\">\n"
|
||||
));
|
||||
|
||||
// Axes.
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{margin_l}\" y1=\"{margin_t}\" x2=\"{margin_l}\" y2=\"{}\" stroke=\"#333\" stroke-width=\"1\"/>\n",
|
||||
margin_t + chart_h,
|
||||
));
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{margin_l}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#333\" stroke-width=\"1\"/>\n",
|
||||
margin_t + chart_h,
|
||||
margin_l + chart_w,
|
||||
margin_t + chart_h,
|
||||
));
|
||||
|
||||
// Y-axis labels.
|
||||
for pct in [0, 25, 50, 75, 100] {
|
||||
let y = margin_t + chart_h - (pct as f64 / 100.0) * chart_h;
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{}\" y=\"{:.1}\" text-anchor=\"end\" font-size=\"10\" fill=\"#666\">{pct}%</text>\n",
|
||||
margin_l - 6.0, y + 3.0,
|
||||
));
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{margin_l}\" y1=\"{y:.1}\" x2=\"{}\" y2=\"{y:.1}\" stroke=\"#eee\" stroke-width=\"1\"/>\n",
|
||||
margin_l + chart_w,
|
||||
));
|
||||
}
|
||||
|
||||
// X-axis labels.
|
||||
let step = (rounds / 10).max(1);
|
||||
for r in (0..rounds).step_by(step) {
|
||||
let x = margin_l + (r as f64 + 0.5) / rounds as f64 * chart_w;
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{}\" text-anchor=\"middle\" font-size=\"10\" fill=\"#666\">{}</text>\n",
|
||||
margin_t + chart_h + 16.0, r + 1,
|
||||
));
|
||||
}
|
||||
// X-axis title.
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"11\" fill=\"#444\">Round</text>\n",
|
||||
margin_l + chart_w / 2.0,
|
||||
margin_t + chart_h + 34.0,
|
||||
));
|
||||
|
||||
// Compute data points.
|
||||
let n = trace.node_names.len();
|
||||
let mut points = Vec::with_capacity(rounds);
|
||||
for round_snaps in &trace.snapshots_per_round {
|
||||
let converged = round_snaps
|
||||
.iter()
|
||||
.filter(|(_, snap)| snap.entries.len() >= trace.total_keys && trace.total_keys > 0)
|
||||
.count();
|
||||
let pct = if n > 0 {
|
||||
converged as f64 / n as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
points.push(pct);
|
||||
}
|
||||
|
||||
// Draw line.
|
||||
let mut path = String::new();
|
||||
for (i, &pct) in points.iter().enumerate() {
|
||||
let x = margin_l + (i as f64 + 0.5) / rounds as f64 * chart_w;
|
||||
let y = margin_t + chart_h - (pct / 100.0) * chart_h;
|
||||
if i == 0 {
|
||||
path.push_str(&format!("M{x:.1},{y:.1}"));
|
||||
} else {
|
||||
path.push_str(&format!(" L{x:.1},{y:.1}"));
|
||||
}
|
||||
}
|
||||
html.push_str(&format!(
|
||||
"<path d=\"{path}\" fill=\"none\" stroke=\"#4a90d9\" stroke-width=\"2\"/>\n"
|
||||
));
|
||||
|
||||
// Draw dots.
|
||||
for (i, &pct) in points.iter().enumerate() {
|
||||
let x = margin_l + (i as f64 + 0.5) / rounds as f64 * chart_w;
|
||||
let y = margin_t + chart_h - (pct / 100.0) * chart_h;
|
||||
html.push_str(&format!(
|
||||
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"3\" fill=\"#4a90d9\"/>\n"
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</svg>\n");
|
||||
}
|
||||
|
||||
// ── Message flow timeline ────────────────────────────────────────────────
|
||||
|
||||
fn render_message_flow_svg(html: &mut String, trace: &SimulationTrace) {
|
||||
html.push_str("<h2>Message Flow Timeline</h2>\n");
|
||||
html.push_str("<p>Arrows show Push messages from sender to receiver, grouped by round.</p>\n");
|
||||
|
||||
let n = trace.node_names.len();
|
||||
let rounds = trace.num_rounds;
|
||||
if n == 0 || rounds == 0 {
|
||||
html.push_str("<p>No data.</p>\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let name_to_col: HashMap<&str, usize> = trace
|
||||
.node_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.as_str(), i))
|
||||
.collect();
|
||||
|
||||
// Collect message arrows grouped by round.
|
||||
let mut arrows_per_round: Vec<Vec<(usize, usize)>> = vec![Vec::new(); rounds];
|
||||
for event in &trace.events {
|
||||
if let GossipEventKind::PushReceived { ref from_name, .. } = event.kind {
|
||||
let round_idx = event.tick.saturating_sub(1) as usize;
|
||||
if round_idx < rounds {
|
||||
if let (Some(&from_col), Some(&to_col)) = (
|
||||
name_to_col.get(from_name.as_str()),
|
||||
name_to_col.get(event.node_name.as_str()),
|
||||
) {
|
||||
arrows_per_round[round_idx].push((from_col, to_col));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let col_w = 80.0_f64;
|
||||
let row_h = 40.0_f64;
|
||||
let header_h = 30.0_f64;
|
||||
let label_h = 24.0_f64;
|
||||
let svg_w = col_w * n as f64 + 40.0;
|
||||
let svg_h = header_h + label_h + row_h * rounds as f64 + 20.0;
|
||||
|
||||
html.push_str(&format!(
|
||||
"<svg width=\"{svg_w:.0}\" height=\"{svg_h:.0}\" viewBox=\"0 0 {svg_w:.0} {svg_h:.0}\">\n"
|
||||
));
|
||||
html.push_str("<defs><marker id=\"flow-arrow\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6\" fill=\"#d94a4a\"/></marker></defs>\n");
|
||||
|
||||
// Column headers (node names).
|
||||
for (i, name) in trace.node_names.iter().enumerate() {
|
||||
let x = 20.0 + i as f64 * col_w + col_w / 2.0;
|
||||
html.push_str(&format!(
|
||||
"<text x=\"{x:.1}\" y=\"{label_h:.0}\" text-anchor=\"middle\" font-size=\"11\" font-weight=\"bold\" fill=\"#333\">{name}</text>\n"
|
||||
));
|
||||
// Vertical lifeline.
|
||||
let y_start = header_h + label_h;
|
||||
let y_end = header_h + label_h + row_h * rounds as f64;
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{x:.1}\" y1=\"{y_start:.0}\" x2=\"{x:.1}\" y2=\"{y_end:.0}\" stroke=\"#ddd\" stroke-width=\"1\" stroke-dasharray=\"4,3\"/>\n"
|
||||
));
|
||||
}
|
||||
|
||||
// Round labels and arrows.
|
||||
for (r, arrows) in arrows_per_round.iter().enumerate() {
|
||||
let y = header_h + label_h + r as f64 * row_h + row_h / 2.0;
|
||||
// Round label on left.
|
||||
html.push_str(&format!(
|
||||
"<text x=\"4\" y=\"{y:.1}\" font-size=\"9\" fill=\"#999\">R{}</text>\n",
|
||||
r + 1,
|
||||
));
|
||||
|
||||
for &(from_col, to_col) in arrows {
|
||||
let x1 = 20.0 + from_col as f64 * col_w + col_w / 2.0;
|
||||
let x2 = 20.0 + to_col as f64 * col_w + col_w / 2.0;
|
||||
// Offset slightly so overlapping arrows are visible.
|
||||
let offset = if from_col < to_col { -3.0 } else { 3.0 };
|
||||
html.push_str(&format!(
|
||||
"<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"#d94a4a\" stroke-width=\"1.5\" marker-end=\"url(#flow-arrow)\"/>\n",
|
||||
y1 = y + offset,
|
||||
y2 = y + offset,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
html.push_str("</svg>\n");
|
||||
}
|
||||
|
||||
// ── Event log table ──────────────────────────────────────────────────────
|
||||
|
||||
fn render_event_table(html: &mut String, trace: &SimulationTrace) {
|
||||
html.push_str("<h2>Event Log</h2>\n");
|
||||
|
||||
let max_rows = 500;
|
||||
let events: Vec<_> = trace
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| !matches!(e.kind, GossipEventKind::StateSnapshot { .. }))
|
||||
.collect();
|
||||
|
||||
let total = events.len();
|
||||
let display = events.iter().take(max_rows);
|
||||
|
||||
html.push_str("<table>\n<tr><th>Round</th><th>Node</th><th>Event</th><th>Details</th></tr>\n");
|
||||
for event in display {
|
||||
let (kind_str, detail) = format_event_kind(&event.kind);
|
||||
html.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{kind_str}</td><td>{detail}</td></tr>\n",
|
||||
event.tick,
|
||||
escape_html(&event.node_name),
|
||||
));
|
||||
}
|
||||
html.push_str("</table>\n");
|
||||
|
||||
if total > max_rows {
|
||||
html.push_str(&format!(
|
||||
"<p class=\"capped\">Showing {max_rows} of {total} events.</p>\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn format_event_kind(kind: &GossipEventKind) -> (&'static str, String) {
|
||||
match kind {
|
||||
GossipEventKind::LocalSet { key } => ("LocalSet", format!("key={}", escape_html(key))),
|
||||
GossipEventKind::GossipRoundStarted { target_name } => {
|
||||
("GossipRound", format!("→ {}", escape_html(target_name)))
|
||||
}
|
||||
GossipEventKind::GossipRoundNoPeers => ("GossipRound", "no peers".into()),
|
||||
GossipEventKind::PushReceived {
|
||||
from_name,
|
||||
keys_updated,
|
||||
} => (
|
||||
"PushReceived",
|
||||
format!(
|
||||
"from {} ({keys_updated} updated)",
|
||||
escape_html(from_name)
|
||||
),
|
||||
),
|
||||
GossipEventKind::QueryReceived { key } => {
|
||||
("Query", format!("key={}", escape_html(key)))
|
||||
}
|
||||
GossipEventKind::PeerAdded { peer_name } => {
|
||||
("PeerAdded", escape_html(peer_name))
|
||||
}
|
||||
GossipEventKind::PeerRemoved { peer_name } => {
|
||||
("PeerRemoved", escape_html(peer_name))
|
||||
}
|
||||
GossipEventKind::StateSnapshot { .. } => ("Snapshot", String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
235
crates/swactor-gossip/src/sim.rs
Normal file
235
crates/swactor-gossip/src/sim.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::protocol::{GossipActor, GossipMessage};
|
||||
use crate::trace::{
|
||||
EventLog, GossipEventKind, NameRegistry, NodeSnapshot, SimulationTrace, TickCounter,
|
||||
};
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Topology {
|
||||
/// Each node gossips to the next; last gossips to first.
|
||||
Ring,
|
||||
/// Node 0 is the hub; all others gossip to/from it.
|
||||
Star,
|
||||
/// Every node gossips to every other node.
|
||||
FullMesh,
|
||||
/// Unidirectional chain: 0→1→2→…→(n-1).
|
||||
Chain,
|
||||
/// Two halves with no cross-links (healed later via `heal_after_round`).
|
||||
Partitioned,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimConfig {
|
||||
pub name: String,
|
||||
pub topology: Topology,
|
||||
pub num_nodes: usize,
|
||||
/// `(key, value)` pairs to set on node 0 before gossip starts.
|
||||
pub initial_data: Vec<(String, Vec<u8>)>,
|
||||
pub num_rounds: usize,
|
||||
pub ticks_per_round: usize,
|
||||
/// If `Some(r)`, cross-partition links are added after round `r`.
|
||||
pub heal_after_round: Option<usize>,
|
||||
}
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────────
|
||||
|
||||
pub fn run_simulation(config: SimConfig) -> SimulationTrace {
|
||||
let event_log: EventLog = Arc::new(Mutex::new(Vec::new()));
|
||||
let tick_counter: TickCounter = Arc::new(AtomicU64::new(0));
|
||||
let name_registry: NameRegistry = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Spawn nodes.
|
||||
let mut addrs = Vec::with_capacity(config.num_nodes);
|
||||
let mut names = Vec::with_capacity(config.num_nodes);
|
||||
for i in 0..config.num_nodes {
|
||||
let name = format!("node-{i}");
|
||||
let actor = GossipActor::traced(
|
||||
Arc::clone(&event_log),
|
||||
Arc::clone(&tick_counter),
|
||||
Arc::clone(&name_registry),
|
||||
);
|
||||
let addr = rt.spawn(actor).unwrap();
|
||||
name_registry.lock().unwrap().insert(addr, name.clone());
|
||||
addrs.push(addr);
|
||||
names.push(name);
|
||||
}
|
||||
|
||||
// Wire topology.
|
||||
let edges = wire_topology(&rt, &config.topology, &addrs, &names);
|
||||
// Deliver AddPeer messages.
|
||||
for _ in 0..3 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Set initial data on node 0.
|
||||
let total_keys = config.initial_data.len();
|
||||
for (key, value) in &config.initial_data {
|
||||
rt.send_to(
|
||||
addrs[0],
|
||||
GossipMessage::Set {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
rt.tick();
|
||||
|
||||
// Run gossip rounds.
|
||||
let mut snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>> = Vec::new();
|
||||
|
||||
for round in 0..config.num_rounds {
|
||||
// Heal partition if needed.
|
||||
if config.heal_after_round == Some(round) {
|
||||
heal_partition(&rt, &config.topology, &addrs, &names);
|
||||
for _ in 0..3 {
|
||||
rt.tick();
|
||||
}
|
||||
}
|
||||
|
||||
tick_counter.store((round + 1) as u64, Ordering::Relaxed);
|
||||
|
||||
// Trigger gossip on all nodes.
|
||||
for &addr in &addrs {
|
||||
rt.send_to(addr, GossipMessage::DoGossipRound).unwrap();
|
||||
}
|
||||
for _ in 0..config.ticks_per_round {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Take snapshots.
|
||||
for &addr in &addrs {
|
||||
rt.send_to(addr, GossipMessage::TakeSnapshot).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
rt.tick();
|
||||
}
|
||||
|
||||
// Extract snapshots from event log.
|
||||
let current_round_tick = (round + 1) as u64;
|
||||
let log = event_log.lock().unwrap();
|
||||
let mut round_snapshots: Vec<(String, NodeSnapshot)> = Vec::new();
|
||||
for event in log.iter().rev() {
|
||||
if event.tick != current_round_tick {
|
||||
break;
|
||||
}
|
||||
if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
|
||||
round_snapshots.push((event.node_name.clone(), snapshot.clone()));
|
||||
}
|
||||
}
|
||||
round_snapshots.reverse();
|
||||
snapshots_per_round.push(round_snapshots);
|
||||
}
|
||||
|
||||
let events = event_log.lock().unwrap().clone();
|
||||
|
||||
SimulationTrace {
|
||||
name: config.name,
|
||||
node_names: names,
|
||||
node_addrs: addrs,
|
||||
topology_edges: edges,
|
||||
events,
|
||||
snapshots_per_round,
|
||||
num_rounds: config.num_rounds,
|
||||
total_keys,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Topology wiring ──────────────────────────────────────────────────────
|
||||
|
||||
fn wire_topology(
|
||||
rt: &Runtime,
|
||||
topology: &Topology,
|
||||
addrs: &[ActorAddress],
|
||||
names: &[String],
|
||||
) -> Vec<(String, String)> {
|
||||
let n = addrs.len();
|
||||
let mut edges = Vec::new();
|
||||
|
||||
let mut add_edge = |from: usize, to: usize| {
|
||||
rt.send_to(addrs[from], GossipMessage::AddPeer(addrs[to]))
|
||||
.unwrap();
|
||||
edges.push((names[from].clone(), names[to].clone()));
|
||||
};
|
||||
|
||||
match topology {
|
||||
Topology::Ring => {
|
||||
for i in 0..n {
|
||||
add_edge(i, (i + 1) % n);
|
||||
}
|
||||
}
|
||||
Topology::Star => {
|
||||
for i in 1..n {
|
||||
add_edge(0, i);
|
||||
add_edge(i, 0);
|
||||
}
|
||||
}
|
||||
Topology::FullMesh => {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if i != j {
|
||||
add_edge(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Topology::Chain => {
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
add_edge(i, i + 1);
|
||||
}
|
||||
}
|
||||
Topology::Partitioned => {
|
||||
let half = n / 2;
|
||||
// Wire each half as a full mesh.
|
||||
for i in 0..half {
|
||||
for j in 0..half {
|
||||
if i != j {
|
||||
add_edge(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in half..n {
|
||||
for j in half..n {
|
||||
if i != j {
|
||||
add_edge(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
fn heal_partition(
|
||||
rt: &Runtime,
|
||||
topology: &Topology,
|
||||
addrs: &[ActorAddress],
|
||||
_names: &[String],
|
||||
) {
|
||||
if !matches!(topology, Topology::Partitioned) {
|
||||
return;
|
||||
}
|
||||
let n = addrs.len();
|
||||
let half = n / 2;
|
||||
// Add bidirectional links between the two halves (bridge nodes).
|
||||
if half > 0 && half < n {
|
||||
rt.send_to(addrs[half - 1], GossipMessage::AddPeer(addrs[half]))
|
||||
.unwrap();
|
||||
rt.send_to(addrs[half], GossipMessage::AddPeer(addrs[half - 1]))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
99
crates/swactor-gossip/src/trace.rs
Normal file
99
crates/swactor-gossip/src/trace.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
|
||||
use crate::protocol::VersionedValue;
|
||||
|
||||
// ── Shared handles ───────────────────────────────────────────────────────
|
||||
|
||||
/// Shared, append-only event log.
|
||||
pub type EventLog = Arc<Mutex<Vec<GossipEvent>>>;
|
||||
|
||||
/// Shared tick counter — the simulation harness increments this.
|
||||
pub type TickCounter = Arc<AtomicU64>;
|
||||
|
||||
/// Maps actor addresses to human-readable names like `"node-0"`.
|
||||
pub type NameRegistry = Arc<Mutex<HashMap<ActorAddress, String>>>;
|
||||
|
||||
// ── TraceContext ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Bundles the three shared handles needed for tracing into one value.
|
||||
pub struct TraceContext {
|
||||
pub event_log: EventLog,
|
||||
pub tick_counter: TickCounter,
|
||||
pub name_registry: NameRegistry,
|
||||
}
|
||||
|
||||
impl TraceContext {
|
||||
pub fn current_tick(&self) -> u64 {
|
||||
self.tick_counter.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn resolve_name(&self, addr: ActorAddress) -> String {
|
||||
self.name_registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&addr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{:?}", &addr.0[..4]))
|
||||
}
|
||||
|
||||
pub fn record_event(&self, event: GossipEvent) {
|
||||
self.event_log.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event types ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GossipEvent {
|
||||
pub tick: u64,
|
||||
pub node_name: String,
|
||||
pub node_addr: ActorAddress,
|
||||
pub kind: GossipEventKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GossipEventKind {
|
||||
/// A local `Set { key, .. }` was processed.
|
||||
LocalSet { key: String },
|
||||
/// `DoGossipRound` chose a peer and sent a Push.
|
||||
GossipRoundStarted { target_name: String },
|
||||
/// `DoGossipRound` had no peers.
|
||||
GossipRoundNoPeers,
|
||||
/// Received a Push from another node.
|
||||
PushReceived {
|
||||
from_name: String,
|
||||
keys_updated: usize,
|
||||
},
|
||||
/// Received a Query.
|
||||
QueryReceived { key: String },
|
||||
/// A peer was added.
|
||||
PeerAdded { peer_name: String },
|
||||
/// A peer was removed.
|
||||
PeerRemoved { peer_name: String },
|
||||
/// Full state snapshot (requested via `TakeSnapshot`).
|
||||
StateSnapshot { snapshot: NodeSnapshot },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeSnapshot {
|
||||
pub entries: HashMap<String, VersionedValue>,
|
||||
pub peer_count: usize,
|
||||
}
|
||||
|
||||
// ── Simulation trace (complete run output) ───────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulationTrace {
|
||||
pub name: String,
|
||||
pub node_names: Vec<String>,
|
||||
pub node_addrs: Vec<ActorAddress>,
|
||||
pub topology_edges: Vec<(String, String)>,
|
||||
pub events: Vec<GossipEvent>,
|
||||
pub snapshots_per_round: Vec<Vec<(String, NodeSnapshot)>>,
|
||||
pub num_rounds: usize,
|
||||
pub total_keys: usize,
|
||||
}
|
||||
243
crates/swactor-gossip/tests/gossip_convergence.rs
Normal file
243
crates/swactor-gossip/tests/gossip_convergence.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use swactor::config::RuntimeConfig;
|
||||
use swactor::runtime::Runtime;
|
||||
use swactor_gossip::{GossipActor, GossipMessage, GossipQueryResponse};
|
||||
|
||||
fn single_thread_runtime() -> Runtime {
|
||||
Runtime::new(RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive ticks until the inbox receives a response, or panic after a limit.
|
||||
fn recv_query_response(
|
||||
rt: &Runtime,
|
||||
inbox: &swactor::runtime::Inbox<GossipQueryResponse>,
|
||||
max_ticks: usize,
|
||||
) -> GossipQueryResponse {
|
||||
for _ in 0..max_ticks {
|
||||
rt.tick();
|
||||
if let Some(resp) = inbox.try_recv() {
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
panic!("no GossipQueryResponse after {max_ticks} ticks");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: Value propagates through a chain A → B → C
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn value_propagates_through_chain() {
|
||||
// Given: three gossip nodes wired A→B→C (each only gossips to the next)
|
||||
let rt = single_thread_runtime();
|
||||
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||
let c = rt.spawn(GossipActor::new()).unwrap();
|
||||
|
||||
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||
rt.send_to(b, GossipMessage::AddPeer(c)).unwrap();
|
||||
rt.tick(); // deliver AddPeer messages
|
||||
|
||||
// When: we set a value on A and trigger gossip hops
|
||||
rt.send_to(a, GossipMessage::Set {
|
||||
key: "color".into(),
|
||||
value: b"blue".to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
rt.tick(); // A processes Set
|
||||
|
||||
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.tick(); // A pushes to B
|
||||
rt.tick(); // B processes Push
|
||||
|
||||
rt.send_to(b, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.tick(); // B pushes to C
|
||||
rt.tick(); // C processes Push
|
||||
|
||||
// Then: querying C returns the value that originated at A
|
||||
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||
rt.send_to(c, GossipMessage::Query {
|
||||
key: "color".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.key, "color");
|
||||
assert_eq!(resp.value.as_deref(), Some(b"blue".as_slice()));
|
||||
assert_eq!(resp.version, Some(1));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: Higher version wins during merge
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn higher_version_wins() {
|
||||
// Given: two nodes A and B, each set the same key at different versions
|
||||
let rt = single_thread_runtime();
|
||||
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||
|
||||
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||
rt.tick();
|
||||
|
||||
// A sets "x" once (version 1)
|
||||
rt.send_to(a, GossipMessage::Set {
|
||||
key: "x".into(),
|
||||
value: b"old".to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
rt.tick();
|
||||
|
||||
// B sets "x" three times (version 3)
|
||||
for val in [b"v1".as_slice(), b"v2", b"new"] {
|
||||
rt.send_to(b, GossipMessage::Set {
|
||||
key: "x".into(),
|
||||
value: val.to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
rt.tick();
|
||||
|
||||
// When: A pushes its lower-version state to B
|
||||
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.tick(); // A sends Push
|
||||
rt.tick(); // B receives Push
|
||||
|
||||
// Then: B still has the higher-version value
|
||||
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||
rt.send_to(b, GossipMessage::Query {
|
||||
key: "x".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.value.as_deref(), Some(b"new".as_slice()));
|
||||
assert_eq!(resp.version, Some(3));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test 3: Disjoint keys merge — both nodes end up with both keys
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn disjoint_keys_merge() {
|
||||
// Given: A owns key "a", B owns key "b", they are mutual peers
|
||||
let rt = single_thread_runtime();
|
||||
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||
|
||||
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||
rt.send_to(b, GossipMessage::AddPeer(a)).unwrap();
|
||||
rt.tick();
|
||||
|
||||
rt.send_to(a, GossipMessage::Set {
|
||||
key: "a".into(),
|
||||
value: b"from-a".to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
rt.send_to(b, GossipMessage::Set {
|
||||
key: "b".into(),
|
||||
value: b"from-b".to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
rt.tick();
|
||||
|
||||
// When: both gossip to each other
|
||||
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.send_to(b, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.tick(); // send Pushes
|
||||
rt.tick(); // receive Pushes
|
||||
|
||||
// Then: A has key "b" and B has key "a"
|
||||
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||
|
||||
rt.send_to(a, GossipMessage::Query {
|
||||
key: "b".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.key, "b");
|
||||
assert_eq!(resp.value.as_deref(), Some(b"from-b".as_slice()));
|
||||
|
||||
rt.send_to(b, GossipMessage::Query {
|
||||
key: "a".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.key, "a");
|
||||
assert_eq!(resp.value.as_deref(), Some(b"from-a".as_slice()));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test 4: Query for nonexistent key returns None
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn query_nonexistent_key_returns_none() {
|
||||
// Given: a gossip node with no data
|
||||
let rt = single_thread_runtime();
|
||||
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||
rt.tick();
|
||||
|
||||
// When: we query a key that was never set
|
||||
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||
rt.send_to(a, GossipMessage::Query {
|
||||
key: "ghost".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Then: response has None value and None version
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.key, "ghost");
|
||||
assert!(resp.value.is_none());
|
||||
assert!(resp.version.is_none());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Test 5: Idempotent push — double-push doesn't bump versions
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn idempotent_push() {
|
||||
// Given: A has a key set, B is its peer
|
||||
let rt = single_thread_runtime();
|
||||
let a = rt.spawn(GossipActor::new()).unwrap();
|
||||
let b = rt.spawn(GossipActor::new()).unwrap();
|
||||
|
||||
rt.send_to(a, GossipMessage::AddPeer(b)).unwrap();
|
||||
rt.tick();
|
||||
|
||||
rt.send_to(a, GossipMessage::Set {
|
||||
key: "k".into(),
|
||||
value: b"val".to_vec(),
|
||||
})
|
||||
.unwrap();
|
||||
rt.tick();
|
||||
|
||||
// When: A gossips to B twice (same state, same version)
|
||||
for _ in 0..2 {
|
||||
rt.send_to(a, GossipMessage::DoGossipRound).unwrap();
|
||||
rt.tick(); // send Push
|
||||
rt.tick(); // receive Push
|
||||
}
|
||||
|
||||
// Then: B's version is still 1 (merge is idempotent, not additive)
|
||||
let inbox = rt.new_inbox::<GossipQueryResponse>().unwrap();
|
||||
rt.send_to(b, GossipMessage::Query {
|
||||
key: "k".into(),
|
||||
reply_to: *inbox.addr(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let resp = recv_query_response(&rt, &inbox, 10);
|
||||
assert_eq!(resp.version, Some(1));
|
||||
assert_eq!(resp.value.as_deref(), Some(b"val".as_slice()));
|
||||
}
|
||||
15
tools/analyze.sh
Executable file
15
tools/analyze.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./tools/analyze.sh <crate-src-dir> [output-dir]
|
||||
# Example: ./tools/analyze.sh crates/swactor-gossip/src
|
||||
|
||||
SRC_DIR="${1:?Usage: $0 <crate-src-dir> [output-dir]}"
|
||||
OUT_DIR="${2:-$(dirname "$SRC_DIR")/docs/connectome}"
|
||||
|
||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- \
|
||||
--src-dir "$SRC_DIR" --output-dir "$OUT_DIR"
|
||||
|
||||
uv run --with numpy --with scipy \
|
||||
python tools/spectral/spectral_analysis.py \
|
||||
"$OUT_DIR/deps.dot" --json --no-plots -o "$OUT_DIR"
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "depgraph"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -54,31 +54,25 @@ struct Edge {
|
|||
|
||||
// ─── Module colors ───────────────────────────────────────────────────────────
|
||||
|
||||
fn module_colors(module: &str) -> (&'static str, &'static str, &'static str) {
|
||||
// Returns (cluster_fill, cluster_border, node_fill)
|
||||
match module {
|
||||
"error" => ("#f0f0f0", "#888", "#e8f5e9"),
|
||||
"config" => ("#f0f0f0", "#888", "#e8f5e9"),
|
||||
"channel" => ("#f0f0f0", "#888", "#fff9c4"),
|
||||
"actor" => ("#e3f2fd", "#1565c0", "#bbdefb"),
|
||||
"address_map" => ("#f3e5f5", "#7b1fa2", "#e1bee7"),
|
||||
"runtime" => ("#fce4ec", "#c62828", "#ffcdd2"),
|
||||
"worker" => ("#fff3e0", "#e65100", "#ffe0b2"),
|
||||
"python" => ("#f5f5f5", "#999", "#d7ccc8"),
|
||||
_ => ("#f0f0f0", "#888", "#e0e0e0"),
|
||||
}
|
||||
/// 8-color pastel palette for module clusters.
|
||||
/// Each entry: (cluster_fill, cluster_border, node_fill)
|
||||
const PALETTE: &[(&str, &str, &str)] = &[
|
||||
("#e3f2fd", "#1565c0", "#bbdefb"),
|
||||
("#fce4ec", "#c62828", "#ffcdd2"),
|
||||
("#fff3e0", "#e65100", "#ffe0b2"),
|
||||
("#f3e5f5", "#7b1fa2", "#e1bee7"),
|
||||
("#e8f5e9", "#2e7d32", "#c8e6c9"),
|
||||
("#fff9c4", "#f9a825", "#fff59d"),
|
||||
("#e0f7fa", "#00838f", "#b2ebf2"),
|
||||
("#fbe9e7", "#d84315", "#ffccbc"),
|
||||
];
|
||||
|
||||
fn module_colors_by_index(index: usize) -> (&'static str, &'static str, &'static str) {
|
||||
PALETTE[index % PALETTE.len()]
|
||||
}
|
||||
|
||||
fn module_edge_color(module: &str) -> &'static str {
|
||||
match module {
|
||||
"error" | "config" | "channel" => "#666",
|
||||
"actor" => "#1565c0",
|
||||
"address_map" => "#7b1fa2",
|
||||
"runtime" => "#c62828",
|
||||
"worker" => "#e65100",
|
||||
"python" => "#999",
|
||||
_ => "#666",
|
||||
}
|
||||
fn module_edge_color_by_index(index: usize) -> &'static str {
|
||||
PALETTE[index % PALETTE.len()].1
|
||||
}
|
||||
|
||||
// ─── Phase 1: Module discovery ───────────────────────────────────────────────
|
||||
|
|
@ -827,22 +821,21 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
|||
writeln!(out, " splines=ortho;").unwrap();
|
||||
writeln!(out).unwrap();
|
||||
|
||||
// Define module ordering for consistent output
|
||||
let module_order = [
|
||||
"error",
|
||||
"config",
|
||||
"channel",
|
||||
"actor",
|
||||
"address_map",
|
||||
"runtime",
|
||||
"worker",
|
||||
"python",
|
||||
];
|
||||
// Use actual module names in discovery order for consistent output
|
||||
let module_order: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
|
||||
|
||||
// Build module_name → index lookup for palette rotation
|
||||
let module_index: HashMap<&str, usize> = module_order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &name)| (name, i))
|
||||
.collect();
|
||||
|
||||
// Emit subgraph clusters
|
||||
for mod_name in &module_order {
|
||||
for (i, mod_name) in module_order.iter().enumerate() {
|
||||
if let Some(module) = modules.iter().find(|m| m.name == *mod_name) {
|
||||
emit_cluster(&mut out, module);
|
||||
let (cluster_fill, cluster_border, node_fill) = module_colors_by_index(i);
|
||||
emit_cluster(&mut out, module, cluster_fill, cluster_border, node_fill);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -866,7 +859,8 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
|||
writeln!(out).unwrap();
|
||||
|
||||
for edge in edges.iter().filter(|e| e.from_module == e.to_module) {
|
||||
emit_edge(&mut out, edge, true);
|
||||
let idx = module_index.get(edge.from_module.as_str()).copied().unwrap_or(0);
|
||||
emit_edge(&mut out, edge, true, module_edge_color_by_index(idx));
|
||||
}
|
||||
|
||||
// Emit cross-module edges
|
||||
|
|
@ -928,7 +922,8 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
|||
)
|
||||
.unwrap();
|
||||
for edge in edges_group {
|
||||
emit_edge(&mut out, edge, false);
|
||||
let idx = module_index.get(edge.from_module.as_str()).copied().unwrap_or(0);
|
||||
emit_edge(&mut out, edge, false, module_edge_color_by_index(idx));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -936,8 +931,7 @@ fn generate_dot(modules: &[ModuleInfo], edges: &[Edge]) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
fn emit_cluster(out: &mut String, module: &ModuleInfo) {
|
||||
let (cluster_fill, cluster_border, node_fill) = module_colors(&module.name);
|
||||
fn emit_cluster(out: &mut String, module: &ModuleInfo, cluster_fill: &str, cluster_border: &str, node_fill: &str) {
|
||||
|
||||
let style = if module.feature_gate.is_some() {
|
||||
"rounded,dashed,filled"
|
||||
|
|
@ -1010,12 +1004,7 @@ fn emit_cluster(out: &mut String, module: &ModuleInfo) {
|
|||
writeln!(out, " }}").unwrap();
|
||||
}
|
||||
|
||||
fn emit_edge(out: &mut String, edge: &Edge, intra: bool) {
|
||||
let color = if intra {
|
||||
"#666"
|
||||
} else {
|
||||
module_edge_color(&edge.from_module)
|
||||
};
|
||||
fn emit_edge(out: &mut String, edge: &Edge, intra: bool, color: &str) {
|
||||
|
||||
let (style, penwidth) = match edge.kind {
|
||||
EdgeKind::TraitImpl => {
|
||||
|
|
@ -1247,6 +1236,7 @@ fn main() {
|
|||
|
||||
let mut src_dir = PathBuf::from("src");
|
||||
let mut output_prefix = String::from("deps");
|
||||
let mut output_dir: Option<PathBuf> = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
|
|
@ -1259,10 +1249,15 @@ fn main() {
|
|||
i += 1;
|
||||
output_prefix = args[i].clone();
|
||||
}
|
||||
"--output-dir" => {
|
||||
i += 1;
|
||||
output_dir = Some(PathBuf::from(&args[i]));
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
eprintln!("Usage: depgraph [--src-dir src/] [--output deps]");
|
||||
eprintln!("Usage: depgraph [--src-dir src/] [--output deps] [--output-dir DIR]");
|
||||
eprintln!(" --src-dir DIR Source directory (default: src/)");
|
||||
eprintln!(" --output PREFIX Output prefix (default: deps)");
|
||||
eprintln!(" --output-dir DIR Directory for output files (default: cwd)");
|
||||
eprintln!(" Produces PREFIX.dot and PREFIX.html");
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
|
@ -1274,6 +1269,11 @@ fn main() {
|
|||
i += 1;
|
||||
}
|
||||
|
||||
// Ensure output directory exists.
|
||||
if let Some(ref dir) = output_dir {
|
||||
fs::create_dir_all(dir).expect("Failed to create output directory");
|
||||
}
|
||||
|
||||
eprintln!("Scanning source directory: {}", src_dir.display());
|
||||
|
||||
// Phase 1: Module discovery
|
||||
|
|
@ -1330,15 +1330,23 @@ fn main() {
|
|||
|
||||
// Phase 5: DOT output
|
||||
let dot = generate_dot(&modules, &edges);
|
||||
let dot_path = format!("{}.dot", output_prefix);
|
||||
let dot_file = format!("{}.dot", output_prefix);
|
||||
let dot_path = match &output_dir {
|
||||
Some(dir) => dir.join(&dot_file),
|
||||
None => PathBuf::from(&dot_file),
|
||||
};
|
||||
fs::write(&dot_path, &dot).expect("Failed to write .dot file");
|
||||
eprintln!("Wrote {}", dot_path);
|
||||
eprintln!("Wrote {}", dot_path.display());
|
||||
|
||||
// Phase 6: HTML output
|
||||
let html = generate_html(&dot);
|
||||
let html_path = format!("{}.html", output_prefix);
|
||||
let html_file = format!("{}.html", output_prefix);
|
||||
let html_path = match &output_dir {
|
||||
Some(dir) => dir.join(&html_file),
|
||||
None => PathBuf::from(&html_file),
|
||||
};
|
||||
fs::write(&html_path, &html).expect("Failed to write .html file");
|
||||
eprintln!("Wrote {}", html_path);
|
||||
eprintln!("Wrote {}", html_path.display());
|
||||
|
||||
eprintln!("Done!");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,20 @@ class ComplexityMetrics:
|
|||
connected_components: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructuralProperties:
|
||||
avg_degree: float
|
||||
max_fan_in: int
|
||||
max_fan_in_node: str
|
||||
max_fan_out: int
|
||||
max_fan_out_node: str
|
||||
dag_depth: int
|
||||
clustering_coeff: float
|
||||
module_cohesion: dict[str, float]
|
||||
avg_module_cohesion: float
|
||||
avg_module_size: float
|
||||
|
||||
|
||||
# ─── DOT Parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_dot(text: str) -> DependencyGraph:
|
||||
|
|
@ -403,6 +417,117 @@ def compute_complexity_metrics(
|
|||
)
|
||||
|
||||
|
||||
# ─── Structural Properties ───────────────────────────────────────────────────
|
||||
|
||||
def _compute_dag_depth(A: np.ndarray) -> int:
|
||||
"""Longest directed path in the graph."""
|
||||
n = A.shape[0]
|
||||
if n == 0:
|
||||
return 0
|
||||
UNVISITED, VISITING, DONE = 0, 1, 2
|
||||
state = [UNVISITED] * n
|
||||
depth = [0] * n
|
||||
|
||||
def dfs(node: int) -> int:
|
||||
if state[node] == DONE:
|
||||
return depth[node]
|
||||
if state[node] == VISITING:
|
||||
return 0 # cycle — treat as leaf
|
||||
state[node] = VISITING
|
||||
best = 0
|
||||
for j in range(n):
|
||||
if A[node, j] > 0:
|
||||
best = max(best, 1 + dfs(j))
|
||||
state[node] = DONE
|
||||
depth[node] = best
|
||||
return best
|
||||
|
||||
return max(dfs(i) for i in range(n))
|
||||
|
||||
|
||||
def _compute_clustering_coefficient(A_sym: np.ndarray) -> float:
|
||||
"""Global clustering coefficient (transitivity) on the undirected graph.
|
||||
|
||||
Uses the matrix identity: C = trace(A³) / (||A²||₁ - trace(A²))
|
||||
where ||·||₁ is the sum of all elements.
|
||||
"""
|
||||
n = A_sym.shape[0]
|
||||
if n < 3:
|
||||
return 0.0
|
||||
A2 = A_sym @ A_sym
|
||||
A3 = A2 @ A_sym
|
||||
numerator = np.trace(A3)
|
||||
denominator = A2.sum() - np.trace(A2)
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return float(numerator / denominator)
|
||||
|
||||
|
||||
def compute_structural_properties(
|
||||
graph: DependencyGraph,
|
||||
spectral: SpectralResults,
|
||||
) -> StructuralProperties:
|
||||
"""Compute graph-theoretic structural properties."""
|
||||
n = len(graph.nodes)
|
||||
n_edges = len(graph.edges)
|
||||
node_names = spectral.node_names
|
||||
A = spectral.adjacency
|
||||
|
||||
avg_degree = n_edges / n if n > 0 else 0.0
|
||||
|
||||
in_degrees = A.sum(axis=0)
|
||||
out_degrees = A.sum(axis=1)
|
||||
|
||||
if n > 0:
|
||||
fi_idx = int(np.argmax(in_degrees))
|
||||
fo_idx = int(np.argmax(out_degrees))
|
||||
max_fan_in = int(in_degrees[fi_idx])
|
||||
max_fan_out = int(out_degrees[fo_idx])
|
||||
max_fan_in_node = node_names[fi_idx]
|
||||
max_fan_out_node = node_names[fo_idx]
|
||||
else:
|
||||
max_fan_in = max_fan_out = 0
|
||||
max_fan_in_node = max_fan_out_node = ""
|
||||
|
||||
dag_depth = _compute_dag_depth(A)
|
||||
clustering_coeff = _compute_clustering_coefficient(spectral.adjacency_sym)
|
||||
|
||||
# Per-module cohesion: intra-edges / max-possible-intra-edges
|
||||
module_cohesion: dict[str, float] = {}
|
||||
module_sizes: dict[str, int] = {}
|
||||
for mod in graph.modules:
|
||||
mod_nodes = [i for i, name in enumerate(node_names)
|
||||
if graph.node_to_module.get(name) == mod]
|
||||
k = len(mod_nodes)
|
||||
module_sizes[mod] = k
|
||||
if k <= 1:
|
||||
module_cohesion[mod] = float("nan")
|
||||
continue
|
||||
max_possible = k * (k - 1)
|
||||
actual = sum(1 for i in mod_nodes for j in mod_nodes
|
||||
if i != j and A[i, j] > 0)
|
||||
module_cohesion[mod] = actual / max_possible
|
||||
|
||||
valid = [v for v in module_cohesion.values() if not math.isnan(v)]
|
||||
avg_cohesion = sum(valid) / len(valid) if valid else 0.0
|
||||
|
||||
sizes = list(module_sizes.values())
|
||||
avg_size = sum(sizes) / len(sizes) if sizes else 0.0
|
||||
|
||||
return StructuralProperties(
|
||||
avg_degree=avg_degree,
|
||||
max_fan_in=max_fan_in,
|
||||
max_fan_in_node=max_fan_in_node,
|
||||
max_fan_out=max_fan_out,
|
||||
max_fan_out_node=max_fan_out_node,
|
||||
dag_depth=dag_depth,
|
||||
clustering_coeff=clustering_coeff,
|
||||
module_cohesion=module_cohesion,
|
||||
avg_module_cohesion=avg_cohesion,
|
||||
avg_module_size=avg_size,
|
||||
)
|
||||
|
||||
|
||||
# ─── Full Pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
|
|
@ -411,6 +536,7 @@ class AnalysisResult:
|
|||
spectral: SpectralResults
|
||||
coupling: ModuleCouplingResult
|
||||
metrics: ComplexityMetrics
|
||||
structural: StructuralProperties
|
||||
|
||||
|
||||
def run_analysis(graph: DependencyGraph) -> AnalysisResult:
|
||||
|
|
@ -418,11 +544,13 @@ def run_analysis(graph: DependencyGraph) -> AnalysisResult:
|
|||
spectral = compute_spectral(graph)
|
||||
coupling = compute_module_coupling(graph)
|
||||
metrics = compute_complexity_metrics(spectral, coupling)
|
||||
structural = compute_structural_properties(graph, spectral)
|
||||
return AnalysisResult(
|
||||
graph=graph,
|
||||
spectral=spectral,
|
||||
coupling=coupling,
|
||||
metrics=metrics,
|
||||
structural=structural,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -433,6 +561,7 @@ def generate_report(result: AnalysisResult) -> str:
|
|||
s = result.spectral
|
||||
m = result.metrics
|
||||
c = result.coupling
|
||||
p = result.structural
|
||||
lines: list[str] = []
|
||||
|
||||
def w(text: str = "") -> None:
|
||||
|
|
@ -453,36 +582,28 @@ def generate_report(result: AnalysisResult) -> str:
|
|||
w(f" Modules: {', '.join(c.module_names)}")
|
||||
w()
|
||||
|
||||
# Eigenvalue spectrum
|
||||
w("LAPLACIAN EIGENVALUE SPECTRUM")
|
||||
# Structural properties
|
||||
w("STRUCTURAL PROPERTIES")
|
||||
w("-" * 40)
|
||||
for i, ev in enumerate(s.eigenvalues):
|
||||
marker = " <-- Fiedler value (lambda_2)" if i == 1 else ""
|
||||
w(f" lambda_{i:2d} = {ev:8.4f}{marker}")
|
||||
w()
|
||||
if len(s.eigenvalues) > 1:
|
||||
spectral_gap = float(s.eigenvalues[-1] - s.eigenvalues[1])
|
||||
w(f" Spectral gap (lambda_max - lambda_2): {spectral_gap:.4f}")
|
||||
w(f" Fiedler value (algebraic connectivity): {s.fiedler_value:.4f}")
|
||||
w(f" Edges/node (avg degree): {p.avg_degree:.2f}")
|
||||
w(f" Max fan-in: {p.max_fan_in:<4d} ({p.max_fan_in_node})")
|
||||
w(f" Max fan-out: {p.max_fan_out:<4d} ({p.max_fan_out_node})")
|
||||
w(f" DAG depth: {p.dag_depth}")
|
||||
w(f" Clustering coefficient: {p.clustering_coeff:.4f}")
|
||||
w()
|
||||
|
||||
# Fiedler vector analysis
|
||||
if len(s.fiedler_vector) > 0:
|
||||
w("FIEDLER VECTOR — SPECTRAL BISECTION")
|
||||
# Module cohesion
|
||||
w("MODULE COHESION")
|
||||
w("-" * 40)
|
||||
# Sort by fiedler value
|
||||
indices = np.argsort(s.fiedler_vector)
|
||||
w(" Partition A (Fiedler < 0):")
|
||||
for idx in indices:
|
||||
if s.fiedler_vector[idx] < 0:
|
||||
w(f" {s.node_names[idx]:25s} [{s.node_modules[idx]:12s}] "
|
||||
f"f = {s.fiedler_vector[idx]:+.4f}")
|
||||
w(" ────────────────────────────────────")
|
||||
w(" Partition B (Fiedler >= 0):")
|
||||
for idx in indices:
|
||||
if s.fiedler_vector[idx] >= 0:
|
||||
w(f" {s.node_names[idx]:25s} [{s.node_modules[idx]:12s}] "
|
||||
f"f = {s.fiedler_vector[idx]:+.4f}")
|
||||
w(f" {'Module':<16s} {'Size':>5s} {'Cohesion':>8s}")
|
||||
for mod in c.module_names:
|
||||
coh = p.module_cohesion.get(mod, float("nan"))
|
||||
size = sum(1 for n in result.graph.nodes if n.module == mod)
|
||||
coh_str = f"{coh:.3f}" if not math.isnan(coh) else " —"
|
||||
w(f" {mod:<16s} {size:>5d} {coh_str:>8s}")
|
||||
w(f" {'─' * 32}")
|
||||
w(f" {'Average cohesion:':<22s} {p.avg_module_cohesion:8.3f}")
|
||||
w(f" {'Avg module size:':<22s} {p.avg_module_size:8.1f}")
|
||||
w()
|
||||
|
||||
# Module coupling
|
||||
|
|
@ -545,23 +666,28 @@ def generate_report(result: AnalysisResult) -> str:
|
|||
|
||||
# ─── Dashboard Visualization ─────────────────────────────────────────────────
|
||||
|
||||
# Module colors matching the depgraph tool
|
||||
MODULE_COLORS = {
|
||||
"error": "#4caf50",
|
||||
"config": "#8bc34a",
|
||||
"channel": "#ffeb3b",
|
||||
"actor": "#2196f3",
|
||||
"address_map": "#9c27b0",
|
||||
"runtime": "#f44336",
|
||||
"worker": "#ff9800",
|
||||
"python": "#795548",
|
||||
}
|
||||
# Module border colors from the depgraph palette (used as the accent color).
|
||||
# These rotate by discovery-order index; the palette has 8 entries.
|
||||
_PALETTE_BORDER = [
|
||||
"#1565c0", # 0 — blue
|
||||
"#c62828", # 1 — red
|
||||
"#e65100", # 2 — orange
|
||||
"#7b1fa2", # 3 — purple
|
||||
"#2e7d32", # 4 — green
|
||||
"#f9a825", # 5 — yellow
|
||||
"#00838f", # 6 — teal
|
||||
"#d84315", # 7 — deep orange
|
||||
]
|
||||
|
||||
DEFAULT_COLOR = "#9e9e9e"
|
||||
# Module index assigned at analysis time (populated by generate_dashboard_html)
|
||||
_module_index: dict[str, int] = {}
|
||||
|
||||
|
||||
def get_module_color(module: str) -> str:
|
||||
return MODULE_COLORS.get(module, DEFAULT_COLOR)
|
||||
idx = _module_index.get(module)
|
||||
if idx is not None:
|
||||
return _PALETTE_BORDER[idx % len(_PALETTE_BORDER)]
|
||||
return "#9e9e9e"
|
||||
|
||||
|
||||
def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
||||
|
|
@ -571,6 +697,11 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
|||
import matplotlib.pyplot as plt
|
||||
from matplotlib.gridspec import GridSpec
|
||||
|
||||
# Ensure module palette indices are populated
|
||||
_module_index.clear()
|
||||
for i, mod in enumerate(result.graph.modules):
|
||||
_module_index[mod] = i
|
||||
|
||||
s = result.spectral
|
||||
m = result.metrics
|
||||
c = result.coupling
|
||||
|
|
@ -595,63 +726,52 @@ def generate_dashboard(result: AnalysisResult, output_path: str) -> None:
|
|||
fig.suptitle("Spectral Analysis Dashboard — Dependency DAG",
|
||||
fontsize=16, fontweight="bold", color="#e0e0e0")
|
||||
|
||||
# ── Top-left: Eigenvalue spectrum ──
|
||||
p = result.structural
|
||||
|
||||
# ── Top-left: Structural properties ──
|
||||
ax1 = fig.add_subplot(gs[0, 0])
|
||||
n = len(s.eigenvalues)
|
||||
colors_eig = ["#ff4444" if i == 1 else "#4fc3f7" for i in range(n)]
|
||||
markerline, stemlines, baseline = ax1.stem(
|
||||
range(n), s.eigenvalues, linefmt="-", markerfmt="o", basefmt=" "
|
||||
)
|
||||
markerline.set_color("#4fc3f7")
|
||||
markerline.set_markersize(5)
|
||||
stemlines.set_color("#4fc3f7")
|
||||
stemlines.set_alpha(0.6)
|
||||
# Highlight lambda_2
|
||||
if n > 1:
|
||||
ax1.plot(1, s.eigenvalues[1], "o", color="#ff4444", markersize=10,
|
||||
zorder=5, label=f"$\\lambda_2$ = {s.fiedler_value:.4f}")
|
||||
ax1.legend(fontsize=10, loc="upper left",
|
||||
facecolor="#16213e", edgecolor="#444")
|
||||
ax1.set_xlabel("Index")
|
||||
ax1.set_ylabel("Eigenvalue")
|
||||
ax1.set_title("Laplacian Eigenvalue Spectrum", fontsize=12, fontweight="bold")
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.axis("off")
|
||||
ax1.set_title("Structural Properties", fontsize=12, fontweight="bold")
|
||||
props = [
|
||||
("Edges/node (avg degree)", f"{p.avg_degree:.2f}"),
|
||||
("Max fan-in", f"{p.max_fan_in} ({p.max_fan_in_node})"),
|
||||
("Max fan-out", f"{p.max_fan_out} ({p.max_fan_out_node})"),
|
||||
("DAG depth", f"{p.dag_depth}"),
|
||||
("Clustering coefficient", f"{p.clustering_coeff:.4f}"),
|
||||
("Avg module size", f"{p.avg_module_size:.1f}"),
|
||||
("Avg module cohesion", f"{p.avg_module_cohesion:.3f}"),
|
||||
]
|
||||
y = 0.88
|
||||
for label, value in props:
|
||||
ax1.text(0.05, y, label, transform=ax1.transAxes, fontsize=10,
|
||||
color="#aaa", fontfamily="monospace", va="top")
|
||||
ax1.text(0.95, y, value, transform=ax1.transAxes, fontsize=10,
|
||||
fontweight="bold", color="#e0e0e0", fontfamily="monospace",
|
||||
va="top", ha="right")
|
||||
y -= 0.12
|
||||
|
||||
# ── Top-right: Fiedler vector ──
|
||||
# ── Top-right: Module cohesion ──
|
||||
ax2 = fig.add_subplot(gs[0, 1])
|
||||
if len(s.fiedler_vector) > 0:
|
||||
sorted_indices = np.argsort(s.fiedler_vector)
|
||||
sorted_values = s.fiedler_vector[sorted_indices]
|
||||
sorted_names = [s.node_names[i] for i in sorted_indices]
|
||||
sorted_modules = [s.node_modules[i] for i in sorted_indices]
|
||||
bar_colors = [get_module_color(mod) for mod in sorted_modules]
|
||||
|
||||
bars = ax2.barh(range(len(sorted_values)), sorted_values,
|
||||
color=bar_colors, edgecolor="none", height=0.8)
|
||||
ax2.axvline(x=0, color="#ff4444", linewidth=1.5, linestyle="--",
|
||||
alpha=0.8, label="Bisection boundary")
|
||||
ax2.set_yticks(range(len(sorted_names)))
|
||||
ax2.set_yticklabels(sorted_names, fontsize=6)
|
||||
ax2.set_xlabel("Fiedler value")
|
||||
ax2.set_title("Fiedler Vector (spectral bisection)", fontsize=12,
|
||||
fontweight="bold")
|
||||
|
||||
# Legend for modules
|
||||
unique_modules = []
|
||||
seen = set()
|
||||
for mod in sorted_modules:
|
||||
if mod not in seen:
|
||||
seen.add(mod)
|
||||
unique_modules.append(mod)
|
||||
from matplotlib.patches import Patch
|
||||
legend_patches = [Patch(facecolor=get_module_color(mod), label=mod)
|
||||
for mod in unique_modules]
|
||||
ax2.legend(handles=legend_patches, fontsize=7, loc="lower right",
|
||||
facecolor="#16213e", edgecolor="#444", ncol=2)
|
||||
cohesion_mods = [mod for mod in c.module_names
|
||||
if not math.isnan(p.module_cohesion.get(mod, float("nan")))]
|
||||
if cohesion_mods:
|
||||
cohesion_vals = [p.module_cohesion[mod] for mod in cohesion_mods]
|
||||
bar_colors = [get_module_color(mod) for mod in cohesion_mods]
|
||||
bars = ax2.barh(range(len(cohesion_mods)), cohesion_vals,
|
||||
color=bar_colors, edgecolor="none", height=0.6)
|
||||
ax2.set_yticks(range(len(cohesion_mods)))
|
||||
ax2.set_yticklabels(cohesion_mods, fontsize=9)
|
||||
ax2.set_xlim(0, 1.05)
|
||||
ax2.set_xlabel("Cohesion (intra-edges / max possible)")
|
||||
ax2.axvline(x=p.avg_module_cohesion, color="#ff4444", linewidth=1.5,
|
||||
linestyle="--", alpha=0.7, label=f"avg = {p.avg_module_cohesion:.3f}")
|
||||
ax2.legend(fontsize=9, loc="lower right",
|
||||
facecolor="#16213e", edgecolor="#444")
|
||||
ax2.grid(True, axis="x", alpha=0.3)
|
||||
else:
|
||||
ax2.text(0.5, 0.5, "No Fiedler vector\n(single node graph)",
|
||||
ax2.text(0.5, 0.5, "No modules with 2+ types",
|
||||
ha="center", va="center", fontsize=14, transform=ax2.transAxes)
|
||||
ax2.set_title("Fiedler Vector", fontsize=12, fontweight="bold")
|
||||
ax2.set_title("Module Cohesion", fontsize=12, fontweight="bold")
|
||||
|
||||
# ── Bottom-left: Module coupling heatmap ──
|
||||
ax3 = fig.add_subplot(gs[1, 0])
|
||||
|
|
@ -746,25 +866,40 @@ def generate_dashboard_html(
|
|||
m = result.metrics
|
||||
c = result.coupling
|
||||
|
||||
# Prepare data as JSON for embedding
|
||||
sorted_indices = list(np.argsort(s.fiedler_vector)) if len(s.fiedler_vector) > 0 else []
|
||||
fiedler_data = []
|
||||
for idx in sorted_indices:
|
||||
fiedler_data.append({
|
||||
"name": s.node_names[idx],
|
||||
"module": s.node_modules[idx],
|
||||
"value": float(s.fiedler_vector[idx]),
|
||||
})
|
||||
p = result.structural
|
||||
|
||||
eigenvalue_data = [{"index": i, "value": float(v)}
|
||||
for i, v in enumerate(s.eigenvalues)]
|
||||
# Prepare data as JSON for embedding
|
||||
structural_data = {
|
||||
"avg_degree": round(p.avg_degree, 2),
|
||||
"max_fan_in": p.max_fan_in,
|
||||
"max_fan_in_node": p.max_fan_in_node,
|
||||
"max_fan_out": p.max_fan_out,
|
||||
"max_fan_out_node": p.max_fan_out_node,
|
||||
"dag_depth": p.dag_depth,
|
||||
"clustering_coeff": round(p.clustering_coeff, 4),
|
||||
"avg_module_cohesion": round(p.avg_module_cohesion, 3),
|
||||
"avg_module_size": round(p.avg_module_size, 1),
|
||||
}
|
||||
|
||||
cohesion_data = []
|
||||
for mod in c.module_names:
|
||||
coh = p.module_cohesion.get(mod, float("nan"))
|
||||
if not math.isnan(coh):
|
||||
cohesion_data.append({
|
||||
"module": mod,
|
||||
"cohesion": round(coh, 3),
|
||||
"size": sum(1 for n in result.graph.nodes if n.module == mod),
|
||||
})
|
||||
|
||||
coupling_data = {
|
||||
"modules": c.module_names,
|
||||
"matrix": c.coupling_matrix.tolist(),
|
||||
}
|
||||
|
||||
# Module colors
|
||||
# Module colors — populate index from discovery order so palette rotates
|
||||
_module_index.clear()
|
||||
for i, mod in enumerate(result.graph.modules):
|
||||
_module_index[mod] = i
|
||||
all_modules = list(dict.fromkeys(n.module for n in result.graph.nodes))
|
||||
module_colors_json = {mod: get_module_color(mod) for mod in all_modules}
|
||||
|
||||
|
|
@ -799,12 +934,11 @@ def generate_dashboard_html(
|
|||
"cci_label": cci_label,
|
||||
"cci_color": cci_color,
|
||||
"cci_desc": cci_desc,
|
||||
"fiedler_value": round(s.fiedler_value, 4),
|
||||
}
|
||||
|
||||
data_blob = json.dumps({
|
||||
"eigenvalues": eigenvalue_data,
|
||||
"fiedler": fiedler_data,
|
||||
"structural": structural_data,
|
||||
"cohesion": cohesion_data,
|
||||
"coupling": coupling_data,
|
||||
"metrics": metrics_json,
|
||||
"module_colors": module_colors_json,
|
||||
|
|
@ -901,11 +1035,7 @@ svg text { user-select:none; }
|
|||
.hm-cell { cursor:pointer; transition:opacity 0.15s; }
|
||||
.hm-cell:hover { opacity:0.8; stroke:#4fc3f7; stroke-width:2; }
|
||||
|
||||
/* Eigenvalue bars */
|
||||
.ev-bar { cursor:pointer; transition:opacity 0.15s; }
|
||||
.ev-bar:hover { opacity:0.8; }
|
||||
|
||||
/* Fiedler bars */
|
||||
/* Cohesion / heatmap bars */
|
||||
.fi-bar { cursor:pointer; transition:opacity 0.15s; }
|
||||
.fi-bar:hover { opacity:0.85; }
|
||||
</style>
|
||||
|
|
@ -914,11 +1044,11 @@ svg text { user-select:none; }
|
|||
|
||||
<div class="tab-bar">
|
||||
<div class="title">swactor — dependency analysis</div>
|
||||
<button class="tab active" data-tab="dag">Dependency DAG</button>
|
||||
<button class="tab" data-tab="spectral">Spectral Analysis</button>
|
||||
<button class="tab active" data-tab="spectral">Spectral Analysis</button>
|
||||
<button class="tab" data-tab="dag">Dependency DAG</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="tab-dag">
|
||||
<div class="tab-content" id="tab-dag">
|
||||
<div id="dag-controls">
|
||||
<button onclick="zoomIn()">+</button>
|
||||
<button onclick="zoomOut()">−</button>
|
||||
|
|
@ -929,16 +1059,16 @@ svg text { user-select:none; }
|
|||
<div id="dag-loading">Loading Graphviz…</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-spectral">
|
||||
<div class="tab-content active" id="tab-spectral">
|
||||
<div class="grid">
|
||||
<div class="panel" id="panel-eigenvalues">
|
||||
<h2><span class="icon">λ</span> Laplacian Eigenvalue Spectrum</h2>
|
||||
<svg id="svg-eigenvalues"></svg>
|
||||
<div class="panel" id="panel-structural">
|
||||
<h2><span class="icon">◉</span> Structural Properties</h2>
|
||||
<div id="structural-content"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-fiedler">
|
||||
<h2><span class="icon">✂</span> Fiedler Vector — Spectral Bisection</h2>
|
||||
<svg id="svg-fiedler"></svg>
|
||||
<div class="panel" id="panel-cohesion">
|
||||
<h2><span class="icon">▨</span> Module Cohesion</h2>
|
||||
<svg id="svg-cohesion"></svg>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-heatmap">
|
||||
|
|
@ -959,7 +1089,7 @@ svg text { user-select:none; }
|
|||
<script>
|
||||
// ─── Data ──────────────────────────────────────────────────────────────────
|
||||
const DATA = __DATA_BLOB__;
|
||||
const { eigenvalues, fiedler, coupling, metrics, module_colors } = DATA;
|
||||
const { structural, cohesion, coupling, metrics, module_colors } = DATA;
|
||||
|
||||
// ─── Tab switching ─────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
|
|
@ -968,6 +1098,9 @@ document.querySelectorAll('.tab').forEach(btn => {
|
|||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||
if (btn.dataset.tab === 'dag') {
|
||||
window.dispatchEvent(new Event('dag-visible'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -984,138 +1117,99 @@ function hideTip() { TT.style.display = 'none'; }
|
|||
|
||||
function modColor(mod) { return module_colors[mod] || '#9e9e9e'; }
|
||||
|
||||
// ─── Eigenvalue Spectrum ───────────────────────────────────────────────────
|
||||
// ─── Structural Properties ────────────────────────────────────────────────
|
||||
(function() {
|
||||
const svg = document.getElementById('svg-eigenvalues');
|
||||
const W = 560, H = 300, M = {t:20,r:20,b:40,l:50};
|
||||
const c = document.getElementById('structural-content');
|
||||
const s = structural;
|
||||
c.innerHTML = `
|
||||
<div class="metrics-grid">
|
||||
<div class="sub-header">Density & Depth</div>
|
||||
<div class="metric-item"><span class="metric-label">Edges/node (avg degree)</span><span class="metric-value">${s.avg_degree}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">DAG depth</span><span class="metric-value">${s.dag_depth}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Clustering coefficient</span><span class="metric-value">${s.clustering_coeff}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Avg module size</span><span class="metric-value">${s.avg_module_size}</span></div>
|
||||
|
||||
<div class="sub-header">Dependency Hotspots</div>
|
||||
<div class="metric-item"><span class="metric-label">Max fan-in</span><span class="metric-value">${s.max_fan_in} ← ${s.max_fan_in_node}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Max fan-out</span><span class="metric-value">${s.max_fan_out} → ${s.max_fan_out_node}</span></div>
|
||||
|
||||
<div class="sub-header">Cohesion</div>
|
||||
<div class="metric-item"><span class="metric-label">Avg module cohesion</span><span class="metric-value">${s.avg_module_cohesion}</span></div>
|
||||
<div class="metric-item"><span class="metric-label">Cross-module ratio</span><span class="metric-value">${(metrics.cross_module_ratio*100).toFixed(1)}%</span></div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
|
||||
// ─── Module Cohesion ──────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const svg = document.getElementById('svg-cohesion');
|
||||
const n = cohesion.length;
|
||||
if (n === 0) return;
|
||||
const barH = Math.max(20, Math.min(36, 300/n));
|
||||
const W = 560, H = Math.max(200, n*barH + 60), M = {t:10,r:30,b:30,l:120};
|
||||
const w = W-M.l-M.r, h = H-M.t-M.b;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
const maxVal = Math.max(...eigenvalues.map(d=>d.value), 1);
|
||||
const xScale = i => M.l + (i / (eigenvalues.length-1||1)) * w;
|
||||
const yScale = v => M.t + h - (v / maxVal) * h;
|
||||
const xScale = v => M.l + v * w;
|
||||
const yScale = i => M.t + (i/n) * h + barH/2;
|
||||
|
||||
// Grid lines
|
||||
for (let tick = 0; tick <= maxVal; tick += Math.ceil(maxVal/5)) {
|
||||
const y = yScale(tick);
|
||||
// Background grid
|
||||
for (const tick of [0.25, 0.5, 0.75, 1.0]) {
|
||||
const x = xScale(tick);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:M.l,x2:W-M.r,y1:y,y2:y,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||
Object.entries({x1:x,x2:x,y1:M.t,y2:M.t+h,stroke:'#2a2a5a','stroke-width':0.5}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||
svg.appendChild(line);
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
txt.setAttribute('x', M.l-6); txt.setAttribute('y', y+4);
|
||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill','#888'); txt.setAttribute('font-size','10');
|
||||
txt.textContent = tick.toFixed(0);
|
||||
txt.setAttribute('x', x); txt.setAttribute('y', H-8);
|
||||
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#666'); txt.setAttribute('font-size','10');
|
||||
txt.textContent = (tick*100).toFixed(0) + '%';
|
||||
svg.appendChild(txt);
|
||||
}
|
||||
|
||||
// Axis labels
|
||||
const xLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
xLabel.setAttribute('x', M.l+w/2); xLabel.setAttribute('y', H-4);
|
||||
xLabel.setAttribute('text-anchor','middle'); xLabel.setAttribute('fill','#888'); xLabel.setAttribute('font-size','11');
|
||||
xLabel.textContent = 'Index';
|
||||
svg.appendChild(xLabel);
|
||||
|
||||
const yLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
yLabel.setAttribute('x', 14); yLabel.setAttribute('y', M.t+h/2);
|
||||
yLabel.setAttribute('text-anchor','middle'); yLabel.setAttribute('fill','#888');
|
||||
yLabel.setAttribute('font-size','11'); yLabel.setAttribute('transform', `rotate(-90,14,${M.t+h/2})`);
|
||||
yLabel.textContent = 'Eigenvalue';
|
||||
svg.appendChild(yLabel);
|
||||
|
||||
eigenvalues.forEach((d, i) => {
|
||||
const x = xScale(i), y = yScale(d.value), y0 = yScale(0);
|
||||
// Stem line
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:x,x2:x,y1:y0,y2:y,stroke:i===1?'#ff4444':'#4fc3f7','stroke-width':i===1?2.5:1.5,'stroke-opacity':i===1?1:0.6}).forEach(([k,v])=>line.setAttribute(k,v));
|
||||
svg.appendChild(line);
|
||||
// Dot
|
||||
const circ = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||
circ.setAttribute('cx',x); circ.setAttribute('cy',y);
|
||||
circ.setAttribute('r', i===1?6:3.5);
|
||||
circ.setAttribute('fill', i===1?'#ff4444':'#4fc3f7');
|
||||
circ.classList.add('ev-bar');
|
||||
circ.addEventListener('mousemove', e => showTip(e,
|
||||
`<span class="tt-label">λ<sub>${i}</sub></span> = <span class="tt-val">${d.value.toFixed(4)}</span>`
|
||||
+ (i===1 ? '<br><span style="color:#ff4444">Fiedler value (algebraic connectivity)</span>' : '')
|
||||
));
|
||||
circ.addEventListener('mouseleave', hideTip);
|
||||
svg.appendChild(circ);
|
||||
});
|
||||
|
||||
// Fiedler label
|
||||
if (eigenvalues.length > 1) {
|
||||
const lbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
lbl.setAttribute('x', xScale(1)+10); lbl.setAttribute('y', yScale(eigenvalues[1].value)-6);
|
||||
lbl.setAttribute('fill','#ff4444'); lbl.setAttribute('font-size','11'); lbl.setAttribute('font-weight','600');
|
||||
lbl.textContent = `\u03BB\u2082 = ${metrics.fiedler_value}`;
|
||||
svg.appendChild(lbl);
|
||||
}
|
||||
})();
|
||||
|
||||
// ─── Fiedler Vector ────────────────────────────────────────────────────────
|
||||
(function() {
|
||||
const svg = document.getElementById('svg-fiedler');
|
||||
const n = fiedler.length;
|
||||
const barH = Math.max(12, Math.min(22, 500/n));
|
||||
const W = 560, H = Math.max(300, n*barH + 60), M = {t:10,r:20,b:30,l:140};
|
||||
const w = W-M.l-M.r, h = H-M.t-M.b;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
|
||||
const maxAbs = Math.max(...fiedler.map(d=>Math.abs(d.value)), 0.01);
|
||||
const xScale = v => M.l + w/2 + (v/maxAbs) * (w/2);
|
||||
const yScale = i => M.t + (i/n) * h + barH/2;
|
||||
|
||||
// Zero line
|
||||
const zl = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:xScale(0),x2:xScale(0),y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>zl.setAttribute(k,v));
|
||||
svg.appendChild(zl);
|
||||
|
||||
// Partition labels
|
||||
const negCount = fiedler.filter(d=>d.value<0).length;
|
||||
if (negCount > 0 && negCount < n) {
|
||||
const lblA = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
lblA.setAttribute('x', M.l+4); lblA.setAttribute('y', M.t + (negCount/n)*h/2 + barH/2);
|
||||
lblA.setAttribute('fill','#ff4444'); lblA.setAttribute('font-size','10'); lblA.setAttribute('opacity','0.5');
|
||||
lblA.textContent = 'Partition A';
|
||||
svg.appendChild(lblA);
|
||||
}
|
||||
|
||||
fiedler.forEach((d, i) => {
|
||||
const x0 = xScale(0), x1 = xScale(d.value);
|
||||
const y = yScale(i) - barH*0.4;
|
||||
const bw = Math.abs(x1-x0);
|
||||
// Average line
|
||||
const avgX = xScale(structural.avg_module_cohesion);
|
||||
const avgLine = document.createElementNS('http://www.w3.org/2000/svg','line');
|
||||
Object.entries({x1:avgX,x2:avgX,y1:M.t,y2:M.t+h,stroke:'#ff4444','stroke-width':1.5,'stroke-dasharray':'5,3','stroke-opacity':0.7}).forEach(([k,v])=>avgLine.setAttribute(k,v));
|
||||
svg.appendChild(avgLine);
|
||||
const avgLbl = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
avgLbl.setAttribute('x', avgX+4); avgLbl.setAttribute('y', M.t+10);
|
||||
avgLbl.setAttribute('fill','#ff4444'); avgLbl.setAttribute('font-size','9'); avgLbl.setAttribute('opacity','0.8');
|
||||
avgLbl.textContent = 'avg';
|
||||
svg.appendChild(avgLbl);
|
||||
|
||||
cohesion.forEach((d, i) => {
|
||||
const barW = Math.max(d.cohesion * w, 2);
|
||||
const y = yScale(i) - barH*0.35;
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
||||
rect.setAttribute('x', Math.min(x0,x1)); rect.setAttribute('y', y);
|
||||
rect.setAttribute('width', Math.max(bw, 1)); rect.setAttribute('height', barH*0.8);
|
||||
rect.setAttribute('rx', 2);
|
||||
rect.setAttribute('x', M.l); rect.setAttribute('y', y);
|
||||
rect.setAttribute('width', barW); rect.setAttribute('height', barH*0.7);
|
||||
rect.setAttribute('rx', 3);
|
||||
rect.setAttribute('fill', modColor(d.module));
|
||||
rect.setAttribute('opacity', 0.85);
|
||||
rect.classList.add('fi-bar');
|
||||
rect.addEventListener('mousemove', e => showTip(e,
|
||||
`<span class="tt-label">${d.name}</span><br>` +
|
||||
`Module: <span class="tt-val">${d.module}</span><br>` +
|
||||
`Fiedler: <span class="tt-val">${d.value.toFixed(4)}</span><br>` +
|
||||
`Partition: <span class="tt-val">${d.value < 0 ? 'A' : 'B'}</span>`
|
||||
`<span class="tt-label">${d.module}</span><br>` +
|
||||
`Types: <span class="tt-val">${d.size}</span><br>` +
|
||||
`Cohesion: <span class="tt-val">${(d.cohesion*100).toFixed(1)}%</span>`
|
||||
));
|
||||
rect.addEventListener('mouseleave', hideTip);
|
||||
svg.appendChild(rect);
|
||||
|
||||
// Label
|
||||
// Value label on bar
|
||||
const valTxt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
valTxt.setAttribute('x', M.l + barW + 6); valTxt.setAttribute('y', yScale(i)+4);
|
||||
valTxt.setAttribute('fill','#ccc'); valTxt.setAttribute('font-size','10'); valTxt.setAttribute('font-weight','600');
|
||||
valTxt.textContent = (d.cohesion*100).toFixed(0) + '%';
|
||||
svg.appendChild(valTxt);
|
||||
|
||||
// Module label
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
txt.setAttribute('x', M.l-4); txt.setAttribute('y', yScale(i)+3);
|
||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill','#ccc');
|
||||
txt.setAttribute('font-size', Math.min(11, barH*0.75));
|
||||
txt.textContent = d.name;
|
||||
txt.setAttribute('x', M.l-8); txt.setAttribute('y', yScale(i)+4);
|
||||
txt.setAttribute('text-anchor','end'); txt.setAttribute('fill', modColor(d.module));
|
||||
txt.setAttribute('font-size','11'); txt.setAttribute('font-weight','600');
|
||||
txt.textContent = `${d.module} (${d.size})`;
|
||||
svg.appendChild(txt);
|
||||
});
|
||||
|
||||
// X axis label
|
||||
const xLabel = document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||
xLabel.setAttribute('x', M.l+w/2); xLabel.setAttribute('y', H-6);
|
||||
xLabel.setAttribute('text-anchor','middle'); xLabel.setAttribute('fill','#888'); xLabel.setAttribute('font-size','11');
|
||||
xLabel.textContent = 'Fiedler value';
|
||||
svg.appendChild(xLabel);
|
||||
})();
|
||||
|
||||
// ─── Module Coupling Heatmap ───────────────────────────────────────────────
|
||||
|
|
@ -1250,7 +1344,9 @@ vp.appendChild(svg);
|
|||
|
||||
// ─── Dark-mode SVG recoloring ──────────────────────────────────────────────
|
||||
svg.querySelectorAll('polygon[fill="white"]').forEach(el => el.setAttribute('fill','#1a1a2e'));
|
||||
svg.querySelectorAll('.graph > text, .cluster > text, .edge text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
||||
svg.querySelectorAll('.graph > text').forEach(el => el.setAttribute('fill','#e0e0e0'));
|
||||
svg.querySelectorAll('.cluster > text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||
svg.querySelectorAll('.edge text').forEach(el => el.setAttribute('fill','#ffb74d'));
|
||||
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
|
||||
|
||||
// ─── Click-to-focus ────────────────────────────────────────────────────────
|
||||
|
|
@ -1347,7 +1443,10 @@ window.resetView = function() {
|
|||
ty = (vh - bb.height * scale) / 2;
|
||||
applyTransform();
|
||||
};
|
||||
resetView();
|
||||
let dagFitted = false;
|
||||
window.addEventListener('dag-visible', () => {
|
||||
if (!dagFitted) { dagFitted = true; requestAnimationFrame(resetView); }
|
||||
});
|
||||
|
||||
window.zoomIn = function() { scale *= 1.3; applyTransform(); };
|
||||
window.zoomOut = function() { scale *= 0.7; applyTransform(); };
|
||||
|
|
@ -1367,8 +1466,8 @@ vp.addEventListener('click', e => { if (!didDrag && !e.target.closest('.node'))
|
|||
def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
||||
"""Convert analysis results to a JSON-serializable dict."""
|
||||
m = result.metrics
|
||||
s = result.spectral
|
||||
c = result.coupling
|
||||
p = result.structural
|
||||
|
||||
return {
|
||||
"graph": {
|
||||
|
|
@ -1378,12 +1477,13 @@ def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
|||
"connected_components": m.connected_components,
|
||||
"modules": c.module_names,
|
||||
},
|
||||
"spectral": {
|
||||
"eigenvalues": s.eigenvalues.tolist(),
|
||||
"fiedler_value": s.fiedler_value,
|
||||
"fiedler_vector": s.fiedler_vector.tolist(),
|
||||
"node_names": s.node_names,
|
||||
"node_modules": s.node_modules,
|
||||
"structural": {
|
||||
"avg_degree": p.avg_degree,
|
||||
"max_fan_in": {"count": p.max_fan_in, "node": p.max_fan_in_node},
|
||||
"max_fan_out": {"count": p.max_fan_out, "node": p.max_fan_out_node},
|
||||
"dag_depth": p.dag_depth,
|
||||
"clustering_coefficient": p.clustering_coeff,
|
||||
"avg_module_size": p.avg_module_size,
|
||||
},
|
||||
"module_coupling": {
|
||||
"module_names": c.module_names,
|
||||
|
|
@ -1391,15 +1491,17 @@ def metrics_to_dict(result: AnalysisResult) -> dict[str, Any]:
|
|||
"cross_module_edges": c.cross_module_edges,
|
||||
"total_edges": c.total_edges,
|
||||
},
|
||||
"module_cohesion": {
|
||||
mod: None if math.isnan(v) else v
|
||||
for mod, v in p.module_cohesion.items()
|
||||
},
|
||||
"metrics": {
|
||||
"algebraic_connectivity": m.algebraic_connectivity,
|
||||
"normalized_algebraic_connectivity": m.normalized_algebraic_connectivity,
|
||||
"spectral_entropy": m.spectral_entropy,
|
||||
"normalized_spectral_entropy": m.normalized_spectral_entropy,
|
||||
"edge_density": m.edge_density,
|
||||
"cross_module_ratio": m.cross_module_ratio,
|
||||
"spectral_radius": m.spectral_radius,
|
||||
"normalized_spectral_radius": m.normalized_spectral_radius,
|
||||
"avg_module_cohesion": p.avg_module_cohesion,
|
||||
"cci": m.cci,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -686,11 +686,12 @@ class TestIntegration(unittest.TestCase):
|
|||
d = metrics_to_dict(result)
|
||||
|
||||
self.assertIn("graph", d)
|
||||
self.assertIn("spectral", d)
|
||||
self.assertIn("structural", d)
|
||||
self.assertIn("module_coupling", d)
|
||||
self.assertIn("module_cohesion", d)
|
||||
self.assertIn("metrics", d)
|
||||
self.assertEqual(d["graph"]["n_nodes"], 3)
|
||||
self.assertIsInstance(d["spectral"]["eigenvalues"], list)
|
||||
self.assertIsInstance(d["structural"]["dag_depth"], int)
|
||||
self.assertIsInstance(d["metrics"]["cci"], float)
|
||||
|
||||
# Should be JSON-serializable
|
||||
|
|
|
|||
Loading…
Reference in a new issue