/* 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
    `; } 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('

    No source row found for this UUID.

    '); return; } const payload = await resp.json(); const matches = payload.matches || []; if (!matches.length) { replaceLeafBody('

    No source row found.

    '); 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', `
    probability
    ` + `
    ` + `${escapeHtml(formatProbabilityValue(payload.probability))}
    `, ); const dd = dl.querySelector('dd[data-prob-uuid]'); if (dd) dd.addEventListener('click', () => editProbability(dd)); } } const items = matches.map(m => { const cells = Object.entries(m.row || {}).map( ([k, v]) => `
    ${escapeHtml(k)}
    ${escapeHtml(v == null ? '' : String(v))}
    ` ).join(''); return `

    ${escapeHtml(m.relation)}

    ${cells}
    `; }).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