//! `aura` — the programmatic / CLI face of the engine. Topology lives only as //! data (#159): `aura run`/`sweep`/`walkforward`/`mc` load a serialized blueprint //! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests; //! this binary authors no built-in harness. mod diag; mod render; mod graph_construct; mod campaign_run; mod research_docs; mod scaffold; mod verb_sugar; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{Scalar, Timestamp}; #[cfg(test)] use aura_core::ScalarKind; #[cfg(test)] use aura_composites::StopRule; use aura_engine::{ blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, param_stability, Composite, JoinedRow, SelectionMode, }; #[cfg(test)] use aura_engine::{window_of, ColumnarTrace, Harness, VecSource}; use aura_registry::{ check_r_metric, group_families, mc_member_reports, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind, FamilyMember, Generalization, NameKind, PlateauMode, RunTraces, }; #[cfg(test)] use aura_registry::Registry; use aura_backtest::{ fit_wf_ms_sizes, intersect_shared_window, r_metrics_from_rs, McAggregate, RBootstrap, RunReport, WalkForwardResult, }; #[cfg(test)] use aura_backtest::{SweepFamily, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS}; use aura_measurement::information_coefficient; // The C28 assembly position (#295): the member-run recipe, the axis/translate // helpers, and the harness-assembly constants all live in `aura-runner` now; // brought into scope by plain name so call sites are unchanged. // `blueprint_axis_probe_reopened` is production code only from `verb_sugar.rs` // (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`, // the crate-root re-export this `use` gives it), not from this module itself. use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData}; use aura_runner::TapPlan; #[cfg(test)] use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE}; // The family builders (blueprint sweep / walk-forward / MC), the shared // `DataSource`/`DataChoice` data provider, and the `--select` objective // `Selection` all live in `aura_runner::family` now (#295 Task 8); brought // into scope by plain name so call sites are unchanged. `blueprint_sweep_over` // and `run_oos_blueprint` are reached only from `blueprint_walkforward_family` // itself (now inside `aura_runner::family`, not from this module). use aura_runner::family::{blueprint_mc_family, blueprint_sweep_family, blueprint_walkforward_family, DataChoice, DataSource, Selection}; #[cfg(test)] use aura_runner::family::{render_bind_error, select_winner, showcase_prices}; // `reproduce_family_in` (#295) lives in `aura_runner::reproduce`, returning // `Result<_, aura_runner::RunnerError>`; the test module's own call sites // unwrap it (the production dispatch arm is `dispatch_reproduce`). #[cfg(test)] use aura_runner::reproduce::reproduce_family_in; // The measurement run path (#295 Task 8) lives in `aura_runner::measure` now. use aura_runner::measure::run_measurement; // `render_value` is the single source (aura-runner, #295 Task 7): the // campaign trace layout's member-key label and this shell's own // presentation (reproduce output, `--list-axes` default printing) render a // scalar identically. use aura_runner::runner::render_value; use aura_runner::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH}; // `sim_optimal_manifest`/`GraphBuilder`/`summarize`/`summarize_r`/`Sub` — every // production call site now lives inside `aura_runner::member` (manifest // construction, harness assembly); the test module still builds reference // blueprints and hand-computed metrics directly, so the imports are test-only, // mirroring `Bias` below. #[cfg(test)] use aura_runner::member::sim_optimal_manifest; // `CostLeg` is only reached from the test module's persist-side !reduce // re-run (production cost-leg construction happens inside // `run_blueprint_member`, not here); the import is test-only, mirroring // `Bias` below. The r_breakout|r_meanrev|r_channel carves and // `r_sma_prices` moved to `aura_runner::member` proper (#295) along with the // tests that exercised them — no longer imported here. #[cfg(test)] use aura_runner::member::CostLeg; #[cfg(test)] use aura_backtest::{summarize, summarize_r}; #[cfg(test)] use aura_engine::GraphBuilder; #[cfg(test)] use aura_std::Sub; // `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in production // code; the test module still builds reference blueprints against it directly, so // the import is test-only. #[cfg(test)] use aura_vocabulary::std_vocabulary; // `Bias`/`Sma` are only reached from the test module; the imports are test-only. #[cfg(test)] use aura_strategy::Bias; #[cfg(test)] use aura_std::Sma; #[cfg(test)] use std::sync::mpsc; use std::collections::HashSet; #[cfg(test)] use std::collections::BTreeMap; use clap::{Args, Parser, Subcommand}; /// The winner-selection objective for walk-forward's per-window IS refit — the /// deflation-aware SQN variant (#144 default). Named once so the two shell-side /// call sites (the dissolved campaign sugar's sweep/walkforward/mc bridges) /// cannot drift apart on the token; `aura_runner::family` keeps its own copy /// of this same token for `select_winner`/`blueprint_walkforward_family` /// (#295 Task 8 — the family builders are not shell code, so the constant /// does not cross the boundary). const WINNER_SELECTION_METRIC: &str = "sqn_normalized"; /// 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; /// Per-tap decimation kind (#111): the bounded exposure stream (C10, f64 ∈ [-1,+1]) /// reduces by per-bucket mean, so its net/duty-cycle level survives decimation /// instead of collapsing to a -1..+1 band (every bucket of a multi-year exposure /// straddles many sign flips, so min/max would be ±1 everywhere). An unbounded /// cumulative curve (equity) keeps the min/max envelope so drawdowns survive. Keyed /// on the tap name — `exposure` is the only bounded level tap today. fn reduce_for_tap(tap: &str) -> ReduceKind { if tap == "exposure" { ReduceKind::Mean } else { ReduceKind::MinMax } } /// Serve-time 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 reduce each series per its [`ReduceKind`] (#111): a `MinMax` series emits min /// then max (the envelope — equity drawdowns survive), a `Mean` series emits the /// per-bucket mean in both slots (the net level — a bounded exposure shows its /// duty-cycle instead of a -1..+1 band). An all-null bucket emits null. `meta` passes /// through unchanged. Deterministic (C1). Full data stays on disk; only the served /// page is thinned. No-op when `xs.len() <= 2 * buckets`. fn decimate(data: ChartData, buckets: usize) -> ChartData { let buckets = buckets.max(1); let n = data.xs.len(); if n <= 2 * buckets { return data; } let ChartData { xs, series, meta } = data; // Bucket index bounds (lo, hi_exclusive, two_slots) + the decimated shared spine. // xs is sorted+deduped (strictly increasing) -> boundary timestamps are strictly // increasing across and within buckets, so the spine stays monotonic for uPlot. let mut bounds: Vec<(usize, usize, bool)> = Vec::with_capacity(buckets); let mut out_xs: Vec = Vec::with_capacity(2 * buckets); for b in 0..buckets { let lo = b * n / buckets; let hi = (b + 1) * n / buckets; if lo >= hi { continue; } let two = hi - lo > 1; out_xs.push(xs[lo]); if two { out_xs.push(xs[hi - 1]); } bounds.push((lo, hi, two)); } let out_series: Vec = series .into_iter() .map(|s| { let mut points: Vec> = Vec::with_capacity(out_xs.len()); for &(lo, hi, two) in &bounds { let (first, second) = match s.reduce { ReduceKind::MinMax => { // envelope: min at the first slot, max at the second. 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 { (Some(mn), Some(mx)) } else { (None, None) } } ReduceKind::Mean => { // net level: the per-bucket mean written to both slots (a flat // step), so a bounded high-flip series shows its duty-cycle // instead of a -1..+1 band (#111). let mut sum = 0.0; let mut cnt = 0u32; for v in s.points[lo..hi].iter().flatten() { sum += *v; cnt += 1; } let m = if cnt > 0 { Some(sum / cnt as f64) } else { None }; (m, m) } }; points.push(first); if two { points.push(second); } } Series { name: s.name, y_scale_id: s.y_scale_id, points, reduce: s.reduce } }) .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 /// 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(name: &str, 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, reduce: reduce_for_tap(&tap.tap) }); } } 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 /// name) paired with the chosen tap's drained `(ts, row)` pairs. type MemberRows = (String, Vec<(Timestamp, Vec)>); /// Build the comparison `ChartData` for a family: one `Series` per member (the /// chosen `tap`'s column), labelled by `member.key`, ALL sharing ONE `y_scale_id` /// (the members measure one identical quantity, so a shared scale is what makes /// them comparable — unlike the single-run overlay, whose series are different /// 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). fn build_comparison_chart_data( name: &str, members: &[FamilyMember], tap: &str, ) -> Result { let mut member_rows: Vec = Vec::new(); for m in members { if let Some(t) = m.traces.taps.iter().find(|t| t.tap == tap) { member_rows.push((m.key.clone(), t.to_rows())); } } if member_rows.is_empty() { return Err(format!("no family member has a tap named '{tap}'")); } let mut xs: Vec = member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect(); xs.sort_unstable(); xs.dedup(); let spine: Vec<(Timestamp, Vec)> = xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect(); let sides: Vec<&[(Timestamp, Vec)]> = member_rows.iter().map(|(_, r)| r.as_slice()).collect(); let joined: Vec = join_on_ts(&spine, &sides); // One shared y-scale across all member series (same quantity). let y_scale_id = format!("y_cmp_{tap}"); let mut series: Vec = Vec::new(); for (i, (key, _)) in member_rows.iter().enumerate() { // Project column 0 — the doc's "chosen tap's column" (singular). The // comparison taps in scope (equity / exposure) are single-column `f64`. // A future multi-column tap selection would need a column index here. let points: Vec> = 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, reduce: reduce_for_tap(tap) }); } // 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 /// run has no such tap (refuse-don't-guess). Used by the `--tap` flag on the /// single-run chart path; without `--tap` the single-run page is unchanged. fn filter_to_tap(data: ChartData, tap: &str) -> Result { if !data.series.iter().any(|s| s.name == tap) { // List the valid taps so the user can correct a typo (#131) — refuse, but help. let available: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect(); return Err(format!("run has no tap named '{tap}' (available: {})", available.join(", "))); } let series: Vec = data.series.into_iter().filter(|s| s.name == tap).collect(); let mut meta = data.meta; meta.taps = vec![tap.to_string()]; Ok(ChartData { xs: data.xs, series, meta }) } /// `aura chart [--tap ] [--panels]`: classify the name and render. A /// single run charts all its taps (or the one `--tap` selects); a family overlays /// one tap (default `equity`) across its members; an unknown name is a runtime error /// (stderr + exit 1), never a panic. fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &aura_runner::project::Env) { let store = env.trace_store(); render_chart_by_kind(name, store.name_kind(name), tap, mode, env, &store); } /// Render (or refuse) a chart for a name whose [`NameKind`] is already known — /// the shared tail `emit_chart` calls directly for the original name, and /// [`resolve_campaign_name`]'s `NotFound` arm re-enters with the resolved /// trace-store handle's own kind (never the un-resolved name), so the /// handle-charted path (Run/Family) is exercised exactly once either way. fn render_chart_by_kind( name: &str, kind: NameKind, tap: Option<&str>, mode: ChartMode, env: &aura_runner::project::Env, store: &aura_registry::TraceStore, ) { match kind { NameKind::Run => { let traces = match store.read(name) { Ok(t) => t, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; let mut data = build_chart_data(name, traces); if let Some(t) = tap { data = match filter_to_tap(data, t) { Ok(d) => d, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; } let data = decimate(data, CHART_DECIMATE_BUCKETS); print!("{}", render::render_chart_html(&data, mode)); } NameKind::Family => { let members = match store.read_family(name) { Ok(m) => m, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; let data = match build_comparison_chart_data(name, &members, tap.unwrap_or("equity")) { Ok(d) => d, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; let data = decimate(data, CHART_DECIMATE_BUCKETS); print!("{}", render::render_chart_html(&data, mode)); } NameKind::NotFound => match resolve_campaign_name(name, env) { NameResolution::Unique(handle) => { let resolved_kind = store.name_kind(&handle); render_chart_by_kind(&handle, resolved_kind, tap, mode, env, store); } NameResolution::Ambiguous(handles) => { eprintln!( "aura: the campaign name '{name}' names {} recorded runs ({}) — \ chart one of these handles directly", handles.len(), handles.join(", "), ); std::process::exit(1); } NameResolution::None => { eprintln!( "aura: no recorded run or family '{name}' under runs/traces \ (check the handle a sweep/walk-forward/campaign run printed for a typo — \ re-running with this handle as `--trace` will not create it)" ); std::process::exit(1); } }, } } /// The outcome of resolving a `chart ` argument against the recorded /// campaign runs (#238): `NAME` is not a trace-store handle, but it may be the /// `--trace ` the user chose when the family was produced, which lands /// only in the campaign document's `name` field (`put_campaign`), never in the /// trace-store layout itself. enum NameResolution { /// Exactly one recorded campaign run's stored document carries `name == /// NAME` and a persisted `trace_name` — the trace-store handle to chart. Unique(String), /// More than one recorded run's document carries `name == NAME` — refuse /// rather than silently pick one; the candidate handles, in the campaign /// store's file (append) order, so the refusal is deterministic. Ambiguous(Vec), /// No recorded run's document carries `name == NAME` — NAME is genuinely /// unknown, not a chosen campaign name either. None, } /// Resolve a `chart` argument against `env.registry()`'s campaign-run records: /// keep every record whose `trace_name` is `Some` (a family was actually /// persisted for it) AND whose stored campaign document (`get_campaign`) /// parses with `name == name`. Malformed/missing campaign documents are /// skipped rather than propagated — a resolution failure downgrades to "not a /// campaign name", not a hard error, since the trace-store NotFound message is /// still an honest fallback. fn resolve_campaign_name(name: &str, env: &aura_runner::project::Env) -> NameResolution { let registry = env.registry(); let records = match registry.load_campaign_runs() { Ok(r) => r, Err(_) => return NameResolution::None, }; let mut candidates = Vec::new(); for record in &records { let Some(trace_name) = &record.trace_name else { continue }; let Ok(Some(doc_json)) = registry.get_campaign(&record.campaign) else { continue }; let Ok(doc) = aura_research::parse_campaign(&doc_json) else { continue }; if doc.name == name { candidates.push(trace_name.clone()); } } match candidates.len() { 0 => NameResolution::None, 1 => NameResolution::Unique(candidates.remove(0)), _ => NameResolution::Ambiguous(candidates), } } // `DataChoice`/`DataSource` (the family builders' `--real`/synthetic data // provider) and `Selection` (the `--select` winner objective) moved to // `aura_runner::family` (#295 Task 8) — imported below by plain name so // call sites are unchanged. /// Parse a `--select` token: `argmax` | `plateau` (alias for `plateau:mean`) | /// `plateau:mean` | `plateau:worst`. Unknown tokens are a usage error (the /// caller maps `Err(())` to exit 2). fn parse_select(s: &str) -> Result { match s { "argmax" => Ok(Selection::Argmax), "plateau" | "plateau:mean" => Ok(Selection::Plateau(PlateauMode::Mean)), "plateau:worst" => Ok(Selection::Plateau(PlateauMode::Worst)), _ => Err(()), } } /// Map the CLI `--select` value to the research selection rule the campaign /// document carries. fn select_rule_of(sel: Selection) -> aura_research::SelectRule { match sel { Selection::Argmax => aura_research::SelectRule::Argmax, Selection::Plateau(PlateauMode::Mean) => aura_research::SelectRule::PlateauMean, Selection::Plateau(PlateauMode::Worst) => aura_research::SelectRule::PlateauWorst, } } /// Render a family-member stdout line: the assigned `family_id` plus the embedded /// `RunReport`. The report is emitted in its own declaration key order (manifest /// leads with `commit`, C18) so the line is byte-identical to the stored /// `families.jsonl`. `serde_json::json!` would route the report through a /// `serde_json::Value` and re-alphabetize the manifest keys (broker-first), /// diverging from the store — hence the report is spliced in pre-serialized (#99). fn family_member_line(id: &str, report: &RunReport) -> String { format!( r#"{{"family_id":{},"report":{}}}"#, serde_json::to_string(id).expect("a string id always serializes"), report.to_json() ) } /// Monte-Carlo variant of [`family_member_line`]: the per-draw line also carries the /// realization `seed` (between `family_id` and `report`) — the shape `run_blueprint_mc` prints. fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { format!( r#"{{"family_id":{},"seed":{},"report":{}}}"#, serde_json::to_string(id).expect("a string id always serializes"), seed, report.to_json() ) } /// Pool every OOS window's per-trade R series into one flat vector, in roll order /// (window order, then within-window trade order). Windows with no `r` block /// contribute nothing. The single home of the pooling-in-roll-order semantics — /// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this. fn pooled_oos_net_trade_rs(result: &WalkForwardResult) -> Vec { result .windows .iter() .flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.net_trade_rs.clone()).unwrap_or_default()) .collect() } /// The walk-forward summary line: window count, stitched OOS total pips (the last /// stitched-curve value), and the on-demand per-param stability. Canonical JSON /// (C14). fn walkforward_summary_json(result: &WalkForwardResult) -> String { let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0); let pooled_rs = pooled_oos_net_trade_rs(result); let mut obj = serde_json::json!({ "windows": result.windows.len(), "stitched_total_pips": total, "param_stability": param_stability(result), }); if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) { // RMetrics serializes its scalar fields (net_trade_rs is serde-skipped, so the // oos_r block is the clean R-metric summary of the pooled series). obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) .expect("RMetrics serializes"); } serde_json::json!({ "walkforward": obj }).to_string() } /// The walk-forward summary line reconstructed from the recorded per-window OOS /// reports (the campaign path's `WalkForward` `StageFamily.reports`) rather than a /// live `WalkForwardResult`. `stitched_total_pips` = the per-window `total_pips` /// summed left-to-right in roll order (the engine `stitch` folds each segment's /// final cumulative value, and a window's OOS segment ends at its `total_pips`); /// `param_stability` reduces each IS-refit axis in `axes` over the per-window /// chosen params (read from each report's `manifest.params` via /// [`aura_runner::axes::raw_matches_wrapped`] — blueprint axes are recorded wrapped /// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while /// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match /// would miss the wrapped ones) through the same `MetricStats::from_values`; the /// `oos_r` block pools the per-window `net_trade_rs` through `r_metrics_from_rs`. /// Canonical JSON (C14). /// /// `axes` carries the invocation's raw axis names in argv order, followed by the /// stop columns when a regime is bound (#220 — no axis name is hardcoded here). /// Order matters: the committed exact-grade anchor pins /// `param_stability[0].mean` = the first axis's refit mean. fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) -> String { let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum(); // Reconstructs the minimal `WalkForwardResult` shape `aura_engine::param_stability` // actually reads (`space` + `windows[*].run.chosen_params`) from the recorded // reports, so this path shares the ONE per-axis coercion+reduction with // `walkforward_summary_json` (line ~578) instead of a second copy of it. // `bounds`/`oos_equity` play no role in the reduction (cheap placeholders); // `oos_report` carries the already-owned report along. let space: Vec = axes .iter() .map(|axis| { let (_, v) = reports[0] .manifest .params .iter() .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) .expect("each walk-forward window records its chosen axis"); aura_engine::ParamSpec { name: axis.clone(), kind: v.kind() } }) .collect(); let windows: Vec<_> = reports .iter() .map(|r| { let chosen_params = axes .iter() .map(|axis| { r.manifest .params .iter() .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) .expect("each walk-forward window records its chosen axis") .1 .cell() }) .collect(); aura_engine::WindowOutcome { bounds: aura_engine::WindowBounds { is: (Timestamp(0), Timestamp(0)), oos: (Timestamp(0), Timestamp(0)), }, run: aura_engine::WindowRun { chosen_params, oos_equity: Vec::new(), oos_report: r.clone() }, } }) .collect(); let result = aura_engine::WalkForwardResult { space, windows, stitched_oos_equity: Vec::new() }; let stability = param_stability(&result); let pooled_rs: Vec = reports .iter() .flat_map(|r| r.metrics.r.as_ref().map(|m| m.net_trade_rs.clone()).unwrap_or_default()) .collect(); let mut obj = serde_json::json!({ "windows": reports.len(), "stitched_total_pips": total, "param_stability": stability, }); if reports.iter().any(|r| r.metrics.r.is_some()) { obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) .expect("RMetrics serializes"); } serde_json::json!({ "walkforward": obj }).to_string() } /// The cross-instrument generalization line: the chosen metric, instrument count, /// worst-case floor, sign-agreement count, and the per-instrument breakdown. Canonical /// JSON (C14), mirroring `walkforward_summary_json`'s `{"generalize": obj}` shape. fn generalize_json(agg: &Generalization) -> String { let per: Vec = agg .per_instrument .iter() .map(|(sym, v)| serde_json::json!([sym, v])) .collect(); let obj = serde_json::json!({ "metric": agg.selection_metric, "n_instruments": agg.n_instruments, "worst_case": agg.worst_case, "sign_agreement": agg.sign_agreement, "per_instrument": per, }); serde_json::json!({ "generalize": obj }).to_string() } /// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not /// `Serialize` (only its `MetricStats` fields are), so the line is built from the /// three per-metric stat blocks. fn mc_aggregate_json(agg: &McAggregate) -> String { serde_json::json!({ "mc_aggregate": { "total_pips": agg.total_pips, "max_drawdown": agg.max_drawdown, "bias_sign_flips": agg.bias_sign_flips, } }) .to_string() } /// Render an `RBootstrap` as one canonical JSON line (`MetricStats` serializes; the /// scalar fields are spliced in), mirroring `mc_aggregate_json`. fn mc_r_bootstrap_json(b: &RBootstrap) -> String { serde_json::json!({ "mc_r_bootstrap": { "n_trades": b.n_trades, "block_len": b.block_len, "n_resamples": b.n_resamples, "e_r": b.e_r, "prob_le_zero": b.prob_le_zero, } }) .to_string() } /// `aura runs families`: one header line per stored family (id, kind, member /// count), in first-seen store order. fn runs_families(env: &aura_runner::project::Env) { let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; for fam in group_families(members) { println!( "{}", serde_json::json!({ "family_id": fam.id, "kind": fam.kind, "members": fam.members.len() }) ); } } /// `aura runs family [rank ]`: list one family's member reports in /// ordinal order, or best-first by `metric`. An unknown id is an empty family /// (prints nothing, exit 0); an unknown metric is a usage error (stderr + exit 2). fn runs_family(id: &str, rank: Option<&str>, env: &aura_runner::project::Env) { let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else { return; // unknown family id: empty, exit 0 }; let reports: Vec = family.members.iter().map(|m| m.report.clone()).collect(); let ordered = match rank { Some(metric) => match rank_by(reports, metric) { Ok(r) => r, Err(e) => { eprintln!("aura: {e}"); std::process::exit(2); } }, None => reports, }; for report in &ordered { println!("{}", report.to_json()); if let Some(sel) = &report.manifest.selection { match sel.mode { // `deflated_score` is `None` only on an Argmax record with no // deflation run (report.rs); guard it, symmetric with the // plateau branch, so a from-disk record cannot panic here. When // present (the sole producer always stamps it), the bytes are // unchanged. SelectionMode::Argmax => if let Some(deflated) = sel.deflated_score { match sel.overfit_probability { Some(p) => println!(" deflated={deflated:.4} P(overfit)={p:.4}"), None => println!(" deflated={deflated:.4}"), } }, SelectionMode::PlateauMean | SelectionMode::PlateauWorst => { let label = if matches!(sel.mode, SelectionMode::PlateauMean) { "mean" } else { "worst" }; if let (Some(score), Some(n)) = (sel.neighbourhood_score, sel.n_neighbours) { println!(" plateau({label})={score:.4} over {n} cells"); } } } } } } // --- r-sma harness (the SMA-cross signal scored in R) -------------------- /// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158). /// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`) /// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the /// two surfaces agree by construction over the same canonical bytes. Research-side /// (aura-cli), off the frozen engine (invariant 8). fn content_id(canonical_json: &str) -> String { aura_research::content_id_of(canonical_json) } /// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a /// signal blueprint — the run's `topology_hash` (#158). fn topology_hash(signal: &Composite) -> String { content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) } /// One measurement run's IC scalar + its permutation-null significance, for stdout. #[derive(serde::Serialize)] struct IcReport { run: String, signal_tap: String, price_tap: String, horizon: usize, permutations: usize, seed: u64, n_pairs: usize, information_coefficient: f64, overfit_probability: f64, } impl IcReport { fn to_json(&self) -> String { serde_json::to_string(self).expect("a finite IcReport always serializes") } } /// `aura sweep --list-axes`: one `:` line per open /// sweepable knob, in `param_space()` order (byte-identical to before #246), /// followed by one `: default=` line per BOUND param — /// every bound param IS a sweepable default, re-openable by naming it as an /// `--axis` (#246), on EVERY blueprint shape (fully open, partially open, or /// fully closed) — not only the fully-closed case: `--axis`/`override_paths` /// accepts a bound name regardless of how many knobs happen to be open /// alongside it, so the discovery surface must list it regardless too. Names /// are exactly what `--axis` binds. fn list_blueprint_axes(doc: &str, env: &aura_runner::project::Env) { let space = blueprint_axis_probe(doc, env).param_space(); for p in &space { println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp } let signal = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"); let bp = signal.name().to_string(); for b in signal.bound_param_space() { println!("{bp}.{}:{:?} default={}", b.name, b.kind, render_value(&b.value)); } } /// `aura sweep --axis = …`: sweep a loaded signal over its /// named param-space axes (the cycle-2 World/C21 verb). Builds the family via /// [`blueprint_sweep_family`], surfaces an unknown / kind-mismatched axis as a named /// error (stderr + exit 2, never a panic), ALWAYS records it as a `FamilyKind::Sweep` /// family (C18/C21 lineage, exactly as the other family verbs), and prints each member /// carrying the assigned `family_id` via [`family_member_line`] — so a printed member is /// linkable back to its stored family, like `run_blueprint_walkforward` / `run_blueprint_mc`. /// /// Divergence from the retired `run_sweep` (the one place this does less): the blueprint sweep is /// reduce-only this cycle — [`blueprint_sweep_family`] writes no per-member traces — so /// `persist`/`--trace` neither writes trace files nor reserves a trace-store name (that /// reservation would guard a write that never happens, and could spuriously reject a /// valid sweep on a name collision). `persist` is therefore not yet load-bearing here; /// it is retained for the deferred per-member trace path. The family record itself is /// written unconditionally, so lineage (C18/C21) holds whether or not `--trace` is given. /// /// Synthetic-only: real-data invocations route through /// `verb_sugar::run_sweep_sugar` at the dispatch boundary and never reach /// this fn. fn run_blueprint_sweep( doc: &str, axes: &[(String, Vec)], name: &str, persist: bool, data: DataSource, env: &aura_runner::project::Env, ) { let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); }); let reg = env.registry(); // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash — // exactly the bytes whose SHA256 the members carry (#164 byte-canonical, round-trip // idempotent). One stored topology per family (C18/C11/C12). let topo = family.points[0] .report .manifest .topology_hash .clone() .expect("a blueprint sweep stamps every member's topology_hash"); let canonical = blueprint_to_json( &blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); // Record the family unconditionally (C18/C21 lineage), exactly like // `run_blueprint_walkforward` / `run_blueprint_mc`. let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; for pt in &family.points { println!("{}", family_member_line(&id, &pt.report)); } } /// `aura walkforward --axis …`: build the loaded-blueprint /// IS-refit walk-forward, store the canonical blueprint ONCE keyed by the shared /// `topology_hash` (the C18 hook, so `aura reproduce` re-derives it), record it as /// a `FamilyKind::WalkForward` family, and print each OOS member line + the summary. /// Mirrors `run_blueprint_sweep` (content-addressed family verb; no ensure_name_free). fn run_blueprint_walkforward( doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection, env: &aura_runner::project::Env, ) { let result = blueprint_walkforward_family(doc, axes, &data, select, env); let reg = env.registry(); let topo = result.windows[0] .run .oos_report .manifest .topology_hash .clone() .expect("a blueprint walk-forward stamps every member's topology_hash"); let canonical = blueprint_to_json( &blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { Ok(id) => id, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; for w in &result.windows { println!("{}", family_member_line(&id, &w.run.oos_report)); } crate::diag::note_zero_trade_windows( result .windows .iter() .map(|w| w.run.oos_report.metrics.r.as_ref().map_or(0, |m| m.n_trades)), ); println!("{}", walkforward_summary_json(&result)); } /// `aura mc --seeds N`: build a Monte-Carlo family from a loaded CLOSED /// blueprint (the World/C21 verb), store the canonical blueprint ONCE keyed by the shared /// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a /// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line /// (carrying the seed) plus the aggregate — mirroring `run_blueprint_sweep`. fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &aura_runner::project::Env) { let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); }); let reg = env.registry(); // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash. let topo = family.draws[0] .report .manifest .topology_hash .clone() .expect("a blueprint mc stamps every member's topology_hash"); let canonical = blueprint_to_json( &blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"), ) .expect("a loaded blueprint re-serializes"); reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { Ok(id) => id, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; for draw in &family.draws { println!("{}", mc_member_line(&id, draw.seed, &draw.report)); } println!("{}", mc_aggregate_json(&family.aggregate)); } /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells /// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells /// bind positionally against the loaded signal's `param_space`. A malformed array is /// refused with the flag named, never silently coerced to an empty param vector /// (refuse-don't-guess, C10) — a dropped param would bootstrap a *different* graph. fn parse_param_cells(json: &str) -> Result, String> { serde_json::from_str(json).map_err(|e| format!("--params: {e}")) } /// Lex a `--axis` CSV into typed Scalars by shape: an integer-shaped token is i64, /// otherwise f64. `resolve_axes` kind-checks each value against the param's declared /// kind afterwards (a mismatch is a named error, not a panic). fn parse_scalar_csv(csv: &str) -> Option> { csv.split(',').map(|t| { let t = t.trim(); if t.is_empty() { return None; } match t.parse::() { Ok(i) => Some(Scalar::i64(i)), Err(_) => t.parse::().ok().map(Scalar::f64), } }).collect() } // ============================== clap parser surface ============================== // The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` // handlers below convert each `*Cmd` into the argument shapes the existing execution // fns accept, reusing the value helpers (`Selection`, // `DataSource::from_choice`, `parse_scalar_csv`, `parse_select`, // `parse_param_cells`). The four subcommands with an optional `[blueprint]` positional // dispatch on `is_blueprint_file`: a first-positional naming an existing `.json` file // selects the loaded-blueprint branch. There is no second built-in grammar anymore // (#159 demo retirement / #220 axis generalization): without a blueprint the // dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to // the campaign path. Usage errors exit 2; runtime refusals exit 1; a run that // completes with ≥1 failed cell exits 3 (#272); a clean run exits 0. /// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, /// falling back to `"unknown"`): both `--version` (via `version_string`) and /// `sim_optimal_manifest`'s `RunManifest.commit` read this one const, so a /// stale binary is distinguishable from a fresh one at the CLI surface, /// before any run, without a second commit-sourcing mechanism (#266). const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") { Some(c) => c, None => "unknown", }; /// `aura 0.1.0 ()` as a process-lifetime `&'static str` — computed once /// (clap's `version` builder method wants `Into`, and `clap::builder::Str` /// (clap 4.6) only implements `From<&'static str>`, not `From`, so the /// one owned `String` this formats is leaked for the process's lifetime, same /// as any other CLI one-shot startup string). fn version_string() -> &'static str { static VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); VERSION.get_or_init(|| format!("{} ({ENGINE_COMMIT})", env!("CARGO_PKG_VERSION"))) } /// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the /// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so /// `aura --version` prints `aura 0.1.0 ()`. /// The two-layer concepts paragraph `aura --help` opens with (#315): the /// zero-setup self-description a reader without repo access gets. const CONCEPTS_HELP: &str = "\ Author, backtest, and validate trading strategies — research CLI. Two layers, one vocabulary: the research verbs are the convenience surface — over --real data, sweep, walkforward, mc, and generalize desugar to registered process/campaign documents and execute them (run is their single-backtest sibling; synthetic runs execute in-process). That document data plane is directly authorable: `aura process` / `aura campaign` (validate | introspect | register | run), growing a document from a bare {} via `introspect --unwired`. Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the continuously-tracked target position; a protective stop defines the risk unit R, and signal quality is measured in R. Traces: `sweep --real --trace` / `walkforward --real --trace` record per-cycle taps under runs/, consumed by `aura chart` and `aura measure`."; #[derive(Parser)] #[command( name = "aura", version = version_string(), about = "Author, backtest, and validate trading strategies — research CLI", long_about = CONCEPTS_HELP, infer_long_args = true )] struct Cli { #[command(subcommand)] command: Command, /// Load the project dylib from target/release instead of target/debug. #[arg(long, global = true)] release: bool, } #[derive(Subcommand)] enum Command { /// Run a single backtest (built-in harness or a loaded blueprint). /// /// Part of the research sugar surface; the canonical, document-first form /// of any research run is a registered process + campaign pair — see /// `aura process` / `aura campaign`. Run(RunCmd), /// Render a recorded run's trace to an HTML chart. Chart(ChartCmd), /// Emit / construct / introspect a graph. Graph(GraphCmd), /// Sweep a parameter grid over a strategy or a loaded blueprint. /// /// Sugar over the document layer: over --real data this desugars to a /// registered process (std::sweep) + campaign pair — author the documents /// directly via `aura process` / `aura campaign`. Sweep(SweepCmd), /// Walk-forward validation over a strategy or a loaded blueprint. Over --real, the /// fixed 90/30-day roller fits to a --from/--to window shorter than it, preserving /// the 3:1 IS:OOS ratio, instead of refusing the window outright. /// /// Sugar over the document layer: over --real data this desugars to a /// registered process ([std::grid, std::walk_forward]) + campaign pair — /// author the documents directly via `aura process` / `aura campaign`. Walkforward(WalkforwardCmd), /// Grade one candidate across multiple instruments. /// /// Sugar over the document layer: desugars to a registered process /// ([std::sweep (selection), std::generalize]) + campaign pair — author /// the documents directly via `aura process` / `aura campaign`. Generalize(GeneralizeCmd), /// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. Over /// --real, the fixed 90/30-day walk-forward roller fits to a --from/--to window /// shorter than it, preserving the 3:1 IS:OOS ratio, instead of refusing outright. /// /// Sugar over the document layer: over --real data this desugars to a /// registered process ([std::grid, std::walk_forward, std::monte_carlo]) + /// campaign pair — author the documents directly via `aura process` / /// `aura campaign`. Mc(McCmd), /// List or inspect recorded run families. Runs(RunsCmd), /// Reproduce a recorded family by content id. Reproduce(ReproduceCmd), /// Scaffold a new data-only research project. New(NewCmd), /// Scaffold and attach node crates (native-node development). Nodes(NodesCmd), /// Validate, introspect, and register process documents (methodology). Process(research_docs::ProcessCmd), /// Validate, introspect, and register campaign documents (experiment intent). Campaign(research_docs::CampaignCmd), /// Inspect the project's data archive. Data(DataCmd), /// Reduce a measurement run's recorded taps to a signal-quality metric. Measure(MeasureCmd), } #[derive(Args)] struct ChartCmd { /// The recorded run or family name to chart. name: String, /// Chart only the given tap. #[arg(long)] tap: Option, /// Render stacked panels instead of an overlay. #[arg(long)] panels: bool, } #[derive(Args)] struct NewCmd { /// Directory name to create. name: String, } #[derive(Args)] struct NodesCmd { #[command(subcommand)] command: NodesCommand, } #[derive(Subcommand)] enum NodesCommand { /// Scaffold a node crate beside this project and attach it via `[nodes]`. New(NodesNewCmd), } #[derive(Args)] struct NodesNewCmd { /// Crate name; also the default namespace (dashes become underscores). name: String, /// Engine checkout for the crate's aura-core path-dep. #[arg(long)] engine_path: Option, /// Vocabulary namespace override. #[arg(long)] namespace: Option, } #[derive(Args)] struct DataCmd { #[command(subcommand)] command: DataCommand, } #[derive(Subcommand)] enum DataCommand { /// Report a symbol's monthly file-index coverage (span + interior gaps). Coverage(DataCoverageCmd), /// List the archive's known symbols, sorted, one per line. List, } #[derive(Args)] struct DataCoverageCmd { /// The symbol to inventory (e.g. `GER40`). symbol: String, } #[derive(Args)] struct MeasureCmd { #[command(subcommand)] command: MeasureCommand, } #[derive(Subcommand)] enum MeasureCommand { /// Information Coefficient of a signal tap against forward returns of a price tap. Ic(MeasureIcCmd), } #[derive(Args)] struct MeasureIcCmd { /// The persisted run name (its trace-store subdirectory). run: String, #[arg(long)] signal: String, #[arg(long)] price: String, #[arg(long, default_value_t = 1)] horizon: usize, #[arg(long, default_value_t = 1000)] permutations: usize, #[arg(long, default_value_t = 0)] seed: u64, } #[derive(Args)] #[command(args_conflicts_with_subcommands = true)] struct GraphCmd { /// A blueprint .json file to render (omit for the built-in sample). blueprint: Option, #[command(subcommand)] sub: Option, } #[derive(Subcommand)] enum GraphSub { /// Construct a graph from a stdin op-list. #[command(after_help = crate::graph_construct::OP_REFERENCE)] Build, /// Introspect a graph. Introspect(GraphIntrospectCmd), /// Register a blueprint document into the content-addressed store (#196). Register { /// The blueprint .json file to register. file: std::path::PathBuf, }, } #[derive(Args)] struct GraphIntrospectCmd { /// List the closed node vocabulary (one node type + its meaning per line). #[arg(long)] vocabulary: bool, /// Describe one node type's ports by name. #[arg(long)] node: Option, /// List the graph's unwired (unbound) ports. #[arg(long)] unwired: bool, /// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning). #[arg(long)] folds: bool, /// Print the graph's content id (topology hash). With FILE, read the /// document from it (a blueprint envelope or an op-list, shape- /// discriminated, #196); without FILE, read a stdin op-list as before. #[arg(long, value_name = "FILE")] content_id: Option>, /// Print the graph's topology-identity id (debug names stripped). #[arg(long)] identity_id: bool, /// Print a blueprint's raw param space (the campaign-axis namespace), one /// name:kind line per open param. FILE path or 64-hex store content id (#196). #[arg(long, value_name = "FILE|ID")] params: Option, } #[derive(Args)] struct GeneralizeCmd { /// The candidate blueprint (.json, required) — graded across instruments. blueprint: Option, /// Comma-separated instrument list (>=2 distinct, required). #[arg(long)] real: Option, /// Candidate axis `=` (repeatable, >=1; one value per axis). #[arg(long)] axis: Vec, /// Candidate stop length (single value; optional, defaults to /// [`R_SMA_STOP_LENGTH`]). #[arg(long)] stop_length: Option, /// Candidate stop-k multiple (single value; optional, defaults to /// [`R_SMA_STOP_K`]). #[arg(long)] stop_k: Option, /// Window start (Unix ms, inclusive). #[arg(long)] from: Option, /// Window end (Unix ms, inclusive). #[arg(long)] to: Option, /// Grading metric (default expectancy_r). #[arg(long)] metric: Option, /// Family name (default generalize). #[arg(long)] name: Option, } #[derive(Args)] struct RunsCmd { #[command(subcommand)] sub: RunsSub, } #[derive(Subcommand)] enum RunsSub { /// List recorded families. Families, /// Inspect one family, optionally ranked by a metric. Family { id: String, /// The literal keyword `rank`, if a ranking metric follows. rank_kw: Option, /// The metric to rank by (only valid after `rank`). metric: Option, }, } #[derive(Args)] struct ReproduceCmd { /// The family content id to reproduce. id: String, } #[derive(Args)] struct RunCmd { /// A serialized signal blueprint (.json). An existing file selects the /// loaded-blueprint grammar; otherwise the built-in harness grammar. blueprint: Option, /// Blueprint params (JSON scalar-cell array; .json mode). #[arg(long)] params: Option, /// Blueprint seed (.json mode). #[arg(long)] seed: Option, /// Real instrument symbol to backtest over (recorded data); omit for the synthetic stream. #[arg(long)] real: Option, /// Window start (Unix ms, inclusive); requires --real. #[arg(long)] from: Option, /// Window end (Unix ms, inclusive); requires --real. #[arg(long)] to: Option, /// Not accepted — CLI-side trace persistence is retired (see #224). #[arg(long)] trace: Option, } #[derive(Args)] struct SweepCmd { /// A loaded blueprint (.json); omit for the built-in --strategy grammar. blueprint: Option, /// Legacy `--strategy` selector: no built-in value remains (use a blueprint). /// Retired tokens fall to the generic usage error. #[arg(long)] strategy: Option, /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. #[arg(long)] real: Option, /// Window start (Unix ms, inclusive); requires --real. #[arg(long)] from: Option, /// Window end (Unix ms, inclusive); requires --real. #[arg(long)] to: Option, /// Family name (records to the registry without persisting per-member traces). #[arg(long)] name: Option, /// Family name that also persists each member's taps (--real mode; mutually /// exclusive with --name). #[arg(long)] trace: Option, /// Blueprint sweep axis `=` (repeatable; .json mode). #[arg(long)] axis: Vec, /// List a loaded blueprint's sweepable axes and exit (.json mode, stands alone). #[arg(long)] list_axes: bool, } #[derive(Args)] struct WalkforwardCmd { /// A loaded blueprint (.json, required — both grammars are blueprint-first). blueprint: Option, /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. #[arg(long)] real: Option, /// Window start (Unix ms, inclusive); requires --real. #[arg(long)] from: Option, /// Window end (Unix ms, inclusive); requires --real. #[arg(long)] to: Option, /// Family name (records to the registry without persisting per-member traces). #[arg(long)] name: Option, /// Family name that also persists each OOS window's taps (--real mode; /// mutually exclusive with --name). #[arg(long)] trace: Option, /// Campaign-path stop length (--real mode). #[arg(long)] stop_length: Option, /// Campaign-path stop-k multiple (--real mode). #[arg(long)] stop_k: Option, /// In-sample winner selection: argmax | plateau (alias for plateau:mean) | /// plateau:mean | plateau:worst (default argmax). #[arg(long)] select: Option, /// Blueprint IS-refit axis `=` (repeatable, >=1 required; .json mode). #[arg(long)] axis: Vec, } #[derive(Args)] struct McCmd { /// A loaded blueprint (.json); omit for the built-in grammar. blueprint: Option, /// Real instrument symbol for the R-bootstrap campaign path (recorded data); omit /// for the synthetic seed family. #[arg(long)] real: Option, /// Window start (Unix ms, inclusive); requires --real. #[arg(long)] from: Option, /// Window end (Unix ms, inclusive); requires --real. #[arg(long)] to: Option, /// Family name for the synthetic seed-resweep (records without persisting traces). #[arg(long)] name: Option, /// Not accepted with --real — the R-bootstrap records without a family name. #[arg(long)] trace: Option, /// Blueprint IS-refit axis `=` (repeatable, >=1 required; --real mode). #[arg(long)] axis: Vec, /// Campaign-path stop length (--real mode). #[arg(long)] stop_length: Option, /// Campaign-path stop-k multiple (--real mode). #[arg(long)] stop_k: Option, /// Moving-block bootstrap block length (R-bootstrap; default 1). #[arg(long)] block_len: Option, /// Number of bootstrap resamples (R-bootstrap; default 1000). #[arg(long)] resamples: Option, /// Bootstrap RNG seed (R-bootstrap; default 1). #[arg(long)] seed: Option, /// Number of synthetic draws (required; .json mode). #[arg(long)] seeds: Option, } /// The dual-grammar discriminator: a first-positional that names an existing /// `.json` file selects the loaded-blueprint branch. Single-sourced so the /// four dual-grammar subcommands stay in lockstep. fn is_blueprint_file(arg: &Option) -> Option<&str> { arg.as_deref() .filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) } /// Resolve a `[blueprint].json`-branch `--real`/`--from`/`--to` into a `RunData`, /// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to` /// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2). fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunData { let usage = "Usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]]"; match real { Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to }, Some(_) => { eprintln!("aura: {usage}"); std::process::exit(2); } None if from.is_some() || to.is_some() => { eprintln!("aura: {usage}"); std::process::exit(2); } None => RunData::Synthetic, } } /// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar /// consumes (the dissolved `.json --real` branch). Single instrument, single-value /// stop (Fork A: the stop is a risk regime, not a swept axis); `--stop-length`/ /// `--stop-k` are typed `Option`/`Option` (clap rejects a multi-value /// spelling itself, "invalid value") and each OPTIONAL (#217): a missing flag /// defaults independently to the single-sourced [`R_SMA_STOP_LENGTH`]/ /// [`R_SMA_STOP_K`] regime. `--name`/`--trace` are mutually exclusive, matching /// the flag's own documented contract and the inline path's `name_persist` /// refusal; an omitted flag defaults the family name to "walkforward". The /// IS-refit `--axis` grid is parsed separately at the dispatch site, not here. #[allow(clippy::type_complexity)] fn walkforward_args_from( a: &WalkforwardCmd, ) -> Result<(String, bool, String, i64, f64, Option, Option), String> { let symbol = match a.real.as_deref() { None | Some("") => return Err("walkforward dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let (name, trace) = match (a.name.as_deref(), a.trace.as_deref()) { (Some(_), Some(_)) => { return Err("walkforward: --name and --trace are mutually exclusive".to_string()); } (Some(n), None) => (n.to_string(), false), (None, Some(t)) => (t.to_string(), true), (None, None) => ("walkforward".to_string(), false), }; Ok((name, trace, symbol, stop_length, stop_k, a.from, a.to)) } /// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the /// `--real` campaign branch). Single instrument, single-value stop (Fork A: the /// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are typed /// `Option`/`Option` (clap rejects a multi-value spelling itself, /// "invalid value") and each OPTIONAL (#217), independently defaulting to the /// single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted. /// `--block-len`/`--resamples`/`--seed` default to `1`/`1000`/`1` — the same /// defaults the retired real-R dispatch used, so an omitted flag produces the /// same document either way. The usize->u32 conversion lands HERE, at the argv /// boundary where the CLI's `usize` meets the document's `u32` vocabulary /// (`StageBlock::MonteCarlo`). `--name`/`--trace` are rejected: the R-bootstrap /// records without a family name (the campaign name is a constant "mc"). #[allow(clippy::type_complexity)] fn mc_args_from( a: &McCmd, ) -> Result<(String, String, i64, f64, u32, u32, u64, Option, Option), String> { let symbol = match a.real.as_deref() { None | Some("") => return Err("mc dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; if a.name.is_some() || a.trace.is_some() { return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); } let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); let seed = a.seed.unwrap_or(1); Ok(("mc".to_string(), symbol, stop_length, stop_k, block_len, resamples, seed, a.from, a.to)) } /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar /// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are single /// values, each OPTIONAL (#217) and independently defaulting to the single- /// sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — clap's /// typed `Option`/`Option` already forbids a multi-value spelling, so /// no separate regime refusal is needed here. Every refusal whose flag survives /// #220 reuses the old message string (byte-identical front-end). #[allow(clippy::type_complexity)] fn generalize_args_from( a: &GeneralizeCmd, ) -> Result<(String, Vec, i64, f64, String, Option, Option), String> { let symbols: Vec = match a.real.as_deref() { None => return Err("generalize requires --real — a comma list of two or more instruments".to_string()), Some(v) => { let parts: Vec = v.split(',').map(|s| s.to_string()).collect(); if parts.iter().any(|s| s.is_empty()) { return Err("generalize: --real takes a comma list of non-empty symbols (e.g. GER40,USDJPY)".to_string()); } parts } }; if symbols.len() < 2 { return Err(format!( "generalize needs at least two instruments to compare across; got {} (--real takes a comma list of >=2 symbols)", symbols.len() )); } let mut seen = HashSet::new(); if !symbols.iter().all(|s| seen.insert(s.clone())) { return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string()); } let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string()); let name = a.name.clone().unwrap_or_else(|| "generalize".to_string()); Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to)) } /// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a /// non-empty symbol yields `DataChoice::Real`, a window flag without `--real` is a /// usage error, absence is synthetic. Single-sourced (the old `RealWindowGrammar` /// finish logic) so the family subcommands agree. fn data_choice_from( real: Option<&str>, from: Option, to: Option, usage: &impl Fn() -> String, ) -> Result { match real { Some("") => Err(usage()), Some(s) => Ok(DataChoice::Real { symbol: s.to_string(), from_ms: from, to_ms: to }), None if from.is_some() || to.is_some() => Err(usage()), None => Ok(DataChoice::Synthetic), } } /// Resolve `--name`/`--trace` (mutually exclusive) into `(family_name, persist)`, /// defaulting the name when neither is given. fn name_persist( name: Option<&str>, trace: Option<&str>, default: &str, usage: &impl Fn() -> String, ) -> Result<(String, bool), String> { match (name, trace) { (Some(_), Some(_)) => Err(usage()), (Some(n), None) => Ok((n.to_string(), false)), (None, Some(t)) => Ok((t.to_string(), true)), (None, None) => Ok((default.to_string(), false)), } } /// Parse the repeatable `--axis =` list into by-name grid axes, mirroring /// the old blueprint-sweep axis grammar: an empty/duplicate name or a malformed csv /// is a usage error. fn parse_axes( raw: &[String], usage: &impl Fn() -> String, ) -> Result)>, String> { let mut axes: Vec<(String, Vec)> = Vec::new(); for item in raw { let (n, csv) = item.split_once('=').ok_or_else(usage)?; if n.is_empty() || axes.iter().any(|(a, _)| a == n) { return Err(usage()); } let vals = parse_scalar_csv(csv).ok_or_else(usage)?; axes.push((n.to_string(), vals)); } Ok(axes) } /// The three distinguishable ways `validate_and_register_axes` can fail: an /// unknown axis name is a usage error (exit 2, echoed before the archive is /// touched); a registry write failure is a runtime error (exit 1); running /// outside a project (no `Aura.toml` found up from cwd, #218's gate) is also /// a runtime error (exit 1) — the strategy document cannot resolve against a /// project store/vocabulary that doesn't exist. enum AxisRegisterError { UnknownAxis(String), Registry(String), NoProject(String), } /// Validate every `--axis` name against the blueprint's WRAPPED probe namespace /// (`blueprint_axis_probe`/`--list-axes`) — an axis naming a BOUND param (#246: /// re-openable, same as every already-open knob) passes exactly like an open /// one; only a name matching NEITHER space is refused — then canonicalize + /// register the blueprint by topology hash and strip every axis name to the RAW /// campaign namespace (the sweep sequence, #210 c0110). A raw-form or /// fat-fingered axis name is refused here, echoing exactly what the user typed, /// before the archive is touched or the blueprint is registered — shared by /// every campaign-path dispatcher (sweep/generalize/walkforward/mc all landed /// the identical block; #220 slice-1 deferred this dedup to "once wf/mc land /// the same block", rule-of-three now exceeded 4x). The override set itself is /// re-derived downstream (`DefaultMemberRunner::run_member`, /// `verb_sugar::validate_before_register`) rather than threaded through this /// return value — this preflight only decides go/no-go on the NAME. #[allow(clippy::type_complexity)] fn validate_and_register_axes( verb: &str, doc: &str, axes: &[(String, Vec)], env: &aura_runner::project::Env, ) -> Result<(String, Vec<(String, Vec)>), AxisRegisterError> { let space = blueprint_axis_probe(doc, env).param_space(); let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) .expect("doc parse-validated at the dispatch boundary"); let bound = wrapped_bound_names(&blueprint); for (n, _) in axes { if !space.iter().any(|p| &p.name == n) && !bound.contains(n) { return Err(AxisRegisterError::UnknownAxis(format!( "axis \"{n}\" is not one of this blueprint's \ sweepable axes — run 'aura sweep --list-axes' \ to see them" ))); } } if env.provenance().is_none() { let cwd = std::env::current_dir() .map(|d| d.display().to_string()) .unwrap_or_default(); return Err(AxisRegisterError::NoProject(format!( "{verb} needs a project: strategies resolve against the project \ store and vocabulary (no Aura.toml found up from {cwd})" ))); } let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); let reg = env.registry(); let topo = topology_hash(&blueprint); reg.put_blueprint(&topo, &canonical) .map_err(|e| AxisRegisterError::Registry(e.to_string()))?; let raw_axes: Vec<(String, Vec)> = axes .iter() .map(|(n, v)| (aura_runner::axes::wrapped_to_raw_axis(n).to_string(), v.clone())) .collect(); Ok((canonical, raw_axes)) } /// Exit-map for a `validate_and_register_axes` failure — the stderr line and /// exit code every campaign dispatcher (sweep/generalize/walkforward/mc) /// preserves: an unknown axis is a usage error (exit 2, echoed before the /// archive is touched); a registry write failure or a missing project (no /// `Aura.toml` found up from cwd, #218's gate) is a runtime error (exit 1). fn exit_axis_register_error(e: AxisRegisterError) -> ! { match e { AxisRegisterError::UnknownAxis(m) => { eprintln!("aura: {m}"); std::process::exit(2) } AxisRegisterError::Registry(m) => { eprintln!("aura: {m}"); std::process::exit(1) } AxisRegisterError::NoProject(m) => { eprintln!("aura: {m}"); std::process::exit(1) } } } /// Terminate per a campaign-path result (#272): a String error is a refusal /// (exit 1); an Ok carrying the failed-cell count exits 3 when any cell failed /// ("completed with failed cells"), else returns cleanly (exit 0). pub(crate) fn exit_on_campaign_result(r: Result) { match r { Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } Ok(0) => {} Ok(_) => std::process::exit(3), } } /// The shared campaign window in Unix-ms, clipped to the archive. `full_window` /// probes the ARCHIVE's actual first/last bar in range (never the literal ms /// request) and returns aura's native epoch-ns `Timestamp` (the ms->ns crossing /// happens once, at the ingest seam — C3); the campaign document's `Window` /// field is Unix-ms (same currency as `--from`/`--to` and every existing /// campaign fixture, e.g. `campaign_doc_json` in research_docs.rs). Convert /// back through the seam's own `aura_ingest::epoch_ns_to_unix_ms` at this one /// seam-crossing (never reimplement the division inline) so the executor's /// `unix_ms_to_epoch_ns` re-normalizes exactly once downstream, not twice. /// Shared by the campaign dispatchers — sweep/walkforward/mc always clip; /// generalize reaches here once per listed symbol, only through its /// no-explicit-window fallback, and intersects the per-symbol results into the /// one shared window (#213); an explicit `--from`+`--to` pair passes through /// unclipped there BY DESIGN. fn campaign_window_ms(choice: DataChoice, env: &aura_runner::project::Env) -> (i64, i64) { let source = DataSource::from_choice(choice, env); let (from_ts, to_ts) = source.full_window(env); ( aura_ingest::epoch_ns_to_unix_ms(from_ts), aura_ingest::epoch_ns_to_unix_ms(to_ts), ) } /// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or /// the built-in harness-kind dispatch. fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { // The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to; // the built-in-only flags are rejected here (exit 2), never silently dropped — // mirroring the sweep/mc blueprint branches, which reject their non-branch flags // exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional // makes these structurally parseable, so the guard is re-asserted at dispatch. if a.trace.is_some() { eprintln!("aura: Usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]]"); std::process::exit(2); } let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); let signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { let mut msg = graph_construct::blueprint_load_prose(&e); if let Some(hint) = graph_construct::unresolved_namespace_hint(&e, env) { msg.push_str(" — "); msg.push_str(&hint); } eprintln!("aura: {path}: {msg}"); std::process::exit(2); }); // Shape dispatch (C28 phase 3): a `bias` output → the strategy path // (wrap_r + R evaluation), byte-identical; else ≥1 declared tap → a // bare measurement run (no wrap_r); else an inert blueprint → refuse. // The closed-blueprint guard differs per arm: the strategy arm keeps // `blueprint_axis_probe` (which welds wrap_r by the `bias` port); the // measurement arm uses the signal's own `param_space()` (wrap_r-free — // probing a no-`bias` signal would panic on UnknownOutPort). let has_bias = signal.output().iter().any(|o| o.name == "bias"); let has_tap = !signal.taps().is_empty(); if has_bias { // Refuse an open (free-knob) blueprint at the dispatch boundary, // mirroring `blueprint_mc_family`'s closed-guard: `run` bootstraps // over the EMPTY point, so a free knob would panic in // `compile_with_params` — reject it clean instead (#176). let free = blueprint_axis_probe(&doc, env).param_space(); if !free.is_empty() { eprintln!( "aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \ bind them or use `aura sweep --axis`", free.len() ); std::process::exit(2); } let params = match a.params.as_deref() { Some(j) => parse_param_cells(j).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }), None => Vec::new(), }; let data = run_data_from(a.real.as_deref(), a.from, a.to); let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all()); println!("{}", report.to_json()); } else if has_tap { // Measurement path: wrap_r-free closed guard via the signal's own // open knobs (blueprint_axis_probe would weld wrap_r and panic). if !signal.param_space().is_empty() { eprintln!( "aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \ bind them or use `aura sweep --axis`", signal.param_space().len() ); std::process::exit(2); } let params = match a.params.as_deref() { Some(j) => parse_param_cells(j).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }), None => Vec::new(), }; let data = run_data_from(a.real.as_deref(), a.from, a.to); let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all()); println!("{}", report.to_json()); } else { eprintln!( "aura: `aura run` needs either a `bias` output (a strategy) or ≥1 \ declared tap (a measurement); this blueprint exposes neither" ); std::process::exit(1); } } None => { eprintln!( "aura: Usage: aura run [--params ] \ [--seed ] [--real [--from ] [--to ]]" ); std::process::exit(2); } } } fn dispatch_chart(a: ChartCmd, env: &aura_runner::project::Env) { emit_chart( &a.name, a.tap.as_deref(), if a.panels { ChartMode::Panels } else { ChartMode::Overlay }, env, ); } fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) { match a.sub { None => match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); let bp = graph_construct::composite_from_any(&doc, env).unwrap_or_else(|msg| { eprintln!("aura: {path}: {msg}"); std::process::exit(2); }); print!("{}", render::render_html(&bp)); } None if a.blueprint.is_none() => { let bp = blueprint_from_json( include_str!("../examples/r_sma.json"), &|t| env.resolve(t), ) .expect("the shipped r-sma example reloads into a renderable blueprint"); print!("{}", render::render_html(&bp)); } None => { eprintln!( "aura: Usage: aura graph [] [build|introspect|register]; \ {} is not a readable .json blueprint", a.blueprint.as_deref().unwrap_or_default() ); std::process::exit(2); } }, Some(GraphSub::Build) => graph_construct::build_cmd(env), Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env), Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env), } } fn dispatch_generalize(a: GeneralizeCmd, env: &aura_runner::project::Env) { let usage = || format!("Usage: aura generalize --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--metric ] [--name ] [--from ] [--to ]"); let Some(path) = is_blueprint_file(&a.blueprint) else { eprintln!("aura: {}", usage()); std::process::exit(2); }; let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } let (name, symbols, stop_length, stop_k, metric, from, to) = generalize_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); // Data-free R-metric refusal, byte-identical to the retired welded path // (exit 2) before any archive is touched. if let Err(e) = check_r_metric(&metric) { eprintln!("aura: {e}"); std::process::exit(2); } let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); if axes.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); } // A candidate is a single grid cell, not a sweep: every axis carries // exactly one value (the refusal the retired `--fast 2,3` grammar made). for (n, vals) in &axes { if vals.len() != 1 { eprintln!( "aura: generalize: each --axis takes exactly one value; axis \"{n}\" has {}", vals.len() ); std::process::exit(2); } } let (canonical, raw_axes) = validate_and_register_axes("generalize", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // Window: the explicit --from/--to when both present (byte-identical to the // retired welded path, verified by the exact-grade anchor); otherwise the // INTERSECTION of every listed symbol's full archive window — the only span // over which every instrument actually has data (#213). A single listed // symbol degenerates to its own full window unchanged. let (from_ms, to_ms) = match (from, to) { (Some(f), Some(t)) => (f, t), _ => { let windows: Vec<(i64, i64)> = symbols .iter() .map(|s| { campaign_window_ms( DataChoice::Real { symbol: s.clone(), from_ms: from, to_ms: to }, env, ) }) .collect(); match intersect_shared_window(&symbols, &windows) { Ok(w) => w, Err(spans) => { for (s, w) in spans { eprintln!("aura: generalize: {s} spans [{}, {}]", w.0, w.1); } eprintln!( "aura: generalize: no window is shared across all listed symbols \ (their archives do not overlap)" ); std::process::exit(1); } } } }; let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, symbols, from_ms, to_ms, blueprint_canonical: &canonical, stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), // `generalize` has no `--trace` flag in its own grammar (#224 delivered // sweep + walkforward only); trace-writing is out of scope here. trace: false, }; exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env)); } fn dispatch_runs(a: RunsCmd, env: &aura_runner::project::Env) { match a.sub { RunsSub::Families => runs_families(env), RunsSub::Family { id, rank_kw, metric } => match (rank_kw.as_deref(), metric) { (None, None) => runs_family(&id, None, env), (Some("rank"), Some(m)) => runs_family(&id, Some(&m), env), _ => { eprintln!("aura: Usage: aura runs family [rank ]"); std::process::exit(2); } }, } } fn dispatch_reproduce(a: ReproduceCmd, env: &aura_runner::project::Env) { // `reproduce_family` (#295) returns a `RunnerError` instead of exiting // the process itself; this is the one place that prints its message // (empty for the "not every member reproduced" refusal, since no stderr // line accompanies it — only the stdout summary `reproduce_family` // already emitted) and exits with its code. if let Err(e) = aura_runner::reproduce::reproduce_family(&a.id, env) { if !e.message.is_empty() { eprintln!("aura: {}", e.message); } std::process::exit(e.exit_code); } } fn dispatch_new(a: NewCmd, _env: &aura_runner::project::Env) { let cwd = std::env::current_dir().unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); let spec = scaffold::project_scaffold_spec(&a.name, &cwd).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); scaffold::scaffold_project(&spec).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(1); }); println!( "created project \"{}\" (data-only; attach native nodes later with `aura nodes new`)", spec.name ); } fn dispatch_nodes(cmd: NodesCmd) { match cmd.command { NodesCommand::New(a) => dispatch_nodes_new(a), } } fn dispatch_nodes_new(a: NodesNewCmd) { let cwd = std::env::current_dir().unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); let Some(root) = aura_runner::project::discover_from(&cwd) else { eprintln!( "aura: `aura nodes new` needs a project (no Aura.toml found up from {})", cwd.display() ); std::process::exit(1); }; let toml = match aura_runner::project::read_aura_toml(&root) { Ok(t) => t, Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }; // Mirror `append_nodes_pointer`'s textual `[nodes]`-section check (not just // `crates.is_empty()`) so a `[nodes]` section with an empty crates array // is caught here too, before scaffold_node_crate writes a crate that // append_nodes_pointer would then refuse to attach. let already_attached = !toml.nodes.crates.is_empty() || std::fs::read_to_string(root.join("Aura.toml")).is_ok_and(|t| t.contains("[nodes]")); if already_attached { eprintln!("aura: a node crate is already attached (multi-crate loading is not yet supported)"); std::process::exit(1); } let parent = root.parent().unwrap_or(&root).to_path_buf(); let spec = match scaffold::scaffold_spec( &a.name, a.engine_path.as_deref(), a.namespace.as_deref(), &parent, ) { Ok(s) => s, Err(m) => { eprintln!("aura: {m}"); std::process::exit(2); } }; if let Err(m) = scaffold::scaffold_node_crate(&spec) { eprintln!("aura: {m}"); std::process::exit(1); } if let Err(m) = scaffold::append_nodes_pointer(&root, &format!("../{}", a.name)) { eprintln!("aura: {m}"); std::process::exit(1); } println!( "created node crate \"../{}\" (namespace \"{}\") and attached it to {}", a.name, spec.namespace, root.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() ); } fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) { match cmd.command { DataCommand::Coverage(a) => dispatch_data_coverage(a, env), DataCommand::List => dispatch_data_list(env), } } fn dispatch_measure(cmd: MeasureCmd, env: &aura_runner::project::Env) { match cmd.command { MeasureCommand::Ic(a) => dispatch_measure_ic(a, env), } } fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) { let traces = env.trace_store().read(&a.run).unwrap_or_else(|e| { eprintln!("aura: reading run '{}' traces failed: {e}", a.run); std::process::exit(1); }); let series = |tap: &str| -> Vec<(Timestamp, f64)> { let ct = traces.taps.iter().find(|t| t.tap == tap).unwrap_or_else(|| { let have: Vec<&str> = traces.taps.iter().map(|t| t.tap.as_str()).collect(); eprintln!("aura: run '{}' has no tap '{tap}' (taps: {have:?})", a.run); std::process::exit(1); }); // column 0 is the tap's value series; `f64_field` (already imported in main.rs, // used by run_signal_r) projects field 0 of the (Timestamp, Vec) rows // ColumnarTrace::to_rows rebuilds (all cells are Scalar::f64, report.rs:248, so // f64_field never hits its kind-mismatch panic). f64_field(&ct.to_rows(), 0) }; let signal = series(&a.signal); let price = series(&a.price); let out = information_coefficient(&signal, &price, a.horizon, a.permutations, a.seed); let report = IcReport { run: a.run, signal_tap: a.signal, price_tap: a.price, horizon: a.horizon, permutations: a.permutations, seed: a.seed, n_pairs: out.n_pairs, information_coefficient: out.information_coefficient, overfit_probability: out.overfit_probability, }; println!("{}", report.to_json()); } /// `aura data list` (#264 cut 2): print the archive's known symbols, sorted /// ascending, one per line — the discovery step before `aura data coverage` /// or scoping a campaign's instrument matrix. Resolves the archive root /// through the normal project path ([`dispatch_data_coverage`]'s /// `Env::data_path`), then reuses `DataServer`'s own symbol index /// (`DataServer::symbols()`, already sorted) rather than re-deriving a /// directory scan. An empty or absent archive is informational absence, not a /// fault: a `no symbols` prose line, exit 0 (the verb corpus's established /// empty-result register — cf. `runs family`'s unknown-id empty exit 0). fn dispatch_data_list(env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); let server = aura_ingest::DataServer::new(&data_path); for line in data_list_report(&server.symbols()) { println!("{line}"); } } /// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one /// line per symbol, or a single `no symbols` line when the archive is empty /// or absent — informational absence, never a fault (#264). fn data_list_report(symbols: &[std::sync::Arc]) -> Vec { if symbols.is_empty() { return vec!["no symbols".to_string()]; } symbols.iter().map(|s| s.to_string()).collect() } /// `aura data coverage ` (#264 cut 1): print the archive's month /// coverage for `SYMBOL` — the Copper failure mode (files present at both /// ends of a window while an interior month is missing, so a first/last-bounds /// check passes but a campaign run aborts mid-window) made visible up front. /// Resolves the archive root through the normal project path (`Env::data_path` /// — a project-local `[paths] data` override when set, the data-server default /// otherwise), so a project-scoped archive is inventoried, not the host one. fn dispatch_data_coverage(a: DataCoverageCmd, env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); let months = aura_ingest::list_m1_months(&data_path, &a.symbol); // The report body is single-sourced with a campaign cell's own coverage // annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months` // is the one gap walk both consumers call). match aura_runner::coverage::data_coverage_report(&a.symbol, &months) { Ok(lines) => { for line in lines { println!("{line}"); } } Err(e) => { eprintln!("aura: {e} at {}", env.data_path()); std::process::exit(1); } } } /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) { // Single-sourced: the blueprint grammar and the no-blueprint usage error must // stay in lockstep, so both arms below read this one closure. let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); // Parse-validate the blueprint once at the boundary (with file-path context), // house-style prose (#184's convention, single-sourced — #210 c0110 finding). if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } // A built-in-only flag with a blueprint file is not in this grammar. if a.strategy.is_some() { eprintln!("aura: {}", usage()); std::process::exit(2); } if a.list_axes { // A query, not a sweep: it must stand alone. if !a.axis.is_empty() || a.name.is_some() || a.trace.is_some() || a.real.is_some() || a.from.is_some() || a.to.is_some() { eprintln!("aura: --list-axes lists axes and takes no other flags"); std::process::exit(2); } list_blueprint_axes(&doc, env); return; } let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); if axes.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); } let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) .unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); match &data { DataChoice::Real { symbol, .. } => { // The dissolved branch: real-data blueprint sweeps run as // sugar over a generated campaign document through the // one campaign executor. The blueprint store write (by // topology hash) is kept — the // canonical bytes are also the strategy ref's content, and // `topology_hash` IS `content_id_of` applied to the same // canonical bytes (verified by reading: `topology_hash` == // `content_id` == `aura_research::content_id_of`), so the // strategy ref resolves against this one write; a second // put under `content_id_of(&canonical)` would target the // identical key and is not needed. // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). let (canonical, raw_axes) = validate_and_register_axes("sweep", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); let symbol = symbol.clone(); // Archive-clipped Unix-ms window; the ns->ms seam-crossing // rationale lives on `campaign_window_ms`. let (from_ms, to_ms) = campaign_window_ms(data, env); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name: name.clone(), symbols: vec![symbol.clone()], from_ms, to_ms, blueprint_canonical: &canonical, stop: None, // #224: the real-data campaign path DELIVERS `--trace` // (per-member tap-series persistence); `persist` is // `name_persist`'s bool (true iff `--trace` was given). trace: persist, }; exit_on_campaign_result(verb_sugar::run_sweep_sugar(&inv, env)); } DataChoice::Synthetic => { // The synthetic in-process family path is still reduce-only // (`blueprint_sweep_family` writes no per-member traces, #224 // delivered only the real-data campaign path) — refuse rather // than silently accept an advertised-but-unhonoured flag. if persist { eprintln!( "aura: --trace is not yet available on a synthetic sweep (no --real); see #224" ); std::process::exit(2); } run_blueprint_sweep( &doc, &axes, &name, persist, DataSource::from_choice(data, env), env, ); } } } None => { eprintln!("aura: {}", usage()); std::process::exit(2); } } } /// `aura walkforward`: IS-refit walk-forward over a loaded blueprint — the /// synthetic in-process family (no `--real`) or the campaign path (`--real`, /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) { // Single-sourced: both arms below read this one closure (house style, #179). let usage = || format!("Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); if axes.is_empty() { eprintln!("aura: walkforward requires >= 1 --axis to re-fit per window"); std::process::exit(2); } if a.real.is_some() { // The campaign path (#220): the real-archive execution routes // through the one campaign executor, over the user's own // blueprint and axes (formerly the welded r-sma branch). let select = match a.select.as_deref() { Some(s) => match parse_select(s) { Ok(sel) => select_rule_of(sel), Err(()) => { eprintln!("aura: {}", usage()); std::process::exit(2); } }, None => aura_research::SelectRule::Argmax, }; let (name, trace, symbol, stop_length, stop_k, from, to) = walkforward_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). let (canonical, raw_axes) = validate_and_register_axes("walkforward", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // Unlike `dispatch_generalize` (a single run per instrument, insensitive // to a day's edge shift), `blueprint_walkforward_family` sources its span // from `DataSource::wf_full_span`, which — for Real — ALWAYS clips // `--from`/`--to` to the archive's actual first/last bar in range (never the // literal ms request): a holiday/weekend edge shifts every IS/OOS window's // calendar placement, so the roller must clip identically here too, even // when both flags are given, or the per-window winners and OOS pips diverge // from the committed exact-grade anchor. let (from_ms, to_ms) = campaign_window_ms( DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, env, ); let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, symbols: vec![symbol], from_ms, to_ms, blueprint_canonical: &canonical, stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), // #224: the walkforward sibling delivers `--trace` identically // to sweep, riding the SAME per-cell nominee trace mechanism // (unchanged since 0109 — walkforward always nominates). trace, }; let result = verb_sugar::run_walkforward_sugar( &inv, WINNER_SELECTION_METRIC, verb_sugar::WfWindows { in_sample_ms: is_ms, out_of_sample_ms: oos_ms, step_ms, }, select, env, ); exit_on_campaign_result(result); return; } // Synthetic in-process family path — unchanged (#220 non-goal); it // persists no per-window taps, so `--trace` stays refused here (#224 // delivered only the `--real` campaign path above), mirroring // `dispatch_sweep`'s synthetic-arm refusal with its own named pointer. if a.trace.is_some() { eprintln!( "aura: --trace is not yet available on a synthetic walkforward (no --real); see #224" ); std::process::exit(2); } if a.stop_length.is_some() || a.stop_k.is_some() { eprintln!("aura: {}", usage()); std::process::exit(2); } let select = match a.select.as_deref() { Some(s) => parse_select(s).unwrap_or_else(|()| { eprintln!("aura: {}", usage()); std::process::exit(2); }), None => Selection::Argmax, }; let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string()); run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); } None => { eprintln!("aura: {}", usage()); std::process::exit(2); } } } /// `aura mc`: Monte-Carlo over a loaded blueprint — the synthetic seed family /// (`--seeds`, closed blueprint) or the R-bootstrap campaign path (`--real`, /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_mc(a: McCmd, env: &aura_runner::project::Env) { // Single-sourced: every arm below reads this one closure (house style, #179). let usage = || format!("Usage: aura mc --seeds [--name ] | aura mc --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]"); match is_blueprint_file(&a.blueprint) { Some(path) => { let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } if a.real.is_some() { // The campaign path (#220): blueprint + --real + --axis routes // the R-bootstrap pipeline through the one campaign executor. // The two mc modes stay disjoint: --seeds belongs to the // synthetic seed family only. if a.seeds.is_some() { eprintln!("aura: {}", usage()); std::process::exit(2); } let (name, symbol, stop_length, stop_k, block_len, resamples, seed, from, to) = mc_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); if axes.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); } // Axis validation + canonicalize/register + the wrapped->raw // strip are single-sourced in `validate_and_register_axes` // (data-free, so this fires before the archive is touched). let (canonical, raw_axes) = validate_and_register_axes("mc", &doc, &axes, env) .unwrap_or_else(|e| exit_axis_register_error(e)); // `blueprint_walkforward_family` sources its span from // `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to` // to the archive's actual first/last bar in range: a holiday/weekend edge // shifts every IS/OOS window's calendar placement, so the roller must clip // identically here too, even when both flags are given, or the per-window // winners and pooled OOS series diverge from the committed exact-grade anchor. let (from_ms, to_ms) = campaign_window_ms( DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, env, ); let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, symbols: vec![symbol], from_ms, to_ms, blueprint_canonical: &canonical, stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), // mc --real refuses --name/--trace up front (`mc_args_from`) — // the R-bootstrap records without a family name; trace-writing // is out of #224's scope here. trace: false, }; let result = verb_sugar::run_mc_sugar( &inv, WINNER_SELECTION_METRIC, verb_sugar::WfWindows { in_sample_ms: is_ms, out_of_sample_ms: oos_ms, step_ms, }, verb_sugar::McKnobs { resamples, block_len, seed }, env, ); exit_on_campaign_result(result); return; } // Synthetic seed family (unchanged, #220 non-goal): closed // blueprint, --seeds only; every campaign-mode flag is rejected. if a.from.is_some() || a.to.is_some() || a.stop_length.is_some() || a.stop_k.is_some() || a.block_len.is_some() || a.resamples.is_some() || a.seed.is_some() || a.trace.is_some() || !a.axis.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); } let n_seeds = match a.seeds { Some(n) if n > 0 => n, _ => { eprintln!("aura: {}", usage()); std::process::exit(2); } }; let name = a.name.clone().unwrap_or_else(|| "mc".to_string()); run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env); } None => { eprintln!("aura: {}", usage()); std::process::exit(2); } } } fn main() { // Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN // at startup, so a write to a closed stdout pipe (`aura sweep | head`, a closed UI // pane) returns EPIPE and panics in `println!` instead of terminating quietly on // SIGPIPE — the conventional Unix CLI behaviour. One reset covers every // family-emitting subcommand at once. #[cfg(unix)] unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } let cli = Cli::parse(); // `aura new`/`aura nodes new` scaffold; they must not require a // loadable project even when invoked inside one (e.g. an unbuilt tree). let env = if matches!(cli.command, Command::New(_) | Command::Nodes(_)) { aura_runner::project::Env::std() } else { match std::env::current_dir() .ok() .and_then(|d| aura_runner::project::discover_from(&d)) { Some(root) => match aura_runner::project::load(&root, cli.release) { Ok(p) => aura_runner::project::Env::with_project(p), Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }, None => aura_runner::project::Env::std(), } }; match cli.command { Command::Run(a) => dispatch_run(a, &env), Command::Chart(a) => dispatch_chart(a, &env), Command::Graph(a) => dispatch_graph(a, &env), Command::Sweep(a) => dispatch_sweep(a, &env), Command::Walkforward(a) => dispatch_walkforward(a, &env), Command::Generalize(a) => dispatch_generalize(a, &env), Command::Mc(a) => dispatch_mc(a, &env), Command::Runs(a) => dispatch_runs(a, &env), Command::Reproduce(a) => dispatch_reproduce(a, &env), Command::New(a) => dispatch_new(a, &env), Command::Nodes(a) => dispatch_nodes(a), Command::Process(a) => research_docs::process_cmd(a, &env), Command::Campaign(a) => research_docs::campaign_cmd(a, &env), Command::Data(a) => dispatch_data(a, &env), Command::Measure(a) => dispatch_measure(a, &env), } } #[cfg(test)] mod tests { use super::*; /// Audit follow-up to #247/#269: `render_bind_error` is exhaustive — the /// axis-usage variants the old catch-all Debug-framed render as prose /// naming the axis, with no Rust identifier on the user's stderr. #[test] fn render_bind_error_prose_covers_the_axis_usage_variants() { let dup = render_bind_error(&aura_engine::BindError::DuplicateBinding("a.b".into())); assert!(dup.contains("a.b") && dup.contains("bound twice"), "{dup}"); assert!(!dup.contains("DuplicateBinding"), "Debug leak: {dup}"); let empty = render_bind_error(&aura_engine::BindError::EmptyAxis("a.b".into())); assert!(empty.contains("a.b") && empty.contains("no values"), "{empty}"); assert!(!empty.contains("EmptyAxis"), "Debug leak: {empty}"); let range = render_bind_error(&aura_engine::BindError::EmptyRange("a.b".into())); assert!(range.contains("a.b") && !range.contains("EmptyRange"), "Debug leak: {range}"); } /// Audit follow-up to #247/#269: a `Compile` fault at the sweep boundary — /// a blueprint defect, not an axis usage error — renders as a prose frame /// that names the situation and labels the embedded compile detail as /// internal, instead of leaking the bare Debug struct as the whole message. #[test] fn render_bind_error_frames_a_compile_fault_as_prose_with_labeled_detail() { let msg = render_bind_error(&aura_engine::BindError::Compile( aura_engine::CompileError::BadInteriorIndex, )); assert!(msg.contains("failed to bootstrap"), "{msg}"); assert!(msg.contains("internal detail"), "{msg}"); assert!(!msg.starts_with("Compile"), "the frame must lead with prose: {msg}"); } /// An empty (or absent) archive is informational absence, not a fault /// (#264): `data_list_report` returns the single `no symbols` prose line /// rather than an empty `Vec`, so the caller's exit-0 println always has /// something to say. #[test] fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() { let symbols: Vec> = vec![]; assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]); } /// A non-empty archive renders one line per symbol, in the order handed /// in (`DataServer::symbols()` already sorts) — no `no symbols` line /// mixed in (#264). #[test] fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() { let symbols: Vec> = vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")]; assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]); } /// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a /// costed member in `!reduce` mode (fresh per-cycle taps + a `CostLeg` /// bound the same way `cost_nodes_for` binds it) and compares the result /// against the recorded reduce-mode member. This pins that cross-mode /// equivalence holds UNDER a non-empty cost model: the `!reduce` + /// `CostLeg` wiring (this test, verbatim) and the reduce-mode /// `run_blueprint_member` path must net to the exact same `RunMetrics` /// over the same synthetic realization — the equality the drift alarm /// structurally depends on to not false-positive on every legitimate /// costed campaign. #[test] fn persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost() { let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); let space = blueprint_axis_probe(&doc, &env).param_space(); let binding = aura_runner::binding::resolve_binding("persistnet", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; let window = data.full_window(&env); let pip = data.pip_size(); let cost = [aura_research::CostSpec::Constant { cost_per_trade: aura_research::CostValue::Scalar(0.0005), }]; // The recorded member: reduce-mode, cost-bound — `run_blueprint_member`, // the exact path `DefaultMemberRunner::run_member` calls. let recorded = run_blueprint_member( reload(), &[], &space, data.run_sources(&env, &binding.columns()), window, 0, pip, "topo", &env, stop, &binding, &cost, "GER40", ); // The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` // built through the SAME `aura_runner::translate::cost_nodes_for`, then // `summarize` + `summarize_r(&r_rows, &cost_rows)` — exactly // `persist_campaign_traces`'s C1 drift-alarm computation. let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, _rx_req) = mpsc::channel(); let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = Some(CostLeg { nodes: aura_runner::translate::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg) .compile_with_params(&[]) .expect("the persist-side wrap builds"); let mut h = Harness::bootstrap(flat).expect("the persist-side harness bootstraps"); h.run(data.run_sources(&env, &binding.columns())); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows)); assert!( rerun_metrics.r.as_ref().is_some_and(|r| r.n_trades > 0), "the fixture must actually close at least one costed trade" ); assert_eq!( rerun_metrics, recorded.metrics, "the persist path's !reduce + CostLeg re-run must net to the exact same \ RunMetrics as the reduce-mode member under the same cost model — the \ C1 drift alarm `persist_campaign_traces` relies on" ); } /// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through the /// public `blueprint_from_json` path — the single call site so a fixture /// rename or vocabulary change is one edit, not fourteen. fn load_closed_r_sma() -> Composite { blueprint_from_json(include_str!("../examples/r_sma.json"), &|t| std_vocabulary(t)) .expect("loads") } /// Loads the shipped open r-sma example (both SMA lengths free) through the /// public `blueprint_from_json` path. fn load_open_r_sma() -> Composite { blueprint_from_json(include_str!("../tests/fixtures/r_sma_open.json"), &|t| std_vocabulary(t)) .expect("loads") } #[test] fn select_winner_refuses_plateau_without_a_lattice() { // A plateau request with no lattice (a random sweep would yield None) is // refused, never silently argmaxed. The refuse short-circuits before the // family is read, so an empty family is fine here. let fam = SweepFamily { space: vec![], points: vec![] }; let err = select_winner(&fam, "total_pips", Selection::Plateau(PlateauMode::Mean), None) .unwrap_err(); assert!(err.contains("requires a grid sweep"), "refuse message: {err}"); } /// The `--select` token grammar (`parse_select`) maps argmax / plateau:mean / /// plateau:worst and rejects an unknown token — the pure selector clap's /// `--select` value feeds on both the built-in and blueprint walk-forward paths. #[test] fn parse_select_token_grammar() { assert!(matches!(parse_select("argmax").unwrap(), Selection::Argmax), "default is argmax"); assert!(matches!(parse_select("plateau:mean").unwrap(), Selection::Plateau(PlateauMode::Mean))); assert!(matches!(parse_select("plateau:worst").unwrap(), Selection::Plateau(PlateauMode::Worst))); assert!(matches!( parse_select("plateau").unwrap(), Selection::Plateau(PlateauMode::Mean) )); assert!(parse_select("bogus").is_err(), "unknown --select token is a usage error"); } 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)> = ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect(); let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows); FamilyMember { key: key.to_string(), traces: RunTraces { manifest: sim_optimal_manifest( vec![], (Timestamp(window.0), Timestamp(window.1)), 0, 1.0, ), taps: vec![tap], }, } } #[test] fn comparison_overlays_one_shared_scale_series_per_member() { let members = vec![ cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]), cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]), ]; let data = build_comparison_chart_data("fam", &members, "equity").expect("builds"); assert_eq!(data.xs, vec![1, 2, 3]); assert_eq!(data.series.len(), 2); assert_eq!(data.series[0].name, "a"); assert_eq!(data.series[1].name, "b"); // ONE shared y-scale across members (same quantity). assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id); // shared ts -> dense, no nulls. 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] fn comparison_disjoint_members_are_null_complementary() { let members = vec![ cmp_member("oos1", &[1, 2], &[10.0, 11.0]), cmp_member("oos2", &[3, 4], &[20.0, 21.0]), ]; let data = build_comparison_chart_data("fam", &members, "equity").expect("builds"); 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[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] fn comparison_errors_when_no_member_has_the_tap() { let members = vec![cmp_member("a", &[1], &[1.0])]; 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 = (0..n as i64).collect(); let points: Vec> = (0..n).map(|i| Some(i as f64)).collect(); let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2000); assert!(out.xs.len() <= 4000, "spine not bounded: {}", out.xs.len()); assert_eq!(out.xs.len(), out.series[0].points.len(), "xs and points must stay aligned"); } #[test] fn decimate_preserves_per_bucket_min_and_max() { // 10 points, 2 buckets -> bucket 0 = idx 0..5 (a spike), bucket 1 = idx 5..10 (a trough). let xs: Vec = (0..10).collect(); let mut pv = vec![1.0_f64; 10]; pv[3] = 999.0; pv[7] = -50.0; let points: Vec> = pv.into_iter().map(Some).collect(); let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2); let got = out.series[0].points.clone(); assert!(got.contains(&Some(999.0)), "bucket max (spike) dropped: {got:?}"); assert!(got.contains(&Some(-50.0)), "bucket min (trough) dropped: {got:?}"); } #[test] fn decimate_keeps_an_all_null_bucket_null() { let xs: Vec = (0..10).collect(); let mut points: Vec> = (0..5).map(|i| Some(i as f64)).collect(); points.extend(std::iter::repeat_n(None, 5)); let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::MinMax }], 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)], reduce: ReduceKind::MinMax }], meta: ChartMeta::default(), }; let out = decimate(data, 2000); assert_eq!(out.xs, vec![1, 2, 3], "within-budget data must pass through unchanged"); assert_eq!(out.series[0].points, vec![Some(1.0), Some(2.0), Some(3.0)]); } #[test] fn decimate_passes_meta_through_and_keeps_xs_monotonic() { let n = 10_000usize; let xs: Vec = (0..n as i64).collect(); let points: Vec> = (0..n).map(|i| Some(i as f64)).collect(); let 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, reduce: ReduceKind::MinMax }], 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"); } /// #111: a bounded *level* series with `reduce = Mean` decimates to each bucket's /// MEAN, not its min/max envelope — so a high-flip bipolar exposure shows its /// net/duty-cycle level instead of collapsing to a -1..+1 band. RED under the /// shipped min/max-only decimation (any bucket holding a +1 emits +1); GREEN once /// `decimate` honours `ReduceKind::Mean`. #[test] fn decimate_mean_reduces_a_bipolar_series_to_its_bucket_level() { // 10 points, 2 buckets. Bucket 0 (idx 0..5) = [+1,+1,-1,+1,+1] -> mean +0.6; // bucket 1 (idx 5..10) = all -1 -> mean -1.0. let xs: Vec = (0..10).collect(); let pv = vec![1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0]; let points: Vec> = pv.into_iter().map(Some).collect(); let data = ChartData { xs, series: vec![Series { name: "exposure".into(), y_scale_id: "y_0".into(), points, reduce: ReduceKind::Mean }], meta: ChartMeta::default(), }; let out = decimate(data, 2); let got = out.series[0].points.clone(); // No -1..+1 envelope: bucket 0 is its mean (+0.6), not a min/max pair. assert!(!got.contains(&Some(1.0)), "mean reduce must not emit a +1 envelope point: {got:?}"); assert!(got.contains(&Some(0.6)), "bucket-0 duty-cycle mean (+0.6) missing: {got:?}"); // bucket 0 spans two slots, both = the mean (a flat step, not a -1->+1 ramp). assert_eq!(got[0], Some(0.6), "first slot must be the bucket mean"); assert_eq!(got[1], Some(0.6), "second slot must also be the bucket mean"); } /// #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)> = [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 /// its own declaration key order (manifest leads with `commit`), byte-matching the /// stored `families.jsonl` — never `serde_json::Value`'s alphabetical order (which /// would lead the manifest with `broker`). #[test] fn family_member_line_keeps_report_in_store_key_order() { let report = RunReport { manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0), metrics: summarize(&[], &[]), }; let line = family_member_line("demo-1", &report); assert!( line.starts_with(r#"{"family_id":"demo-1","report":{"manifest":{"commit":"#), "got: {line}" ); assert!( !line.contains(r#""manifest":{"broker":"#), "manifest re-alphabetized (broker-first), should be commit-first: {line}" ); } /// #99: the Monte-Carlo per-draw line carries the `seed` between `family_id` and /// `report`, and the embedded report stays in store (commit-first) key order. #[test] fn mc_member_line_keeps_report_in_store_key_order_with_seed() { let report = RunReport { manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 7, 1.0), metrics: summarize(&[], &[]), }; let line = mc_member_line("mc-1", 7, &report); assert!( line.starts_with(r#"{"family_id":"mc-1","seed":7,"report":{"manifest":{"commit":"#), "got: {line}" ); assert!( !line.contains(r#""manifest":{"broker":"#), "manifest re-alphabetized (broker-first), should be commit-first: {line}" ); } #[test] fn data_source_synthetic_pip_and_window_match_the_built_ins() { let env = aura_runner::project::Env::std(); let d = DataSource::Synthetic; assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE); assert!(!d.run_sources(&env, &[aura_ingest::M1Field::Close]).is_empty()); assert_eq!(d.wf_window_sizes(), (24, 12, 12)); // full_window equals window_of over the showcase stream (byte-unchanged source) let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; assert_eq!(d.full_window(&env), window_of(&s).unwrap()); } /// A multi-column blueprint over synthetic data (a single close walk) /// refuses honestly through the builders' exit-free Err contract, naming /// the beyond-close columns and the --real remedy — never a panic from a /// source-count mismatch. High/low-consuming, closed (mc requires it), /// with the mandatory `bias` output. const OHLC_REFUSAL_BLUEPRINT: &str = r#"{ "format_version": 1, "blueprint": { "name": "hl_range", "nodes": [ {"primitive":{"type":"Sub"}} ], "edges": [], "input_roles": [ {"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"}, {"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"} ], "output": [{"node":0,"field":0,"name":"bias"}] } }"#; #[test] fn synthetic_data_refuses_a_multi_column_blueprint() { let env = aura_runner::project::Env::std(); let err = blueprint_mc_family(OHLC_REFUSAL_BLUEPRINT, 2, &DataSource::Synthetic, &env) .expect_err("a high/low blueprint cannot run over the synthetic close walk"); assert_eq!( err, "strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \ data generates a close series only; run with --real " ); let err = blueprint_sweep_family( OHLC_REFUSAL_BLUEPRINT, &[("x".to_string(), vec![Scalar::i64(1)])], &DataSource::Synthetic, &env, ) .expect_err("the synthetic sweep path refuses the same shape"); assert!(err.contains("consumes columns beyond close"), "got: {err}"); } #[test] fn wf_real_roller_sizes_are_90_30_30_days_in_ns() { // Independent expected value: a day reconstructed from its time units // (24 h * 60 min * 60 s * 1e9 ns), not the constant's own `86_400_000_000_000` // literal — so the test fails if either the literal or the day-count is wrong. let day_ns: i64 = 24 * 60 * 60 * 1_000_000_000; assert_eq!(WF_REAL_IS_NS, 90 * day_ns); assert_eq!(WF_REAL_OOS_NS, 30 * day_ns); assert_eq!(WF_REAL_STEP_NS, 30 * day_ns); } // Note (#159 cut 4 collateral): this also removes the `member_key_*` unit // tests, the `pair()` fixture helper they shared, and // `momentum_param_space_is_ema_exposure_longonly` / // `momentum_sweep_is_deterministic_and_has_eight_points` — every one of them // exercised `member_key`/`momentum_blueprint_with_sinks`/`momentum_sweep_family`, // whose only production caller was the retired PIP built-in `run_sweep` // machinery (Task 1 of this iter deleted `member_key`/`MAX_KEY`/`fnv1a64` // outright: their sole callers were `sweep_family`/`momentum_sweep_family`, // both gone). The member-key-distinctness assertion in the survivor test // below is dropped for the same reason (`member_key` no longer exists); the // doc comment is trimmed to match. /// Property: a `blueprint_sweep_family` member built from a serialized signal is /// the SAME trading result as the cycle-1 single run of that signal at the same /// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path /// (the keystone). Every member of one family carries the SAME `topology_hash` (the /// loaded signal's, the deviation from the Rust-built mirror). #[test] fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() { // An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the // serialized doc round-trips to the topology the single run hashes. let env = aura_runner::project::Env::std(); let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; // fast pinned at 2, slow varied over {4, 6}: a 2x1 grid, slow the varying axis. let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("named axes resolve"); assert_eq!(family.points.len(), 2, "2x1 grid -> 2 members"); // (b) every member carries the shared topology_hash of the loaded signal. let topo = topology_hash(&open); assert_eq!(topo.len(), 64, "topology_hash is a 64-hex SHA256"); for pt in &family.points { assert_eq!(pt.report.manifest.topology_hash.as_deref(), Some(topo.as_str())); } // (a) the slow=4 member reproduces the cycle-1 single run at fast=2, slow=4 — // same equity/exposure stream (total_pips/max_drawdown/bias_sign_flips) and the // same topology_hash, proving the loaded blueprint runs through the identical path. let single = run_signal_r( load_open_r_sma(), &[Scalar::i64(2), Scalar::i64(4)], RunData::Synthetic, 0, &env, TapPlan::record_all(), ); let member4 = &family.points[0].report; // slow=4 is the first odometer point assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run"); assert_eq!(member4.manifest.topology_hash, single.manifest.topology_hash); } #[test] fn blueprint_axis_probe_lists_prefixed_open_knobs() { // The open fixture's two SMA lengths are the sweepable knobs; the probe // wraps the signal (name "sma_signal") so the names are prefixed — // exactly what `--axis` binds. let env = aura_runner::project::Env::std(); let open = include_str!("../tests/fixtures/r_sma_open.json"); let space = blueprint_axis_probe(open, &env).param_space(); let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["sma_signal.fast.length", "sma_signal.slow.length"]); assert!(space.iter().all(|p| matches!(p.kind, ScalarKind::I64))); // A closed blueprint (both lengths bound) has no open axes. let closed = include_str!("../examples/r_sma.json"); assert!(blueprint_axis_probe(closed, &env).param_space().is_empty()); } #[test] fn blueprint_walkforward_family_refits_each_window() { // The closed blueprint's two bound SMA lengths are re-fit per IS window over // a 2x2 grid via the #246 bound-override reopen path (bound = overridable default). let env = aura_runner::project::Env::std(); let doc = include_str!("../examples/r_sma.json"); let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; let result = blueprint_walkforward_family(doc, &axes, &DataSource::Synthetic, Selection::Argmax, &env); // 24/12/12 over the 60-bar synthetic span -> 3 rolling windows. assert_eq!(result.windows.len(), 3, "three rolling IS/OOS windows"); for w in &result.windows { assert_eq!(w.run.chosen_params.len(), 2, "both axes re-fit each window"); assert!(w.run.oos_report.metrics.r.is_some(), "OOS record is R-metrics"); } // reduce-mode retains no raw pip curve -> the stitched pip-equity is empty. assert!(result.stitched_oos_equity.is_empty(), "no raw pip curve in reduce-mode"); } #[test] fn blueprint_mc_family_seeds_differ() { // MC over a CLOSED signal (both SMA knobs bound): 3 seeds -> 3 draws, one shared // topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct // synthetic walk per draw), not just the manifest label. The anti-degenerate guard: // a regression to seed-as-label-only would make the three draws identical. let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint"); assert_eq!(family.draws.len(), 3, "one draw per seed"); assert_eq!(family.draws.iter().map(|d| d.seed).collect::>(), vec![1, 2, 3]); let topo = family.draws[0].report.manifest.topology_hash.clone(); assert!(topo.is_some(), "members carry a topology_hash"); assert!( family.draws.iter().all(|d| d.report.manifest.topology_hash == topo), "all members share one topology_hash" ); let m: Vec<_> = family.draws.iter().map(|d| &d.report.metrics).collect(); assert!(m[0] != m[1] || m[1] != m[2], "seeds must yield differing realizations"); } #[test] fn blueprint_mc_family_rejects_vacuous_deep_lookback() { // Property: the mc family builder REFUSES a silent-vacuous Monte-Carlo — one where // every per-seed draw collapses to a bit-identical realization — by RETURNING a named // error rather than an `Ok` family that looks like a real (but indistinguishable) // distribution. A CLOSED deep-lookback signal whose slow SMA length (60) equals the // fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus // identical metrics; that is a wrong result with no error (C10 refuse-don't-guess). let env = aura_runner::project::Env::std(); // slow len (60) == walk len -> never warms let mut g = GraphBuilder::new("deep_probe"); let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2))); let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(60))); let spread = g.add(Sub::builder()); let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); let price = g.source_role("price", ScalarKind::F64); g.feed(price, vec![fast.input("series"), slow.input("series")]); g.connect(fast.output("value"), spread.input("lhs")); g.connect(slow.output("value"), spread.input("rhs")); g.connect(spread.output("value"), exposure.input("signal")); g.expose(exposure.output("bias"), "bias"); let deep = g.build().expect("deep probe wiring resolves"); let doc = blueprint_to_json(&deep).expect("serializes"); let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env) .expect_err("a vacuous (all-identical) Monte-Carlo is rejected, not returned"); assert!( err.contains("vacuous") || err.contains("identical"), "names the vacuous/degenerate condition: {err}" ); } #[test] fn blueprint_mc_family_rejects_an_open_blueprint() { // Property: the mc family builder REFUSES an open blueprint (free knobs) by RETURNING // a named error — never a hidden process exit — so the closed-blueprint precondition // is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling // blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the // rejection pre-empts the downstream compile_with_params arity panic. let env = aura_runner::project::Env::std(); // both SMA knobs free -> non-empty param_space let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic, &env) .expect_err("an open blueprint is rejected, not run"); assert!(err.contains("closed blueprint"), "names the closed-blueprint requirement: {err}"); } #[test] fn reproduce_family_re_derives_every_member_bit_identically() { // a unique temp runs store so the on-disk family + blueprint store do not collide. let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-repro"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); let env = aura_runner::project::Env::std(); let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; // 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member. let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), ]; let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("axes resolve"); // persist exactly as run_blueprint_sweep does: store the blueprint, append the family. let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo"); let canonical = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap(); reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg .append_family("repro", FamilyKind::Sweep, &sweep_member_reports(&family)) .expect("append"); // reproduce: every member re-derives bit-identically (incl the open-at-end member). let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 2, "two members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), "every member re-derives bit-identically: {:?}", rep.outcomes ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn reproduce_re_derives_the_member_stop_regime_not_the_default() { // Property: reproduce re-runs each member under the SAME stop regime the member // was minted with (its manifest stamps stop_length/stop_k) — the stop defines // the risk unit R and is part of the member's identity (C1). A family minted // under a NON-default vol-stop regime (a campaign risk-regime cell, or wf/mc/ // generalize with --stop-length/--stop-k) must still reproduce bit-identically; // silently re-running it under the default Vol{3,2.0} reports a spurious DIVERGED. let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-repro-stop"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; // A CLOSED blueprint (both SMA knobs bound): its wrapped param_space is empty, so // the stop regime is the ONLY dimension that can differ between mint and reproduce // — isolating the defect to the hardcoded reproduce-side stop. let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); let topo = topology_hash(&reload()); let space = blueprint_axis_probe(&doc, &env).param_space(); let pip = data.pip_size(); let window = data.full_window(&env); // Mint one member under a NON-default vol-stop regime (default is length=3, k=2.0). // run_blueprint_member stamps stop_length=8/stop_k=4.0 into the manifest params. let non_default = StopRule::Vol { length: 8, k: 4.0 }; let binding = aura_runner::binding::resolve_binding("stoprepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let report = run_blueprint_member( reload(), &[], &space, data.run_sources(&env, &binding.columns()), window, 0, pip, &topo, &env, non_default, &binding, &[], "GER40", ); // persist exactly as the sweep/campaign paths do: store the blueprint, append the family. let canonical = blueprint_to_json(&reload()).unwrap(); reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg .append_family("stoprepro", FamilyKind::Sweep, &[report]) .expect("append"); // reproduce: the member re-derives bit-identically only if reproduce honours the // manifest's recorded stop regime — currently DIVERGED because reproduce hardcodes // the default Vol{3,2.0} for both the param-space probe and the member re-run. let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 1, "one member reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), "the non-default-stop member re-derives bit-identically: {:?}", rep.outcomes ); let _ = std::fs::remove_dir_all(&dir); } /// #234: a family minted under a cost model must reproduce bit-identically — /// reproduce re-derives the components from the member manifest (the #233 /// stop-regime pattern). Without the re-derivation the re-run joins an /// empty cost slice, its net_expectancy_r reverts to gross, and the member /// reports DIVERGED. All three variants ride along so every knob name /// round-trips (the vol_slippage component also exercises the reduce-mode /// vol proxy). #[test] fn reproduce_family_re_derives_a_costed_member_bit_identically() { let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-repro-cost"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); let topo = topology_hash(&reload()); let space = blueprint_axis_probe(&doc, &env).param_space(); let pip = data.pip_size(); let window = data.full_window(&env); let binding = aura_runner::binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let cost = vec![ aura_research::CostSpec::Constant { cost_per_trade: aura_research::CostValue::Scalar(0.0005), }, aura_research::CostSpec::VolSlippage { slip_vol_mult: aura_research::CostValue::Scalar(0.5), }, aura_research::CostSpec::Carry { carry_per_cycle: aura_research::CostValue::Scalar(0.0001), }, ]; let report = run_blueprint_member( reload(), &[], &space, data.run_sources(&env, &binding.columns()), window, 0, pip, &topo, &env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &cost, "GER40", ); // Non-vacuity: the cost model must actually bite, else a DIVERGED // verdict could never be observed and this pin proves nothing. let r = report.metrics.r.as_ref().expect("member carries R metrics"); assert_ne!(r.net_expectancy_r, r.expectancy_r, "the cost model must move net off gross"); let canonical = blueprint_to_json(&reload()).unwrap(); reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg.append_family("costrepro", FamilyKind::Sweep, &[report]).expect("append"); let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 1, "one member reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), "the costed member re-derives bit-identically: {:?}", rep.outcomes ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn reproduce_family_re_derives_every_mc_member_bit_identically() { let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-repro-mc"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); // a CLOSED signal (both SMA knobs bound) — MC binds no axis. let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); // persist exactly as run_blueprint_mc does: store the blueprint, append the MC family. let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo"); let canonical = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap(); reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg .append_family("mcrepro", FamilyKind::MonteCarlo, &mc_member_reports(&family)) .expect("append"); // reproduce: every MC member re-derives bit-identically (its seed-driven walk is // reconstructed from manifest.seed — the realization branch). let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 3, "three MC members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), "every MC member re-derives bit-identically: {:?}", rep.outcomes ); let _ = std::fs::remove_dir_all(&dir); } /// #246: a fully-bound (closed) blueprint IS sweepable — an axis naming a /// bound param re-opens it (the bound value is the default), so the retired /// "fully bound; nothing to sweep" refusal must not resurface. Inverse /// guard: an axis naming NEITHER an open nor a bound param gets the one /// clear boundary message (not a terse `UnknownKnob` debug leak). #[test] fn blueprint_sweep_family_overrides_a_bound_param_and_names_unknown_axes() { let env = aura_runner::project::Env::std(); // same fixture the retired test swept: both SMA knobs bound. let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); // (a) override axis: two members, no refusal let axes = vec![( "sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(4)], )]; let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) .expect("a bound param is a default — the axis overrides it"); assert_eq!(fam.points.len(), 2); // (b) unknown axis: the boundary message, no UnknownKnob leak let bad = vec![("sma_signal.nope".to_string(), vec![Scalar::i64(1)])]; let err = blueprint_sweep_family(&doc, &bad, &DataSource::Synthetic, &env) .expect_err("an axis matching neither space is refused"); assert!(err.contains("names no param"), "boundary message, got: {err}"); assert!(err.contains("--list-axes"), "must point at --list-axes: {err}"); assert!(!err.contains("UnknownKnob"), "must not leak the debug render: {err}"); } /// #249: an axis-reopened bound param flows through `params` ("what /// varied") and must NOT also appear in `defaults` ("what was held") — the /// two are disjoint by construction. Sweeping `fast.length` on the fully /// bound r_sma example leaves `slow.length`/`bias.scale` untouched (they /// stay in `defaults`), while `fast.length` moves to `params` and drops /// out of `defaults` entirely. #[test] fn sweep_override_excludes_the_reopened_default_from_the_manifest() { let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let axes = vec![("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)])]; let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env) .expect("a bound param is a default — the axis overrides it"); assert_eq!(fam.points.len(), 1); let manifest = &fam.points[0].report.manifest; let param_names: Vec<&str> = manifest.params.iter().map(|(n, _)| n.as_str()).collect(); assert!(param_names.contains(&"sma_signal.fast.length"), "swept axis is a param: {param_names:?}"); let default_names: Vec<&str> = manifest.defaults.iter().map(|(n, _)| n.as_str()).collect(); assert!( !default_names.contains(&"sma_signal.fast.length"), "the reopened default must not also appear in defaults: {default_names:?}" ); assert_eq!( default_names, ["sma_signal.slow.length", "sma_signal.bias.scale"], "the untouched bound params stay in defaults: {default_names:?}" ); } /// Property: an MC family's `aura reproduce` lines carry the member's own `seed=` /// label. MC members hold no tuning params (the params-join is empty), so the line would /// otherwise print a BLANK member label; the seed is each draw's realization identity and /// must show. Sweep / walk-forward labels still echo their params (covered elsewhere). #[test] fn reproduce_mc_member_labels_carry_the_seed() { let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-repro-mc-seed"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); let env = aura_runner::project::Env::std(); // MC binds no axis -> closed blueprint let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo"); let canonical = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap(); reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg .append_family("mcseed", FamilyKind::MonteCarlo, &mc_member_reports(&family)) .expect("append"); let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 3, "three MC members"); for (label, _) in &rep.outcomes { assert!( label.starts_with("seed="), "an MC reproduce label carries the seed, not a blank params-join: {label:?}" ); } let seen: HashSet<&str> = rep.outcomes.iter().map(|(l, _)| l.as_str()).collect(); assert!( seen.contains("seed=1") && seen.contains("seed=2") && seen.contains("seed=3"), "each MC draw's own seed appears: {seen:?}" ); let _ = std::fs::remove_dir_all(&dir); } /// Property: an f64 blueprint param survives the content-addressed store's /// serialize -> parse -> re-serialize round-trip **bit-identically**, so `aura /// reproduce` re-derives an f64-bearing member without DIVERGED. This is exactly /// what workspace `serde_json/float_roundtrip` buys: the constant below is a /// full-precision f64 (`0.12387080150408619`) that the DEFAULT serde_json parser /// mis-parses by 1 ULP — with `float_roundtrip` on it parses back exactly, so the /// canonical bytes are stable and the re-run reproduces. The i64-axis reproduce /// tests exercise a `scale=0.5` blueprint (exactly representable), so this is the /// only test that actually depends on the feature. #[test] fn f64_blueprint_param_survives_store_round_trip_bit_identically() { // 1-ULP canary for serde_json float_roundtrip: parses back off-by-one without it. const HARD_SCALE: f64 = 0.12387080150408619; // A one-node signal whose only knob is the non-short-decimal f64 scale. let mut g = GraphBuilder::new("scale_probe"); let bias = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(HARD_SCALE))); let signal = g.source_role("signal", ScalarKind::F64); g.feed(signal, vec![bias.input("signal")]); g.expose(bias.output("bias"), "bias"); let sig = g.build().expect("one-node bias signal wiring resolves"); // the store keeps canonical bytes verbatim (dumb bytes-by-key), so the f64 // fidelity lives entirely in this serialize -> parse -> re-serialize hop. let doc = blueprint_to_json(&sig).expect("serializes"); assert!( doc.contains("0.12387080150408619"), "canonical JSON carries the full-precision f64: {doc}" ); let reloaded = blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); let doc2 = blueprint_to_json(&reloaded).expect("re-serializes"); // Without float_roundtrip the reparsed scale drifts 1 ULP and ryu re-serializes // it to a different string, so these canonical bytes would differ. assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically"); } #[test] fn parse_param_cells_decodes_typed_cells_in_order_and_refuses_malformed() { // The property: `--params` round-trips the externally-tagged Scalar wire form in // array order, and a malformed array is refused with the flag named — never // silently dropped (a dropped cell would compile-bind a different graph). let cells = parse_param_cells("[{\"I64\":2},{\"F64\":0.5}]").expect("valid cell array"); assert_eq!(cells, vec![Scalar::I64(2), Scalar::F64(0.5)]); assert!(parse_param_cells("[]").expect("empty array").is_empty()); let err = parse_param_cells("{not an array}").unwrap_err(); assert!(err.contains("--params"), "a malformed --params value names the flag: {err}"); } /// `topology_hash` is deterministic per signal and distinguishes topologies — /// the #158 reproducibility-anchor property. #[test] fn topology_hash_is_stable_and_distinguishes() { let closed = load_closed_r_sma(); let open = load_open_r_sma(); let h = topology_hash(&closed); assert_eq!(h, topology_hash(&closed), "same signal -> same hash"); assert_ne!(h, topology_hash(&open), "distinct blueprints -> distinct hash"); // Same shape as the closed example, differing only in the BOUND fast-SMA // length (3 vs 2): the hash must still discriminate on bound param values, // not merely on topology shape — distinct sweep/mc members must key to // distinct store entries. let mut g = GraphBuilder::new("sma_signal"); let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(3))); let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4))); let spread = g.add(Sub::builder()); let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); let price = g.source_role("price", ScalarKind::F64); g.feed(price, vec![fast.input("series"), slow.input("series")]); g.connect(fast.output("value"), spread.input("lhs")); g.connect(slow.output("value"), spread.input("rhs")); g.connect(spread.output("value"), exposure.input("signal")); g.expose(exposure.output("bias"), "bias"); let bound_variant = g.build().expect("bound-value probe wiring resolves"); assert_ne!( h, topology_hash(&bound_variant), "same shape, different bound value -> different hash" ); } /// #158 acc 1 (content-id stability across the store round-trip): a blueprint's content /// id survives serialize -> reload -> re-serialize (#164 idempotence) — the property /// content-addressed reproduction rests on (the stored bytes re-hash to the members' /// topology_hash). `content_id` is the shared primitive `topology_hash` uses. #[test] fn content_id_is_stable_across_the_store_round_trip() { let closed = load_closed_r_sma(); let json = blueprint_to_json(&closed).expect("serializes"); let id = content_id(&json); assert_eq!(id.len(), 64, "a 64-hex sha256"); let reloaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("reloads"); assert_eq!( content_id(&blueprint_to_json(&reloaded).expect("re-serializes")), id, "content id survives serialize -> reload -> re-serialize (#164)" ); } /// #158 acc 3 (Tier-1 format addition leaves the content id unchanged): a Tier-1 /// optional field the blueprint does not use is tolerated by the loader (#156) and /// absent from the canonical omit-defaults form, so re-serializing yields the same /// bytes and thus the same content id. #[test] fn content_id_is_stable_across_a_tolerated_tier1_field() { let closed = load_closed_r_sma(); let base = blueprint_to_json(&closed).expect("serializes"); // a future Tier-1 optional field injected at the top level (the blueprint does not use it). let with_extra = base.replacen("{\"format_version\":1,", "{\"format_version\":1,\"future_optional\":123,", 1); assert_ne!(base, with_extra, "the doc actually carries the extra field"); let reparsed = blueprint_from_json(&with_extra, &|t| std_vocabulary(t)) .expect("loader tolerates an unknown Tier-1 field (#156)"); assert_eq!( content_id(&blueprint_to_json(&reparsed).expect("re-serializes")), content_id(&base), "a Tier-1 optional the blueprint does not use leaves the content id unchanged" ); } /// #158 acc 1 (cross-surface agreement): `topology_hash` (from a live `Composite`, the /// run/sweep path) and the op-script `graph introspect --content-id` surface are the /// SAME hash of the SAME canonical bytes — both go through the one `content_id` /// primitive. Pins that the two command paths cannot silently drift apart. #[test] fn topology_hash_is_the_content_id_of_the_canonical_form() { let sig = load_closed_r_sma(); assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes"))); } /// The op-script twin of the closed r-sma example (`load_closed_r_sma`): same /// topology — SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` /// BOUND to 0.5 (boundness is identity-bearing) — differing only in debug names /// (composite "graph" vs "sma_signal", unnamed Bias vs `.named("bias")`). const IDENTITY_TWIN_DOC: &str = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}, {"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}}, {"op":"add","type":"Sub"}, {"op":"add","type":"Bias","bind":{"scale":{"F64":0.5}}}, {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"}, {"op":"connect","from":"slow.value","to":"sub.rhs"}, {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; /// #171 acc 1 (cross-path identity): the Rust `sma_signal` builder and its /// op-script twin differ in canonical bytes (debug names) — distinct content /// ids — but project to the same identity JSON, hence one identity id across /// authoring paths. #[test] fn identity_id_bridges_the_rust_builder_and_op_script_paths() { let rust_built = load_closed_r_sma(); let env = aura_runner::project::Env::std(); let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env) .expect("op-script twin builds"); assert_ne!( content_id(&json), topology_hash(&rust_built), "authoring paths keep distinct content ids" ); let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads"); let identity = |c: &Composite| { content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes")) }; assert_eq!( identity(&loaded), identity(&rust_built), "one topology -> one identity id across authoring paths" ); } #[test] fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() { // Property: the `mc_r_bootstrap` output line is the full RBootstrap shape — // each field is named and value-faithful, so a renamed/dropped field here // breaks a test instead of silently shipping. Pins the user-visible wire // shape of the mc R-bootstrap render (the parser + engine primitive are // covered elsewhere; this is the output-shape layer). let boot = aura_backtest::r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7); let line = mc_r_bootstrap_json(&boot); let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line"); let obj = &v["mc_r_bootstrap"]; assert_eq!(obj["n_trades"], serde_json::json!(boot.n_trades)); assert_eq!(obj["block_len"], serde_json::json!(boot.block_len)); assert_eq!(obj["n_resamples"], serde_json::json!(boot.n_resamples)); assert_eq!(obj["prob_le_zero"], serde_json::json!(boot.prob_le_zero)); // e_r is the nested MetricStats block (mean + quantiles), not a flat scalar. assert_eq!(obj["e_r"], serde_json::to_value(&boot.e_r).expect("MetricStats serializes")); assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}"); } /// A bare `McCmd` with every optional field defaulted to `None`/empty, so each /// `mc_args_from` refusal test below only sets the fields its scenario needs. fn bare_mc_cmd() -> McCmd { McCmd { blueprint: None, real: None, from: None, to: None, name: None, trace: None, axis: Vec::new(), stop_length: None, stop_k: None, block_len: None, resamples: None, seed: None, seeds: None, } } #[test] fn mc_args_from_refuses_without_a_real_symbol() { let a = bare_mc_cmd(); let err = mc_args_from(&a).unwrap_err(); assert!(err.contains("dissolves only over --real"), "refuse message: {err}"); } #[test] fn mc_args_from_refuses_an_empty_real_symbol() { let a = McCmd { real: Some(String::new()), ..bare_mc_cmd() }; let err = mc_args_from(&a).unwrap_err(); assert!(err.contains("dissolves only over --real"), "refuse message: {err}"); } #[test] fn mc_args_from_refuses_name_and_trace() { let a = McCmd { real: Some("GER40".to_string()), name: Some("a".to_string()), trace: Some("b".to_string()), ..bare_mc_cmd() }; let err = mc_args_from(&a).unwrap_err(); assert!(err.contains("--name/--trace are not accepted"), "refuse message: {err}"); } /// Property (#217): a missing `--stop-length`/`--stop-k` no longer refuses — /// each independently defaults to the single-sourced [`R_SMA_STOP_LENGTH`]/ /// [`R_SMA_STOP_K`] regime (length 3, k 2.0). #[test] fn mc_args_from_defaults_missing_knobs_to_the_regime() { let a = McCmd { real: Some("GER40".to_string()), ..bare_mc_cmd() }; let (_, _, stop_length, stop_k, ..) = mc_args_from(&a).expect("stop-less mc resolves"); assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime"); assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime"); } /// Property (#217): a missing `--stop-length`/`--stop-k` no longer refuses — /// each independently defaults to the single-sourced [`R_SMA_STOP_LENGTH`]/ /// [`R_SMA_STOP_K`] regime (length 3, k 2.0). #[test] fn generalize_args_from_defaults_missing_knobs_to_the_regime() { let a = GeneralizeCmd { blueprint: Some("candidate.json".to_string()), real: Some("GER40,USDJPY".to_string()), axis: vec!["k=1".to_string()], stop_length: None, stop_k: None, from: None, to: None, metric: None, name: None, }; let (_, _, stop_length, stop_k, ..) = generalize_args_from(&a).expect("stop-less generalize resolves"); assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime"); assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime"); } }