4474814fa7
Architect drift review (cycle a3785a6..b3b7115) found the cycle's raw switch incomplete on two high items; both fixed forward here rather than minuted as residue, since #319 builds on this namespace: - The real/campaign executor still minted WRAPPED manifest.params and manifest.defaults (AC2 overclaim in the feat commit body). The mint seams now reshape to raw on every route (member.rs raw_bound_defaults, runner.rs manifest_space mirror of the family.rs pattern); the fix surfaced a latent regression in persist_campaign_traces, which keyed point_from_params by exact wrapped name and is now translation-aware (same recipe as reproduce_family_in). Old on-disk families stay replayable (C29; reproduce reads both shapes). - The walkforward --axis intake silently accepted wrapped names — the silent alias fork 2 rejected. It now runs the same refuse_wrapped_synthetic_axes preflight as the plain synthetic sweep (new e2e: aura_walkforward_synthetic_blueprint_refuses_a_wrapped_form _axis_name); family builders translate raw names onto wrapped SweepBinder slots on all three entry points. C24's "retired from the surface" claim is thereby made true instead of softened. - Low items: stale graph_construct docstring updated; new graph_params_and_sweep_list_axes_are_line_identical pins the two discovery surfaces against format drift. - Bench infra: aura-bench's campaign_sweep surface seeded wrapped axis literals and was correctly refused by the new intake — seeds converted to raw; full bench rerun: all 5 surfaces fingerprint OK, deltas within load noise (report-only, no baseline update needed). Library-unit tests calling family builders directly keep wrapped input (valid: translation is idempotent on exact wrapped names; intake-level refusal is the user contract). Suite + clippy verified green post-fix. refs #328
4062 lines
186 KiB
Rust
4062 lines
186 KiB
Rust
//! `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, RunData};
|
|
use aura_runner::{TapPlan, TapSubscription};
|
|
#[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;
|
|
use std::collections::BTreeSet;
|
|
#[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<i64> = Vec::with_capacity(2 * buckets);
|
|
for b in 0..buckets {
|
|
let lo = b * n / buckets;
|
|
let hi = (b + 1) * n / buckets;
|
|
if lo >= hi {
|
|
continue;
|
|
}
|
|
let two = hi - lo > 1;
|
|
out_xs.push(xs[lo]);
|
|
if two {
|
|
out_xs.push(xs[hi - 1]);
|
|
}
|
|
bounds.push((lo, hi, two));
|
|
}
|
|
|
|
let out_series: Vec<Series> = series
|
|
.into_iter()
|
|
.map(|s| {
|
|
let mut points: Vec<Option<f64>> = Vec::with_capacity(out_xs.len());
|
|
for &(lo, hi, two) in &bounds {
|
|
let (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<f64>` over xs.
|
|
fn build_chart_data(name: &str, traces: RunTraces) -> ChartData {
|
|
let mut xs: Vec<i64> = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect();
|
|
xs.sort_unstable();
|
|
xs.dedup();
|
|
|
|
let spine: Vec<(Timestamp, Vec<Scalar>)> = xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
|
|
let tap_rows: Vec<Vec<(Timestamp, Vec<Scalar>)>> = traces.taps.iter().map(|t| t.to_rows()).collect();
|
|
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> = tap_rows.iter().map(|r| r.as_slice()).collect();
|
|
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
|
|
|
|
let mut series: Vec<Series> = 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<Option<f64>> =
|
|
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<Scalar>)>);
|
|
|
|
/// 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<ChartData, String> {
|
|
let mut member_rows: Vec<MemberRows> = 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<i64> =
|
|
member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect();
|
|
xs.sort_unstable();
|
|
xs.dedup();
|
|
|
|
let spine: Vec<(Timestamp, Vec<Scalar>)> =
|
|
xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
|
|
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> =
|
|
member_rows.iter().map(|(_, r)| r.as_slice()).collect();
|
|
let joined: Vec<JoinedRow> = 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<Series> = 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<Option<f64>> =
|
|
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<ChartData, String> {
|
|
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<Series> = 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 <name> [--tap <t>] [--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 <NAME>` argument against the recorded
|
|
/// campaign runs (#238): `NAME` is not a trace-store handle, but it may be the
|
|
/// `--trace <NAME>` 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<String>),
|
|
/// 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<Selection, ()> {
|
|
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<f64> {
|
|
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<aura_engine::ParamSpec> = 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<f64> = 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<serde_json::Value> = 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 <id> [rank <metric>]`: 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<RunReport> = 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 <blueprint.json> --list-axes`: one `<name>:<kind>` line per open
|
|
/// sweepable knob, in `param_space()` order (byte-identical to before #246),
|
|
/// followed by one `<name>:<kind> default=<value>` 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 RAW `<node>.<param>` paths (#328: the wrapped `<blueprint>.<node>.<param>`
|
|
/// form is retired from the surface) — 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 {
|
|
// Strip the wrapper's one leading node segment (#328): open names print
|
|
// RAW, matching `--axis`'s own accepted namespace.
|
|
println!("{}:{:?}", aura_runner::axes::wrapped_to_raw_axis(&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");
|
|
for b in signal.bound_param_space() {
|
|
// `bound_param_space()`'s `.name` is already RAW (#203) — no blueprint-name
|
|
// concatenation (#328: the blueprint name stays out of axis paths, C23).
|
|
println!("{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
|
}
|
|
}
|
|
|
|
/// The one-sentence prose for a refused WRAPPED `--axis` name (#328), shared
|
|
/// by both intake routes so the wording carried by `refuse_wrapped_synthetic_axes`
|
|
/// (the synthetic-sweep preflight) and `validate_and_register_axes` (the
|
|
/// real-route/campaign preflight) cannot drift into two copies. Returns the
|
|
/// message WITHOUT the leading `aura: ` — each caller prepends that itself
|
|
/// (the synthetic route via its own `eprintln!`, the real route via
|
|
/// `exit_axis_register_error`'s shared `eprintln!("aura: {m}")`), so the
|
|
/// rendered bytes on both routes stay byte-identical.
|
|
fn wrapped_axis_refusal(n: &str, raw: &str) -> String {
|
|
format!(
|
|
"axis \"{n}\": axis names are raw node.param paths — \
|
|
use \"{raw}\" (the wrapped --list-axes form was retired, #328)"
|
|
)
|
|
}
|
|
|
|
/// `--axis` intake preflight for the SYNTHETIC (no `--real`) blueprint-sweep
|
|
/// route (#328): this route bypasses `validate_and_register_axes` entirely (no
|
|
/// project/registry touched), so it needs its own copy of the same WRAPPED-name
|
|
/// refusal — the shared [`aura_runner::axes::classify_axis_intake`] predicate,
|
|
/// intercepting only a `WrappedRetired` hit (echoing the translation pointer,
|
|
/// exit 2, before the sweep ever runs). A name matching neither namespace is
|
|
/// deliberately left unrefused HERE: `blueprint_sweep_family`'s own
|
|
/// `override_paths` rejects it downstream with today's unchanged prose, so this
|
|
/// preflight does not duplicate that message under a second wording.
|
|
fn refuse_wrapped_synthetic_axes(
|
|
doc: &str,
|
|
env: &aura_runner::project::Env,
|
|
axes: &[(String, Vec<Scalar>)],
|
|
) {
|
|
let wrapped_open = blueprint_axis_probe(doc, env).param_space();
|
|
let raw_bound: HashSet<String> = blueprint_from_json(doc, &|t| env.resolve(t))
|
|
.expect("doc parse-validated at the dispatch boundary")
|
|
.bound_param_space()
|
|
.into_iter()
|
|
.map(|b| b.name)
|
|
.collect();
|
|
for (n, _) in axes {
|
|
if let aura_runner::axes::AxisIntake::WrappedRetired(raw) =
|
|
aura_runner::axes::classify_axis_intake(n, &wrapped_open, &raw_bound)
|
|
{
|
|
eprintln!("aura: {}", wrapped_axis_refusal(n, &raw));
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `aura sweep <blueprint.json> --axis <name>=<csv> …`: 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<Scalar>)], 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 <blueprint.json> --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<Scalar>)], 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 <blueprint.json> --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<Vec<Scalar>, 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<Vec<Scalar>> {
|
|
csv.split(',').map(|t| {
|
|
let t = t.trim();
|
|
if t.is_empty() { return None; }
|
|
match t.parse::<i64>() {
|
|
Ok(i) => Some(Scalar::i64(i)),
|
|
Err(_) => t.parse::<f64>().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 (<commit>)` as a process-lifetime `&'static str` — computed once
|
|
/// (clap's `version` builder method wants `Into<Str>`, and `clap::builder::Str`
|
|
/// (clap 4.6) only implements `From<&'static str>`, not `From<String>`, 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<String> = 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 (<commit>)`.
|
|
/// 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<String>,
|
|
/// 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<std::path::PathBuf>,
|
|
/// Vocabulary namespace override.
|
|
#[arg(long)]
|
|
namespace: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[command(subcommand)]
|
|
sub: Option<GraphSub>,
|
|
}
|
|
|
|
#[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,
|
|
/// Label the registered content id for `use` by name (#317); a
|
|
/// re-registered label repoints (latest-wins on resolve).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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,
|
|
/// List every registered blueprint label (#317): label, id prefix, root doc.
|
|
#[arg(long)]
|
|
registered: 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<Option<std::path::PathBuf>>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct GeneralizeCmd {
|
|
/// The candidate blueprint (.json, required) — graded across instruments.
|
|
blueprint: Option<String>,
|
|
/// Comma-separated instrument list (>=2 distinct, required).
|
|
#[arg(long)]
|
|
real: Option<String>,
|
|
/// Candidate axis `<name>=<value>` (repeatable, >=1; one value per axis).
|
|
#[arg(long)]
|
|
axis: Vec<String>,
|
|
/// Candidate stop length (single value; optional, defaults to
|
|
/// [`R_SMA_STOP_LENGTH`]).
|
|
#[arg(long)]
|
|
stop_length: Option<i64>,
|
|
/// Candidate stop-k multiple (single value; optional, defaults to
|
|
/// [`R_SMA_STOP_K`]).
|
|
#[arg(long)]
|
|
stop_k: Option<f64>,
|
|
/// Window start (Unix ms, inclusive).
|
|
#[arg(long)]
|
|
from: Option<i64>,
|
|
/// Window end (Unix ms, inclusive).
|
|
#[arg(long)]
|
|
to: Option<i64>,
|
|
/// Grading metric (default expectancy_r).
|
|
#[arg(long)]
|
|
metric: Option<String>,
|
|
/// Family name (default generalize).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// The metric to rank by (only valid after `rank`).
|
|
metric: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
/// Blueprint params (JSON scalar-cell array; .json mode).
|
|
#[arg(long)]
|
|
params: Option<String>,
|
|
/// Blueprint seed (.json mode).
|
|
#[arg(long)]
|
|
seed: Option<u64>,
|
|
/// Real instrument symbol to backtest over (recorded data); omit for the synthetic stream.
|
|
#[arg(long)]
|
|
real: Option<String>,
|
|
/// Window start (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
from: Option<i64>,
|
|
/// Window end (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
to: Option<i64>,
|
|
/// Not accepted — CLI-side trace persistence is retired (see #224).
|
|
#[arg(long)]
|
|
trace: Option<String>,
|
|
/// Subscribe a declared tap to a fold for this run (repeatable,
|
|
/// TAP=FOLD; e.g. --tap signal=mean). Replaces the record-all
|
|
/// default: only listed taps are bound, unlisted taps stay unbound.
|
|
/// Fold roster: `aura graph introspect --folds`.
|
|
#[arg(long = "tap", value_name = "TAP=FOLD")]
|
|
tap: Vec<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct SweepCmd {
|
|
/// A loaded blueprint (.json); omit for the built-in --strategy grammar.
|
|
blueprint: Option<String>,
|
|
/// Legacy `--strategy` selector: no built-in value remains (use a blueprint).
|
|
/// Retired tokens fall to the generic usage error.
|
|
#[arg(long)]
|
|
strategy: Option<String>,
|
|
/// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream.
|
|
#[arg(long)]
|
|
real: Option<String>,
|
|
/// Window start (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
from: Option<i64>,
|
|
/// Window end (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
to: Option<i64>,
|
|
/// Family name (records to the registry without persisting per-member traces).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Family name that also persists each member's taps (--real mode; mutually
|
|
/// exclusive with --name).
|
|
#[arg(long)]
|
|
trace: Option<String>,
|
|
/// Blueprint sweep axis `<name>=<csv>` (repeatable; .json mode).
|
|
#[arg(long)]
|
|
axis: Vec<String>,
|
|
/// 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<String>,
|
|
/// Real instrument symbol to validate over (recorded data); omit for the synthetic stream.
|
|
#[arg(long)]
|
|
real: Option<String>,
|
|
/// Window start (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
from: Option<i64>,
|
|
/// Window end (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
to: Option<i64>,
|
|
/// Family name (records to the registry without persisting per-member traces).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Family name that also persists each OOS window's taps (--real mode;
|
|
/// mutually exclusive with --name).
|
|
#[arg(long)]
|
|
trace: Option<String>,
|
|
/// Campaign-path stop length (--real mode).
|
|
#[arg(long)]
|
|
stop_length: Option<i64>,
|
|
/// Campaign-path stop-k multiple (--real mode).
|
|
#[arg(long)]
|
|
stop_k: Option<f64>,
|
|
/// In-sample winner selection: argmax | plateau (alias for plateau:mean) |
|
|
/// plateau:mean | plateau:worst (default argmax).
|
|
#[arg(long)]
|
|
select: Option<String>,
|
|
/// Blueprint IS-refit axis `<name>=<csv>` (repeatable, >=1 required; .json mode).
|
|
#[arg(long)]
|
|
axis: Vec<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct McCmd {
|
|
/// A loaded blueprint (.json); omit for the built-in grammar.
|
|
blueprint: Option<String>,
|
|
/// Real instrument symbol for the R-bootstrap campaign path (recorded data); omit
|
|
/// for the synthetic seed family.
|
|
#[arg(long)]
|
|
real: Option<String>,
|
|
/// Window start (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
from: Option<i64>,
|
|
/// Window end (Unix ms, inclusive); requires --real.
|
|
#[arg(long)]
|
|
to: Option<i64>,
|
|
/// Family name for the synthetic seed-resweep (records without persisting traces).
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Not accepted with --real — the R-bootstrap records without a family name.
|
|
#[arg(long)]
|
|
trace: Option<String>,
|
|
/// Blueprint IS-refit axis `<name>=<csv>` (repeatable, >=1 required; --real mode).
|
|
#[arg(long)]
|
|
axis: Vec<String>,
|
|
/// Campaign-path stop length (--real mode).
|
|
#[arg(long)]
|
|
stop_length: Option<i64>,
|
|
/// Campaign-path stop-k multiple (--real mode).
|
|
#[arg(long)]
|
|
stop_k: Option<f64>,
|
|
/// Moving-block bootstrap block length (R-bootstrap; default 1).
|
|
#[arg(long)]
|
|
block_len: Option<usize>,
|
|
/// Number of bootstrap resamples (R-bootstrap; default 1000).
|
|
#[arg(long)]
|
|
resamples: Option<usize>,
|
|
/// Bootstrap RNG seed (R-bootstrap; default 1).
|
|
#[arg(long)]
|
|
seed: Option<u64>,
|
|
/// Number of synthetic draws (required; .json mode).
|
|
#[arg(long)]
|
|
seeds: Option<u64>,
|
|
}
|
|
|
|
/// 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<String>) -> 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<i64>, to: Option<i64>) -> RunData {
|
|
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]";
|
|
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<i64>`/`Option<f64>` (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<i64>, Option<i64>), String> {
|
|
let symbol = match a.real.as_deref() {
|
|
None | Some("") => return Err("walkforward dissolves only over --real <SYMBOL>".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<i64>`/`Option<f64>` (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<i64>, Option<i64>), String> {
|
|
let symbol = match a.real.as_deref() {
|
|
None | Some("") => return Err("mc dissolves only over --real <SYMBOL>".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<i64>`/`Option<f64>` 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<String>, i64, f64, String, Option<i64>, Option<i64>), String> {
|
|
let symbols: Vec<String> = match a.real.as_deref() {
|
|
None => return Err("generalize requires --real <SYM1,SYM2,...> — a comma list of two or more instruments".to_string()),
|
|
Some(v) => {
|
|
let parts: Vec<String> = 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<i64>,
|
|
to: Option<i64>,
|
|
usage: &impl Fn() -> String,
|
|
) -> Result<DataChoice, String> {
|
|
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 <name>=<csv>` 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<Vec<(String, Vec<Scalar>)>, String> {
|
|
let mut axes: Vec<(String, Vec<Scalar>)> = 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 RAW campaign-axis namespace only
|
|
/// (#328: `--list-axes`'s own namespace, open-or-bound) — a WRAPPED name (the
|
|
/// retired `<blueprint>.<node>.<param>` form) is refused with a translation
|
|
/// pointer to its raw candidate; a name in neither namespace gets today's
|
|
/// unmatched-axis prose, unchanged. [`aura_runner::axes::classify_axis_intake`]
|
|
/// is the shared predicate (deliberately not `raw_matches_wrapped`, whose own
|
|
/// equality branch would silently accept a wrapped name — see its doc
|
|
/// comment). Then canonicalize + register the blueprint by topology hash — no
|
|
/// further strip: the axes are already raw by the time this returns them (the
|
|
/// old wrapped->raw strip is gone, since a splice path's own dots, e.g.
|
|
/// `anchor.sess.period_minutes`, must not be mangled a second time). 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<Scalar>)],
|
|
env: &aura_runner::project::Env,
|
|
) -> Result<(String, Vec<(String, Vec<Scalar>)>), AxisRegisterError> {
|
|
let wrapped_open = 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 raw_bound: HashSet<String> =
|
|
blueprint.bound_param_space().into_iter().map(|b| b.name).collect();
|
|
for (n, _) in axes {
|
|
match aura_runner::axes::classify_axis_intake(n, &wrapped_open, &raw_bound) {
|
|
aura_runner::axes::AxisIntake::Raw => {}
|
|
aura_runner::axes::AxisIntake::WrappedRetired(raw) => {
|
|
return Err(AxisRegisterError::UnknownAxis(wrapped_axis_refusal(n, &raw)));
|
|
}
|
|
aura_runner::axes::AxisIntake::Unknown => {
|
|
return Err(AxisRegisterError::UnknownAxis(format!(
|
|
"axis \"{n}\" is not one of this blueprint's \
|
|
sweepable axes — run 'aura sweep <bp> --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()))?;
|
|
Ok((canonical, axes.to_vec()))
|
|
}
|
|
|
|
/// 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<usize, String>) {
|
|
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),
|
|
)
|
|
}
|
|
|
|
/// Build the run's tap plan from repeated `--tap TAP=FOLD` selections
|
|
/// (#310). No selections → the record-all default (today's behaviour).
|
|
/// Any selection → an explicit plan that REPLACES the default entirely:
|
|
/// only listed taps are bound; unlisted declared taps stay unbound,
|
|
/// which C27 defines as inert, not an error. Tap/label existence is
|
|
/// bind_tap_plan's to validate (roster refusal / UndeclaredTap), before
|
|
/// any store I/O.
|
|
fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
|
|
if args.is_empty() {
|
|
return Ok(TapPlan::record_all());
|
|
}
|
|
let mut plan = TapPlan::empty();
|
|
let mut seen = BTreeSet::new();
|
|
for raw in args {
|
|
let (tap, label) = match raw.split_once('=') {
|
|
Some((t, l)) if !t.is_empty() && !l.is_empty() => (t, l),
|
|
_ => return Err(format!("--tap expects TAP=FOLD, got \"{raw}\"")),
|
|
};
|
|
if !seen.insert(tap.to_string()) {
|
|
return Err(format!("--tap names tap \"{tap}\" twice"));
|
|
}
|
|
plan.subscribe(tap, TapSubscription::named(label));
|
|
}
|
|
Ok(plan)
|
|
}
|
|
|
|
/// `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/--tap;
|
|
// 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 <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]");
|
|
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();
|
|
let tap_plan = match tap_plan_from_args(&a.tap) {
|
|
Ok(p) => p,
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(2);
|
|
}
|
|
};
|
|
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, tap_plan);
|
|
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, tap_plan);
|
|
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 <blueprint.json> [--params <json-cell-array>] \
|
|
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] \
|
|
[--tap <TAP=FOLD> …]"
|
|
);
|
|
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 [<blueprint.json>] [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, name }) => graph_construct::register_cmd(&file, name.as_deref(), env),
|
|
}
|
|
}
|
|
|
|
fn dispatch_generalize(a: GeneralizeCmd, env: &aura_runner::project::Env) {
|
|
let usage = || format!("Usage: aura generalize <blueprint.json> --real <SYM1,SYM2[,…]> --axis <name>=<value> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--metric <m>] [--name <n>] [--from <ms>] [--to <ms>]");
|
|
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 <id> [rank <metric>]");
|
|
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<Scalar>) 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<str>]) -> Vec<String> {
|
|
if symbols.is_empty() {
|
|
return vec!["no symbols".to_string()];
|
|
}
|
|
symbols.iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
/// `aura data coverage <SYMBOL>` (#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 <blueprint.json> --axis <name>=<csv> [--axis …] [--name <n> | --trace <n>] [--real <SYM> [--from <ms>] [--to <ms>]]".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 (RAW-only, #328) + canonicalize/register 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);
|
|
}
|
|
// #328: this route bypasses `validate_and_register_axes` (no
|
|
// project/registry involved), so it needs its own WRAPPED-name
|
|
// refusal ahead of the sweep.
|
|
refuse_wrapped_synthetic_axes(&doc, env, &axes);
|
|
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 <blueprint.json> --axis <name>=<csv> [--axis …] [--select <argmax|plateau|plateau:mean|plateau:worst>] [--name <n>] | aura walkforward <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--from <ms>] [--to <ms>] [--name <n> | --trace <n>]");
|
|
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 <blueprint.json> 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 (RAW-only, #328) + canonicalize/register 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 (#220 non-goal, unchanged beyond
|
|
// the #328 tidy below); 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());
|
|
// #328: this route bypasses `validate_and_register_axes` (no
|
|
// project/registry involved, same gap the plain synthetic sweep
|
|
// arm had) — the same WRAPPED-name preflight closes it here too.
|
|
refuse_wrapped_synthetic_axes(&doc, env, &axes);
|
|
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 <blueprint.json> --seeds <n> [--name <n>] | aura mc <blueprint.json> --real <SYMBOL> --axis <name>=<csv> [--axis …] [--stop-length <n> (default {R_SMA_STOP_LENGTH})] [--stop-k <x> (default {R_SMA_STOP_K:.1})] [--block-len <n>] [--resamples <n>] [--seed <n>] [--from <ms>] [--to <ms>]");
|
|
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 (RAW-only, #328) + canonicalize/register 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<std::sync::Arc<str>> = 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<std::sync::Arc<str>> =
|
|
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<Scalar>)> = rx_eq.try_iter().collect();
|
|
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
|
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
|
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = 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<Scalar>)> =
|
|
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<i64> = (0..n as i64).collect();
|
|
let points: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64)).collect();
|
|
let data = ChartData {
|
|
xs,
|
|
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, 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<i64> = (0..10).collect();
|
|
let mut pv = vec![1.0_f64; 10];
|
|
pv[3] = 999.0;
|
|
pv[7] = -50.0;
|
|
let points: Vec<Option<f64>> = pv.into_iter().map(Some).collect();
|
|
let data = ChartData {
|
|
xs,
|
|
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, 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<i64> = (0..10).collect();
|
|
let mut points: Vec<Option<f64>> = (0..5).map(|i| Some(i as f64)).collect();
|
|
points.extend(std::iter::repeat_n(None, 5));
|
|
let data = ChartData {
|
|
xs,
|
|
series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, 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<i64> = (0..n as i64).collect();
|
|
let points: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64)).collect();
|
|
let meta = ChartMeta { name: "keep-me".into(), ..Default::default() };
|
|
let data = ChartData { xs, series: vec![Series { name: "equity".into(), y_scale_id: "y_0".into(), points, 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<i64> = (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<Option<f64>> = 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<Scalar>)> =
|
|
[1i64, 2, 3].iter().map(|&t| (Timestamp(t), vec![Scalar::f64(t as f64)])).collect();
|
|
let traces = RunTraces {
|
|
manifest: sim_optimal_manifest(
|
|
vec![("len".into(), Scalar::i64(10)), ("scale".into(), Scalar::f64(0.5))],
|
|
(Timestamp(1), Timestamp(3)),
|
|
7,
|
|
1.0,
|
|
),
|
|
taps: vec![ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &eq_rows)],
|
|
};
|
|
let data = build_chart_data("demo", traces);
|
|
let meta = &data.meta;
|
|
assert_eq!(meta.kind, "run");
|
|
assert_eq!(meta.name, "demo");
|
|
assert_eq!(meta.window, (1, 3));
|
|
assert_eq!(meta.broker, "sim-optimal(pip_size=1)");
|
|
assert_eq!(meta.seed, 7);
|
|
assert_eq!(meta.taps, vec!["equity".to_string()]);
|
|
assert_eq!(meta.members, None);
|
|
// params stringified via render_value: typed Scalars keep their lexical form.
|
|
assert_eq!(
|
|
meta.params,
|
|
vec![("len".to_string(), "10".to_string()), ("scale".to_string(), "0.5".to_string())]
|
|
);
|
|
}
|
|
|
|
/// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in
|
|
/// 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<Box<dyn aura_engine::Source>> = 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 <SYMBOL>"
|
|
);
|
|
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<_>>(), 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/#328: a fully-bound (closed) blueprint IS sweepable — a RAW 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 (RAW name, #328): two members, no refusal
|
|
let axes = vec![(
|
|
"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![("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/#328: 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` (RAW, #328) 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. Both `manifest.params`
|
|
/// and `manifest.defaults` keys are RAW (#328, batch 2 flips defaults too).
|
|
#[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![("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(&"fast.length"), "swept axis is a raw param: {param_names:?}");
|
|
|
|
let default_names: Vec<&str> = manifest.defaults.iter().map(|(n, _)| n.as_str()).collect();
|
|
assert!(
|
|
!default_names.contains(&"fast.length"),
|
|
"the reopened default must not also appear in defaults: {default_names:?}"
|
|
);
|
|
assert_eq!(
|
|
default_names,
|
|
["slow.length", "bias.scale"],
|
|
"the untouched bound params stay in defaults, RAW: {default_names:?}"
|
|
);
|
|
}
|
|
|
|
/// Property: an MC family's `aura reproduce` lines carry the member's own `seed=<N>`
|
|
/// 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"
|
|
);
|
|
}
|
|
|
|
/// #271 (cross-path identity, extending #171's bridge pattern to an
|
|
/// arg-bearing type): the Rust-authored `Session::configured` builder and
|
|
/// its `add`-op `args` twin differ in canonical bytes (debug names) —
|
|
/// distinct content ids — but project to the same identity JSON, one
|
|
/// identity id across authoring paths even though the construction
|
|
/// channel (Rust call vs `args` object) differs.
|
|
#[test]
|
|
fn identity_id_bridges_the_rust_configured_session_and_args_script() {
|
|
use aura_market::Session;
|
|
|
|
let rust_built = Composite::new(
|
|
"sess",
|
|
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
|
vec![],
|
|
vec![aura_engine::Role {
|
|
name: "trigger".into(),
|
|
targets: vec![aura_engine::Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![aura_engine::OutField { node: 0, field: 0, name: "bars".into() }],
|
|
);
|
|
|
|
let doc = r#"[
|
|
{"op":"source","role":"trigger","kind":"F64"},
|
|
{"op":"add","type":"Session","name":"sess",
|
|
"args":{"tz":"Europe/Berlin","open":"09:30"},
|
|
"bind":{"period_minutes":{"I64":15}}},
|
|
{"op":"feed","role":"trigger","into":["sess.trigger"]},
|
|
{"op":"expose","from":"sess.bars_since_open","as":"bars"}
|
|
]"#;
|
|
let env = aura_runner::project::Env::std();
|
|
let json = crate::graph_construct::build_from_str(doc, &env).expect("args op-script twin builds");
|
|
assert_ne!(
|
|
content_id(&json),
|
|
content_id(&blueprint_to_json(&rust_built).expect("serializes")),
|
|
"authoring paths keep distinct content ids (debug names differ)"
|
|
);
|
|
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, Rust-configured vs args op-script"
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[test]
|
|
fn tap_plan_from_args_empty_is_record_all_ok() {
|
|
assert!(tap_plan_from_args(&[]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn tap_plan_from_args_refuses_a_pair_without_equals() {
|
|
let err = match tap_plan_from_args(&["fast_tapmean".to_string()]) {
|
|
Err(e) => e,
|
|
Ok(_) => panic!("expected an error"),
|
|
};
|
|
assert_eq!(err, "--tap expects TAP=FOLD, got \"fast_tapmean\"");
|
|
}
|
|
|
|
#[test]
|
|
fn tap_plan_from_args_refuses_empty_tap_or_label() {
|
|
assert!(tap_plan_from_args(&["=mean".to_string()]).is_err());
|
|
assert!(tap_plan_from_args(&["fast_tap=".to_string()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tap_plan_from_args_refuses_the_same_tap_twice() {
|
|
let args = vec!["a=mean".to_string(), "a=last".to_string()];
|
|
let err = match tap_plan_from_args(&args) {
|
|
Err(e) => e,
|
|
Ok(_) => panic!("expected an error"),
|
|
};
|
|
assert_eq!(err, "--tap names tap \"a\" twice");
|
|
}
|
|
|
|
#[test]
|
|
fn tap_plan_from_args_accepts_distinct_selections() {
|
|
let args = vec!["a=mean".to_string(), "b=record".to_string()];
|
|
assert!(tap_plan_from_args(&args).is_ok());
|
|
}
|
|
}
|
|
|