/* ProvSQL Studio: circuit-mode renderer.
Lazy-loaded by app.js once the user clicks a UUID/agg_token cell in
circuit-mode results. The DOT layout is computed server-side
(provsql_studio/circuit.py via `dot -Tjson`); this module just paints,
handles zoom/pan/pin, and delegates expansion to the server. */
(function () {
if (window.ProvsqlCircuit) return; // idempotent; app.js may call setup repeatedly
const NS = 'http://www.w3.org/2000/svg';
const TYPE_SUMMARY = {
plus: 'Plus gate (⊕): alternatives (duplicate elimination, multi-derivation)',
times: 'Times gate (⊗): combined use (join, cross product)',
monus: 'Monus gate (⊖): m-semiring difference (EXCEPT)',
project: 'Project gate (Π): column projection (where-provenance only)',
eq: 'Eq gate (⋈): equijoin witness (where-provenance only)',
agg: 'Aggregation gate: aggregate function',
semimod: 'Semimodule scalar (⋆): tensor product of scalar and semiring value',
cmp: 'Compare gate: aggregate-value comparison',
delta: 'Delta gate (δ): δ-semiring operator',
value: 'Value gate: scalar constant',
mulinput: 'Multivalued input (⋮)',
input: 'Input gate (ι): base tuple',
one: 'One (𝟙): semiring ⊗ identity (always true)',
zero: 'Zero (𝟘): semiring ⊕ identity (always false)',
update: 'Update gate (υ): INSERT / UPDATE / DELETE',
};
// ─── state ────────────────────────────────────────────────────────────
let state = {
scene: null, // {nodes, edges, root, depth} from /api/circuit
showUuids: false,
zoom: 1,
pan: { x: 0, y: 0 },
pinnedNode: null,
// Drag-to-move offsets, keyed by node id. Survive frontier expansion
// (paint() reads them on every repaint) so the user's manual nudges
// aren't undone when new nodes appear; reset on renderCircuit() so a
// new circuit always starts from the Graphviz layout.
dragOffsets: Object.create(null),
};
let svg = null, edgeLayer = null, nodeLayer = null, bannerEl = null;
let titleEl = null, subEl = null;
let inspectorEl = null, inspectorTitle = null, inspectorBody = null;
// Active node-drag session, populated on mousedown over a .node-group
// and consumed by the window-level mousemove/mouseup handlers.
let _drag = null;
// Gate types whose children carry a meaningful order: cmp's lhs/rhs,
// monus's minuend/subtrahend, and agg : but agg only when the function
// is order-sensitive (array_agg, string_agg, json_agg, …). For
// sum/count/min/max/avg the result is independent of input order, so
// the digits would be noise. semimod is omitted: its value/scalar
// split is implied by gate type. eq has a single child so positional
// labels would be redundant.
const ORDERED_GATES = new Set(['cmp', 'monus', 'agg']);
const COMMUTATIVE_AGG = new Set(['sum', 'count', 'min', 'max', 'avg']);
function shouldLabelChildren(parent) {
if (!ORDERED_GATES.has(parent.type)) return false;
if (parent.type === 'agg') {
const fn = (parent.info1_name || '').toLowerCase();
return !COMMUTATIVE_AGG.has(fn);
}
return true;
}
// ─── public API ───────────────────────────────────────────────────────
window.ProvsqlCircuit = {
init, // bind DOM handles after the sidebar markup is injected
renderCircuit, // (scene): replace the current scene
setStatus, // (title, sub): update header copy
showLoading, // (): placeholder while fetching
showError, // (msg)
showTooLarge, // (payload, onRetry): structured 413 banner with retry button
clearScene, // (): wipe canvas, inspector, eval result, and target
};
// ─── init ─────────────────────────────────────────────────────────────
function init() {
svg = document.getElementById('circuit');
edgeLayer = document.getElementById('circuit-edges');
nodeLayer = document.getElementById('circuit-nodes');
bannerEl = document.getElementById('cv-banner');
titleEl = document.getElementById('circuit-title');
subEl = document.getElementById('circuit-sub');
inspectorEl = document.getElementById('inspector');
inspectorTitle = document.getElementById('inspector-title');
inspectorBody = document.getElementById('inspector-body');
// Toolbar.
document.getElementById('tool-zoom-in').onclick = () => { state.zoom = Math.min(2.5, state.zoom * 1.2); fitView(); };
document.getElementById('tool-zoom-out').onclick = () => { state.zoom = Math.max(0.4, state.zoom / 1.2); fitView(); };
document.getElementById('tool-zoom-fit').onclick = () => { state.zoom = 1; state.pan = { x: 0, y: 0 }; fitView(); };
const uBtn = document.getElementById('tool-show-uuids');
// Sync body class with the initial pressed state so query-result UUID
// cells (rendered by formatCell with paired short/full spans) start
// out matching whatever the toggle currently shows.
document.body.classList.toggle('show-uuids', state.showUuids);
uBtn.onclick = () => {
state.showUuids = !state.showUuids;
uBtn.setAttribute('aria-pressed', String(state.showUuids));
// Drives the .wp-uuid__short / .wp-uuid__full visibility in the
// result table without re-rendering it; works even when no circuit
// has been loaded yet (the toggle is shared between the result
// table and the circuit view).
document.body.classList.toggle('show-uuids', state.showUuids);
if (state.scene) {
paint();
// If a node is pinned, its inspector is showing the abbreviated
// (or full) UUID under the old toggle state. Re-render so the
// displayed uuid line tracks the new toggle.
if (state.pinnedNode) {
const pinned = state.scene.nodes.find(n => n.id === state.pinnedNode);
if (pinned) openInspector(pinned);
}
}
};
// Close = clear pin: dismiss the inspector AND drop state.pinnedNode
// so subsequent paint() / Show-UUIDs toggles don't reopen it. The X
// button used to call closeInspector directly (CSS-only hide), which
// left pinnedNode set; the show-uuids handler then saw a "pinned"
// node and re-rendered the panel.
document.getElementById('inspector-close').onclick = clearPin;
// Fullscreen toggle: a body-level class pins .cv-canvas to the
// viewport via CSS; the ResizeObserver already wired up below
// catches the new size and reflows the viewBox via fitView. Esc
// exits : that's the standard convention for fullscreen and saves
// a trip to the toolbar.
const fsBtn = document.getElementById('tool-fullscreen');
if (fsBtn) {
fsBtn.onclick = () => toggleFullscreen();
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('circuit-fullscreen')) {
toggleFullscreen(false);
}
});
}
// Semiring-evaluation strip wiring. The select drives which side
// control is visible: a provenance-mapping picker for compiled
// semirings (mapping is optional for boolexpr and prov-xml), a
// method picker for probability.
initEvalStrip();
// Pan via drag.
let dragging = false, dragStart = null;
svg.addEventListener('mousedown', (e) => {
if (e.target.closest('.node-group')) return;
dragging = true;
dragStart = { x: e.clientX, y: e.clientY, panX: state.pan.x, panY: state.pan.y };
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = (e.clientX - dragStart.x) / state.zoom;
const dy = (e.clientY - dragStart.y) / state.zoom;
state.pan.x = dragStart.panX - dx;
state.pan.y = dragStart.panY - dy;
fitView();
});
window.addEventListener('mouseup', () => { dragging = false; });
// Background click clears the pin.
svg.addEventListener('click', (e) => {
if (e.target.closest('.node-group')) return;
if (state.pinnedNode) clearPin();
});
// Wheel-to-zoom. Same clamp as the toolbar buttons (0.4..2.5) but
// a smaller per-tick factor so successive notches feel smooth. We
// need passive: false to call preventDefault : otherwise the
// browser also scrolls the page while the user is zooming the
// canvas.
svg.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
state.zoom = Math.max(0.4, Math.min(2.5, state.zoom * factor));
fitView();
}, { passive: false });
// Re-fit when the canvas's on-screen size changes (e.g. window
// resize, sidebar reflow). fitView builds the viewBox from the
// SVG's clientWidth/clientHeight, so a stale fit otherwise leaves
// the circuit clipped or letterboxed against the new geometry.
if (window.ResizeObserver) {
new ResizeObserver(() => { if (state.scene) fitView(); }).observe(svg);
} else {
window.addEventListener('resize', () => { if (state.scene) fitView(); });
}
// Drag-to-move circuit nodes. Per-node mousedown handlers (set in
// paint()) seed `_drag`; the window-level move/up handlers track the
// gesture so the drag continues even if the pointer leaves the
// node's circle.
window.addEventListener('mousemove', _onDragMove);
window.addEventListener('mouseup', _onDragEnd);
const resetBtn = document.getElementById('tool-reset-layout');
if (resetBtn) resetBtn.onclick = resetLayout;
}
// Wipe any user-applied positional offsets. Re-paints so the next
// frame restores the Graphviz layout. The "I made it worse" escape
// hatch flagged in the v0.2 TODO: positions are accumulated tweaks,
// and there is no per-node "reset this one" affordance, so a single
// canvas-wide reset is the simplest recovery path.
function resetLayout() {
state.dragOffsets = Object.create(null);
if (state.scene) paint();
}
function setStatus(title, sub) {
if (titleEl && title != null) titleEl.textContent = title;
if (subEl && sub != null) subEl.textContent = sub;
}
function hideBanner() {
if (!bannerEl) bannerEl = document.getElementById('cv-banner');
if (bannerEl) {
bannerEl.hidden = true;
bannerEl.innerHTML = '';
}
}
function showLoading() {
hideBanner();
if (edgeLayer) edgeLayer.innerHTML = '';
if (nodeLayer) {
nodeLayer.innerHTML =
''
+ 'Loading…';
}
setStatus('Provenance Circuit', 'Fetching subgraph…');
}
function clearScene() {
hideBanner();
if (edgeLayer) edgeLayer.innerHTML = '';
if (nodeLayer) nodeLayer.innerHTML = '';
state.scene = null;
state.pinnedNode = null;
state.dragOffsets = Object.create(null);
closeInspector();
setStatus('Provenance Circuit', 'Click a UUID cell to render.');
refreshEvalTarget();
clearEvalResult();
}
function showError(msg) {
hideBanner();
if (edgeLayer) edgeLayer.innerHTML = '';
if (nodeLayer) {
nodeLayer.innerHTML =
''
+ escapeHtml(String(msg)) + '';
}
setStatus('Provenance Circuit', 'Error.');
}
// Structured "circuit too large" banner. payload comes straight from
// the 413 body: {node_count, cap, depth, depth_1_size, hint}. onRetry
// is invoked with the suggested lower depth when the user clicks the
// retry button; the button is suppressed entirely when depth <= 1 or
// when even depth-1 wouldn't fit under the cap (the wide-bound case).
//
// opts.rootUuid: when given (loadCircuit's 413 path), install a stub
// scene rooted at that UUID so the eval strip can still fire against
// it -- the eval API only needs the token, not a rendered DAG, so a
// too-large circuit shouldn't lock the user out of evaluation.
// Omit it from expandFrontier's 413 path: the existing rendered
// scene is still the right eval target there.
function showTooLarge(payload, onRetry, opts) {
if (!bannerEl) bannerEl = document.getElementById('cv-banner');
if (edgeLayer) edgeLayer.innerHTML = '';
if (nodeLayer) nodeLayer.innerHTML = '';
if (opts && opts.rootUuid) {
state.scene = {
root: opts.rootUuid,
nodes: [],
edges: [],
depth: payload && payload.depth != null ? payload.depth : 0,
};
state.pinnedNode = null;
state.dragOffsets = Object.create(null);
closeInspector();
refreshEvalTarget();
}
if (!bannerEl) return;
const count = payload && payload.node_count != null ? payload.node_count : 0;
const cap = payload && payload.cap != null ? payload.cap : 0;
const depth = payload && payload.depth != null ? payload.depth : null;
const d1 = payload && payload.depth_1_size != null ? payload.depth_1_size : null;
// Offer "Render at depth 1" only when the user is at depth > 1 AND
// the depth-1 view (root + direct children) actually fits under
// the cap. Wide-bound circuits (e.g. an aggregation root with
// thousands of children) leave d1 > cap, so the button vanishes
// rather than promising a render that would 413 again.
const offerD1 = depth != null && depth > 1 && d1 != null && d1 <= cap;
let html = '
Circuit too large to render
';
html += '
This subgraph has '
+ count.toLocaleString() + ' nodes; the cap is '
+ cap.toLocaleString() + '';
if (depth != null) {
html += ' (rendering at depth ' + depth + ')';
}
html += '.
';
if (offerD1) {
html += '
'
+ '
';
}
bannerEl.innerHTML = html;
bannerEl.hidden = false;
if (offerD1 && typeof onRetry === 'function') {
const btn = document.getElementById('cv-banner-retry');
if (btn) btn.addEventListener('click', () => onRetry(1), { once: true });
}
setStatus('Provenance Circuit', 'Circuit too large.');
}
function renderCircuit(scene) {
hideBanner();
state.scene = scene;
state.pinnedNode = null;
// Each new circuit starts from a clean fit: reset zoom + pan so
// the whole graph fits in the viewport regardless of how the user
// had panned/zoomed the previous one. The fitView() inside paint()
// then sizes the viewBox around the new bounding box. Drop any
// node-drag offsets accumulated against the previous circuit:
// they're keyed by uuid, so a stray entry from a different DAG
// would otherwise re-apply if the same uuid recurred.
state.zoom = 1;
state.pan = { x: 0, y: 0 };
state.dragOffsets = Object.create(null);
closeInspector();
paint();
refreshEvalTarget();
clearEvalResult();
}
// ─── paint ────────────────────────────────────────────────────────────
function paint() {
if (!state.scene || !state.scene.nodes.length) {
showError('Empty circuit (no nodes returned).');
return;
}
nodeLayer.innerHTML = '';
paintEdges();
// nodes
for (const n of state.scene.nodes) {
const cls = `node-group node--${n.type}` + (n.frontier ? ' is-frontier' : '');
const p = nodePos(n);
const g = svgEl('g', { class: cls, 'data-id': n.id, transform: `translate(${p.x},${p.y})` });
g.appendChild(svgEl('circle', { class: 'node-shape', r: 22 }));
const label = svgEl('text', { class: 'node-label', y: -2 });
label.textContent = n.label || n.type[0];
g.appendChild(label);
// Meta line below: only leaf gates (input / update : both reference
// a source row) get one. Internal gates stay bare : dropping a
// 36-char UUID under each circle overlapped the edge curves and
// made nothing readable; the full UUID is one click away in the
// inspector. Leaves always render their compact form (relation id
// when info1 is set, otherwise an abbreviated UUID) regardless of
// the "Show UUIDs" toggle: leaves are dense enough that the full
// UUID would overflow neighbouring nodes.
const isLeafGate = n.type === 'input' || n.type === 'update';
const metaText = isLeafGate
? (n.info1 ? `tbl ${n.info1}` : shortUuid(n.id))
: '';
if (metaText) {
const meta = svgEl('text', { class: 'node-meta', y: 38 });
meta.textContent = metaText;
g.appendChild(meta);
}
// Frontier marker: small "+" badge top-right
if (n.frontier) {
const badge = svgEl('circle', { class: 'frontier-badge', cx: 16, cy: -16, r: 7,
fill: 'var(--gold-500)', stroke: 'var(--gold-700)' });
const bt = svgEl('text', { x: 16, y: -16, 'text-anchor': 'middle',
'dominant-baseline': 'central', 'font-size': 11,
'font-weight': '700', fill: 'var(--purple-900)',
'pointer-events': 'none' });
bt.textContent = '+';
g.appendChild(badge);
g.appendChild(bt);
}
g.addEventListener('mousedown', (ev) => _onNodeMouseDown(ev, n, g));
g.addEventListener('click', (ev) => {
ev.stopPropagation();
// A drag that crossed the movement threshold sets this flag so
// the post-mouseup click does not pin / expand the node we just
// dropped : the user's intent was to move it, not to click it.
if (g._suppressClick) { g._suppressClick = false; return; }
onNodeClick(n);
});
g.addEventListener('mouseenter', () => highlightSubtree(n.id, true));
g.addEventListener('mouseleave', () => { if (!state.pinnedNode) highlightSubtree(n.id, false); });
nodeLayer.appendChild(g);
}
fitView();
if (titleEl) titleEl.textContent = 'Provenance Circuit';
if (subEl) {
// Emit the root UUID as a short/full pair so the toolbar's "Show
// UUIDs" button toggles its display via the body-level CSS class
// (no need to rerun the painter on toggle).
const root = state.scene.root;
subEl.innerHTML =
`${state.scene.nodes.length} gates · root `
+ ``
+ `${escapeHtml(shortUuid(root))}`
+ `${escapeHtml(root)}`
+ ``;
}
}
function fitView() {
if (!state.scene) return;
// Bounding box is over the displaced positions: a node the user
// dragged outside the original Graphviz envelope still belongs
// inside the viewBox, otherwise "Fit" silently clips it.
const ps = state.scene.nodes.map(nodePos);
const xs = ps.map(p => p.x);
const ys = ps.map(p => p.y);
const minX = Math.min(...xs) - 60, maxX = Math.max(...xs) + 60;
const minY = Math.min(...ys) - 60, maxY = Math.max(...ys) + 60;
const bbW = Math.max(maxX - minX, 200);
const bbH = Math.max(maxY - minY, 150);
const cx = minX + bbW / 2 + state.pan.x;
const cy = minY + bbH / 2 + state.pan.y;
// Match the viewBox aspect ratio to the SVG element's on-screen
// aspect ratio. With preserveAspectRatio="xMidYMid meet" any
// mismatch is rendered as letterbox bands inside the canvas
// border, so the circuit appears to live in a smaller area than
// the bordered rectangle. We take the dimensions from the parent
// .cv-canvas (the visibly-bordered container) rather than the
// SVG itself: SVG sizing inside a flex parent can be reported as
// half-height in some browsers because of the SVG element's
// intrinsic-aspect-ratio quirks, so we anchor on the container
// whose box model is unambiguous.
const host = svg.parentElement || svg;
const elW = host.clientWidth || bbW;
const elH = host.clientHeight || bbH;
const aspect = elW / elH;
let vbW, vbH;
if (bbW / bbH > aspect) { vbW = bbW; vbH = bbW / aspect; }
else { vbH = bbH; vbW = bbH * aspect; }
vbW /= state.zoom;
vbH /= state.zoom;
svg.setAttribute('viewBox', `${cx - vbW / 2} ${cy - vbH / 2} ${vbW} ${vbH}`);
}
// ─── edges + position helpers ────────────────────────────────────────
// The painted (x, y) for a node: layout coordinate plus any drag offset.
function nodePos(n) {
const o = state.dragOffsets[n.id];
return o ? { x: n.x + o.dx, y: n.y + o.dy } : { x: n.x, y: n.y };
}
// Rebuilds every edge path + ordered-child position label from
// current nodePos(). Cheap (a few dozen paths typically), and lets
// drag-move re-flow incident edges without diffing.
function paintEdges() {
edgeLayer.innerHTML = '';
if (!state.scene) return;
const nodesById = Object.fromEntries(state.scene.nodes.map(n => [n.id, n]));
for (const e of state.scene.edges) {
const from = nodesById[e.from], to = nodesById[e.to];
if (!from || !to) continue;
const fp = nodePos(from), tp = nodePos(to);
const path = svgEl('path', {
class: 'edge',
d: `M ${fp.x} ${fp.y + 22} C ${fp.x} ${fp.y + 50}, ${tp.x} ${tp.y - 50}, ${tp.x} ${tp.y - 22}`,
'data-from': e.from, 'data-to': e.to,
});
edgeLayer.appendChild(path);
// Position label at the child end of the edge for ordered gates.
// Offset 32px (r=22 + a small gap) away from the child centre
// along the edge direction so the digit clears the stroke.
if (shouldLabelChildren(from) && e.child_pos != null) {
const dx = fp.x - tp.x;
const dy = fp.y - tp.y;
const len = Math.hypot(dx, dy) || 1;
const offset = 32;
const lx = tp.x + (dx / len) * offset;
const ly = tp.y + (dy / len) * offset;
const tag = svgEl('text', {
class: 'edge-pos',
x: lx, y: ly,
'text-anchor': 'middle', 'dominant-baseline': 'central',
});
tag.textContent = String(e.child_pos);
edgeLayer.appendChild(tag);
}
}
// The pinned-subtree edge highlight lives on `.is-active` classes
// we just discarded; reapply so a drag-while-pinned doesn't lose
// the visual cue.
if (state.pinnedNode) {
const set = descendants(state.pinnedNode);
edgeLayer.querySelectorAll('.edge').forEach(p => {
if (set.has(p.dataset.from) && set.has(p.dataset.to)) p.classList.add('is-active');
});
}
}
// ─── drag-to-move ────────────────────────────────────────────────────
// Convert client (mouse) coordinates to the SVG's user-space, so a
// delta in pixels translates correctly regardless of the current
// zoom / pan / aspect ratio. Reading getScreenCTM() each call is
// fine: the SVG only resizes on layout changes, not per mousemove.
function clientToSvg(clientX, clientY) {
const ctm = svg.getScreenCTM();
if (!ctm) return { x: clientX, y: clientY };
const pt = svg.createSVGPoint();
pt.x = clientX; pt.y = clientY;
const p = pt.matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function _onNodeMouseDown(e, n, g) {
if (e.button !== 0) return;
// Don't kick off the SVG-level pan handler underneath us.
e.stopPropagation();
// Fresh interaction: clear any stale click-suppress flag from a
// previous drag whose mouseup happened off-element (no click event
// delivered to clear it the natural way).
g._suppressClick = false;
const start = clientToSvg(e.clientX, e.clientY);
const off = state.dragOffsets[n.id] || { dx: 0, dy: 0 };
_drag = {
nodeId: n.id, group: g,
sx: start.x, sy: start.y,
origDx: off.dx, origDy: off.dy,
clientX: e.clientX, clientY: e.clientY,
didDrag: false,
};
}
function _onDragMove(e) {
if (!_drag) return;
// Movement threshold (~4px in screen space): below this, we treat
// the gesture as a click in waiting and don't perturb the layout.
const dpx = Math.hypot(e.clientX - _drag.clientX, e.clientY - _drag.clientY);
if (!_drag.didDrag && dpx < 4) return;
_drag.didDrag = true;
const cur = clientToSvg(e.clientX, e.clientY);
state.dragOffsets[_drag.nodeId] = {
dx: _drag.origDx + (cur.x - _drag.sx),
dy: _drag.origDy + (cur.y - _drag.sy),
};
// Translate the moved group; meta line, label, and frontier badge
// are children of the group, so they follow for free.
const node = state.scene && state.scene.nodes.find(x => x.id === _drag.nodeId);
if (node && _drag.group) {
const p = nodePos(node);
_drag.group.setAttribute('transform', `translate(${p.x},${p.y})`);
}
// Reflow incident edges (cheap full rebuild beats diffing).
paintEdges();
}
function _onDragEnd() {
if (_drag && _drag.didDrag && _drag.group) {
// Eat the click event the browser is about to deliver: the user
// dragged the node, they didn't click it.
_drag.group._suppressClick = true;
}
_drag = null;
}
// ─── interactions ─────────────────────────────────────────────────────
function onNodeClick(node) {
if (node.frontier) {
expandFrontier(node);
return;
}
pinNode(node);
}
function pinNode(node) {
if (state.pinnedNode === node.id) {
clearPin();
return;
}
state.pinnedNode = node.id;
document.querySelectorAll('.node-group').forEach(g => g.classList.remove('is-active', 'is-pinned'));
document.querySelectorAll('.edge').forEach(p => p.classList.remove('is-active'));
highlightSubtree(node.id, true, true);
openInspector(node);
refreshEvalTarget();
}
function clearPin() {
document.querySelectorAll('.node-group').forEach(g => g.classList.remove('is-pinned'));
document.querySelectorAll('.edge').forEach(p => p.classList.remove('is-active'));
state.pinnedNode = null;
closeInspector();
refreshEvalTarget();
}
function descendants(id) {
if (!state.scene) return new Set();
const out = new Set([id]);
const stack = [id];
while (stack.length) {
const cur = stack.pop();
for (const e of state.scene.edges) {
if (e.from === cur && !out.has(e.to)) {
out.add(e.to);
stack.push(e.to);
}
}
}
return out;
}
function highlightSubtree(id, on, pinned = false) {
const set = descendants(id);
document.querySelectorAll('.node-group').forEach(g => {
const match = set.has(g.dataset.id);
g.classList.toggle('is-active', on && match && !pinned);
g.classList.toggle('is-pinned', pinned && match);
});
document.querySelectorAll('.edge').forEach(p => {
const match = set.has(p.dataset.from) && set.has(p.dataset.to);
p.classList.toggle('is-active', on && match);
});
}
// ─── inspector ────────────────────────────────────────────────────────
function openInspector(node) {
inspectorEl.classList.add('is-open');
inspectorTitle.textContent = TYPE_SUMMARY[node.type] || `Gate (${node.type})`;
let html = '
';
// The title already says which gate type this is, so we drop the
// separate `type` row from the body. depth stays : it tells the
// user how far this gate is from the root, useful when navigating
// a deep BFS.
// Match the in-circuit display: abbreviated UUID by default, full
// value only when the "Show UUIDs" toggle is pressed. The title
// attribute keeps the full string available on hover for the
// collapsed form.
const uuidText = state.showUuids ? node.id : shortUuid(node.id);
html += `
uuid
${escapeHtml(uuidText)}
`;
html += `
depth
${node.depth}
`;
// info1 / info2 are gate-type-specific integers in the raw schema
// (see provsql.set_infos doc). Translate to a human-readable form
// wherever we can: aggregate function name (info1) + result type
// (info2) for `agg`, comparison operator name for `cmp`, the
// multivalued variable's actual value for `mulinput`, attribute
// indices for `eq`. Anything else falls back to raw `infoN`.
for (const fact of _gateInfos(node)) {
html += `
${escapeHtml(fact.label)}
${escapeHtml(fact.value)}
`;
}
// `extra` is set by project (input→output column mapping array),
// value (scalar), and agg (computed scalar). Project's mapping is
// PG's text-encoded array-of-pairs ({{1,1},{2,3}}); pretty-print
// it as "input col → output col" lines so the user doesn't have to
// parse the punctuation.
if (node.extra != null && node.extra !== '') {
if (node.type === 'project') {
const pairs = _parseProjectMapping(node.extra);
if (pairs.length) {
const items = pairs.map(([a, b]) =>
`
input col ${escapeHtml(a)} → output col ${escapeHtml(b)}
`
).join('');
html += `
mapping
${items}
`;
} else {
html += `
mapping
${escapeHtml(node.extra)}
`;
}
} else if (node.type === 'value' || node.type === 'agg') {
html += `
value
${escapeHtml(node.extra)}
`;
} else {
html += `
extra
${escapeHtml(node.extra)}
`;
}
}
html += '';
if (node.type === 'input' || node.type === 'mulinput') {
html += '
Resolving source row…
';
} else if (node.frontier) {
html += '
Frontier node: click again to expand its subtree.
';
}
inspectorBody.innerHTML = html;
if (node.type === 'input' || node.type === 'mulinput') {
fetchLeafRow(node.id);
}
}
function closeInspector() {
if (inspectorEl) inspectorEl.classList.remove('is-open');
}
async function fetchLeafRow(uuid) {
let resp;
try {
resp = await fetch(`/api/leaf/${encodeURIComponent(uuid)}`);
} catch {
return;
}
if (!resp.ok) {
replaceLeafBody('
');
return;
}
// Probability is per-input-gate (the UUID itself), not per-resolved-row.
// Append it to the gate-metadata
as another
/
row so it
// sits in the same visual stream as uuid / depth / info1, rather
// than getting a separate paragraph that breaks the rhythm. The dd
// is click-to-edit: clicking it swaps the displayed value for a
// number input, Enter fires POST /api/set_prob, Esc / blur cancels.
if (payload.probability != null) {
const dl = inspectorBody.querySelector('dl');
if (dl) {
dl.insertAdjacentHTML(
'beforeend',
`
`;
}).join('');
replaceLeafBody(items);
}
function replaceLeafBody(html) {
// Replace the placeholder paragraph at the bottom of the inspector body.
const ps = inspectorBody.querySelectorAll('p');
if (ps.length) ps[ps.length - 1].outerHTML = html;
else inspectorBody.insertAdjacentHTML('beforeend', html);
}
function formatProbabilityValue(p) {
const dec = (window.ProvsqlStudio && window.ProvsqlStudio.getProbDecimals)
? window.ProvsqlStudio.getProbDecimals()
: 4;
const n = Number(p);
return Number.isFinite(n) ? n.toFixed(dec) : String(p);
}
// Click-to-edit on the inspector probability cell. Replaces the
// rendered value with a number input; Enter fires POST /api/set_prob,
// Esc and blur cancel without saving (blur-as-cancel avoids surprise
// commits when the user clicks elsewhere mid-thought).
function editProbability(dd) {
const uuid = dd.dataset.probUuid;
const current = dd.dataset.probValue;
if (!uuid) return;
const cur = Number(current);
const initial = Number.isFinite(cur) ? cur : 1.0;
dd.innerHTML =
``
+ ``;
const input = dd.querySelector('input');
const msg = dd.querySelector('.cv-prob__msg');
input.focus();
input.select();
let saved = false;
function showMsg(text, isError) {
if (!msg) return;
msg.textContent = text;
msg.hidden = false;
msg.classList.toggle('is-error', !!isError);
}
function restore(value) {
const v = value != null ? value : initial;
dd.dataset.probValue = String(v);
dd.innerHTML = escapeHtml(formatProbabilityValue(v));
}
async function save() {
if (saved) return;
const v = Number(input.value);
if (!Number.isFinite(v) || v < 0 || v > 1) {
input.classList.add('is-error');
showMsg('must be 0..1', true);
return;
}
saved = true;
input.disabled = true;
try {
const resp = await fetch('/api/set_prob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, probability: v }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
input.disabled = false;
input.classList.add('is-error');
showMsg(err.detail || err.error || `HTTP ${resp.status}`, true);
saved = false;
return;
}
restore(v);
} catch (e) {
input.disabled = false;
input.classList.add('is-error');
showMsg(e.message || 'network error', true);
saved = false;
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); save(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('blur', () => {
// Avoid restoring while save() is in flight (the disabled input
// briefly loses focus on some browsers when network mode swaps).
if (!saved) restore();
});
}
// ─── expansion ────────────────────────────────────────────────────────
async function expandFrontier(node, additionalDepth) {
const root = state.scene && state.scene.root;
if (!root) return;
const depth = Number.isFinite(additionalDepth) ? additionalDepth : state.scene.depth;
setStatus(null, `Expanding ${shortUuid(node.id)}…`);
let resp;
try {
resp = await fetch(`/api/circuit/${encodeURIComponent(root)}/expand`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frontier_node_uuid: node.id, additional_depth: depth }),
});
} catch (e) {
showError(`Network error: ${e.message}`);
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
// Same actionable banner as loadCircuit's 413 path: when the
// anchor's subgraph is too large at the requested depth, offer a
// depth-1 retry if the depth-1 frontier fits under the cap.
if (resp.status === 413 && err && err.error === 'circuit too large') {
showTooLarge(err, (lowerDepth) => expandFrontier(node, lowerDepth));
return;
}
showError(err.error || `HTTP ${resp.status}`);
return;
}
const sub = await resp.json();
mergeSubgraph(node, sub);
paint();
}
function mergeSubgraph(anchor, sub) {
if (!state.scene || !sub || !sub.nodes) return;
// Geometric anchor: shift sub.nodes so that sub's root lands on anchor's
// (x, y), then drop the sub-root (it's the same node as `anchor`).
const subRoot = sub.nodes.find(n => n.id === sub.root);
const dx = anchor.x - (subRoot ? subRoot.x : 0);
const dy = anchor.y - (subRoot ? subRoot.y : 0);
// Depth rebase: the sub-DAG's depths are relative to the frontier
// (sub.root is at depth 0), but state.scene's depths are relative
// to the original root. The inspector reads node.depth, so without
// the offset, expanded nodes report wrong depths. Anchor.depth in
// state.scene is the absolute depth of the frontier; new nodes
// sit `anchor.depth + n.depth` levels under the original root.
const ddepth = (anchor.depth != null ? anchor.depth : 0)
- (subRoot && subRoot.depth != null ? subRoot.depth : 0);
const known = new Set(state.scene.nodes.map(n => n.id));
for (const n of sub.nodes) {
if (known.has(n.id)) continue;
state.scene.nodes.push({
...n,
x: n.x + dx,
y: n.y + dy,
depth: (n.depth != null ? n.depth + ddepth : n.depth),
});
}
const knownEdges = new Set(state.scene.edges.map(e => `${e.from}->${e.to}`));
for (const e of sub.edges) {
if (knownEdges.has(`${e.from}->${e.to}`)) continue;
state.scene.edges.push(e);
}
// The anchor is no longer a frontier (we just expanded it).
const idx = state.scene.nodes.findIndex(n => n.id === anchor.id);
if (idx >= 0) state.scene.nodes[idx] = { ...state.scene.nodes[idx], frontier: false };
}
// ─── semiring evaluation ──────────────────────────────────────────────
// Single source of truth for compiled semirings exposed in the eval
// strip. Each entry drives the dropdown population (label + group),
// the mapping-required check, the type-compatibility filter on the
// mapping picker (matched against `mapping.value_base_type`), the
// PG-version gate, and the optional hint shown next to the mapping
// dropdown. Adding a new compiled semiring is a one-line registry
// entry plus a matching backend dispatch.
//
// `types: null` means polymorphic (no filter, no expectation message).
// `types: [...]` filters the mapping picker; an empty result yields a
// "no compatible mappings" sentinel so the user can't run the option
// against a mapping the kernel won't accept.
// `acceptsEnum: true` filters the mapping picker to user-defined enum
// carriers (matched against `mapping.is_enum`), used by sr_minmax /
// sr_maxmin where the carrier is "any user-defined enum".
const _NUMERIC_BASE_TYPES = [
'smallint', 'integer', 'bigint',
'numeric', 'real', 'double precision',
];
const _INTERVAL_BASE_TYPES = [
'tstzmultirange', 'nummultirange', 'int4multirange',
];
const _COMPILED_REGISTRY = {
// Boolean.
'boolexpr': { label: 'Boolean expression', group: 'bool',
needsMapping: false, types: null, hint: null,
optionalMapping: true },
'boolean': { label: 'Boolean', group: 'bool',
needsMapping: true, types: ['boolean'], hint: 'Expects boolean values.' },
// Lineage. `formula` is the canonical free-polynomial expression
// (Green-Karvounarakis-Tannen) as a circuit pretty-print; `how` is
// the same algebra collapsed to canonical sum-of-products form,
// making it suitable for provenance-equivalence checks across
// syntactically different circuits.
'formula': { label: 'Formula', group: 'lin',
needsMapping: true, types: null, hint: null },
'how': { label: 'How-provenance', group: 'lin',
needsMapping: true, types: null,
hint: 'Canonical N[X] polynomial; equal circuits collapse to the same string.' },
'why': { label: 'Why-provenance', group: 'lin',
needsMapping: true, types: null, hint: null },
'which': { label: 'Which-provenance', group: 'lin',
needsMapping: true, types: null, hint: null },
// Numeric / scoring. The [0, 1] constraint for Viterbi / Łukasiewicz
// can't be enforced at type level (no PG type for "numeric in [0, 1]")
// so the hint flags it; the kernel itself doesn't reject out-of-range
// values, it just yields nonsense.
'counting': { label: 'Counting', group: 'num',
needsMapping: true, types: _NUMERIC_BASE_TYPES, hint: 'Expects numeric values.' },
'tropical': { label: 'Tropical (min-plus)', group: 'num',
needsMapping: true, types: _NUMERIC_BASE_TYPES, hint: 'Expects numeric (cost) values.' },
'viterbi': { label: 'Viterbi (max-times)', group: 'num',
needsMapping: true, types: _NUMERIC_BASE_TYPES, hint: 'Expects numeric values in [0, 1].' },
'lukasiewicz': { label: 'Łukasiewicz (numeric fuzzy)', group: 'num',
needsMapping: true, types: _NUMERIC_BASE_TYPES, hint: 'Expects numeric values in [0, 1].' },
// Intervals. One UI option, three kernels: the backend picks
// sr_temporal / sr_interval_num / sr_interval_int from the mapping's
// multirange type. PG14+ because every multirange type was added in 14.
'interval-union': { label: 'Interval union (multirange)', group: 'iv',
needsMapping: true, types: _INTERVAL_BASE_TYPES,
minPg: 140000,
hint: 'Multirange-valued (PostgreSQL 14+); selects sr_temporal / sr_interval_num / sr_interval_int by mapping type.' },
// User-enum carrier. The bottom and top come from the enum's
// pg_enum.enumsortorder; the kernel is polymorphic over any
// user-defined enum, so the picker filters by `is_enum` rather
// than a fixed type list.
'minmax': { label: 'Min-max (security shape)', group: 'enum',
needsMapping: true, types: null, acceptsEnum: true,
hint: 'Expects a user-defined enum carrier; alternatives combine to enum-min, joins to enum-max.' },
'maxmin': { label: 'Max-min (enum fuzzy / trust)', group: 'enum',
needsMapping: true, types: null, acceptsEnum: true,
hint: 'Expects a user-defined enum carrier; alternatives combine to enum-max, joins to enum-min.' },
};
const _COMPILED_GROUPS = [
['bool', 'Boolean'],
['lin', 'Lineage'],
['num', 'Numeric'],
['iv', 'Intervals'],
['enum', 'User-enum'],
];
// Custom-semiring options (encoded as `custom:.`) also need a
// mapping; see `needsMapping`. `prov-xml` and `boolexpr` accept an
// optional mapping (used to label leaves) so the dropdown shows for
// them too, but emptying the selection is allowed : see
// `_OPTIONAL_MAPPING`.
const _OPTIONAL_MAPPING = new Set(['prov-xml', 'boolexpr']);
function needsMapping(v) {
if (v.startsWith('custom:')) return true;
if (_OPTIONAL_MAPPING.has(v)) return true;
const spec = _COMPILED_REGISTRY[v];
return !!(spec && spec.needsMapping);
}
function mappingOptional(v) {
return _OPTIONAL_MAPPING.has(v);
}
// PG type names psycopg surfaces as either JS numbers (smallints, ints,
// floats) or strings (numeric / Decimal). Either way we render with 4
// decimals for parity with the probability-value formatter.
const _CUSTOM_NUMERIC_TYPES = new Set([
'numeric', 'double precision', 'real',
'integer', 'bigint', 'smallint',
'int', 'int2', 'int4', 'int8', 'float4', 'float8',
]);
function formatCustomValue(value, typeName) {
if (value == null) return '(null)';
if (_CUSTOM_NUMERIC_TYPES.has(typeName)) {
const n = typeof value === 'number' ? value : parseFloat(value);
if (Number.isFinite(n)) return n.toFixed(4);
}
if (typeName === 'boolean' && typeof value === 'boolean') {
return value ? 'true' : 'false';
}
// Multiranges, enums, ranges, text : already display-ready as strings.
return String(value);
}
// For each probability method that takes an `arguments` value, point
// at the dedicated control. Each control keeps its own state (so the
// user's MC sample count survives a round-trip through compilation
// and back) and offers an input shape that matches the expected value:
// a number field for samples, a dropdown of ProvSQL-known compilers,
// a free-form text field pre-filled with the WeightMC defaults.
const _PROB_ARG_CONTROL = {
'monte-carlo': 'eval-args-mc',
'compilation': 'eval-args-compiler',
'weightmc': 'eval-args-wmc',
};
// Build the "Compiled Semirings" sub-optgroups from the registry and
// splice them into the