From 12bfa50a4ff3a8bbc23287336acf6a74d11ecfb6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 22 Jun 2026 11:48:46 +0200 Subject: [PATCH] =?UTF-8?q?plan:=200062=20visual-world-cut-2=20=E2=80=94?= =?UTF-8?q?=20decimation=20+=20run-context=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tasks on the aura-cli render path + a workspace gate: - Task 1 (#102, compile-atomic): ChartMeta + ChartData.meta, built from RunManifest in build_chart_data/build_comparison_chart_data (gain a `name` param), every call site threaded (emit_chart + 3 comparison unit tests + the render test helper), the manifest re-injected into window.AURA_TRACES, the negative render pin flipped. - Task 2 (#102): #ctx CHART_HEAD slot + a pure buildHeader in chart-viewer.js with a node-driven headless guard pair (chart_viewer_header.{mjs,rs}). - Task 3 (#108): a pure decimate(ChartData, buckets) min-max transform applied in emit_chart on both paths; engine + registry untouched (C14). - Task 4: cargo test --workspace + clippy -D warnings. refs #108 refs #102 --- docs/plans/0062-visual-world-cut-2.md | 753 ++++++++++++++++++++++++++ 1 file changed, 753 insertions(+) create mode 100644 docs/plans/0062-visual-world-cut-2.md diff --git a/docs/plans/0062-visual-world-cut-2.md b/docs/plans/0062-visual-world-cut-2.md new file mode 100644 index 0000000..75ff24d --- /dev/null +++ b/docs/plans/0062-visual-world-cut-2.md @@ -0,0 +1,753 @@ +# Visual World cut 2 — decimation + run-context header — Implementation Plan + +> **Parent spec:** `docs/specs/0062-visual-world-cut-2.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make the families-comparison chart page openable on real multi-year data +(serve-time min-max decimation, #108) and legible (a run-context header threaded +from the manifest, #102), with the engine + registry untouched (C14). + +**Architecture:** Three tasks on the aura-cli render path. Task 1 is the +compile-atomic #102 plumbing: a `ChartMeta` struct + a `ChartData.meta` field, built +from `RunManifest` in the two builders (which gain a `name` param), every call site +threaded, and the manifest re-injected into `window.AURA_TRACES`. Task 2 is the +#102 presentation: a `#ctx` header slot + a pure `buildHeader` in `chart-viewer.js` +with a headless `.mjs` guard. Task 3 is #108: a pure `decimate(ChartData, buckets)` +transform applied in `emit_chart` on both paths. Task 4 is the workspace gate. + +**Tech Stack:** Rust (`crates/aura-cli` — `render.rs`, `main.rs`), JavaScript +(`crates/aura-cli/assets/chart-viewer.js`), `node`-driven headless guards +(`crates/aura-cli/tests/*.mjs` shelled by paired `*.rs`). + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-cli/src/render.rs:128-143` — add `ChartMeta`; add `meta` field to `ChartData`; reword the `ChartData` doc. +- Modify: `crates/aura-cli/src/render.rs:154-159` — inject `"meta"` into `render_chart_html`'s `window.AURA_TRACES`. +- Modify: `crates/aura-cli/src/render.rs:65-72` — add the `#ctx` slot to `CHART_HEAD`. +- Modify: `crates/aura-cli/src/render.rs:215-262` — update `sample_chart_data()`; flip the negative render test to `render_chart_html_injects_run_context`. +- Modify: `crates/aura-cli/src/main.rs:15` — import `ChartMeta`. +- Modify: `crates/aura-cli/src/main.rs:243-387` — `build_chart_data`/`build_comparison_chart_data` gain `name` + build `meta`; `filter_to_tap` narrows `meta.taps`; `emit_chart` threads `name` and (Task 3) applies `decimate`; add `scalar_display`, `decimate`, `CHART_DECIMATE_BUCKETS`. +- Modify: `crates/aura-cli/src/main.rs:1642,1659,1668` — thread `name` into the 3 `build_comparison_chart_data` unit-test calls; add `decimate` unit tests. +- Modify: `crates/aura-cli/assets/chart-viewer.js:90-151` — add pure `buildHeader`; fill `#ctx` in `mount`; export `buildHeader`. +- Create: `crates/aura-cli/tests/chart_viewer_header.mjs` — headless guard driving `buildHeader`. +- Create: `crates/aura-cli/tests/chart_viewer_header.rs` — `node`-shelling runner mirroring `chart_viewer.rs`. + +--- + +## Task 1: #102 — `ChartMeta` plumbing (compile-atomic) + +Adds the run-context type and threads it from the manifest into the served page. +The field add + the two signature changes break the whole crate until every call +site is threaded, so all threading lands here and the build gate (Step 7) is the +first compile. + +**Files:** +- Modify: `crates/aura-cli/src/render.rs` +- Modify: `crates/aura-cli/src/main.rs` + +- [ ] **Step 1: Add `ChartMeta` + the `ChartData.meta` field (render.rs)** + +Replace the `ChartData` doc + struct at `crates/aura-cli/src/render.rs:135-143`: + +```rust +/// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta` +/// and rendered client-side by `chart-viewer.js`'s pure `buildHeader`. A single run +/// carries its manifest fields + bound params; a family carries the shared context +/// (commit/window/broker) + member count — per-member identity is the series label +/// (`member.key`), not repeated here. +#[derive(serde::Serialize, Default)] +pub struct ChartMeta { + /// "run" | "family". + pub kind: String, + /// The chart name (run name or family id). + pub name: String, + /// Git commit of the run's frozen artifact (C18); `buildHeader` shows the short form. + pub commit: String, + /// Inclusive data window `(from, to)` epoch-ns. + pub window: (i64, i64), + /// Broker profile label, e.g. "sim-optimal(pip_size=1)". + pub broker: String, + /// RNG seed (0 for a seed-free synthetic run). + pub seed: u64, + /// The charted taps (single run: all charted taps or the `--tap` pick; family: + /// the one compared tap). + pub taps: Vec, + /// Family member count; `None` for a single run. + pub members: Option, + /// Bound params (single run only; empty for a family, where params are the + /// per-member series labels). Stringified `name -> display` for the header. + pub params: Vec<(String, String)>, +} + +/// The serve-ready chart payload: the shared timestamp axis, the per-(tap,column) +/// series, and the run-context `meta`. These are exactly the fields `chart-viewer.js` +/// reads; injected verbatim as `window.AURA_TRACES` (plus the `mode`). `meta` carries +/// the manifest context for the header (#102); the full manifest stays on disk. +pub struct ChartData { + pub xs: Vec, + pub series: Vec, + pub meta: ChartMeta, +} +``` + +- [ ] **Step 2: Add `scalar_display` + build `meta` in `build_chart_data` (main.rs)** + +Change the import at `crates/aura-cli/src/main.rs:15`: + +```rust +use render::{ChartData, ChartMeta, ChartMode, Series}; +``` + +Add this helper immediately above `build_chart_data` (before the doc at `main.rs:243`): + +```rust +/// Render a bound param's `Scalar` to a compact string for the chart header (#102). +/// Local to the CLI header path: `Scalar` has no `Display` impl (C7 keeps it +/// tag-only on the param plane), and the on-disk serde-tagged form (`{"I64":10}`) +/// is too noisy for a one-line header. +fn scalar_display(s: &Scalar) -> String { + match s { + Scalar::I64(v) => v.to_string(), + Scalar::F64(v) => format!("{v}"), + Scalar::Bool(v) => v.to_string(), + Scalar::Timestamp(t) => t.0.to_string(), + } +} +``` + +Change `build_chart_data`'s signature at `main.rs:251` and its return at `main.rs:272`. +Signature line: + +```rust +fn build_chart_data(name: &str, traces: RunTraces) -> ChartData { +``` + +Return (replace `ChartData { xs, series }` at `main.rs:272`): + +```rust + let m = &traces.manifest; + let meta = ChartMeta { + kind: "run".to_string(), + name: name.to_string(), + commit: m.commit.clone(), + window: (m.window.0.0, m.window.1.0), + broker: m.broker.clone(), + seed: m.seed, + taps: traces.taps.iter().map(|t| t.tap.clone()).collect(), + members: None, + params: m.params.iter().map(|(k, v)| (k.clone(), scalar_display(v))).collect(), + }; + ChartData { xs, series, meta } +``` + +- [ ] **Step 3: Add `name` + family `meta` in `build_comparison_chart_data` (main.rs)** + +Change the signature at `main.rs:285`: + +```rust +fn build_comparison_chart_data( + name: &str, + members: &[FamilyMember], + tap: &str, +) -> Result { +``` + +Replace the return `Ok(ChartData { xs, series })` at `main.rs:321`: + +```rust + // member_rows is non-empty here (checked above) => members is non-empty, so + // members[0] is safe; commit/window/broker are shared across a family. + let m = &members[0].traces.manifest; + let meta = ChartMeta { + kind: "family".to_string(), + name: name.to_string(), + commit: m.commit.clone(), + window: (m.window.0.0, m.window.1.0), + broker: m.broker.clone(), + seed: m.seed, + taps: vec![tap.to_string()], + members: Some(members.len()), + params: Vec::new(), + }; + Ok(ChartData { xs, series, meta }) +``` + +- [ ] **Step 4: Narrow `meta.taps` in `filter_to_tap` (main.rs)** + +Replace the body of `filter_to_tap` (`main.rs:327-333`): + +```rust +fn filter_to_tap(data: ChartData, tap: &str) -> Result { + let series: Vec = data.series.into_iter().filter(|s| s.name == tap).collect(); + if series.is_empty() { + return Err(format!("run has no tap named '{tap}'")); + } + let mut meta = data.meta; + meta.taps = vec![tap.to_string()]; + Ok(ChartData { xs: data.xs, series, meta }) +} +``` + +- [ ] **Step 5: Thread `name` into the `emit_chart` call sites (main.rs)** + +In the Run arm, change `main.rs:350`: + +```rust + let mut data = build_chart_data(name, traces); +``` + +In the Family arm, change `main.rs:370`: + +```rust + let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) { +``` + +(The `decimate` calls are added in Task 3; do not add them here.) + +- [ ] **Step 6: Thread `name` into the 3 comparison unit tests + the render test helper** + +In `main.rs`, the three `build_comparison_chart_data(&members, …)` calls become +name-first. At `main.rs:1642`: + +```rust + let data = build_comparison_chart_data("fam", &members, "equity").expect("builds"); +``` + +At `main.rs:1659`: + +```rust + let data = build_comparison_chart_data("fam", &members, "equity").expect("builds"); +``` + +At `main.rs:1668`: + +```rust + assert!(build_comparison_chart_data("fam", &members, "nosuch").is_err()); +``` + +In `crates/aura-cli/src/render.rs`, replace `sample_chart_data()` at `render.rs:215-223`: + +```rust + fn sample_chart_data() -> ChartData { + ChartData { + xs: vec![1, 2, 3], + series: vec![ + Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)] }, + Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)] }, + ], + meta: ChartMeta { + kind: "run".into(), + name: "sample".into(), + commit: "abc1234def".into(), + window: (1, 3), + broker: "sim-optimal(pip_size=1)".into(), + seed: 0, + taps: vec!["equity".into(), "exposure".into()], + members: None, + params: vec![], + }, + } + } +``` + +- [ ] **Step 7: Build gate — the whole crate compiles again** + +Run: `cargo build -p aura-cli --tests` +Expected: `Finished` with 0 errors (all call sites threaded; `render_chart_html` +does not yet inject `meta`, which is intentional for the Step 9 RED). + +- [ ] **Step 8: Verify the OLD negative test still passes (meta not yet injected)** + +Run: `cargo test -p aura-cli --lib render_chart_html_injects_only_what_the_viewer_reads` +Expected: PASS (the field exists but is not injected into the page yet, so the +`!contains("manifest")` / `!contains("taps")` assertions still hold). + +- [ ] **Step 9: Replace the negative render test with the positive one (RED)** + +Replace the test + its doc at `crates/aura-cli/src/render.rs:255-262`: + +```rust + /// `window.AURA_TRACES.meta` now carries the run-context the header reads + /// (#102): the run's commit / window / broker / taps, threaded from the + /// manifest (this flipped the earlier "manifest is NOT carried" pin). + #[test] + fn render_chart_html_injects_run_context() { + let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay); + assert!(html.contains("\"meta\""), "run-context meta not injected"); + assert!(html.contains("abc1234def"), "manifest commit not injected"); + assert!(html.contains("sim-optimal(pip_size=1)"), "manifest broker not injected"); + assert!(html.contains("\"kind\":\"run\""), "meta kind not injected"); + } +``` + +Run: `cargo test -p aura-cli --lib render_chart_html_injects_run_context` +Expected: FAIL — `run-context meta not injected` (render_chart_html still injects +only `{mode, xs, series}`). + +- [ ] **Step 10: Inject `meta` into the page (GREEN)** + +Replace the `serde_json::json!` block in `render_chart_html` at `render.rs:154-159`: + +```rust + let traces = serde_json::json!({ + "mode": mode_str, + "xs": data.xs, + "series": data.series, + "meta": data.meta, + }) + .to_string(); +``` + +Run: `cargo test -p aura-cli --lib render_chart_html_injects_run_context` +Expected: PASS. + +- [ ] **Step 11: Whole-crate test gate** + +Run: `cargo test -p aura-cli` +Expected: PASS — all lib + integration tests green (the family/single chart E2E +tests in `cli_run.rs` tolerate the additive `"meta"` key). + +--- + +## Task 2: #102 — run-context header (CHART_HEAD slot + `buildHeader` + guard) + +Renders the injected `meta` as a header. The headless guard is written first (RED: +`buildHeader` is not yet exported), then the function + DOM wiring (GREEN). + +**Files:** +- Create: `crates/aura-cli/tests/chart_viewer_header.mjs` +- Create: `crates/aura-cli/tests/chart_viewer_header.rs` +- Modify: `crates/aura-cli/assets/chart-viewer.js` +- Modify: `crates/aura-cli/src/render.rs:65-72` + +- [ ] **Step 1: Write the headless guard (.mjs) driving `buildHeader`** + +Create `crates/aura-cli/tests/chart_viewer_header.mjs`: + +```js +// Headless guard for the run-context header builder (chart-viewer.js buildHeader). +// Property protected: buildHeader maps an AURA_TRACES.meta object to header chips +// WITHOUT a DOM — a run shows name/tap/broker/window/commit chips; a family adds a +// members chip; absent meta yields []. Loads the real viewer module and drives the +// exported pure buildHeader; mount() (browser-only) is never called. +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); + +const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); }; +const has = (chips, label, needle, msg) => { + const c = chips.find((c) => c.label === label); + if (!c) fail(msg + " (no '" + label + "' chip)"); + if (needle != null && String(c.value).indexOf(needle) < 0) fail(msg + " (got " + c.value + ")"); +}; + +// No `document` global -> chart-viewer.js's browser mount is skipped; require gives +// only the pure export (mirrors chart_viewer_build.mjs stubbing window before require). +global.window = {}; +const { buildHeader } = require(join(here, "..", "assets", "chart-viewer.js")); + +// run meta -> name / tap / broker / window / commit chips; no members chip +const run = { + kind: "run", name: "eur-demo", commit: "abc1234def567", + window: [1704067200000000000, 1735689600000000000], broker: "sim-optimal(pip_size=0.0001)", + seed: 0, taps: ["equity", "exposure"], members: null, params: [["len", "20"]], +}; +const rc = buildHeader(run); +has(rc, "run", "eur-demo", "run name chip"); +has(rc, "tap", "equity", "tap chip"); +has(rc, "broker", "sim-optimal", "broker chip"); +has(rc, "window", "2024", "window chip (formatted day)"); +has(rc, "commit", "abc1234", "commit chip (shortened)"); +if (rc.find((c) => c.label === "members")) fail("a single run must not carry a members chip"); + +// family meta -> adds a members chip, name label is 'family' +const fam = { + kind: "family", name: "ger40-5y", commit: "deadbeef", window: [0, 0], + broker: "sim-optimal(pip_size=1)", seed: 0, taps: ["equity"], members: 4, params: [], +}; +const fc = buildHeader(fam); +has(fc, "family", "ger40-5y", "family name chip"); +has(fc, "members", "4", "family members chip"); + +// absent meta -> [] +if (buildHeader(undefined).length !== 0) fail("absent meta must yield no chips"); + +console.log("OK — buildHeader run/family/empty chips."); +process.exit(0); +``` + +- [ ] **Step 2: Write the `node`-shelling runner (.rs)** + +Create `crates/aura-cli/tests/chart_viewer_header.rs`: + +```rust +//! Integration guard for the `aura chart` run-context header builder. +//! +//! `chart-viewer.js`'s `buildHeader` maps `AURA_TRACES.meta` to header chips in +//! JavaScript, so the property "buildHeader yields the right run/family chips" +//! cannot be checked from Rust. This shells out to `node`, running +//! `tests/chart_viewer_header.mjs`, which loads the real viewer module and drives +//! the exported pure `buildHeader`. `node` is REQUIRED: if absent this test FAILS. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn chart_viewer_builds_run_context_header() { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("chart_viewer_header.mjs"); + assert!(script.exists(), "guard script missing at {}", script.display()); + + let out = match Command::new("node").arg(&script).output() { + Ok(out) => out, + Err(e) => panic!( + "node is required for the chart-viewer header guard but could not be run \ + ({e}); install Node.js or ensure `node` is on PATH" + ), + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "chart-viewer header guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}", + out.status.code() + ); +} +``` + +- [ ] **Step 3: Run the guard to verify it fails (RED)** + +Run: `cargo test -p aura-cli --test chart_viewer_header` +Expected: FAIL — node throws because `buildHeader` is not exported by +`chart-viewer.js` yet (`buildHeader is not a function` / destructure yields +`undefined`). + +- [ ] **Step 4: Add the pure `buildHeader` + export it (chart-viewer.js)** + +Insert `buildHeader` into the IIFE immediately after `keyXRange` ends +(`crates/aura-cli/assets/chart-viewer.js:88`, the line with the closing `}` of +`keyXRange`), before the `mount` doc comment at line 90: + +```js + // {kind,name,commit,window:[from,to],broker,seed,taps,members,params} -> array of + // {label,value} chips. PURE (no DOM), the unit the headless guard drives; mount() + // renders the chips into the header's #ctx slot. Absent/empty meta -> []. + function buildHeader(meta) { + if (!meta) return []; + function fmtDay(ns) { return new Date(ns / 1e6).toISOString().slice(0, 10); } + var chips = []; + chips.push({ label: meta.kind === "family" ? "family" : "run", value: meta.name }); + if (meta.members != null) chips.push({ label: "members", value: String(meta.members) }); + if (meta.taps && meta.taps.length) chips.push({ label: "tap", value: meta.taps.join(", ") }); + if (meta.broker) chips.push({ label: "broker", value: meta.broker }); + if (meta.window) chips.push({ label: "window", value: fmtDay(meta.window[0]) + "→" + fmtDay(meta.window[1]) }); + if (meta.seed) chips.push({ label: "seed", value: String(meta.seed) }); + if (meta.commit) chips.push({ label: "commit", value: String(meta.commit).slice(0, 7) }); + if (meta.params && meta.params.length) + chips.push({ label: "params", value: meta.params.map(function (p) { return p[0] + "=" + p[1]; }).join(" ") }); + return chips; + } +``` + +Change the `module.exports` line at `chart-viewer.js:151`: + +```js + if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts, keyXRange: keyXRange, buildHeader: buildHeader }; +``` + +- [ ] **Step 5: Render the header in `mount()` (chart-viewer.js)** + +Immediately after `var toggle = document.getElementById("xmode-toggle");` +(`chart-viewer.js:97`), insert: + +```js + var ctx = document.getElementById("ctx"); + if (ctx) { + ctx.innerHTML = ""; + buildHeader(traces.meta).forEach(function (c) { + var span = document.createElement("span"); + var b = document.createElement("b"); + b.textContent = c.label + " "; + span.appendChild(b); + span.appendChild(document.createTextNode(c.value)); + span.style.marginRight = "12px"; + ctx.appendChild(span); + }); + } +``` + +- [ ] **Step 6: Add the `#ctx` slot to `CHART_HEAD` (render.rs)** + +Replace `CHART_HEAD` at `crates/aura-cli/src/render.rs:65-72`: + +```rust +const CHART_HEAD: &str = r#"
+ aura chart + timestamp-aligned trace series + + +
+
+

+"#;
+```
+
+- [ ] **Step 7: Run the guard to verify it passes (GREEN)**
+
+Run: `cargo test -p aura-cli --test chart_viewer_header`
+Expected: PASS — `OK — buildHeader run/family/empty chips.`
+
+- [ ] **Step 8: Verify the existing chart-label test still passes**
+
+Run: `cargo test -p aura-cli --lib render_chart_html_wears_its_own_chart_label`
+Expected: PASS (the `#ctx` span is additive; the `aura chart` label + the
+`xmode-toggle` control are untouched).
+
+---
+
+## Task 3: #108 — serve-time min-max decimation
+
+A pure `decimate(ChartData, buckets)` transform, applied in `emit_chart` on both
+paths. Written stub-first so the unit tests have a symbol to compile against, then
+RED on the bounds test, then GREEN.
+
+**Files:**
+- Modify: `crates/aura-cli/src/main.rs`
+
+- [ ] **Step 1: Add the const + an identity stub (main.rs)**
+
+Add immediately above `scalar_display` (which sits above `build_chart_data` at
+`main.rs:243`):
+
+```rust
+/// Default decimation budget: target horizontal buckets. ~2000 buckets ⇒ ≤ ~4000
+/// spine slots (min+max per bucket) — a few-thousand-point page regardless of the
+/// underlying multi-year M1 point count.
+const CHART_DECIMATE_BUCKETS: usize = 2000;
+
+/// Serve-time min-max decimation on the aligned `ChartData` (#108). STUB — replaced
+/// in Step 4.
+fn decimate(data: ChartData, _buckets: usize) -> ChartData {
+    data
+}
+```
+
+- [ ] **Step 2: Write the `decimate` unit tests (main.rs)**
+
+Add to the `#[cfg(test)] mod tests` block in `main.rs` (next to the comparison
+tests, after `comparison_errors_when_no_member_has_the_tap` at `main.rs:1669`):
+
+```rust
+    #[test]
+    fn decimate_bounds_the_spine_to_twice_the_bucket_count() {
+        let n = 10_000usize;
+        let xs: Vec = (0..n as i64).collect();
+        let points: Vec> = (0..n).map(|i| Some(i as f64)).collect();
+        let data = ChartData {
+            xs,
+            series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }],
+            meta: ChartMeta::default(),
+        };
+        let out = decimate(data, 2000);
+        assert!(out.xs.len() <= 4000, "spine not bounded: {}", out.xs.len());
+        assert_eq!(out.xs.len(), out.series[0].points.len(), "xs and points must stay aligned");
+    }
+
+    #[test]
+    fn decimate_preserves_per_bucket_min_and_max() {
+        // 10 points, 2 buckets -> bucket 0 = idx 0..5 (a spike), bucket 1 = idx 5..10 (a trough).
+        let xs: Vec = (0..10).collect();
+        let mut pv = vec![1.0_f64; 10];
+        pv[3] = 999.0;
+        pv[7] = -50.0;
+        let points: Vec> = pv.into_iter().map(Some).collect();
+        let data = ChartData {
+            xs,
+            series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }],
+            meta: ChartMeta::default(),
+        };
+        let out = decimate(data, 2);
+        let got = out.series[0].points.clone();
+        assert!(got.contains(&Some(999.0)), "bucket max (spike) dropped: {got:?}");
+        assert!(got.contains(&Some(-50.0)), "bucket min (trough) dropped: {got:?}");
+    }
+
+    #[test]
+    fn decimate_keeps_an_all_null_bucket_null() {
+        let xs: Vec = (0..10).collect();
+        let mut points: Vec> = (0..5).map(|i| Some(i as f64)).collect();
+        points.extend(std::iter::repeat(None).take(5));
+        let data = ChartData {
+            xs,
+            series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }],
+            meta: ChartMeta::default(),
+        };
+        let out = decimate(data, 2);
+        assert_eq!(*out.series[0].points.last().unwrap(), None, "all-null bucket must stay null");
+    }
+
+    #[test]
+    fn decimate_is_a_noop_within_budget() {
+        let data = ChartData {
+            xs: vec![1, 2, 3],
+            series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(1.0), Some(2.0), Some(3.0)] }],
+            meta: ChartMeta::default(),
+        };
+        let out = decimate(data, 2000);
+        assert_eq!(out.xs, vec![1, 2, 3], "within-budget data must pass through unchanged");
+        assert_eq!(out.series[0].points, vec![Some(1.0), Some(2.0), Some(3.0)]);
+    }
+
+    #[test]
+    fn decimate_passes_meta_through_and_keeps_xs_monotonic() {
+        let n = 10_000usize;
+        let xs: Vec = (0..n as i64).collect();
+        let points: Vec> = (0..n).map(|i| Some(i as f64)).collect();
+        let mut meta = ChartMeta::default();
+        meta.name = "keep-me".into();
+        let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points }], meta };
+        let out = decimate(data, 2000);
+        assert_eq!(out.meta.name, "keep-me", "meta must pass through decimation");
+        assert!(out.xs.windows(2).all(|w| w[0] < w[1]), "decimated spine must stay strictly increasing");
+    }
+```
+
+- [ ] **Step 3: Run to verify the bounds test fails (RED)**
+
+Run: `cargo test -p aura-cli decimate`
+Expected: FAIL — `decimate_bounds_the_spine_to_twice_the_bucket_count` (`spine not
+bounded: 10000`); the identity stub does not thin. (`decimate_is_a_noop_within_budget`
+and `decimate_passes_meta_through_*` may pass against the stub; the bounds /
+min-max / null tests fail.)
+
+- [ ] **Step 4: Implement `decimate` (GREEN)**
+
+Replace the stub `decimate` body:
+
+```rust
+/// Serve-time min-max decimation on the aligned `ChartData` (#108). Partition the
+/// shared `xs` into at most `buckets` contiguous index ranges; per non-empty bucket
+/// emit the bucket's first (and, if it spans >1 index, last) timestamp as shared
+/// spine slots, and for each series the min then max of its non-null values in that
+/// range (sub-pixel ordering — the drawn vertical extent of the column is preserved).
+/// An all-null bucket emits null. `meta` passes through unchanged. Deterministic
+/// (C1). Full data stays on disk; only the served page is thinned. No-op when
+/// `xs.len() <= 2 * buckets`.
+fn decimate(data: ChartData, buckets: usize) -> ChartData {
+    let buckets = buckets.max(1);
+    let n = data.xs.len();
+    if n <= 2 * buckets {
+        return data;
+    }
+    let ChartData { xs, series, meta } = data;
+
+    // Bucket index bounds (lo, hi_exclusive, two_slots) + the decimated shared spine.
+    // xs is sorted+deduped (strictly increasing) -> boundary timestamps are strictly
+    // increasing across and within buckets, so the spine stays monotonic for uPlot.
+    let mut bounds: Vec<(usize, usize, bool)> = Vec::with_capacity(buckets);
+    let mut out_xs: Vec = Vec::with_capacity(2 * buckets);
+    for b in 0..buckets {
+        let lo = b * n / buckets;
+        let hi = (b + 1) * n / buckets;
+        if lo >= hi {
+            continue;
+        }
+        let two = hi - lo > 1;
+        out_xs.push(xs[lo]);
+        if two {
+            out_xs.push(xs[hi - 1]);
+        }
+        bounds.push((lo, hi, two));
+    }
+
+    let out_series: Vec = series
+        .into_iter()
+        .map(|s| {
+            let mut points: Vec> = Vec::with_capacity(out_xs.len());
+            for &(lo, hi, two) in &bounds {
+                let mut mn = f64::INFINITY;
+                let mut mx = f64::NEG_INFINITY;
+                let mut any = false;
+                for p in &s.points[lo..hi] {
+                    if let Some(v) = p {
+                        any = true;
+                        if *v < mn {
+                            mn = *v;
+                        }
+                        if *v > mx {
+                            mx = *v;
+                        }
+                    }
+                }
+                if any {
+                    points.push(Some(mn));
+                    if two {
+                        points.push(Some(mx));
+                    }
+                } else {
+                    points.push(None);
+                    if two {
+                        points.push(None);
+                    }
+                }
+            }
+            Series { name: s.name, y_scale_id: s.y_scale_id, points }
+        })
+        .collect();
+
+    ChartData { xs: out_xs, series: out_series, meta }
+}
+```
+
+Run: `cargo test -p aura-cli decimate`
+Expected: PASS — all five `decimate_*` tests green.
+
+- [ ] **Step 5: Apply `decimate` in `emit_chart` (both arms)**
+
+In the Run arm, insert before the `print!` (after the `--tap` filter block,
+`main.rs:359`):
+
+```rust
+            let data = decimate(data, CHART_DECIMATE_BUCKETS);
+            print!("{}", render::render_chart_html(&data, mode));
+```
+
+In the Family arm, insert before the `print!` (`main.rs:377`), turning the existing
+`let data = match … {}` binding into a decimated one:
+
+```rust
+            let data = decimate(data, CHART_DECIMATE_BUCKETS);
+            print!("{}", render::render_chart_html(&data, mode));
+```
+
+- [ ] **Step 6: Whole-crate test gate**
+
+Run: `cargo test -p aura-cli`
+Expected: PASS — all tests green. The `cli_run.rs` chart E2E fixtures are small
+(`xs.len() <= 4000`), so `decimate` is a no-op and the existing page assertions
+still hold; the new `decimate_*` and `chart_viewer_header` tests pass.
+
+---
+
+## Task 4: Workspace verification
+
+**Files:** none (verification only).
+
+- [ ] **Step 1: Full workspace test**
+
+Run: `cargo test --workspace`
+Expected: PASS — no regression in any crate (engine + registry untouched, C14).
+
+- [ ] **Step 2: Lint gate**
+
+Run: `cargo clippy --workspace --all-targets -- -D warnings`
+Expected: `Finished` with 0 warnings.