Files
Aura/docs/plans/0062-visual-world-cut-2.md
T
Brummel 12bfa50a4f plan: 0062 visual-world-cut-2 — decimation + run-context header
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
2026-06-22 11:48:46 +02:00

29 KiB

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-clirender.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-387build_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.rsnode-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:

/// 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<String>,
    /// Family member count; `None` for a single run.
    pub members: Option<usize>,
    /// 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<i64>,
    pub series: Vec<Series>,
    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:

use render::{ChartData, ChartMeta, ChartMode, Series};

Add this helper immediately above build_chart_data (before the doc at main.rs:243):

/// 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:

fn build_chart_data(name: &str, traces: RunTraces) -> ChartData {

Return (replace ChartData { xs, series } at main.rs:272):

    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:

fn build_comparison_chart_data(
    name: &str,
    members: &[FamilyMember],
    tap: &str,
) -> Result<ChartData, String> {

Replace the return Ok(ChartData { xs, series }) at main.rs:321:

    // 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):

fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
    let series: Vec<Series> = 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:

            let mut data = build_chart_data(name, traces);

In the Family arm, change main.rs:370:

            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:

        let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");

At main.rs:1659:

        let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");

At main.rs:1668:

        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:

    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:

    /// `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:

    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:

// 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:

//! 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:

  // {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:

  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:

    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:

const CHART_HEAD: &str = r#"<header>
  <b>aura chart</b>
  <span class="sub">timestamp-aligned trace series</span>
  <span id="ctx" class="sub"></span>
  <button id="xmode-toggle" type="button">x: collapsed (gaps off)</button>
</header>
<div id="stage"></div>
<pre id="err"></pre>
"#;
  • 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):

/// 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):

    #[test]
    fn decimate_bounds_the_spine_to_twice_the_bucket_count() {
        let n = 10_000usize;
        let xs: Vec<i64> = (0..n as i64).collect();
        let points: Vec<Option<f64>> = (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<i64> = (0..10).collect();
        let mut pv = vec![1.0_f64; 10];
        pv[3] = 999.0;
        pv[7] = -50.0;
        let points: Vec<Option<f64>> = 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<i64> = (0..10).collect();
        let mut points: Vec<Option<f64>> = (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<i64> = (0..n as i64).collect();
        let points: Vec<Option<f64>> = (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:

/// 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<i64> = 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> = series
        .into_iter()
        .map(|s| {
            let mut points: Vec<Option<f64>> = 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):

            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:

            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.