Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions tools/dag-viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ The viewer has one visualization mode: **Pre/Post-ASAP**.

- Select one query to see that query's complete pre-ASAP and post-ASAP DAGs.
- Select multiple queries to see two workload-union DAGs: one pre-ASAP union
and one post-ASAP union. Structurally identical nodes and edges are
collapsed while query roots and ownership are retained.
and one post-ASAP union. Nodes with the same exporter-assigned workload
identity are collapsed while query roots and ownership are retained.
- Pre-ASAP nodes show only their original IR content.
- Post-ASAP nodes show their translated IR content and the explicit planner
decision carried by that node.
Expand Down
29 changes: 8 additions & 21 deletions tools/dag-viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,23 +140,23 @@
.schemaHint { color:var(--muted); font-size:.7rem; }
.plannerActions { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.55rem; }
#plannerStatus { color: var(--muted); font-size: 0.75rem; }
#baPicker {
#scopePicker {
display: none;
flex-wrap: wrap;
gap: 0.6rem 1.2rem;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
#baPicker.visible { display: flex; }
.baGroup { display: flex; flex-direction: column; gap: 0.3rem; min-width: 220px; }
.baGroupLabel {
#scopePicker.visible { display: flex; }
.scopeGroup { display: flex; flex-direction: column; gap: 0.3rem; min-width: 220px; }
.scopeGroupLabel {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: .03em;
color: var(--muted);
}
.baRow {
.scopeRow {
display: flex;
align-items: center;
gap: 0.5rem;
Expand All @@ -165,29 +165,16 @@
border-radius: 8px;
background: var(--panel2);
color: var(--fg);
cursor: pointer;
font: inherit;
font-size: 0.78rem;
text-align: left;
}
.baRow:hover { border-color: var(--accent); }
.baRow:disabled { opacity: 0.55; cursor: not-allowed; }
.baRow:disabled:hover { border-color: var(--border); }
.baRow.active {
.scopeRow.active {
border-color: var(--accent);
background: var(--bg);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 15%, transparent);
}
.baRow .baWinner {
font-size: 0.66rem;
font-weight: 700;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 0.05rem 0.4rem;
flex-shrink: 0;
}
.baRow .baMeta { color: var(--muted); font-size: 0.72rem; margin-left: auto; white-space: nowrap; }
.scopeRow .scopeMeta { color: var(--muted); font-size: 0.72rem; margin-left: auto; white-space: nowrap; }
.proxyNote {
font-size: 0.72rem;
color: var(--muted);
Expand Down Expand Up @@ -424,7 +411,7 @@ <h2>Input table schemas</h2>
</div>

<div id="tabs"></div>
<div id="baPicker"></div>
<div id="scopePicker"></div>

<div id="main">
<div id="empty">
Expand Down
129 changes: 11 additions & 118 deletions tools/dag-viewer/node-style.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// Category table for QueryExpr node kinds — the single source of truth for
// how the viewer colors/labels/icons each `DagNode.kind`. Edit this file to
// how the viewer colors and labels each `DagNode.kind`. Edit this file to
// reclassify a kind or retune a palette; nothing else in index.html needs to
// change. Icon SVGs are adapted from ProjectASAP/bgp-query-dag-explorer's
// hand-drawn icon set (see tools/dag-viewer/README.md).
// change.
//
// Also covers the 7 post-ASAP-only SummaryDagNode kinds (KeepPreAsap,
// SummaryAgg, SummaryJoin, SummarySubtract, SummaryDelete, SummaryEstimate,
// SummaryMerge) that only ever appear in Before/After mode's After lane when
// an entry's `after.kind === "Summary"` — see the `summary` category below.
// SummaryMerge) that appear in the post-ASAP lane — see the `summary`
// category below.

// kind (DagNode.kind from crates/ir/src/dag_export.rs) -> category name.
const KIND_CATEGORY = {
Expand Down Expand Up @@ -36,7 +35,7 @@ const KIND_CATEGORY = {
Sort: 'sort',
Limit: 'sort',
LetBinding: 'bind',
// Post-ASAP SummaryDagNode kinds (Before/After mode's After lane only).
// Post-ASAP SummaryDagNode kinds (post-ASAP lane only).
KeepPreAsap: 'summary',
SummaryAgg: 'summary',
SummaryJoin: 'summary',
Expand All @@ -46,47 +45,6 @@ const KIND_CATEGORY = {
SummaryMerge: 'summary',
};

const ICON_STROKE = '2.2';
const ICON_FILL = 'rgba(255,255,255,.06)';

// Each icon fn takes a stroke/fill color and returns a standalone SVG string
// (viewBox 0 0 48 48, consistent stroke weight) for use as a node background
// image. Cylinder/funnel/transform/grid/venn/dashed-circle/sort-arrow are
// adapted directly from the reference repo's `iconFor()`; `bind` and the
// root-node badge are new, drawn in the same style.
const ICONS = {
data: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><ellipse cx="24" cy="10" rx="15" ry="6" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><path d="M9 10v24c0 3.4 6.7 6 15 6s15-2.6 15-6V10" fill="none" stroke="${c}" stroke-width="${ICON_STROKE}"/><path d="M9 22c0 3.4 6.7 6 15 6s15-2.6 15-6" fill="none" stroke="${c}" stroke-width="${ICON_STROKE}"/></svg>`,
filter: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M8 10h32L28 24v12l-8 4V24z" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"/></svg>`,
derive: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="13" cy="14" r="4" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><circle cx="35" cy="14" r="4" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><circle cx="24" cy="34" r="4" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><path d="M17 16l14 0M15 18l7 13M33 18l-7 13" stroke="${c}" stroke-width="${ICON_STROKE}" fill="none" stroke-linecap="round"/></svg>`,
aggregate: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><text x="24" y="34" font-size="30" font-weight="900" text-anchor="middle" font-family="ui-sans-serif,system-ui,sans-serif" fill="${c}">Σ</text></svg>`,
window: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x="9" y="10" width="30" height="28" rx="4" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><path d="M16 10v28M24 10v28M32 10v28M9 20h30M9 29h30" stroke="${c}" stroke-width="1.6" opacity=".8"/><path d="M16 7l16 34" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linecap="round"/></svg>`,
join: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="19" cy="24" r="13" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><circle cx="29" cy="24" r="13" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/></svg>`,
set: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="16" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-dasharray="3 3"/><circle cx="18" cy="22" r="2" fill="${c}"/><circle cx="26" cy="18" r="2" fill="${c}"/><circle cx="29" cy="29" r="2" fill="${c}"/><circle cx="20" cy="31" r="2" fill="${c}"/></svg>`,
sort: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M12 12h20M12 22h15M12 32h10" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linecap="round"/><path d="M35 12v24m0 0l-6-6m6 6l6-6" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
// New: a name tag, for LetBinding naming a sub-expression for reuse.
bind: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M8 22V12a4 4 0 0 1 4-4h10l18 18-14 14L8 22z" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"/><circle cx="17" cy="17" r="2.4" fill="${c}"/></svg>`,
// New: stacked layers, for the post-ASAP `summary` category — a materialized
// structure (a sketch, a rollup, a merged/estimated join) rather than a
// pre-ASAP relational operator.
summary: (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x="9" y="7" width="30" height="10" rx="3" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><rect x="9" y="19" width="30" height="10" rx="3" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><rect x="9" y="31" width="30" height="10" rx="3" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/></svg>`,
};

// KeepPreAsap-only: a dashed box with a through-arrow, standing in for
// "unchanged pre-ASAP content carried through as-is" — see the `summary`
// CATEGORIES entry and buildCyStyle's `node[kind = "KeepPreAsap"]` override
// in viewer.js for why this doesn't just reuse ICONS.summary's stroke color.
const KEEP_PRE_ASAP_ICON = (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x="7" y="14" width="34" height="20" rx="4" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-dasharray="4 3"/><path d="M13 24h22m0 0l-6-6m6 6l-6 6" fill="none" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linecap="round" stroke-linejoin="round"/></svg>`;

function keepPreAsapIconDataUri() {
const muted = getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || '#6b7280';
return svgDataUri(KEEP_PRE_ASAP_ICON(muted));
}

// New: a small flag/marker badge layered onto whichever node is a query's
// root, since QueryExpr has no dedicated terminal "output" node kind the way
// the reference's BGP `out_*` steps do.
const ROOT_BADGE_ICON = (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M14 12h20v9c0 8-4 13-10 15-6-2-10-7-10-15z" fill="${ICON_FILL}" stroke="${c}" stroke-width="${ICON_STROKE}"/><path d="M18 38h12M21 42h6" stroke="${c}" stroke-width="${ICON_STROKE}" stroke-linecap="round"/></svg>`;

// name -> { label, description, light: {bg, border}, dark: {bg, border} }
const CATEGORIES = {
data: {
Expand Down Expand Up @@ -143,7 +101,7 @@ const CATEGORIES = {
light: { bg: '#fff5ea', border: '#b45309' },
dark: { bg: '#3a2408', border: '#fb923c' },
},
// Post-ASAP only (Before/After mode's After lane): every other category
// Post-ASAP only (post-ASAP lane): every other category
// above is a saturated, hand-picked hue for a pre-ASAP QueryExpr operator.
// `summary` is deliberately plain neutral gray instead of another hue —
// partly because a 10th saturated color starts getting hard to
Expand All @@ -157,7 +115,7 @@ const CATEGORIES = {
// through) rather than a different concept.
summary: {
label: 'Summary',
description: 'KeepPreAsap, SummaryAgg, SummaryJoin, SummarySubtract, SummaryDelete, SummaryEstimate, SummaryMerge — post-ASAP materialized structures (Before/After mode only)',
description: 'KeepPreAsap, SummaryAgg, SummaryJoin, SummarySubtract, SummaryDelete, SummaryEstimate, SummaryMerge — post-ASAP materialized structures',
light: { bg: '#f1f2f4', border: '#4b5563' },
dark: { bg: '#20242b', border: '#9ca3af' },
},
Expand All @@ -170,35 +128,10 @@ const ROOT_BADGE = {
dark: { border: '#f87171' },
};

// `DagNote.kind` -> badge color, for a node whose `notes` array (issue #257 —
// asap-aware-mapping's ReplacementExplanation, matched onto this node by
// dag_export's own hash) is non-empty. Only the two kinds
// asap_aware_mapping::ExplanationKind currently ships get their own entry;
// NOTE_KIND_FALLBACK covers anything else (a future kind, or a node whose
// notes mix more than one kind) without needing a new color per addition.
const NOTE_KIND_COLOR = {
SketchApproximation: { light: '#a16207', dark: '#facc15' }, // reuses the 'aggregate' category hue
CommonSubexpressionReuse: { light: '#4f46e5', dark: '#a5b4fc' }, // reuses the 'set' category hue
};
const NOTE_KIND_FALLBACK = { light: '#334155', dark: '#cbd5e1' };
const NOTE_BADGE_LABEL = {
SketchApproximation: 'Sketch alternative available',
CommonSubexpressionReuse: 'Shareable across consumers',
};

// Small "why" marker (a filled circle + exclamation mark) for a node whose
// `notes` is non-empty — layered in the bottom-right corner, so it never
// collides with the root badge (top-right) or the category icon (top-center).
const NOTE_BADGE_ICON = (c) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="17" fill="${c}"/><path d="M24 14v16" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="24" cy="35" r="2.6" fill="white"/></svg>`;

function isDarkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}

function svgDataUri(svg) {
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

function categoryOf(kind) {
return KIND_CATEGORY[kind] || 'derive';
}
Expand All @@ -208,52 +141,12 @@ function categoryColors(name) {
return isDarkMode() ? cat.dark : cat.light;
}

function categoryIconDataUri(name) {
const { border } = categoryColors(name);
const icon = ICONS[name] || ICONS.derive;
return svgDataUri(icon(border));
}

function rootBadgeIconDataUri() {
const { border } = isDarkMode() ? ROOT_BADGE.dark : ROOT_BADGE.light;
return svgDataUri(ROOT_BADGE_ICON(border));
}

// The color a `notes` badge/chip should use for `kind` — one of
// ExplanationKind's two known variants, or NOTE_KIND_FALLBACK for anything
// else (future kind, or `noteBadgeColor`'s own "mixed kinds" case below).
function noteKindColor(kind) {
const c = NOTE_KIND_COLOR[kind] || NOTE_KIND_FALLBACK;
return isDarkMode() ? c.dark : c.light;
}

// The color for a node's *badge* (as opposed to one note's own chip): the
// shared color if every note on the node is the same kind, otherwise the
// fallback — a node need not itself distinguish "two sketch findings" from
// "a sketch finding and a reuse finding" the way the side-panel detail
// (which lists each note individually) does.
function noteBadgeColor(notes) {
const kinds = new Set((notes || []).map((n) => n.kind));
if (kinds.size === 1) return noteKindColor(notes[0].kind);
const c = NOTE_KIND_FALLBACK;
return isDarkMode() ? c.dark : c.light;
}

function noteBadgeIconDataUri(notes) {
return svgDataUri(NOTE_BADGE_ICON(noteBadgeColor(notes)));
}

// Mirror the palette into CSS custom properties so plain-DOM chrome (legend,
// side panel chips) can use var(--cat-<name>-bg/border) instead of
// duplicating these hex values in index.html's <style>. Cytoscape's own
// stylesheet reads the JS tables directly (categoryColors/categoryIconDataUri)
// since var() support in a vendored cytoscape build isn't something to rely on.
function applyPaletteToCssVars() {
const root = document.documentElement.style;
for (const [name, cat] of Object.entries(CATEGORIES)) {
const c = isDarkMode() ? cat.dark : cat.light;
root.setProperty(`--cat-${name}-bg`, c.bg);
root.setProperty(`--cat-${name}-border`, c.border);
for (const [name, category] of Object.entries(CATEGORIES)) {
const colors = isDarkMode() ? category.dark : category.light;
root.setProperty(`--cat-${name}-bg`, colors.bg);
root.setProperty(`--cat-${name}-border`, colors.border);
}
root.setProperty('--cat-output-border', (isDarkMode() ? ROOT_BADGE.dark : ROOT_BADGE.light).border);
}
Expand Down
20 changes: 0 additions & 20 deletions tools/dag-viewer/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,29 +246,9 @@ def prepare_graph(graph: object) -> None:
nested = (node.get("detail") or {}).get("pre_asap_subgraph")
prepare_graph(nested)

def append_root_line(graph: object, line: str) -> None:
if not isinstance(graph, dict):
return
root_id = graph.get("root")
root = next((node for node in graph.get("nodes", []) if node.get("id") == root_id), None)
if root is not None:
root["label"] += f"\n{line}"

for query in prepared.get("queries", []):
prepare_graph(query.get("graph"))
prepare_graph(query.get("post_graph"))
for replacement in query.get("replacements", []):
before = replacement.get("before")
after = replacement.get("after") or {}
after_graph = after.get("graph")
prepare_graph(before)
prepare_graph(after_graph)
if replacement.get("strategy") == "SharedSubtree" or replacement.get("provenance") == "CseShare":
rationale = str(replacement.get("rationale", ""))
count = re.search(r"(\d+)\s+consumers?", rationale)
suffix = f" ({count.group(1)} consumers)" if count else ""
append_root_line(before, "reuse: recomputed per consumer")
append_root_line(after_graph, f"reuse: shared across workload{suffix}")
return prepared


Expand Down
56 changes: 15 additions & 41 deletions tools/dag-viewer/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,14 @@ def test_prepares_before_after_and_whole_post_asap_graphs(self):
"detail": {"measures": [{"kind": "avg", "col": 3}]},
"children": [],
}
graph = {"nodes": [node], "root": 0}
def graph():
return {"nodes": [dict(node)], "root": 0}
workload = {
"queries": [
{
"graph": graph,
"post_graph": graph,
"replacements": [{"before": graph, "after": {"graph": graph}}],
"graph": graph(),
"post_graph": graph(),
"replacements": [{"before": graph(), "after": {"graph": graph()}}],
}
]
}
Expand All @@ -228,47 +229,20 @@ def test_prepares_before_after_and_whole_post_asap_graphs(self):
labels = [
query["graph"]["nodes"][0]["label"],
query["post_graph"]["nodes"][0]["label"],
query["replacements"][0]["before"]["nodes"][0]["label"],
query["replacements"][0]["after"]["graph"]["nodes"][0]["label"],
]
self.assertEqual(labels, ["Aggregate\nmeasure: avg(col[3])"] * 4)
self.assertEqual(labels, ["Aggregate\nmeasure: avg(col[3])"] * 2)
self.assertEqual(
query["replacements"][0]["before"]["nodes"][0]["label"],
"Aggregate(1 measures)",
)

def test_cse_before_after_makes_reuse_decision_visible(self):
node = {
"id": 0,
"kind": "Scan",
"label": "Scan(metrics)",
"detail": {},
"children": [],
}
def test_replacement_subgraphs_are_not_prepared_for_the_current_viewer(self):
def graph():
return {"nodes": [dict(node)], "root": 0}

workload = {
"queries": [
{
"graph": graph(),
"replacements": [
{
"strategy": "SharedSubtree",
"provenance": "CseShare",
"rationale": "Scan(metrics) has 2 consumers across this workload",
"before": graph(),
"after": {"graph": graph()},
}
],
}
]
}
return {"nodes": [{"id": 0, "kind": "Scan", "label": "legacy", "detail": {}, "children": []}], "root": 0}
workload = {"queries": [{"graph": graph(), "replacements": [{"before": graph(), "after": {"graph": graph()}}]}]}
replacement = prepare_workload(workload)["queries"][0]["replacements"][0]
self.assertEqual(
replacement["before"]["nodes"][0]["label"],
"Scan\nsource: metrics\nreuse: recomputed per consumer",
)
self.assertEqual(
replacement["after"]["graph"]["nodes"][0]["label"],
"Scan\nsource: metrics\nreuse: shared across workload (2 consumers)",
)
self.assertEqual(replacement["before"]["nodes"][0]["label"], "legacy")
self.assertEqual(replacement["after"]["graph"]["nodes"][0]["label"], "legacy")


class MainCliTests(unittest.TestCase):
Expand Down
Loading