diff --git a/dev/qshape/.gitignore b/dev/qshape/.gitignore
new file mode 100644
index 0000000000..7a60b85e14
--- /dev/null
+++ b/dev/qshape/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/dev/qshape/examples/pr2223.svg b/dev/qshape/examples/pr2223.svg
new file mode 100644
index 0000000000..2a24761ae2
--- /dev/null
+++ b/dev/qshape/examples/pr2223.svg
@@ -0,0 +1,316 @@
+
\ No newline at end of file
diff --git a/dev/qshape/examples/pr2223.yaml b/dev/qshape/examples/pr2223.yaml
new file mode 100644
index 0000000000..bf572ae71a
--- /dev/null
+++ b/dev/qshape/examples/pr2223.yaml
@@ -0,0 +1,37 @@
+# PR #2223 — parallel BWAG for the range-window shape (h2o Q8)
+# Query: SELECT sum(v2) OVER (ORDER BY v2 RANGE BETWEEN 3 PRECEDING AND CURRENT ROW) FROM large
+
+title: "h2o Q8 — parallel range-window"
+K: 8
+
+stages:
+ - id: s0
+ label: "Stage 0"
+ execs:
+ - {id: e1, slices: 4}
+ - {id: e2, slices: 4}
+ ops:
+ - {kind: scan, label: "parquet (S3)"}
+ - {kind: sort, label: "SortExec", keys: [v2]}
+ - {kind: runtime_stats, label: "RuntimeStats (local sketch)"}
+ - {kind: orre, label: "ORRE", K: 8}
+ - {kind: runtime_stats, label: "RuntimeStats (post-ORRE sketch)"}
+ - {kind: shuffle_write, label: "ShuffleWriter", K: 8, index_by: v2, out: rng}
+
+ - id: s1
+ label: "Stage 1"
+ execs:
+ - {id: e1, slices: 4}
+ - {id: e2, slices: 4}
+ input: {from: rng, merge: "kway(v2)"}
+ ops:
+ - {kind: range_filter, label: "RangeFilter (wide + halo)", mode: wide, halo: [-3, 0]}
+ - {kind: partitioned_bwag, label: "PartitionedBWAG", fn: "sum(v2)", frame: "range(-3, 0)"}
+ - {kind: range_filter, label: "RangeFilter (narrow)", mode: narrow, halo: [0, 0]}
+ - {kind: projection, label: "Projection"}
+ sink: client
+
+shuffles:
+ rng:
+ index_by: v2
+ batches_per_writer: 6 # rendered as N cells per shuffle block
diff --git a/dev/qshape/qshape.py b/dev/qshape/qshape.py
new file mode 100755
index 0000000000..e3fc701732
--- /dev/null
+++ b/dev/qshape/qshape.py
@@ -0,0 +1,427 @@
+#!/usr/bin/env python3
+"""qshape: render a distributed query shape as SVG.
+
+Input: YAML describing stages, execs, operator bands, slice grids, shuffles.
+Output: SVG.
+
+v0.1 scope: stage / exec / op-band / slice-cell grid + dashed shuffle edges.
+Not yet: shuffle-block batch cells, K-way glyph, bracketed read windows.
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+CELL_W = 46
+CELL_H = 24
+OP_ROW_H = 34
+ORRE_ROW_H = 62 # taller row for ORRE so the N→N crossing arrows have space
+SW_ROW_H = 68 # taller row for ShuffleWriter so batch sub-cells fit
+HIGHLIGHT_READER_K = 1 # example reader whose read windows we highlight with brackets
+HIGHLIGHT_COLOR = "#e07a00"
+EXEC_PAD = 10
+EXEC_LABEL_H = 18
+STAGE_PAD = 14
+STAGE_LABEL_H = 22
+STAGE_GAP = 90
+OP_LABEL_W = 260
+FONT = "ui-monospace, SFMono-Regular, Menlo, monospace"
+FS_SMALL = 10
+FS_OP = 12
+FS_STAGE = 14
+
+
+@dataclass
+class Exec:
+ id: str
+ slices: int
+
+
+@dataclass
+class Op:
+ kind: str
+ label: str
+ extra: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class Stage:
+ id: str
+ label: str
+ execs: list[Exec]
+ ops: list[Op]
+ input_desc: str | None = None
+ sink: str | None = None
+
+
+@dataclass
+class Doc:
+ title: str
+ K: int
+ stages: list[Stage]
+ shuffles: dict[str, dict]
+
+
+def load(path: Path) -> Doc:
+ data = yaml.safe_load(path.read_text())
+ stages = []
+ for s in data.get("stages", []):
+ execs = [Exec(id=e["id"], slices=int(e["slices"])) for e in s["execs"]]
+ ops = []
+ for o in s.get("ops", []):
+ kind = o.pop("kind")
+ label = o.pop("label", kind)
+ ops.append(Op(kind=kind, label=label, extra=o))
+ input_desc = None
+ if "input" in s:
+ inp = s["input"]
+ input_desc = f"from {inp['from']} · {inp.get('merge', 'concat')}"
+ stages.append(
+ Stage(
+ id=s["id"],
+ label=s.get("label", s["id"]),
+ execs=execs,
+ ops=ops,
+ input_desc=input_desc,
+ sink=s.get("sink"),
+ )
+ )
+ return Doc(
+ title=data.get("title", ""),
+ K=int(data.get("K", 0)),
+ stages=stages,
+ shuffles=data.get("shuffles", {}) or {},
+ )
+
+
+def exec_width(ex: Exec) -> int:
+ return ex.slices * CELL_W + 2 * EXEC_PAD
+
+
+def stage_inner_width(stage: Stage) -> int:
+ return sum(exec_width(e) for e in stage.execs) + EXEC_PAD * (len(stage.execs) - 1)
+
+
+def stage_width(stage: Stage) -> int:
+ return OP_LABEL_W + stage_inner_width(stage) + 2 * STAGE_PAD
+
+
+def row_height(op: Op) -> int:
+ if op.kind == "orre":
+ return ORRE_ROW_H
+ if op.kind == "shuffle_write":
+ return SW_ROW_H
+ return OP_ROW_H
+
+
+def stage_height(stage: Stage) -> int:
+ return STAGE_LABEL_H + EXEC_LABEL_H + sum(row_height(o) for o in stage.ops) + 2 * STAGE_PAD
+
+
+def rect(x, y, w, h, fill="#fff", stroke="#333", sw=1, rx=4) -> str:
+ return f''
+
+
+def text(x, y, s, size=FS_SMALL, anchor="middle", weight="normal", fill="#111") -> str:
+ return (
+ f'{s}'
+ )
+
+
+def line(x1, y1, x2, y2, stroke="#555", sw=1, dash=None) -> str:
+ d = f' stroke-dasharray="{dash}"' if dash else ""
+ return f''
+
+
+OP_FILL = {
+ "scan": "#eef",
+ "sort": "#efe",
+ "runtime_stats": "#fff5d6",
+ "orre": "#ffdede",
+ "shuffle_write": "#e2f0ff",
+ "range_filter": "#f0e6ff",
+ "partitioned_bwag": "#ffe6cc",
+ "projection": "#eeeeee",
+}
+
+
+def render(doc: Doc) -> str:
+ # Layout: stages stacked vertically, source at BOTTOM, sink at TOP.
+ # (matches user's mental model — execution flows up.)
+ ordered = list(reversed(doc.stages)) # so index 0 is topmost
+ max_w = max(stage_width(s) for s in doc.stages)
+
+ y_cursor = 40 # title space
+ if doc.title:
+ pass # rendered at top later
+
+ stage_positions = []
+ for s in ordered:
+ y_cursor += 0 if not stage_positions else STAGE_GAP
+ sh = stage_height(s)
+ x = (max_w - stage_width(s)) // 2 + 20
+ stage_positions.append((s, x, y_cursor))
+ y_cursor += sh
+
+ canvas_w = max_w + 40
+ canvas_h = y_cursor + 40
+
+ parts: list[str] = []
+ parts.append(
+ f'")
+ return "\n".join(parts)
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Render a query shape as SVG")
+ ap.add_argument("input", type=Path, help="input YAML file")
+ ap.add_argument("-o", "--output", type=Path, required=True, help="output SVG file")
+ args = ap.parse_args()
+
+ doc = load(args.input)
+ svg = render(doc)
+ args.output.write_text(svg)
+ print(f"wrote {args.output}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()