feat(chart): decimate served pages + run-context header (visual World cut 2)
Hardens the families-comparison chart page (#107) so it is openable and legible on real multi-year data. Two halves, both confined to the CLI render path (engine + registry untouched, C14): #108 — serve-time min-max decimation. A pure `decimate(ChartData, buckets)` transform partitions the shared union-ts spine into <=~2000 buckets and emits each series' per-bucket min+max (nulls preserved), bounding the served page to a few thousand points regardless of the underlying M1 point count. Applied in emit_chart on both the single-run and family paths; a no-op under budget (every existing fixture). Full recorded data stays on disk; only the page is thinned. For a walk-forward family the null-fill blow-up collapses for free (an all-null bucket stays null). Deterministic (C1). #102 — a run-context header. A `ChartMeta` (name/commit/window/broker/seed/taps, + member count for a family, + bound params for a single run) is built from the RunManifest in build_chart_data / build_comparison_chart_data (which gain a `name` arg), injected into window.AURA_TRACES.meta, and rendered by a new pure buildHeader in chart-viewer.js (headless .mjs-guarded). The family window is the SPAN across members (min from, max to), not the first member's window — the only reading that labels a disjoint walk-forward family's true OOS coverage (it collapses to the shared window for sweep/MC). Reuses the existing render_value for param display (no new Scalar Display, keeping C7's tag-only param plane). The earlier "manifest is NOT carried into the page" render pin is flipped to a positive one. Verified: cargo test --workspace = 446 passed / 0 failed; cargo clippy --workspace --all-targets -D warnings clean. New coverage: 5 decimate unit tests, single-run + family meta wiring (incl. the span-window invariant), a buildHeader headless guard, and two end-to-end cli_run tests pinning the served-page meta at the binary boundary. closes #108 closes #102
This commit is contained in:
@@ -87,6 +87,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// {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;
|
||||||
|
}
|
||||||
|
|
||||||
// Browser-only: instantiate the vendored global uPlot per config into #stage, wire the
|
// Browser-only: instantiate the vendored global uPlot per config into #stage, wire the
|
||||||
// header #xmode-toggle to flip the x spine, and add keyboard control (discrete, so it
|
// header #xmode-toggle to flip the x spine, and add keyboard control (discrete, so it
|
||||||
// sidesteps the glitchy pointer drag): arrows pan, +/- zoom toward the cursor, 0/Home
|
// sidesteps the glitchy pointer drag): arrows pan, +/- zoom toward the cursor, 0/Home
|
||||||
@@ -95,6 +114,19 @@
|
|||||||
var traces = window.AURA_TRACES;
|
var traces = window.AURA_TRACES;
|
||||||
var stage = document.getElementById("stage");
|
var stage = document.getElementById("stage");
|
||||||
var toggle = document.getElementById("xmode-toggle");
|
var toggle = document.getElementById("xmode-toggle");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
var xMode = "ordinal";
|
var xMode = "ordinal";
|
||||||
var insts = [];
|
var insts = [];
|
||||||
var anchorVal = 0, anchorActive = false; // x-value under the mouse, for zoom-to-cursor
|
var anchorVal = 0, anchorActive = false; // x-value under the mouse, for zoom-to-cursor
|
||||||
@@ -148,5 +180,5 @@
|
|||||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
|
||||||
else mount();
|
else mount();
|
||||||
}
|
}
|
||||||
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts, keyXRange: keyXRange };
|
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts, keyXRange: keyXRange, buildHeader: buildHeader };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+271
-11
@@ -12,7 +12,7 @@
|
|||||||
//! first real-data backtest from the CLI.
|
//! first real-data backtest from the CLI.
|
||||||
|
|
||||||
mod render;
|
mod render;
|
||||||
use render::{ChartData, ChartMode, Series};
|
use render::{ChartData, ChartMeta, ChartMode, Series};
|
||||||
|
|
||||||
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
||||||
use aura_engine::{
|
use aura_engine::{
|
||||||
@@ -240,6 +240,82 @@ fn member_key(named: &[(String, Scalar)], varying: &HashSet<String>) -> String {
|
|||||||
if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) }
|
if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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). 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 v in s.points[lo..hi].iter().flatten() {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
|
/// 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:
|
/// 3-step union-spine alignment — no tap privileged, no point dropped:
|
||||||
/// (1) xs = the sorted, deduped union of every tap's timestamps;
|
/// (1) xs = the sorted, deduped union of every tap's timestamps;
|
||||||
@@ -248,7 +324,7 @@ fn member_key(named: &[(String, Scalar)], varying: &HashSet<String>) -> String {
|
|||||||
/// of `join_on_ts`, so no side row is dropped and none occupies the privileged
|
/// of `join_on_ts`, so no side row is dropped and none occupies the privileged
|
||||||
/// `JoinedRow.spine`;
|
/// `JoinedRow.spine`;
|
||||||
/// (3) flatten each (tap, column) to a `Series` of `Option<f64>` over xs.
|
/// (3) flatten each (tap, column) to a `Series` of `Option<f64>` over xs.
|
||||||
fn build_chart_data(traces: RunTraces) -> ChartData {
|
fn build_chart_data(name: &str, traces: RunTraces) -> ChartData {
|
||||||
let mut xs: Vec<i64> = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect();
|
let mut xs: Vec<i64> = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect();
|
||||||
xs.sort_unstable();
|
xs.sort_unstable();
|
||||||
xs.dedup();
|
xs.dedup();
|
||||||
@@ -269,7 +345,19 @@ fn build_chart_data(traces: RunTraces) -> ChartData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChartData { xs, series }
|
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(), render_value(v))).collect(),
|
||||||
|
};
|
||||||
|
ChartData { xs, series, meta }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One member's contribution to the comparison build: its key (the future series
|
/// One member's contribution to the comparison build: its key (the future series
|
||||||
@@ -283,6 +371,7 @@ type MemberRows = (String, Vec<(Timestamp, Vec<Scalar>)>);
|
|||||||
/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data
|
/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data
|
||||||
/// uses. `Err` if NO member carries `tap` (refuse-don't-guess).
|
/// uses. `Err` if NO member carries `tap` (refuse-don't-guess).
|
||||||
fn build_comparison_chart_data(
|
fn build_comparison_chart_data(
|
||||||
|
name: &str,
|
||||||
members: &[FamilyMember],
|
members: &[FamilyMember],
|
||||||
tap: &str,
|
tap: &str,
|
||||||
) -> Result<ChartData, String> {
|
) -> Result<ChartData, String> {
|
||||||
@@ -318,7 +407,30 @@ fn build_comparison_chart_data(
|
|||||||
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect();
|
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect();
|
||||||
series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points });
|
series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points });
|
||||||
}
|
}
|
||||||
Ok(ChartData { xs, series })
|
// member_rows is non-empty here (checked above) => members is non-empty, so
|
||||||
|
// members[0] is safe. commit/broker ARE shared across a family (one frozen
|
||||||
|
// artifact, one broker profile), but the window is NOT: a walk-forward family's
|
||||||
|
// members are disjoint OOS windows (commit 4c64feb), so the family window is the
|
||||||
|
// SPAN across all members — (min from, max to). For sweep/MC, whose members
|
||||||
|
// share one window, the span collapses to that shared window, so this is the one
|
||||||
|
// correct reading for all three kinds.
|
||||||
|
let m = &members[0].traces.manifest;
|
||||||
|
let window = (
|
||||||
|
members.iter().map(|fm| fm.traces.manifest.window.0.0).min().unwrap(),
|
||||||
|
members.iter().map(|fm| fm.traces.manifest.window.1.0).max().unwrap(),
|
||||||
|
);
|
||||||
|
let meta = ChartMeta {
|
||||||
|
kind: "family".to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
commit: m.commit.clone(),
|
||||||
|
window,
|
||||||
|
broker: m.broker.clone(),
|
||||||
|
seed: m.seed,
|
||||||
|
taps: vec![tap.to_string()],
|
||||||
|
members: Some(members.len()),
|
||||||
|
params: Vec::new(),
|
||||||
|
};
|
||||||
|
Ok(ChartData { xs, series, meta })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the
|
/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the
|
||||||
@@ -329,7 +441,9 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
|
|||||||
if series.is_empty() {
|
if series.is_empty() {
|
||||||
return Err(format!("run has no tap named '{tap}'"));
|
return Err(format!("run has no tap named '{tap}'"));
|
||||||
}
|
}
|
||||||
Ok(ChartData { xs: data.xs, series })
|
let mut meta = data.meta;
|
||||||
|
meta.taps = vec![tap.to_string()];
|
||||||
|
Ok(ChartData { xs: data.xs, series, meta })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
|
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
|
||||||
@@ -347,7 +461,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut data = build_chart_data(traces);
|
let mut data = build_chart_data(name, traces);
|
||||||
if let Some(t) = tap {
|
if let Some(t) = tap {
|
||||||
data = match filter_to_tap(data, t) {
|
data = match filter_to_tap(data, t) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@@ -357,6 +471,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
let data = decimate(data, CHART_DECIMATE_BUCKETS);
|
||||||
print!("{}", render::render_chart_html(&data, mode));
|
print!("{}", render::render_chart_html(&data, mode));
|
||||||
}
|
}
|
||||||
NameKind::Family => {
|
NameKind::Family => {
|
||||||
@@ -367,13 +482,14 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let data = match build_comparison_chart_data(&members, tap.unwrap_or("equity")) {
|
let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("aura: {e}");
|
eprintln!("aura: {e}");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let data = decimate(data, CHART_DECIMATE_BUCKETS);
|
||||||
print!("{}", render::render_chart_html(&data, mode));
|
print!("{}", render::render_chart_html(&data, mode));
|
||||||
}
|
}
|
||||||
NameKind::NotFound => {
|
NameKind::NotFound => {
|
||||||
@@ -1621,13 +1737,25 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember {
|
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember {
|
||||||
|
cmp_member_win(key, ts, vals, (0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`cmp_member`] but with an explicit manifest `window` so a test can
|
||||||
|
/// model walk-forward members (disjoint per-member OOS windows) and assert the
|
||||||
|
/// family window spans them.
|
||||||
|
fn cmp_member_win(key: &str, ts: &[i64], vals: &[f64], window: (i64, i64)) -> FamilyMember {
|
||||||
let rows: Vec<(Timestamp, Vec<Scalar>)> =
|
let rows: Vec<(Timestamp, Vec<Scalar>)> =
|
||||||
ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect();
|
ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect();
|
||||||
let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
|
let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
|
||||||
FamilyMember {
|
FamilyMember {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
traces: RunTraces {
|
traces: RunTraces {
|
||||||
manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0),
|
manifest: sim_optimal_manifest(
|
||||||
|
vec![],
|
||||||
|
(Timestamp(window.0), Timestamp(window.1)),
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
taps: vec![tap],
|
taps: vec![tap],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1639,7 +1767,7 @@ mod tests {
|
|||||||
cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]),
|
cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]),
|
||||||
cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]),
|
cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]),
|
||||||
];
|
];
|
||||||
let data = build_comparison_chart_data(&members, "equity").expect("builds");
|
let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");
|
||||||
assert_eq!(data.xs, vec![1, 2, 3]);
|
assert_eq!(data.xs, vec![1, 2, 3]);
|
||||||
assert_eq!(data.series.len(), 2);
|
assert_eq!(data.series.len(), 2);
|
||||||
assert_eq!(data.series[0].name, "a");
|
assert_eq!(data.series[0].name, "a");
|
||||||
@@ -1648,6 +1776,13 @@ mod tests {
|
|||||||
assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id);
|
assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id);
|
||||||
// shared ts -> dense, no nulls.
|
// shared ts -> dense, no nulls.
|
||||||
assert!(data.series[0].points.iter().all(Option::is_some));
|
assert!(data.series[0].points.iter().all(Option::is_some));
|
||||||
|
// #102 meta wiring: a family carries kind/name/member-count + the one
|
||||||
|
// compared tap, and never the per-member params (those are the labels).
|
||||||
|
assert_eq!(data.meta.kind, "family");
|
||||||
|
assert_eq!(data.meta.name, "fam");
|
||||||
|
assert_eq!(data.meta.members, Some(2));
|
||||||
|
assert_eq!(data.meta.taps, vec!["equity".to_string()]);
|
||||||
|
assert!(data.meta.params.is_empty(), "family meta must not repeat per-member params");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1656,16 +1791,141 @@ mod tests {
|
|||||||
cmp_member("oos1", &[1, 2], &[10.0, 11.0]),
|
cmp_member("oos1", &[1, 2], &[10.0, 11.0]),
|
||||||
cmp_member("oos2", &[3, 4], &[20.0, 21.0]),
|
cmp_member("oos2", &[3, 4], &[20.0, 21.0]),
|
||||||
];
|
];
|
||||||
let data = build_comparison_chart_data(&members, "equity").expect("builds");
|
let data = build_comparison_chart_data("fam", &members, "equity").expect("builds");
|
||||||
assert_eq!(data.xs, vec![1, 2, 3, 4]);
|
assert_eq!(data.xs, vec![1, 2, 3, 4]);
|
||||||
assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]);
|
assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]);
|
||||||
assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]);
|
assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #102 family-window semantics: the header's `window` for a family is the
|
||||||
|
/// SPAN across all members — `(min member.from, max member.to)` — not the first
|
||||||
|
/// member's window. The distinction is load-bearing for a walk-forward family,
|
||||||
|
/// whose members are DISJOINT OOS windows (commit 4c64feb): labelling such a
|
||||||
|
/// family with `members[0]`'s window mislabels the family's true coverage. The
|
||||||
|
/// span reading is correct for all three kinds (sweep/MC members share a window,
|
||||||
|
/// so their span collapses to that shared window).
|
||||||
|
#[test]
|
||||||
|
fn comparison_window_spans_disjoint_walk_forward_members() {
|
||||||
|
let members = vec![
|
||||||
|
cmp_member_win("oos1", &[10, 20], &[1.0, 2.0], (10, 20)),
|
||||||
|
cmp_member_win("oos2", &[30, 40], &[3.0, 4.0], (30, 40)),
|
||||||
|
cmp_member_win("oos3", &[50, 60], &[5.0, 6.0], (50, 60)),
|
||||||
|
];
|
||||||
|
let data = build_comparison_chart_data("wf", &members, "equity").expect("builds");
|
||||||
|
// SPAN of all OOS windows (10..60), NOT members[0]'s window (10..20).
|
||||||
|
assert_eq!(data.meta.window, (10, 60));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn comparison_errors_when_no_member_has_the_tap() {
|
fn comparison_errors_when_no_member_has_the_tap() {
|
||||||
let members = vec![cmp_member("a", &[1], &[1.0])];
|
let members = vec![cmp_member("a", &[1], &[1.0])];
|
||||||
assert!(build_comparison_chart_data(&members, "nosuch").is_err());
|
assert!(build_comparison_chart_data("fam", &members, "nosuch").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_n(None, 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 meta = ChartMeta { name: "keep-me".into(), ..Default::default() };
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #102 single-run meta wiring: `build_chart_data` maps the `RunManifest` into
|
||||||
|
/// `ChartData.meta` — kind "run", the name arg, the manifest window/broker, the
|
||||||
|
/// charted taps, and the bound params stringified (each typed `Scalar` rendered
|
||||||
|
/// via `render_value`, preserving its lexical form: `i64` decimal, `f64`
|
||||||
|
/// shortest round-trip). A single run carries no member count.
|
||||||
|
#[test]
|
||||||
|
fn build_chart_data_threads_run_manifest_into_meta() {
|
||||||
|
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> =
|
||||||
|
[1i64, 2, 3].iter().map(|&t| (Timestamp(t), vec![Scalar::f64(t as f64)])).collect();
|
||||||
|
let traces = RunTraces {
|
||||||
|
manifest: sim_optimal_manifest(
|
||||||
|
vec![("len".into(), Scalar::i64(10)), ("scale".into(), Scalar::f64(0.5))],
|
||||||
|
(Timestamp(1), Timestamp(3)),
|
||||||
|
7,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
taps: vec![ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &eq_rows)],
|
||||||
|
};
|
||||||
|
let data = build_chart_data("demo", traces);
|
||||||
|
let meta = &data.meta;
|
||||||
|
assert_eq!(meta.kind, "run");
|
||||||
|
assert_eq!(meta.name, "demo");
|
||||||
|
assert_eq!(meta.window, (1, 3));
|
||||||
|
assert_eq!(meta.broker, "sim-optimal(pip_size=1)");
|
||||||
|
assert_eq!(meta.seed, 7);
|
||||||
|
assert_eq!(meta.taps, vec!["equity".to_string()]);
|
||||||
|
assert_eq!(meta.members, None);
|
||||||
|
// params stringified via render_value: typed Scalars keep their lexical form.
|
||||||
|
assert_eq!(
|
||||||
|
meta.params,
|
||||||
|
vec![("len".to_string(), "10".to_string()), ("scale".to_string(), "0.5".to_string())]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
|
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const GRAPH_HEAD: &str = r#"<header>
|
|||||||
const CHART_HEAD: &str = r#"<header>
|
const CHART_HEAD: &str = r#"<header>
|
||||||
<b>aura chart</b>
|
<b>aura chart</b>
|
||||||
<span class="sub">timestamp-aligned trace series</span>
|
<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>
|
<button id="xmode-toggle" type="button">x: collapsed (gaps off)</button>
|
||||||
</header>
|
</header>
|
||||||
<div id="stage"></div>
|
<div id="stage"></div>
|
||||||
@@ -132,14 +133,49 @@ pub struct Series {
|
|||||||
pub points: Vec<Option<f64>>,
|
pub points: Vec<Option<f64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The serve-ready chart payload: the shared timestamp axis and the per-(tap,column)
|
/// Run-context for the page header (#102): serialized into `window.AURA_TRACES.meta`
|
||||||
/// series. These are exactly the fields `chart-viewer.js` reads; injected verbatim
|
/// and rendered client-side by `chart-viewer.js`'s pure `buildHeader`. A single run
|
||||||
/// as `window.AURA_TRACES` (plus the `mode`). The run's manifest stays on disk in the
|
/// carries its manifest fields + bound params; a family carries its shared identity
|
||||||
/// trace store's `index.json`; it is not carried into the served page, which renders
|
/// (commit/broker — one frozen artifact, one broker profile) plus the SPANNING
|
||||||
/// only the series, so nothing here is dead injected payload.
|
/// window across its members (see the `window` field) + member count — per-member
|
||||||
|
/// identity is the series label (`member.key`), not repeated here.
|
||||||
|
#[derive(Default, serde::Serialize)]
|
||||||
|
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. For a single run, the run's
|
||||||
|
/// manifest window. For a family, the SPAN across all members —
|
||||||
|
/// `(min member.from, max member.to)` — NOT the first member's window: a
|
||||||
|
/// walk-forward family's members are disjoint OOS windows, so only the span
|
||||||
|
/// labels the family's true coverage (and for sweep/MC, whose members share a
|
||||||
|
/// window, the span collapses to it).
|
||||||
|
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 struct ChartData {
|
||||||
pub xs: Vec<i64>,
|
pub xs: Vec<i64>,
|
||||||
pub series: Vec<Series>,
|
pub series: Vec<Series>,
|
||||||
|
pub meta: ChartMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the self-contained uPlot chart page for one run's aligned traces.
|
/// Assemble the self-contained uPlot chart page for one run's aligned traces.
|
||||||
@@ -155,6 +191,7 @@ pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
|
|||||||
"mode": mode_str,
|
"mode": mode_str,
|
||||||
"xs": data.xs,
|
"xs": data.xs,
|
||||||
"series": data.series,
|
"series": data.series,
|
||||||
|
"meta": data.meta,
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -219,6 +256,17 @@ mod tests {
|
|||||||
Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)] },
|
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)] },
|
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![],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +300,16 @@ mod tests {
|
|||||||
assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
|
assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `window.AURA_TRACES` carries only the fields `chart-viewer.js` reads
|
/// `window.AURA_TRACES.meta` now carries the run-context the header reads
|
||||||
/// (`mode`, `xs`, `series`) — no `manifest`/`taps` injected-but-unread payload.
|
/// (#102): the run's commit / window / broker / taps, threaded from the
|
||||||
|
/// manifest (this flipped the earlier "manifest is NOT carried" pin).
|
||||||
#[test]
|
#[test]
|
||||||
fn render_chart_html_injects_only_what_the_viewer_reads() {
|
fn render_chart_html_injects_run_context() {
|
||||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||||
assert!(!html.contains("\"manifest\""), "unread manifest carried into the served chart");
|
assert!(html.contains("\"meta\""), "run-context meta not injected");
|
||||||
assert!(!html.contains("\"taps\""), "unread taps list carried into the served chart");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 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', and renders its
|
||||||
|
// SPANNING window. The window models a walk-forward family's full OOS coverage
|
||||||
|
// (built Rust-side as (min member.from, max member.to)); the rendered chip must
|
||||||
|
// show that full span's endpoints, not a single member's window.
|
||||||
|
const fam = {
|
||||||
|
kind: "family", name: "ger40-5y", commit: "deadbeef",
|
||||||
|
window: [1577836800000000000, 1735689600000000000], // 2020-01-01 -> 2025-01-01
|
||||||
|
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");
|
||||||
|
has(fc, "window", "2020", "family window chip (span start)");
|
||||||
|
has(fc, "window", "2025", "family window chip (span end)");
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1377,3 +1377,77 @@ fn chart_family_with_tap_overlays_the_named_tap_per_member() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&cwd);
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property (#102, single-run run-context reaches the served page): `aura chart
|
||||||
|
/// <run>` injects the run's `RunManifest` context into `window.AURA_TRACES.meta`
|
||||||
|
/// at the binary boundary — `kind:"run"`, the chart name, the manifest broker and
|
||||||
|
/// data window, the charted taps, and `members:null` (a single run carries no
|
||||||
|
/// member count). The render-layer unit (`render_chart_html_injects_run_context`)
|
||||||
|
/// proves the struct serializes, but only an end-to-end run pins that the
|
||||||
|
/// *built-and-read-back* manifest threads through `build_chart_data` into the
|
||||||
|
/// served HTML; a regression dropping the `meta` build (charting bare series with a
|
||||||
|
/// `ChartMeta::default()`) would leave every render/unit test green while serving a
|
||||||
|
/// header-less page. Asserts on the stable manifest fields (the synthetic window is
|
||||||
|
/// `[1,7]`, the broker is the FX sim-optimal label, kind/taps/members are fixed),
|
||||||
|
/// NEVER on the volatile `commit` (the dirty git HEAD), so it stays deterministic.
|
||||||
|
#[test]
|
||||||
|
fn chart_run_serves_manifest_run_context_in_meta() {
|
||||||
|
let cwd = temp_cwd("chart-run-meta");
|
||||||
|
|
||||||
|
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||||
|
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||||||
|
|
||||||
|
let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||||||
|
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||||||
|
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||||
|
|
||||||
|
// The run-context meta is injected, tagged as a single run named `demo`.
|
||||||
|
assert!(html.contains("\"meta\":{"), "run-context meta not injected into the served page: {html:?}");
|
||||||
|
assert!(html.contains("\"kind\":\"run\""), "served meta must be kind run");
|
||||||
|
assert!(html.contains("\"name\":\"demo\""), "served meta must carry the run name");
|
||||||
|
// The manifest's broker + data window thread through to the served page.
|
||||||
|
assert!(html.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "manifest broker missing from served meta");
|
||||||
|
assert!(html.contains("\"window\":[1,7]"), "manifest window missing from served meta");
|
||||||
|
// The charted taps + the single-run sentinel.
|
||||||
|
assert!(html.contains("\"taps\":[\"equity\",\"exposure\"]"), "charted taps missing from served meta");
|
||||||
|
assert!(html.contains("\"members\":null"), "a single run must carry no member count");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (#102, family run-context + SPANNING window at the served page): `aura
|
||||||
|
/// chart <family>` injects family-shaped `meta` — `kind:"family"`, a `members`
|
||||||
|
/// count, the one compared `taps`, and crucially a `window` that is the SPAN across
|
||||||
|
/// all members, NOT a single member's window. The built-in sweep grid has 4
|
||||||
|
/// members, each over the synthetic `[1,7]` window, but the family's recorded
|
||||||
|
/// timestamps union to a wider `[1,18]` span; the served `window` must report that
|
||||||
|
/// span (the ledger's families-comparison invariant: only the span labels a
|
||||||
|
/// walk-forward family's true OOS coverage, and for sweep/MC it is still the union
|
||||||
|
/// of member windows). A regression that took the first member's window, or dropped
|
||||||
|
/// the family `members` count, would still pass the family-overlay series test
|
||||||
|
/// (which only checks series labels); this pins the header context. Deterministic:
|
||||||
|
/// asserts the fixed kind/members/taps/span, never the volatile commit.
|
||||||
|
#[test]
|
||||||
|
fn chart_family_serves_member_count_and_spanning_window_in_meta() {
|
||||||
|
let cwd = temp_cwd("chart-family-meta");
|
||||||
|
|
||||||
|
let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||||||
|
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||||||
|
|
||||||
|
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||||||
|
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
|
||||||
|
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||||
|
|
||||||
|
assert!(html.contains("\"meta\":{"), "family run-context meta not injected: {html:?}");
|
||||||
|
assert!(html.contains("\"kind\":\"family\""), "served meta must be kind family");
|
||||||
|
assert!(html.contains("\"name\":\"fam\""), "served meta must carry the family name");
|
||||||
|
// The 4-point built-in grid -> a member count of 4 (the single-run sentinel is gone).
|
||||||
|
assert!(html.contains("\"members\":4"), "family meta must carry the member count, got: {html}");
|
||||||
|
assert!(!html.contains("\"members\":null"), "a family must not carry the single-run null sentinel");
|
||||||
|
// The default compared tap (equity), one entry — not the per-member labels.
|
||||||
|
assert!(html.contains("\"taps\":[\"equity\"]"), "family meta must carry the single compared tap");
|
||||||
|
// The SPANNING window across members ([1,18]), wider than any single member's [1,7].
|
||||||
|
assert!(html.contains("\"window\":[1,18]"), "family meta must carry the SPAN across members, not one member's window; got: {html}");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user