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
29 KiB
Visual World cut 2 — decimation + run-context header — Implementation Plan
Parent spec:
docs/specs/0062-visual-world-cut-2.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill 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— addChartMeta; addmetafield toChartData; reword theChartDatadoc. - Modify:
crates/aura-cli/src/render.rs:154-159— inject"meta"intorender_chart_html'swindow.AURA_TRACES. - Modify:
crates/aura-cli/src/render.rs:65-72— add the#ctxslot toCHART_HEAD. - Modify:
crates/aura-cli/src/render.rs:215-262— updatesample_chart_data(); flip the negative render test torender_chart_html_injects_run_context. - Modify:
crates/aura-cli/src/main.rs:15— importChartMeta. - Modify:
crates/aura-cli/src/main.rs:243-387—build_chart_data/build_comparison_chart_datagainname+ buildmeta;filter_to_tapnarrowsmeta.taps;emit_chartthreadsnameand (Task 3) appliesdecimate; addscalar_display,decimate,CHART_DECIMATE_BUCKETS. - Modify:
crates/aura-cli/src/main.rs:1642,1659,1668— threadnameinto the 3build_comparison_chart_dataunit-test calls; adddecimateunit tests. - Modify:
crates/aura-cli/assets/chart-viewer.js:90-151— add purebuildHeader; fill#ctxinmount; exportbuildHeader. - Create:
crates/aura-cli/tests/chart_viewer_header.mjs— headless guard drivingbuildHeader. - Create:
crates/aura-cli/tests/chart_viewer_header.rs—node-shelling runner mirroringchart_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+ theChartData.metafield (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+ buildmetainbuild_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+ familymetainbuild_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.tapsinfilter_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
nameinto theemit_chartcall 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
nameinto 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
metainto 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
#ctxslot toCHART_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
decimateunit 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
decimateinemit_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.