From 15ee6d5f4f1c23cf96aa73b58d8b6844fee21419 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 19 Jun 2026 00:52:45 +0200 Subject: [PATCH] feat(trace-charts): serve persisted traces as a web chart (iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2 (serve half) of the visual face — spec 0056 / plan 0056: read a persisted run's traces and serve them as a self-contained uPlot HTML chart. Completes the goal "tap data from a graph, serve it as a chart in the browser". - chart-viewer.js (first-party): a pure buildCharts(traces, mode) that maps the injected window.AURA_TRACES to uPlot configs (no DOM) — overlay = one plot, every series on its own y-scale over the shared xs; panels = one plot per series, all on the same timestamp x. A browser-only mount() instantiates the vendored global uPlot. Guarded by a node .mjs DOM test (+ a sparse/gaps variant) shelling out exactly like viewer_dot.rs. - render_chart_html + ChartMode/ChartData/Series (aura-cli/render.rs): mirrors render_html — self-contained page, uPlot (1.6.32, vendored d534f10) + chart-viewer.js inlined via include_str!, data injected as window.AURA_TRACES. - emit_chart + build_chart_data (aura-cli/main.rs): the spec-§6 3-step union-spine alignment — xs = sorted-dedup union of every tap's ts; a synthetic empty-payload spine + ALL taps as symmetric join_on_ts sides (no tap privileged, no point dropped); flatten each (tap,column) to a Series of Option. New arms `aura chart [--panels]`; a missing run is stderr + exit 2 (TraceStoreError::NotFound), never a panic. USAGE advertises the verb in lockstep. Verified by the orchestrator: `cargo test --workspace` green (incl. the node guards, render_chart_html, and the chart e2e); `cargo clippy --workspace --all-targets -D warnings` exit 0; and a real end-to-end — `aura run --trace demo && aura chart demo` emits a 57KB self-contained page (doctype + inlined uPlot + AURA_TRACES carrying the equity/exposure series), `--panels` reflects the panels mode, `aura chart nope` exits 2. Implementer deviation folded in (compiler-prescribed): serde added to aura-cli/Cargo.toml for the Series derive (Cargo.lock updated). refs #101 --- Cargo.lock | 1 + crates/aura-cli/Cargo.toml | 7 +- crates/aura-cli/assets/chart-viewer.js | 61 +++++++ crates/aura-cli/src/main.rs | 65 +++++++- crates/aura-cli/src/render.rs | 155 +++++++++++++++++- crates/aura-cli/tests/chart_viewer.rs | 35 ++++ crates/aura-cli/tests/chart_viewer_build.mjs | 49 ++++++ crates/aura-cli/tests/chart_viewer_gaps.mjs | 48 ++++++ crates/aura-cli/tests/chart_viewer_gaps.rs | 37 +++++ crates/aura-cli/tests/cli_run.rs | 54 ++++++ .../tests/fixtures/sample-traces.json | 10 ++ .../tests/fixtures/sparse-traces.json | 10 ++ 12 files changed, 517 insertions(+), 15 deletions(-) create mode 100644 crates/aura-cli/assets/chart-viewer.js create mode 100644 crates/aura-cli/tests/chart_viewer.rs create mode 100644 crates/aura-cli/tests/chart_viewer_build.mjs create mode 100644 crates/aura-cli/tests/chart_viewer_gaps.mjs create mode 100644 crates/aura-cli/tests/chart_viewer_gaps.rs create mode 100644 crates/aura-cli/tests/fixtures/sample-traces.json create mode 100644 crates/aura-cli/tests/fixtures/sparse-traces.json diff --git a/Cargo.lock b/Cargo.lock index 2d4b43b..ff4f20c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,7 @@ dependencies = [ "aura-registry", "aura-std", "data-server", + "serde", "serde_json", ] diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index 32e583e..2a0fd6e 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -18,6 +18,11 @@ aura-ingest = { path = "../aura-ingest" } # data-server: the local M1 archive `aura run --real` streams from. Mirrors the # git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth). data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" } -# serde_json renders the walk-forward summary line (MetricStats + stitched total); +# serde derives Serialize on the chart-trace payload (Series) so render_chart_html +# can embed it via serde_json::json!; admitted under the per-case dependency policy, +# same as aura-engine. +serde = { workspace = true } +# serde_json renders the walk-forward summary line (MetricStats + stitched total) +# and the injected chart traces (window.AURA_TRACES); # admitted under the per-case dependency policy, same as aura-engine. serde_json = { workspace = true } diff --git a/crates/aura-cli/assets/chart-viewer.js b/crates/aura-cli/assets/chart-viewer.js new file mode 100644 index 0000000..083ee8b --- /dev/null +++ b/crates/aura-cli/assets/chart-viewer.js @@ -0,0 +1,61 @@ +// The `aura chart` viewer: turns the injected window.AURA_TRACES (xs + per-series +// Option points, already timestamp-union-aligned by the CLI) into uPlot charts. +// +// buildCharts is PURE (no DOM, no uPlot) and is the unit the headless guard drives. +// mount() is browser-only — it instantiates the vendored global `uPlot` per config. +(function () { + var PALETTE = ["#89b4fa", "#f38ba8", "#a6e3a1", "#f9e2af", "#cba6f7", "#94e2d5"]; + + // {xs, series:[{name, y_scale_id, points}]} + mode -> array of {opts, data} configs. + // overlay -> one chart, all series, each on its own auto-ranged y-scale (shared xs). + // panels -> one chart per series, each on the SAME shared xs (timestamp-aligned). + function buildCharts(traces, mode) { + var xs = traces.xs; + var series = traces.series; + if (mode === "panels") { + return series.map(function (s, i) { + return { + opts: { + title: s.name, + scales: { x: { time: false }, [s.y_scale_id]: {} }, + series: [{}, { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }], + axes: [{}, { scale: s.y_scale_id }], + }, + data: [xs, s.points], + }; + }); + } + // overlay (default) + var scales = { x: { time: false } }; + var uSeries = [{}]; + var axes = [{}]; + series.forEach(function (s, i) { + scales[s.y_scale_id] = {}; + uSeries.push({ label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }); + // the first two series carry a drawn axis (left, then right); the rest keep + // their own auto-scale, read via the legend. + if (i < 2) axes.push({ scale: s.y_scale_id, side: i === 0 ? 3 : 1 }); + }); + return [{ opts: { scales: scales, series: uSeries, axes: axes }, data: [xs].concat(series.map(function (s) { return s.points; })) }]; + } + + // Browser-only: instantiate the vendored global uPlot per config into #stage. + function mount() { + var traces = window.AURA_TRACES; + var configs = buildCharts(traces, traces.mode); + var stage = document.getElementById("stage"); + var w = stage.clientWidth || 900; + var h = configs.length > 1 ? Math.max(160, Math.floor(440 / configs.length)) : 460; + configs.forEach(function (cfg) { + var opts = Object.assign({ width: w, height: h }, cfg.opts); + // eslint-disable-next-line no-undef + new uPlot(opts, cfg.data, stage); + }); + } + + if (typeof window !== "undefined" && typeof document !== "undefined" && window.AURA_TRACES) { + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount); + else mount(); + } + if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts }; +})(); diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 5b252f3..ce930dd 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -12,17 +12,18 @@ //! first real-data backtest from the CLI. mod render; +use render::{ChartData, ChartMode, Series}; use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, ColumnarTrace, - Composite, Edge, FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode, - RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource, - WalkForwardResult, WindowBounds, WindowRoller, WindowRun, + f64_field, join_on_ts, monte_carlo, param_stability, summarize, walk_forward, window_of, + ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow, McAggregate, + McFamily, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, + VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun, }; use aura_registry::{ group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, - walkforward_member_reports, FamilyKind, Registry, TraceStore, + walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError, }; use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -173,6 +174,56 @@ fn persist_traces( } } +/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6 +/// 3-step union-spine alignment — no tap privileged, no point dropped: +/// (1) xs = the sorted, deduped union of every tap's timestamps; +/// (2) synthesize an empty-payload spine over xs and pass ALL taps (via +/// `ColumnarTrace::to_rows`, which yields uniformly-f64 rows) as symmetric sides +/// of `join_on_ts`, so no side row is dropped and none occupies the privileged +/// `JoinedRow.spine`; +/// (3) flatten each (tap, column) to a `Series` of `Option` over xs. +fn build_chart_data(traces: RunTraces) -> ChartData { + let mut xs: Vec = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect(); + xs.sort_unstable(); + xs.dedup(); + + let spine: Vec<(Timestamp, Vec)> = xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect(); + let tap_rows: Vec)>> = traces.taps.iter().map(|t| t.to_rows()).collect(); + let sides: Vec<&[(Timestamp, Vec)]> = tap_rows.iter().map(|r| r.as_slice()).collect(); + let joined: Vec = join_on_ts(&spine, &sides); + + let mut series: Vec = Vec::new(); + for (i, tap) in traces.taps.iter().enumerate() { + for c in 0..tap.columns.len() { + let name = if tap.columns.len() == 1 { tap.tap.clone() } else { format!("{}[{c}]", tap.tap) }; + let y_scale_id = format!("y_{}", series.len()); + let points: Vec> = + joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[c].as_f64())).collect(); + series.push(Series { name, y_scale_id, points }); + } + } + + ChartData { xs, series } +} + +/// `aura chart [--panels]`: read the persisted run's traces, align them, and +/// print the self-contained uPlot page. A missing run is a usage error (stderr + +/// exit 2), per the CLI's convention — never a panic. +fn emit_chart(name: &str, mode: ChartMode) { + let traces = match TraceStore::open("runs").read(name) { + Ok(t) => t, + Err(TraceStoreError::NotFound(n)) => { + eprintln!("aura: no recorded run '{n}' under runs/traces (run `aura run --trace {n}` first)"); + std::process::exit(2); + } + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + print!("{}", render::render_chart_html(&build_chart_data(traces), mode)); +} + /// Run the sample harness and fold it into a `RunReport` (drain both sinks → /// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic /// (C1): the same build yields the same report. @@ -875,7 +926,7 @@ fn run_macd(trace: Option<&str>) -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura graph | aura sweep [--name ] | aura mc [--name ] | aura walkforward [--name ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--name ] | aura mc [--name ] | aura walkforward [--name ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -896,6 +947,8 @@ fn main() { std::process::exit(2); } }, + ["chart", name] => emit_chart(name, ChartMode::Overlay), + ["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels), ["graph"] => print!("{}", render::render_html(&sample_blueprint())), ["sweep"] => run_sweep("sweep"), ["sweep", "--name", n] => run_sweep(n), diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index cc3f249..f07a200 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -8,16 +8,18 @@ use aura_engine::Composite; -/// The prototype's `` shell + body skeleton (header, stage, tooltip, error -/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines -/// 1-38 — minus the two `\n\n\n\n\n\n"); + html +} + #[cfg(test)] mod tests { use super::*; @@ -95,6 +178,9 @@ mod tests { // a complete HTML document assert!(html.starts_with(""), "not an HTML doc"); assert!(html.trim_end().ends_with(""), "HTML doc not closed"); + // the graph page wears its own label (not the chart's) + assert!(html.contains("aura graph"), "graph page title missing"); + assert!(html.contains("aura graph"), "graph header label missing"); // self-contained: every asset is inlined, no remote