1080 lines
37 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plan Viewer</title>
<style>
:root {
--bg: #f6f7f9;
--panel: #ffffff;
--border: #d9dde3;
--text: #1f2430;
--muted: #6b7280;
--accent: #2563eb;
--accent-soft: #dbeafe;
--ok: #15803d;
--ok-soft: #dcfce7;
--bad: #b91c1c;
--bad-soft: #fee2e2;
--warn: #92400e;
--warn-soft: #fef3c7;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 16px; margin: 0; }
header .spacer { flex: 1; }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.bad { background: var(--bad-soft); color: var(--bad); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
#scenario-name { font-family: var(--mono); color: var(--muted); }
main {
display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(340px, 480px);
gap: 12px;
padding: 12px 16px;
align-items: start;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.panel > h2 {
font-size: 13px;
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.panel .body { padding: 12px; }
#dag-panel .body { overflow: auto; }
#right-col { display: flex; flex-direction: column; gap: 12px; }
#drop-hint {
margin: 40px auto;
max-width: 460px;
text-align: center;
color: var(--muted);
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px 24px;
background: var(--panel);
}
#drop-hint code { font-family: var(--mono); background: var(--bg); padding: 1px 5px; border-radius: 4px; }
body.dragging #drop-hint, body.dragging main { outline: 3px dashed var(--accent); outline-offset: -6px; }
table.rel {
border-collapse: collapse;
font-family: var(--mono);
font-size: 12.5px;
margin: 4px 0;
}
table.rel th, table.rel td {
border: 1px solid var(--border);
padding: 3px 10px;
text-align: left;
white-space: nowrap;
}
table.rel th { background: var(--bg); font-weight: 600; }
table.rel td.mark { border: none; background: none; padding-left: 6px; }
table.rel tr.clickable { cursor: pointer; }
table.rel tr.clickable:hover td { background: var(--accent-soft); }
table.rel tr.rowsel td { background: var(--accent-soft); }
.var-wild { color: var(--muted); }
.rowcount { color: var(--muted); font-size: 12px; margin: 2px 0 10px; }
.relname { font-family: var(--mono); font-weight: 600; margin-top: 8px; }
.missing-row { color: var(--bad); }
.explain { color: var(--muted); margin: 6px 0 10px; }
.explain code, #node-detail code, .atom-line code {
font-family: var(--mono); background: var(--bg); padding: 1px 4px; border-radius: 4px;
}
.prov-title { font-size: 12px; color: var(--muted); margin: 10px 0 0; }
.atom-line { margin: 6px 0; font-size: 13px; }
.dag-head { font-size: 12.5px; color: var(--muted); margin: 10px 2px 2px; }
.dag-head strong { color: var(--text); font-family: var(--mono); }
.compare { font-size: 12.5px; color: var(--muted); }
select { font-size: 13px; max-width: 200px; }
input[type="file"] { font-size: 13px; }
/* DAG SVG */
svg { display: block; }
.node rect {
fill: var(--panel);
stroke: var(--border);
stroke-width: 1.5;
rx: 7;
cursor: pointer;
}
.node:hover rect { stroke: var(--accent); }
.node.empty rect { stroke: var(--bad); stroke-dasharray: 5 3; }
.node.empty text.sub { fill: var(--bad); }
.node.selected rect { stroke: var(--accent); stroke-width: 2.5; fill: var(--accent-soft); }
.node text { font: 12px var(--mono); fill: var(--text); pointer-events: none; }
.node text.sub { font-size: 11px; fill: var(--muted); }
.node text.nid { font-size: 10px; fill: var(--muted); }
.edge { fill: none; stroke: #9ca3af; stroke-width: 1.5; }
.edge-label { font: 10px var(--mono); fill: var(--muted); }
.root-tag { font: 10px system-ui; fill: var(--ok); font-weight: 600; }
</style>
</head>
<body>
<header>
<h1>Plan Viewer</h1>
<span id="scenario-name"></span>
<span id="verify-badge"></span>
<span class="spacer"></span>
<select id="fixture-select" hidden></select>
<label class="compare" id="compare-label" hidden>
compare: <input type="file" id="compare-input" accept=".json,application/json">
</label>
<input type="file" id="file-input" accept=".json,application/json">
</header>
<div id="drop-hint">
<p><strong>Drop a fixture JSON here</strong> or use the file picker above.</p>
<p>Fixtures live in <code>crates/plan-runner/fixtures/</code>.
Exporter scenarios from <code>tools/exporter/examples/</code> render in a
reduced mode without a plan DAG.</p>
<p>The viewer evaluates the plan in the browser with the same scan,
semijoin, and natural join semantics as <code>plan-runner</code>, and
shows the relation computed at every node of the plan DAG.</p>
</div>
<main id="main" hidden>
<section class="panel" id="dag-panel">
<h2 id="dag-title">Plan DAG</h2>
<div class="body" id="dag-body"></div>
</section>
<div id="right-col">
<section class="panel" id="detail-panel">
<h2 id="detail-title">Selected Node</h2>
<div class="body" id="node-detail"></div>
</section>
<section class="panel">
<h2 id="result-title">Result Bindings</h2>
<div class="body" id="result-body"></div>
</section>
<section class="panel">
<h2 id="facts-title">Input Facts</h2>
<div class="body" id="facts-body"></div>
</section>
</div>
</main>
<script id="engine">
// Plan evaluation engine. Mirrors the semantics of crates/query-ops
// (scan_atom, semijoin, natural_join) and crates/plan-runner (execute,
// verify). Values are tagged objects: {int: n} or {str: s}. This block has
// no DOM dependencies so it can be extracted and tested under Node; see
// test.js and the `make viewer-test` target.
"use strict";
function valueKey(v) {
return "int" in v ? "i:" + v.int : "s:" + v.str;
}
function valueShow(v) {
return "int" in v ? String(v.int) : v.str;
}
function rowKey(row) {
return row.map(valueKey).join(" ");
}
// scan_atom: literals filter, a repeated variable forces cell equality, and
// distinct variables project in first-occurrence order.
function scanAtom(table, columns) {
if (columns.length !== table.arity) {
throw new Error(
"pattern arity mismatch: pattern has " + columns.length +
", table has " + table.arity);
}
const outputVars = [];
const outputPositions = [];
const equalityPairs = [];
const literalChecks = [];
const firstPosition = new Map();
columns.forEach((term, i) => {
if ("var" in term) {
if (firstPosition.has(term.var)) {
equalityPairs.push([firstPosition.get(term.var), i]);
} else {
firstPosition.set(term.var, i);
outputVars.push(term.var);
outputPositions.push(i);
}
} else {
literalChecks.push([i, term.lit]);
}
});
const rows = [];
outer: for (const row of table.rows) {
for (const [i, lit] of literalChecks) {
if (valueKey(row[i]) !== valueKey(lit)) continue outer;
}
for (const [j, i] of equalityPairs) {
if (valueKey(row[i]) !== valueKey(row[j])) continue outer;
}
rows.push(outputPositions.map((i) => row[i]));
}
return { columns: outputVars, rows };
}
function sharedColumns(left, right) {
const pairs = [];
left.columns.forEach((name, li) => {
const ri = right.columns.indexOf(name);
if (ri !== -1) pairs.push([li, ri]);
});
return pairs;
}
function projectKey(row, indices) {
return indices.map((i) => valueKey(row[i])).join(" ");
}
// semijoin: keep rows of left whose shared-column values appear in right.
function semijoin(left, right) {
const shared = sharedColumns(left, right);
const leftKeys = shared.map(([li]) => li);
const rightKeys = shared.map(([, ri]) => ri);
const rightSet = new Set(right.rows.map((r) => projectKey(r, rightKeys)));
return {
columns: left.columns.slice(),
rows: left.rows.filter((r) => rightSet.has(projectKey(r, leftKeys))),
};
}
// natural_join: hash join on shared columns; output columns are
// left.columns followed by right.columns minus the shared ones.
function naturalJoin(left, right) {
const shared = sharedColumns(left, right);
const leftKeys = shared.map(([li]) => li);
const rightKeys = shared.map(([, ri]) => ri);
const sharedRight = new Set(rightKeys);
const rightOnly = [];
right.columns.forEach((_, i) => { if (!sharedRight.has(i)) rightOnly.push(i); });
const columns = left.columns.concat(rightOnly.map((i) => right.columns[i]));
const index = new Map();
for (const row of right.rows) {
const key = projectKey(row, rightKeys);
if (!index.has(key)) index.set(key, []);
index.get(key).push(row);
}
const rows = [];
for (const leftRow of left.rows) {
const matches = index.get(projectKey(leftRow, leftKeys));
if (!matches) continue;
for (const rightRow of matches) {
rows.push(leftRow.concat(rightOnly.map((i) => rightRow[i])));
}
}
return { columns, rows };
}
// build_tables: schema declares every relation; facts fill the rows.
function buildTables(plan) {
const tables = new Map();
for (const [name, arity] of Object.entries(plan.schema)) {
tables.set(name, { arity, rows: [] });
}
for (const [name, rows] of Object.entries(plan.facts || {})) {
const table = tables.get(name);
if (!table) throw new Error("facts reference relation \"" + name + "\" not in schema");
for (const row of rows) {
if (row.length !== table.arity) {
throw new Error(
"relation \"" + name + "\": row arity " + row.length +
" differs from schema arity " + table.arity);
}
table.rows.push(row);
}
}
return tables;
}
// execute: walk nodes in ascending id order, mirroring plan_runner::execute.
// Returns a Map from node id to its computed relation; the root relation is
// results.get(plan.query.root).
function executePlan(plan, tables) {
const nodes = plan.query.nodes.slice().sort((a, b) => a.id - b.id);
const seen = new Set();
for (const node of nodes) {
if (seen.has(node.id)) throw new Error("duplicate node id " + node.id);
seen.add(node.id);
}
if (!seen.has(plan.query.root)) {
throw new Error("plan root id " + plan.query.root + " matches no node");
}
const results = new Map();
for (const node of nodes) {
let computed;
if (node.action.scan) {
const atom = node.action.scan;
const table = tables.get(atom.table);
if (!table) throw new Error("scan references missing table \"" + atom.table + "\"");
computed = scanAtom(table, atom.columns);
} else if (node.action.join) {
const join = node.action.join;
const left = results.get(join.left);
const right = results.get(join.right);
if (!left) throw new Error("node " + node.id + " depends on uncomputed node " + join.left);
if (!right) throw new Error("node " + node.id + " depends on uncomputed node " + join.right);
if (join.op === "left") computed = semijoin(left, right);
else if (join.op === "right") computed = semijoin(right, left);
else if (join.op === "natural") computed = naturalJoin(left, right);
else throw new Error("unknown join op \"" + join.op + "\"");
} else {
throw new Error("node " + node.id + " has an unsupported action");
}
results.set(node.id, computed);
}
return results;
}
// verify: project the root relation to the expected columns and compare row
// multisets, like plan_runner::verify. Returns per-row marks for display.
function verifyBindings(plan, relation) {
const expected = plan.expected_bindings;
if (!expected) return { status: "no-oracle" };
const projection = [];
for (const col of expected.columns) {
const idx = relation.columns.indexOf(col);
if (idx === -1) return { status: "error", message: "expected column \"" + col + "\" not in plan output" };
projection.push(idx);
}
const remaining = new Map();
for (const row of expected.rows) {
const key = rowKey(row);
remaining.set(key, (remaining.get(key) || 0) + 1);
}
const actualRows = relation.rows.map((row) => projection.map((i) => row[i]));
const marks = actualRows.map((row) => {
const key = rowKey(row);
const count = remaining.get(key) || 0;
if (count > 0) {
remaining.set(key, count - 1);
return true;
}
return false;
});
const missing = [];
for (const row of expected.rows) {
const key = rowKey(row);
const count = remaining.get(key) || 0;
if (count > 0) {
remaining.set(key, count - 1);
missing.push(row);
}
}
const matched = marks.every(Boolean) && missing.length === 0;
return {
status: matched ? "match" : "mismatch",
columns: expected.columns,
actualRows,
marks,
missing,
};
}
// Row provenance: filter a relation's rows to those consistent with a
// column-to-value binding taken from an output row. For the operators here
// (scan, semijoin, natural join) value agreement on the shared columns is
// exactly the contributing-row condition.
function filterByBindings(relation, bindings) {
const checks = [];
relation.columns.forEach((c, i) => {
if (c in bindings) checks.push([i, valueKey(bindings[c])]);
});
return relation.rows.filter((row) =>
checks.every(([i, key]) => valueKey(row[i]) === key));
}
// The scan-side analogue of filterByBindings: match fact rows against a
// scan pattern under a variable binding. Literal positions must equal the
// literal; variable positions must equal the bound value.
function scanMatches(table, columns, bindings) {
return table.rows.filter((row) => columns.every((term, i) => {
if ("lit" in term) return valueKey(row[i]) === valueKey(term.lit);
const bound = bindings[term.var];
return bound === undefined || valueKey(row[i]) === valueKey(bound);
}));
}
// Layered DAG layout: scans sit at depth 0, a join sits one past its deepest
// input. Within a depth layer, a node's slot is the average of its inputs'
// slots (one barycenter pass), then collisions are resolved in order.
function layoutQuery(query) {
const byId = new Map(query.nodes.map((n) => [n.id, n]));
const depth = new Map();
const resolveDepth = (id) => {
if (depth.has(id)) return depth.get(id);
const node = byId.get(id);
const d = node.action.join
? Math.max(resolveDepth(node.action.join.left), resolveDepth(node.action.join.right)) + 1
: 0;
depth.set(id, d);
return d;
};
query.nodes.forEach((n) => resolveDepth(n.id));
const layers = new Map();
const sorted = query.nodes.slice().sort((a, b) => a.id - b.id);
for (const node of sorted) {
const d = depth.get(node.id);
if (!layers.has(d)) layers.set(d, []);
layers.get(d).push(node);
}
const slot = new Map();
const maxDepth = Math.max(...layers.keys());
for (let d = 0; d <= maxDepth; d++) {
const nodes = layers.get(d) || [];
const wanted = nodes.map((node) => {
if (!node.action.join || d === 0) return null;
const j = node.action.join;
return ((slot.get(j.left) ?? 0) + (slot.get(j.right) ?? 0)) / 2;
});
let next = 0;
nodes.forEach((node, i) => {
let s = wanted[i] === null ? next : Math.max(wanted[i], next);
slot.set(node.id, s);
next = s + 1;
});
}
return { depth, slot, maxDepth, byId };
}
if (typeof module !== "undefined") {
module.exports = {
buildTables, executePlan, verifyBindings, layoutQuery,
filterByBindings, scanMatches, valueShow,
};
}
</script>
<script>
"use strict";
const NODE_W = 172;
const NODE_H = 62;
2026-06-11 15:58:24 +02:00
const SIBLING_GAP = 28;
const LAYER_GAP = 56;
const PAD = 16;
const ROW_DISPLAY_LIMIT = 200;
const FIXTURES_DIR = "../../crates/plan-runner/fixtures/";
// Loaded plans. plans[0] is the primary plan; plans[1], when present, is
// the comparison plan. Each entry: {name, plan, tables, results, verdict, byId}.
let plans = [];
let selected = null; // {planIdx, id}
let selectedRow = null; // {planIdx, id, rowIdx} for provenance display
function el(tag, attrs, children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") node.className = v;
else node.setAttribute(k, v);
}
for (const child of children || []) {
node.append(child);
}
return node;
}
function svgEl(tag, attrs) {
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v);
return node;
}
function fit(text, max) {
return text.length > max ? text.slice(0, max - 1) + "…" : text;
}
function varSpan(name) {
return el("span", { class: name.startsWith("_") ? "var-wild" : "" }, [name]);
}
function columnList(columns) {
const span = el("span", {}, ["("]);
columns.forEach((c, i) => {
if (i > 0) span.append(", ");
span.append(varSpan(c));
});
span.append(")");
return span;
}
function relationTable(columns, rows, opts) {
const { marks, onRowClick, selectedIdx, show } = opts || {};
const display = show || valueShow;
const table = el("table", { class: "rel" });
const head = el("tr", {}, columns.map((c) => el("th", {}, [varSpan(c)])));
if (marks) head.append(el("th", {}, [""]));
table.append(head);
const limit = Math.min(rows.length, ROW_DISPLAY_LIMIT);
for (let i = 0; i < limit; i++) {
const tr = el("tr", {}, rows[i].map((v) => el("td", {}, [display(v)])));
if (marks) {
tr.append(el("td", { class: "mark" }, [marks[i] ? "✓" : "✗"]));
if (!marks[i]) tr.style.color = "var(--bad)";
}
if (onRowClick) {
tr.classList.add("clickable");
if (i === selectedIdx) tr.classList.add("rowsel");
tr.addEventListener("click", () => onRowClick(i));
}
table.append(tr);
}
const wrap = el("div", {}, [table]);
if (rows.length === 0) {
wrap.append(el("div", { class: "rowcount" }, ["no rows"]));
} else if (rows.length > limit) {
wrap.append(el("div", { class: "rowcount" }, [
"showing " + limit + " of " + rows.length + " rows",
]));
}
return wrap;
}
function nodeTitle(node) {
if (node.action.scan) return "scan " + node.action.scan.table;
const op = node.action.join.op;
const symbol = op === "left" ? "⋉" : op === "right" ? "⋊" : "⋈";
return symbol + " " + op + " join";
}
function joinShared(ps, node) {
const join = node.action.join;
const left = ps.results.get(join.left);
const right = ps.results.get(join.right);
return left.columns.filter((c) => right.columns.includes(c));
}
// Second line of a node box: the scan pattern, or the join columns.
function nodeLine2(ps, node) {
if (node.action.scan) {
const pattern = node.action.scan.columns
.map((t) => ("var" in t ? t.var : valueShow(t.lit)))
.join(", ");
return fit("(" + pattern + ")", 23);
}
const shared = joinShared(ps, node);
return fit(shared.length ? "on " + shared.join(", ") : "cartesian", 23);
}
// Third line of a node box: output columns and row count.
function nodeLine3(ps, node) {
const rel = ps.results.get(node.id);
const cols = rel.columns.slice(0, 3).join(", ") + (rel.columns.length > 3 ? ", …" : "");
const count = rel.rows.length + " row" + (rel.rows.length === 1 ? "" : "s");
return fit("(" + cols + ") · " + count, 26);
}
function badgeFor(status) {
if (status === "match") return ["ok", "✓ matches expected bindings"];
if (status === "mismatch" || status === "error") return ["bad", "✗ differs from expected bindings"];
return ["warn", "no expected bindings"];
}
function setHeaderBadge(cls, text) {
const badge = document.getElementById("verify-badge");
badge.className = text ? "badge " + cls : "";
badge.textContent = text || "";
}
function updateHeader() {
const name = document.getElementById("scenario-name");
if (plans.length === 2) {
name.textContent = plans[0].name + " vs " + plans[1].name;
setHeaderBadge("", "");
} else if (plans.length === 1) {
name.textContent = plans[0].name;
const [cls, text] = badgeFor(plans[0].verdict.status);
setHeaderBadge(cls, text);
}
}
function renderDagPanel() {
document.getElementById("dag-title").textContent =
plans.length > 1 ? "Plan DAGs" : "Plan DAG";
const body = document.getElementById("dag-body");
body.innerHTML = "";
plans.forEach((ps, planIdx) => {
if (plans.length > 1) {
const [cls, text] = badgeFor(ps.verdict.status);
const root = ps.results.get(ps.plan.query.root);
const layout = layoutQuery(ps.plan.query);
body.append(el("div", { class: "dag-head" }, [
el("strong", {}, [ps.name]),
" ",
el("span", { class: "badge " + cls }, [text]),
" · " + ps.plan.query.nodes.length + " nodes · depth " + layout.maxDepth +
" · result " + root.rows.length + " row" + (root.rows.length === 1 ? "" : "s"),
]));
}
body.append(renderDagSvg(ps, planIdx));
});
}
function renderDagSvg(ps, planIdx) {
const layout = layoutQuery(ps.plan.query);
const maxSlot = Math.max(...ps.plan.query.nodes.map((n) => layout.slot.get(n.id)));
2026-06-11 15:58:24 +02:00
const width = PAD * 2 + (maxSlot + 1) * NODE_W + maxSlot * SIBLING_GAP;
const height = PAD * 2 + (layout.maxDepth + 1) * NODE_H + layout.maxDepth * LAYER_GAP + 14;
const svg = svgEl("svg", { width, height, viewBox: "0 0 " + width + " " + height });
const pos = new Map();
for (const node of ps.plan.query.nodes) {
2026-06-11 15:58:24 +02:00
const x = PAD + layout.slot.get(node.id) * (NODE_W + SIBLING_GAP);
const y = PAD + layout.depth.get(node.id) * (NODE_H + LAYER_GAP);
pos.set(node.id, { x, y });
}
for (const node of ps.plan.query.nodes) {
if (!node.action.join) continue;
const target = pos.get(node.id);
for (const [side, sourceId] of [["L", node.action.join.left], ["R", node.action.join.right]]) {
const source = pos.get(sourceId);
2026-06-11 15:58:24 +02:00
const x1 = source.x + NODE_W / 2;
const y1 = source.y + NODE_H;
const x2 = target.x + NODE_W / 2 + (side === "L" ? -16 : 16);
const y2 = target.y;
const my = (y1 + y2) / 2;
svg.append(svgEl("path", {
class: "edge",
2026-06-11 15:58:24 +02:00
d: `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`,
}));
2026-06-11 15:58:24 +02:00
const label = svgEl("text", {
class: "edge-label",
x: x2 + (side === "L" ? -12 : 6),
y: y2 - 4,
});
label.textContent = side;
svg.append(label);
}
}
for (const node of ps.plan.query.nodes) {
const { x, y } = pos.get(node.id);
const isRoot = node.id === ps.plan.query.root;
const isSelected = selected && selected.planIdx === planIdx && selected.id === node.id;
const isEmpty = ps.results.get(node.id).rows.length === 0;
const group = svgEl("g", {
class: "node" + (isRoot ? " root" : "") + (isEmpty ? " empty" : "") +
(isSelected ? " selected" : ""),
transform: `translate(${x}, ${y})`,
});
group.append(svgEl("rect", { width: NODE_W, height: NODE_H, rx: 7 }));
const title = svgEl("text", { x: 10, y: 17 });
title.textContent = fit(nodeTitle(node), 20);
group.append(title);
const line2 = svgEl("text", { class: "sub", x: 10, y: 33 });
line2.textContent = nodeLine2(ps, node);
group.append(line2);
const line3 = svgEl("text", { class: "sub", x: 10, y: 49 });
line3.textContent = nodeLine3(ps, node);
group.append(line3);
const nid = svgEl("text", { class: "nid", x: NODE_W - 8, y: 13, "text-anchor": "end" });
nid.textContent = "#" + node.id;
group.append(nid);
if (isRoot) {
const tag = svgEl("text", { class: "root-tag", x: NODE_W / 2, y: NODE_H + 13, "text-anchor": "middle" });
tag.textContent = "root";
group.append(tag);
}
group.addEventListener("click", () => selectNode(planIdx, node.id));
svg.append(group);
}
return svg;
}
function joinExplanation(ps, node) {
const join = node.action.join;
const shared = joinShared(ps, node);
const sharedText = shared.length ? shared.join(", ") : "none, so this is a cartesian product";
const p = el("p", { class: "explain" });
if (join.op === "left") {
p.append("Semijoin: keeps rows of node ", el("code", {}, ["#" + join.left]),
" whose shared columns appear in node ", el("code", {}, ["#" + join.right]),
". Shared columns: ", el("code", {}, [shared.join(", ") || "none"]), ".");
} else if (join.op === "right") {
p.append("Semijoin (reversed): keeps rows of node ", el("code", {}, ["#" + join.right]),
" whose shared columns appear in node ", el("code", {}, ["#" + join.left]),
". Shared columns: ", el("code", {}, [shared.join(", ") || "none"]), ".");
} else {
p.append("Natural join of nodes ", el("code", {}, ["#" + join.left]), " and ",
el("code", {}, ["#" + join.right]), " on ", el("code", {}, [sharedText]), ".");
}
return p;
}
function scanExplanation(node) {
const scan = node.action.scan;
const p = el("p", { class: "explain" });
p.append("Scans table ", el("code", {}, [scan.table]), " with pattern ");
const pattern = scan.columns
.map((t) => ("var" in t ? t.var : JSON.stringify(t.lit)))
.join(", ");
p.append(el("code", {}, ["(" + pattern + ")"]), ". ");
const vars = scan.columns.filter((t) => "var" in t).map((t) => t.var);
const repeated = vars.filter((v, i) => vars.indexOf(v) !== i);
const literals = scan.columns.filter((t) => "lit" in t);
if (repeated.length) {
p.append("Repeated variables (" + [...new Set(repeated)].join(", ") + ") force cell equality. ");
}
if (literals.length) {
p.append("Literal positions filter rows by constant. ");
}
p.append("Columns starting with an underscore are per-atom wildcards.");
return p;
}
// Contributing rows for one output row of a node, computed by value
// agreement against each direct input (see filterByBindings/scanMatches).
function provenanceSections(ps, node, rowIdx) {
const rel = ps.results.get(node.id);
const bindings = {};
rel.columns.forEach((c, i) => { bindings[c] = rel.rows[rowIdx][i]; });
if (node.action.scan) {
const scan = node.action.scan;
const table = ps.tables.get(scan.table);
const columns = Array.from({ length: table.arity }, (_, i) => "col" + i);
return [{
title: "facts: " + scan.table,
columns,
rows: scanMatches(table, scan.columns, bindings),
}];
}
const join = node.action.join;
return [["L", join.left], ["R", join.right]].map(([side, id]) => {
const input = ps.results.get(id);
return {
title: side + ": node #" + id + " (" + nodeTitle(ps.byId.get(id)) + ")",
columns: input.columns,
rows: filterByBindings(input, bindings),
};
});
}
function selectNode(planIdx, id) {
selected = { planIdx, id };
selectedRow = null;
renderDagPanel();
renderDetail();
renderResult();
renderFacts();
}
function renderDetail() {
const ps = plans[selected.planIdx];
const node = ps.byId.get(selected.id);
const rel = ps.results.get(selected.id);
document.getElementById("detail-title").textContent =
plans.length > 1 ? "Selected Node: " + ps.name : "Selected Node";
const detail = document.getElementById("node-detail");
detail.innerHTML = "";
detail.append(el("p", {}, [
el("strong", {}, [nodeTitle(node) + " "]),
el("code", {}, ["#" + selected.id]),
]));
detail.append(node.action.scan ? scanExplanation(node) : joinExplanation(ps, node));
const colLine = el("p", {}, ["Output columns: "]);
colLine.append(columnList(rel.columns));
detail.append(colLine);
const rowSelected = selectedRow &&
selectedRow.planIdx === selected.planIdx && selectedRow.id === selected.id;
detail.append(relationTable(rel.columns, rel.rows, {
onRowClick: toggleRowProvenance,
selectedIdx: rowSelected ? selectedRow.rowIdx : undefined,
}));
if (rel.rows.length > 0) {
detail.append(el("p", { class: "prov-title" }, [
"Click a row to see the input rows that produce it.",
]));
}
if (rowSelected) {
for (const section of provenanceSections(ps, node, selectedRow.rowIdx)) {
detail.append(el("p", { class: "prov-title" }, [section.title]));
detail.append(relationTable(section.columns, section.rows));
}
}
}
function toggleRowProvenance(rowIdx) {
const same = selectedRow &&
selectedRow.planIdx === selected.planIdx &&
selectedRow.id === selected.id &&
selectedRow.rowIdx === rowIdx;
selectedRow = same ? null : { planIdx: selected.planIdx, id: selected.id, rowIdx };
renderDetail();
}
function renderResult() {
const ps = plans[selected.planIdx];
const { plan, results, verdict } = ps;
document.getElementById("result-title").textContent =
plans.length > 1 ? "Result Bindings: " + ps.name : "Result Bindings";
const body = document.getElementById("result-body");
body.innerHTML = "";
const root = results.get(plan.query.root);
if (verdict.status === "match" || verdict.status === "mismatch") {
body.append(relationTable(verdict.columns, verdict.actualRows, { marks: verdict.marks }));
if (verdict.status === "mismatch" && verdict.missing.length) {
body.append(el("p", { class: "missing-row" }, ["Expected rows not produced:"]));
body.append(relationTable(verdict.columns, verdict.missing));
}
} else if (verdict.status === "error") {
body.append(el("p", { class: "missing-row" }, [verdict.message]));
body.append(relationTable(root.columns, root.rows));
} else {
body.append(relationTable(root.columns, root.rows));
}
body.append(el("p", { class: "explain" }, [
"Projection of root node #" + plan.query.root +
" to the fixture's expected columns. The full root relation, including " +
"wildcard columns, is shown by selecting the root node.",
]));
}
function renderFacts() {
const ps = plans[selected.planIdx];
document.getElementById("facts-title").textContent =
plans.length > 1 ? "Input Facts: " + ps.name : "Input Facts";
const body = document.getElementById("facts-body");
body.innerHTML = "";
const names = Object.keys(ps.plan.schema).sort();
for (const name of names) {
const arity = ps.plan.schema[name];
const rows = (ps.plan.facts || {})[name] || [];
body.append(el("div", { class: "relname" }, [name + " / " + arity]));
const columns = Array.from({ length: arity }, (_, i) => "col" + i);
body.append(relationTable(columns, rows));
}
}
// Scenario files (tools/exporter/examples/*.scenario.json) carry atoms and
// facts but no plan, so they render in a reduced mode without a DAG.
function scenarioValueShow(v) {
if (v && typeof v === "object") {
if ("entity" in v) return v.entity[0] + ":" + v.entity[1];
if ("int" in v) return String(v.int);
if ("str" in v) return v.str;
if ("lit" in v) return scenarioValueShow(v.lit);
}
return JSON.stringify(v);
}
function renderScenario(scenario, sourceName) {
plans = [];
selected = null;
selectedRow = null;
document.getElementById("scenario-name").textContent = scenario.name || sourceName || "";
setHeaderBadge("warn", "scenario (not evaluated)");
document.getElementById("compare-label").hidden = true;
document.getElementById("detail-panel").hidden = true;
document.getElementById("drop-hint").hidden = true;
document.getElementById("main").hidden = false;
document.getElementById("dag-title").textContent = "Atoms";
const dag = document.getElementById("dag-body");
dag.innerHTML = "";
dag.append(el("p", { class: "explain" }, [
"This is an exporter scenario: a conjunctive query with no plan yet. " +
"Run `make export-fixtures` and load the exported fixture from " +
"crates/plan-runner/fixtures/ to see the plan DAG.",
]));
for (const atom of scenario.atoms || []) {
const arity = ((scenario.schema || {})[atom.table] || {}).columns?.length ?? 0;
const terms = Array.from({ length: arity }, (_, i) => {
const v = (atom.values || {})[String(i)];
if (!v) return "_";
return "var" in v ? v.var : scenarioValueShow(v);
});
dag.append(el("div", { class: "atom-line" }, [
el("code", {}, [atom.table + "(" + terms.join(", ") + ")"]),
]));
}
document.getElementById("result-title").textContent = "Expected Bindings";
const result = document.getElementById("result-body");
result.innerHTML = "";
const expected = scenario.expected_bindings;
if (expected) {
result.append(relationTable(expected.columns, expected.rows, { show: scenarioValueShow }));
} else {
result.append(el("p", { class: "explain" }, ["No expected bindings in this scenario."]));
}
document.getElementById("facts-title").textContent = "Input Facts";
const facts = document.getElementById("facts-body");
facts.innerHTML = "";
for (const name of Object.keys(scenario.schema || {}).sort()) {
const rows = (scenario.facts || {})[name] || [];
const arity = (scenario.schema[name].columns || []).length;
facts.append(el("div", { class: "relname" }, [name + " / " + arity]));
const columns = Array.from({ length: arity }, (_, i) => "col" + i);
facts.append(relationTable(columns, rows, { show: scenarioValueShow }));
}
}
function loadPlan(text, sourceName, slot) {
slot = slot || 0;
let plan;
try {
plan = JSON.parse(text);
} catch (err) {
alert("Not valid JSON: " + err.message);
return;
}
if (plan.atoms && !plan.query) {
if (slot !== 0) {
alert("Comparison expects an exported fixture, not an exporter scenario.");
return;
2026-06-11 15:58:24 +02:00
}
renderScenario(plan, sourceName);
return;
}
if (!plan.query || !plan.schema) {
alert(
"This JSON is missing a \"query\" or \"schema\" block; expected a plan-runner " +
"fixture from crates/plan-runner/fixtures/.");
return;
}
let tables, results, verdict;
try {
tables = buildTables(plan);
results = executePlan(plan, tables);
verdict = verifyBindings(plan, results.get(plan.query.root));
} catch (err) {
alert("Failed to evaluate plan: " + err.message);
return;
}
const ps = {
name: plan._scenario || sourceName || "plan",
plan,
tables,
results,
verdict,
byId: new Map(plan.query.nodes.map((n) => [n.id, n])),
};
if (slot === 0) plans = [ps];
else plans[1] = ps;
document.getElementById("detail-panel").hidden = false;
document.getElementById("compare-label").hidden = false;
document.getElementById("drop-hint").hidden = true;
document.getElementById("main").hidden = false;
updateHeader();
selectNode(slot, ps.plan.query.root);
}
function loadFile(file, slot) {
file.text().then((text) => loadPlan(text, file.name, slot));
}
function fetchText(url) {
return fetch(url).then((response) => {
if (!response.ok) throw new Error("HTTP " + response.status);
return response.text();
});
}
function baseName(path) {
return path.split("/").pop();
}
document.getElementById("file-input").addEventListener("change", (event) => {
if (event.target.files.length) loadFile(event.target.files[0], 0);
});
document.getElementById("compare-input").addEventListener("change", (event) => {
if (event.target.files.length) loadFile(event.target.files[0], 1);
});
document.addEventListener("dragover", (event) => {
event.preventDefault();
document.body.classList.add("dragging");
});
document.addEventListener("dragleave", () => document.body.classList.remove("dragging"));
document.addEventListener("drop", (event) => {
event.preventDefault();
document.body.classList.remove("dragging");
if (event.dataTransfer.files.length) loadFile(event.dataTransfer.files[0], 0);
});
// When served over HTTP (`make viewer`), offer a dropdown of fixtures by
// parsing the directory listing that python's http.server returns.
async function initFixturePicker() {
if (!location.protocol.startsWith("http")) return;
let names;
try {
const listing = await fetchText(FIXTURES_DIR);
const doc = new DOMParser().parseFromString(listing, "text/html");
names = [...doc.querySelectorAll("a")]
.map((a) => a.getAttribute("href") || "")
.filter((href) => href.endsWith(".json") && !href.includes("/"));
} catch {
return;
}
if (!names.length) return;
const select = document.getElementById("fixture-select");
select.append(el("option", { value: "" }, ["load a fixture…"]));
for (const name of names.sort()) {
select.append(el("option", { value: name }, [name]));
}
select.addEventListener("change", () => {
if (!select.value) return;
fetchText(FIXTURES_DIR + select.value)
.then((text) => loadPlan(text, select.value, 0))
.catch((err) => alert("Failed to fetch " + select.value + ": " + err.message));
});
select.hidden = false;
}
initFixturePicker();
// ?fixture=<path> loads a fixture relative to this page; &compare=<path>
// loads a second plan for side-by-side comparison.
const urlParams = new URLSearchParams(location.search);
const fixtureParam = urlParams.get("fixture");
const compareParam = urlParams.get("compare");
if (fixtureParam) {
fetchText(fixtureParam)
.then((text) => {
loadPlan(text, baseName(fixtureParam), 0);
if (compareParam && plans.length === 1) {
return fetchText(compareParam)
.then((text2) => loadPlan(text2, baseName(compareParam), 1));
}
return undefined;
})
.catch((err) => alert("Failed to fetch fixture: " + err.message));
}
</script>
</body>
</html>