AgentStateGraph / app /src /content /embeds /prefix-tree-collapse.html
seonglae's picture
Automata from Agent Traces — interactive article
59027a2
Raw
History Blame Contribute Delete
15.9 kB
<!-- Prefix Tree Collapse: Animated tree → FSM visualization -->
<div class="prefix-collapse"></div>
<style>
.prefix-collapse { position: relative; width: 100%; min-height: 480px; }
.prefix-collapse svg { display: block; width: 100%; }
.prefix-collapse .top-bar {
display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap;
}
.prefix-collapse .seg-control {
display: inline-flex; background: var(--surface-bg);
border: 1px solid var(--border-color); border-radius: 8px;
padding: 3px; gap: 2px;
}
.prefix-collapse .seg-control button {
padding: 5px 14px; border-radius: 6px; border: none;
background: transparent; font-size: 12px; font-weight: 500;
color: var(--text-color); cursor: pointer; transition: all 0.15s ease;
opacity: 0.6;
}
.prefix-collapse .seg-control button:hover { opacity: 0.8; }
.prefix-collapse .seg-control button.active {
background: var(--text-color); color: var(--page-bg);
opacity: 1; font-weight: 600;
}
.prefix-collapse .stat-pills {
display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap;
}
.prefix-collapse .stat-pill {
padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600;
border: 1px solid var(--border-color); background: var(--surface-bg);
color: var(--text-color); font-variant-numeric: tabular-nums;
}
.prefix-collapse .stat-pill.highlight {
border-color: #3d5a80; color: #3d5a80;
}
.prefix-collapse .node-label-tree {
font-size: 9px; fill: var(--text-color); pointer-events: none; text-anchor: middle;
opacity: 0.7;
}
</style>
<script>
(() => {
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
s.addEventListener('load', () => cb(), { once: true });
};
const bootstrap = () => {
const container = document.querySelector('.prefix-collapse:not([data-mounted])');
if (!container) return;
container.dataset.mounted = 'true';
const d3 = window.d3;
// --- Palette (ported from browser/) ---
const RUST = '#3d5a80';
const traces = [
['init','sys','usr','bash','tool','edit','tool','bash','tool','submit','tool'],
['init','sys','usr','bash','tool','bash','tool','edit','tool','submit','tool'],
['init','sys','usr','edit','tool','bash','tool','submit','tool'],
['init','sys','usr','bash','tool','edit','tool','edit','tool','bash','tool','submit','tool'],
['init','sys','usr','bash','tool','bash','tool','bash','tool','submit','tool'],
];
// Build prefix tree
let nextId = 0;
const root = { id: nextId++, label: 'init', children: {}, depth: 0 };
traces.forEach(trace => {
let node = root;
for (let i = 1; i < trace.length; i++) {
const sym = trace[i];
if (!node.children[sym]) {
node.children[sym] = { id: nextId++, label: sym, children: {}, depth: i };
}
node = node.children[sym];
}
});
function flattenTree(node, parent) {
const result = [{ id: node.id, label: node.label, parent: parent ? parent.id : null, depth: node.depth }];
Object.values(node.children).forEach(child => {
result.push(...flattenTree(child, node));
});
return result;
}
const treeNodes = flattenTree(root, null);
const treeEdges = treeNodes.filter(n => n.parent !== null).map(n => ({ source: n.parent, target: n.id }));
const fsmNodes = [
{ id: 'f-init', label: 'init' },
{ id: 'f-sys', label: 'sys' },
{ id: 'f-usr', label: 'usr' },
{ id: 'f-bash', label: 'bash' },
{ id: 'f-tool', label: 'tool' },
{ id: 'f-edit', label: 'edit' },
{ id: 'f-submit', label: 'submit' },
];
const fsmEdges = [
{ source: 'f-init', target: 'f-sys' },
{ source: 'f-sys', target: 'f-usr' },
{ source: 'f-usr', target: 'f-bash' },
{ source: 'f-usr', target: 'f-edit' },
{ source: 'f-bash', target: 'f-tool' },
{ source: 'f-edit', target: 'f-tool' },
{ source: 'f-tool', target: 'f-bash' },
{ source: 'f-tool', target: 'f-edit' },
{ source: 'f-tool', target: 'f-submit' },
{ source: 'f-submit', target: 'f-tool' },
{ source: 'f-tool', target: 'f-usr' },
];
// Tree node colors (warm, subtle)
const treeNodeColor = '#3d5a80';
let showFSM = true;
// Top bar
const topBar = document.createElement('div');
topBar.className = 'top-bar';
const seg = document.createElement('div');
seg.className = 'seg-control';
const treeBtn = document.createElement('button');
treeBtn.textContent = 'Prefix Tree';
const mergeBtn = document.createElement('button');
mergeBtn.textContent = 'Merged FSM';
mergeBtn.className = 'active';
seg.append(treeBtn, mergeBtn);
const statPills = document.createElement('div');
statPills.className = 'stat-pills';
topBar.append(seg, statPills);
container.prepend(topBar);
function updateStats() {
if (!showFSM) {
statPills.innerHTML = '<span class="stat-pill">' + treeNodes.length + ' nodes</span><span class="stat-pill">' + treeEdges.length + ' edges</span>';
} else {
const ratio = Math.round(treeNodes.length / fsmNodes.length);
statPills.innerHTML = '<span class="stat-pill">' + fsmNodes.length + ' states</span><span class="stat-pill">' + fsmEdges.length + ' edges</span><span class="stat-pill highlight">' + treeNodes.length + ' \u2192 ' + fsmNodes.length + ' (' + ratio + '\u00d7)</span>';
}
}
function render() {
const oldSvg = container.querySelector('svg');
if (oldSvg) {
oldSvg.style.transition = 'opacity 0.2s ease';
oldSvg.style.opacity = '0';
setTimeout(() => oldSvg.remove(), 200);
}
const rect = container.getBoundingClientRect();
const W = Math.max(400, Math.round(rect.width));
const H = 420;
updateStats();
setTimeout(() => {
const svg = d3.select(container).append('svg')
.attr('width', W).attr('height', H)
.style('opacity', '0')
.style('transition', 'opacity 0.25s ease');
if (!showFSM) {
// ========== Prefix Tree ==========
// Arrow marker for tree
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'pc-tree-arrow').attr('viewBox', '0 0 10 6')
.attr('refX', 10).attr('refY', 3)
.attr('markerWidth', 5).attr('markerHeight', 3.5)
.attr('orient', 'auto')
.append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', '#b5afa5');
const treeData = d3.stratify()
.id(d => d.id)
.parentId(d => d.parent)(treeNodes);
const treeLayout = d3.tree().size([W - 60, H - 80]);
const layoutData = treeLayout(treeData);
// Edges - warm taupe
svg.append('g').selectAll('path').data(layoutData.links()).enter().append('path')
.attr('d', d => {
const sx = d.source.x + 30, sy = d.source.y + 35;
const tx = d.target.x + 30, ty = d.target.y + 35;
const my = (sy + ty) / 2;
return 'M' + sx + ',' + sy + ' C' + sx + ',' + my + ' ' + tx + ',' + my + ' ' + tx + ',' + ty;
})
.attr('stroke', '#b5afa5')
.attr('stroke-opacity', 0.35).attr('stroke-width', 1.2)
.attr('fill', 'none');
// Nodes - rust tinted by depth
svg.append('g').selectAll('circle').data(layoutData.descendants()).enter().append('circle')
.attr('cx', d => d.x + 30).attr('cy', d => d.y + 35)
.attr('r', d => d.depth === 0 ? 7 : 5)
.attr('fill', d => {
if (d.depth === 0) return 'var(--surface-bg)';
const t = Math.min(1, d.depth / 8);
return 'rgba(61, 90, 128, ' + (0.15 + t * 0.35) + ')';
})
.attr('stroke', d => d.depth === 0 ? RUST : '#b5afa5')
.attr('stroke-width', d => d.depth === 0 ? 2 : 0.8);
// Init double circle
const initNode = layoutData.descendants().find(d => d.depth === 0);
if (initNode) {
svg.append('circle')
.attr('cx', initNode.x + 30).attr('cy', initNode.y + 35)
.attr('r', 4).attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1);
}
// Labels for shallow nodes
svg.append('g').selectAll('text').data(layoutData.descendants().filter(d => d.depth <= 2))
.enter().append('text').attr('class', 'node-label-tree')
.attr('x', d => d.x + 30).attr('y', d => d.y + 23)
.attr('font-family', 'IBM Plex Mono, ui-monospace, monospace')
.attr('font-size', '8.5px')
.text(d => d.data.label);
} else {
// ========== Merged FSM (browser-style) ==========
// Arrow marker - warm taupe
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'pc-fsm-arrow').attr('viewBox', '0 0 10 6')
.attr('refX', 10).attr('refY', 3)
.attr('markerWidth', 7).attr('markerHeight', 4.5)
.attr('orient', 'auto')
.append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', '#8a8478');
// Pre-compute degree
const deg = {};
fsmNodes.forEach(n => { deg[n.id] = 0; });
fsmEdges.forEach(e => { deg[e.source]++; deg[e.target]++; });
const maxDeg = Math.max(1, ...Object.values(deg));
// Edge set for bidirectional detection
const edgeSet = new Set();
fsmEdges.forEach(e => edgeSet.add(e.source + '|' + e.target));
function nodeR(id) {
if (id === 'f-init') return 13;
return 12 + (deg[id] / maxDeg) * 6;
}
// Force layout - run to completion, then static drag
const nodes = fsmNodes.map(n => ({ ...n }));
const links = fsmEdges.map(e => ({ source: e.source, target: e.target }));
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(85))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(W / 2, H / 2))
.force('collision', d3.forceCollide().radius(d => nodeR(d.id) + 6))
.stop();
// Run simulation to completion (static layout)
for (let i = 0; i < 300; i++) simulation.tick();
const linkGroup = svg.append('g');
const nodeGroup = svg.append('g');
// Edges
const linkSel = linkGroup.selectAll('path').data(links).enter().append('path')
.attr('fill', 'none')
.attr('stroke', '#b5afa5')
.attr('stroke-width', d => {
return 0.5 + ((deg[d.source.id] + deg[d.target.id]) / (maxDeg * 2)) * 2.5;
})
.attr('stroke-opacity', d => {
return 0.3 + ((deg[d.source.id] + deg[d.target.id]) / (maxDeg * 2)) * 0.5;
})
.attr('marker-end', 'url(#pc-fsm-arrow)');
// Nodes
const nodeGs = nodeGroup.selectAll('g').data(nodes).enter().append('g')
.attr('cursor', 'grab');
nodeGs.each(function(d) {
const g = d3.select(this);
const isInit = d.id === 'f-init';
const r = nodeR(d.id);
const degNorm = deg[d.id] / maxDeg;
if (isInit) {
g.append('circle').attr('r', r)
.attr('fill', 'var(--surface-bg)').attr('stroke', RUST).attr('stroke-width', 2);
g.append('circle').attr('r', r - 3)
.attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1);
} else {
g.append('circle').attr('r', r)
.attr('fill', 'rgba(61, 90, 128, ' + (0.06 + degNorm * 0.14) + ')')
.attr('stroke', degNorm > 0.5 ? RUST : '#b5afa5')
.attr('stroke-width', degNorm > 0.5 ? 1.5 : 1);
}
g.append('text')
.attr('y', r + 12)
.attr('text-anchor', 'middle')
.attr('fill', 'var(--text-color)').attr('opacity', 0.7)
.attr('font-size', '9px')
.attr('font-family', 'IBM Plex Mono, ui-monospace, monospace')
.attr('font-weight', '600')
.attr('pointer-events', 'none')
.text(d.label);
});
// Update positions helper (no simulation, just re-render)
function updatePositions() {
linkSel.attr('d', d => {
const s = d.source, t = d.target;
const rS = nodeR(s.id), rT = nodeR(t.id);
if (s.id === t.id) {
return 'M' + s.x + ',' + (s.y - rS) +
' C' + (s.x - 30) + ',' + (s.y - rS - 35) +
' ' + (s.x + 30) + ',' + (s.y - rS - 35) +
' ' + s.x + ',' + (s.y - rS);
}
const dx = t.x - s.x, dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = dx / dist, ny = dy / dist;
if (edgeSet.has(t.id + '|' + s.id)) {
const curve = 22;
const mx = (s.x + t.x) / 2 - ny * curve;
const my = (s.y + t.y) / 2 + nx * curve;
const x1 = s.x + nx * (rS + 2) - ny * 3;
const y1 = s.y + ny * (rS + 2) + nx * 3;
const x2 = t.x - nx * (rT + 4) - ny * 3;
const y2 = t.y - ny * (rT + 4) + nx * 3;
return 'M' + x1 + ',' + y1 + ' Q' + mx + ',' + my + ' ' + x2 + ',' + y2;
}
const x1 = s.x + nx * (rS + 2);
const y1 = s.y + ny * (rS + 2);
const x2 = t.x - nx * (rT + 4);
const y2 = t.y - ny * (rT + 4);
return 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2;
});
nodeGs.attr('transform', d => {
d.x = Math.max(40, Math.min(W - 40, d.x));
d.y = Math.max(40, Math.min(H - 40, d.y));
return 'translate(' + d.x + ',' + d.y + ')';
});
}
// Initial render from pre-computed positions
updatePositions();
// Static drag - only moves the dragged node, no forces
nodeGs.call(d3.drag()
.on('start', function() { d3.select(this).attr('cursor', 'grabbing'); })
.on('drag', (ev, d) => { d.x = ev.x; d.y = ev.y; updatePositions(); })
.on('end', function() { d3.select(this).attr('cursor', 'grab'); })
);
}
// Fade in
requestAnimationFrame(() => { svg.style('opacity', '1'); });
}, oldSvg ? 200 : 0);
}
treeBtn.addEventListener('click', () => {
if (!showFSM) return;
showFSM = false;
treeBtn.className = 'active';
mergeBtn.className = '';
render();
});
mergeBtn.addEventListener('click', () => {
if (showFSM) return;
showFSM = true;
mergeBtn.className = 'active';
treeBtn.className = '';
render();
});
render();
let resizeTimer;
if (window.ResizeObserver) new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => render(), 150);
}).observe(container);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
else ensureD3(bootstrap);
})();
</script>