2026-06-11 15:50:41 +02:00
|
|
|
<!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; }
|
2026-06-11 17:14:29 +02:00
|
|
|
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); }
|
2026-06-11 15:50:41 +02:00
|
|
|
.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; }
|
2026-06-11 17:14:29 +02:00
|
|
|
.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; }
|
2026-06-11 15:50:41 +02:00
|
|
|
/* 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); }
|
2026-06-11 17:14:29 +02:00
|
|
|
.node.empty rect { stroke: var(--bad); stroke-dasharray: 5 3; }
|
|
|
|
|
.node.empty text.sub { fill: var(--bad); }
|
2026-06-11 15:50:41 +02:00
|
|
|
.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>
|
2026-06-11 17:14:29 +02:00
|
|
|
<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>
|
2026-06-11 15:50:41 +02:00
|
|
|
<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>
|
2026-06-11 17:14:29 +02:00
|
|
|
<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>
|
2026-06-11 15:50:41 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<main id="main" hidden>
|
|
|
|
|
<section class="panel" id="dag-panel">
|
2026-06-11 17:14:29 +02:00
|
|
|
<h2 id="dag-title">Plan DAG</h2>
|
2026-06-11 15:50:41 +02:00
|
|
|
<div class="body" id="dag-body"></div>
|
|
|
|
|
</section>
|
|
|
|
|
<div id="right-col">
|
2026-06-11 17:14:29 +02:00
|
|
|
<section class="panel" id="detail-panel">
|
|
|
|
|
<h2 id="detail-title">Selected Node</h2>
|
2026-06-11 15:50:41 +02:00
|
|
|
<div class="body" id="node-detail"></div>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="panel">
|
2026-06-11 17:14:29 +02:00
|
|
|
<h2 id="result-title">Result Bindings</h2>
|
2026-06-11 15:50:41 +02:00
|
|
|
<div class="body" id="result-body"></div>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="panel">
|
2026-06-11 17:14:29 +02:00
|
|
|
<h2 id="facts-title">Input Facts</h2>
|
2026-06-11 15:50:41 +02:00
|
|
|
<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
|
2026-06-11 17:14:29 +02:00
|
|
|
// no DOM dependencies so it can be extracted and tested under Node; see
|
|
|
|
|
// test.js and the `make viewer-test` target.
|
2026-06-11 15:50:41 +02:00
|
|
|
"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) {
|
2026-06-11 17:14:29 +02:00
|
|
|
return row.map(valueKey).join(" ");
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-06-11 17:14:29 +02:00
|
|
|
return indices.map((i) => valueKey(row[i])).join(" ");
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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);
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 15:50:41 +02:00
|
|
|
// Layered DAG layout: scans sit at depth 0, a join sits one past its deepest
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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.
|
2026-06-11 15:50:41 +02:00
|
|
|
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));
|
2026-06-11 17:14:29 +02:00
|
|
|
const layers = new Map();
|
2026-06-11 15:50:41 +02:00
|
|
|
const sorted = query.nodes.slice().sort((a, b) => a.id - b.id);
|
|
|
|
|
for (const node of sorted) {
|
|
|
|
|
const d = depth.get(node.id);
|
2026-06-11 17:14:29 +02:00
|
|
|
if (!layers.has(d)) layers.set(d, []);
|
|
|
|
|
layers.get(d).push(node);
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
const slot = new Map();
|
2026-06-11 17:14:29 +02:00
|
|
|
const maxDepth = Math.max(...layers.keys());
|
2026-06-11 15:50:41 +02:00
|
|
|
for (let d = 0; d <= maxDepth; d++) {
|
2026-06-11 17:14:29 +02:00
|
|
|
const nodes = layers.get(d) || [];
|
2026-06-11 15:50:41 +02:00
|
|
|
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) => {
|
2026-06-11 17:14:29 +02:00
|
|
|
let s = wanted[i] === null ? next : Math.max(wanted[i], next);
|
|
|
|
|
slot.set(node.id, s);
|
|
|
|
|
next = s + 1;
|
2026-06-11 15:50:41 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { depth, slot, maxDepth, byId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof module !== "undefined") {
|
2026-06-11 17:14:29 +02:00
|
|
|
module.exports = {
|
|
|
|
|
buildTables, executePlan, verifyBindings, layoutQuery,
|
|
|
|
|
filterByBindings, scanMatches, valueShow,
|
|
|
|
|
};
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
const NODE_W = 172;
|
2026-06-11 17:14:29 +02:00
|
|
|
const NODE_H = 62;
|
2026-06-11 15:58:24 +02:00
|
|
|
const SIBLING_GAP = 28;
|
2026-06-11 17:14:29 +02:00
|
|
|
const LAYER_GAP = 56;
|
2026-06-11 15:50:41 +02:00
|
|
|
const PAD = 16;
|
|
|
|
|
const ROW_DISPLAY_LIMIT = 200;
|
2026-06-11 17:14:29 +02:00
|
|
|
const FIXTURES_DIR = "../../crates/plan-runner/fixtures/";
|
2026-06-11 15:50:41 +02:00
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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
|
2026-06-11 15:50:41 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
function fit(text, max) {
|
|
|
|
|
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 15:50:41 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
function relationTable(columns, rows, opts) {
|
|
|
|
|
const { marks, onRowClick, selectedIdx, show } = opts || {};
|
|
|
|
|
const display = show || valueShow;
|
2026-06-11 15:50:41 +02:00
|
|
|
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++) {
|
2026-06-11 17:14:29 +02:00
|
|
|
const tr = el("tr", {}, rows[i].map((v) => el("td", {}, [display(v)])));
|
2026-06-11 15:50:41 +02:00
|
|
|
if (marks) {
|
|
|
|
|
tr.append(el("td", { class: "mark" }, [marks[i] ? "✓" : "✗"]));
|
|
|
|
|
if (!marks[i]) tr.style.color = "var(--bad)";
|
|
|
|
|
}
|
2026-06-11 17:14:29 +02:00
|
|
|
if (onRowClick) {
|
|
|
|
|
tr.classList.add("clickable");
|
|
|
|
|
if (i === selectedIdx) tr.classList.add("rowsel");
|
|
|
|
|
tr.addEventListener("click", () => onRowClick(i));
|
|
|
|
|
}
|
2026-06-11 15:50:41 +02:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
function renderDagPanel() {
|
|
|
|
|
document.getElementById("dag-title").textContent =
|
|
|
|
|
plans.length > 1 ? "Plan DAGs" : "Plan DAG";
|
2026-06-11 15:50:41 +02:00
|
|
|
const body = document.getElementById("dag-body");
|
|
|
|
|
body.innerHTML = "";
|
2026-06-11 17:14:29 +02:00
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-11 15:50:41 +02:00
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
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;
|
2026-06-11 15:50:41 +02:00
|
|
|
const svg = svgEl("svg", { width, height, viewBox: "0 0 " + width + " " + height });
|
|
|
|
|
|
|
|
|
|
const pos = new Map();
|
2026-06-11 17:14:29 +02:00
|
|
|
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);
|
2026-06-11 15:50:41 +02:00
|
|
|
pos.set(node.id, { x, y });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
for (const node of ps.plan.query.nodes) {
|
2026-06-11 15:50:41 +02:00
|
|
|
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;
|
2026-06-11 15:50:41 +02:00
|
|
|
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:50:41 +02:00
|
|
|
}));
|
2026-06-11 15:58:24 +02:00
|
|
|
const label = svgEl("text", {
|
|
|
|
|
class: "edge-label",
|
|
|
|
|
x: x2 + (side === "L" ? -12 : 6),
|
|
|
|
|
y: y2 - 4,
|
|
|
|
|
});
|
2026-06-11 15:50:41 +02:00
|
|
|
label.textContent = side;
|
|
|
|
|
svg.append(label);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
for (const node of ps.plan.query.nodes) {
|
2026-06-11 15:50:41 +02:00
|
|
|
const { x, y } = pos.get(node.id);
|
2026-06-11 17:14:29 +02:00
|
|
|
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;
|
2026-06-11 15:50:41 +02:00
|
|
|
const group = svgEl("g", {
|
2026-06-11 17:14:29 +02:00
|
|
|
class: "node" + (isRoot ? " root" : "") + (isEmpty ? " empty" : "") +
|
|
|
|
|
(isSelected ? " selected" : ""),
|
2026-06-11 15:50:41 +02:00
|
|
|
transform: `translate(${x}, ${y})`,
|
|
|
|
|
});
|
|
|
|
|
group.append(svgEl("rect", { width: NODE_W, height: NODE_H, rx: 7 }));
|
2026-06-11 17:14:29 +02:00
|
|
|
const title = svgEl("text", { x: 10, y: 17 });
|
|
|
|
|
title.textContent = fit(nodeTitle(node), 20);
|
2026-06-11 15:50:41 +02:00
|
|
|
group.append(title);
|
2026-06-11 17:14:29 +02:00
|
|
|
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);
|
2026-06-11 15:50:41 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-11 17:14:29 +02:00
|
|
|
group.addEventListener("click", () => selectNode(planIdx, node.id));
|
2026-06-11 15:50:41 +02:00
|
|
|
svg.append(group);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
return svg;
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
function joinExplanation(ps, node) {
|
2026-06-11 15:50:41 +02:00
|
|
|
const join = node.action.join;
|
2026-06-11 17:14:29 +02:00
|
|
|
const shared = joinShared(ps, node);
|
2026-06-11 15:50:41 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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";
|
2026-06-11 15:50:41 +02:00
|
|
|
const detail = document.getElementById("node-detail");
|
|
|
|
|
detail.innerHTML = "";
|
2026-06-11 17:14:29 +02:00
|
|
|
detail.append(el("p", {}, [
|
|
|
|
|
el("strong", {}, [nodeTitle(node) + " "]),
|
|
|
|
|
el("code", {}, ["#" + selected.id]),
|
|
|
|
|
]));
|
|
|
|
|
detail.append(node.action.scan ? scanExplanation(node) : joinExplanation(ps, node));
|
2026-06-11 15:50:41 +02:00
|
|
|
const colLine = el("p", {}, ["Output columns: "]);
|
|
|
|
|
colLine.append(columnList(rel.columns));
|
|
|
|
|
detail.append(colLine);
|
2026-06-11 17:14:29 +02:00
|
|
|
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();
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderResult() {
|
2026-06-11 17:14:29 +02:00
|
|
|
const ps = plans[selected.planIdx];
|
|
|
|
|
const { plan, results, verdict } = ps;
|
|
|
|
|
document.getElementById("result-title").textContent =
|
|
|
|
|
plans.length > 1 ? "Result Bindings: " + ps.name : "Result Bindings";
|
2026-06-11 15:50:41 +02:00
|
|
|
const body = document.getElementById("result-body");
|
|
|
|
|
body.innerHTML = "";
|
|
|
|
|
const root = results.get(plan.query.root);
|
2026-06-11 17:14:29 +02:00
|
|
|
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));
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
2026-06-11 17:14:29 +02:00
|
|
|
} else if (verdict.status === "error") {
|
|
|
|
|
body.append(el("p", { class: "missing-row" }, [verdict.message]));
|
|
|
|
|
body.append(relationTable(root.columns, root.rows));
|
2026-06-11 15:50:41 +02:00
|
|
|
} 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() {
|
2026-06-11 17:14:29 +02:00
|
|
|
const ps = plans[selected.planIdx];
|
|
|
|
|
document.getElementById("facts-title").textContent =
|
|
|
|
|
plans.length > 1 ? "Input Facts: " + ps.name : "Input Facts";
|
2026-06-11 15:50:41 +02:00
|
|
|
const body = document.getElementById("facts-body");
|
|
|
|
|
body.innerHTML = "";
|
2026-06-11 17:14:29 +02:00
|
|
|
const names = Object.keys(ps.plan.schema).sort();
|
2026-06-11 15:50:41 +02:00
|
|
|
for (const name of names) {
|
2026-06-11 17:14:29 +02:00
|
|
|
const arity = ps.plan.schema[name];
|
|
|
|
|
const rows = (ps.plan.facts || {})[name] || [];
|
2026-06-11 15:50:41 +02:00
|
|
|
body.append(el("div", { class: "relname" }, [name + " / " + arity]));
|
|
|
|
|
const columns = Array.from({ length: arity }, (_, i) => "col" + i);
|
|
|
|
|
body.append(relationTable(columns, rows));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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;
|
2026-06-11 15:50:41 +02:00
|
|
|
let plan;
|
|
|
|
|
try {
|
|
|
|
|
plan = JSON.parse(text);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert("Not valid JSON: " + err.message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-11 17:14:29 +02:00
|
|
|
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
|
|
|
}
|
2026-06-11 17:14:29 +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/.");
|
2026-06-11 15:50:41 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-11 17:14:29 +02:00
|
|
|
const ps = {
|
|
|
|
|
name: plan._scenario || sourceName || "plan",
|
2026-06-11 15:50:41 +02:00
|
|
|
plan,
|
2026-06-11 17:14:29 +02:00
|
|
|
tables,
|
2026-06-11 15:50:41 +02:00
|
|
|
results,
|
|
|
|
|
verdict,
|
2026-06-11 17:14:29 +02:00
|
|
|
byId: new Map(plan.query.nodes.map((n) => [n.id, n])),
|
2026-06-11 15:50:41 +02:00
|
|
|
};
|
2026-06-11 17:14:29 +02:00
|
|
|
if (slot === 0) plans = [ps];
|
|
|
|
|
else plans[1] = ps;
|
|
|
|
|
|
|
|
|
|
document.getElementById("detail-panel").hidden = false;
|
|
|
|
|
document.getElementById("compare-label").hidden = false;
|
2026-06-11 15:50:41 +02:00
|
|
|
document.getElementById("drop-hint").hidden = true;
|
|
|
|
|
document.getElementById("main").hidden = false;
|
2026-06-11 17:14:29 +02:00
|
|
|
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();
|
|
|
|
|
});
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
function baseName(path) {
|
|
|
|
|
return path.split("/").pop();
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById("file-input").addEventListener("change", (event) => {
|
2026-06-11 17:14:29 +02:00
|
|
|
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);
|
2026-06-11 15:50:41 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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");
|
2026-06-11 17:14:29 +02:00
|
|
|
if (event.dataTransfer.files.length) loadFile(event.dataTransfer.files[0], 0);
|
2026-06-11 15:50:41 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-11 17:14:29 +02:00
|
|
|
// 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");
|
2026-06-11 15:50:41 +02:00
|
|
|
if (fixtureParam) {
|
2026-06-11 17:14:29 +02:00
|
|
|
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));
|
2026-06-11 15:50:41 +02:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|