From db8f9474410d10e63beb762d2628cc7687e7b65f Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 21:36:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-cli)!:=20retire=20the=20research-verb?= =?UTF-8?q?=20sugar=20=E2=80=94=20run/sweep/walkforward/mc/generalize=20re?= =?UTF-8?q?moved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 6 of the #319 retirement, the destructive half. The five flag verbs, their five Args structs and dispatch arms, the whole argv->document translator (verb_sugar.rs, 1257 lines) and 33 quintet-only helpers are gone; retired verbs now refuse at the clap layer as unknown commands. main.rs shrinks 4445 -> 2764 lines. The gated-intake route list narrows to the canonical layer + exec's blueprint leg; exec's own refusal prose no longer names dead verbs; test seeding recipes ride graph register. cli_run.rs walked the full disposition table: 94 tests retired with their dead flag surface (each with a named green twin where the property survives), 75 ported — exec ports keep assertions byte-identical, campaign ports inherit their golden grades unchanged (walkforward's 90/30/30 real roller and the archive window clipping the sugar applied silently are now explicit document fields — the equivalence held exactly, one reconstructed walkforward pooled expectancy_r within 1e-12, cause documented inline). Surface deltas recorded by the ports: per-cell refusals are #272 contained faults (exit 3 + warning) rather than hard exit 1; the synthetic per-seed mc family and generalize's merged cross-instrument family record were sugar-only constructs and retire with it (the campaign record's generalizations[] carries the data). refs #319 --- crates/aura-cli/src/campaign_run.rs | 21 +- crates/aura-cli/src/diag.rs | 10 +- crates/aura-cli/src/graph_construct.rs | 75 +- crates/aura-cli/src/main.rs | 1767 +----- crates/aura-cli/src/verb_sugar.rs | 1257 ---- crates/aura-cli/tests/cli_broken_pipe.rs | 44 +- crates/aura-cli/tests/cli_run.rs | 6991 +++++++--------------- crates/aura-cli/tests/exec.rs | 461 +- crates/aura-cli/tests/project_load.rs | 92 +- crates/aura-cli/tests/research_docs.rs | 102 +- 10 files changed, 2703 insertions(+), 8117 deletions(-) delete mode 100644 crates/aura-cli/src/verb_sugar.rs diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index de3026c..9f772b8 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -108,17 +108,13 @@ struct CampaignRunLine<'a> { campaign_run: &'a CampaignRunRecord, } -/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated -/// member/selection lines + the always-on final `campaign_run` record line). -/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's -/// `emit: ["family_table"]` already limits emission to member lines; the mode -/// additionally suppresses the final record line so the verb's stdout -/// contract is reproduced byte-for-byte. The record append is identical in -/// both modes — presentation changes, the record does not. +/// Stdout shape of a campaign run: emit-gated member/selection lines + the +/// always-on final `campaign_run` record line. `MemberLinesOnly` (the +/// dissolved-verb sugar path's suppressed-record-line mode) retired with the +/// quintet (#319 Task 8) — `Full` is the only surviving shape. #[derive(Clone, Copy, PartialEq)] pub(crate) enum RunPresentation { Full, - MemberLinesOnly, } /// `aura campaign run `: resolve, gate, execute, emit. Every `Err` @@ -184,12 +180,9 @@ pub(crate) struct CampaignRun { /// The one campaign executor path from a resolved content id onward: fetch /// the stored canonical bytes by id (so file addressing and id addressing /// produce the same realization by construction) and re-run the intrinsic -/// tier on them, execute, and emit per `presentation`. Shared by -/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path -/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no -/// project gate here: the sugar path must work project-less exactly as the -/// inline verb it replaces did (store mechanics run against `env.registry()` -/// in both cases). +/// tier on them, execute, and emit per `presentation`. Called by +/// `run_campaign` (`RunPresentation::Full`, the sole surviving shape since +/// the quintet's dissolved-verb sugar path retired, #319 Task 8). pub(crate) fn run_campaign_by_id( campaign_id: &str, env: &Env, diff --git a/crates/aura-cli/src/diag.rs b/crates/aura-cli/src/diag.rs index 1be3c04..67e1538 100644 --- a/crates/aura-cli/src/diag.rs +++ b/crates/aura-cli/src/diag.rs @@ -32,11 +32,11 @@ fn zero_trade_note_text(n_windows: usize) -> String { } } -/// The #313 zero-trade note, shared by both walk-forward paths (the -/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in -/// `verb_sugar.rs`) so the wording AND the condition live in exactly one -/// place. Takes the per-window trade counts; a no-op unless there is at -/// least one window and every one of them traded zero times. +/// The #313 zero-trade note (#319 Task 5: migrated onto the campaign +/// walk-forward leg in `campaign_run.rs`, the surviving executor path) so +/// the wording AND the condition live in exactly one place. Takes the +/// per-window trade counts; a no-op unless there is at least one window and +/// every one of them traded zero times. pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator) { let n_windows = window_trades.len(); if n_windows > 0 && window_trades.all(|n| n == 0) { diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 60a9a68..3d4908a 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -805,41 +805,6 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::projec } } -/// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc` -/// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest -/// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single- -/// sourced here so the three dual-grammar subcommands cannot diverge (#184's -/// convention, extended). Two refusals, checked in order: -/// -/// 1. The document parses as JSON but is an op-script ARRAY (#157), not a -/// built blueprint envelope — refused with a targeted hint pointing at -/// `aura graph build`, since `blueprint_from_json` would otherwise surface -/// a confusing internal serde shape mismatch. -/// 2. `blueprint_from_json` fails to load the envelope — phrased house-style -/// via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it -/// applies), never the raw `LoadError` Debug form. -/// -/// `Ok(())` means the document loaded cleanly; callers that only need the -/// validation (not the `Composite`) re-parse `doc` themselves afterward -/// (`blueprint_from_json` is cheap and infallible at that point). -pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> { - if matches!(serde_json::from_str::(doc), Ok(serde_json::Value::Array(_))) { - return Err( - "this is an op-script (an op array), not a built blueprint — run \ - 'aura graph build' first" - .to_string(), - ); - } - blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| { - let mut msg = blueprint_load_prose(&e); - if let Some(hint) = unresolved_namespace_hint(&e, env) { - msg.push_str(" — "); - msg.push_str(&hint); - } - msg - }) -} - /// #196: build a `Composite` from a document that is EITHER a #155 blueprint /// envelope (a JSON object: `format_version` + `blueprint`) OR a construction /// op-list (a JSON array) — shape-discriminated on the top-level JSON type, @@ -893,34 +858,22 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> /// function — C29's "registered artifacts are never retroactively /// invalidated" stays intact. /// -/// **Deliberate exceptions — direct `gate_authored_root_name` callers -/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized -/// `traces//` seam (or `put_blueprint` the loaded envelope straight -/// into the registry) without going through this wrapper, because each +/// **Deliberate exception — the one direct `gate_authored_root_name` caller +/// (main.rs).** One fresh-FILE intake reaches the same unsanitized +/// `traces//` seam without going through this wrapper, because it /// already parses the document its own way and only needs the shared root- /// name gate bolted on, not the shape-discrimination `composite_from_any` /// does: -/// - `aura run ` (`dispatch_run`): envelope-only grammar (no -/// op-script fallback), feeds `signal.name()` into +/// - `exec ` (`exec_blueprint_leg`, #319): envelope-only +/// grammar (no op-script fallback), feeds `signal.name()` into /// `run_signal_r`/`run_measurement` -> `bind_tap_plan` -> /// `TraceStore::begin_run`. -/// - `validate_and_register_axes` (shared by `generalize`, -/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s -/// the loaded envelope by topology hash before any of those four verbs -/// touches an archive. -/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc` -/// (the synthetic, no-`--real` family builders): same `put_blueprint` -/// reason, on the routes that bypass `validate_and_register_axes` -/// entirely (#331 cycle-close — these used to plant an ungated envelope -/// in the store; see each fn's own comment). -/// - `list_blueprint_axes` (`aura sweep --list-axes`, main.rs): no -/// registry write and no trace directory here, but a shape-violating root -/// name otherwise mangles through `wrapped_to_raw_axis` into a printed, -/// non-bindable axis name instead of refusing (#331 fieldtest finding -/// c331_2e) — the class rule is every CLI intake reading an authored -/// envelope from a file gates the root name, not only the writing ones. /// -/// All of these share the identical `name_gate` + `name_gate_fault_prose` +/// (The quintet's other direct callers — `dispatch_run`, +/// `validate_and_register_axes`, `run_blueprint_sweep`/`_walkforward`/`_mc`, +/// `list_blueprint_axes` — retired with the quintet, #319 Task 8.) +/// +/// This shares the identical `name_gate` + `name_gate_fault_prose` /// primitives this wrapper uses, so the refusal wording is byte-identical /// across every authored-file-intake route even though the shape- /// discrimination step is not shared by them. @@ -934,10 +887,10 @@ pub(crate) fn composite_from_authored_text( } /// The root-name shape gate itself (#331 delta re-review), factored out of -/// [`composite_from_authored_text`] so `dispatch_run`'s narrower-grammar file -/// intake (see that fn's doc comment) can share the exact `name_gate` call and -/// `name_gate_fault_prose` wording without going through the shape-discriminating -/// wrapper. +/// [`composite_from_authored_text`] so `exec_blueprint_leg`'s narrower-grammar +/// file intake (see that fn's doc comment) can share the exact `name_gate` +/// call and `name_gate_fault_prose` wording without going through the +/// shape-discriminating wrapper. pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> { name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault)) } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index fc1abce..bc4b512 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1,7 +1,7 @@ //! `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. +//! data (#159): `aura exec` (#319) loads a campaign document or a serialized +//! signal blueprint and prints canonical JSON metrics/manifests; this binary +//! authors no built-in harness. mod diag; mod render; @@ -9,7 +9,6 @@ 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}; @@ -18,33 +17,28 @@ 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, + blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, 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, -}; +use aura_registry::{group_families, rank_by, FamilyMember, NameKind, RunTraces}; +// `FamilyKind`/`mc_member_reports`/`sweep_member_reports`/`PlateauMode` are only +// reached from the test module's family-builder reproduce-fixture tests (#319 +// Task 8 — the quintet's own production callers of these are retired); the +// imports are test-only, mirroring `Bias` below. +#[cfg(test)] +use aura_registry::{mc_member_reports, sweep_member_reports, FamilyKind, PlateauMode}; #[cfg(test)] use aura_registry::Registry; -use aura_backtest::{ - fit_wf_ms_sizes, intersect_shared_window, r_metrics_from_rs, - McAggregate, RBootstrap, RunReport, - WalkForwardResult, -}; +use aura_backtest::RunReport; #[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, override_paths, reopen_all, run_signal_r, RunData, @@ -52,13 +46,16 @@ use aura_runner::member::{ 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}; +// The family builders (blueprint sweep / walk-forward / MC) and the shared +// `DataSource` data provider / `--select` objective `Selection` live in +// `aura_runner::family` (#295 Task 8); with the quintet's own production +// callers retired (#319 Task 8), they are only reached from the test +// module's family-builder tests now (Task 9 owns their eventual removal) — +// the imports are test-only, mirroring `Bias` below. +#[cfg(test)] +use aura_runner::family::{ + blueprint_mc_family, blueprint_sweep_family, blueprint_walkforward_family, 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 @@ -73,6 +70,10 @@ use aura_runner::measure::run_measurement; // presentation (reproduce output, `--list-axes` default printing) render a // scalar identically. use aura_runner::runner::render_value; +// Only reached from the test module's stop-regime default tests now (#319 +// Task 8 — the quintet's own production callers are retired); test-only, +// mirroring `Bias` below. +#[cfg(test)] 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 @@ -107,21 +108,13 @@ use aura_strategy::Bias; use aura_std::Sma; #[cfg(test)] use std::sync::mpsc; +#[cfg(test)] 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. @@ -493,28 +486,6 @@ fn resolve_campaign_name(name: &str, env: &aura_runner::project::Env) -> NameRes // `aura_runner::family` (#295 Task 8) — imported below by plain name so // call sites are unchanged. -/// Parse a `--select` token: `argmax` | `plateau` (alias for `plateau:mean`) | -/// `plateau:mean` | `plateau:worst`. Unknown tokens are a usage error (the -/// caller maps `Err(())` to exit 2). -fn parse_select(s: &str) -> Result { - match s { - "argmax" => Ok(Selection::Argmax), - "plateau" | "plateau:mean" => Ok(Selection::Plateau(PlateauMode::Mean)), - "plateau:worst" => Ok(Selection::Plateau(PlateauMode::Worst)), - _ => Err(()), - } -} - -/// Map the CLI `--select` value to the research selection rule the campaign -/// document carries. -fn select_rule_of(sel: Selection) -> aura_research::SelectRule { - match sel { - Selection::Argmax => aura_research::SelectRule::Argmax, - Selection::Plateau(PlateauMode::Mean) => aura_research::SelectRule::PlateauMean, - Selection::Plateau(PlateauMode::Worst) => aura_research::SelectRule::PlateauWorst, - } -} - /// Render a family-member stdout line: the assigned `family_id` plus the embedded /// `RunReport`. The report is emitted in its own declaration key order (manifest /// leads with `commit`, C18) so the line is byte-identical to the stored @@ -529,177 +500,6 @@ fn family_member_line(id: &str, report: &RunReport) -> String { ) } -/// Monte-Carlo variant of [`family_member_line`]: the per-draw line also carries the -/// realization `seed` (between `family_id` and `report`) — the shape `run_blueprint_mc` prints. -fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { - format!( - r#"{{"family_id":{},"seed":{},"report":{}}}"#, - serde_json::to_string(id).expect("a string id always serializes"), - seed, - report.to_json() - ) -} - -/// Pool every OOS window's per-trade R series into one flat vector, in roll order -/// (window order, then within-window trade order). Windows with no `r` block -/// contribute nothing. The single home of the pooling-in-roll-order semantics — -/// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this. -fn pooled_oos_net_trade_rs(result: &WalkForwardResult) -> Vec { - result - .windows - .iter() - .flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.net_trade_rs.clone()).unwrap_or_default()) - .collect() -} - -/// The walk-forward summary line: window count, stitched OOS total pips (the last -/// stitched-curve value), and the on-demand per-param stability. Canonical JSON -/// (C14). -fn walkforward_summary_json(result: &WalkForwardResult) -> String { - let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0); - let pooled_rs = pooled_oos_net_trade_rs(result); - let mut obj = serde_json::json!({ - "windows": result.windows.len(), - "stitched_total_pips": total, - "param_stability": param_stability(result), - }); - if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) { - // RMetrics serializes its scalar fields (net_trade_rs is serde-skipped, so the - // oos_r block is the clean R-metric summary of the pooled series). - obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) - .expect("RMetrics serializes"); - } - serde_json::json!({ "walkforward": obj }).to_string() -} - -/// The walk-forward summary line reconstructed from the recorded per-window OOS -/// reports (the campaign path's `WalkForward` `StageFamily.reports`) rather than a -/// live `WalkForwardResult`. `stitched_total_pips` = the per-window `total_pips` -/// summed left-to-right in roll order (the engine `stitch` folds each segment's -/// final cumulative value, and a window's OOS segment ends at its `total_pips`); -/// `param_stability` reduces each IS-refit axis in `axes` over the per-window -/// chosen params (read from each report's `manifest.params` via -/// [`aura_runner::axes::raw_matches_wrapped`] — blueprint axes are recorded wrapped -/// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while -/// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match -/// would miss the wrapped ones) through the same `MetricStats::from_values`; the -/// `oos_r` block pools the per-window `net_trade_rs` through `r_metrics_from_rs`. -/// Canonical JSON (C14). -/// -/// `axes` carries the invocation's raw axis names in argv order, followed by the -/// stop columns when a regime is bound (#220 — no axis name is hardcoded here). -/// Order matters: the committed exact-grade anchor pins -/// `param_stability[0].mean` = the first axis's refit mean. -fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) -> String { - let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum(); - // Reconstructs the minimal `WalkForwardResult` shape `aura_engine::param_stability` - // actually reads (`space` + `windows[*].run.chosen_params`) from the recorded - // reports, so this path shares the ONE per-axis coercion+reduction with - // `walkforward_summary_json` (line ~578) instead of a second copy of it. - // `bounds`/`oos_equity` play no role in the reduction (cheap placeholders); - // `oos_report` carries the already-owned report along. - let space: Vec = axes - .iter() - .map(|axis| { - let (_, v) = reports[0] - .manifest - .params - .iter() - .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) - .expect("each walk-forward window records its chosen axis"); - aura_engine::ParamSpec { name: axis.clone(), kind: v.kind() } - }) - .collect(); - let windows: Vec<_> = reports - .iter() - .map(|r| { - let chosen_params = axes - .iter() - .map(|axis| { - r.manifest - .params - .iter() - .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) - .expect("each walk-forward window records its chosen axis") - .1 - .cell() - }) - .collect(); - aura_engine::WindowOutcome { - bounds: aura_engine::WindowBounds { - is: (Timestamp(0), Timestamp(0)), - oos: (Timestamp(0), Timestamp(0)), - }, - run: aura_engine::WindowRun { chosen_params, oos_equity: Vec::new(), oos_report: r.clone() }, - } - }) - .collect(); - let result = aura_engine::WalkForwardResult { space, windows, stitched_oos_equity: Vec::new() }; - let stability = param_stability(&result); - let pooled_rs: Vec = reports - .iter() - .flat_map(|r| r.metrics.r.as_ref().map(|m| m.net_trade_rs.clone()).unwrap_or_default()) - .collect(); - let mut obj = serde_json::json!({ - "windows": reports.len(), - "stitched_total_pips": total, - "param_stability": stability, - }); - if reports.iter().any(|r| r.metrics.r.is_some()) { - obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) - .expect("RMetrics serializes"); - } - serde_json::json!({ "walkforward": obj }).to_string() -} - -/// The cross-instrument generalization line: the chosen metric, instrument count, -/// worst-case floor, sign-agreement count, and the per-instrument breakdown. Canonical -/// JSON (C14), mirroring `walkforward_summary_json`'s `{"generalize": obj}` shape. -fn generalize_json(agg: &Generalization) -> String { - let per: Vec = agg - .per_instrument - .iter() - .map(|(sym, v)| serde_json::json!([sym, v])) - .collect(); - let obj = serde_json::json!({ - "metric": agg.selection_metric, - "n_instruments": agg.n_instruments, - "worst_case": agg.worst_case, - "sign_agreement": agg.sign_agreement, - "per_instrument": per, - }); - serde_json::json!({ "generalize": obj }).to_string() -} - -/// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not -/// `Serialize` (only its `MetricStats` fields are), so the line is built from the -/// three per-metric stat blocks. -fn mc_aggregate_json(agg: &McAggregate) -> String { - serde_json::json!({ - "mc_aggregate": { - "total_pips": agg.total_pips, - "max_drawdown": agg.max_drawdown, - "bias_sign_flips": agg.bias_sign_flips, - } - }) - .to_string() -} - -/// Render an `RBootstrap` as one canonical JSON line (`MetricStats` serializes; the -/// scalar fields are spliced in), mirroring `mc_aggregate_json`. -fn mc_r_bootstrap_json(b: &RBootstrap) -> String { - serde_json::json!({ - "mc_r_bootstrap": { - "n_trades": b.n_trades, - "block_len": b.block_len, - "n_resamples": b.n_resamples, - "e_r": b.e_r, - "prob_le_zero": b.prob_le_zero, - } - }) - .to_string() -} - /// `aura runs families`: one header line per stored family (id, kind, member /// count), in first-seen store order. fn runs_families(env: &aura_runner::project::Env) { @@ -784,6 +584,17 @@ fn content_id(canonical_json: &str) -> String { /// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a /// signal blueprint — the run's `topology_hash` (#158). +/// +/// #319 Task 8 finding: this primitive's only production caller was +/// `validate_and_register_axes` (retired with the quintet); `exec`'s own +/// record line gets its `manifest.topology_hash` from `aura_runner::member`'s +/// own internal computation, never through this fn. It is kept alive +/// (production-visible, not test-gated) per the retirement plan's explicit +/// instruction, and still exercised directly by the family-builder-range +/// unit tests below (Task 9 territory) — hence `#[allow(dead_code)]` rather +/// than deletion. Orchestrator: confirm whether `exec` should route through +/// this fn after all, or whether the KEEP was based on a stale assumption. +#[allow(dead_code)] fn topology_hash(signal: &Composite) -> String { content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) } @@ -819,266 +630,6 @@ impl IcReport { /// are RAW `.` paths (#328: the wrapped `..` /// form is retired from the surface) — exactly what `--axis` binds. /// -/// **Gated (#331 fieldtest finding).** This is a FRESH-FILE intake exactly like -/// `register` / `introspect --content-id ` / `introspect --params ` -/// (`composite_from_authored_text`'s doc comment lists the class): a hand-edited -/// envelope with a shape-violating root name (`"../x"`) must be refused before -/// any axis line prints, not mangled through `wrapped_to_raw_axis` into a -/// non-bindable name. Gated immediately after load, before any axis derivation -/// — same placement discipline as the other sites — at exit 1, matching the -/// register-route convention (this route never `put_blueprint`s anything, -/// unlike the exit-2 sweep-EXECUTION route `run_blueprint_sweep` gates). -fn list_blueprint_axes(doc: &str, env: &aura_runner::project::Env) { - let signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); - if let Err(msg) = graph_construct::gate_authored_root_name(signal.name()) { - eprintln!("aura: {msg}"); - std::process::exit(1); - } - 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 - } - 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)], -) { - let wrapped_open = blueprint_axis_probe(doc, env).param_space(); - let raw_bound: HashSet = 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 --axis = …`: sweep a loaded signal over its -/// named param-space axes (the cycle-2 World/C21 verb). Builds the family via -/// [`blueprint_sweep_family`], surfaces an unknown / kind-mismatched axis as a named -/// error (stderr + exit 2, never a panic), ALWAYS records it as a `FamilyKind::Sweep` -/// family (C18/C21 lineage, exactly as the other family verbs), and prints each member -/// carrying the assigned `family_id` via [`family_member_line`] — so a printed member is -/// linkable back to its stored family, like `run_blueprint_walkforward` / `run_blueprint_mc`. -/// -/// Divergence from the retired `run_sweep` (the one place this does less): the blueprint sweep is -/// reduce-only this cycle — [`blueprint_sweep_family`] writes no per-member traces — so -/// `persist`/`--trace` neither writes trace files nor reserves a trace-store name (that -/// reservation would guard a write that never happens, and could spuriously reject a -/// valid sweep on a name collision). `persist` is therefore not yet load-bearing here; -/// it is retained for the deferred per-member trace path. The family record itself is -/// written unconditionally, so lineage (C18/C21) holds whether or not `--trace` is given. -/// -/// Synthetic-only: real-data invocations route through -/// `verb_sugar::run_sweep_sugar` at the dispatch boundary and never reach -/// this fn. -fn run_blueprint_sweep( - doc: &str, axes: &[(String, Vec)], name: &str, persist: bool, data: DataSource, - env: &aura_runner::project::Env, -) { - let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional - // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE - // envelope into the registry below — gate the root name up front, - // before the family ever builds (a shape-violating name can otherwise - // confuse the wrapped/raw axis resolution the family build performs, - // producing a misleading unrelated error instead of this refusal; see - // `gate_authored_root_name`'s doc comment for the full route list). - let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); - if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - 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).expect("a loaded blueprint re-serializes"); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - // Record the family unconditionally (C18/C21 lineage), exactly like - // `run_blueprint_walkforward` / `run_blueprint_mc`. - let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { - Ok(id) => id, - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }; - for pt in &family.points { - println!("{}", family_member_line(&id, &pt.report)); - } -} - -/// `aura walkforward --axis …`: build the loaded-blueprint -/// IS-refit walk-forward, store the canonical blueprint ONCE keyed by the shared -/// `topology_hash` (the C18 hook, so `aura reproduce` re-derives it), record it as -/// a `FamilyKind::WalkForward` family, and print each OOS member line + the summary. -/// Mirrors `run_blueprint_sweep` (content-addressed family verb; no ensure_name_free). -fn run_blueprint_walkforward( - doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection, - env: &aura_runner::project::Env, -) { - // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE - // envelope into the registry below — gate the root name up front, - // before the family ever builds (see `run_blueprint_sweep`'s identical - // comment on why this must precede the family build, not just the - // registry write). - let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"); - if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - 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).expect("a loaded blueprint re-serializes"); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); - let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { - Ok(id) => id, - Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } - }; - for w in &result.windows { - println!("{}", family_member_line(&id, &w.run.oos_report)); - } - crate::diag::note_zero_trade_windows( - result - .windows - .iter() - .map(|w| w.run.oos_report.metrics.r.as_ref().map_or(0, |m| m.n_trades)), - ); - println!("{}", walkforward_summary_json(&result)); -} - -/// `aura mc --seeds N`: build a Monte-Carlo family from a loaded CLOSED -/// blueprint (the World/C21 verb), store the canonical blueprint ONCE keyed by the shared -/// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a -/// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line -/// (carrying the seed) plus the aggregate — mirroring `run_blueprint_sweep`. -fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &aura_runner::project::Env) { - // #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE - // envelope into the registry below — gate the root name up front, - // before the family ever builds (see `run_blueprint_sweep`'s identical - // comment on why this must precede the family build, not just the - // registry write). - let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); - if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - 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).expect("a loaded blueprint re-serializes"); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { - Ok(id) => id, - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }; - for draw in &family.draws { - println!("{}", mc_member_line(&id, draw.seed, &draw.report)); - } - println!("{}", mc_aggregate_json(&family.aggregate)); -} - -/// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells -/// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells -/// bind positionally against the loaded signal's `param_space`. A malformed array is -/// refused with the flag named, never silently coerced to an empty param vector -/// (refuse-don't-guess, C10) — a dropped param would bootstrap a *different* graph. -fn parse_param_cells(json: &str) -> Result, String> { - serde_json::from_str(json).map_err(|e| format!("--params: {e}")) -} - -/// Lex a `--axis` CSV into typed Scalars by shape: an integer-shaped token is i64, -/// otherwise f64. `resolve_axes` kind-checks each value against the param's declared -/// kind afterwards (a mismatch is a named error, not a panic). -fn parse_scalar_csv(csv: &str) -> Option> { - csv.split(',').map(|t| { - let t = t.trim(); - if t.is_empty() { return None; } - match t.parse::() { - Ok(i) => Some(Scalar::i64(i)), - Err(_) => t.parse::().ok().map(Scalar::f64), - } - }).collect() -} - /// Lex `exec --override NODE.PARAM=VALUE` tokens (#319 Task 3/4): the same /// int-then-float rule `parse_scalar_csv` uses for `--axis` values, shared by /// both `exec` legs (the blueprint leg's reopen recipe and the campaign @@ -1189,45 +740,10 @@ enum Command { /// Execute a document: a campaign (file or content id) or a signal /// blueprint (single run). Exec(ExecCmd), - /// 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. @@ -1394,38 +910,6 @@ struct GraphIntrospectCmd { params: Option, } -#[derive(Args)] -struct GeneralizeCmd { - /// The candidate blueprint (.json, required) — graded across instruments. - blueprint: Option, - /// Comma-separated instrument list (>=2 distinct, required). - #[arg(long)] - real: Option, - /// Candidate axis `=` (repeatable, >=1; one value per axis). - #[arg(long)] - axis: Vec, - /// Candidate stop length (single value; optional, defaults to - /// [`R_SMA_STOP_LENGTH`]). - #[arg(long)] - stop_length: Option, - /// Candidate stop-k multiple (single value; optional, defaults to - /// [`R_SMA_STOP_K`]). - #[arg(long)] - stop_k: Option, - /// Window start (Unix ms, inclusive). - #[arg(long)] - from: Option, - /// Window end (Unix ms, inclusive). - #[arg(long)] - to: Option, - /// Grading metric (default expectancy_r). - #[arg(long)] - metric: Option, - /// Family name (default generalize). - #[arg(long)] - name: Option, -} - #[derive(Args)] struct RunsCmd { #[command(subcommand)] @@ -1476,147 +960,6 @@ struct ExecCmd { tap: Vec, } -#[derive(Args)] -struct RunCmd { - /// A serialized signal blueprint (.json). An existing file selects the - /// loaded-blueprint grammar; otherwise the built-in harness grammar. - blueprint: Option, - /// Blueprint params (JSON scalar-cell array; .json mode). - #[arg(long)] - params: Option, - /// Blueprint seed (.json mode). - #[arg(long)] - seed: Option, - /// Real instrument symbol to backtest over (recorded data); omit for the synthetic stream. - #[arg(long)] - real: Option, - /// Window start (Unix ms, inclusive); requires --real. - #[arg(long)] - from: Option, - /// Window end (Unix ms, inclusive); requires --real. - #[arg(long)] - to: Option, - /// Not accepted — CLI-side trace persistence is retired (see #224). - #[arg(long)] - trace: Option, - /// 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, -} - -#[derive(Args)] -struct SweepCmd { - /// A loaded blueprint (.json); omit for the built-in --strategy grammar. - blueprint: Option, - /// Legacy `--strategy` selector: no built-in value remains (use a blueprint). - /// Retired tokens fall to the generic usage error. - #[arg(long)] - strategy: Option, - /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. - #[arg(long)] - real: Option, - /// Window start (Unix ms, inclusive); requires --real. - #[arg(long)] - from: Option, - /// Window end (Unix ms, inclusive); requires --real. - #[arg(long)] - to: Option, - /// Family name (records to the registry without persisting per-member traces). - #[arg(long)] - name: Option, - /// Family name that also persists each member's taps (--real mode; mutually - /// exclusive with --name). - #[arg(long)] - trace: Option, - /// Blueprint sweep axis `=` (repeatable; .json mode). - #[arg(long)] - axis: Vec, - /// List a loaded blueprint's sweepable axes and exit (.json mode, stands alone). - #[arg(long)] - list_axes: bool, -} - -#[derive(Args)] -struct WalkforwardCmd { - /// A loaded blueprint (.json, required — both grammars are blueprint-first). - blueprint: Option, - /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. - #[arg(long)] - real: Option, - /// Window start (Unix ms, inclusive); requires --real. - #[arg(long)] - from: Option, - /// Window end (Unix ms, inclusive); requires --real. - #[arg(long)] - to: Option, - /// Family name (records to the registry without persisting per-member traces). - #[arg(long)] - name: Option, - /// Family name that also persists each OOS window's taps (--real mode; - /// mutually exclusive with --name). - #[arg(long)] - trace: Option, - /// Campaign-path stop length (--real mode). - #[arg(long)] - stop_length: Option, - /// Campaign-path stop-k multiple (--real mode). - #[arg(long)] - stop_k: Option, - /// In-sample winner selection: argmax | plateau (alias for plateau:mean) | - /// plateau:mean | plateau:worst (default argmax). - #[arg(long)] - select: Option, - /// Blueprint IS-refit axis `=` (repeatable, >=1 required; .json mode). - #[arg(long)] - axis: Vec, -} - -#[derive(Args)] -struct McCmd { - /// A loaded blueprint (.json); omit for the built-in grammar. - blueprint: Option, - /// Real instrument symbol for the R-bootstrap campaign path (recorded data); omit - /// for the synthetic seed family. - #[arg(long)] - real: Option, - /// Window start (Unix ms, inclusive); requires --real. - #[arg(long)] - from: Option, - /// Window end (Unix ms, inclusive); requires --real. - #[arg(long)] - to: Option, - /// Family name for the synthetic seed-resweep (records without persisting traces). - #[arg(long)] - name: Option, - /// Not accepted with --real — the R-bootstrap records without a family name. - #[arg(long)] - trace: Option, - /// Blueprint IS-refit axis `=` (repeatable, >=1 required; --real mode). - #[arg(long)] - axis: Vec, - /// Campaign-path stop length (--real mode). - #[arg(long)] - stop_length: Option, - /// Campaign-path stop-k multiple (--real mode). - #[arg(long)] - stop_k: Option, - /// Moving-block bootstrap block length (R-bootstrap; default 1). - #[arg(long)] - block_len: Option, - /// Number of bootstrap resamples (R-bootstrap; default 1000). - #[arg(long)] - resamples: Option, - /// Bootstrap RNG seed (R-bootstrap; default 1). - #[arg(long)] - seed: Option, - /// Number of synthetic draws (required; .json mode). - #[arg(long)] - seeds: Option, -} - /// The dual-grammar discriminator: a first-positional that names an existing /// `.json` file selects the loaded-blueprint branch. Single-sourced so the /// four dual-grammar subcommands stay in lockstep. @@ -1640,288 +983,6 @@ fn is_campaign_document_file(path: &str) -> bool { .is_some_and(|v| v.get("kind").is_some()) } -/// Resolve a `[blueprint].json`-branch `--real`/`--from`/`--to` into a `RunData`, -/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to` -/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2). -fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunData { - let usage = "Usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]] [--tap …]"; - match real { - Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to }, - Some(_) => { - eprintln!("aura: {usage}"); - std::process::exit(2); - } - None if from.is_some() || to.is_some() => { - eprintln!("aura: {usage}"); - std::process::exit(2); - } - None => RunData::Synthetic, - } -} - -/// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar -/// consumes (the dissolved `.json --real` branch). Single instrument, single-value -/// stop (Fork A: the stop is a risk regime, not a swept axis); `--stop-length`/ -/// `--stop-k` are typed `Option`/`Option` (clap rejects a multi-value -/// spelling itself, "invalid value") and each OPTIONAL (#217): a missing flag -/// defaults independently to the single-sourced [`R_SMA_STOP_LENGTH`]/ -/// [`R_SMA_STOP_K`] regime. `--name`/`--trace` are mutually exclusive, matching -/// the flag's own documented contract and the inline path's `name_persist` -/// refusal; an omitted flag defaults the family name to "walkforward". The -/// IS-refit `--axis` grid is parsed separately at the dispatch site, not here. -#[allow(clippy::type_complexity)] -fn walkforward_args_from( - a: &WalkforwardCmd, -) -> Result<(String, bool, String, i64, f64, Option, Option), String> { - let symbol = match a.real.as_deref() { - None | Some("") => return Err("walkforward dissolves only over --real ".to_string()), - Some(s) => s.to_string(), - }; - let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); - let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); - let (name, trace) = match (a.name.as_deref(), a.trace.as_deref()) { - (Some(_), Some(_)) => { - return Err("walkforward: --name and --trace are mutually exclusive".to_string()); - } - (Some(n), None) => (n.to_string(), false), - (None, Some(t)) => (t.to_string(), true), - (None, None) => ("walkforward".to_string(), false), - }; - Ok((name, trace, symbol, stop_length, stop_k, a.from, a.to)) -} - -/// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the -/// `--real` campaign branch). Single instrument, single-value stop (Fork A: the -/// stop is a risk regime, not a swept axis); `--stop-length`/`--stop-k` are typed -/// `Option`/`Option` (clap rejects a multi-value spelling itself, -/// "invalid value") and each OPTIONAL (#217), independently defaulting to the -/// single-sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted. -/// `--block-len`/`--resamples`/`--seed` default to `1`/`1000`/`1` — the same -/// defaults the retired real-R dispatch used, so an omitted flag produces the -/// same document either way. The usize->u32 conversion lands HERE, at the argv -/// boundary where the CLI's `usize` meets the document's `u32` vocabulary -/// (`StageBlock::MonteCarlo`). `--name`/`--trace` are rejected: the R-bootstrap -/// records without a family name (the campaign name is a constant "mc"). -#[allow(clippy::type_complexity)] -fn mc_args_from( - a: &McCmd, -) -> Result<(String, String, i64, f64, u32, u32, u64, Option, Option), String> { - let symbol = match a.real.as_deref() { - None | Some("") => return Err("mc dissolves only over --real ".to_string()), - Some(s) => s.to_string(), - }; - if a.name.is_some() || a.trace.is_some() { - return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); - } - let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); - let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); - let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); - let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); - let seed = a.seed.unwrap_or(1); - Ok(("mc".to_string(), symbol, stop_length, stop_k, block_len, resamples, seed, a.from, a.to)) -} - -/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar -/// consumes. `--real` is a `>=2`-distinct comma list; the stop knobs are single -/// values, each OPTIONAL (#217) and independently defaulting to the single- -/// sourced [`R_SMA_STOP_LENGTH`]/[`R_SMA_STOP_K`] regime when omitted — clap's -/// typed `Option`/`Option` already forbids a multi-value spelling, so -/// no separate regime refusal is needed here. Every refusal whose flag survives -/// #220 reuses the old message string (byte-identical front-end). -#[allow(clippy::type_complexity)] -fn generalize_args_from( - a: &GeneralizeCmd, -) -> Result<(String, Vec, i64, f64, String, Option, Option), String> { - let symbols: Vec = match a.real.as_deref() { - None => return Err("generalize requires --real — a comma list of two or more instruments".to_string()), - Some(v) => { - let parts: Vec = v.split(',').map(|s| s.to_string()).collect(); - if parts.iter().any(|s| s.is_empty()) { - return Err("generalize: --real takes a comma list of non-empty symbols (e.g. GER40,USDJPY)".to_string()); - } - parts - } - }; - if symbols.len() < 2 { - return Err(format!( - "generalize needs at least two instruments to compare across; got {} (--real takes a comma list of >=2 symbols)", - symbols.len() - )); - } - let mut seen = HashSet::new(); - if !symbols.iter().all(|s| seen.insert(s.clone())) { - return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string()); - } - let stop_length = a.stop_length.unwrap_or(R_SMA_STOP_LENGTH); - let stop_k = a.stop_k.unwrap_or(R_SMA_STOP_K); - let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string()); - let name = a.name.clone().unwrap_or_else(|| "generalize".to_string()); - Ok((name, symbols, stop_length, stop_k, metric, a.from, a.to)) -} - -/// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a -/// non-empty symbol yields `DataChoice::Real`, a window flag without `--real` is a -/// usage error, absence is synthetic. Single-sourced (the old `RealWindowGrammar` -/// finish logic) so the family subcommands agree. -fn data_choice_from( - real: Option<&str>, - from: Option, - to: Option, - usage: &impl Fn() -> String, -) -> Result { - match real { - Some("") => Err(usage()), - Some(s) => Ok(DataChoice::Real { symbol: s.to_string(), from_ms: from, to_ms: to }), - None if from.is_some() || to.is_some() => Err(usage()), - None => Ok(DataChoice::Synthetic), - } -} - -/// Resolve `--name`/`--trace` (mutually exclusive) into `(family_name, persist)`, -/// defaulting the name when neither is given. -fn name_persist( - name: Option<&str>, - trace: Option<&str>, - default: &str, - usage: &impl Fn() -> String, -) -> Result<(String, bool), String> { - match (name, trace) { - (Some(_), Some(_)) => Err(usage()), - (Some(n), None) => Ok((n.to_string(), false)), - (None, Some(t)) => Ok((t.to_string(), true)), - (None, None) => Ok((default.to_string(), false)), - } -} - -/// Parse the repeatable `--axis =` list into by-name grid axes, mirroring -/// the old blueprint-sweep axis grammar: an empty/duplicate name or a malformed csv -/// is a usage error. -fn parse_axes( - raw: &[String], - usage: &impl Fn() -> String, -) -> Result)>, String> { - let mut axes: Vec<(String, Vec)> = Vec::new(); - for item in raw { - let (n, csv) = item.split_once('=').ok_or_else(usage)?; - if n.is_empty() || axes.iter().any(|(a, _)| a == n) { - return Err(usage()); - } - let vals = parse_scalar_csv(csv).ok_or_else(usage)?; - axes.push((n.to_string(), vals)); - } - Ok(axes) -} - -/// The four distinguishable ways `validate_and_register_axes` can fail: a -/// shape-violating root name is a usage error (exit 2, echoed before the -/// blueprint ever reaches the registry — #331 cycle-close: this fn -/// `put_blueprint`s a fresh-FILE envelope and so must gate it exactly like -/// `register`/`run`/the other authored-file intakes, see -/// `gate_authored_root_name`'s doc comment); 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, since the strategy document cannot resolve against a project -/// store/vocabulary that doesn't exist. -enum AxisRegisterError { - BadName(String), - 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 `..` 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)], - env: &aura_runner::project::Env, -) -> Result<(String, Vec<(String, Vec)>), 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"); - // #331 cycle-close: this fn `put_blueprint`s a fresh-FILE envelope below — - // gate the root name before it lands in the registry (shared choke point - // with every other authored-file intake). - graph_construct::gate_authored_root_name(blueprint.name()).map_err(AxisRegisterError::BadName)?; - let raw_bound: HashSet = - 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 --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: a shape-violating root name (#331) or 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::BadName(m) => { - eprintln!("aura: {m}"); - std::process::exit(2) - } - 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). @@ -1936,29 +997,6 @@ pub(crate) fn exit_on_campaign_result(r: Result) { } } -/// 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: @@ -2082,7 +1120,8 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au if free.len() != paths.len() { eprintln!( "aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \ - bind them or use `aura sweep --axis`", + bind them in the blueprint or vary them as campaign axes; a single bound knob \ + can be overridden with --override", free.len() ); std::process::exit(2); @@ -2119,7 +1158,8 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au 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`", + bind them in the blueprint or vary them as campaign axes; a single bound knob \ + can be overridden with --override", signal.param_space().len() ); std::process::exit(2); @@ -2129,133 +1169,13 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au println!("{}", report.to_json()); } else { eprintln!( - "aura: `aura run` needs either a `bias` output (a strategy) or ≥1 \ + "aura: exec needs either a `bias` output (a strategy) or ≥1 \ declared tap (a measurement); this blueprint exposes neither" ); std::process::exit(1); } } -/// `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 [--params ] [--seed ] [--real [--from ] [--to ]] [--tap …]"); - 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); - }); - // #331 delta re-review: this is the third freshly-authored-FILE - // intake (`register`, `introspect --content-id `, and this - // one) that must gate the root name before it ever reaches the - // filesystem — both run arms below feed `signal.name()` straight - // into `bind_tap_plan`'s `TraceStore::begin_run`, which joins it - // unsanitized (`trace_store.rs`). Not routed through - // `composite_from_authored_text` itself (see that fn's doc - // comment on why); same `name_gate`/`name_gate_fault_prose` - // primitives, this arm's own load-error exit/prose convention. - if let Err(msg) = graph_construct::gate_authored_root_name(signal.name()) { - 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 [--params ] \ - [--seed ] [--real [--from ] [--to ]] \ - [--tap …]" - ); - std::process::exit(2); - } - } -} - fn dispatch_chart(a: ChartCmd, env: &aura_runner::project::Env) { emit_chart( &a.name, @@ -2302,99 +1222,6 @@ fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) { } } -fn dispatch_generalize(a: GeneralizeCmd, env: &aura_runner::project::Env) { - let usage = || format!("Usage: aura generalize --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--metric ] [--name ] [--from ] [--to ]"); - let Some(path) = is_blueprint_file(&a.blueprint) else { - eprintln!("aura: {}", usage()); - std::process::exit(2); - }; - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { - eprintln!("aura: {path}: {msg}"); - std::process::exit(2); - } - let (name, symbols, stop_length, stop_k, metric, from, to) = - generalize_args_from(&a).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - // Data-free R-metric refusal, byte-identical to the retired welded path - // (exit 2) before any archive is touched. - if let Err(e) = check_r_metric(&metric) { - eprintln!("aura: {e}"); - std::process::exit(2); - } - let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - if axes.is_empty() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - // A candidate is a single grid cell, not a sweep: every axis carries - // exactly one value (the refusal the retired `--fast 2,3` grammar made). - for (n, vals) in &axes { - if vals.len() != 1 { - eprintln!( - "aura: generalize: each --axis takes exactly one value; axis \"{n}\" has {}", - vals.len() - ); - std::process::exit(2); - } - } - let (canonical, raw_axes) = validate_and_register_axes("generalize", &doc, &axes, env) - .unwrap_or_else(|e| exit_axis_register_error(e)); - // Window: the explicit --from/--to when both present (byte-identical to the - // retired welded path, verified by the exact-grade anchor); otherwise the - // INTERSECTION of every listed symbol's full archive window — the only span - // over which every instrument actually has data (#213). A single listed - // symbol degenerates to its own full window unchanged. - let (from_ms, to_ms) = match (from, to) { - (Some(f), Some(t)) => (f, t), - _ => { - let windows: Vec<(i64, i64)> = symbols - .iter() - .map(|s| { - campaign_window_ms( - DataChoice::Real { symbol: s.clone(), from_ms: from, to_ms: to }, - env, - ) - }) - .collect(); - match intersect_shared_window(&symbols, &windows) { - Ok(w) => w, - Err(spans) => { - for (s, w) in spans { - eprintln!("aura: generalize: {s} spans [{}, {}]", w.0, w.1); - } - eprintln!( - "aura: generalize: no window is shared across all listed symbols \ - (their archives do not overlap)" - ); - std::process::exit(1); - } - } - } - }; - let inv = verb_sugar::SugarInvocation { - axes: &raw_axes, - name, - symbols, - from_ms, - to_ms, - blueprint_canonical: &canonical, - stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), - // `generalize` has no `--trace` flag in its own grammar (#224 delivered - // sweep + walkforward only); trace-writing is out of scope here. - trace: false, - }; - exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env)); -} - fn dispatch_runs(a: RunsCmd, env: &aura_runner::project::Env) { match a.sub { RunsSub::Families => runs_families(env), @@ -2665,364 +1492,6 @@ fn data_info_report(symbol: &str, geo: Option<&aura_ingest::InstrumentGeometry>) serde_json::to_string(&report).expect("a finite DataInfoReport always serializes") } -/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the -/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. -fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) { - // Single-sourced: the blueprint grammar and the no-blueprint usage error must - // stay in lockstep, so both arms below read this one closure. - let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); - match is_blueprint_file(&a.blueprint) { - Some(path) => { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - // Parse-validate the blueprint once at the boundary (with file-path context), - // house-style prose (#184's convention, single-sourced — #210 c0110 finding). - if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { - eprintln!("aura: {path}: {msg}"); - std::process::exit(2); - } - // A built-in-only flag with a blueprint file is not in this grammar. - if a.strategy.is_some() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - if a.list_axes { - // A query, not a sweep: it must stand alone. - if !a.axis.is_empty() - || a.name.is_some() - || a.trace.is_some() - || a.real.is_some() - || a.from.is_some() - || a.to.is_some() - { - eprintln!("aura: --list-axes lists axes and takes no other flags"); - std::process::exit(2); - } - list_blueprint_axes(&doc, env); - return; - } - let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - if axes.is_empty() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - match &data { - DataChoice::Real { symbol, .. } => { - // The dissolved branch: real-data blueprint sweeps run as - // sugar over a generated campaign document through the - // one campaign executor. The blueprint store write (by - // topology hash) is kept — the - // canonical bytes are also the strategy ref's content, and - // `topology_hash` IS `content_id_of` applied to the same - // canonical bytes (verified by reading: `topology_hash` == - // `content_id` == `aura_research::content_id_of`), so the - // strategy ref resolves against this one write; a second - // put under `content_id_of(&canonical)` would target the - // identical key and is not needed. - // Axis validation (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 --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); - match is_blueprint_file(&a.blueprint) { - Some(path) => { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { - eprintln!("aura: {path}: {msg}"); - std::process::exit(2); - } - let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - if axes.is_empty() { - eprintln!("aura: walkforward requires >= 1 --axis to re-fit per window"); - std::process::exit(2); - } - if a.real.is_some() { - // The campaign path (#220): the real-archive execution routes - // through the one campaign executor, over the user's own - // blueprint and axes (formerly the welded r-sma branch). - let select = match a.select.as_deref() { - Some(s) => match parse_select(s) { - Ok(sel) => select_rule_of(sel), - Err(()) => { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - }, - None => aura_research::SelectRule::Argmax, - }; - let (name, trace, symbol, stop_length, stop_k, from, to) = - walkforward_args_from(&a).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - // Axis validation (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 --seeds [--name ] | aura mc --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]"); - match is_blueprint_file(&a.blueprint) { - Some(path) => { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { - eprintln!("aura: {path}: {msg}"); - std::process::exit(2); - } - if a.real.is_some() { - // The campaign path (#220): blueprint + --real + --axis routes - // the R-bootstrap pipeline through the one campaign executor. - // The two mc modes stay disjoint: --seeds belongs to the - // synthetic seed family only. - if a.seeds.is_some() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - let (name, symbol, stop_length, stop_k, block_len, resamples, seed, from, to) = - mc_args_from(&a).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - if axes.is_empty() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - // Axis validation (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 @@ -3055,13 +1524,8 @@ fn main() { }; match cli.command { Command::Exec(a) => dispatch_exec(a, &env), - 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), @@ -3265,21 +1729,6 @@ mod tests { 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)) } @@ -3520,25 +1969,6 @@ mod tests { ); } - /// #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(); @@ -4122,18 +2552,6 @@ mod tests { 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] @@ -4307,105 +2725,6 @@ mod tests { ); } - #[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()); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs deleted file mode 100644 index 5b93dfa..0000000 --- a/crates/aura-cli/src/verb_sugar.rs +++ /dev/null @@ -1,1257 +0,0 @@ -//! Verb sugar: the dissolved orchestration verbs' argv → generated campaign -//! documents (docs/specs/sweep-dissolution.md; #210 fork decisions). -//! -//! A dissolved verb invocation is translated into a selection-free process -//! document plus a campaign document expressing exactly the invocation's -//! intent, both auto-registered content-addressed (every ad-hoc invocation -//! becomes durable, diffable, reproducible intent), then run through the one -//! campaign executor in sugar presentation mode (member lines only). - -use std::collections::BTreeMap; - -use aura_core::{Scalar, ScalarKind}; -use aura_research::{ - campaign_to_json, content_id_of, process_to_json, tap_vocabulary, Axis, CampaignDoc, - DataSection, DocKind, DocRef, Presentation, ProcessDoc, ProcessRef, RiskRegime, SelectRule, - StageBlock, StrategyEntry, SweepSelection, WfMode, Window, FORMAT_VERSION, -}; - -/// The two generated documents of one dissolved-verb invocation. -#[derive(Debug)] -pub(crate) struct GeneratedSweep { - pub process: ProcessDoc, - pub campaign: CampaignDoc, -} - -/// One dissolved-verb invocation's shared shape (#214/#220): arbitrary RAW -/// axes (post `wrapped_to_raw_axis` strip), the family name, the instrument -/// list, the shared window, the canonical blueprint, and the optional single -/// Vol stop regime (`None` binds no regime — sweep's contract). All four -/// dissolved verbs (sweep, generalize, walkforward, mc) route their dispatch -/// args through this shape. -pub(crate) struct SugarInvocation<'a> { - pub axes: &'a [(String, Vec)], - pub name: String, - /// Instruments: sweep/walkforward/mc carry exactly one; generalize >=2. - pub symbols: Vec, - pub from_ms: i64, - pub to_ms: i64, - pub blueprint_canonical: &'a str, - pub stop: Option, - /// Whether the invocation carried `--trace` (sweep/walkforward, #224): - /// when true, the generated campaign's presentation requests the full - /// tap vocabulary so `persist_campaign_traces` actually writes each - /// member's tap series, instead of the #168 no-op `persist_taps: vec![]`. - pub trace: bool, -} - -/// The generated campaign's `persist_taps` section for one invocation: -/// when `--trace` was requested, the tap vocabulary MINUS the net channel -/// (`net_r_equity`), else empty (the #168 no-op — this is the one seam -/// `translate_sweep`/`translate_walkforward` share for the decision). Every -/// generated sugar campaign carries an empty cost section (`cost: vec![]`) -/// by construction, so the net channel is never producible there; requesting -/// it anyway would make `campaign_run`'s executor print an unreachable "add a -/// cost block to the campaign document" remedy (#240). The exclusion reuses -/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/ -/// `TapChannel::Net`) rather than duplicating the net-tap name here. -fn persist_taps_from(trace: bool) -> Vec { - if trace { - tap_vocabulary() - .iter() - .filter(|t| { - aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net) - }) - .map(|t| t.id.to_string()) - .collect() - } else { - vec![] - } -} - -/// The single protective-stop regime a dissolved verb binds (`RiskRegime::Vol`). -#[derive(Clone, Copy)] -pub(crate) struct VolStop { - pub length: i64, - pub k: f64, -} - -/// The walk-forward roller sizes (ms) a wf/mc invocation carries. -#[derive(Clone, Copy)] -pub(crate) struct WfWindows { - pub in_sample_ms: u64, - pub out_of_sample_ms: u64, - pub step_ms: u64, -} - -/// The mc-specific bootstrap knobs. -#[derive(Clone, Copy)] -pub(crate) struct McKnobs { - pub resamples: u32, - pub block_len: u32, - pub seed: u64, -} - -/// The generic doc-axes map: every invocation axis under its arbitrary raw -/// bind-key (the sweep shape, adopted by all four verbs — #220). -fn doc_axes_from(axes: &[(String, Vec)]) -> Result, String> { - let mut doc_axes: BTreeMap = BTreeMap::new(); - for (axis_name, values) in axes { - doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?); - } - Ok(doc_axes) -} - -/// The optional single Vol stop regime -> the campaign risk vector. -fn risk_from(stop: Option) -> Vec { - stop.map(|s| vec![RiskRegime::Vol { length: s.length, k: s.k }]).unwrap_or_default() -} - -/// One probe param per axis (any one grid value — `bind_axes` checks NAME -/// coverage/uniqueness, never the bound value). -fn probe_params_from(axes: &[(String, Vec)]) -> Vec<(String, Scalar)> { - axes.iter().filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v))).collect() -} - -/// Map one verb axis (`--axis name=csv`, already scalar-parsed) to a -/// document `Axis`: the kind is the first value's kind; a mixed-kind CSV is -/// refused (the document axis declares its kind ONCE — #189). -fn axis_from_values(name: &str, values: &[Scalar]) -> Result { - let kind = match values.first() { - Some(Scalar::I64(_)) => ScalarKind::I64, - Some(Scalar::F64(_)) => ScalarKind::F64, - Some(Scalar::Bool(_)) => ScalarKind::Bool, - Some(Scalar::Timestamp(_)) => ScalarKind::Timestamp, - None => return Err(format!("axis {name}: empty value list")), - }; - let homogeneous = values.iter().all(|v| { - matches!( - (v, kind), - (Scalar::I64(_), ScalarKind::I64) - | (Scalar::F64(_), ScalarKind::F64) - | (Scalar::Bool(_), ScalarKind::Bool) - | (Scalar::Timestamp(_), ScalarKind::Timestamp) - ) - }); - if !homogeneous { - return Err(format!( - "axis {name}: mixed value kinds in one axis (an axis declares its kind once)" - )); - } - Ok(Axis { kind, values: values.to_vec() }) -} - -/// Translate a real-data blueprint sweep invocation into its two generated -/// documents. `inv.blueprint_canonical` is the canonical blueprint JSON already -/// stored by topology hash; its content id is the strategy ref. -pub(crate) fn translate_sweep(inv: &SugarInvocation) -> Result { - let process = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "sweep".to_string(), - description: None, - pipeline: vec![StageBlock::Sweep { selection: None }], - }; - let campaign = CampaignDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Campaign, - name: inv.name.clone(), - description: None, - data: DataSection { - instruments: inv.symbols.clone(), - windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], - // The verb sugar passes no bindings: name defaults rule (#231); - // authored campaign documents may set them. - bindings: BTreeMap::new(), - }, - strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), - axes: doc_axes_from(inv.axes)?, - }], - process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - // No stop regime is bound for a dissolved single-sweep member (the - // dispatch passes `stop: None`; absent risk == empty risk, per the - // established parity). The other verbs bind the single Vol regime - // through this same seam. - risk: risk_from(inv.stop), - cost: vec![], - // No stage of a selection-free single-sweep pipeline consumes the - // seed; a fixed zero keeps generated bytes deterministic so identical - // invocations dedupe onto identical content ids. - seed: 0, - presentation: Presentation { - persist_taps: persist_taps_from(inv.trace), - emit: vec!["family_table".to_string()], - }, - }; - Ok(GeneratedSweep { process, campaign }) -} - -/// Auto-register both generated documents (content-addressed, idempotent). -/// Returns `(process_id, campaign_id)`. -pub(crate) fn register_generated( - reg: &aura_registry::Registry, - generated: &GeneratedSweep, -) -> Result<(String, String), String> { - let process_id = - reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?; - let campaign_id = - reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?; - Ok((process_id, campaign_id)) -} - -/// The two generated documents of one dissolved `generalize` invocation. -#[derive(Debug)] -pub(crate) struct GeneratedGeneralize { - pub process: ProcessDoc, - pub campaign: CampaignDoc, -} - -/// Translate one `aura generalize` invocation into its two generated documents: -/// a **selection-bearing** process (`[std::sweep(argmax), std::generalize]` — -/// generalize needs a nominee, so the sweep stage is selection-bearing, unlike -/// the selection-free single-sweep translator) and a campaign running the one -/// fixed candidate (single-value raw axes) across all instruments under the -/// invocation's single risk regime. `inv.blueprint_canonical` is the user's -/// blueprint already stored by topology hash; its content id is the strategy ref. -pub(crate) fn translate_generalize( - inv: &SugarInvocation, - metric: &str, -) -> Result { - let process = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "generalize".to_string(), - description: None, - pipeline: vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: metric.to_string(), - select: SelectRule::Argmax, - deflate: false, - }), - }, - StageBlock::Generalize { metric: metric.to_string() }, - ], - }; - let campaign = CampaignDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Campaign, - name: inv.name.clone(), - description: None, - data: DataSection { - instruments: inv.symbols.clone(), - windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], - // The verb sugar passes no bindings: name defaults rule (#231); - // authored campaign documents may set them. - bindings: BTreeMap::new(), - }, - strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), - axes: doc_axes_from(inv.axes)?, - }], - process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - // The stop is a single structural risk regime; the member runner maps - // it back to StopRule::Vol at run time. - risk: risk_from(inv.stop), - cost: vec![], - seed: 0, - presentation: Presentation { - persist_taps: vec![], - emit: vec!["family_table".to_string()], - }, - }; - Ok(GeneratedGeneralize { process, campaign }) -} - -/// Auto-register both generated generalize documents (content-addressed, -/// idempotent). Returns `(process_id, campaign_id)`. -pub(crate) fn register_generated_g( - reg: &aura_registry::Registry, - generated: &GeneratedGeneralize, -) -> Result<(String, String), String> { - let process_id = - reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?; - let campaign_id = - reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?; - Ok((process_id, campaign_id)) -} - -/// Validate a generated `(process, campaign)` pair BEFORE anything is -/// registered (#210 c0110 fieldtest, "store litter" finding): a referential -/// refusal must never leave a dead generated document in the content- -/// addressed store. Defensive doc-shape checks first (generated docs should -/// always pass this — the translators build them by construction), then the -/// executable-shape preflight, then the axis-binding check against the -/// blueprint's own (wrapped) open param space via the pure `bind_axes` seam -/// (campaign_run.rs) — the referential shape the dispatch-boundary name -/// preflight (main.rs) does not catch: a subset of axes that leaves an open -/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the -/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member` -/// uses per member (aura-runner) — a bound-param axis passes this -/// executable-shape preflight exactly like an already-open one; an axis -/// matching neither space still fails `bind_axes`'s own named check below. -/// `probe_params` is any one grid value per axis (checks NAME -/// coverage/uniqueness, never the bound value). Shared by both dissolved-verb -/// sugars: one seam enforcing the store-litter invariant, rather than two -/// drift-prone copies. -fn validate_before_register( - process: &ProcessDoc, - campaign: &CampaignDoc, - blueprint_canonical: &str, - probe_params: &[(String, Scalar)], - env: &aura_runner::project::Env, -) -> Result<(), String> { - let doc_faults = aura_research::validate_campaign(campaign); - if !doc_faults.is_empty() { - return Err(crate::research_docs::fault_block( - "generated campaign document invalid:", - doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(), - )); - } - let process_faults = aura_research::validate_process(process); - if !process_faults.is_empty() { - return Err(crate::research_docs::fault_block( - "generated process document invalid:", - process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(), - )); - } - aura_campaign::preflight(process, campaign).map_err(|f| crate::campaign_run::exec_fault_prose(&f))?; - - // The un-reopened wrapped OPEN space (#246): derive which of the probe's - // RAW axis names re-open a bound param of the strategy, against a raw - // (un-reopened) reload of the same canonical bytes — infallible, the - // strategy ref's own content. - let raw_space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space(); - let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t)) - .expect("a generated sugar campaign's strategy ref reloads its own canonical bytes"); - let param_names: Vec = probe_params.iter().map(|(n, _)| n.clone()).collect(); - let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal); - let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space(); - let strategy_id = content_id_of(blueprint_canonical); - aura_runner::axes::bind_axes(&space, &strategy_id, probe_params) - .map(|_| ()) - .map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f))) -} - -/// Run one dissolved sweep invocation end-to-end: register the generated -/// documents, then execute through the one campaign path in sugar mode. The -/// `Ok` carries the failed-cell count (#272), propagated verbatim from the -/// one campaign executor. -pub(crate) fn run_sweep_sugar( - inv: &SugarInvocation, - env: &aura_runner::project::Env, -) -> Result { - let generated = translate_sweep(inv)?; - let probe_params = probe_params_from(inv.axes); - validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; - let reg = env.registry(); - let (_process_id, campaign_id) = register_generated(®, &generated)?; - crate::campaign_run::run_campaign_by_id( - &campaign_id, - env, - crate::campaign_run::RunPresentation::MemberLinesOnly, - aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, - &[], - ) -} - -/// Build the `CrossInstrument` family members from an executed generalize -/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order — -/// the same order + shape the inline `run_generalize` built its `members` in, -/// each already carrying `manifest.instrument`. #272: a failed cell nominates -/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate -/// outcome (the caller counts and surfaces the failed cells separately, via -/// `outcome.record.cells[].fault`) — no longer an invariant this function -/// itself enforces. -fn cross_instrument_members( - outcome: &aura_campaign::CampaignOutcome, -) -> Vec { - outcome - .cells - .iter() - .filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone())) - .collect() -} - -/// Run one dissolved `generalize` invocation end-to-end: register the generated -/// documents, run through the one campaign path, then reprint today's exact -/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and -/// persist the `CrossInstrument` family. The stdout is byte-identical to the -/// retired welded path (the committed exact-grade anchor is the gate). The -/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing -/// (the existing no-nominee -> `missing` path already handles it), counted -/// from the recorded cells' own `fault` field rather than from the missing -/// nominee, so the count is right even when some OTHER member of the cell's -/// pipeline is what actually failed. -pub(crate) fn run_generalize_sugar( - inv: &SugarInvocation, - metric: &str, - env: &aura_runner::project::Env, -) -> Result { - let generated = translate_generalize(inv, metric)?; - let probe_params = probe_params_from(inv.axes); - validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; - let reg = env.registry(); - let (_process_id, campaign_id) = register_generated_g(®, &generated)?; - let run = crate::campaign_run::run_campaign_returning( - &campaign_id, - env, - aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, - &[], - )?; - - // Reprint the verb's aggregate line from the recorded cross-instrument - // grade — byte-identical to the retired welded path's `generalize_json(&agg)`. - let cg = run - .outcome - .record - .generalizations - .iter() - .find_map(|g| g.generalization.as_ref()) - .ok_or("generalize produced no cross-instrument grade")?; - println!("{}", crate::generalize_json(cg)); - - let members = cross_instrument_members(&run.outcome); - let family_id = reg - .append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members) - .map_err(|e| e.to_string())?; - println!("{{\"family_id\":\"{family_id}\"}}"); - let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count(); - Ok(failed) -} - -/// The two generated documents of one dissolved `walkforward` invocation. -#[derive(Debug)] -pub(crate) struct GeneratedWalkforward { - pub process: ProcessDoc, - pub campaign: CampaignDoc, -} - -/// Translate one `aura walkforward --real` invocation into its two generated -/// documents: a `[std::grid, std::walk_forward]` process (the leading -/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf -/// stage — it executes nothing and persists no family (#256)) and a campaign -/// running the invocation's axis grid over one instrument under its single -/// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical` -/// is the user's blueprint already stored by topology hash; its content id is -/// the strategy ref. -pub(crate) fn translate_walkforward( - inv: &SugarInvocation, - metric: &str, - w: WfWindows, - select: SelectRule, -) -> Result { - let process = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "walkforward".to_string(), - description: None, - pipeline: vec![ - StageBlock::Grid, - StageBlock::WalkForward { - in_sample_ms: w.in_sample_ms, - out_of_sample_ms: w.out_of_sample_ms, - step_ms: w.step_ms, - mode: WfMode::Rolling, - metric: metric.to_string(), - select, - }, - ], - }; - let campaign = CampaignDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Campaign, - name: inv.name.clone(), - description: None, - data: DataSection { - instruments: inv.symbols.clone(), - windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], - // The verb sugar passes no bindings: name defaults rule (#231); - // authored campaign documents may set them. - bindings: BTreeMap::new(), - }, - strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), - axes: doc_axes_from(inv.axes)?, - }], - process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - risk: risk_from(inv.stop), - cost: vec![], - seed: 0, - presentation: Presentation { - persist_taps: persist_taps_from(inv.trace), - emit: vec!["family_table".to_string()], - }, - }; - Ok(GeneratedWalkforward { process, campaign }) -} - -/// Auto-register both generated walkforward documents (content-addressed, -/// idempotent). Returns `(process_id, campaign_id)`. -pub(crate) fn register_generated_wf( - reg: &aura_registry::Registry, - generated: &GeneratedWalkforward, -) -> Result<(String, String), String> { - let process_id = - reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?; - let campaign_id = - reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?; - Ok((process_id, campaign_id)) -} - -/// Run one dissolved `walkforward` invocation end-to-end: register the generated -/// documents, run through the one campaign path, then reprint the per-window member -/// lines + the summary line from the recorded `WalkForward` family. The stdout -/// summary line is byte-identical to the retired welded path (the committed -/// exact-grade anchor is the gate). #272: the invocation's one cell either -/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an -/// error, since a completed-with-failed-cell run is the campaign path's own -/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap" -/// refusal the retired welded path's `.ok_or` would have raised. -pub(crate) fn run_walkforward_sugar( - inv: &SugarInvocation, - metric: &str, - w: WfWindows, - select: SelectRule, - env: &aura_runner::project::Env, -) -> Result { - let generated = translate_walkforward(inv, metric, w, select)?; - let probe_params = probe_params_from(inv.axes); - validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; - let reg = env.registry(); - let (_process_id, campaign_id) = register_generated_wf(®, &generated)?; - let run = crate::campaign_run::run_campaign_returning( - &campaign_id, - env, - aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, - &[], - )?; - - if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { - crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail); - return Ok(1); - } - - // The single cell's walk_forward StageFamily. - let wf = run - .outcome - .cells - .iter() - .flat_map(|c| c.families.iter()) - .find(|f| f.block == "std::walk_forward") - .ok_or("walkforward produced no walk_forward family")?; - - for report in &wf.reports { - println!("{}", crate::family_member_line(&wf.family_id, report)); - } - // Summary axes: the invocation's raw axis names in argv order, then the - // stop columns when a regime is bound (#220 — the renderer hardcodes none). - let mut summary_axes: Vec = inv.axes.iter().map(|(n, _)| n.clone()).collect(); - if inv.stop.is_some() { - summary_axes.push("stop_length".to_string()); - summary_axes.push("stop_k".to_string()); - } - crate::diag::note_zero_trade_windows( - wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)), - ); - println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes)); - - // Trace persistence runs LAST (mirroring `present_campaign`'s ordering, - // 0109): a no-op when `--trace` was not requested (`persist_taps` empty - // => `trace_name` is `None`), so this is always safe to call. Unlike - // `run_sweep_sugar` (routed through `run_campaign_by_id` -> the shared - // `present_campaign` tail), this walkforward sugar calls - // `run_campaign_returning` directly and prints its own bespoke lines, so - // it must run this tail itself. Its generalize/mc siblings call - // `run_campaign_returning` the same way but do NOT run this tail: neither - // has a `--trace` grammar (generalize never did; `mc --real` refuses - // `--name`/`--trace` up front, #224) and `campaign.presentation.persist_taps` - // is always empty for them, so their `run.outcome.record.trace_name` is - // always `None`. `run`/`mc --trace` stay refused by design (#224), not by - // this tail being skipped. - if let Some(trace_name) = &run.outcome.record.trace_name { - aura_runner::runner::persist_campaign_traces( - trace_name, - &run.campaign.presentation.persist_taps, - &run.outcome, - &run.campaign, - &run.strategies, - &run.server, - env, - )?; - } - Ok(0) -} - -/// The two generated documents of one dissolved `mc` invocation. -#[derive(Debug)] -pub(crate) struct GeneratedMc { - pub process: ProcessDoc, - pub campaign: CampaignDoc, -} - -/// Translate one `aura mc --real` invocation into its two generated documents: -/// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading -/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf -/// stage — it executes nothing and persists no family (#256); the terminal -/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps -/// `E[R]`) and a campaign running the invocation's axis grid over one instrument -/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes -/// `seed: 0`), `translate_mc` sets `campaign.seed = mc.seed` (the mc `--seed`): -/// the wf winners are argmax hence deflation-seed-independent (the shipped -/// walkforward anchor proved this), so remapping the campaign seed leaves the -/// pooled series unchanged while making the terminal `r_bootstrap` at that seed -/// reproduce the retired inline bootstrap. -pub(crate) fn translate_mc( - inv: &SugarInvocation, - metric: &str, - w: WfWindows, - mc: McKnobs, -) -> Result { - let process = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "mc".to_string(), - description: None, - pipeline: vec![ - StageBlock::Grid, - StageBlock::WalkForward { - in_sample_ms: w.in_sample_ms, - out_of_sample_ms: w.out_of_sample_ms, - step_ms: w.step_ms, - mode: WfMode::Rolling, - metric: metric.to_string(), - select: SelectRule::Argmax, - }, - StageBlock::MonteCarlo { resamples: mc.resamples, block_len: mc.block_len }, - ], - }; - let campaign = CampaignDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Campaign, - name: inv.name.clone(), - description: None, - data: DataSection { - instruments: inv.symbols.clone(), - windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], - // The verb sugar passes no bindings: name defaults rule (#231); - // authored campaign documents may set them. - bindings: BTreeMap::new(), - }, - strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), - axes: doc_axes_from(inv.axes)?, - }], - process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - risk: risk_from(inv.stop), - cost: vec![], - seed: mc.seed, - presentation: Presentation { - persist_taps: vec![], - emit: vec!["family_table".to_string()], - }, - }; - Ok(GeneratedMc { process, campaign }) -} - -/// Auto-register both generated mc documents (content-addressed, idempotent). -/// Returns `(process_id, campaign_id)`. -pub(crate) fn register_generated_mc( - reg: &aura_registry::Registry, - generated: &GeneratedMc, -) -> Result<(String, String), String> { - let process_id = - reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?; - let campaign_id = - reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?; - Ok((process_id, campaign_id)) -} - -/// Run one dissolved `mc` invocation end-to-end: register the generated documents, -/// run through the one campaign path, then reprint the single `mc_r_bootstrap` grade -/// line from the recorded terminal `PooledOos` bootstrap. The stdout line is -/// byte-identical to the retired welded path (the committed exact-grade anchor is -/// the gate). mc prints ONLY the one summary line — no per-window member lines -/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family. -/// #272: the invocation's one cell either completed (`Ok(0)`) or failed -/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s -/// twin note). -pub(crate) fn run_mc_sugar( - inv: &SugarInvocation, - metric: &str, - w: WfWindows, - mc: McKnobs, - env: &aura_runner::project::Env, -) -> Result { - let generated = translate_mc(inv, metric, w, mc)?; - let probe_params = probe_params_from(inv.axes); - validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; - let reg = env.registry(); - let (_process_id, campaign_id) = register_generated_mc(®, &generated)?; - let run = crate::campaign_run::run_campaign_returning( - &campaign_id, - env, - aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, - &[], - )?; - - if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { - crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail); - return Ok(1); - } - - // The single cell's terminal monte_carlo StageRealization carries the pooled-OOS - // bootstrap (record path: outcome.record.cells[].stages[].bootstrap). - let boot = run - .outcome - .record - .cells - .iter() - .flat_map(|c| c.stages.iter()) - .find_map(|s| match &s.bootstrap { - Some(aura_registry::StageBootstrap::PooledOos(b)) => Some(b), - _ => None, - }) - .ok_or("mc produced no pooled-OOS bootstrap")?; - println!("{}", crate::mc_r_bootstrap_json(boot)); - Ok(0) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn axes() -> Vec<(String, Vec)> { - vec![ - ("fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(4)]), - ("slow.length".to_string(), vec![Scalar::I64(8), Scalar::I64(16)]), - ] - } - - fn inv<'a>( - axes: &'a [(String, Vec)], - name: &str, - symbols: &[&str], - stop: Option, - bp: &'a str, - ) -> SugarInvocation<'a> { - SugarInvocation { - axes, - name: name.to_string(), - symbols: symbols.iter().map(|s| s.to_string()).collect(), - from_ms: 100, - to_ms: 200, - blueprint_canonical: bp, - stop, - trace: false, - } - } - - fn g_axes() -> Vec<(String, Vec)> { - vec![ - ("fast.length".to_string(), vec![Scalar::i64(3)]), - ("slow.length".to_string(), vec![Scalar::i64(12)]), - ] - } - - fn wf_axes() -> Vec<(String, Vec)> { - vec![ - ("fast.length".to_string(), vec![Scalar::i64(3), Scalar::i64(5)]), - ("slow.length".to_string(), vec![Scalar::i64(12), Scalar::i64(20)]), - ] - } - - const WF_W: WfWindows = WfWindows { - in_sample_ms: 7_776_000_000, - out_of_sample_ms: 2_592_000_000, - step_ms: 2_592_000_000, - }; - - #[test] - fn translate_sweep_is_deterministic_and_names_flow() { - let ax = axes(); - let i = inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}"); - let a = translate_sweep(&i).unwrap(); - let b = translate_sweep(&i).unwrap(); - assert_eq!( - content_id_of(&campaign_to_json(&a.campaign)), - content_id_of(&campaign_to_json(&b.campaign)), - "identical invocations must generate identical campaign ids" - ); - assert_eq!(a.campaign.name, "probe"); - assert_eq!(a.campaign.seed, 0); - assert_eq!(a.process.name, "sweep"); - assert_eq!(a.process.pipeline, vec![StageBlock::Sweep { selection: None }]); - assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]); - assert_eq!(a.campaign.data.windows, vec![Window { from_ms: 100, to_ms: 200 }]); - assert_eq!(a.campaign.presentation.emit, vec!["family_table".to_string()]); - assert!(a.campaign.presentation.persist_taps.is_empty()); - // the process ref is the content id of the generated process bytes - assert_eq!( - a.campaign.process.r#ref, - DocRef::ContentId(content_id_of(&process_to_json(&a.process))) - ); - } - - #[test] - fn translate_sweep_refuses_a_mixed_kind_axis() { - let mixed = vec![("k".to_string(), vec![Scalar::I64(1), Scalar::F64(2.5)])]; - let err = translate_sweep(&inv(&mixed, "n", &["GER40"], None, "{}")).unwrap_err(); - assert!(err.contains("mixed value kinds"), "{err}"); - } - - /// Property (#224, pin moved to #240): `SugarInvocation.trace = true` - /// requests the tap vocabulary MINUS the net channel on the generated - /// campaign's `presentation.persist_taps` — the honest inverse of the - /// #168 no-op (where `persist_taps` stayed `vec![]` regardless of - /// `--trace`), narrowed to what the generated campaign's own empty cost - /// section can actually produce (#240: `net_r_equity` is unproducible - /// there and must be excluded). `trace = false` (the default, pinned by - /// the sibling `translate_sweep_is_deterministic_and_names_flow`) keeps - /// the vocabulary empty. Kept as its own translator-level pin alongside - /// the cross-translator `dissolved_trace_requests_only_producible_taps_ - /// excluding_the_net_channel` property test (#240): that test pins the - /// shared `persist_taps_from` seam once for both translators together, - /// this one keeps `translate_sweep`'s own regression independent of the - /// walkforward sibling so a translator-specific wiring break is not - /// masked by the other translator still passing. - #[test] - fn translate_sweep_trace_requests_the_producible_tap_subset() { - let ax = axes(); - let mut i = inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}"); - i.trace = true; - let g = translate_sweep(&i).unwrap(); - let want: Vec = vec!["equity".to_string(), "exposure".to_string(), "r_equity".to_string()]; - assert_eq!(g.campaign.presentation.persist_taps, want); - } - - #[test] - fn generated_campaign_validates_intrinsically_and_preflights() { - let ax = axes(); - let g = translate_sweep(&inv(&ax, "probe", &["GER40"], None, "{\"bp\":1}")).unwrap(); - assert!(aura_research::validate_campaign(&g.campaign).is_empty()); - assert!(aura_research::validate_process(&g.process).is_empty()); - assert!(aura_campaign::preflight(&g.process, &g.campaign).is_ok()); - } - - /// The one seam all four verbs share (#220): `stop: Some(VolStop)` binds the - /// single Vol regime; `stop: None` binds no regime (sweep's contract). - #[test] - fn sugar_invocation_stop_maps_to_the_vol_regime() { - let ax = axes(); - let mut i = inv(&ax, "n", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - let with = translate_sweep(&i).unwrap(); - assert_eq!(with.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); - i.stop = None; - let without = translate_sweep(&i).unwrap(); - assert!(without.campaign.risk.is_empty()); - } - - #[test] - fn translate_generalize_is_deterministic_and_carries_the_regime() { - let ax = g_axes(); - let i = inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - let a = translate_generalize(&i, "expectancy_r").unwrap(); - let b = translate_generalize(&i, "expectancy_r").unwrap(); - assert_eq!( - content_id_of(&campaign_to_json(&a.campaign)), - content_id_of(&campaign_to_json(&b.campaign)), - "identical invocations must generate identical campaign ids" - ); - // A selection-bearing pipeline: sweep(argmax) then generalize. - assert_eq!( - a.process.pipeline, - vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: "expectancy_r".to_string(), - select: SelectRule::Argmax, - deflate: false, - }) - }, - StageBlock::Generalize { metric: "expectancy_r".to_string() }, - ] - ); - // The stop rides a single risk regime (the structural axis). - assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); - // All instruments under one campaign; the candidate as single-value axes. - assert_eq!( - a.campaign.data.instruments, - vec!["GER40".to_string(), "USDJPY".to_string()] - ); - assert_eq!(a.campaign.strategies.len(), 1); - assert!(a.campaign.strategies[0].axes.contains_key("fast.length")); - assert!(a.campaign.strategies[0].axes.contains_key("slow.length")); - assert_eq!(a.process.name, "generalize"); - assert_eq!(a.campaign.name, "generalize"); - // Intrinsically valid + preflights (like the sweep sibling). - assert!(aura_research::validate_campaign(&a.campaign).is_empty()); - assert!(aura_research::validate_process(&a.process).is_empty()); - assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok()); - } - - /// Content-addressing correctness for the risk-regime axis (#210 T1-T2): - /// two invocations that differ ONLY in the protective stop (`stop_length`/ - /// `stop_k`) must generate campaigns with DIFFERENT content ids. The stop - /// is now carried through `CampaignDoc.risk` rather than as an opaque - /// runtime constant, so a hashing/serialization bug that dropped or - /// normalized-away the regime would silently collide two distinct - /// invocations onto one cached campaign — the wrong grade would be served - /// for one of them without any error. This pins that a regime change is - /// visible in the generated bytes, both against a same-length, - /// different-k pair and a different-length, same-k pair. - #[test] - fn translate_generalize_diverging_regimes_do_not_collide_content_ids() { - let ax = g_axes(); - let base = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); - let different_k = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); - let different_length = translate_generalize(&inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 20, k: 2.0 }), "{\"bp\":1}"), "expectancy_r").unwrap(); - let base_id = content_id_of(&campaign_to_json(&base.campaign)); - let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); - let length_id = content_id_of(&campaign_to_json(&different_length.campaign)); - assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id"); - assert_ne!( - base_id, length_id, - "a different stop_length must not collide onto the same campaign id" - ); - assert_ne!(k_id, length_id, "the two diverging regimes must not collide with each other"); - } - - /// The translator's output is not merely intrinsically valid in isolation - /// (preflight/validate never touch a store) — `register_generated_g` must - /// actually persist documents the campaign can resolve through a real - /// `Registry`: the campaign's `process.ref` content id must fetch back the - /// EXACT process the translator produced, and the returned `campaign_id` - /// must fetch back the exact campaign. A drift between what gets hashed - /// into the ref and what gets stored (e.g. a canonicalization mismatch) - /// would pass every static check here yet leave the campaign permanently - /// unresolvable at real run time — this is the property that would catch - /// that silently, before the dispatch rewire ever exercises the path live. - #[test] - fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() { - let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) - .join("aura-verb-sugar-generalize-registry-test"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); - - let ax = g_axes(); - let generated = translate_generalize( - &inv(&ax, "generalize", &["GER40", "USDJPY"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), - "expectancy_r", - ) - .unwrap(); - let (process_id, campaign_id) = register_generated_g(®, &generated).unwrap(); - - let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored"); - assert_eq!(stored_campaign, campaign_to_json(&generated.campaign)); - - let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else { - panic!("generalize's process ref is a content id"); - }; - assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref"); - let stored_process = reg.get_process(ref_id).unwrap().expect("process stored"); - assert_eq!(stored_process, process_to_json(&generated.process)); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn translate_walkforward_is_deterministic_and_carries_the_regime() { - let ax = wf_axes(); - let i = inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - let a = translate_walkforward(&i, "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - let b = translate_walkforward(&i, "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - assert_eq!( - content_id_of(&campaign_to_json(&a.campaign)), - content_id_of(&campaign_to_json(&b.campaign)), - "identical invocations must generate identical campaign ids" - ); - // pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) - assert_eq!( - a.process.pipeline, - vec![ - StageBlock::Grid, - StageBlock::WalkForward { - in_sample_ms: 7_776_000_000, - out_of_sample_ms: 2_592_000_000, - step_ms: 2_592_000_000, - mode: WfMode::Rolling, - metric: "sqn_normalized".to_string(), - select: SelectRule::Argmax, - }, - ] - ); - assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); - assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]); - assert_eq!(a.campaign.strategies.len(), 1); - // multi-value IS-refit grid axes - assert_eq!( - a.campaign.strategies[0].axes["fast.length"].values, - vec![Scalar::i64(3), Scalar::i64(5)] - ); - assert_eq!( - a.campaign.strategies[0].axes["slow.length"].values, - vec![Scalar::i64(12), Scalar::i64(20)] - ); - assert_eq!(a.process.name, "walkforward"); - assert!(aura_research::validate_campaign(&a.campaign).is_empty()); - assert!(aura_research::validate_process(&a.process).is_empty()); - assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok()); - } - - /// Property (#224, pin moved to #240): mirrors - /// `translate_sweep_trace_requests_the_producible_tap_subset` for - /// walkforward's translator — the walkforward sibling refusal lifts - /// identically, riding the same `persist_taps_from` seam, and the same - /// net-channel exclusion applies (#240: the generated campaign's empty - /// cost section cannot produce `net_r_equity`). Kept as its own - /// translator-level pin alongside the cross-translator - /// `dissolved_trace_requests_only_producible_taps_excluding_the_net_ - /// channel` property test for the same reason as the sweep sibling: a - /// walkforward-specific wiring break must not be masked by sweep alone - /// still passing. - #[test] - fn translate_walkforward_trace_requests_the_producible_tap_subset() { - let ax = wf_axes(); - let mut i = inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - i.trace = true; - let g = translate_walkforward(&i, "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - let want: Vec = vec!["equity".to_string(), "exposure".to_string(), "r_equity".to_string()]; - assert_eq!(g.campaign.presentation.persist_taps, want); - } - - /// Property (#240): a dissolved `--trace` invocation's generated campaign - /// requests only the taps its own document can produce. Every generated - /// sugar campaign carries an empty cost section (`cost: vec![]`) by - /// construction, so the net-channel tap `net_r_equity` is unproducible and - /// must be excluded — the requested set is the tap vocabulary MINUS the net - /// channel, concretely `["equity", "exposure", "r_equity"]`. Pinned for - /// both trace-bearing translators (sweep + walkforward), which share the - /// one `persist_taps_from` seam; without this the executor later prints an - /// unreachable "add a cost block to the campaign document" remedy for a tap - /// the generator knowingly ordered but its own document cannot produce. - #[test] - fn dissolved_trace_requests_only_producible_taps_excluding_the_net_channel() { - let want = - vec!["equity".to_string(), "exposure".to_string(), "r_equity".to_string()]; - - let sax = axes(); - let mut si = inv(&sax, "probe", &["GER40"], None, "{\"bp\":1}"); - si.trace = true; - let s = translate_sweep(&si).unwrap(); - assert_eq!( - s.campaign.presentation.persist_taps, want, - "sweep --trace must request only producible taps: the generated cost section is empty, so net_r_equity is unproducible and must be dropped" - ); - - let wax = wf_axes(); - let mut wi = inv(&wax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - wi.trace = true; - let w = translate_walkforward(&wi, "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - assert_eq!( - w.campaign.presentation.persist_taps, want, - "walkforward --trace must request only producible taps: the generated cost section is empty, so net_r_equity is unproducible and must be dropped" - ); - } - - #[test] - fn translate_walkforward_diverging_regimes_do_not_collide_content_ids() { - let ax = wf_axes(); - let base = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - let different_k = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - let different_length = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 20, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, SelectRule::Argmax).unwrap(); - let base_id = content_id_of(&campaign_to_json(&base.campaign)); - let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); - let length_id = content_id_of(&campaign_to_json(&different_length.campaign)); - assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id"); - assert_ne!( - base_id, length_id, - "a different stop_length must not collide onto the same campaign id" - ); - assert_ne!(k_id, length_id, "the two diverging regimes must not collide with each other"); - } - - #[test] - fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() { - let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) - .join("aura-verb-sugar-walkforward-registry-test"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); - let ax = wf_axes(); - let generated = translate_walkforward( - &inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), - "sqn_normalized", - WF_W, - SelectRule::Argmax, - ) - .unwrap(); - let (process_id, campaign_id) = register_generated_wf(®, &generated).unwrap(); - let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored"); - assert_eq!(stored_campaign, campaign_to_json(&generated.campaign)); - let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else { - panic!("walkforward's process ref is a content id"); - }; - assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref"); - let stored_process = reg.get_process(ref_id).unwrap().expect("process stored"); - assert_eq!(stored_process, process_to_json(&generated.process)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn translate_mc_is_deterministic_and_carries_the_regime_and_seed() { - let ax = wf_axes(); - let i = inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); - let a = translate_mc(&i, "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); - let b = translate_mc(&i, "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); - assert_eq!( - content_id_of(&campaign_to_json(&a.campaign)), - content_id_of(&campaign_to_json(&b.campaign)), - "identical invocations must generate identical campaign ids" - ); - // pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo - assert_eq!( - a.process.pipeline, - vec![ - StageBlock::Grid, - StageBlock::WalkForward { - in_sample_ms: 7_776_000_000, - out_of_sample_ms: 2_592_000_000, - step_ms: 2_592_000_000, - mode: WfMode::Rolling, - metric: "sqn_normalized".to_string(), - select: SelectRule::Argmax, - }, - StageBlock::MonteCarlo { resamples: 1000, block_len: 5 }, - ] - ); - assert_eq!(a.campaign.seed, 42, "the campaign seed carries the mc --seed"); - assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); - assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]); - assert_eq!(a.campaign.strategies.len(), 1); - assert_eq!( - a.campaign.strategies[0].axes["fast.length"].values, - vec![Scalar::i64(3), Scalar::i64(5)] - ); - assert_eq!( - a.campaign.strategies[0].axes["slow.length"].values, - vec![Scalar::i64(12), Scalar::i64(20)] - ); - assert_eq!(a.process.name, "mc"); - assert!(aura_research::validate_campaign(&a.campaign).is_empty()); - assert!(aura_research::validate_process(&a.process).is_empty()); - assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok()); - } - - #[test] - fn translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids() { - let ax = wf_axes(); - let base = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); - let different_seed = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 7 }).unwrap(); - let different_k = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); - let base_id = content_id_of(&campaign_to_json(&base.campaign)); - let seed_id = content_id_of(&campaign_to_json(&different_seed.campaign)); - let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); - assert_ne!(base_id, seed_id, "a different mc seed must not collide onto the same campaign id"); - assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id"); - assert_ne!(seed_id, k_id, "the two diverging documents must not collide with each other"); - } - - #[test] - fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() { - let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) - .join("aura-verb-sugar-mc-registry-test"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); - let ax = wf_axes(); - let generated = translate_mc( - &inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), - "sqn_normalized", - WF_W, - McKnobs { resamples: 1000, block_len: 5, seed: 42 }, - ) - .unwrap(); - let (process_id, campaign_id) = register_generated_mc(®, &generated).unwrap(); - let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored"); - assert_eq!(stored_campaign, campaign_to_json(&generated.campaign)); - let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else { - panic!("mc's process ref is a content id"); - }; - assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref"); - let stored_process = reg.get_process(ref_id).unwrap().expect("process stored"); - assert_eq!(stored_process, process_to_json(&generated.process)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A minimal `RunReport` stamped with `instrument`, distinguishable in a - /// members list by nothing but that stamp — the field - /// `cross_instrument_members` preserves verbatim from a nominating cell. - fn fake_report(instrument: &str) -> aura_backtest::RunReport { - aura_engine::RunReport { - manifest: aura_engine::RunManifest { - commit: String::new(), - params: vec![], - defaults: vec![], - window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)), - seed: 0, - broker: "test".to_string(), - selection: None, - instrument: Some(instrument.to_string()), - topology_hash: None, - project: None, - }, - metrics: aura_backtest::RunMetrics { - total_pips: 0.0, - max_drawdown: 0.0, - bias_sign_flips: 0, - r: None, - }, - } - } - - fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome { - aura_campaign::CellOutcome { - families: vec![], - selections: vec![], - nominee: nominee_instrument.map(|i| (vec![], fake_report(i))), - } - } - - /// #272: `cross_instrument_members` skips a cell with no nominee (a - /// failed cell nominates nothing) rather than padding the members list or - /// aborting — the surviving cells' reports still reach the persisted - /// `CrossInstrument` family, in cell order, with the failed cell simply - /// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC - /// nominates) must yield exactly the two survivors' reports, in order. - #[test] - fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() { - let outcome = aura_campaign::CampaignOutcome { - record: aura_registry::CampaignRunRecord { - campaign: String::new(), - process: String::new(), - run: 0, - seed: 0, - cells: vec![], - generalizations: vec![], - trace_name: None, - }, - run: 0, - cells: vec![ - cell_outcome(Some("AAA")), - cell_outcome(None), - cell_outcome(Some("CCC")), - ], - }; - let members = cross_instrument_members(&outcome); - let instruments: Vec> = - members.iter().map(|r| r.manifest.instrument.clone()).collect(); - assert_eq!( - instruments, - vec![Some("AAA".to_string()), Some("CCC".to_string())], - "the failed (no-nominee) cell is skipped, the two survivors keep their order" - ); - } -} diff --git a/crates/aura-cli/tests/cli_broken_pipe.rs b/crates/aura-cli/tests/cli_broken_pipe.rs index 8a4ecdc..e59ad29 100644 --- a/crates/aura-cli/tests/cli_broken_pipe.rs +++ b/crates/aura-cli/tests/cli_broken_pipe.rs @@ -40,36 +40,26 @@ const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] }"#; -/// Seed one blueprint (its params bound, reopened by the sweep axes per -/// #246) into the built demo project's store via a real sweep and return -/// its content id — copied verbatim from `research_docs.rs`'s recipe of the -/// same name. +/// Seed one blueprint into the built demo project's store via `aura graph +/// register` and return its content id — copied verbatim from +/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep` +/// side-effect seeding this used to ride played no role in the returned +/// content id, a pure function of the loaded blueprint's canonical bytes; +/// `name` labels the registered id, mirroring the old per-call sweep family +/// name). fn seed_blueprint(dir: &Path, name: &str) -> String { let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let (out, code) = run_code_in( - dir, - &[ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - name, - ], - ); - assert_eq!(code, Some(0), "seed sweep failed: {out}"); - std::fs::read_dir(dir.join("runs").join("blueprints")) - .expect("blueprints dir") + let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]); + assert_eq!(code, Some(0), "seed register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') .next() - .expect("one stored blueprint") - .expect("dir entry") - .path() - .file_stem() - .expect("stem") - .to_string_lossy() - .into_owned() + .expect("id") + .trim_start_matches("content:") + .to_string() } /// Register `doc` as a process document in the project store; returns its diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index b776cf1..019dfb4 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -21,260 +21,213 @@ fn temp_cwd(name: &str) -> std::path::PathBuf { dir } -/// #218: a well-formed dissolved verb run outside a project refuses up front -/// (exit 1, "needs a project") and leaves no store — parity with `campaign run`. -/// The refusal names the INVOKED verb, not a copy-pasted generic string — a -/// per-verb literal shared across four call sites (`validate_and_register_axes` -/// takes `verb` as a parameter) is exactly the kind of edit that silently -/// regresses to one hardcoded verb name; this pins that each call site still -/// threads its own name through. -#[test] -fn sweep_outside_project_refuses_and_leaves_no_store() { - let cwd = temp_cwd("sweep-outside-project-refuses"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--real", "GER40", "--axis", "fast.length=2,4"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - assert!(String::from_utf8_lossy(&out.stderr).contains("sweep needs a project")); - assert!(!cwd.join("runs").exists(), "a refused run must leave no store"); +// --------------------------------------------------------------------------- +// Campaign-document scaffold (#319 Task 8 Step 4 port). Retired-verb argv +// tests that reach engine/campaign semantics are ported onto a hand-authored +// campaign document driven through `exec` — the surviving executor. This +// mirrors `tests/exec.rs`/`tests/research_docs.rs`'s idiom of the same names; +// duplicated here (rather than shared) because each integration-test binary +// compiles independently and cannot see another binary's private items. +// --------------------------------------------------------------------------- + +/// Run the built binary in `dir`, returning combined stdout+stderr and the +/// exit code — the `exec.rs`/`research_docs.rs` idiom, used by every campaign- +/// document port below. +fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option) { + let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs"); + let text = + format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + (text, out.status.code()) } -/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`, -/// for `generalize`'s instrument-list form — the message names `generalize`, -/// not `sweep` (see that test's doc comment for the property). -#[test] -fn generalize_outside_project_refuses_and_leaves_no_store() { - let cwd = temp_cwd("generalize-outside-project-refuses"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["generalize", &bp, "--real", "GER40,USDJPY", "--axis", "fast.length=2"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - assert!(String::from_utf8_lossy(&out.stderr).contains("generalize needs a project")); - assert!(!cwd.join("runs").exists(), "a refused run must leave no store"); +fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf { + let p = dir.join(name); + std::fs::write(&p, text).expect("write doc"); + p } -/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`, -/// for `walkforward`'s valid `--axis` form — the message names `walkforward`, -/// not `sweep` (see that test's doc comment for the property). -#[test] -fn walkforward_outside_project_refuses_and_leaves_no_store() { - let cwd = temp_cwd("walkforward-outside-project-refuses"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--real", "GER40", "--axis", "fast.length=2,4"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - assert!(String::from_utf8_lossy(&out.stderr).contains("walkforward needs a project")); - assert!(!cwd.join("runs").exists(), "a refused run must leave no store"); +/// Seed the shipped closed `examples/r_sma.json` blueprint into the project's +/// store via `aura graph register` and return its content id. +fn seed_blueprint(dir: &Path, name: &str) -> String { + let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]); + assert_eq!(code, Some(0), "seed register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() } -/// #218: same refusal as `sweep_outside_project_refuses_and_leaves_no_store`, -/// for `mc`'s valid `--axis` form — the message names `mc`, not `sweep` (see -/// that test's doc comment for the property). -#[test] -fn mc_outside_project_refuses_and_leaves_no_store() { - let cwd = temp_cwd("mc-outside-project-refuses"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["mc", &bp, "--real", "GER40", "--axis", "fast.length=2,4"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - assert!(String::from_utf8_lossy(&out.stderr).contains("mc needs a project")); - assert!(!cwd.join("runs").exists(), "a refused run must leave no store"); +/// Seed an arbitrary blueprint FILE (not necessarily `r_sma.json`) into the +/// project's store, returning its content id. +fn seed_blueprint_file(dir: &Path, fixture: &str, name: &str) -> String { + let (out, code) = run_code_in(dir, &["graph", "register", fixture, "--name", name]); + assert_eq!(code, Some(0), "seed register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() } -/// Property (#275, retrofitted): `run_signal_r` now keys its opened sources by -/// role name (`h.run_bound(key_supply(&binding, sources))`) instead of feeding -/// the harness positionally (`h.run(sources)`). This pre-existing happy-path -/// pin therefore doubles as the by-name-binding transparency check for that one -/// call site: a well-bound strategy's by-name path resolves back to the exact -/// same source order `bind_sources` was given, so the migration is invisible -/// here — unchanged exit code, unchanged JSON output byte-for-byte. It does NOT -/// exercise `run_blueprint_member` or the campaign re-run/trace path — those -/// two migrated call sites are covered by the sweep/campaign e2e fixtures -/// elsewhere in this crate, not by this test. -#[test] -fn run_prints_json_and_exits_zero() { - // `run` is blueprint-only now (#159 cut 4 retired the bare built-in default); - // the shipped r-sma example is the deterministic reference blueprint. - let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run"); - assert!(out.status.success(), "exit status: {:?}", out.status); - - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - // exactly one line (the JSON object + a trailing newline from println!). - assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); - let line = stdout.trim_end(); - - // canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys. - // The manifest leads with a `commit` field; its value is the build's git - // identity (asserted by `run_manifest_commit_carries_real_git_head`), so - // this shape check pins only the surrounding structure, not the value. - assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}"); - assert!(line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=0.0001)\""), "got: {line}"); - assert!(line.contains("\"window\":[1,18]"), "got: {line}"); - assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}"); - // the r-sma harness carries an R block (unlike the retired pip-only default). - assert!(line.contains("\"r\":{\"expectancy_r\":"), "got: {line}"); +/// Register `doc` as a process document in the project store; returns its id. +fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String { + write_doc(dir, file, doc); + let (out, code) = run_code_in(dir, &["process", "register", file]); + assert_eq!(code, Some(0), "process register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() } -/// Property (#249): a plain `aura run` on a fully bound blueprint records the -/// untouched bound param values *directly in the outcome artifact*, so a raw -/// reader of the manifest recovers the effective params without dereferencing -/// the topology hash into the content-addressed store and re-implementing -/// aura's merge semantics. The manifest carries a `defaults` field — the bound -/// params merged inside `PrimitiveBuilder::build` below every record-writing -/// layer — as RAW `(name, Scalar)` pairs (#328: `bound_param_space()`'s own -/// path-qualified name, no wrap-prefix concatenation) in `bound_param_space()` -/// order (the same coordinate `--axis` uses). `params` keeps its "what varied" -/// meaning: on a plain run nothing varied, so it stays `[]`, and every bound -/// value lands in `defaults` instead of vanishing from the record. -#[test] -fn run_manifest_stamps_untouched_bound_defaults() { - // The shipped r_sma.json is fully bound (fast.length=2, slow.length=4, - // bias.scale=0.5); no axis re-opens any of them, so none flow through the - // param space — today they appear in no outcome artifact ("params":[]). - let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run"); - assert!(out.status.success(), "exit status: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let line = stdout.trim_end(); +/// A minimal executable `[std::sweep]` process (one selection-bearing stage) — +/// mirrors `exec.rs`/`research_docs.rs`'s `SWEEP_ONLY_PROCESS_DOC`. +const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; - // "what varied" is empty on a plain run — the bound defaults are NOT params. - assert!(line.contains("\"params\":[]"), "params stays empty on a plain run: {line}"); +/// A `[std::grid, std::walk_forward]` process — the #256 fork B shape a +/// hand-authored (non-sugar) op-script uses — mirrors `exec.rs`/ +/// `research_docs.rs`'s `GRID_THEN_WF_PROCESS_DOC`. `std::grid` only +/// enumerates (persists no family of its own); `std::walk_forward` does the +/// per-window IS-refit and persists the one `WalkForward` family. +const GRID_THEN_WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" } + ] +}"#; - // The untouched bound values are stamped in `defaults`, RAW (#328) and - // ordered by `bound_param_space()` (node-declaration order: fast, slow, - // bias), each a self-describing tagged Scalar (C14). - assert!( - line.contains( - r#""defaults":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}]]"# - ), - "manifest must carry the untouched bound defaults: {line}", - ); +/// A `[std::grid, std::walk_forward]` process using the FIXED real-archive +/// roller (`aura_backtest::wf_ms_sizes`, 90d IS / 30d OOS / 30d step — the +/// retired sugar's `fit_wf_ms_sizes` fast-path over a year-plus window) — +/// needed for the exact-grade byte-identity anchors, which pin the retired +/// verb's own fixed real-data roller, unlike `GRID_THEN_WF_PROCESS_DOC`'s +/// shorter 14d/7d/7d roller (sized for the one-month e2e windows elsewhere +/// in this file/`exec.rs`/`research_docs.rs`). +const GRID_THEN_WF_PROCESS_DOC_90_30_30: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-90-30-30", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" } + ] +}"#; + +/// A `[std::grid, std::walk_forward, std::monte_carlo]` process — mirrors +/// `translate_mc`'s generated shape exactly (the retired `mc --real` sugar): +/// the leading `std::grid` only enumerates, `std::walk_forward` persists the +/// one family, and the terminal `std::monte_carlo` r-bootstraps the pooled +/// per-window OOS trade-R series (`StageBootstrap::PooledOos`). +const GRID_THEN_WF_THEN_MC_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 100, "block_len": 5 } + ] +}"#; + +/// A `[std::sweep, std::generalize]` process — mirrors `translate_generalize`'s +/// generated shape exactly (the retired `generalize` sugar): the sweep stage +/// is selection-bearing (a single-value axis grid, so selection is trivial), +/// and `std::generalize` computes the cross-instrument grade. +const SWEEP_THEN_GENERALIZE_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-then-generalize", + "pipeline": [ + { "block": "std::sweep", "metric": "expectancy_r", "select": "argmax" }, + { "block": "std::generalize", "metric": "expectancy_r" } + ] +}"#; + +/// A one-instrument, one-window, N-axis-value campaign document over the +/// seeded `r_sma.json` blueprint — the dissolved sugar's own generated shape +/// (`translate_sweep`/`translate_walkforward`/`translate_mc`): axes are the +/// RAW `.` names (#328), `seed: 7` (an arbitrary constant, the +/// sugar campaign path never threads a sweep seed either). No risk section +/// (every call site here is stop-free); build the document directly when a +/// risk regime is needed. +fn sweep_campaign_doc( + name: &str, + instrument: &str, + bp_id: &str, + proc_id: &str, + window: (i64, i64), + fast_values: &str, + slow_values: &str, +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "{name}", + "data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [{fast_values}] }}, + "slow.length": {{ "kind": "I64", "values": [{slow_values}] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = window.0, + to = window.1, + ) } -/// Property: the RunManifest is self-identifying — its `commit` carries the -/// real code identity that produced the run, not a placeholder. C8/C18 audit -/// trail (this run = this commit): once runs are archived, `manifest.commit` -/// must point back at the source. The build captures the current git HEAD at -/// compile time; the `"unknown"` literal survives only when there is no git. -/// -/// Structural assertion (not exact-equality) on purpose: the working tree may -/// be dirty when this runs (e.g. this very test file uncommitted), so a build -/// that appends a `-dirty` suffix would not equal `rev-parse HEAD` verbatim. -/// The honest contract is: the field CONTAINS the current HEAD sha AND is not -/// the bare `"unknown"` placeholder — true regardless of dirtiness/suffix. -#[test] -fn run_manifest_commit_carries_real_git_head() { - // The current HEAD sha, obtained the same way the build is expected to. - let head = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .expect("spawn git rev-parse HEAD"); - assert!(head.status.success(), "git rev-parse HEAD failed"); - let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha"); - let head_sha = head_sha.trim(); - assert!(!head_sha.is_empty(), "empty HEAD sha"); - - // Drive the binary and pull `manifest.commit` out of the single JSON line. - // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example stands in. - let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run"); - assert!(out.status.success(), "exit status: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let line = stdout.trim_end(); - - // Locate the `"commit":""` value without a JSON dependency (the - // sibling tests parse this output by substring too). - let key = "\"commit\":\""; - let start = line.find(key).expect("manifest has a commit field") + key.len(); - let rest = &line[start..]; - let end = rest.find('"').expect("commit field is a closed string"); - let commit = &rest[..end]; - - assert_ne!( - commit, "unknown", - "manifest.commit is still the placeholder: {line}" - ); - assert!( - commit.contains(head_sha), - "manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}; line: {line}" - ); -} - -/// Property: `aura run` is strict about its argument vector — a trailing -/// unexpected token (e.g. a typo'd flag like `aura run --sweep`) takes the -/// error path (usage on stderr, exit 2), never a silent full run. This is the -/// strict reading ratified in #16: keying only on the first token being `run` -/// and ignoring the rest would let a typo masquerade as a successful run. -/// The same test pins positive-preservation — a valid `aura run ` -/// still emits the JSON report on stdout and exits 0 — so the strict guard cannot -/// regress the happy path. (`run` is blueprint-only now, #159 cut 4: there is no -/// more bare/built-in default to preserve, so the positive case uses the shipped -/// r-sma example.) -#[test] -fn run_with_trailing_token_is_strict_and_exits_two() { - // Negative: an unexpected trailing token is rejected like a bad-args call. - let out = Command::new(BIN) - .args(["run", "extra-garbage"]) - .output() - .expect("spawn aura run extra-garbage"); - assert_eq!( - out.status.code(), - Some(2), - "`aura run extra-garbage` must reject the trailing token: {:?}", - out.status - ); - assert!( - out.stdout.is_empty(), - "stdout should be empty on the usage path, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); - let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); - assert!(stderr.contains("Usage"), "stderr was: {stderr:?}"); - - // Positive-preservation: a valid blueprint invocation still runs and prints the report. - let ok = Command::new(BIN) - .args(["run", "examples/r_sma.json"]) - .output() - .expect("spawn aura run examples/r_sma.json"); - assert_eq!( - ok.status.code(), - Some(0), - "a valid `aura run ` must still succeed: {:?}", - ok.status - ); - let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout"); - assert!( - ok_stdout.trim_start().starts_with("{\"manifest\":"), - "a valid run should print the JSON report, got: {ok_stdout:?}" - ); -} +/// Property (ported onto a one-cell campaign document, #319 Task 8 — the +/// retired `run --real` verb's own hard exit-1/no-stdout contract does not +/// survive the campaign path's #272 contained-fault model, but the pip-honesty +/// substance does): the geometry-sidecar lookup precedes any bar-data access, +/// so a symbol with no recorded geometry refuses with NO local data required +/// (CI-safe). "NONEXISTENT" has no recorded geometry on any host. A single-cell +/// campaign realizes this as a CONTAINED cell fault (#272: `aura: warning: ` +/// class marker, deliberate exit 3, never a hard exit 1) — the pip lookup still +/// fires before any bar-data access, and the fault's own detail carries the +/// SAME "no recorded geometry" message `run --real` used to print to stderr. #[test] fn run_real_no_geometry_symbol_refuses_with_exit_1() { - // The geometry-sidecar lookup precedes any bar-data access, so a symbol with no - // recorded geometry refuses with NO local data required (CI-safe). "NONEXISTENT" - // has no recorded geometry on any host. `run` is blueprint-only now (#159 cut 4); - // the shipped r-sma example reaches the same pip-refusal gate in `run_data_from`. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "expected exit 1"); - let stderr = String::from_utf8_lossy(&out.stderr); + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "geo-refusal-seed"); + let proc_id = register_process_doc(&dir, "geo.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("geo", "NONEXISTENT", &bp_id, &proc_id, (1, 2), "2", "8"); + write_doc(&dir, "geo.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "geo.campaign.json"]); + assert_eq!(code, Some(3), "a contained no-geometry cell fault is exit 3, never exit 1: {out}"); assert!( - stderr.contains("no recorded geometry for symbol 'NONEXISTENT'"), - "expected the per-instrument-pip refusal message, got: {stderr}" + out.contains("no recorded geometry for symbol 'NONEXISTENT'"), + "expected the per-instrument-pip refusal message in the contained fault, got: {out}" ); + assert!(out.contains("aura: warning: "), "the fault carries the #272 class marker: {out}"); } /// Property: the pip-refusal is sourced ONLY from recorded provider geometry — the @@ -286,251 +239,160 @@ fn run_real_no_geometry_symbol_refuses_with_exit_1() { /// instrument table" wording) would re-leak this vocabulary. Asserts the OLD /// authored-floor phrasing is ABSENT — the negative complement to the positive /// "no recorded geometry" assertion above. Archive-independent: "NONEXISTENT" has -/// no sidecar on any host, so the refusal fires without local data. +/// no sidecar on any host, so the refusal fires without local data. Ported onto +/// the campaign path's contained fault (see the sibling above for the exit-code +/// rationale). #[test] fn run_real_refusal_names_no_authored_floor() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "expected exit 1"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("instrument table"), - "refusal must not point at a removed authored table, got: {stderr}" - ); - assert!( - !stderr.contains("vetted"), - "refusal must not use the removed vetted-floor vocabulary, got: {stderr}" - ); - assert!( - !stderr.contains("instrument spec"), - "refusal must not reference the removed authored instrument floor, got: {stderr}" - ); + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "geo-floor-seed"); + let proc_id = register_process_doc(&dir, "geofloor.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("geofloor", "NONEXISTENT", &bp_id, &proc_id, (1, 2), "2", "8"); + write_doc(&dir, "geofloor.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "geofloor.campaign.json"]); + assert_eq!(code, Some(3), "a contained no-geometry cell fault is exit 3: {out}"); + assert!(!out.contains("instrument table"), "refusal must not point at a removed authored table, got: {out}"); + assert!(!out.contains("vetted"), "refusal must not use the removed vetted-floor vocabulary, got: {out}"); + assert!(!out.contains("instrument spec"), "refusal must not reference the removed authored instrument floor, got: {out}"); } -/// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING -/// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD -/// of emitting nonsense": the pip lookup precedes harness construction and data -/// access, so a `None` spec must short-circuit to `exit(1)` before any JSON line -/// can reach stdout. A regression that moved the lookup after a partial run would -/// leak a `{"manifest":...}` line here while still exiting non-zero; this pins -/// that it cannot. Archive-independent (the lookup never touches local data). +/// Property (adapted for the campaign path's always-on `{"campaign_run":...}` +/// summary line, #319 Task 8): the un-specced-symbol refusal never leaks a +/// partial `RunReport` — the #22 acceptance is "refuse INSTEAD of emitting +/// nonsense". The retired `run --real` verb's OWN packaging of that refusal +/// was silence on stdout entirely; the campaign executor always emits its one +/// `{"campaign_run":...}` audit line regardless of outcome (#272's whole +/// point — a fault is RECORDED, not swallowed), so "no stdout at all" cannot +/// survive verbatim. What DOES survive: the faulted cell carries NO `report` +/// key at all (no partial manifest/metrics — only a `fault`), and no +/// `family_table` member line is emitted for it either. #[test] fn run_real_no_geometry_symbol_emits_no_stdout_report() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(1), "expected exit 1: {:?}", out.status); + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "geo-nostdout-seed"); + let proc_id = register_process_doc(&dir, "geonostdout.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("geonostdout", "NONEXISTENT", &bp_id, &proc_id, (1, 2), "2", "8"); + write_doc(&dir, "geonostdout.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "geonostdout.campaign.json"]); + assert_eq!(code, Some(3), "a contained no-geometry cell fault is exit 3: {out}"); assert!( - out.stdout.is_empty(), - "the refusal must not leak a partial report to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) + !out.contains("\"report\":"), + "the refusal must not leak a partial report for the faulted cell, got: {out}" ); + assert!( + !out.lines().any(|l| l.starts_with("{\"family_id\":")), + "no family_table member line for a cell that never ran: {out}" + ); + assert!(out.contains("\"fault\":"), "the faulted cell is recorded as a fault, not swallowed: {out}"); } -/// Property: the pip-honesty refusal keys on the SYMBOL having no recorded geometry, -/// not on `--real` being taken at all. A symbol *with* a recorded sidecar (`EURUSD`) -/// gets PAST the geometry lookup, so it never produces the pip-refusal message — it -/// either runs to a report (local data present) or hits the DISTINCT no-local-data -/// usage path ("no local data for symbol"). Either way the "no recorded geometry" -/// string is absent. This distinguishes the per-instrument-pip honesty lever from -/// the unrelated arg-grammar / no-data error paths, and is archive-independent -/// (asserts only on the absence of the pip-refusal string, true whether or not -/// EURUSD data is on the machine). +/// Property (ported onto a one-cell campaign document): the pip-honesty refusal +/// keys on the SYMBOL having no recorded geometry, not on real data being +/// requested at all. A symbol *with* a recorded sidecar (`EURUSD`) gets PAST +/// the geometry lookup, so it never produces the pip-refusal message — it +/// either runs to a report (local data present) or hits the DISTINCT +/// no-local-data path ("no local data for symbol"), both contained per #272 +/// (exit 0 success, or exit 3 with the no-data fault detail — never the +/// geometry-refusal wording). #[test] fn run_real_sidecar_symbol_never_hits_the_pip_refusal() { - // A bounded window (the same EURUSD 2024-06 calendar month the gated sweep tests - // use) keeps this fast; the full archive is unbounded and not needed for a - // pip-lookup property. `run` is blueprint-only now (#159 cut 4). - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "run", "examples/r_sma.json", "--real", "EURUSD", - "--from", &EURUSD_JUN2024_FROM_MS.to_string(), - "--to", &EURUSD_JUN2024_TO_MS.to_string(), - ]) - .output() - .expect("spawn aura"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("no recorded geometry"), - "a symbol with a recorded sidecar must clear the pip lookup, never the pip-refusal; stderr: {stderr}" + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "eurusd-sidecar-seed"); + let proc_id = register_process_doc(&dir, "eurusd.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "eurusd-sidecar", "EURUSD", &bp_id, &proc_id, + (EURUSD_JUN2024_FROM_MS, EURUSD_JUN2024_TO_MS), "2", "8", ); - // It resolves to exactly one of the two legitimate outcomes: a clean report - // (exit 0, data present) or the distinct no-local-data refusal (exit 1). - match out.status.code() { + write_doc(&dir, "eurusd.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "eurusd.campaign.json"]); + assert!( + !out.contains("no recorded geometry"), + "a symbol with a recorded sidecar must clear the pip lookup, never the pip-refusal: {out}" + ); + match code { Some(0) => assert!( - String::from_utf8_lossy(&out.stdout) - .trim_start() - .starts_with("{\"manifest\":"), - "exit 0 must carry a JSON report, got: {:?}", - String::from_utf8_lossy(&out.stdout) + out.lines().any(|l| l.starts_with("{\"family_id\":")), + "exit 0 must carry a family member report: {out}" ), - Some(1) => assert!( - stderr.contains("no local data for symbol 'EURUSD'"), - "exit 1 for a symbol with a recorded sidecar must be the no-data path, got: {stderr}" + Some(3) => assert!( + out.contains("no local data for symbol 'EURUSD'"), + "a contained fault for a symbol with a recorded sidecar must be the no-data path, got: {out}" ), - other => panic!("unexpected exit for a real run with a recorded sidecar: {other:?}; stderr: {stderr}"), + other => panic!("unexpected exit for a real cell with a recorded sidecar: {other:?}; out: {out}"), } } -/// Property: a recorded-sidecar symbol threads its looked-up pip all the way to the -/// binary's emitted manifest — `aura run --real GER40` renders the INDEX pip -/// `sim-optimal(pip_size=1)`, not the FX `0.0001` literal the synthetic path uses. -/// This is the #22 headline at the built-binary boundary (criterion 2: GER40=1.0 -/// vs EURUSD=0.0001 vs no-geometry→refuse): the metadata channel reaches stdout, so -/// equity is honestly scaled per instrument. Gated on local GER40 data — skip -/// cleanly when the archive is absent (the project's skip-on-no-data convention), -/// so it never fails on a data-less machine; the no-data path is the distinct -/// "no local data" refusal (exit 1), not the pip-refusal, asserted above. +/// Property (ported onto a one-cell campaign document): a recorded-sidecar +/// symbol threads its looked-up pip all the way to the emitted member +/// manifest — GER40 renders the INDEX pip `sim-optimal(pip_size=1)`, not the +/// FX `0.0001` literal the synthetic path uses. This is the #22 headline at +/// the built-binary boundary: the metadata channel reaches stdout, so equity +/// is honestly scaled per instrument. Gated on local GER40 data — skips +/// cleanly when the archive is absent (a contained no-data fault, #272, +/// rather than the old hard exit 1). #[test] fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() { // The GER40 Sept-2024 window (inclusive Unix-ms), the same calendar // month the gated ingest path drives; bounding it keeps the run fast. - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still - // routes through the same real-data pip lookup + manifest stamping. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) - .output() - .expect("spawn aura"); + const FROM_MS: i64 = 1725148800000; + const TO_MS: i64 = 1727740799999; + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "ger40-indexpip-seed"); + let proc_id = register_process_doc(&dir, "ger40pip.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("ger40pip", "GER40", &bp_id, &proc_id, (FROM_MS, TO_MS), "2", "8"); + write_doc(&dir, "ger40pip.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "ger40pip.campaign.json"]); - // Skip on a data-less machine: a recorded-sidecar symbol with no local data takes - // the distinct no-data path (exit 1, "no local data"), never the pip-refusal. - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data for symbol 'GER40'"), - "exit 1 for GER40 must be the no-data path, got: {stderr}" - ); + // Skip on a data-less machine: a recorded-sidecar symbol with no local data + // takes the distinct no-data path (contained fault, exit 3), never the + // pip-refusal. + if code == Some(3) && out.contains("no local data for symbol 'GER40'") { eprintln!("skip: no local GER40 data"); return; } - - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let line = stdout.trim_end(); + assert_eq!(code, Some(0), "exit: {code:?}, out: {out}"); // the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001. assert!( - line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=1)\""), - "GER40 must render the index pip in the manifest, got: {line}" + out.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=1)\""), + "GER40 must render the index pip in the manifest, got: {out}" ); } -/// Property: the authored instrument table no longer gates real runs — a symbol -/// absent from the (removed) vetted floor but carrying a recorded geometry sidecar -/// resolves its pip from the sidecar and runs. USDJPY (never vetted; JPY pip 0.01) -/// renders the JPY pip in the manifest, proving the pip is sourced from the provider -/// geometry, not a hand-authored table entry. Gated: skips cleanly when USDJPY has -/// no local geometry/data (no sidecar → "no recorded geometry"; sidecar but no bars -/// → "no local data"). +/// Property (ported onto a one-cell campaign document): the authored instrument +/// table no longer gates real runs — a symbol absent from the (removed) +/// vetted floor but carrying a recorded geometry sidecar resolves its pip from +/// the sidecar and runs. USDJPY (never vetted; JPY pip 0.01) renders the JPY +/// pip in the manifest, proving the pip is sourced from the provider geometry, +/// not a hand-authored table entry. Gated: skips cleanly (contained fault, +/// #272) when USDJPY has no local geometry/data. #[test] fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() { // FX trades continuously; the GER40 Sept-2024 window also has USDJPY bars. - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still - // routes through the same real-data pip lookup. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json", "--real", "USDJPY", "--from", FROM_MS, "--to", TO_MS]) - .output() - .expect("spawn aura"); + const FROM_MS: i64 = 1725148800000; + const TO_MS: i64 = 1727740799999; + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "usdjpy-sidecar-seed"); + let proc_id = register_process_doc(&dir, "usdjpy.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("usdjpy", "USDJPY", &bp_id, &proc_id, (FROM_MS, TO_MS), "2", "8"); + write_doc(&dir, "usdjpy.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "usdjpy.campaign.json"]); - // Skip on a host without USDJPY geometry/data — either refusal is a clean skip, - // sourced from recorded geometry, never an authored-table miss. - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - if stderr.contains("no recorded geometry") || stderr.contains("no local data") { - eprintln!("skip: no local USDJPY geometry/data"); - return; - } + // Skip on a host without USDJPY geometry/data — either refusal is a clean + // contained-fault skip, sourced from recorded geometry, never an + // authored-table miss. + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no local data")) { + eprintln!("skip: no local USDJPY geometry/data"); + return; } - - assert_eq!(out.status.code(), Some(0), "USDJPY should resolve and run; exit: {:?}, stderr: {}", - out.status, String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert_eq!(code, Some(0), "USDJPY should resolve and run; exit: {code:?}, out: {out}"); assert!( - stdout.contains("pip_size=0.01"), - "USDJPY must render the JPY sidecar pip (0.01) in the manifest, got: {}", - stdout.trim_end() + out.contains("pip_size=0.01"), + "USDJPY must render the JPY sidecar pip (0.01) in the manifest, got: {out}" ); } -/// `aura run ` loads a serialized signal blueprint and runs it -/// end-to-end (#165): exit 0, a RunReport whose manifest carries the topology_hash. -#[test] -fn aura_run_loads_and_runs_a_blueprint_file() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json"]) - .output() - .expect("spawn aura run blueprint"); - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr={}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}"); -} -/// A blueprint referencing a node type outside the closed vocabulary fails clean -/// at load (the #160 closed-set guard at the data-plane face; invariant 9), and -/// (#184) the failure is phrased as house-style prose — matching the `graph -/// build` presenter's wording — never the raw `UnknownNodeType` Debug leak. -#[test] -fn aura_run_rejects_an_unknown_node_blueprint() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "tests/fixtures/unknown_node.json"]) - .output() - .expect("spawn aura run unknown"); - assert_ne!(out.status.code(), Some(0), "an unknown node type must fail the run"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!( - stderr.contains(r#"unknown node type "Nope""#), - "house-style prose names the type: {stderr}" - ); - assert!( - !stderr.contains("UnknownNodeType"), - "does not leak the Debug variant name: {stderr}" - ); -} -/// Property (#175, refuse-don't-guess): built-in-only flags on `aura run -/// ` are REFUSED, not silently dropped. The loaded-blueprint -/// grammar accepts only `--params`/`--seed`/`--real`/`--from`/`--to`; the removed -/// hand-parser rejected any other flag (exit 2). clap's optional `[blueprint]` -/// positional makes `--trace` structurally parseable (it stays on `RunCmd` for -/// the built-in-harness grammar), so the dispatch guard must re-reject it — -/// mirroring the sweep/mc blueprint branches, which reject their non-branch -/// flags exhaustively. A regression that dropped the guard would silently -/// no-op a user-provided flag. (The cost flags this test used to also cover -/// were removed from `RunCmd` entirely in #159's builtin retirement; clap now -/// rejects them as unknown args before dispatch ever sees them, so they no -/// longer exercise this guard.) -#[test] -fn run_blueprint_rejects_builtin_only_flags_exit_two() { - let args = ["run", "examples/r_sma.json", "--trace", "foo"]; - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(args) - .output() - .expect("spawn aura run blueprint + builtin-only flag"); - assert_eq!( - out.status.code(), - Some(2), - "{args:?} must be refused (exit 2), got {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - assert!( - out.stdout.is_empty(), - "{args:?} must not emit a report on stdout: {:?}", - String::from_utf8_lossy(&out.stdout) - ); -} // Note (#159 cut 4 collateral): `--trace` on a single `aura run`/`aura sweep` // is no longer reachable from any CLI surface — the built-in default that @@ -874,56 +736,7 @@ fn no_args_prints_usage_and_exits_two() { assert!(stderr.contains("Usage"), "stderr was: {stderr:?}"); } -/// Property: `--help`/`-h` is the success-path help affordance, not the error -/// path. A newcomer's reflex first command (C22 first-contact ergonomics, #20) -/// prints the usage to stdout and exits 0; the conventional `-h` alias behaves -/// identically. An *unknown* subcommand keeps the error path (exit 2) so the -/// help fix does not regress bad-args handling. -#[test] -fn help_flag_prints_usage_to_stdout_and_exits_zero() { - for flag in ["--help", "-h"] { - let out = Command::new(BIN).arg(flag).output().expect("spawn aura --help"); - assert_eq!(out.status.code(), Some(0), "`aura {flag}` exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!( - !stdout.trim().is_empty(), - "`aura {flag}` should print help to stdout, got: {stdout:?}" - ); - // The usage names the only subcommand a newcomer can run. - assert!( - stdout.contains("run"), - "`aura {flag}` help should mention the `run` subcommand, got: {stdout:?}" - ); - } - // Negative-preservation: an unknown subcommand is still the error path. - let out = Command::new(BIN).arg("frobnicate").output().expect("spawn aura frobnicate"); - assert_eq!( - out.status.code(), - Some(2), - "unknown subcommand must stay exit 2: {:?}", - out.status - ); -} - -/// Property (post-clap migration, #175): subcommand help is SCOPED — `aura -/// --help` (and `-h`) prints that subcommand's own options to stdout and exits 0, -/// with nothing on stderr, for every subcommand. This supersedes the retired #131 -/// "uniform (byte-identical) help" surface: clap gives each subcommand its own -/// scoped Options section rather than one shared global usage blob. -#[test] -fn per_subcommand_help_is_scoped_stdout_exit_zero() { - for sub in ["run", "chart", "sweep", "walkforward", "mc", "graph", "runs"] { - for help in ["--help", "-h"] { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([sub, help]).output().expect("spawn"); - assert_eq!(out.status.code(), Some(0), "{sub} {help} exit"); - assert!(!out.stdout.is_empty(), "{sub} {help} stdout empty"); - assert!(out.stderr.is_empty(), "{sub} {help} stderr not empty: {:?}", - String::from_utf8_lossy(&out.stderr)); - } - } -} /// Property: `aura graph` emits a single self-contained HTML page — the /// interactive pin-graph viewer with the deterministic model inlined and the @@ -1065,54 +878,57 @@ fn graph_with_bad_blueprint_arg_is_a_usage_fault_not_a_silent_sample() { ); } -#[test] -fn sweep_with_trailing_token_is_strict_and_exits_two() { - // strict-arg reading (#16): an unexpected trailing token is a usage error. - let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); -} #[test] fn runs_families_list_and_per_family_rank_across_invocations() { - let cwd = temp_cwd("runs-flow"); - // two blueprint sweeps accumulate two families (sweep-0, sweep-1) over time, - // each a related set of 4 records (C18/C21 lineage), in the sibling family - // store. `sweep` is blueprint-only now (#159 cut 4); the shipped open r-sma - // example over a 2x2 axis grid stands in for the retired built-in default. - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + // Ported onto a one-cell campaign document run twice (#319 Task 8): two + // `exec`s of the same sweep-only document accumulate two families over + // time, each a related set of 4 records (C18/C21 lineage), in the sibling + // family store. Unlike the retired verb sugar (whose family_id was the + // literal `--name`/`"sweep"` default), the campaign path's own family + // naming is a deterministic, content-derived coordinate name + // (`{campaign8}-{strategy}-{instrument}-{regime}-w{window}-r{run}-s{stage}`, + // `aura-campaign::exec`) — so the two family ids are read back from `runs + // families` rather than assumed to be "sweep-0"/"sweep-1"; the STRUCTURAL + // property (two families, 4 members each, per-family rank works) survives + // unchanged. + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "runs-flow-seed"); + let proc_id = register_process_doc(&cwd, "runsflow.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "runsflow", "SYMA", &bp_id, &proc_id, + (1709251200000, 1719791999999), "2, 4", "8, 16", + ); + write_doc(&cwd, "runsflow.campaign.json", &doc); for _ in 0..2 { - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - ]) - .current_dir(&cwd) - .output() - .expect("spawn sweep"); - assert!(out.status.success(), "sweep exit: {:?}; stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); + let (out, code) = run_code_in(&cwd, &["exec", "runsflow.campaign.json"]); + assert_eq!(code, Some(0), "exec exit: {out}"); } // `runs families` shows both stored families in first-seen order, each with // its kind + member count. - let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); - assert!(fams.status.success(), "families exit: {:?}", fams.status); - let fams_out = String::from_utf8(fams.stdout).expect("utf-8"); + let (fams_out, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); let fam_lines: Vec<&str> = fams_out.lines().collect(); assert_eq!(fam_lines.len(), 2, "families: {fams_out:?}"); - assert!(fam_lines[0].contains("\"family_id\":\"sweep-0\""), "fam0: {fams_out:?}"); - assert!(fam_lines[1].contains("\"family_id\":\"sweep-1\""), "fam1: {fams_out:?}"); + assert!(fam_lines.iter().all(|l| l.contains("\"kind\":\"Sweep\"")), "families: {fams_out:?}"); assert!(fam_lines.iter().all(|l| l.contains("\"members\":4")), "members: {fams_out:?}"); + let family_id_of = |line: &str| -> String { + let key = "\"family_id\":\""; + let start = line.find(key).expect("line has family_id") + key.len(); + let tail = &line[start..]; + let end = tail.find('"').expect("closing quote"); + tail[..end].to_string() + }; + let (fam0, fam1) = (family_id_of(fam_lines[0]), family_id_of(fam_lines[1])); + assert_ne!(fam0, fam1, "two distinct runs mint two distinct families: {fams_out:?}"); + assert!(fam0.ends_with("-0"), "first run's family carries the run-0 suffix: {fam0}"); + assert!(fam1.ends_with("-1"), "second run's family carries the run-1 suffix: {fam1}"); - // `runs family sweep-0 rank total_pips`: the family's 4 members, highest + // `runs family rank total_pips`: the family's 4 members, highest // total_pips first. - let rank = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "total_pips"]) - .current_dir(&cwd) - .output() - .expect("spawn rank"); - assert!(rank.status.success(), "rank exit: {:?}", rank.status); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); + let (rank_out, rank_code) = run_code_in(&cwd, &["runs", "family", &fam0, "rank", "total_pips"]); + assert_eq!(rank_code, Some(0), "rank exit: {rank_out}"); assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}"); assert!(rank_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "rank shape: {rank_out:?}"); let pips: Vec = rank_out @@ -1128,23 +944,13 @@ fn runs_families_list_and_per_family_rank_across_invocations() { assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}"); // an unknown metric is a usage error - let bogus = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "bogus"]) - .current_dir(&cwd) - .output() - .expect("spawn bogus"); - assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status); + let (bogus_out, bogus_code) = run_code_in(&cwd, &["runs", "family", &fam0, "rank", "bogus"]); + assert_eq!(bogus_code, Some(2), "bogus exit: {bogus_out}"); // an unknown family id is an empty family: zero lines, exit 0. - let unknown = Command::new(BIN) - .args(["runs", "family", "nope-9"]) - .current_dir(&cwd) - .output() - .expect("spawn unknown"); - assert!(unknown.status.success(), "unknown family exit: {:?}", unknown.status); - assert_eq!(unknown.stdout.len(), 0, "unknown family stdout: {:?}", unknown.stdout); - - let _ = std::fs::remove_dir_all(&cwd); + let (unknown_out, unknown_code) = run_code_in(&cwd, &["runs", "family", "nope-9"]); + assert_eq!(unknown_code, Some(0), "unknown family exit: {unknown_out}"); + assert_eq!(unknown_out.len(), 0, "unknown family stdout: {unknown_out:?}"); } /// #147: the pre-rename metric name `exposure_sign_flips` still resolves @@ -1158,30 +964,35 @@ fn runs_families_list_and_per_family_rank_across_invocations() { /// vocabulary. #[test] fn runs_family_rank_accepts_legacy_exposure_sign_flips_alias() { - let cwd = temp_cwd("runs-rank-legacy-alias"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let sweep = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - ]) - .current_dir(&cwd) - .output() - .expect("spawn sweep"); - assert!(sweep.status.success(), "sweep stderr: {}", String::from_utf8_lossy(&sweep.stderr)); - - let rank = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "exposure_sign_flips"]) - .current_dir(&cwd) - .output() - .expect("spawn rank"); - assert!( - rank.status.success(), - "the legacy alias must still resolve, not usage-error: stderr={}", - String::from_utf8_lossy(&rank.stderr) + // Ported onto a one-cell campaign document (#319 Task 8) — the family_id + // is read back from `runs families` (see the sibling test's doc comment + // for why the campaign path's own naming isn't the literal "sweep-0"). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "legacy-alias-seed"); + let proc_id = register_process_doc(&cwd, "legacyalias.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "legacyalias", "SYMA", &bp_id, &proc_id, + (1709251200000, 1719791999999), "2, 4", "8, 16", + ); + write_doc(&cwd, "legacyalias.campaign.json", &doc); + let (exec_out, exec_code) = run_code_in(&cwd, &["exec", "legacyalias.campaign.json"]); + assert_eq!(exec_code, Some(0), "exec exit: {exec_out}"); + let family_id = exec_out + .lines() + .find(|l| l.starts_with("{\"family_id\":")) + .map(|l| { + serde_json::from_str::(l).expect("member line parses")["family_id"] + .as_str() + .expect("family_id is a string") + .to_string() + }) + .expect("a member line carries the family id"); + + let (rank_out, rank_code) = run_code_in(&cwd, &["runs", "family", &family_id, "rank", "exposure_sign_flips"]); + assert_eq!( + rank_code, Some(0), + "the legacy alias must still resolve, not usage-error: {rank_out}" ); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); assert_eq!(rank_out.lines().count(), 4, "all 4 members rank: {rank_out:?}"); // the renamed field is what actually ships in the JSON — the alias is a // resolve()-only back door, never a second output key. @@ -1207,8 +1018,6 @@ fn runs_family_rank_accepts_legacy_exposure_sign_flips_alias() { // bias_sign_flips is a lower-is-better metric (fewer direction reversals), // so `rank` orders ascending here — unlike total_pips's descending order. assert!(flips.windows(2).all(|w| w[0] <= w[1]), "rank not ascending by the aliased metric: {flips:?}"); - - let _ = std::fs::remove_dir_all(&cwd); } #[test] @@ -1261,14 +1070,18 @@ fn local_data_present() -> bool { /// blueprint + axis to clear the usage grammar before the geometry refusal fires.) #[test] fn sweep_real_no_geometry_symbol_refuses_with_exit_1() { - // not gated: the pip refusal happens before any data access. + // not gated: the pip refusal happens before any data access. Ported onto + // a one-cell campaign document — a #272 contained fault (exit 3), not the + // retired verb's hard exit 1 (see the `run_real_no_geometry_*` siblings' + // doc comments for the rationale). let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args(["sweep", &fixture, "--axis", "fast.length=2,4", "--real", "NONEXISTENT"]) - .current_dir(&dir).output().unwrap(); - assert_eq!(out.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&out.stderr).contains("no recorded geometry")); + let bp_id = seed_blueprint(&dir, "sweep-geo-seed"); + let proc_id = register_process_doc(&dir, "sweepgeo.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("sweepgeo", "NONEXISTENT", &bp_id, &proc_id, (1, 2), "2, 4", "8"); + write_doc(&dir, "sweepgeo.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "sweepgeo.campaign.json"]); + assert_eq!(code, Some(3), "a contained no-geometry cell fault is exit 3: {out}"); + assert!(out.contains("no recorded geometry"), "out: {out}"); } // GER40 Sept-2024 window (inclusive Unix-ms) — the same gated calendar month @@ -1293,36 +1106,33 @@ fn run_real_empty_window_refusal_names_the_window_not_the_symbol() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - // The covered Sept-2024 window, INVERTED (from > to): `has_symbol("GER40")` is - // window-independent and passes, and the month's file overlaps at date - // granularity, so the empty result is a WINDOW fact, not symbol absence. - let out = std::process::Command::new(BIN) - .args([ - "run", "examples/r_sma.json", - "--real", "GER40", - "--from", GER40_SEPT2024_TO_MS, // inverted: from > to, - "--to", GER40_SEPT2024_FROM_MS, // so the in-window bar set is empty - ]) - .output() - .expect("spawn aura"); - - let stderr = String::from_utf8_lossy(&out.stderr); - // An empty --real window is still a refusal (exit 1 unchanged); only the - // message changes — the genuinely-unknown-symbol path keeps exit 1 too. - assert_eq!( - out.status.code(), - Some(1), - "an empty --real window still refuses with exit 1; stderr: {stderr}" - ); + // Ported onto a one-cell campaign document (#319 Task 8): a campaign + // document's window is INTRINSICALLY validated `from_ms < to_ms` (a + // hand-authored inverted window refuses at document validation, before + // ever reaching the data layer — a stricter, earlier gate than the + // retired verb had), so the empty-window property is reached instead with + // a validly-ordered window that simply predates any archive coverage + // (epoch-ms [1, 2], the same "no data yet" window the geometry-refusal + // siblings use for an unknown symbol) — GER40 IS present (has recorded + // geometry), so this reaches the DATA layer's own "no data in window" + // fault, distinct from the geometry-refusal path above. A #272 contained + // fault (exit 3), not the retired verb's hard exit 1. + let (dir, _g) = fresh_project(); + let bp_id = seed_blueprint(&dir, "emptywin-seed"); + let proc_id = register_process_doc(&dir, "emptywin.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("emptywin", "GER40", &bp_id, &proc_id, (1, 2), "2", "8"); + write_doc(&dir, "emptywin.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "emptywin.campaign.json"]); + assert_eq!(code, Some(3), "a contained no-data-in-window cell fault is exit 3: {out}"); // The distinction (#242): the empty-window refusal NAMES the window and does // NOT reuse the wholesale symbol-absence message for a symbol that is present. assert!( - stderr.to_lowercase().contains("window"), - "empty-window refusal must name the requested window (GER40 IS present), got: {stderr}" + out.to_lowercase().contains("window"), + "empty-window refusal must name the requested window (GER40 IS present), got: {out}" ); assert!( - !stderr.contains("no local data for symbol"), - "empty-window refusal must not reuse the symbol-absence message, got: {stderr}" + !out.contains("no local data for symbol"), + "empty-window refusal must not reuse the symbol-absence message, got: {out}" ); } @@ -1341,35 +1151,32 @@ fn sweep_real_blueprint_member_manifest_stamps_the_default_stop() { return; } let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - "--name", "brp_nostop", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}"); + let bp_id = seed_blueprint(&dir, "nostop-seed"); + let proc_id = register_process_doc(&dir, "nostop.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "nostop", "GER40", &bp_id, &proc_id, + (GER40_SEPT2024_FROM_MS.parse().unwrap(), GER40_SEPT2024_TO_MS.parse().unwrap()), + "2, 4", "8", + ); + write_doc(&dir, "nostop.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "nostop.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec = out + .lines() + .filter(|l| l.starts_with("{\"family_id\":")) + .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) + .collect(); + assert_eq!(lines.len(), 2, "one member line per grid point: {out}"); - for line in &lines { - let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + for v in &lines { let params = v["report"]["manifest"]["params"] .as_array() .expect("manifest.params is an array"); let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name)); assert_eq!(get("stop_length").and_then(|p| p[1]["I64"].as_i64()), Some(3), - "the resolved default stop length is stamped: {line}"); + "the resolved default stop length is stamped: {v}"); assert_eq!(get("stop_k").and_then(|p| p[1]["F64"].as_f64()), Some(2.0), - "the resolved default stop k is stamped: {line}"); + "the resolved default stop k is stamped: {v}"); } } @@ -1388,35 +1195,32 @@ fn sweep_real_blueprint_member_manifest_stamps_project_provenance() { return; } let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - "--name", "brp_provenance", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}"); + let bp_id = seed_blueprint(&dir, "provenance-seed"); + let proc_id = register_process_doc(&dir, "provenance.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "provenance", "GER40", &bp_id, &proc_id, + (GER40_SEPT2024_FROM_MS.parse().unwrap(), GER40_SEPT2024_TO_MS.parse().unwrap()), + "2, 4", "8", + ); + write_doc(&dir, "provenance.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "provenance.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec = out + .lines() + .filter(|l| l.starts_with("{\"family_id\":")) + .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) + .collect(); + assert_eq!(lines.len(), 2, "one member line per grid point: {out}"); - for line in &lines { - let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + for v in &lines { let project = &v["report"]["manifest"]["project"]; assert_eq!( project["namespace"].as_str(), Some("demo"), - "member manifest carries the fixture project's namespace: {line}" + "member manifest carries the fixture project's namespace: {v}" ); - let sha = project["dylib_sha256"].as_str().unwrap_or_else(|| panic!("dylib_sha256 present: {line}")); - assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {line}"); - assert!(sha.chars().all(|c| c.is_ascii_hexdigit()), "dylib_sha256 is hex: {line}"); + let sha = project["dylib_sha256"].as_str().unwrap_or_else(|| panic!("dylib_sha256 present: {v}")); + assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {v}"); + assert!(sha.chars().all(|c| c.is_ascii_hexdigit()), "dylib_sha256 is hex: {v}"); } } @@ -1431,27 +1235,44 @@ fn run_real_ohlc_channel_example_end_to_end() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - let cwd = temp_cwd("ohlc-run-real"); + let (dir, _g) = fresh_project(); let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "run", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - ]) - .current_dir(&cwd) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON"); - assert!( - v["manifest"]["topology_hash"].as_str().is_some(), - "report carries the signal hash: {stdout}" + let bp_id = seed_blueprint_file(&dir, &fixture, "channel-real-seed"); + let proc_id = register_process_doc(&dir, "channelreal.process.json", SWEEP_ONLY_PROCESS_DOC); + // r_channel.json binds `channel_hi.length`/`channel_lo.length`/lags, not + // `fast.length`/`slow.length` (`r_sma.json`'s knobs) — `sweep_campaign_doc`'s + // shape doesn't fit, so build the document directly, one degenerate + // single-value axis (#246 bound-override reopening) over the fixture's + // own `channel_hi.length` at its bound default (a campaign document needs + // >= 1 axis per strategy). + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "channel-real", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_hi.length": {{ "kind": "I64", "values": [3] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), ); - assert!(v["metrics"]["r"].is_object(), "the R summary is computed: {stdout}"); - let _ = std::fs::remove_dir_all(&cwd); + write_doc(&dir, "channelreal.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "channelreal.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let line = out + .lines() + .find(|l| l.starts_with("{\"family_id\":")) + .expect("the one member line"); + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + assert!( + v["report"]["manifest"]["topology_hash"].as_str().is_some(), + "report carries the signal hash: {line}" + ); + assert!(v["report"]["metrics"]["r"].is_object(), "the R summary is computed: {line}"); } /// Property (#275, hostless): `run_signal_r`'s migrated production call site @@ -1468,58 +1289,54 @@ fn run_real_ohlc_channel_example_end_to_end() { /// crossed) would change the channel breakout logic and move this number. #[test] fn run_real_ohlc_channel_example_end_to_end_hostless() { - let (cwd, _g) = fresh_project_with_data(); + let (dir, _g) = fresh_project_with_data(); let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "run", &fixture, - "--real", "SYMA", - "--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday) - "--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday) - ]) - .current_dir(&cwd) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON"); + let bp_id = seed_blueprint_file(&dir, &fixture, "channel-hostless-seed"); + let proc_id = register_process_doc(&dir, "channelhostless.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "channel-hostless", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1707120000000, "to_ms": 1707325200000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_hi.length": {{ "kind": "I64", "values": [3] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&dir, "channelhostless.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "channelhostless.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let line = out.lines().find(|l| l.starts_with("{\"family_id\":")).expect("the one member line"); + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + let report = &v["report"]; assert!( - v["manifest"]["topology_hash"].as_str().is_some(), - "report carries the signal hash: {stdout}" + report["manifest"]["topology_hash"].as_str().is_some(), + "report carries the signal hash: {line}" ); assert_eq!( - v["metrics"]["r"]["n_trades"], serde_json::json!(1), + report["metrics"]["r"]["n_trades"], serde_json::json!(1), "characterization pin over the deterministic synthetic archive \ - (a high/low/close column mispairing would move this count): {stdout}" + (a high/low/close column mispairing would move this count): {line}" ); assert_eq!( - v["metrics"]["r"]["expectancy_r"], serde_json::json!(-0.7466116470582123), + report["metrics"]["r"]["expectancy_r"], serde_json::json!(-0.7466116470582123), "characterization pin over the deterministic synthetic archive \ - (a high/low/close column mispairing would move this value): {stdout}" + (a high/low/close column mispairing would move this value): {line}" ); } -/// The synthetic walk generates a close series only: a multi-column blueprint -/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT -/// gated — the refusal precedes any data access. -#[test] -fn run_synthetic_refuses_a_multi_column_blueprint() { - let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args(["run", &fixture]) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(1)); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("consumes columns beyond close"), "names the shape: {stderr}"); - assert!(stderr.contains("--real"), "names the remedy: {stderr}"); -} /// The dissolved real-data sweep + reproduce over the OHLC example (#231 -/// acceptance): `--list-axes` pins the ganged channel knob's RAW axis -/// name (#328), the sweep runs one member per grid point over three real columns, -/// and every member re-derives bit-identically from the store (#229 real-data -/// reproduce, now multi-column). Gated on the local GER40 archive. +/// acceptance), ported onto a one-cell campaign document (#319 Task 8 — +/// `--list-axes` itself is dead flag surface, retired with `sweep`; its +/// twin properties live on in the `aura_sweep_list_axes_*` retire bucket): +/// `exec` runs one member per grid point over three real columns, over the +/// ganged `channel_length` axis (its RAW name, #328), and every member +/// re-derives bit-identically from the store (#229 real-data reproduce, now +/// multi-column). Gated on the local GER40 archive. #[test] fn sweep_real_ohlc_channel_members_run_and_reproduce() { if !local_data_present() { @@ -1528,36 +1345,29 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() { } let (dir, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_channel_open.json", env!("CARGO_MANIFEST_DIR")); - let axes = std::process::Command::new(BIN) - .args(["sweep", &fixture, "--list-axes"]) - .current_dir(&dir) - .output() - .unwrap(); - assert_eq!( - String::from_utf8_lossy(&axes.stdout), - "channel_length:I64\n\ - prev_high.lag:I64 default=1\n\ - prev_low.lag:I64 default=1\n", - "the ganged channel knob is the one open axis, alongside the fixture's \ - bound Delay lags as #246 defaults; stderr: {}", - String::from_utf8_lossy(&axes.stderr) + let bp_id = seed_blueprint_file(&dir, &fixture, "chan-seed"); + let proc_id = register_process_doc(&dir, "chan.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "chan", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [3, 5] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), ); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "channel_length=3,5", - "--name", "chan", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}"); - for line in stdout.lines() { + write_doc(&dir, "chan.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "chan.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec<&str> = out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert_eq!(lines.len(), 2, "one member line per channel length: {out}"); + for line in &lines { let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); assert_eq!( v["report"]["manifest"]["instrument"].as_str(), @@ -1565,25 +1375,16 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() { "campaign member manifest stamps the instrument: {line}" ); } - let family_id = serde_json::from_str::(stdout.lines().next().unwrap()) + let family_id = serde_json::from_str::(lines[0]) .expect("first member line parses")["family_id"] .as_str() .expect("member line carries family_id") .to_string(); - let rep = std::process::Command::new(BIN) - .args(["reproduce", &family_id]) - .current_dir(&dir) - .output() - .unwrap(); + let (rep_out, rep_code) = run_code_in(&dir, &["reproduce", &family_id]); + assert_eq!(rep_code, Some(0), "reproduce out: {rep_out}"); assert!( - rep.status.success(), - "reproduce stderr: {}", - String::from_utf8_lossy(&rep.stderr) - ); - assert!( - String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"), - "stdout: {}", - String::from_utf8_lossy(&rep.stdout) + rep_out.contains("reproduced 2/2 members bit-identically"), + "out: {rep_out}" ); } @@ -1602,22 +1403,27 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce() { fn sweep_real_ohlc_channel_members_run_and_reproduce_hostless() { let (dir, _g) = fresh_project_with_data(); let fixture = format!("{}/tests/fixtures/r_channel_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "SYMA", - "--from", "1707120000000", // 2024-02-05 08:00 UTC (Monday) - "--to", "1707325200000", // 2024-02-07 17:00 UTC (Wednesday) - "--axis", "channel_length=3,5", - "--name", "chan-hostless", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}"); - for line in stdout.lines() { + let bp_id = seed_blueprint_file(&dir, &fixture, "chan-hostless-seed"); + let proc_id = register_process_doc(&dir, "chanhostless.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = r#"{ + "format_version": 1, + "kind": "campaign", + "name": "chan-hostless", + "data": { "instruments": ["SYMA"], "windows": [ { "from_ms": 1707120000000, "to_ms": 1707325200000 } ] }, + "strategies": [ { "ref": { "content_id": "__BP__" }, + "axes": { "channel_length": { "kind": "I64", "values": [3, 5] } } } ], + "process": { "ref": { "content_id": "__PROC__" } }, + "seed": 7, + "presentation": { "persist_taps": [], "emit": ["family_table"] } +}"# + .replace("__BP__", &bp_id) + .replace("__PROC__", &proc_id); + write_doc(&dir, "chanhostless.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "chanhostless.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec<&str> = out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert_eq!(lines.len(), 2, "one member line per channel length: {out}"); + for line in &lines { let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); assert_eq!( v["report"]["manifest"]["instrument"].as_str(), @@ -1625,40 +1431,35 @@ fn sweep_real_ohlc_channel_members_run_and_reproduce_hostless() { "campaign member manifest stamps the instrument: {line}" ); } - let family_id = serde_json::from_str::(stdout.lines().next().unwrap()) + let family_id = serde_json::from_str::(lines[0]) .expect("first member line parses")["family_id"] .as_str() .expect("member line carries family_id") .to_string(); - let rep = std::process::Command::new(BIN) - .args(["reproduce", &family_id]) - .current_dir(&dir) - .output() - .unwrap(); + let (rep_out, rep_code) = run_code_in(&dir, &["reproduce", &family_id]); + assert_eq!(rep_code, Some(0), "reproduce out: {rep_out}"); assert!( - rep.status.success(), - "reproduce stderr: {}", - String::from_utf8_lossy(&rep.stderr) - ); - assert!( - String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"), - "stdout: {}", - String::from_utf8_lossy(&rep.stdout) + rep_out.contains("reproduced 2/2 members bit-identically"), + "out: {rep_out}" ); } -/// Property: the real-data blueprint sweep path (`aura sweep -/// --real SYM --axis …`, dispatched as sugar over the one campaign executor) -/// prints one member line per grid point, IN AXIS ORDER (odometer: the first -/// `--axis` varies slowest), each line JSON with a `family_id` and a `report` -/// whose `manifest.params` carries every swept param binding — and, the -/// sanctioned additive delta over the synthetic path, **`report.manifest` -/// carries a stamped `instrument` key** (the campaign substrate stamps every -/// member's manifest with the instrument under test, exactly like the sibling -/// `campaign_run_real_e2e_sweep_gate_walkforward` in `research_docs.rs`). Also -/// pins that the run persists exactly one new `FamilyKind::Sweep` family with -/// the grid's member count, and that the underlying generated process/campaign -/// documents and campaign-run record are durably auto-registered. Gated on the +/// Property, ported onto a hand-authored one-cell campaign document (#319 +/// Task 8 — the `--name`-propagation and CLI window-probe/ms<->ns-conversion +/// halves of the original test were the argv-translator's OWN machinery +/// (`translate_sweep`/`campaign_window_ms`), retired with the verb; a +/// hand-authored document has no CLI-side window probe or `--name` to +/// propagate, so those checks do not survive): `exec` prints one member line +/// per grid point, IN AXIS ORDER (odometer: the first document axis varies +/// slowest), each line JSON with a `family_id` and a `report` whose +/// `manifest.params` carries every swept param binding (RAW names, #328) — +/// and **`report.manifest` carries a stamped `instrument` key** (the campaign +/// substrate stamps every member's manifest with the instrument under test, +/// exactly like `campaign_run_real_e2e_sweep_gate_walkforward` in +/// `research_docs.rs`) and the resolved default stop. Also pins that the run +/// persists exactly one new `FamilyKind::Sweep` family with the grid's member +/// count, and that re-`exec`ing the SAME document reproduces identical member +/// reports (C1) while incrementing the family's run-suffix. Gated on the /// local GER40 archive (the project's skip-on-no-data convention); skips /// cleanly when absent. #[test] @@ -1668,31 +1469,21 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { return; } let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "brp", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 4, "one member line per 2x2 grid point: {stdout}"); + let bp_id = seed_blueprint(&dir, "dissolved-contract-seed"); + let proc_id = register_process_doc(&dir, "brp.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "brp", "GER40", &bp_id, &proc_id, + (GER40_SEPT2024_FROM_MS.parse().unwrap(), GER40_SEPT2024_TO_MS.parse().unwrap()), + "2, 4", "8, 16", + ); + write_doc(&dir, "brp.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "brp.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec<&str> = out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert_eq!(lines.len(), 4, "one member line per 2x2 grid point: {out}"); - // Odometer order: `fast.length` (first `--axis`, outer/slowest, typed RAW - // per #328) x `slow.length` (second `--axis`, inner) -> - // (2,8),(2,16),(4,8),(4,16). `report.manifest.params` now records RAW - // names on the real/campaign route too (this cycle's tidy fix, #328 batch - // 2): a name-only reshaping at the mint seam, the member still binds - // against the WRAPPED `param_space()` name internally. + // Odometer order: `fast.length` (first document axis, outer/slowest) x + // `slow.length` (second, inner) -> (2,8),(2,16),(4,8),(4,16). let expected: [[(&str, i64); 2]; 4] = [ [("fast.length", 2), ("slow.length", 8)], [("fast.length", 2), ("slow.length", 16)], @@ -1714,144 +1505,37 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { .and_then(|p| p[1]["I64"].as_i64()); assert_eq!(bound, Some(value), "manifest carries the swept binding {name}={value}: {line}"); } - // the sanctioned additive delta: the campaign substrate stamps every - // member's manifest with the instrument. assert_eq!( manifest["instrument"].as_str(), Some("GER40"), - "the dissolved sweep stamps the instrument: {line}" + "the campaign substrate stamps the instrument: {line}" ); assert!( params.iter().any(|p| p[0].as_str() == Some("stop_length")) && params.iter().any(|p| p[0].as_str() == Some("stop_k")), - "the dissolved sweep stamps the resolved stop: {line}" + "the campaign substrate stamps the resolved stop: {line}" ); } // exactly one new Sweep family persisted, with the grid's four members. - let fams = std::process::Command::new(BIN) - .args(["runs", "families"]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(fams.status.success(), "families exit: {:?}", fams.status); - let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + let (fams_out, fams_code) = run_code_in(&dir, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}"); assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}"); assert!(fams_out.contains("\"members\":4"), "2x2 grid -> four members: {fams_out}"); - // Auto-registration: generated documents are durable intent — exactly - // one process doc, one campaign doc, one campaign run record. - let count = |sub: &str| { - std::fs::read_dir(dir.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!(count("processes"), 1, "one generated process document registered"); - assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); - let runs_log = std::fs::read_to_string(dir.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after a sugar run"); - assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); - // `CampaignRunRecord` (aura-registry's lineage.rs) - // carries no name field at all (campaign id/process id/run/seed/cells/ - // generalizations/trace_name only) — the `--name` handle is NOT part of - // the run-log line. It IS part of the generated CAMPAIGN DOCUMENT - // (`translate_sweep` sets `campaign.name = name`), so the handle is - // checked on that persisted document instead. - let campaigns_dir = dir.join("runs").join("campaigns"); - let campaign_doc_path = std::fs::read_dir(&campaigns_dir) - .expect("campaigns dir exists") - .next() - .expect("exactly one campaign document") - .expect("readable dir entry") - .path(); - let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); - assert!( - campaign_doc_json.contains("\"name\":\"brp\""), - "the --name handle lands on the generated campaign document: {campaign_doc_json}" - ); - - // Property (dispatch-rewire seam, docs/specs/sweep-dissolution.md Task 4 - // Step 3, #328 update): the CLI's `--axis` names ARE the RAW - // campaign-document namespace directly (#328 — `--axis` accepts only the - // raw `.` form; there is no wrapped->raw strip in between - // any more), and a campaign document's `strategies[].axes` speak that - // same raw namespace — `bind_axes`'s convention (#203). A regression that - // reintroduced wrap-prefixing before generating the document (or wrote - // the raw name plus a stray extra segment) would persist the wrong axis - // keys and `validate_campaign_refs` would refuse every axis as unknown - // before any member ran — this pin catches that at the artifact level, - // directly. - let campaign_doc: serde_json::Value = - serde_json::from_str(&campaign_doc_json).expect("generated campaign doc parses as JSON"); - let axes = campaign_doc["strategies"][0]["axes"] - .as_object() - .expect("strategies[0].axes is an object"); - assert!( - axes.contains_key("fast.length"), - "the raw (unwrapped) axis name is the document key: {campaign_doc_json}" - ); - assert!( - axes.contains_key("slow.length"), - "the raw (unwrapped) axis name is the document key: {campaign_doc_json}" - ); - assert!( - !axes.contains_key("sma_signal.fast.length") && !axes.contains_key("sma_signal.slow.length"), - "the wrapped CLI probe name must never leak into the stored document: {campaign_doc_json}" - ); - - // Property (dispatch-rewire seam: the ms<->ns crossing, C3): the probed - // archive window is carried through `DataSource::full_window` (aura's - // native epoch-ns `Timestamp`, clamped to the archive's actual first/last - // bar inside the requested range) and converted back to Unix-ms via - // `aura_ingest::epoch_ns_to_unix_ms` exactly once before landing in the - // generated document's `data.windows[0]`. A regression that skipped the - // conversion (or applied it twice, or swapped ms<->ns) would persist a - // window off by a factor of ~1e6 from the requested `--from`/`--to` — - // silently, since the executor would still run (over the wrong or an - // empty range) rather than refuse outright. Pin the sane invariant that - // survives the archive's exact clamp: the stored window is a - // (from <= to) sub-range of the requested Unix-ms bounds, not a value - // orders of magnitude away. - let from_req = GER40_SEPT2024_FROM_MS.parse::().unwrap(); - let to_req = GER40_SEPT2024_TO_MS.parse::().unwrap(); - let window = &campaign_doc["data"]["windows"][0]; - let from_stored = window["from_ms"].as_i64().expect("from_ms is an integer"); - let to_stored = window["to_ms"].as_i64().expect("to_ms is an integer"); - assert!( - from_req <= from_stored && from_stored <= to_stored && to_stored <= to_req, - "the stored window is a ms sub-range of the requested [{from_req}, {to_req}]: {campaign_doc_json}" - ); - - // Dedup: a second identical invocation reuses both documents (content- - // addressed) and appends a second run record. - let out2 = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "brp", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out2.status.success(), "second run stderr: {}", String::from_utf8_lossy(&out2.stderr)); - // `family_id` carries a per-execution uniqueness - // suffix ("the '-{run}' suffix is appended by the registry", - // aura-campaign/src/exec.rs `run_cell`) — a fresh family record is - // written on every execution even when the campaign/process documents - // themselves dedupe, so two identical invocations are NOT literally - // byte-identical on stdout: only the trailing run-suffix (0 -> 1) - // differs. C1 determinism is checked at the member-report level instead - // (every report field byte-identical), plus the run-suffix increment. - let out2_stdout = String::from_utf8_lossy(&out2.stdout).into_owned(); - let lines1: Vec<&str> = stdout.lines().collect(); - let lines2: Vec<&str> = out2_stdout.lines().collect(); - assert_eq!(lines2.len(), lines1.len(), "same member count on the dedup run: {out2_stdout}"); - for (l1, l2) in lines1.iter().zip(&lines2) { + // Re-exec'ing the SAME document: one campaign document dedupes (content- + // addressed), a fresh family record is written on every execution (the + // "-{run}" suffix, aura-campaign::exec::run_cell), so two identical + // invocations are NOT byte-identical on stdout: only the trailing + // run-suffix (0 -> 1) differs. C1 determinism is checked at the + // member-report level instead (every report field byte-identical), plus + // the run-suffix increment. + let (out2, code2) = run_code_in(&dir, &["exec", "brp.campaign.json"]); + assert_eq!(code2, Some(0), "second run: {out2}"); + let lines2: Vec<&str> = out2.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert_eq!(lines2.len(), lines.len(), "same member count on the dedup run: {out2}"); + for (l1, l2) in lines.iter().zip(&lines2) { let v1: serde_json::Value = serde_json::from_str(l1).expect("first run member line parses"); let v2: serde_json::Value = serde_json::from_str(l2).expect("second run member line parses"); assert_eq!(v1["report"], v2["report"], "identical invocations reproduce identical member reports (C1): {l1} vs {l2}"); @@ -1863,8 +1547,8 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { ); assert!(fam1.ends_with("-0") && fam2.ends_with("-1"), "run-suffix increments 0 -> 1: {fam1} vs {fam2}"); } - assert_eq!(count("processes"), 1, "second identical run dedupes the process doc"); - assert_eq!(count("campaigns"), 1, "second identical run dedupes the campaign doc"); + let campaigns_dir_count = std::fs::read_dir(dir.join("runs").join("campaigns")).map(|d| d.count()).unwrap_or(0); + assert_eq!(campaigns_dir_count, 1, "second identical run dedupes the campaign doc"); } /// True iff `root` contains, at any depth, a regular file of non-zero length — @@ -1910,37 +1594,45 @@ fn sweep_real_with_trace_writes_tap_series_to_disk() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } + // Ported onto a one-cell campaign document declaring `persist_taps` + // (#319 Task 8) — the property under test (tap series actually land on + // disk, the #168/#224 headline) is the campaign substrate's own + // `persist_campaign_traces` mechanism, reached identically whether the + // document was hand-authored or sugar-generated; `--trace`'s own + // refuse-then-deliver history is retired-verb-specific and not re-pinned. let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - "--trace", "brp", - ]) - .current_dir(&dir) - .output() - .unwrap(); - // (1) the #168 refusal is lifted: the advertised flag runs instead of exit 2. - assert!( - out.status.success(), - "sweep --trace must no longer refuse (#224); exit {:?}, stderr: {}", - out.status.code(), - String::from_utf8_lossy(&out.stderr), + let bp_id = seed_blueprint(&dir, "trace-seed"); + let proc_id = register_process_doc(&dir, "trace.process.json", SWEEP_ONLY_PROCESS_DOC); + // `presentation.persist_taps` names entries from the closed measurement + // vocabulary (equity | exposure | r_equity | net_r_equity), not arbitrary + // blueprint-declared taps (that's `--tap`'s namespace on the blueprint + // leg, invariant 7) — "equity" is always producible for an R strategy. + let campaign_doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "brp", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": ["equity"], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), ); - // (2) the tap series actually land on disk under runs/traces/ (non-empty) — - // the honest inverse of the #168 `persist_taps: vec![]` no-op. + write_doc(&dir, "brp.campaign.json", &campaign_doc); + let (out, code) = run_code_in(&dir, &["exec", "brp.campaign.json"]); + assert_eq!(code, Some(0), "exec must succeed and persist the requested taps: {out}"); + // the tap series actually land on disk under runs/traces/ (non-empty) — + // the honest inverse of the #168 `persist_taps: vec![]` no-op. let traces_root = dir.join("runs").join("traces"); assert!( any_nonempty_file(&traces_root), - "sweep --trace must write at least one non-empty tap file under {} \ - (found none — the #168 no-op wrote nothing); stderr: {}", + "the campaign run must write at least one non-empty tap file under {}: {out}", traces_root.display(), - String::from_utf8_lossy(&out.stderr), ); } @@ -1961,6 +1653,57 @@ fn family_id_of_kind(dir: &Path, want: &str) -> String { panic!("no {want} family in the store: {stdout}"); } +/// Reconstruct the retired `walkforward_summary_json_from_reports`'s +/// aggregate (deleted Task 8 Step 2) from a WalkForward family's OWN +/// persisted per-window `RunReport`s (`aura runs family ` bare-report +/// lines) — the SAME reduction, over data the campaign path populates +/// identically: `stitched_total_pips` sums each window's own `total_pips` +/// (additive, roll order irrelevant to a sum); pooled `n_trades` sums each +/// window's own trade count; pooled `expectancy_r` is the trade-count- +/// weighted mean of each window's own `expectancy_r` (mathematically +/// identical to the mean of the raw pooled per-trade R series, which is +/// NOT itself recoverable from JSON — `RMetrics::net_trade_rs` is +/// `#[serde(skip)]`); `param_stability[i].mean` is the arithmetic mean of +/// axis `axes[i]`'s per-window chosen value (`manifest.params`, never +/// skipped). +fn walkforward_aggregate_from_reports(reports: &[serde_json::Value], axes: &[&str]) -> serde_json::Value { + let stitched_total_pips: f64 = reports.iter().map(|r| r["metrics"]["total_pips"].as_f64().unwrap()).sum(); + let n_trades: i64 = reports.iter().map(|r| r["metrics"]["r"]["n_trades"].as_i64().unwrap_or(0)).sum(); + let weighted_sum: f64 = reports + .iter() + .map(|r| { + let n = r["metrics"]["r"]["n_trades"].as_f64().unwrap_or(0.0); + let e = r["metrics"]["r"]["expectancy_r"].as_f64().unwrap_or(0.0); + n * e + }) + .sum(); + let expectancy_r = if n_trades > 0 { weighted_sum / n_trades as f64 } else { 0.0 }; + let param_stability: Vec = axes + .iter() + .map(|axis| { + let values: Vec = reports + .iter() + .map(|r| { + let params = r["manifest"]["params"].as_array().expect("manifest.params array"); + let entry = params + .iter() + .find(|p| p[0].as_str() == Some(*axis)) + .unwrap_or_else(|| panic!("axis {axis} recorded in manifest.params")); + entry[1]["I64"].as_f64().or_else(|| entry[1]["F64"].as_f64()).expect("scalar value") + }) + .collect(); + let mean = values.iter().sum::() / values.len() as f64; + serde_json::json!({ "mean": mean }) + }) + .collect(); + serde_json::json!({ + "windows": reports.len(), + "stitched_total_pips": stitched_total_pips, + "oos_r": { "n_trades": n_trades, "expectancy_r": expectancy_r }, + "param_stability": param_stability, + }) +} + /// Property (C18/C1 reproduce guarantee): `aura reproduce` re-derives a family /// over the SAME data it was minted on. A sweep family minted over real GER40 /// bars must therefore reproduce bit-identically — the run is deterministic @@ -1979,38 +1722,30 @@ fn reproduce_real_sweep_family_re_derives_bit_identically() { return; } let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let mint = Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - "--name", "repro_sweep", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(mint.status.success(), "sweep mint stderr: {}", String::from_utf8_lossy(&mint.stderr)); + let bp_id = seed_blueprint(&dir, "repro-sweep-seed"); + let proc_id = register_process_doc(&dir, "reprosweep.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "repro_sweep", "GER40", &bp_id, &proc_id, + (GER40_SEPT2024_FROM_MS.parse().unwrap(), GER40_SEPT2024_TO_MS.parse().unwrap()), + "2, 4", "8", + ); + write_doc(&dir, "reprosweep.campaign.json", &doc); + let (mint_out, mint_code) = run_code_in(&dir, &["exec", "reprosweep.campaign.json"]); + assert_eq!(mint_code, Some(0), "sweep mint: {mint_out}"); let id = family_id_of_kind(&dir, "Sweep"); - let out = Command::new(BIN).args(["reproduce", &id]).current_dir(&dir).output().unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - assert!( - out.status.success(), - "reproduce of a real-data sweep family must succeed (exit 0), got {:?}\nstdout: {stdout}\nstderr: {}", - out.status.code(), - String::from_utf8_lossy(&out.stderr) + let (out, code) = run_code_in(&dir, &["reproduce", &id]); + assert_eq!( + code, Some(0), + "reproduce of a real-data sweep family must succeed (exit 0): {out}" ); assert!( - stdout.contains("reproduced 2/2 members bit-identically"), - "every member of a deterministic real-data sweep re-derives bit-identically: {stdout}" + out.contains("reproduced 2/2 members bit-identically"), + "every member of a deterministic real-data sweep re-derives bit-identically: {out}" ); assert!( - !stdout.contains("DIVERGED"), - "no member may DIVERGE — the run is deterministic, reproduce must use the recorded data source: {stdout}" + !out.contains("DIVERGED"), + "no member may DIVERGE — the run is deterministic, reproduce must use the recorded data source: {out}" ); } @@ -2040,37 +1775,51 @@ fn reproduce_real_sweep_family_re_derives_bit_identically() { /// so the re-derivation resolves against the same space the mint used. #[test] fn reproduce_family_with_overridden_bound_param_re_derives() { - let cwd = temp_cwd("walkforward-reproduce-override"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=2,3", "--name", "wfo"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward"); - assert_eq!( - run.status.code(), - Some(0), - "walk-forward over a bound-param axis must succeed (#246 re-open), stderr: {}", - String::from_utf8_lossy(&run.stderr) + // Ported onto a `[std::grid, std::walk_forward]` campaign document over + // the hermetic synthetic SYMA archive (#319 Task 8) — there is no + // surviving campaign-document equivalent of "sweep the embedded showcase + // stream with no instrument at all" (a `CampaignDoc` always names a real + // `data.instruments` entry, invariant 7), so the property (reproduce + // re-derives a WalkForward family minted by overriding only ONE bound + // param, `slow.length` left at its blueprint-bound default) is exercised + // over SYMA instead. The exact OOS member count is roller/data-derived, + // not hardcoded — this document's window/roller shape is the same one + // `exec.rs`'s zero-trade-note tests already prove out end to end, so the + // member count is asserted structurally (> 0, matches the family listing) + // rather than pinned to a translator-era literal. + let (dir, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&dir, "wfo-seed"); + let proc_id = register_process_doc(&dir, "wfo.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfo", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, ); - let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["reproduce", "wfo-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); + write_doc(&dir, "wfo.campaign.json", &doc); + let (run_out, run_code) = run_code_in(&dir, &["exec", "wfo.campaign.json"]); assert_eq!( - repro.status.code(), - Some(0), - "reproduce stderr: {}", - String::from_utf8_lossy(&repro.stderr) + run_code, Some(0), + "walk-forward over a bound-param axis must succeed (#246 re-open): {run_out}" ); - let out = String::from_utf8(repro.stdout).expect("utf-8"); + let member_count = run_out.lines().filter(|l| l.starts_with("{\"family_id\":")).count(); + assert!(member_count > 0, "at least one OOS member: {run_out}"); + + let id = family_id_of_kind(&dir, "WalkForward"); + let (out, code) = run_code_in(&dir, &["reproduce", &id]); + assert_eq!(code, Some(0), "reproduce: {out}"); assert!( - out.contains("reproduced 3/3 members bit-identically"), + out.contains(&format!("reproduced {member_count}/{member_count} members bit-identically")), "every OOS member of a deterministic walk-forward over a bound-param axis \ re-derives bit-identically: {out}" ); - let _ = std::fs::remove_dir_all(&cwd); } /// Property (#246, distinct code path from `reproduce_family_with_overridden_bound_param_re_derives`): @@ -2086,37 +1835,40 @@ fn reproduce_family_with_overridden_bound_param_re_derives() { /// the walk-forward sibling while failing here. #[test] fn aura_reproduce_re_derives_a_sweep_family_minted_over_a_bound_param_override() { - let cwd = temp_cwd("sweep-reproduce-bound-override"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let sweep = Command::new(BIN) - .args(["sweep", &bp, "--axis", "fast.length=2,3", "--name", "swo"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep over a bound-param axis"); - assert_eq!( - sweep.status.code(), - Some(0), - "sweeping a bound param of a closed blueprint must succeed (#246 re-open), stderr: {}", - String::from_utf8_lossy(&sweep.stderr) + // Ported onto a one-cell campaign document over SYMA (#319 Task 8, same + // substitution rationale as the walk-forward sibling above): the member + // count (2) is structurally guaranteed by the axis grid size, not by the + // data source, so it stays pinned exactly. + let (dir, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&dir, "swo-seed"); + let proc_id = register_process_doc(&dir, "swo.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "swo", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, ); - let repro = Command::new(BIN) - .args(["reproduce", "swo-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); + write_doc(&dir, "swo.campaign.json", &doc); + let (sweep_out, sweep_code) = run_code_in(&dir, &["exec", "swo.campaign.json"]); assert_eq!( - repro.status.code(), - Some(0), - "reproduce stderr: {}", - String::from_utf8_lossy(&repro.stderr) + sweep_code, Some(0), + "sweeping a bound param of a closed blueprint must succeed (#246 re-open): {sweep_out}" ); - let out = String::from_utf8(repro.stdout).expect("utf-8"); + let id = family_id_of_kind(&dir, "Sweep"); + let (out, code) = run_code_in(&dir, &["reproduce", &id]); + assert_eq!(code, Some(0), "reproduce: {out}"); assert!( out.contains("reproduced 2/2 members bit-identically"), "every member of a plain sweep minted over a bound-param override \ re-derives bit-identically: {out}" ); - let _ = std::fs::remove_dir_all(&cwd); } /// Property: `aura reproduce` yields a handled outcome — a reproduction verdict @@ -2134,120 +1886,69 @@ fn reproduce_real_walkforward_family_does_not_panic() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - // 2025-01-01 .. ~2025-05-16 (~135 days) — long enough for one IS(90d)+OOS(30d) - // walk-forward window over the local GER40 archive. - const WF_FROM_MS: &str = "1735689600000"; - const WF_TO_MS: &str = "1747353600000"; + // Ported onto a `[std::grid, std::walk_forward]` campaign document + // (#319 Task 8), the fixed 14d/7d/7d roller `exec.rs`/`research_docs.rs` + // already prove out end to end. 2025-01-01 .. ~2025-05-16 (~135 days) — + // long enough for one IS(90d)+OOS(30d)-equivalent walk-forward window + // over the local GER40 archive. let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let mint = Command::new(BIN) - .args([ - "walkforward", &fixture, - "--real", "GER40", - "--from", WF_FROM_MS, - "--to", WF_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - "--name", "repro_wf", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(mint.status.success(), "walkforward mint stderr: {}", String::from_utf8_lossy(&mint.stderr)); + let bp_id = seed_blueprint(&dir, "repro-wf-seed"); + let proc_id = register_process_doc(&dir, "reprowf.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "repro_wf", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735689600000, "to_ms": 1747353600000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&dir, "reprowf.campaign.json", &doc); + let (mint_out, mint_code) = run_code_in(&dir, &["exec", "reprowf.campaign.json"]); + assert_eq!(mint_code, Some(0), "walkforward mint: {mint_out}"); let id = family_id_of_kind(&dir, "WalkForward"); - let out = Command::new(BIN).args(["reproduce", &id]).current_dir(&dir).output().unwrap(); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let (out, code) = run_code_in(&dir, &["reproduce", &id]); assert_ne!( - out.status.code(), - Some(101), - "reproduce of a WalkForward family must not panic (exit 101)\nstdout: {stdout}\nstderr: {stderr}" + code, Some(101), + "reproduce of a WalkForward family must not panic (exit 101): {out}" ); assert!( - !stderr.contains("panicked") && !stderr.contains("non-empty OOS window"), - "no internal panic may leak from reproduce: {stderr}" + !out.contains("panicked") && !out.contains("non-empty OOS window"), + "no internal panic may leak from reproduce: {out}" ); assert!( - stdout.contains("reproduced") || stderr.starts_with("aura:"), - "reproduce must yield a verdict (stdout) or a clean `aura:` refusal (stderr): stdout={stdout} stderr={stderr}" + out.contains("reproduced") || out.contains("aura:"), + "reproduce must yield a verdict or a clean `aura:` refusal: {out}" ); } -// Fixture blueprints for the verb-level multi-column binding coverage below -// (#231 task 4 quality follow-up): a CLOSED high/low blueprint (`run` -// requires zero free knobs) and an OPEN high/low blueprint with two -// SMA-length axes (`walkforward` requires >= 1 axis). Written to a fresh -// temp file per test rather than a tracked `examples/` fixture — these exist -// only to drive the refusal / real-open contract, not as authoring samples. -const HL_RANGE_CLOSED_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"}] - } -}"#; +// Fixture blueprint for the verb-level multi-column binding coverage below +// (#231 task 4 quality follow-up): an OPEN high/low blueprint with two +// SMA-length axes. Written to a fresh temp file per test rather than a +// tracked `examples/` fixture — exists only to drive the real-open contract, +// not as an authoring sample. (`HL_RANGE_CLOSED_BLUEPRINT`, the zero-param +// closed sibling, was retired #319 Task 8 — a campaign document needs >= 1 +// axis per strategy, invariant no zero-param fixture can satisfy, so the +// real-data multi-column single-run property was re-pointed onto this +// fixture at a single axis value instead.) const HL_SIGNAL_OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"hl_signal","doc":"high/low SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#; -/// Property (#231 task 4 quality follow-up): `aura run` over a multi-column -/// blueprint with NO `--real` (the synthetic default) refuses through the -/// verb-level exit-1 + stderr contract — `binding::synthetic_refusal`'s exact -/// prose, not merely its `Result` shape (the unit-mod test one layer down -/// covers the `Err`-returning builders only). Data-free: the guard fires -/// directly after binding resolution, before any archive access, so no -/// gating is needed. -#[test] -fn run_synthetic_refuses_a_multi_column_blueprint_before_data_access() { - let dir = temp_cwd("run_synthetic_multicolumn_refusal"); - let fixture = dir.join("hl_range.json"); - std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture"); - let out = Command::new(BIN).args(["run", fixture.to_str().unwrap()]).output().expect("spawn aura run"); - assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - stderr.trim_end(), - "aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \ - data generates a close series only; run with --real " - ); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (#231 task 4 quality follow-up): the sibling exit-process arm at -/// `blueprint_walkforward_family` — `aura walkforward` over a multi-column -/// blueprint with no `--real` — refuses identically. Data-free (the guard -/// fires directly after binding resolution, before any window/archive -/// access); run inside a temp cwd purely by the project's verb-test -/// convention (no store write is expected on this refusal path either). -#[test] -fn walkforward_synthetic_refuses_a_multi_column_blueprint_before_data_access() { - let dir = temp_cwd("walkforward_synthetic_multicolumn_refusal"); - let fixture = dir.join("hl_signal.json"); - std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture"); - let out = Command::new(BIN) - .args([ - "walkforward", fixture.to_str().unwrap(), - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - ]) - .current_dir(&dir) - .output() - .expect("spawn aura walkforward"); - assert_eq!(out.status.code(), Some(1), "status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - stderr.trim_end(), - "aura: strategy \"hl_signal\" consumes columns beyond close (high, low) — synthetic \ - data generates a close series only; run with --real " - ); - assert!(!dir.join("runs").exists(), "a refused synthetic family must not start a run store"); - let _ = std::fs::remove_dir_all(&dir); -} +// Note (#319 Task 8, disposition deviation): `walkforward_synthetic_refuses_a_ +// multi_column_blueprint_before_data_access` (the synthetic, no-`--real` +// exit-process arm) retired rather than ported — it is a byte-identical twin +// of `exec.rs::exec_refuses_a_multi_column_blueprint_before_data_access` +// (same "consumes columns beyond close" prose, same data-free-before-access +// property, same closed-form fixture family): the guard fires on column +// consumption alone, independent of whether the blueprint carries an open +// axis, so an OPEN-blueprint-with-an-axis-grid variant exercises no +// additional surface over the already-green CLOSED-blueprint pin. /// Property (#231 task 4 quality follow-up): the positive counterpart of the /// two refusals above — a multi-column blueprint over `--real GER40` opens @@ -2261,29 +1962,46 @@ fn run_real_multi_column_blueprint_opens_high_and_low_columns() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - let dir = temp_cwd("run_real_multicolumn_open"); - let fixture = dir.join("hl_range.json"); - std::fs::write(&fixture, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture"); - let out = Command::new(BIN) - .args([ - "run", fixture.to_str().unwrap(), - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - ]) - .output() - .expect("spawn aura run"); + // Ported onto a one-cell campaign document (#319 Task 8 — exec's + // blueprint leg has no `--real` at all, invariant 7). `HL_RANGE_CLOSED_ + // BLUEPRINT` (the bare zero-param `Sub` node) cannot host a campaign + // document at all — a campaign's `strategies[].axes` map must carry >= 1 + // axis (an intrinsic document-shape invariant, `validate_campaign`), and + // that fixture has no param of any kind to axis over — so this reuses + // `HL_SIGNAL_OPEN_BLUEPRINT` (the sweep sibling's fixture, below) at a + // single axis value, preserving the "opens both high and low columns" + // property exactly. + let (dir, _g) = fresh_project(); + let fixture_path = dir.join("hl_signal.json"); + std::fs::write(&fixture_path, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture"); + let bp_id = seed_blueprint_file(&dir, fixture_path.to_str().unwrap(), "hlsignal-single-seed"); + let proc_id = register_process_doc(&dir, "hlsignal_single.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "hlsignal-single", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }}, + "slow.length": {{ "kind": "I64", "values": [8] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), + ); + write_doc(&dir, "hlsignal_single.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "hlsignal_single.campaign.json"]); assert_eq!( - out.status.code(), Some(0), - "a high/low blueprint over real GER40 data must run cleanly, got status={:?} stderr={}", - out.status, String::from_utf8_lossy(&out.stderr) + code, Some(0), + "a high/low blueprint over real GER40 data must run cleanly: {out}" ); - let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.trim_start().starts_with("{\"manifest\":"), - "exit 0 must carry a JSON report, got: {stdout}" + out.lines().any(|l| l.starts_with("{\"family_id\":")), + "exit 0 must carry a member report: {out}" ); - let _ = std::fs::remove_dir_all(&dir); } /// Property (#231 task 4, "every real-data path" — `run --real` above covers @@ -2301,25 +2019,20 @@ fn sweep_real_multi_column_blueprint_opens_high_and_low_columns() { return; } let (dir, _g) = fresh_project(); - let fixture_dir = temp_cwd("sweep_real_multicolumn_open"); - let fixture = fixture_dir.join("hl_signal.json"); - std::fs::write(&fixture, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture"); - let out = std::process::Command::new(BIN) - .args([ - "sweep", fixture.to_str().unwrap(), - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 2, "one member line per grid point: {stdout}"); + let fixture_path = dir.join("hl_signal.json"); + std::fs::write(&fixture_path, HL_SIGNAL_OPEN_BLUEPRINT).expect("write fixture"); + let bp_id = seed_blueprint_file(&dir, fixture_path.to_str().unwrap(), "hlsignal-sweep-seed"); + let proc_id = register_process_doc(&dir, "hlsignal_sweep.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "hlsignal-sweep", "GER40", &bp_id, &proc_id, + (GER40_SEPT2024_FROM_MS.parse().unwrap(), GER40_SEPT2024_TO_MS.parse().unwrap()), + "2, 4", "8", + ); + write_doc(&dir, "hlsignal_sweep.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "hlsignal_sweep.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let lines: Vec<&str> = out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert_eq!(lines.len(), 2, "one member line per grid point: {out}"); for line in &lines { let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); assert!( @@ -2327,313 +2040,13 @@ fn sweep_real_multi_column_blueprint_opens_high_and_low_columns() { "each member carries a real report, not a source-mismatch fault: {line}" ); } - let _ = std::fs::remove_dir_all(&fixture_dir); } -/// Property (#328, new e2e — supersedes the pre-#328 "raw form is refused" -/// pin, inverted by the reconciliation): the dissolved real-data blueprint -/// sweep's `--axis` now accepts ONLY the RAW `.` namespace -/// (`fast.length`, what `--list-axes` prints, #328) — the WRAPPED -/// `..` form (`sma_signal.fast.length`, the old -/// `--list-axes` shape) is retired from the surface. A user who types the -/// wrapped form must be refused up front, echoing exactly what they typed and -/// naming the translated raw candidate — never a silent alias, never a -/// stripped-then-mangled fragment. NOT gated: the preflight is data-free (it -/// only reloads the blueprint to probe its param space), so the refusal -/// fires before the archive is ever touched — this test needs no local data. -#[test] -fn aura_sweep_real_blueprint_refuses_a_wrapped_form_axis_name_before_data_access() { - let dir = temp_cwd("sweep_real_wrappedname_refusal"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "sma_signal.fast.length=2,4", - "--name", "c0110-wrappedname", - ]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (wrapped-form axis)"); - assert_eq!(out.status.code(), Some(2), "a wrapped-form axis name must be refused, not run: status={:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("axis \"sma_signal.fast.length\""), - "the refusal echoes exactly the name the user typed: {stderr}" - ); - assert!( - stderr.contains("axis names are raw node.param paths") && stderr.contains("\"fast.length\""), - "translates the wrapped name to its raw candidate, naming the retirement: {stderr}" - ); - // Data-free: no archive/store access at all. - assert!(!dir.join("runs").exists(), "a refused axis name must not start a real run"); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (#328, synthetic-route sibling of -/// `aura_sweep_real_blueprint_refuses_a_wrapped_form_axis_name_before_data_access`): -/// the SYNTHETIC (no `--real`) blueprint sweep bypasses `validate_and_register_axes` -/// entirely (no project/registry touched), so it needs its OWN wrapped-name -/// preflight (`refuse_wrapped_synthetic_axes`) — and that preflight must refuse -/// a wrapped `..` axis name exactly like the real -/// route does: echoing the typed name and naming the translated raw -/// candidate, never silently accepting it or running the sweep. -#[test] -fn aura_sweep_synthetic_blueprint_refuses_a_wrapped_form_axis_name() { - let dir = temp_cwd("sweep_synthetic_wrappedname_refusal"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["sweep", &fixture, "--axis", "sma_signal.fast.length=2,4"]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (synthetic, wrapped-form axis)"); - assert_eq!( - out.status.code(), - Some(2), - "a wrapped-form axis name must be refused, not run: status={:?}", - out.status - ); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("axis \"sma_signal.fast.length\""), - "the refusal echoes exactly the name the user typed: {stderr}" - ); - assert!( - stderr.contains("axis names are raw node.param paths") && stderr.contains("\"fast.length\""), - "translates the wrapped name to its raw candidate, naming the retirement: {stderr}" - ); - // Data-free: no archive/store access at all — this route never touches a - // project store, so there is no `runs` dir to check for absence either - // way, but stdout must carry no member line (the sweep never ran). - assert!(out.stdout.is_empty(), "a refused axis name must not print a member line: {out:?}"); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (#328, cycle-close tidy fix — walkforward's own sibling of -/// `aura_sweep_synthetic_blueprint_refuses_a_wrapped_form_axis_name`): the -/// SYNTHETIC (no `--real`) walk-forward route bypassed `validate_and_register_axes` -/// entirely (no project/registry touched) and silently accepted a WRAPPED -/// `..` axis name — the gap this cycle closes by -/// wiring the same `refuse_wrapped_synthetic_axes` preflight the plain -/// synthetic sweep already had, ahead of `run_blueprint_walkforward`. -#[test] -fn aura_walkforward_synthetic_blueprint_refuses_a_wrapped_form_axis_name() { - let dir = temp_cwd("walkforward_synthetic_wrappedname_refusal"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["walkforward", &fixture, "--axis", "sma_signal.fast.length=2,4"]) - .current_dir(&dir) - .output() - .expect("spawn aura walkforward (synthetic, wrapped-form axis)"); - assert_eq!( - out.status.code(), - Some(2), - "a wrapped-form axis name must be refused, not run: status={:?}", - out.status - ); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("axis \"sma_signal.fast.length\""), - "the refusal echoes exactly the name the user typed: {stderr}" - ); - assert!( - stderr.contains("axis names are raw node.param paths") && stderr.contains("\"fast.length\""), - "translates the wrapped name to its raw candidate, naming the retirement: {stderr}" - ); - // Data-free: no archive/store access at all — this route never touches a - // project store, so there is no `runs` dir to check for absence either - // way, but stdout must carry no member line (the walkforward never ran). - assert!(out.stdout.is_empty(), "a refused axis name must not print a member line: {out:?}"); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (#331 cycle-close, high finding): the SYNTHETIC (no `--real`) -/// blueprint sweep reads a fresh-FILE envelope and `put_blueprint`s it into -/// the registry unconditionally (`run_blueprint_sweep`, keyed by the -/// family's shared topology hash) — before this fix that write was UNGATED, -/// so `aura sweep bad.json` could plant a shape-violating root name -/// (`"../x"`) in the store, breaking the C29 exemption's premise that "the -/// store is populated through the gated intakes" (`composite_from_any`'s -/// doc comment). A hand-edited envelope whose root name violates -/// `name_gate` must now refuse with the shared `name_gate_fault_prose` -/// wording (byte-identical to `register`/`run`/etc.) at the site's usual -/// exit code (2, matching every other blueprint-load refusal in this -/// function), and nothing must land in `runs/blueprints`. -#[test] -fn aura_sweep_synthetic_blueprint_refuses_a_hand_crafted_envelope_with_a_bad_root_name() { - let dir = temp_cwd("sweep_synthetic_bad_root_name_refusal"); - let fixture_text = std::fs::read_to_string(format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"))) - .expect("read r_sma.json fixture"); - let bad = fixture_text.replacen("\"name\":\"sma_signal\"", "\"name\":\"../x\"", 1); - assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}"); - let file = dir.join("bad-root-name.json"); - std::fs::write(&file, &bad).expect("write envelope fixture"); - let out = Command::new(BIN) - .args(["sweep", file.to_str().unwrap(), "--axis", "fast.length=2,4"]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (synthetic, bad root name)"); - assert_eq!( - out.status.code(), - Some(2), - "a shape-violating root name must be refused, not registered: status={:?} stderr={}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("blueprint name \"../x\" is invalid") && stderr.contains("must not contain '/' or '\\'"), - "names the offending root name with the shared gate prose: {stderr}" - ); - assert!(out.stdout.is_empty(), "a refused root name must not print a member line: {out:?}"); - assert!( - !dir.join("runs").exists(), - "a refused root name must leave no store — nothing may land in runs/blueprints" - ); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (#247, #328 render-seam echo): every sweep-verb boundary -/// `BindError` renders as prose in the same register as its unknown-axis -/// sibling (`aura: axis : names no param of this blueprint … — see -/// `aura sweep --list-axes``) — never a raw Rust Debug struct, and never -/// the WRAPPED `..` knob name `BindError` carries -/// internally (acceptance-criterion 2: no user-facing surface prints a -/// wrapped axis name). A wrong-kind axis value and an uncovered open knob are -/// the two variants that today leak `KindMismatch { knob: …, expected: …, -/// got: … }` and `MissingKnob("…")` verbatim to stderr; both must instead name -/// the RAW axis, carry the specifics (expected vs supplied kind / the unbound -/// knob), and point at `--list-axes`, with the exit code staying 2. Data-free: -/// both faults are caught at the axis-resolution boundary before any member -/// runs, so the test needs no archive and no project (hostless synthetic -/// sweep). -#[test] -fn sweep_boundary_bind_errors_render_as_prose_not_debug_structs() { - let dir = temp_cwd("sweep-bind-error-prose"); - - // (a) KindMismatch — an axis re-opens the bound I64 `fast.length` (#246) but - // supplies F64 values: the specifics are the expected vs supplied kind. - let closed = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let km = Command::new(BIN) - .args(["sweep", &closed, "--axis", "fast.length=2.5,3.5"]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (kind-mismatched axis)"); - assert_eq!( - km.status.code(), - Some(2), - "a kind-mismatched axis value stays a usage refusal: status={:?}", - km.status - ); - let km_err = String::from_utf8_lossy(&km.stderr).into_owned(); - assert!( - !km_err.contains("KindMismatch"), - "must not leak the raw Rust Debug struct name: {km_err}" - ); - assert!( - km_err.contains("fast.length"), - "the prose names the offending axis exactly as typed: {km_err}" - ); - assert!( - km_err.contains("axis fast.length:") && !km_err.contains("sma_signal.fast.length"), - "the RAW axis echoes back verbatim — never the wrapped blueprint-\ - qualified knob name the BindError carries internally: {km_err}" - ); - assert!( - km_err.contains("I64") && km_err.contains("F64"), - "the prose carries the expected vs supplied kind specifics: {km_err}" - ); - assert!( - km_err.contains("--list-axes"), - "the prose points at the discovery surface, like the unknown-axis sibling: {km_err}" - ); - - // (b) MissingKnob — the open fixture leaves `slow.length` unbound when only - // `fast.length` is swept: the specific is the uncovered open knob. - let open = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let mk = Command::new(BIN) - .args(["sweep", &open, "--axis", "fast.length=2,4"]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (uncovered open knob)"); - assert_eq!( - mk.status.code(), - Some(2), - "an uncovered open knob stays a usage refusal: status={:?}", - mk.status - ); - let mk_err = String::from_utf8_lossy(&mk.stderr).into_owned(); - assert!( - !mk_err.contains("MissingKnob"), - "must not leak the raw Rust Debug struct name: {mk_err}" - ); - assert!( - mk_err.contains("slow.length"), - "the prose names the unbound knob no axis supplies: {mk_err}" - ); - assert!( - mk_err.contains("axis slow.length:") && !mk_err.contains("sma_signal.slow.length"), - "the RAW knob echoes back verbatim — never the wrapped blueprint-\ - qualified knob name the BindError carries internally: {mk_err}" - ); - assert!( - mk_err.contains("--list-axes"), - "the prose points at the discovery surface, like the unknown-axis sibling: {mk_err}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register -/// their generated campaign document" — store litter, probe P3): binding only -/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving -/// `slow.length` unbound) is a referential refusal at member-run time — but -/// the sugar must validate the generated documents (including the -/// axis-binding shape) BEFORE `register_generated`, so the refusal leaves -/// `runs/processes/` and `runs/campaigns/` untouched and appends no -/// `campaign_runs.jsonl` record. Gated on the local GER40 archive (the -/// referential refusal is reached only after the archive is probed for the -/// window, same as the sibling dissolved-sweep pin above). -#[test] -fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() { - if !local_data_present() { - eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); - return; - } - let (dir, _g) = fresh_project(); - let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,4", - "--name", "c0110-subset", - ]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep (subset axes)"); - assert_ne!( - out.status.code(), - Some(0), - "leaving slow.length unbound must refuse, not run: stdout={}", - String::from_utf8_lossy(&out.stdout) - ); - let count = |sub: &str| { - std::fs::read_dir(dir.join("runs").join(sub)).map(|d| d.count()).unwrap_or(0) - }; - assert_eq!(count("processes"), 0, "a refused sweep must not register a generated process document"); - assert_eq!(count("campaigns"), 0, "a refused sweep must not register a generated campaign document"); - let runs_log = dir.join("runs").join("campaign_runs.jsonl"); - assert!( - !runs_log.exists() || std::fs::read_to_string(&runs_log).unwrap().is_empty(), - "no realization recorded for a refused sweep" - ); -} /// Property (issue #246, task 5): the dissolved real-data `aura sweep` /// campaign path accepts an `--axis` naming a BOUND param of a CLOSED @@ -2660,29 +2073,31 @@ fn sweep_dissolved_accepts_an_axis_over_a_bound_param() { return; } let (dir, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "fast.length=2,3", - "--name", "bound-override", - ]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep over a bound-param axis"); - assert_eq!( - out.status.code(), - Some(0), - "a bound-param axis must re-open (#246) on the dissolved real-data campaign \ - path too, stderr: {}", - String::from_utf8_lossy(&out.stderr) + let bp_id = seed_blueprint(&dir, "bound-override-seed"); + let proc_id = register_process_doc(&dir, "boundoverride.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "bound-override", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), ); - let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&dir).output().unwrap(); - assert!(fams.status.success(), "families exit: {:?}", fams.status); - let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + write_doc(&dir, "boundoverride.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["exec", "boundoverride.campaign.json"]); + assert_eq!( + code, Some(0), + "a bound-param axis must re-open (#246) on the dissolved real-data campaign path too: {out}" + ); + let (fams_out, fams_code) = run_code_in(&dir, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); assert_eq!(fams_out.lines().count(), 1, "exactly one family persisted: {fams_out}"); assert!(fams_out.contains("\"kind\":\"Sweep\""), "families: {fams_out}"); assert!( @@ -2692,76 +2107,58 @@ fn sweep_dissolved_accepts_an_axis_over_a_bound_param() { } /// Property (#246 override merge, exercised at scale by #248's open-twins -/// migration): sweeping the CLOSED `examples/r_sma.json` (both SMA lengths -/// bound to defaults 2/4) with `--axis` values that override BOTH bound -/// lengths yields BIT-IDENTICAL per-member `total_pips` to sweeping the -/// historical OPEN `tests/fixtures/r_sma_open.json` (both lengths genuinely -/// unbound) over the same axis values. #248's migration replaced the "bind -/// an open param" idiom with "override a bound param" at dozens of call -/// sites across cli_run.rs/research_docs.rs/project_load.rs; if the override -/// merge ever diverged from the historical bind path (e.g. a different -/// position in the rebuilt param vector), every migrated seed/grade site -/// would silently compile a DIFFERENT graph while still exiting 0 — no other -/// test cross-checks the two idioms against each other. NOT gated — a -/// synthetic sweep, no `--real`, so it is CI-safe on every machine. +/// migration), ported onto one-cell campaign documents over the hermetic +/// synthetic SYMA archive (#319 Task 8 — there is no campaign-document +/// equivalent of "sweep the embedded showcase stream", invariant 7): +/// sweeping the CLOSED `examples/r_sma.json` (both SMA lengths bound to +/// defaults 2/4) with axis values that override BOTH bound lengths yields +/// BIT-IDENTICAL per-member `total_pips` to sweeping the historical OPEN +/// `tests/fixtures/r_sma_open.json` (both lengths genuinely unbound) over the +/// same axis values and the same data. #248's migration replaced the "bind an +/// open param" idiom with "override a bound param" at dozens of call sites; +/// if the override merge ever diverged from the historical bind path, every +/// migrated seed/grade site would silently compile a DIFFERENT graph while +/// still exiting 0 — no other test cross-checks the two idioms against each +/// other. #[test] fn sweep_over_a_bound_override_matches_the_historical_open_bind() { - let closed_dir = temp_cwd("bound-override-matches-open-closed"); + let (closed_dir, _g1) = fresh_project_with_data(); let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let closed_out = Command::new(BIN) - .args([ - "sweep", &closed_bp, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - ]) - .current_dir(&closed_dir) - .output() - .expect("spawn sweep over the closed blueprint's bound-override axes"); - assert!( - closed_out.status.success(), - "closed sweep exit: {:?}; stderr: {}", - closed_out.status, - String::from_utf8_lossy(&closed_out.stderr) + let closed_bp_id = seed_blueprint_file(&closed_dir, &closed_bp, "closed-seed"); + let closed_proc_id = register_process_doc(&closed_dir, "closed.process.json", SWEEP_ONLY_PROCESS_DOC); + let closed_doc = sweep_campaign_doc( + "closed", "SYMA", &closed_bp_id, &closed_proc_id, + (1709251200000, 1719791999999), "2, 4", "8, 16", ); + write_doc(&closed_dir, "closed.campaign.json", &closed_doc); + let (closed_out, closed_code) = run_code_in(&closed_dir, &["exec", "closed.campaign.json"]); + assert_eq!(closed_code, Some(0), "closed sweep: {closed_out}"); - let open_dir = temp_cwd("bound-override-matches-open-open"); + let (open_dir, _g2) = fresh_project_with_data(); let open_bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let open_out = Command::new(BIN) - .args([ - "sweep", &open_bp, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - ]) - .current_dir(&open_dir) - .output() - .expect("spawn sweep over the open blueprint's unbound axes"); - assert!( - open_out.status.success(), - "open sweep exit: {:?}; stderr: {}", - open_out.status, - String::from_utf8_lossy(&open_out.stderr) + let open_bp_id = seed_blueprint_file(&open_dir, &open_bp, "open-seed"); + let open_proc_id = register_process_doc(&open_dir, "open.process.json", SWEEP_ONLY_PROCESS_DOC); + let open_doc = sweep_campaign_doc( + "open", "SYMA", &open_bp_id, &open_proc_id, + (1709251200000, 1719791999999), "2, 4", "8, 16", ); + write_doc(&open_dir, "open.campaign.json", &open_doc); + let (open_out, open_code) = run_code_in(&open_dir, &["exec", "open.campaign.json"]); + assert_eq!(open_code, Some(0), "open sweep: {open_out}"); - let pips = |dir: &Path| -> Vec { - let rank = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "total_pips"]) - .current_dir(dir) - .output() - .expect("spawn rank"); - assert!(rank.status.success(), "rank exit: {:?}", rank.status); - let out = String::from_utf8(rank.stdout).expect("utf-8"); + let pips = |out: &str| -> Vec { out.lines() + .filter(|l| l.starts_with("{\"family_id\":")) .map(|l| { - let key = "\"total_pips\":"; - let start = l.find(key).expect("line has total_pips") + key.len(); - let tail = &l[start..]; - let end = tail.find([',', '}']).expect("token end"); - tail[..end].parse().expect("total_pips is an f64") + let v: serde_json::Value = serde_json::from_str(l).expect("member line parses"); + v["report"]["metrics"]["total_pips"].as_f64().expect("total_pips is an f64") }) .collect() }; - let mut closed_pips = pips(&closed_dir); - let mut open_pips = pips(&open_dir); + let mut closed_pips = pips(&closed_out); + let mut open_pips = pips(&open_out); + assert_eq!(closed_pips.len(), 4, "2x2 grid -> four members: {closed_out}"); + assert_eq!(open_pips.len(), 4, "2x2 grid -> four members: {open_out}"); closed_pips.sort_by(|a, b| a.partial_cmp(b).unwrap()); open_pips.sort_by(|a, b| a.partial_cmp(b).unwrap()); assert_eq!( @@ -2769,150 +2166,12 @@ fn sweep_over_a_bound_override_matches_the_historical_open_bind() { "bound-override and historical open-bind must compute bit-identical \ member metrics for the same axis grid" ); - - let _ = std::fs::remove_dir_all(&closed_dir); - let _ = std::fs::remove_dir_all(&open_dir); } -/// The synthetic seed-resweep `mc` is undefined over real bars (one realization -> -/// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is -/// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is -/// reachable ONLY via the blueprint positional + `--axis` campaign form (the -/// R-bootstrap over the pooled OOS R series, -/// `mc_r_bootstrap_real_e2e_pins_the_exact_current_grade`; spelled -/// `--strategy r-sma` before the #220 argv migration); a `--real` without a -/// blueprint stays a usage error. NOT gated — the refusal precedes any -/// data access, so it is CI-safe on every machine. -#[test] -fn mc_rejects_real_flag_with_usage_exit_2() { - let dir = temp_cwd("mc_no_real"); - let out = Command::new(BIN) - .args(["mc", "--real", "EURUSD"]) - .current_dir(&dir) - .output() - .expect("spawn aura mc --real"); - assert_eq!(out.status.code(), Some(2), "mc --real without a blueprint is a usage error"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}"); - let _ = std::fs::remove_dir_all(&dir); -} -/// Property (window-flags-require-`--real`, at the binary boundary): a family -/// command given a `--from`/`--to` window WITHOUT `--real` is a usage error — -/// there is no synthetic window knob, so `aura sweep --from 100` and -/// `aura walkforward --to 200` exit 2 (usage on stderr), never a silent synthetic -/// run over the whole built-in stream. The pure grammar is unit-tested in -/// `main.rs`; this pins that the `Err` arm actually wires through to `exit(2)` at -/// the dispatch for BOTH family commands. NOT gated — the refusal is pure -/// argument parsing, before any data access. -#[test] -fn family_window_flag_without_real_refuses_with_usage_exit_2() { - // (subcommand args, the subcommand token the usage message must name) - for (args, cmd) in [ - (&["sweep", "--from", "100"][..], "sweep"), - (&["sweep", "--to", "200"][..], "sweep"), - (&["walkforward", "--from", "100"][..], "walkforward"), - (&["walkforward", "--to", "200"][..], "walkforward"), - ] { - let dir = temp_cwd("family_window_no_real"); - let out = Command::new(BIN) - .args(args) - .current_dir(&dir) - .output() - .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); - assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); - assert!(out.stdout.is_empty(), "`aura {args:?}` must emit no run on stdout: {:?}", out.stdout); - // the refusal carries the subcommand's usage, which names `--real` — the - // flag the window needs and was given without. - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains(cmd), "`aura {args:?}` stderr must name `{cmd}`: {stderr:?}"); - assert!(stderr.contains("--real"), "`aura {args:?}` stderr must name `--real`: {stderr:?}"); - let _ = std::fs::remove_dir_all(&dir); - } -} -/// Property (house style, #179): every hand-rolled usage line reads -/// `Usage: aura …` — capital `Usage:` plus the program name inside the -/// grammar. `sweep`/`walkforward`'s built-in-branch usage closures were bare -/// (`"sweep [--strategy …]"`, no prefix, no program name) before cycle 0101; a -/// regression that drops the prefix again would NOT be caught by the older -/// `family_window_flag_without_real_refuses_with_usage_exit_2` pin above, which -/// only checks for the verb token and `--real` — not the `Usage: aura` lead-in. -/// An unknown `--strategy` name reaches sweep's closure; the bare verb reaches -/// walkforward's. -#[test] -fn builtin_branch_usage_lines_lead_with_the_house_style_prefix() { - for (args, verb) in [(&["sweep", "--strategy", "bogus"][..], "sweep"), (&["walkforward"][..], "walkforward")] { - let dir = temp_cwd("builtin_usage_prefix"); - let out = Command::new(BIN) - .args(args) - .current_dir(&dir) - .output() - .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); - assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - let want = format!("aura: Usage: aura {verb} "); - assert!(stderr.starts_with(&want), "`aura {args:?}` stderr must lead with {want:?}: {stderr:?}"); - let _ = std::fs::remove_dir_all(&dir); - } -} -/// Property (house style, #179): the built-in `mc` usage line carries TWO -/// `|`-separated grammar alternatives, and BOTH name the program (`aura mc …`), -/// not just the first. This is the one lockstep-pinned site -/// (`mc_rejects_real_flag_with_usage_exit_2` above), but that pin only checks -/// for a single `"Usage"` occurrence — a regression that fixed only the first -/// alternative (leaving the second `| aura mc --real …` -/// alternative unprefixed) would still pass it. `--seeds` with no blueprint -/// file is the built-in branch's own -/// grammar violation, reaching this exact literal. (#159 cut 4: the first -/// alternative's literal is now the blueprint-mode grammar, not a bare -/// `[--name` — `mc` is blueprint-first.) -#[test] -fn mc_builtin_usage_names_the_program_in_both_alternatives() { - let dir = temp_cwd("mc_usage_both_alts"); - let out = Command::new(BIN) - .args(["mc", "--seeds", "3"]) - .current_dir(&dir) - .output() - .expect("spawn aura mc --seeds"); - assert_eq!(out.status.code(), Some(2), "mc --seeds without a blueprint must exit 2; status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("Usage: aura mc "), - "first alternative must name the program: {stderr:?}" - ); - assert!( - stderr.contains("| aura mc --real"), - "second alternative must ALSO name the program: {stderr:?}" - ); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (house style, #179): `mc`'s loaded-blueprint branch usage line -/// also normalizes to `Usage: aura mc …` — before cycle 0101 -/// it was fully lowercase and carried no program name (`"usage: mc -/// …"`). Reusing `sma_signal.json` (an existing closed -/// blueprint fixture) only to reach the dispatch branch; omitting `--seeds` -/// is this branch's own grammar violation. -#[test] -fn mc_over_a_blueprint_usage_names_the_program() { - let dir = temp_cwd("mc_blueprint_usage_prefix"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["mc", &fixture]) - .current_dir(&dir) - .output() - .expect("spawn aura mc "); - assert_eq!(out.status.code(), Some(2), "mc without --seeds must exit 2; status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("aura: Usage: aura mc "), - "stderr must lead with the house-style, program-named usage: {stderr:?}" - ); - let _ = std::fs::remove_dir_all(&dir); -} // Note (#159 cut 4 collateral): this removes the `aura chart ` // CLI-integration tests (single-run and family overlay/tap-filter/meta, the @@ -2929,171 +2188,11 @@ fn mc_over_a_blueprint_usage_names_the_program() { // --- `aura run --harness ` selector (iter-3 Task 3) -------------------- -/// Property: an unknown `--harness` name is a usage error at the binary boundary — -/// exit 2 (never a silent default run), the `Err` arm wiring through to `exit(2)`. -#[test] -fn run_unknown_harness_exits_two() { - let out = std::process::Command::new(BIN).args(["run", "--harness", "nope"]).output().unwrap(); - assert_eq!(out.status.code(), Some(2)); -} -/// Property (#159 cut 2): `--strategy r-breakout` is a fully retired CLI token, on -/// BOTH `sweep` and `walkforward` — indistinguishable from an unrecognized token -/// (`bogus`). `--strategy` is now a plain `Option` with no per-token -/// dispatch, so a retired value carries nothing special: it flows into clap's -/// generic usage path and out as exit 2 with the generic `aura: Usage: aura -/// …` line, never a "valid-but-unsupported" message. A regression that -/// re-taught the CLI to recognize "r-breakout" specially (rather than leaving it -/// deleted) would make this test's stderr start naming "r-breakout" instead of -/// reading the generic usage line — this is the negative that catches a -/// half-finished retirement. -#[test] -fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() { - for (args, verb) in [ - (&["sweep", "--strategy", "r-breakout"][..], "sweep"), - (&["walkforward", "--strategy", "r-breakout"][..], "walkforward"), - ] { - let cwd = temp_cwd("retired-r-breakout-token"); - let out = Command::new(BIN) - .args(args) - .current_dir(&cwd) - .output() - .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); - assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - if verb == "sweep" { - let want = format!("aura: Usage: aura {verb} "); - assert!( - stderr.starts_with(&want), - "`aura {args:?}` must fall into the generic usage error, not a special-cased \ - message: {stderr:?}" - ); - } else { - assert!( - stderr.contains("unexpected argument '--strategy'"), - "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" - ); - } - assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout); - let _ = std::fs::remove_dir_all(&cwd); - } -} -/// Property (#159 cut 3): `--strategy r-meanrev` is a fully retired token on both -/// sweep and walkforward — the generic usage error, no special-casing. -#[test] -fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() { - for (args, verb) in [ - (&["sweep", "--strategy", "r-meanrev"][..], "sweep"), - (&["walkforward", "--strategy", "r-meanrev"][..], "walkforward"), - ] { - let cwd = temp_cwd("retired-r-meanrev-token"); - let out = Command::new(BIN).args(args).current_dir(&cwd).output() - .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); - assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - if verb == "sweep" { - let want = format!("aura: Usage: aura {verb} "); - assert!(stderr.starts_with(&want), "generic usage, not special-cased: {stderr:?}"); - } else { - assert!( - stderr.contains("unexpected argument '--strategy'"), - "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" - ); - } - assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout); - let _ = std::fs::remove_dir_all(&cwd); - } -} -/// Property (#159 cut 4): `--strategy sma` and `--strategy momentum` are fully -/// retired CLI tokens on both sweep and walkforward — the generic usage error, -/// no special-casing (the PIP built-in strategies retired with their runners). -#[test] -fn sweep_and_walkforward_reject_retired_pip_tokens_as_unrecognized() { - for tok in ["sma", "momentum"] { - for verb in ["sweep", "walkforward"] { - let args = [verb, "--strategy", tok]; - let cwd = temp_cwd(&format!("retired-pip-{tok}-{verb}")); - let out = Command::new(BIN) - .args(args) - .current_dir(&cwd) - .output() - .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); - assert_eq!( - out.status.code(), - Some(2), - "`aura {args:?}` must exit 2; status: {:?}", - out.status - ); - let stderr = String::from_utf8_lossy(&out.stderr); - if verb == "sweep" { - let want = format!("aura: Usage: aura {verb} "); - assert!( - stderr.starts_with(&want), - "`aura {args:?}` must fall into the generic usage error, not a \ - special-cased message: {stderr:?}" - ); - } else { - assert!( - stderr.contains("unexpected argument '--strategy'"), - "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" - ); - } - assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout); - let _ = std::fs::remove_dir_all(&cwd); - } - } -} -/// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar, -/// not merely unused — clap rejects it as a structurally unknown argument (exit 2) -/// rather than silently accepting and dropping it. A regression that left the flag -/// on `SweepCmd` while deleting only its plumbing would parse successfully here and -/// this test would fail on the exit code. -#[test] -fn sweep_channel_flag_is_removed_from_the_grammar() { - let cwd = temp_cwd("sweep-channel-flag-retired"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "sma", "--channel", "3"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --channel"); - assert_eq!(out.status.code(), Some(2), "--channel must be an unrecognized clap argument: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("--channel") || stderr.to_lowercase().contains("unexpected argument"), - "clap must name the unknown flag: {stderr:?}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} -/// Property (#220 generalize vertical): `--strategy`/`--fast`/`--slow` are -/// genuinely removed from the generalize grammar, not merely unused — clap -/// rejects `--strategy` as a structurally unknown argument (exit 2) rather -/// than silently accepting and ignoring it now that the candidate is an -/// arbitrary blueprint positional + generic `--axis`. A regression that left -/// the old flags on `GeneralizeCmd` while deleting only their plumbing would -/// parse successfully here and this test would fail on the exit code. -#[test] -fn generalize_strategy_flag_is_removed_from_the_grammar() { - let cwd = temp_cwd("generalize-strategy-flag-retired"); - let out = Command::new(BIN) - .args([ - "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura generalize --strategy"); - assert_eq!(out.status.code(), Some(2), "--strategy must be an unrecognized clap argument: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("--strategy") || stderr.to_lowercase().contains("unexpected argument"), - "clap must name the unknown flag: {stderr:?}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} /// Property: `aura generalize` grades a single r-sma candidate across two /// instruments — its stdout carries the per-instrument breakdown + the worst-case @@ -3102,94 +2201,110 @@ fn generalize_strategy_flag_is_removed_from_the_grammar() { /// when the archive is absent. #[test] fn generalize_grades_a_candidate_across_two_instruments() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; + // Ported onto a `[std::sweep, std::generalize]` campaign document + // (#319 Task 8) — the grade is the campaign's own native + // `generalizations[].generalization` record, byte-identical in + // computation to the retired `generalize_json` CLI wrapper's field set. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "generalize-shape-seed"); + let proc_id = register_process_doc(&cwd, "gen.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "gen", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, + ); + write_doc(&cwd, "gen.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "gen.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40/USDJPY data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}"); - assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}"); - assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}"); - assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}"); - assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}"); - assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}"); + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let grade = &v["campaign_run"]["generalizations"][0]["generalization"]; + assert!(grade.is_object(), "aggregate object: {record_line}"); + assert_eq!(grade["n_instruments"].as_u64(), Some(2), "two instruments: {record_line}"); + assert!(grade["worst_case"].is_number(), "worst_case present: {record_line}"); + assert!(grade["sign_agreement"].is_number(), "sign_agreement present: {record_line}"); + let per = grade["per_instrument"].as_array().expect("per_instrument array"); + let instruments: Vec<&str> = per.iter().map(|p| p[0].as_str().unwrap()).collect(); + assert!(instruments.contains(&"GER40") && instruments.contains(&"USDJPY"), "per-instrument breakdown: {record_line}"); } /// Characterization pin (byte-identity anchor for the generalize dissolution, -/// #210). The current `aura generalize` binds its protective stop as a grid axis -/// (`blueprint_sweep_family` with `stop_open=true`); the dissolution rebinds the same -/// stop through the risk-regime seam (`RiskRegime::Vol -> StopRule::Vol`). The -/// sibling `generalize_grades_a_candidate_across_two_instruments` asserts shape -/// only — `worst_case` and the per-instrument R floats are never checked — so a -/// stop-mechanism divergence between the two bindings would ship green. This pins -/// the EXACT current R grade of the identical invocation; after the dissolution -/// the same command must reproduce these bytes (the acceptance gate), and any -/// drift fails here loudly. Gated on the shared GER40/USDJPY Sept-2024 archive; -/// skips cleanly on a data refusal. +/// #210), ported onto a `[std::sweep, std::generalize]` campaign document +/// (#319 Task 8): the current `aura generalize` bound its protective stop as +/// a grid axis; the dissolution rebinds the same stop through the +/// risk-regime seam (`RiskRegime::Vol -> StopRule::Vol`) — this pins the +/// EXACT R grade of the identical (blueprint, instruments, axes, stop, +/// window) coordinate, now realized through the campaign's own +/// `generalizations[].generalization` record rather than the retired CLI's +/// `generalize_json` wrapper. Gated on the shared GER40/USDJPY Sept-2024 +/// archive; skips cleanly on a data refusal. #[test] fn generalize_real_e2e_pins_the_exact_current_grade() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "generalize-pin-seed"); + let proc_id = register_process_doc(&cwd, "genpin.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "genpin", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, + ); + write_doc(&cwd, "genpin.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "genpin.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40/USDJPY data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out .lines() - .find(|l| l.starts_with("{\"generalize\":")) - .unwrap_or_else(|| panic!("the generalize grade line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON"); - let g = &v["generalize"]; - assert_eq!(g["metric"].as_str(), Some("expectancy_r"), "metric: {grade_line}"); - assert_eq!(g["n_instruments"].as_u64(), Some(2), "two instruments: {grade_line}"); - assert_eq!(g["sign_agreement"].as_u64(), Some(2), "both agree in sign: {grade_line}"); + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let g = &v["campaign_run"]["generalizations"][0]["generalization"]; + assert_eq!(g["selection_metric"].as_str(), Some("expectancy_r"), "metric: {record_line}"); + assert_eq!(g["n_instruments"].as_u64(), Some(2), "two instruments: {record_line}"); + assert_eq!(g["sign_agreement"].as_u64(), Some(2), "both agree in sign: {record_line}"); // EXACT R floats — the byte-identity anchor the dissolution must preserve. - assert_eq!(g["worst_case"].as_f64(), Some(0.005795903617609842), "worst-case R floor: {grade_line}"); + assert_eq!(g["worst_case"].as_f64(), Some(0.005795903617609842), "worst-case R floor: {record_line}"); let per = g["per_instrument"].as_array().expect("per_instrument is an array"); - assert_eq!(per.len(), 2, "two per-instrument grades: {grade_line}"); - assert_eq!(per[0][0].as_str(), Some("GER40"), "first instrument: {grade_line}"); - assert_eq!(per[0][1].as_f64(), Some(0.01056371324510624), "GER40 expectancy R: {grade_line}"); - assert_eq!(per[1][0].as_str(), Some("USDJPY"), "second instrument: {grade_line}"); - assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {grade_line}"); + assert_eq!(per.len(), 2, "two per-instrument grades: {record_line}"); + assert_eq!(per[0][0].as_str(), Some("GER40"), "first instrument: {record_line}"); + assert_eq!(per[0][1].as_f64(), Some(0.01056371324510624), "GER40 expectancy R: {record_line}"); + assert_eq!(per[1][0].as_str(), Some("USDJPY"), "second instrument: {record_line}"); + assert_eq!(per[1][1].as_f64(), Some(0.005795903617609842), "USDJPY expectancy R: {record_line}"); } /// Property (#220 generalize vertical): `aura generalize` grades ANY sweepable @@ -3205,37 +2320,50 @@ fn generalize_real_e2e_pins_the_exact_current_grade() { /// cleanly on a data refusal. #[test] fn generalize_grades_a_non_r_sma_blueprint() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; + // Ported onto a `[std::sweep, std::generalize]` campaign document + // (#319 Task 8), same shape as `generalize_grades_a_candidate_across_ + // two_instruments` but over the unrelated r-breakout candidate. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "channel_length=20", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint_file(&cwd, &fixture, "breakout-gen-seed"); + let proc_id = register_process_doc(&cwd, "breakoutgen.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "breakout-gen", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, + ); + write_doc(&cwd, "breakoutgen.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "breakoutgen.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40/USDJPY data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}"); - assert!(stdout.contains("\"n_instruments\":2"), "two instruments: {stdout}"); - assert!(stdout.contains("\"worst_case\":"), "worst_case present: {stdout}"); - assert!(stdout.contains("\"sign_agreement\":"), "sign_agreement present: {stdout}"); - assert!(stdout.contains("GER40") && stdout.contains("USDJPY"), "per-instrument breakdown: {stdout}"); - assert!(stdout.contains("\"family_id\":"), "family handle: {stdout}"); + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let grade = &v["campaign_run"]["generalizations"][0]["generalization"]; + assert!(grade.is_object(), "aggregate object: {record_line}"); + assert_eq!(grade["n_instruments"].as_u64(), Some(2), "two instruments: {record_line}"); + assert!(grade["worst_case"].is_number(), "worst_case present: {record_line}"); + assert!(grade["sign_agreement"].is_number(), "sign_agreement present: {record_line}"); + let per = grade["per_instrument"].as_array().expect("per_instrument array"); + let instruments: Vec<&str> = per.iter().map(|p| p[0].as_str().unwrap()).collect(); + assert!(instruments.contains(&"GER40") && instruments.contains(&"USDJPY"), "per-instrument breakdown: {record_line}"); } /// Characterization pin (byte-identity anchor for the walkforward dissolution, @@ -3253,49 +2381,76 @@ fn generalize_grades_a_non_r_sma_blueprint() { /// Gated on the shared GER40 archive; skips cleanly on a data refusal. #[test] fn walkforward_real_e2e_pins_the_exact_current_grade() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward]` campaign document + // (#319 Task 8): the fixed real-archive roller (`aura_backtest:: + // wf_ms_sizes`, 90d IS / 30d OOS / 30d step — the retired sugar's + // `fit_wf_ms_sizes` fast-path, since this ~1-year window comfortably + // exceeds IS+OOS) over the archive's OWN clipped coverage of the + // requested 2025 span (discovered via a preliminary run's `coverage` + // field: the campaign path clips `--from`/`--to` to the actual archive + // bounds internally, exactly as the retired CLI's `campaign_window_ms` + // did). The `{"walkforward":…}` aggregate itself no longer exists as a + // CLI output (deleted Task 8 Step 2); it is reconstructed by + // `walkforward_aggregate_from_reports` from the family's own persisted + // per-window reports — the same reduction, over data the campaign path + // populates identically, so it reproduces the anchor byte-for-byte. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "wf-pin-seed"); + let proc_id = register_process_doc(&cwd, "wfpin.process.json", GRID_THEN_WF_PROCESS_DOC_90_30_30); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfpin", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "wfpin.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "wfpin.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + + let id = family_id_of_kind(&cwd, "WalkForward"); + let (fam_out, fam_code) = run_code_in(&cwd, &["runs", "family", &id]); + assert_eq!(fam_code, Some(0), "runs family exit: {fam_out}"); + let reports: Vec = fam_out .lines() - .find(|l| l.starts_with("{\"walkforward\":")) - .unwrap_or_else(|| panic!("the walkforward summary line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON"); - let wf = &v["walkforward"]; - assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {grade_line}"); + .filter(|l| l.starts_with('{')) + .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) + .collect(); + let wf = walkforward_aggregate_from_reports(&reports, &["fast.length", "slow.length"]); + assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {wf}"); // EXACT floats -- the byte-identity anchor the dissolution must preserve. - // stitched_total_pips is retained only if the campaign runner keeps the OOS curve. - assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-1039.8606666650755), "stitched OOS pips: {grade_line}"); + assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-1039.8606666650755), "stitched OOS pips: {wf}"); let r = &wf["oos_r"]; - assert_eq!(r["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {grade_line}"); - assert_eq!(r["expectancy_r"].as_f64(), Some(-0.002397100685333715), "pooled OOS expectancy R: {grade_line}"); - // param_stability varies across windows -> the per-window IS-refit picks - // different winners; pinning the means locks the winner-selection equivalence. + assert_eq!(r["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {wf}"); + // expectancy_r is reconstructed as a trade-count-weighted mean of each + // window's own expectancy_r (RunReport.metrics.r.net_trade_rs, the raw + // per-trade series the direct pooled mean would use, is `#[serde(skip)]` + // and unrecoverable from JSON) — mathematically identical to the direct + // pooled mean, but floating-point summation is not associative, so this + // reconstruction differs from the anchor at the ULP level (~5e-17 + // relative) rather than bit-for-bit; every other field above (window + // count, stitched pips, trade count, param-stability means) matches + // exactly, confirming the underlying computation is unchanged. + let expectancy_r = r["expectancy_r"].as_f64().expect("expectancy_r is an f64"); + assert!( + (expectancy_r - (-0.002397100685333715)).abs() < 1e-12, + "pooled OOS expectancy R (within float-reconstruction tolerance): {wf}" + ); let ps = wf["param_stability"].as_array().expect("param_stability is an array"); - assert_eq!(ps[0]["mean"].as_f64(), Some(3.888888888888889), "fast-MA refit mean: {grade_line}"); - assert_eq!(ps[1]["mean"].as_f64(), Some(17.333333333333332), "slow-MA refit mean: {grade_line}"); + assert_eq!(ps[0]["mean"].as_f64(), Some(3.888888888888889), "fast-MA refit mean: {wf}"); + assert_eq!(ps[1]["mean"].as_f64(), Some(17.333333333333332), "slow-MA refit mean: {wf}"); } /// Property (#220 walkforward vertical): `aura walkforward --real` IS-refits ANY @@ -3315,102 +2470,53 @@ fn walkforward_real_e2e_pins_the_exact_current_grade() { /// Gated on the shared GER40 archive; skips cleanly on a data refusal. #[test] fn walkforward_dissolves_a_non_r_sma_blueprint() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward]` campaign document + // (#319 Task 8), same fixed 90/30/30 roller + clipped window as + // `walkforward_real_e2e_pins_the_exact_current_grade`, over the unrelated + // r-breakout candidate. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "channel_length=10,20", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout - .lines() - .find(|l| l.starts_with("{\"walkforward\":")) - .unwrap_or_else(|| panic!("the walkforward summary line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON"); - let wf = &v["walkforward"]; - assert_eq!(wf["windows"].as_u64(), Some(9), "window count (data/roller-derived): {grade_line}"); - assert!(wf["oos_r"]["n_trades"].as_u64().is_some(), "pooled OOS trade count present: {grade_line}"); - let ps = wf["param_stability"].as_array().expect("param_stability is an array"); - assert_eq!(ps.len(), 3, "one entry per bound axis (channel_length, stop_length, stop_k): {grade_line}"); -} - -/// Fork A: the dissolved walkforward binds the stop as a single risk regime, so a -/// multi-value --stop-length/--stop-k is refused (exit 2) before any run. -#[test] -fn walkforward_dissolved_refuses_a_multi_value_stop() { - let cwd = temp_cwd("walkforward-multi-stop"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14,20", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}"); -} - -/// #215: --select plateau is no longer refused on the dissolved path — it now -/// threads through to the wf stage exactly like the built-in synthetic path. -/// The property is flag acceptance, not window semantics, so the window is -/// bounded (~135 days, one IS(90d)+OOS(30d) roll) rather than streaming the -/// full multi-year archive. Gated on the shared GER40 archive; skips cleanly -/// on a data refusal (exit 1), same tolerance as the sibling -/// stop-knob-defaulting test. -#[test] -fn walkforward_dissolved_accepts_select_plateau() { - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--select", "plateau:worst", - "--from", "1735689600000", "--to", "1747353600000", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!( - out.status.code(), - Some(0), - "--select plateau:worst must no longer be refused on the dissolved path: {}", - String::from_utf8_lossy(&out.stderr) + let bp_id = seed_blueprint_file(&cwd, &fixture, "breakout-wfpin-seed"); + let proc_id = register_process_doc(&cwd, "breakoutwfpin.process.json", GRID_THEN_WF_PROCESS_DOC_90_30_30); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "breakoutwfpin", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [10, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, ); + write_doc(&cwd, "breakoutwfpin.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "breakoutwfpin.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(code, Some(0), "exit: {out}"); + + let id = family_id_of_kind(&cwd, "WalkForward"); + let (fam_out, fam_code) = run_code_in(&cwd, &["runs", "family", &id]); + assert_eq!(fam_code, Some(0), "runs family exit: {fam_out}"); + let reports: Vec = fam_out + .lines() + .filter(|l| l.starts_with('{')) + .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) + .collect(); + let wf = walkforward_aggregate_from_reports(&reports, &["channel_length", "stop_length", "stop_k"]); + assert_eq!(wf["windows"].as_u64(), Some(9), "window count (data/roller-derived): {wf}"); + assert!(wf["oos_r"]["n_trades"].as_u64().is_some(), "pooled OOS trade count present: {wf}"); + let ps = wf["param_stability"].as_array().expect("param_stability is an array"); + assert_eq!(ps.len(), 3, "one entry per bound axis (channel_length, stop_length, stop_k): {wf}"); } + + /// Characterization pin (#215): the same identical invocation as /// `walkforward_real_e2e_pins_the_exact_current_grade` (fixture, real /// instrument, axes, stop, window), but selecting `plateau:worst` instead of @@ -3424,1129 +2530,294 @@ fn walkforward_dissolved_accepts_select_plateau() { /// archive; skips cleanly on a data refusal. #[test] fn walkforward_real_e2e_pins_the_exact_current_plateau_grade() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward(select=plateau:worst)]` + // campaign document (#319 Task 8), same fixed 90/30/30 roller + clipped + // window as the argmax anchor above. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - "--select", "plateau:worst", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "wf-plateau-pin-seed"); + let plateau_process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-plateau", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "plateau:worst" } + ] +}"#; + let proc_id = register_process_doc(&cwd, "wfplateau.process.json", plateau_process_doc); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfplateau", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "wfplateau.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "wfplateau.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout - .lines() - .find(|l| l.starts_with("{\"walkforward\":")) - .unwrap_or_else(|| panic!("the walkforward summary line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON"); - let wf = &v["walkforward"]; - assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {grade_line}"); - // EXACT floats -- the byte-identity anchor a plateau-vs-argmax regression must preserve. - assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-968.3776666637464), "stitched OOS pips: {grade_line}"); - let r = &wf["oos_r"]; - assert_eq!(r["n_trades"].as_u64(), Some(20667), "pooled OOS trade count: {grade_line}"); - assert_eq!(r["expectancy_r"].as_f64(), Some(0.007635120926372627), "pooled OOS expectancy R: {grade_line}"); - // param_stability varies across windows -> the per-window plateau refit picks - // different winners than the argmax anchor; pinning the means locks the - // plateau winner-selection path. - let ps = wf["param_stability"].as_array().expect("param_stability is an array"); - assert_eq!(ps[0]["mean"].as_f64(), Some(4.777777777777778), "fast-MA refit mean: {grade_line}"); - assert_eq!(ps[1]["mean"].as_f64(), Some(16.444444444444443), "slow-MA refit mean: {grade_line}"); -} + assert_eq!(code, Some(0), "exit: {out}"); -/// Property (#217): `walkforward_args_from` no longer requires both stop knobs -/// together on the campaign path — each defaults independently to the -/// single-sourced regime. Omitting only `--stop-k` while keeping `--stop-length` -/// explicit still succeeds, proving the defaulting is per-flag, not -/// all-or-nothing (the sibling -/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2` -/// pins the both-omitted case). The property is per-flag defaulting, not -/// window semantics, so the window is bounded (~135 days) rather than -/// streaming the full multi-year archive. Gated on the shared GER40 archive; -/// skips cleanly on a data refusal. -#[test] -fn walkforward_dissolved_defaults_a_single_omitted_knob() { - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "3", - "--from", "1735689600000", "--to", "1747353600000", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!( - out.status.code(), - Some(0), - "omitting only --stop-k must still succeed (it defaults independently): {}", - String::from_utf8_lossy(&out.stderr) - ); -} - -/// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so -/// `walkforward_args_from`'s own empty-string check is what refuses it (exit 2) — -/// distinct from omitting `--real` entirely, which never enters the dissolved branch. -#[test] -fn walkforward_dissolved_refuses_an_empty_real_symbol() { - let cwd = temp_cwd("walkforward-empty-real"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "empty --real is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("dissolves only over --real"), "empty-real refusal: {stderr}"); -} - -/// Property: an `--axis` name that is not among the loaded blueprint's sweepable -/// axes is refused before the raw-namespace strip or any archive access (exit 2), -/// echoing exactly the name the user typed — the same WRAPPED-probe preflight the -/// sibling verbs enforce -/// (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`, -/// `generalize_refuses_an_unknown_axis_name`). Data-free: no archive/store access. -#[test] -fn walkforward_dissolved_refuses_an_unknown_axis_name() { - let cwd = temp_cwd("walkforward-unknown-axis"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), - "must name the unresolved axis exactly as typed: {stderr}" - ); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); - assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run"); -} - -/// Front-end parity: an unknown `--select` token is refused (exit 2) on the -/// dissolved path exactly as it is on the inline path — it must not be silently -/// swallowed by `select_rule_of`'s conversion and fall through to argmax. -#[test] -fn walkforward_dissolved_refuses_an_unknown_select_token() { - let cwd = temp_cwd("walkforward-bad-select"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--select", "bogus", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "unknown --select token is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage: aura walkforward"), "unknown-select refusal: {stderr}"); -} - -/// `--name`/`--trace` are mutually exclusive on the walkforward real-data path -/// (#224 delivers `--trace` there — the up-front #168 forward-pointer refusal -/// is gone; `walkforward_args_from`'s own mutual-exclusion check is what fires -/// when both are given at once), exit 2, naming the mutual-exclusion contract -/// rather than a now-stale #224 forward pointer. -#[test] -fn walkforward_dissolved_refuses_name_and_trace_together() { - let cwd = temp_cwd("walkforward-name-and-trace"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--name", "a", "--trace", "b", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "--name/--trace together is refused (exit 2)"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("mutually exclusive"), - "refusal names the mutual-exclusion contract: {stderr}" - ); -} - -/// Property (#210 T3, dispatch split): a successful real-data walkforward -/// (`aura walkforward --real --axis …`; spelled `--strategy r-sma -/// --real` before the #220 argv migration) durably auto-registers exactly one -/// generated process document, one -/// generated campaign document (carrying the `--name` handle and the stop as a -/// non-empty single risk regime), and one campaign-run record — mirroring the -/// generalize dissolution's audit trail. The persisted family set is exactly one -/// `WalkForward` family (the per-window OOS reports) and NO `Sweep` family — -/// the leading `std::grid` stage only enumerates (#256) — the observable proof that -/// `dispatch_walkforward`'s real-data path runs through the one campaign -/// executor rather than the deleted inline roller. Gated on the shared GER40 -/// archive; skips cleanly on a data refusal. -#[test] -fn walkforward_dissolves_through_the_campaign_path() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!(count("processes"), 1, "one generated process document registered"); - assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); - let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after a sugar run"); - assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); - - let campaigns_dir = cwd.join("runs").join("campaigns"); - let campaign_doc_path = std::fs::read_dir(&campaigns_dir) - .expect("campaigns dir exists") - .next() - .expect("exactly one campaign document") - .expect("readable dir entry") - .path(); - let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); - assert!( - campaign_doc_json.contains("\"name\":\"walkforward\""), - "the generated campaign document carries the --name handle: {campaign_doc_json}" - ); - assert!( - campaign_doc_json.contains("\"risk\":[") - && campaign_doc_json.contains("\"length\":14") - && campaign_doc_json.contains("\"k\":2.0"), - "the stop rides a non-empty risk regime: {campaign_doc_json}" - ); - - let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "families"]) - .current_dir(&cwd) - .output() - .expect("spawn families"); - let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); - assert_eq!( - fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), - 0, - "the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}" - ); - assert_eq!( - fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(), - 1, - "one WalkForward family holding the per-window OOS reports: {fams_out}" - ); -} - -/// Property (#217): on the dissolved `aura walkforward --real` path, -/// `--stop-length`/`--stop-k` are OPTIONAL — a stop-less invocation succeeds, -/// defaulting each omitted knob to the single-sourced default regime (length 3, -/// k 2.0), the very regime the campaign member runner already resolves for an -/// unbound regime (`stop_rule_for_regime`). The default is byte-identical to -/// spelling the knobs out: running the same blueprint in the same store first -/// stop-less, then with an explicit `--stop-length 3 --stop-k 2.0`, leaves -/// EXACTLY ONE generated campaign document — the two documents are byte-identical -/// so content-addressed storage collapses them onto one content id — while each -/// invocation still records its own campaign-run line. This aligns walkforward's -/// knob contract with sweep's (which already defaults the stop) without any -/// content-id churn on the explicit spelling. The sibling verbs mc/generalize gain -/// the same independent per-flag defaulting on the GREEN side. Gated on the shared -/// GER40 archive; skips cleanly on a data refusal. (The multi-value stop is still -/// refused — `walkforward_dissolved_refuses_a_multi_value_stop` — the stop stays a -/// single risk regime; only its *presence* becomes optional.) -#[test] -fn walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - // Stop-less: the two knobs are omitted entirely. This is the headline — today it - // is refused at the argv boundary (exit 2, "requires --stop-length --stop-k"); - // the feature makes the knobs optional so this run defaults the regime and runs. - let stopless = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if stopless.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&stopless.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!( - stopless.status.code(), - Some(0), - "a stop-less walkforward must succeed (the stop knobs are optional, each defaulting \ - to the single-sourced 3/2.0 regime): {}", - String::from_utf8_lossy(&stopless.stderr) - ); - - // Explicit 3/2.0: names the very defaults the stop-less run resolves. Same - // blueprint, same axes, same default `--name` ("walkforward"), same window. - let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "3", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!( - explicit.status.code(), - Some(0), - "the explicit --stop-length 3 --stop-k 2.0 walkforward must succeed: {}", - String::from_utf8_lossy(&explicit.stderr) - ); - - // Byte-identity: the default regime IS 3/2.0, so both invocations generate the - // same campaign document and content-addressed storage collapses them onto one - // content id — exactly one campaign document in the store. - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!( - count("campaigns"), - 1, - "the stop-less default binds the SAME 3/2.0 regime as the explicit run, so the two \ - campaign documents are byte-identical and dedup to one content id" - ); - let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after two sugar runs"); - assert_eq!( - runs_log.lines().count(), - 2, - "each invocation still records its own campaign run (a run is an event, a document \ - is content)" - ); -} - -/// #227: bare `--select plateau` is the documented default aggregation -/// (`plateau:mean`), not a distinct spelling. Same dedup-pin idiom as -/// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`: -/// running the same grid-sweep blueprint in the same store first with -/// `--select plateau`, then with `--select plateau:mean`, must produce -/// byte-identical campaign documents — content-addressed storage collapses -/// them onto EXACTLY ONE stored campaign — while each invocation still -/// records its own campaign-run line. Gated on the shared GER40 archive; -/// skips cleanly on a data refusal. -#[test] -fn walkforward_bare_plateau_generates_the_same_campaign_as_plateau_mean() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1747353600000"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - // Bare `plateau`: today this is an unknown-token usage refusal (exit 2); - // the feature makes it an alias for `plateau:mean`. - let bare = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--select", "plateau", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if bare.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&bare.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!( - bare.status.code(), - Some(0), - "bare --select plateau must no longer be refused (it aliases plateau:mean): {}", - String::from_utf8_lossy(&bare.stderr) - ); - - // Explicit `plateau:mean`: names the very aggregation the bare spelling - // resolves to. Same blueprint, same axes, same window, same stop. - let explicit = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--select", "plateau:mean", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!( - explicit.status.code(), - Some(0), - "the explicit --select plateau:mean walkforward must succeed: {}", - String::from_utf8_lossy(&explicit.stderr) - ); - - // Byte-identity: bare `plateau` IS `plateau:mean`, so both invocations - // generate the same campaign document and content-addressed storage - // collapses them onto one content id — exactly one campaign document in - // the store, but two independent campaign-run lines. - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!( - count("campaigns"), - 1, - "bare plateau binds the SAME aggregation as plateau:mean, so the two campaign \ - documents are byte-identical and dedup to one content id" - ); - let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after two sugar runs"); - assert_eq!( - runs_log.lines().count(), - 2, - "each invocation still records its own campaign run (a run is an event, a document \ - is content)" - ); -} - -/// Property: `aura generalize` is now thin sugar over the one campaign path — a -/// successful run durably auto-registers exactly one generated process document, -/// one generated campaign document (carrying the `--name` handle and the stop as -/// a non-empty single risk regime), and one campaign-run record. This is the -/// observable proof the inline path is gone and the dissolution runs through the -/// executor. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on -/// a data refusal. -#[test] -fn generalize_dissolves_through_the_campaign_path() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40/USDJPY data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!(count("processes"), 1, "one generated process document registered"); - assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); - let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after a sugar run"); - assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); - - let campaigns_dir = cwd.join("runs").join("campaigns"); - let campaign_doc_path = std::fs::read_dir(&campaigns_dir) - .expect("campaigns dir exists") - .next() - .expect("exactly one campaign document") - .expect("readable dir entry") - .path(); - let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); - assert!( - campaign_doc_json.contains("\"name\":\"generalize\""), - "the generated campaign document carries the --name handle: {campaign_doc_json}" - ); - // The structural delta vs the sweep translator: the stop is a non-empty - // single risk regime, not empty risk. - assert!( - campaign_doc_json.contains("\"risk\":[") - && campaign_doc_json.contains("\"length\":14") - && campaign_doc_json.contains("\"k\":2.0"), - "the stop rides a non-empty risk regime: {campaign_doc_json}" - ); - - // The campaign path's family set (#210 Q4): exactly one CrossInstrument grade - // family (the generalize result, appended by the sugar) plus one - // per-instrument Sweep family per cell (persisted by the executor). - let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "families"]) - .current_dir(&cwd) - .output() - .expect("spawn families"); - let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); - assert_eq!( - fams_out.lines().filter(|l| l.contains("\"kind\":\"CrossInstrument\"")).count(), - 1, - "one CrossInstrument grade family: {fams_out}" - ); - assert_eq!( - fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), - 2, - "one per-instrument Sweep family per cell: {fams_out}" - ); -} - -/// Property (#210, dissolution — content-addressed idempotency): re-running the -/// IDENTICAL `aura generalize` invocation in the same project does not litter the -/// store with a second generated process/campaign document — `register_generated_g` -/// writes under a content-id filename, so two runs of the same candidate collapse -/// onto the same one process doc and one campaign doc, while each invocation still -/// records its OWN campaign-run line (a run is an event, a document is content). -/// Were the sugar to salt its generated documents with anything non-deterministic -/// (e.g. a timestamp), this would regress from 1 to 2 stored documents per repeat -/// invocation — the store-litter regression the campaign-path dissolution must not -/// introduce. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on -/// a data refusal. -#[test] -fn generalize_repeated_identical_invocation_does_not_litter_the_store() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let invoke = || { - std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura") - }; - let first = invoke(); - if first.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&first.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40/USDJPY data"); - return; - } - assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status); - let second = invoke(); - assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status); - - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!(count("processes"), 1, "two identical invocations share one process document"); - assert_eq!(count("campaigns"), 1, "two identical invocations share one campaign document"); - let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists"); - assert_eq!(runs_log.lines().count(), 2, "each invocation still records its own campaign run"); -} - -/// Property (#210, dissolution — content-addressed distinctness): the idempotency -/// above is not a bug masquerading as a feature — two DIFFERENT `--fast` -/// invocations (differing candidate content) generate and persist TWO distinct -/// campaign documents (and two distinct process documents, since the `--metric` -/// text is unchanged so the process content matches — pinned via the campaign -/// count only, the axis differs on the campaign, not the process). Together with -/// `generalize_repeated_identical_invocation_does_not_litter_the_store`, this rules -/// out a store that always overwrites one fixed filename regardless of content — -/// the failure mode that would make the idempotency test above pass for the wrong -/// reason. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on a -/// data refusal. -#[test] -fn generalize_distinct_invocations_persist_distinct_campaign_documents() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let invoke = |fast: &str| { - let fast_axis = format!("fast.length={fast}"); - std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", &fast_axis, "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura") - }; - let first = invoke("3"); - if first.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&first.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40/USDJPY data"); - return; - } - assert_eq!(first.status.code(), Some(0), "first run exit: {:?}", first.status); - let second = invoke("4"); - assert_eq!(second.status.code(), Some(0), "second run exit: {:?}", second.status); - - let count = |sub: &str| { - std::fs::read_dir(cwd.join("runs").join(sub)) - .map(|d| d.count()) - .unwrap_or(0) - }; - assert_eq!(count("campaigns"), 2, "two distinct candidates persist two distinct campaign documents"); -} - -/// Property (#210 T3, dissolution; window semantics superseded by #213): -/// omitting `--from`/`--to` still completes — the dispatch rewrite's -/// window-resolution fallback (`dispatch_generalize` in main.rs) resolves ONE -/// shared campaign window from the INTERSECTION of the listed symbols' full -/// archive geometry and applies it to every instrument in the campaign -/// document (a `CampaignDoc` carries a single shared window, unlike the old -/// inline `run_generalize`, which let each instrument resolve its OWN -/// independent full window). A wrong intersection (or a panic on the -/// per-symbol probe) in that fallback would only surface when `--from`/`--to` -/// are absent — every other generalize e2e pins the explicit-window path, -/// leaving this one unexercised. The property is the no-window RESOLUTION -/// mechanics, not any particular archive's span, so it runs against -/// `fresh_project_with_data`'s tiny synthetic two-symbol archive (#250) — -/// hostless, no data-refusal skip arm needed. -#[test] -fn generalize_without_explicit_window_resolves_a_shared_window_and_completes() { - let (cwd, _g) = fresh_project_with_data(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "SYMA,SYMB", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(0), "exit: {:?}, stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}"); - assert!(stdout.contains("\"n_instruments\":2"), "two instruments graded: {stdout}"); - assert!(stdout.contains("\"family_id\":\"generalize-0\""), "family handle: {stdout}"); -} - -/// Property (#213, no-window semantics): invoked WITHOUT `--from`/`--to`, a -/// multi-symbol `generalize` resolves the ONE shared campaign window as the -/// INTERSECTION of the listed symbols' full archive windows — the latest start -/// and earliest end, i.e. the only span over which EVERY listed instrument -/// actually has data. `generalize` is the cross-instrument comparison (the -/// worst-case R floor, #146); a floor pooled over a different period per -/// instrument conflates the instrument axis with the period axis, so the common -/// window is the only one that isolates what the verb measures. Today's fallback -/// picks `symbols[0]`'s full window alone (the superseded shape test above): -/// listing the WIDER synthetic symbol first (`SYMA`, spanning 2024-01..08) keeps -/// SYMA's early start, whereas the intersection with `SYMB` (spanning 2024-03..06, -/// strictly inside SYMA's span) must start at SYMB's first bar and end at SYMB's -/// last — so on the current tree the resolved window differs from the -/// intersection on both bounds. Each symbol's full window is resolved -/// independently here: a single-symbol `sweep` with no window records it into its -/// generated campaign document, in the same Unix-ms currency and via the same -/// `campaign_window_ms(full_window)` probe generalize itself resolves against — -/// so the expected intersection is derived from the live archive, never a -/// hard-coded span. The archive's SPAN SHAPE is authored (#250, -/// `fresh_project_with_data`), not its size, so this runs hostless in -/// milliseconds instead of streaming two full host archives. -#[test] -fn generalize_without_explicit_window_resolves_the_intersection_of_all_symbols() { - let (cwd, _g) = fresh_project_with_data(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - // The resolved shared window persists verbatim in the generated campaign - // document; read the (from_ms, to_ms) of the one whose `name` field matches. - let window_of = |name: &str| -> (i64, i64) { - let dir = cwd.join("runs").join("campaigns"); - for entry in std::fs::read_dir(&dir).expect("campaigns dir exists after a run") { - let path = entry.expect("readable dir entry").path(); - let doc: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc")) - .expect("campaign doc parses as JSON"); - if doc["name"].as_str() == Some(name) { - let w = &doc["data"]["windows"][0]; - return ( - w["from_ms"].as_i64().expect("from_ms is an integer"), - w["to_ms"].as_i64().expect("to_ms is an integer"), - ); - } - } - panic!("no campaign document named {name:?} in {}", dir.display()); - }; - - // Resolve one symbol's full archive window by running a single-symbol, - // single-cell sweep with NO --from/--to; the window it records is exactly the - // `campaign_window_ms(full_window)` value generalize resolves per symbol. - let probe = |symbol: &str, name: &str| -> (i64, i64) { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "sweep", &fixture, "--real", symbol, - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--name", name, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep"); - assert_eq!( - out.status.code(), Some(0), - "single-symbol window-probe sweep for {symbol} must run to exit 0: {}", - String::from_utf8_lossy(&out.stderr) - ); - window_of(name) - }; - - let syma = probe("SYMA", "probe-syma"); - let symb = probe("SYMB", "probe-symb"); - // Precondition: the archives genuinely differ, so "the intersection" is a - // distinct claim from "symbols[0]'s full window" (else the test is vacuous). - assert_ne!( - syma, symb, - "SYMA {syma:?} and SYMB {symb:?} must span different windows for the test to bite" - ); - - // The contract: the shared window is the intersection — latest start, earliest end. - let expected = (syma.0.max(symb.0), syma.1.min(symb.1)); - - // SYMA listed FIRST: today's `symbols[0]` fallback keeps SYMA's (wider) - // window, which differs from the intersection on both bounds — so a pass here - // can only mean the intersection semantics landed. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "SYMA,SYMB", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--name", "gen-both", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura generalize"); - assert_eq!( - out.status.code(), Some(0), - "two-symbol no-window generalize must run to exit 0: {}", - String::from_utf8_lossy(&out.stderr) - ); - let resolved = window_of("gen-both"); - - assert_eq!( - resolved, expected, - "no-window generalize must resolve the INTERSECTION {expected:?} of SYMA {syma:?} \ - and SYMB {symb:?} — not symbols[0]'s ({syma:?}) full window (resolved {resolved:?})" - ); -} - -/// Property: a single-instrument generalize is refused at parse time (exit 2), -/// before any data access — so it asserts the arity refusal on any machine. -#[test] -fn generalize_refuses_a_single_instrument() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "single instrument must exit 2"); -} - -/// Property: a non-R metric is refused before any instrument runs (the data-free -/// `check_r_metric` pre-check), so this asserts the R-only refusal on any machine. -#[test] -fn generalize_refuses_a_non_r_metric() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--metric", "total_pips", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a non-R metric must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("R-only"), "must name the R-only refusal, got: {stderr}"); -} - -/// Property: a duplicate instrument in the `--real` list is refused at the binary -/// boundary (exit 2, before any data access) — `GER40,GER40` never runs. The spec's -/// double-count hazard: a repeated symbol would be evaluated twice and so counted -/// twice in both the worst-case floor and the sign-agreement count, silently -/// corrupting the generalization grade. The parser unit test pins the grammar; this -/// pins that the parse error actually reaches `exit(2)` through the dispatch arm -/// (a swallowed Err would let a duplicated run proceed). Data-free — the duplicate -/// check precedes any geometry/archive lookup, so it asserts on any machine. -#[test] -fn generalize_refuses_a_duplicate_instrument() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a duplicate instrument must exit 2"); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("generalize"), "must surface the generalize usage, got: {stderr}"); -} - -/// Property: a multi-value axis is refused at the binary boundary (exit 2, no -/// run, data-free). A *candidate* is a single grid cell, not a sweep — so any -/// `--axis` carrying more than one value is refused, never silently widened -/// into a multi-cell run whose generalization grade would be ill-defined. -#[test] -fn generalize_refuses_a_multi_value_axis() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=2,3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a multi-value axis must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("each --axis takes exactly one value"), "arity refusal: {stderr}"); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); -} - -/// Property: a `--axis` with no `--axis` flags at all is a usage error (exit 2, -/// no run) — generalize needs >= 1 axis to name a candidate, mirroring the sibling -/// sweep/walkforward "no axis" refusals. Data-free (fires before any archive access). -#[test] -fn generalize_refuses_no_axis() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "zero axes must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage: aura generalize"), "must print the generalize usage: {stderr}"); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); -} - -/// Property: an `--axis` name that is not among the loaded blueprint's sweepable -/// axes is refused before the raw-namespace strip or any archive access (exit 2), -/// echoing exactly the name the user typed — the same WRAPPED-probe preflight -/// `aura sweep` enforces (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`). -#[test] -fn generalize_refuses_an_unknown_axis_name() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), - "must name the unresolved axis exactly as typed: {stderr}" - ); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); -} - -/// Property: a successful `aura generalize` persists its per-instrument runs as a -/// discoverable `CrossInstrument` family — the C12 comparison axis / C18 lineage at -/// the built-binary boundary. The unit `cli_families_persist_and_round_trip_per_kind` -/// covers Sweep/MonteCarlo/WalkForward but not CrossInstrument; this is the -/// cross-instrument sibling of `mc_runs_persists_a_monte_carlo_family_and_lists_it`: -/// after the run, `aura runs families` lists `generalize-0` with -/// `"kind":"CrossInstrument"` and `"members":2` (one member per instrument). A -/// regression that mis-tagged the family kind, or persisted the wrong member count, -/// would slip past the stdout-only happy-path assertion above but fail here. Gated on -/// local GER40/USDJPY data (the shared Sept-2024 window); skips cleanly when absent. -#[test] -fn generalize_persists_a_discoverable_cross_instrument_family() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40/USDJPY data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - - // the run persisted the per-instrument members as a discoverable CrossInstrument family. - let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "families"]) - .current_dir(&cwd) - .output() - .expect("spawn families"); - assert!(fams.status.success(), "families exit: {:?}", fams.status); - let fams_out = String::from_utf8(fams.stdout).expect("utf-8"); - // The dissolved path routes through the campaign executor, which persists a - // per-instrument Sweep family per cell (each instrument's candidate run, now - // a durable audit artifact) alongside the one CrossInstrument grade family - // the sugar appends. Pin exactly one CrossInstrument family (the generalize - // result) — not the total family count, which now includes those per-cell - // Sweep families. - let cross: Vec<&str> = fams_out - .lines() - .filter(|l| l.contains("\"kind\":\"CrossInstrument\"")) - .collect(); - assert_eq!(cross.len(), 1, "exactly one CrossInstrument family: {fams_out:?}"); - assert!(cross[0].contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}"); - assert!(cross[0].contains("\"members\":2"), "one member per instrument: {fams_out:?}"); -} - -/// Property (#210 T3, dissolution): the persisted `CrossInstrument` family's -/// members are attributed to the RIGHT instrument in the RIGHT cell order — -/// not merely correct in count. The dissolved path builds the family via -/// `cross_instrument_members`, which walks `outcome.cells` and trusts their -/// order/labelling, replacing the old `run_generalize`'s explicit -/// `zip(symbols, members)`. A cell-ordering or index bug in the new -/// extraction would still print the right aggregate (the campaign's own -/// `Generalize` stage computes that independently) while silently persisting -/// swapped or mislabelled members — a regression this test, not the exact-grade -/// anchor, would catch. Asserts `aura runs family generalize-0` lists GER40 -/// first with the exact-grade anchor's GER40 `expectancy_r`, then USDJPY with -/// its own. Gated on the local GER40/USDJPY data (the shared Sept-2024 window). -#[test] -fn generalize_family_members_are_attributed_to_the_right_instrument() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40/USDJPY data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - - let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "family", "generalize-0"]) - .current_dir(&cwd) - .output() - .expect("spawn runs family"); - assert!(fam.status.success(), "runs family exit: {:?}", fam.status); - let fam_out = String::from_utf8(fam.stdout).expect("utf-8"); - let members: Vec = fam_out + let id = family_id_of_kind(&cwd, "WalkForward"); + let (fam_out, fam_code) = run_code_in(&cwd, &["runs", "family", &id]); + assert_eq!(fam_code, Some(0), "runs family exit: {fam_out}"); + let reports: Vec = fam_out .lines() .filter(|l| l.starts_with('{')) .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) .collect(); - assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}"); + let wf = walkforward_aggregate_from_reports(&reports, &["fast.length", "slow.length"]); + assert_eq!(wf["windows"].as_u64(), Some(9), "window count: {wf}"); + // EXACT floats -- the byte-identity anchor a plateau-vs-argmax regression must preserve. + assert_eq!(wf["stitched_total_pips"].as_f64(), Some(-968.3776666637464), "stitched OOS pips: {wf}"); + let r = &wf["oos_r"]; + assert_eq!(r["n_trades"].as_u64(), Some(20667), "pooled OOS trade count: {wf}"); + // See the argmax anchor's doc comment: expectancy_r is a reconstructed + // trade-count-weighted mean (raw net_trade_rs is unrecoverable from + // JSON), so it is compared within a tight float-reconstruction tolerance + // rather than bit-for-bit. + let expectancy_r = r["expectancy_r"].as_f64().expect("expectancy_r is an f64"); + assert!( + (expectancy_r - 0.007635120926372627).abs() < 1e-12, + "pooled OOS expectancy R (within float-reconstruction tolerance): {wf}" + ); + let ps = wf["param_stability"].as_array().expect("param_stability is an array"); + assert_eq!(ps[0]["mean"].as_f64(), Some(4.777777777777778), "fast-MA refit mean: {wf}"); + assert_eq!(ps[1]["mean"].as_f64(), Some(16.444444444444443), "slow-MA refit mean: {wf}"); +} - assert_eq!( - members[0]["manifest"]["instrument"].as_str(), Some("GER40"), - "first member is GER40: {fam_out:?}" + + + + + + + + + + + +// Note (#319 Task 8, disposition finding): `generalize_without_explicit_ +// window_resolves_a_shared_window_and_completes` and `generalize_without_ +// explicit_window_resolves_the_intersection_of_all_symbols` retired rather +// than ported. Both pinned the retired `dispatch_generalize`'s OWN no-window +// fallback (`campaign_window_ms` + `intersect_shared_window`, both deleted +// Task 8 Step 2): resolving a shared campaign window automatically from the +// listed symbols' archive geometry when `--from`/`--to` are omitted. A +// hand-authored `CampaignDoc` has no such fallback — `data.windows` is a +// required field the schema always demands explicitly (confirmed: +// `validate_campaign` never treats a missing window as "resolve one"); there +// is no campaign-document capability this ports onto, so this is pure +// argv-translator machinery, not a campaign/engine property. The second +// test's own disposition note ("window-resolution halves retire; the +// cross-instrument grading property ports") already anticipated this split — +// the surviving "generalize grades N instruments over an explicit shared +// window" property stays fully covered by `generalize_grades_a_candidate_ +// across_two_instruments` and its siblings above. + + + + + + + +/// Property, ported onto a `[std::sweep, std::generalize]` campaign document +/// (#319 Task 8 — disposition finding): a successful campaign generalize run +/// persists its per-instrument runs as discoverable, but NOT as a merged +/// `CrossInstrument` family the way the retired sugar did. +/// `aura-campaign::exec`'s `Generalize` stage handling never calls +/// `append_family` (only its `Sweep`/`WalkForward` siblings do — grepped: +/// the crate's only two `append_family` call sites) — the grade lives solely +/// in the `campaign_runs.jsonl` record's `generalizations` field. What DOES +/// survive discoverably: exactly one `Sweep` family PER INSTRUMENT (each +/// cell's own candidate run, one member), and the generalize grade itself, +/// readable back via `aura campaign runs ` (or the always-on final +/// stdout line) carrying `n_instruments`. This pins the adapted-but-real +/// discoverability the campaign path actually provides. Gated on local +/// GER40/USDJPY data (the shared Sept-2024 window); skips cleanly when absent. +#[test] +fn generalize_persists_a_discoverable_cross_instrument_family() { + let (cwd, _g) = fresh_project(); + let bp_id = seed_blueprint(&cwd, "gen-persist-seed"); + let proc_id = register_process_doc(&cwd, "genpersist.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "genpersist", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, ); + write_doc(&cwd, "genpersist.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "genpersist.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { + eprintln!("skip: no local GER40/USDJPY data"); + return; + } + assert_eq!(code, Some(0), "exit: {out}"); + + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); assert_eq!( - members[0]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.01056371324510624), - "GER40's own expectancy_r, matching the exact-grade anchor: {fam_out:?}" + v["campaign_run"]["generalizations"][0]["generalization"]["n_instruments"].as_u64(), Some(2), + "the generalize grade is discoverable in the campaign-run record: {record_line}" ); - assert_eq!( - members[1]["manifest"]["instrument"].as_str(), Some("USDJPY"), - "second member is USDJPY: {fam_out:?}" + + let (fams_out, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); + let sweeps: Vec<&str> = fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).collect(); + assert_eq!(sweeps.len(), 2, "one durable per-instrument Sweep family: {fams_out:?}"); + assert!(sweeps.iter().all(|l| l.contains("\"members\":1")), "one member per instrument cell: {fams_out:?}"); +} + +/// Property (#210 T3, dissolution), ported onto a `[std::sweep, std:: +/// generalize]` campaign document (#319 Task 8 — same disposition finding as +/// the sibling above): the per-instrument grades are attributed to the RIGHT +/// instrument in the RIGHT order — not merely correct in count. Reads +/// `generalizations[0].generalization.per_instrument` (order-preserving) +/// directly off the campaign-run record rather than `runs family +/// generalize-0` (no such merged family exists on the campaign path). Asserts +/// GER40 first with the exact-grade anchor's GER40 `expectancy_r`, then +/// USDJPY with its own. Gated on the local GER40/USDJPY data. +#[test] +fn generalize_family_members_are_attributed_to_the_right_instrument() { + let (cwd, _g) = fresh_project(); + let bp_id = seed_blueprint(&cwd, "gen-attrib-seed"); + let proc_id = register_process_doc(&cwd, "genattrib.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "genattrib", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, ); + write_doc(&cwd, "genattrib.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "genattrib.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { + eprintln!("skip: no local GER40/USDJPY data"); + return; + } + assert_eq!(code, Some(0), "exit: {out}"); + + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let per = v["campaign_run"]["generalizations"][0]["generalization"]["per_instrument"] + .as_array() + .expect("per_instrument is an array"); + assert_eq!(per.len(), 2, "one entry per instrument: {record_line}"); + assert_eq!(per[0][0].as_str(), Some("GER40"), "first entry is GER40: {record_line}"); assert_eq!( - members[1]["metrics"]["r"]["expectancy_r"].as_f64(), Some(0.005795903617609842), - "USDJPY's own expectancy_r, matching the exact-grade anchor: {fam_out:?}" + per[0][1].as_f64(), Some(0.01056371324510624), + "GER40's own expectancy_r, matching the exact-grade anchor: {record_line}" + ); + assert_eq!(per[1][0].as_str(), Some("USDJPY"), "second entry is USDJPY: {record_line}"); + assert_eq!( + per[1][1].as_f64(), Some(0.005795903617609842), + "USDJPY's own expectancy_r, matching the exact-grade anchor: {record_line}" ); } -/// Property (#218 migration follow-up): `generalize`'s per-instrument members -/// route through the shared campaign path (`campaign_run.rs`'s -/// `run_blueprint_member` call, a code path distinct from sweep's in -/// `main.rs`), and that path ALSO stamps `manifest.project` with the running -/// project's provenance (C18/C16) for every persisted member — not just the -/// sweep chokepoint. Before the project-fixture migration this cli_run.rs -/// path only ever ran outside a project, so `project` was always `None` here. +/// Property (#218 migration follow-up), ported onto a `[std::sweep, std:: +/// generalize]` campaign document (#319 Task 8 — same disposition finding): +/// `generalize`'s per-instrument members route through the shared campaign +/// member runner, which stamps `manifest.project` with the running project's +/// provenance (C18/C16) for every persisted member. Reads the provenance off +/// the two per-instrument Sweep families (see the sibling tests' doc comments +/// for why no merged "generalize-0" family exists on the campaign path). /// Gated on the shared GER40/USDJPY Sept-2024 archive. #[test] fn generalize_family_members_stamp_project_provenance() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "gen-provenance-seed"); + let proc_id = register_process_doc(&cwd, "genprovenance.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "genprovenance", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = 1725148800000i64, + to = 1727740799999i64, + ); + write_doc(&cwd, "genprovenance.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "genprovenance.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40/USDJPY data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + assert_eq!(code, Some(0), "exit: {out}"); - let fam = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "family", "generalize-0"]) - .current_dir(&cwd) - .output() - .expect("spawn runs family"); - assert!(fam.status.success(), "runs family exit: {:?}", fam.status); - let fam_out = String::from_utf8(fam.stdout).expect("utf-8"); - let members: Vec = fam_out + let (fams_out, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); + let sweep_ids: Vec = fams_out .lines() - .filter(|l| l.starts_with('{')) - .map(|l| serde_json::from_str(l).expect("member line parses as JSON")) + .filter(|l| l.contains("\"kind\":\"Sweep\"")) + .map(|l| { + let v: serde_json::Value = serde_json::from_str(l).expect("family line parses"); + v["family_id"].as_str().expect("family_id").to_string() + }) .collect(); - assert_eq!(members.len(), 2, "one member per instrument: {fam_out:?}"); - for m in &members { - let project = &m["manifest"]["project"]; + assert_eq!(sweep_ids.len(), 2, "one per-instrument Sweep family: {fams_out:?}"); + for id in &sweep_ids { + let (fam_out, fam_code) = run_code_in(&cwd, &["runs", "family", id]); + assert_eq!(fam_code, Some(0), "runs family exit: {fam_out}"); + let member: serde_json::Value = serde_json::from_str(fam_out.lines().next().expect("one member line")) + .expect("member line parses as JSON"); + let project = &member["manifest"]["project"]; assert_eq!( project["namespace"].as_str(), Some("demo"), - "campaign-path member manifest carries the fixture project's namespace: {fam_out:?}" + "campaign-path member manifest carries the fixture project's namespace: {fam_out}" ); let sha = project["dylib_sha256"].as_str() - .unwrap_or_else(|| panic!("dylib_sha256 present: {fam_out:?}")); - assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {fam_out:?}"); + .unwrap_or_else(|| panic!("dylib_sha256 present: {fam_out}")); + assert_eq!(sha.len(), 64, "dylib_sha256 is a full sha256 hex digest: {fam_out}"); } } @@ -4555,81 +2826,23 @@ fn generalize_family_members_stamp_project_provenance() { /// Both bound knobs (fast/slow) must be re-opened by axes; the 2×2 grid yields 4 members. #[test] fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() { - let cwd = temp_cwd("blueprint-sweep"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "f", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep blueprint"); - assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr)); - let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); - let fo = String::from_utf8(fams.stdout).expect("utf-8"); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8 — there + // is no campaign-document equivalent of "sweep the embedded showcase + // stream with no instrument", invariant 7); both bound knobs (fast/slow) + // re-opened by axes, the 2x2 grid yields 4 members. + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "blueprint-sweep-seed"); + let proc_id = register_process_doc(&cwd, "f.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("f", "SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999), "2, 4", "8, 16"); + write_doc(&cwd, "f.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "f.campaign.json"]); + assert_eq!(code, Some(0), "exec exit: {out}"); + let (fo, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fo}"); assert!(fo.contains("\"kind\":\"Sweep\""), "families: {fo}"); assert!(fo.contains("\"members\":4"), "2×2 grid -> four members: {fo}"); - let _ = std::fs::remove_dir_all(&cwd); } -/// Property (#168, refuse-don't-lie): `aura sweep`/`aura walkforward` must REFUSE -/// `--trace` with a forward-pointer to the deferred per-member-trace feature (#224), -/// never silently accept it. The clap help ("also persists each member's taps") and -/// the chart `NotFound` hint ("run `aura sweep --trace` first") advertise -/// trace-recording, but no blueprint-mode arm persists per-member taps — -/// `run_blueprint_sweep` reads `let _ = persist;` and the dissolved `--real` sugar -/// sets `persist_taps: vec![]` — so a bare accept makes the surface lie. This mirrors -/// the existing `aura run` / `aura mc` `--trace` refusals (exit 2, refuse-don't-guess). -/// Driven over the SYNTHETIC path (both open knobs bound, no `--real`), so the test is -/// autonomous: no recorded archive, no project. Today `sweep --trace` exits 0 (silent -/// accept) and `walkforward --trace` exits 2 with a GENERIC usage string (no #224) — -/// both are the lying/pointer-less surface this pins. -#[test] -fn sweep_and_walkforward_refuse_trace_with_a_forward_pointer() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - for verb in ["sweep", "walkforward"] { - let cwd = temp_cwd(&format!("{verb}-trace-refusal")); - let out = Command::new(BIN) - .args([ - verb, - &fixture, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--trace", - "fam", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura --trace"); - assert_eq!( - out.status.code(), - Some(2), - "`aura {verb} --trace` must be refused (exit 2); got {:?}, stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr), - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("--trace"), - "`aura {verb}` refusal names the offending flag: {stderr}" - ); - assert!( - stderr.contains("#224"), - "`aura {verb}` refusal points forward to the deferred trace feature (#224): {stderr}" - ); - assert!( - out.stdout.is_empty(), - "`aura {verb} --trace` must emit no report on stdout: {}", - String::from_utf8_lossy(&out.stdout), - ); - let _ = std::fs::remove_dir_all(&cwd); - } -} /// Property (#158/#164, C18/C11/C12): a blueprint sweep content-addresses its topology /// — the canonical blueprint is persisted ONCE under `runs/blueprints/.json`, @@ -4642,19 +2855,16 @@ fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() { use aura_vocabulary::std_vocabulary; use sha2::{Digest, Sha256}; - let cwd = temp_cwd("blueprint-sweep-store"); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8, same + // rationale as the sibling above). + let (cwd, _g) = fresh_project_with_data(); let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "f", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep blueprint"); - assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr)); + let bp_id = seed_blueprint(&cwd, "blueprint-sweep-store-seed"); + let proc_id = register_process_doc(&cwd, "f.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("f", "SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999), "2, 4", "8, 16"); + write_doc(&cwd, "f.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "f.campaign.json"]); + assert_eq!(code, Some(0), "exec exit: {out}"); // exactly one blueprint stored for the whole family (one topology per family). let store = cwd.join("runs/blueprints"); @@ -4700,16 +2910,29 @@ fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() { /// re-open instead). #[test] fn aura_sweep_rejects_an_unknown_axis() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["sweep", &fixture, "--axis", "nope=1,2", "--name", "f"]) - .output() - .expect("spawn aura sweep unknown-axis"); - assert_ne!(out.status.code(), Some(0), "an unknown axis must fail the sweep"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "unknown-axis-seed"); + let proc_id = register_process_doc(&cwd, "unknownaxis.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "f", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "nope": {{ "kind": "I64", "values": [1, 2] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "unknownaxis.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "unknownaxis.campaign.json"]); + assert_ne!(code, Some(0), "an unknown axis must fail the campaign run: {out}"); assert!( - stderr.contains("Knob") || stderr.to_lowercase().contains("nope"), - "names the unresolved axis: {stderr}" + out.to_lowercase().contains("nope"), + "names the unresolved axis: {out}" ); } @@ -4720,39 +2943,30 @@ fn aura_sweep_rejects_an_unknown_axis() { /// a `reproduced N/N` summary) that the `reproduce_family_in` unit seam bypasses. #[test] fn aura_reproduce_re_derives_a_persisted_sweep_family() { - let cwd = temp_cwd("blueprint-reproduce"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let sweep = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "f", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep blueprint"); - assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr)); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8, same + // rationale as the sweep-family siblings above); the family_id is read + // back from `runs families`, not assumed to be the literal "f-0". + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "blueprint-reproduce-seed"); + let proc_id = register_process_doc(&cwd, "f.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("f", "SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999), "2, 4", "8, 16"); + write_doc(&cwd, "f.campaign.json", &doc); + let (sweep_out, sweep_code) = run_code_in(&cwd, &["exec", "f.campaign.json"]); + assert_eq!(sweep_code, Some(0), "sweep exit: {sweep_out}"); - // `--trace f` names the family `f-0`; reproduce it from disk. - let out = Command::new(BIN) - .args(["reproduce", "f-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); - assert!(out.status.success(), "reproduce exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let id = family_id_of_kind(&cwd, "Sweep"); + let (out, code) = run_code_in(&cwd, &["reproduce", &id]); + assert_eq!(code, Some(0), "reproduce exit: {out}"); // one verdict line per member (all bit-identical) + the summary; the label renders // param values portably (`length=2`, not the `I64(2)` Debug form). assert_eq!( - stdout.lines().filter(|l| l.contains("reproduced: bit-identical")).count(), + out.lines().filter(|l| l.contains("reproduced: bit-identical")).count(), 4, - "every member re-derives bit-identically: {stdout}" + "every member re-derives bit-identically: {out}" ); - assert!(stdout.contains("fast.length=2"), "label renders values portably: {stdout}"); - assert!(!stdout.contains("I64("), "label must not leak the Scalar Debug form: {stdout}"); - assert!(stdout.contains("reproduced 4/4 members bit-identically"), "summary line: {stdout}"); - let _ = std::fs::remove_dir_all(&cwd); + assert!(out.contains("fast.length=2"), "label renders values portably: {out}"); + assert!(!out.contains("I64("), "label must not leak the Scalar Debug form: {out}"); + assert!(out.contains("reproduced 4/4 members bit-identically"), "summary line: {out}"); } /// Property (C1/C18 integrity net): when a persisted member's stored metrics no longer @@ -4761,19 +2975,14 @@ fn aura_reproduce_re_derives_a_persisted_sweep_family() { /// `total_pips` after the sweep (the store's bit-identical compare IS the integrity check). #[test] fn aura_reproduce_reports_a_diverged_member_and_exits_one() { - let cwd = temp_cwd("blueprint-reproduce-diverge"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let sweep = Command::new(BIN) - .args([ - "sweep", &fixture, - "--axis", "fast.length=2,4", - "--axis", "slow.length=8,16", - "--name", "f", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep blueprint"); - assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr)); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "blueprint-reproduce-diverge-seed"); + let proc_id = register_process_doc(&cwd, "f.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("f", "SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999), "2, 4", "8, 16"); + write_doc(&cwd, "f.campaign.json", &doc); + let (sweep_out, sweep_code) = run_code_in(&cwd, &["exec", "f.campaign.json"]); + assert_eq!(sweep_code, Some(0), "sweep exit: {sweep_out}"); // corrupt the first member's stored `total_pips` so its re-run metrics differ. let store = cwd.join("runs/families.jsonl"); @@ -4784,100 +2993,60 @@ fn aura_reproduce_reports_a_diverged_member_and_exits_one() { let corrupted = format!("{}999.0{}", &s[..at], &s[end..]); std::fs::write(&store, corrupted).expect("write corrupted store"); - let out = Command::new(BIN) - .args(["reproduce", "f-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); - assert_eq!(out.status.code(), Some(1), "a diverged member exits 1: stderr={}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("reproduced: DIVERGED"), "the tampered member is reported DIVERGED: {stdout}"); - assert!(stdout.contains("reproduced 3/4 members bit-identically"), "summary counts the diverged member: {stdout}"); - let _ = std::fs::remove_dir_all(&cwd); + let id = family_id_of_kind(&cwd, "Sweep"); + let (out, code) = run_code_in(&cwd, &["reproduce", &id]); + assert_eq!(code, Some(1), "a diverged member exits 1: {out}"); + assert!(out.contains("reproduced: DIVERGED"), "the tampered member is reported DIVERGED: {out}"); + assert!(out.contains("reproduced 3/4 members bit-identically"), "summary counts the diverged member: {out}"); } /// E2E (acc 2): persist a blueprint sweep, then `aura reproduce ` re-derives every /// member bit-identically from the content-addressed store and exits 0. #[test] fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() { - let cwd = temp_cwd("reproduce_roundtrip"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - let sweep = std::process::Command::new(BIN) - .args([ - "sweep", - &fixture, - "--axis", - "fast.length=2", - "--axis", - "slow.length=4,6", - "--name", - "smacross", - ]) - .current_dir(&cwd) - .output() - .expect("run aura sweep"); - assert!(sweep.status.success(), "sweep ok: {}", String::from_utf8_lossy(&sweep.stderr)); - - let repro = std::process::Command::new(BIN) - .args(["reproduce", "smacross-0"]) - .current_dir(&cwd) - .output() - .expect("run aura reproduce"); - let stdout = String::from_utf8_lossy(&repro.stdout); - assert!(repro.status.success(), "reproduce exits 0: {}", String::from_utf8_lossy(&repro.stderr)); - assert!( - stdout.contains("reproduced 2/2 members bit-identically"), - "every member re-derives: {stdout}" + // Ported onto a one-cell campaign document over SYMA (#319 Task 8). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "reproduce-roundtrip-seed"); + let proc_id = register_process_doc(&cwd, "smacross.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc( + "smacross", "SYMA", &bp_id, &proc_id, + (1709251200000, 1719791999999), "2", "4, 6", ); + write_doc(&cwd, "smacross.campaign.json", &doc); + let (sweep_out, sweep_code) = run_code_in(&cwd, &["exec", "smacross.campaign.json"]); + assert_eq!(sweep_code, Some(0), "sweep exit: {sweep_out}"); - let _ = std::fs::remove_dir_all(&cwd); + let id = family_id_of_kind(&cwd, "Sweep"); + let (out, code) = run_code_in(&cwd, &["reproduce", &id]); + assert_eq!(code, Some(0), "reproduce exits 0: {out}"); + assert!( + out.contains("reproduced 2/2 members bit-identically"), + "every member re-derives: {out}" + ); } -/// E2E (acc 1+3+4): `aura mc --seeds N` builds a MonteCarlo -/// family (one member per seed), writes exactly one content-addressed blueprint, and -/// `aura reproduce ` re-derives every member bit-identically (exit 0). -#[test] -fn aura_mc_over_a_blueprint_reproduces_bit_identically() { - let cwd = temp_cwd("mc-blueprint-reproduce"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); +// Note (#319 Task 8, disposition finding): `aura_mc_over_a_blueprint_ +// reproduces_bit_identically` retired rather than ported. `aura mc --seeds N` drove `aura-runner::family::blueprint_mc_ +// family` — a per-RANDOM-SEED synthetic family (each member draws a distinct +// synthetic price walk from the SAME fixed blueprint), a wholly different +// axis of variation than anything a `CampaignDoc` models (strategy params / +// instrument / window — never "vary only the seed, same everything else"). +// There is no document field to bind a seed axis onto, and the plan's own +// Task 9 ("aura-runner family-builder retirement") schedules `blueprint_mc_ +// family` for deletion as a builder with no surviving CLI entry point — this +// property is not "translated", it is retired along with the builder. +// Reported to the orchestrator rather than fabricated onto an unrelated +// substitute. - let mc = Command::new(BIN) - .args(["mc", &fixture, "--seeds", "4", "--name", "mcx"]) - .current_dir(&cwd) - .output() - .expect("spawn aura mc blueprint"); - assert!(mc.status.success(), "mc exit: {:?} stderr={}", mc.status, String::from_utf8_lossy(&mc.stderr)); - let mc_out = String::from_utf8(mc.stdout).expect("utf-8 stdout"); - // one member line per seed (each carries "seed":n) + one aggregate line. - assert_eq!( - mc_out.lines().filter(|l| l.contains("\"seed\":")).count(), - 4, - "one member line per seed: {mc_out}" - ); - assert!(mc_out.contains("mc_aggregate"), "prints the aggregate line: {mc_out}"); - - // exactly one blueprint stored for the whole family (one topology per family). - let store = cwd.join("runs/blueprints"); - let entries: Vec<_> = std::fs::read_dir(&store) - .unwrap_or_else(|e| panic!("mc must create the blueprint store {store:?}: {e}")) - .map(|e| e.expect("dir entry").path()) - .collect(); - assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}"); - - let repro = Command::new(BIN) - .args(["reproduce", "mcx-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); - assert!(repro.status.success(), "reproduce exit: {:?} stderr={}", repro.status, String::from_utf8_lossy(&repro.stderr)); - let repro_out = String::from_utf8(repro.stdout).expect("utf-8 stdout"); - assert!( - repro_out.contains("reproduced 4/4 members bit-identically"), - "every MC member re-derives: {repro_out}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} +// Note (#319 Task 8, disposition finding): `aura_mc_synthetic_member_panic_ +// is_contained` retired for the same reason (the `mc --seeds` poison-member +// containment test rides the same retiring `blueprint_mc_family` seam); the +// general "member-compile faults are contained identically across every +// verb/path" property stays fully covered by its ported +// `aura_walkforward_synthetic_member_panic_is_contained` / +// `aura_sweep_synthetic_member_panic_is_contained` siblings below plus the +// real-data #272 containment tests in `research_docs.rs`. /// E2E (#173): an IS-refit walk-forward over a loaded blueprint persists a /// content-addressed WalkForward family that `aura reproduce`s bit-identically — @@ -4886,109 +3055,85 @@ fn aura_mc_over_a_blueprint_reproduces_bit_identically() { /// cycle's tidy fix, #328 batch 2). #[test] fn aura_walkforward_over_a_blueprint_reproduces_bit_identically() { - let cwd = temp_cwd("blueprint-walkforward-reproduce"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=2,3", - "--axis", "slow.length=4,6", "--name", "wfr"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward"); - assert_eq!(run.status.code(), Some(0), "stderr: {}", String::from_utf8_lossy(&run.stderr)); - let repro = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["reproduce", "wfr-0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura reproduce"); - assert_eq!(repro.status.code(), Some(0)); - let out = String::from_utf8(repro.stdout).expect("utf-8"); - assert!(out.contains("reproduced 3/3 members bit-identically"), "stdout: {out}"); - let _ = std::fs::remove_dir_all(&cwd); + // Ported onto a `[std::grid, std::walk_forward]` campaign document over + // SYMA (#319 Task 8, same substitution rationale as + // `reproduce_family_with_overridden_bound_param_re_derives`): the OOS + // member count is roller/data-derived, so it is read back rather than + // pinned to the translator-era literal (3). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "wfr-seed"); + let proc_id = register_process_doc(&cwd, "wfr.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfr", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }}, + "slow.length": {{ "kind": "I64", "values": [4, 6] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "wfr.campaign.json", &doc); + let (run_out, run_code) = run_code_in(&cwd, &["exec", "wfr.campaign.json"]); + assert_eq!(run_code, Some(0), "stderr: {run_out}"); + let member_count = run_out.lines().filter(|l| l.starts_with("{\"family_id\":")).count(); + assert!(member_count > 0, "at least one OOS member: {run_out}"); + + let id = family_id_of_kind(&cwd, "WalkForward"); + let (out, code) = run_code_in(&cwd, &["reproduce", &id]); + assert_eq!(code, Some(0), "out: {out}"); + assert!( + out.contains(&format!("reproduced {member_count}/{member_count} members bit-identically")), + "out: {out}" + ); } -/// E2E (#173): the loaded-blueprint walk-forward runs + persists a WalkForward -/// family (3 OOS members) with a {"walkforward":…} summary carrying oos_r, and -/// stores exactly one content-addressed blueprint. +/// E2E (#173), ported onto a `[std::grid, std::walk_forward]` campaign +/// document over SYMA (#319 Task 8): the loaded-blueprint walk-forward runs + +/// persists a WalkForward family and stores exactly one content-addressed +/// blueprint. The `{"walkforward":…}` aggregate-JSON summary carrying +/// `oos_r`/`stitched_total_pips`/`param_stability` was the retired CLI +/// verb's OWN post-processing (`walkforward_summary_json_from_reports`, +/// deleted Task 8 Step 2) — no surviving command reconstructs that exact +/// envelope, so it is not re-pinned here; the family-persistence property +/// (member count, `FamilyKind::WalkForward`, one stored topology) survives in +/// full. #[test] fn aura_walkforward_over_a_blueprint_persists_a_family() { - let cwd = temp_cwd("blueprint-walkforward-persist"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=2,3", - "--axis", "slow.length=4,6", "--name", "wfx"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward"); - assert_eq!(out.status.code(), Some(0), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert_eq!(stdout.matches("wfx-").count(), 3, "3 OOS member lines: {stdout}"); - assert!(stdout.contains("\"walkforward\""), "summary line: {stdout}"); - assert!(stdout.contains("\"oos_r\""), "R-measured summary: {stdout}"); - let families = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); - let fo = String::from_utf8(families.stdout).expect("utf-8"); + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "wfx-seed"); + let proc_id = register_process_doc(&cwd, "wfx.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfx", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }}, + "slow.length": {{ "kind": "I64", "values": [4, 6] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "wfx.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "wfx.campaign.json"]); + assert_eq!(code, Some(0), "stderr: {out}"); + let member_count = out.lines().filter(|l| l.starts_with("{\"family_id\":")).count(); + assert!(member_count > 0, "at least one OOS member line: {out}"); + let (fo, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fo}"); assert!(fo.contains("\"kind\":\"WalkForward\""), "families: {fo}"); let n = std::fs::read_dir(cwd.join("runs").join("blueprints")).map(|d| d.count()).unwrap_or(0); assert_eq!(n, 1, "exactly one content-addressed blueprint stored"); - let _ = std::fs::remove_dir_all(&cwd); } -/// E2E (#313, Path 2 — synthetic): a walk-forward whose every window records -/// zero trades emits exactly one `aura: note: all N walk-forward windows -/// recorded zero trades` on stderr; exit stays 0 and the summary JSON is -/// unchanged (a null result is a valid research result, #198). Equal -/// fast/slow lengths make the SMA difference constantly zero, so the bias -/// never leaves 0 and no window trades. -#[test] -fn aura_walkforward_all_zero_trade_windows_emit_one_note() { - let cwd = temp_cwd("blueprint-walkforward-zero-trade-note"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=8", - "--axis", "slow.length=8", "--name", "wfzero"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward (degenerate)"); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - assert_eq!(out.status.code(), Some(0), "exit stays 0: {stderr}"); - assert!(stdout.contains("\"walkforward\""), "summary line unchanged: {stdout}"); - assert_eq!( - stderr.matches("walk-forward windows recorded zero trades").count(), - 1, - "exactly one zero-trade note: {stderr}" - ); - assert!( - stderr.contains("aura: note: all "), - "the note carries the class marker: {stderr}" - ); - assert!( - !stderr.contains("aura: warning: "), - "a benign degenerate run emits no warning lines: {stderr}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} -/// E2E (#313 negative twin): a walk-forward with at least one traded window -/// emits no zero-trade note. -#[test] -fn aura_walkforward_with_trades_emits_no_zero_trade_note() { - let cwd = temp_cwd("blueprint-walkforward-traded-no-note"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=2,3", - "--axis", "slow.length=4,6", "--name", "wftrades"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward (trading)"); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - assert_eq!(out.status.code(), Some(0), "exit 0: {stderr}"); - assert!( - !stderr.contains("recorded zero trades"), - "a traded run emits no zero-trade note: {stderr}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} /// E2E (#278 decision: same fault class, same verb, same surface => same /// treatment): a member fault on the SYNTHETIC walk-forward path is CONTAINED @@ -5000,367 +3145,86 @@ fn aura_walkforward_with_trades_emits_no_zero_trade_note() { /// the consumer's stderr. #[test] fn aura_walkforward_synthetic_member_panic_is_contained() { - let cwd = temp_cwd("blueprint-walkforward-member-panic-contained"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + // Ported onto a `[std::grid, std::walk_forward]` campaign document over + // SYMA (#319 Task 8). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "wfpanic-seed"); + let proc_id = register_process_doc(&cwd, "wfpanic.process.json", GRID_THEN_WF_PROCESS_DOC); // length=0 poisons the member: `Sma::new` asserts "SMA length must be >= 1" // during member compile — the same fault class the real path records as // "a member panicked: …" and survives. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=0", "--name", "wfpanic"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward (poison member)"); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - assert!( - stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")), - "the member fault surfaces as one class-marked warning naming the fault: {stderr}" + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "wfpanic", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [0] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, ); + write_doc(&cwd, "wfpanic.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "wfpanic.campaign.json"]); assert!( - !stderr.contains("panicked at"), - "no raw panic report reaches the consumer: {stderr}" - ); - assert!( - !stderr.contains("crates/"), - "no internal source path leaks to the consumer: {stderr}" + out.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")), + "the member fault surfaces as one class-marked warning naming the fault: {out}" ); + assert!(!out.contains("panicked at"), "no raw panic report reaches the consumer: {out}"); + assert!(!out.contains("crates/"), "no internal source path leaks to the consumer: {out}"); assert_eq!( - out.status.code(), - Some(3), - "a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}" + code, Some(3), + "a contained member fault is the deliberate failed-cells exit 3, never 101: {out}" ); - let _ = std::fs::remove_dir_all(&cwd); } -/// E2E (#278 decision, sweep twin): the synthetic `aura sweep` shares the -/// same bare-member seam (`run_blueprint_member` inside the family builders' -/// sweep closures, no #272 fault boundary), so the same poison must be -/// contained the same way — warning + exit 3, no panic escape. +/// E2E (#278 decision, sweep twin): the campaign path's sweep stage shares the +/// same bare-member seam as its walk-forward sibling, so the same poison must +/// be contained the same way — warning + exit 3, no panic escape. Ported onto +/// a one-cell campaign document over SYMA (#319 Task 8). #[test] fn aura_sweep_synthetic_member_panic_is_contained() { - let cwd = temp_cwd("blueprint-sweep-member-panic-contained"); - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--axis", "fast.length=0", "--name", "swpanic"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep (poison member)"); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "swpanic-seed"); + let proc_id = register_process_doc(&cwd, "swpanic.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "swpanic", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [0] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "swpanic.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "swpanic.campaign.json"]); assert!( - stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")), - "the member fault surfaces as one class-marked warning naming the fault: {stderr}" + out.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")), + "the member fault surfaces as one class-marked warning naming the fault: {out}" ); assert!( - !stderr.contains("panicked at") && !stderr.contains("crates/"), - "no raw panic report or internal source path leaks: {stderr}" + !out.contains("panicked at") && !out.contains("crates/"), + "no raw panic report or internal source path leaks: {out}" ); assert_eq!( - out.status.code(), - Some(3), - "a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} - -/// E2E (#278 decision, mc twin): the synthetic `aura mc` shares the same -/// bare-member seam as its sweep/walkforward siblings (`run_blueprint_member` -/// inside `blueprint_mc_family`'s per-seed draw closure, no #272 fault -/// boundary), so the same member-compile fault class must be contained the -/// same way — one `aura: warning: ` line naming the fault, no raw panic text -/// or internal `crates/` path on stderr, deliberate exit 3 (C14), never 101. -/// mc takes only CLOSED blueprints, so the poison is BOUND (`length=0` on the -/// SMA add-op) rather than a poison axis: the test builds its minimal 4-op -/// poison blueprint inline via `aura graph build`. Two seeds so the parallel -/// draws join before the lowest-seed fault resolves (the shipped sweep -/// contract's thread-order-independent shape, C1). -#[test] -fn aura_mc_synthetic_member_panic_is_contained() { - let cwd = temp_cwd("blueprint-mc-member-panic-contained"); - // Minimal poison: each op is load-bearing (source: the close role; add+bind: - // the length=0 that trips `Sma::new`'s "SMA length must be >= 1" assert at - // member compile; feed: wiring so build validation passes; expose: the bias - // contract). Bound length keeps the blueprint CLOSED, as `aura mc` requires. - let ops = r#"[ - {"op":"source","role":"close","kind":"F64"}, - {"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":0}}}, - {"op":"feed","role":"close","into":["sma.series"]}, - {"op":"expose","from":"sma.value","as":"bias"} - ]"#; - let ops_path = cwd.join("poison.ops.json"); - std::fs::write(&ops_path, ops).expect("write poison op-list"); - let bp_path = cwd.join("poison.bp.json"); - let build = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["graph", "build"]) - .stdin(std::fs::File::open(&ops_path).expect("open poison op-list")) - .current_dir(&cwd) - .output() - .expect("spawn aura graph build (poison blueprint)"); - assert_eq!( - build.status.code(), - Some(0), - "poison blueprint builds (the fault is a member-COMPILE fault, not a build refusal): {}", - String::from_utf8_lossy(&build.stderr) - ); - std::fs::write(&bp_path, &build.stdout).expect("write poison blueprint"); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["mc", bp_path.to_str().expect("utf-8 path"), "--seeds", "2"]) - .current_dir(&cwd) - .output() - .expect("spawn aura mc (poison member)"); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - assert!( - stderr.lines().any(|l| l.contains("aura: warning: ") && l.contains("SMA length must be >= 1")), - "the member fault surfaces as one class-marked warning naming the fault: {stderr}" - ); - assert!( - !stderr.contains("panicked at") && !stderr.contains("crates/"), - "no raw panic report or internal source path leaks: {stderr}" - ); - assert_eq!( - out.status.code(), - Some(3), - "a contained member fault is the deliberate failed-cells exit 3, never 101: {stderr}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} - -/// E2E (#313, Path 1 — the real/sugar walk-forward): the degenerate -/// equal-length config over the shared GER40 archive emits the same -/// zero-trade note. Gated on the archive; skips cleanly on a data refusal. -#[test] -fn walkforward_real_all_zero_trade_windows_emit_the_note() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; - let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=5", "--axis", "slow.length=5", - "--stop-length", "14", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward (real degenerate)"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - return; - } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - assert_eq!( - stderr.matches("walk-forward windows recorded zero trades").count(), - 1, - "exactly one zero-trade note on the real path: {stderr}" - ); - assert!(stderr.contains("aura: note: all "), "class marker: {stderr}"); -} - -/// E2E (#173): a CLOSED blueprint with no --axis (nothing to re-fit) is a usage -/// error (exit 2), naming that >= 1 --axis is required. -#[test] -fn aura_walkforward_over_a_blueprint_rejects_no_axis() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp]) - .output() - .expect("spawn aura walkforward (no axis)"); - assert_eq!(out.status.code(), Some(2)); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains("requires >= 1 --axis"), "stderr: {stderr}"); -} - -/// E2E (#173/#184, safety): a malformed blueprint is rejected at the dispatch -/// boundary (exit 2) rather than panicking, phrased house-style (#184's -/// `blueprint_load_prose` convention) — never the raw `UnknownNodeType` Debug -/// leak (c0110 fieldtest finding, mirroring `aura_run_rejects_an_unknown_node_blueprint`). -#[test] -fn aura_walkforward_over_a_blueprint_rejects_malformed() { - let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "fast.length=2,3"]) - .output() - .expect("spawn aura walkforward (malformed)"); - assert_eq!(out.status.code(), Some(2), "malformed must be rejected, not panic"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}"); - assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}"); - assert!(!stderr.contains("panicked"), "must reject cleanly: {stderr}"); -} - -/// E2E (#173, panic-safety): a `--axis` naming a knob that is not in the loaded -/// blueprint's param_space fails clean (named BindError, exit 2, no panic). This -/// pins the in-closure fallible-bind path — `blueprint_sweep_over` returns a -/// `BindError` from inside the `walk_forward` window closure — which is a DISTINCT -/// branch from the dispatch-boundary blueprint-parse rejection (the malformed test): -/// the blueprint here is well-formed and passes dispatch, then the per-window -/// re-fit's bind fails. A regression to `.expect()` on that bind would panic here -/// while leaving the malformed test green. -#[test] -fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "nope=1,2"]) - .output() - .expect("spawn aura walkforward (unknown axis)"); - assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!( - stderr.contains("names no param") && stderr.contains("nope"), - "names the unresolved axis via the prose payload (#269): {stderr}" - ); - assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}"); -} - -/// E2E (#177): a rejected `--axis` on a loaded walk-forward blueprint is reported -/// EXACTLY ONCE, however many IS/OOS windows the roll would have spanned — mirroring -/// the sibling `aura sweep` / `aura mc` paths, which validate the axis ONCE up front -/// and emit their rejection a single time. The property this protects: axis -/// resolution is a single pre-flight, not a per-window re-raise. -/// -/// The CLOSED fixture has an empty param_space, so ANY `--axis` name is an -/// `UnknownKnob`. The pre-fix code validates the axis INSIDE the per-window closure -/// that `walk_forward` fans out in parallel across the windows, so more than one -/// window `eprintln!`s the same rejection before the racing `exit(2)` tears the -/// process down — a race whose emit count is 1 or 2 per run. A single invocation -/// would therefore be a flaky assertion (it emits once ~1/3 of the time by luck), so -/// this drives the verb repeatedly: observing the double-emit at least once is then -/// near-certain, and the assertion is that EVERY run emits the rejection exactly -/// once. The fix (hoist axis resolution to one pre-flight before the windowed -/// fan-out) makes every run deterministically single. -#[test] -fn aura_walkforward_emits_an_unknown_axis_rejection_once() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - for attempt in 1..=20 { - // "graph.nope.length" is dotted (multi-segment, like a real axis path) - // but matches neither the raw NOR the wrapped namespace (#328's own - // `refuse_wrapped_synthetic_axes` preflight, now ahead of this route - // too, would otherwise intercept a dotted name that happens to - // wrap-strip onto a real bound param, e.g. the retired - // `graph.fast.length` literal this test used to carry) — so it still - // reaches the genuine "names no param" rejection this test pins. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "graph.nope.length=2,3"]) - .output() - .expect("spawn aura walkforward (unknown axis)"); - assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}"); - let emits = stderr.matches("names no param").count(); - assert_eq!( - emits, 1, - "attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}", - ); - } -} - -/// Property (#269): the walkforward verb's unknown-axis rejection reaches a -/// user's stderr as prose — the same register `aura sweep`'s boundary errors -/// adopted in #247 — never wrapped in the `UnknownKnob("…")` Rust `Debug` -/// frame. The walkforward path already carries a fully-prosed payload INSIDE -/// that wrapper (`axis : names no param of this blueprint … — see … -/// --list-axes`), so the fix sheds the frame rather than authoring new prose: -/// the message that reaches stderr is exactly that payload, the axis named as -/// typed, exit still 2. Data-free — the axis is rejected at the resolution -/// pre-flight before any window runs, so no archive and no project are needed. -/// -/// NB: this is the post-#269 contract. The two sibling tests above -/// (`aura_walkforward_over_a_blueprint_rejects_an_unknown_axis`, -/// `aura_walkforward_emits_an_unknown_axis_rejection_once`) still pin the -/// `UnknownKnob` substring — stale pins of the defective frame that the GREEN -/// slice repoints onto the prose fragment. -#[test] -fn walkforward_unknown_axis_rejection_renders_as_prose_not_a_debug_frame() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &bp, "--axis", "nope=1,2"]) - .output() - .expect("spawn aura walkforward (unknown axis)"); - assert_eq!( - out.status.code(), - Some(2), - "an unknown axis stays a usage refusal (exit 2, not a panic): status={:?}", - out.status - ); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - // The headline — the whole defect: no Rust `Debug` frame reaches the user. - assert!( - !stderr.contains("UnknownKnob"), - "must not leak the `UnknownKnob(…)` Debug frame around the prose: {stderr}" - ); - // The prose payload survives intact — the axis named as typed, the - // names-no-param wording, and the `--list-axes` pointer (the sweep - // sibling's register). - assert!( - stderr.contains("nope"), - "the prose names the offending axis exactly as typed: {stderr}" - ); - assert!( - stderr.contains("names no param"), - "the prose carries the boundary payload wording: {stderr}" - ); - assert!( - stderr.contains("--list-axes"), - "the prose points at the discovery surface, like the sweep sibling: {stderr}" - ); - assert!( - !stderr.contains("panicked"), - "must reject cleanly, never panic: {stderr}" + code, Some(3), + "a contained member fault is the deliberate failed-cells exit 3, never 101: {out}" ); } -/// E2E (acc 2): `aura mc ` is rejected with a named error + exit 2 -/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder. -#[test] -fn aura_mc_rejects_an_open_blueprint() { - let cwd = temp_cwd("mc-blueprint-open-reject"); - let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["mc", &fixture, "--seeds", "4"]) - .current_dir(&cwd) - .output() - .expect("spawn aura mc open-blueprint"); - assert_eq!(out.status.code(), Some(2), "an open blueprint fails clean (exit 2, not a panic): stderr={}", String::from_utf8_lossy(&out.stderr)); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains("closed blueprint"), "names the closed-blueprint requirement: {stderr}"); - let _ = std::fs::remove_dir_all(&cwd); -} -/// E2E (#176): `aura run ` refuses an open blueprint the same way the -/// sibling `aura mc` does — a clean named error + exit 2, NEVER a `compile_with_params` -/// arity panic (exit 101). The `run` dispatch bootstraps over the EMPTY point (a -/// closed-blueprint assumption), so a free knob must be rejected at the dispatch boundary -/// before that bootstrap, mirroring `blueprint_mc_family`'s closed-guard. -#[test] -fn aura_run_rejects_an_open_blueprint_without_panicking() { - let cwd = temp_cwd("run-blueprint-open-reject"); - let fixture = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let out = Command::new(BIN) - .args(["run", &fixture]) - .current_dir(&cwd) - .output() - .expect("spawn aura run open-blueprint"); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert_eq!( - out.status.code(), - Some(2), - "an open blueprint fails clean (exit 2, not a panic/exit 101): stderr={stderr}" - ); - assert!( - stderr.contains("closed blueprint"), - "names the closed-blueprint requirement (mirroring `aura mc`): {stderr}" - ); - assert!( - !stderr.contains("panicked"), - "must refuse at the dispatch boundary, never panic: {stderr}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} + + + + + + /// E2E (negative): `aura reproduce ` is a hard error (exit non-zero + named /// cause on stderr), distinct from `runs family `'s treat-as-empty exit 0. @@ -5395,290 +3259,28 @@ fn aura_reproduce_rejects_an_unknown_family() { /// against a guessed topology (C10/C18). #[test] fn aura_reproduce_rejects_a_missing_stored_blueprint() { - let cwd = temp_cwd("reproduce_missing_store"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let sweep = std::process::Command::new(BIN) - .args([ - "sweep", - &fixture, - "--axis", - "fast.length=2", - "--axis", - "slow.length=4", - "--name", - "bp", - ]) - .current_dir(&cwd) - .output() - .expect("run aura sweep"); - assert!(sweep.status.success(), "blueprint sweep ok: {}", String::from_utf8_lossy(&sweep.stderr)); + // Ported onto a one-cell campaign document over SYMA (#319 Task 8). + let (cwd, _g) = fresh_project_with_data(); + let bp_id = seed_blueprint(&cwd, "reproduce-missing-store-seed"); + let proc_id = register_process_doc(&cwd, "bp.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = sweep_campaign_doc("bp", "SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999), "2", "4"); + write_doc(&cwd, "bp.campaign.json", &doc); + let (sweep_out, sweep_code) = run_code_in(&cwd, &["exec", "bp.campaign.json"]); + assert_eq!(sweep_code, Some(0), "blueprint sweep ok: {sweep_out}"); std::fs::remove_dir_all(cwd.join("runs").join("blueprints")).expect("remove the blueprint store"); - let out = std::process::Command::new(BIN) - .args(["reproduce", "bp-0"]) - .current_dir(&cwd) - .output() - .expect("run aura reproduce"); - assert!(!out.status.success(), "a missing stored blueprint exits non-zero"); - assert!( - String::from_utf8_lossy(&out.stderr).contains("missing from store"), - "names the cause: {}", - String::from_utf8_lossy(&out.stderr) - ); - let _ = std::fs::remove_dir_all(&cwd); + + let id = family_id_of_kind(&cwd, "Sweep"); + let (out, code) = run_code_in(&cwd, &["reproduce", &id]); + assert_ne!(code, Some(0), "a missing stored blueprint exits non-zero"); + assert!(out.contains("missing from store"), "names the cause: {out}"); } -/// E2E (#169/#246/#328): `aura sweep --list-axes` prints one -/// `:` line per open sweepable knob, in `param_space()` order, -/// followed by one `: default=` line per BOUND param (the -/// fixture's `bias.scale` stays bound even though the two SMA lengths are -/// open — #246's discovery surface lists bound params on EVERY blueprint -/// shape, not only a fully-closed one), and exits 0. The names are RAW -/// `.` paths (#328: the wrapped `..` form -/// is retired from the surface) — exactly the strings `--axis` binds — so a -/// user can discover then sweep or re-open without guessing. -#[test] -fn aura_sweep_list_axes_prints_raw_names() { - let bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--list-axes"]) - .output() - .expect("spawn aura sweep --list-axes"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert_eq!( - stdout, - "fast.length:I64\n\ - slow.length:I64\n\ - bias.scale:F64 default=0.5\n" - ); -} -/// E2E (#246/#328): `--list-axes` on a CLOSED blueprint (all knobs bound) prints one -/// `.: default=` line per bound param, RAW (#328) — -/// the bound value IS the default, overridable by naming the same path as an -/// `--axis` (#246's re-open contract) — and exits 0. The sibling pin above -/// (`aura_sweep_list_axes_prints_raw_names`) proves the same bound-param -/// lines also appear on a PARTIALLY open blueprint, after its open lines. -#[test] -fn aura_sweep_list_axes_closed_prints_bound_defaults() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--list-axes"]) - .output() - .expect("spawn aura sweep --list-axes (closed)"); - assert_eq!(out.status.code(), Some(0)); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert_eq!( - stdout, - "fast.length:I64 default=2\n\ - slow.length:I64 default=4\n\ - bias.scale:F64 default=0.5\n" - ); -} -/// E2E (#169): `--list-axes` is a standalone query — combining it with a real -/// sweep flag (`--axis`) is a usage error (exit 2), naming that it lists axes -/// and takes no other flags. A query cannot also seed a sweep. -#[test] -fn aura_sweep_list_axes_rejects_other_flags() { - let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--list-axes", "--axis", "fast.length=2,3"]) - .output() - .expect("spawn aura sweep --list-axes + --axis"); - assert_eq!(out.status.code(), Some(2)); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains("--list-axes lists axes"), "stderr was: {stderr}"); -} -/// E2E (#169/#184, safety): `--list-axes` over a malformed blueprint rejects -/// cleanly (exit 2), phrased house-style — never the raw `UnknownNodeType` -/// Debug leak (c0110 fieldtest finding) — rather than panicking; the axis -/// probe reloads the doc, so the dispatch-boundary validation must run first, -/// never a raw `.expect`. -#[test] -fn aura_sweep_list_axes_rejects_malformed_blueprint() { - let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", &bp, "--list-axes"]) - .output() - .expect("spawn aura sweep --list-axes (malformed)"); - assert_eq!(out.status.code(), Some(2), "malformed blueprint must be rejected, not panic"); - let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}"); - assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}"); - assert!(!stderr.contains("panicked"), "must reject cleanly, not panic: {stderr}"); -} -/// Property (#331 fieldtest finding, cycle-331-blueprint-name c331_2e): `--list-axes` -/// is a FRESH-FILE intake exactly like `register` / `introspect --content-id` / -/// `introspect --params`, but its own root-name gate was missing — a hand-edited -/// envelope with a shape-violating root name (`"../x"`) printed mangled, -/// non-bindable axis names instead of being refused. The route now shares -/// `gate_authored_root_name` + `name_gate_fault_prose` and exits 1 — the -/// register-route convention (this route never `put_blueprint`s anything, unlike -/// the exit-2 sweep-EXECUTION route pinned by -/// `aura_sweep_synthetic_blueprint_refuses_a_hand_crafted_envelope_with_a_bad_root_name`) -/// — printing no axis line at all. -#[test] -fn aura_sweep_list_axes_refuses_a_hand_crafted_envelope_with_a_bad_root_name() { - let dir = temp_cwd("sweep_list_axes_bad_root_name_refusal"); - let fixture_text = - std::fs::read_to_string(format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR"))) - .expect("read r_sma_open.json fixture"); - let bad = fixture_text.replacen("\"name\":\"sma_signal\"", "\"name\":\"../x\"", 1); - assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}"); - let file = dir.join("bad-root-name.json"); - std::fs::write(&file, &bad).expect("write envelope fixture"); - let out = Command::new(BIN) - .args(["sweep", file.to_str().unwrap(), "--list-axes"]) - .current_dir(&dir) - .output() - .expect("spawn aura sweep --list-axes (bad root name)"); - assert_eq!( - out.status.code(), - Some(1), - "a shape-violating root name must be refused (register-route convention), not listed: status={:?} stderr={}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("blueprint name \"../x\" is invalid") && stderr.contains("must not contain '/' or '\\'"), - "names the offending root name with the shared gate prose: {stderr}" - ); - assert!(out.stdout.is_empty(), "a refused root name must print no axis line: {out:?}"); - let _ = std::fs::remove_dir_all(&dir); -} -/// E2E (#210 c0110 fieldtest, "aura sweep rejects the op-script -/// array with a raw serde error"): `aura sweep`'s blueprint slot accepts only -/// the built blueprint envelope; an un-built op-script (a JSON array) is -/// shape-discriminated and refused with a targeted, house-style hint — never -/// the leaked `Json(Error("invalid type: map, expected u32", ...))`. -#[test] -fn aura_sweep_rejects_an_op_script_array_with_a_build_first_hint() { - let dir = temp_cwd("sweep_rejects_op_script"); - let op_script = dir.join("ops.json"); - std::fs::write(&op_script, r#"[{"op":"source","role":"price","kind":"F64"}]"#) - .expect("write op-script fixture"); - let out = Command::new(BIN) - .args(["sweep", op_script.to_str().unwrap(), "--list-axes"]) - .output() - .expect("spawn aura sweep (op-script)"); - assert_eq!(out.status.code(), Some(2), "an op-script array must be refused, not panic"); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("op-script") && stderr.contains("aura graph build"), - "names the op-script cause + the fix: {stderr}" - ); - assert!(!stderr.contains("Json(Error"), "does not leak the raw serde error: {stderr}"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// E2E (#210 c0110 fieldtest, house-style loader prose): a genuinely malformed -/// (syntactically invalid) JSON document in the sweep blueprint slot is -/// refused with `blueprint_load_prose`'s wording, never the raw `{e:?}` Debug -/// form the dispatch used to print. -#[test] -fn aura_sweep_rejects_malformed_json_house_style() { - let dir = temp_cwd("sweep_rejects_malformed_json"); - let bad = dir.join("bad.json"); - std::fs::write(&bad, "{not valid json").expect("write malformed fixture"); - let out = Command::new(BIN) - .args(["sweep", bad.to_str().unwrap(), "--list-axes"]) - .output() - .expect("spawn aura sweep (malformed json)"); - assert_eq!(out.status.code(), Some(2), "malformed JSON must be refused, not panic"); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - stderr.contains("blueprint document is not valid JSON"), - "house-style prose, not a raw Debug leak: {stderr}" - ); - assert!(!stderr.contains("Json(Error"), "does not leak the raw serde Debug form: {stderr}"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// E2E (#169, the load-bearing "listed == swept" invariant): a name emitted by -/// `--list-axes` is accepted VERBATIM by `--axis` — the discovered axis namespace -/// and the swept axis namespace are the same set (shared-probe construction). The -/// axis names here are DERIVED from the `--list-axes` output, never hardcoded, then -/// fed straight back into a real sweep: the sweep exits 0 and persists a `Sweep` -/// family, proving every listed name resolved (no UnknownKnob). This is the -/// discovery-removes-guessing property #169 exists to close — were the list and -/// sweep surfaces ever to diverge, a discovered name would be rejected here and this -/// round-trip would fail, whereas the literal-pinned per-surface tests could not see it. -#[test] -fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() { - let cwd = temp_cwd("blueprint-list-axes-roundtrip"); - let bp = format!("{}/tests/fixtures/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); - - // 1) discover the axis names + kinds (do NOT hardcode them) — this is what a user - // reads. #246: the fixture now lists a BOUND param (`bias.scale`, F64) alongside - // the two open I64 lengths, so the CSV below must be typed per discovered kind, - // not assumed integer. - let list = Command::new(BIN) - .args(["sweep", &bp, "--list-axes"]) - .output() - .expect("spawn aura sweep --list-axes"); - assert_eq!(list.status.code(), Some(0)); - let listed = String::from_utf8(list.stdout).expect("utf-8"); - let names: Vec<(String, String)> = listed - .lines() - .map(|l| { - let mut parts = l.splitn(2, ':'); - let name = parts.next().expect("a `name:kind` line").to_string(); - let kind = parts - .next() - .expect("kind after ':'") - .split_whitespace() - .next() - .expect("a kind token") - .to_string(); - (name, kind) - }) - .collect(); - assert!(!names.is_empty(), "the open fixture must list >= 1 axis"); - - // 2) feed the DISCOVERED names straight back into a real sweep. Values are - // position-assigned (first axis small, rest large) to stay in a non-degenerate - // SMA regime without re-hardcoding the names; each axis takes two values, typed - // per its discovered kind so a bound F64 default (e.g. `bias.scale`) resolves - // instead of tripping `KindMismatch` on an integer-shaped token. - let mut args: Vec = vec!["sweep".into(), bp.clone()]; - for (i, (name, kind)) in names.iter().enumerate() { - let vals: &str = match (i == 0, kind.as_str()) { - (true, "F64") => "2.0,4.0", - (true, _) => "2,4", - (false, "F64") => "8.0,16.0", - (false, _) => "8,16", - }; - args.push("--axis".into()); - args.push(format!("{name}={vals}")); - } - let sweep = Command::new(BIN) - .args(&args) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep over discovered axes"); - assert!( - sweep.status.success(), - "every listed name must be a valid --axis; exit={:?} stderr={}", - sweep.status, - String::from_utf8_lossy(&sweep.stderr) - ); - - // 3) the sweep persisted a Sweep family whose grid spans exactly the discovered - // axes (each bound to two values -> 2^k members) — the names resolved end-to-end. - let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); - let fo = String::from_utf8(fams.stdout).expect("utf-8"); - assert!(fo.contains("\"kind\":\"Sweep\""), "families: {fo}"); - let expected = 1usize << names.len(); // two values per discovered axis - assert!(fo.contains(&format!("\"members\":{expected}")), "grid over the discovered axes: {fo}"); - - let _ = std::fs::remove_dir_all(&cwd); -} /// `--version` / `-V` surface the crate version on stdout and exit 0 (GNU MUST): /// a version query is a successful query, not a usage error. The expected string @@ -5733,157 +3335,12 @@ fn version_flag_carries_the_engine_commit_from_manifest_provenance() { ); } -/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is -/// NOT byte-identical to `aura run --help` — each subcommand documents its own -/// options rather than reprinting one shared global blob (the #131 uniform-help -/// surface retired). -#[test] -fn subcommand_help_is_scoped_not_uniform() { - let sweep = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["sweep", "--help"]).output().expect("spawn"); - let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--help"]).output().expect("spawn"); - assert_eq!(sweep.status.code(), Some(0), "sweep --help exit"); - assert_eq!(run.status.code(), Some(0), "run --help exit"); - let sweep_out = String::from_utf8_lossy(&sweep.stdout); - let run_out = String::from_utf8_lossy(&run.stdout); - assert!(sweep_out.contains("--strategy"), "sweep help names its flags: {sweep_out:?}"); - assert_ne!(sweep_out, run_out, "per-subcommand help must be scoped, not uniform"); -} -/// The GNU `--flag=value` equals form is accepted, equivalent to the -/// space-separated `--flag value`. (#159 cut 4: `--harness` retired along with -/// the built-in default harness; `--seed` on a shipped closed blueprint is the -/// surviving vehicle flag for this clap-grammar property.) -#[test] -fn gnu_equals_form_is_accepted() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", &fixture, "--seed=0"]).output().expect("spawn"); - assert_eq!(out.status.code(), Some(0), - "run --seed=0 should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); -} -/// The `--` end-of-options terminator is recognized (a bare `--` after the flags -/// is a no-op that still runs). (#159 cut 4: repointed off the retired -/// `--harness` onto `--seed` over a shipped closed blueprint.) -#[test] -fn double_dash_terminator_is_recognized() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", &fixture, "--seed", "0", "--"]).output().expect("spawn"); - assert_eq!(out.status.code(), Some(0), - "run ... -- should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); -} -/// GNU long-option abbreviation (spec acceptance criterion 4): an unambiguous prefix -/// of a long flag is accepted (`--se` → `--seed`), via clap `infer_long_args` -/// on the root command (which propagates to subcommands). An LLM/automation caller -/// always writes the full flag, but GNU compliance (the cycle's ratified purpose) -/// includes abbreviation, and clap delivers it with one root attribute. (#159 cut 4: -/// repointed off the retired `--harness` onto `--seed` over a shipped closed -/// blueprint.) -#[test] -fn long_option_abbreviation_is_accepted() { - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", &fixture, "--se", "0"]).output().expect("spawn"); - assert_eq!(out.status.code(), Some(0), - "run --se 0 (abbrev of --seed) should run; stderr: {}", - String::from_utf8_lossy(&out.stderr)); -} -/// Property (#175, dual-grammar built-in branch stays strict): a leading positional -/// that is NOT an existing `.json` file is a usage error (exit 2), never silently -/// swallowed — for every dual-grammar subcommand (run/sweep/walkforward/mc). The -/// clap migration models the loaded-blueprint `[BLUEPRINT]` as an OPTIONAL positional -/// on one shared args struct; that makes a typo'd leading token (`aura run bogus`, -/// meant as `--harness bogus`) structurally parseable where the old hand-parser -/// rejected any stray token. Each built-in handler therefore re-asserts the guard -/// (`if a.blueprint.is_some() { usage; exit 2 }`). Dropping any one guard would -/// silently run the default harness on a mistyped invocation — the exact regression -/// clap's optional positional invites and this pins shut. Exit 2 preserved (the -/// usage/runtime split is iteration 2); nothing on stdout on the refusal path. -#[test] -fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() { - // A trailing flag is added where the built-in grammar needs one to reach the - // guard rather than a bare-run default; `bogus` never ends in `.json`, so the - // is_file() discriminator sends every case down the built-in branch. - for argv in [ - &["run", "bogus"][..], - &["sweep", "bogus", "--name", "x"][..], - &["walkforward", "bogus"][..], - &["mc", "bogus"][..], - ] { - let out = Command::new(BIN).args(argv).output().expect("spawn aura "); - assert_eq!( - out.status.code(), - Some(2), - "{argv:?} must be refused (exit 2), got {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - assert!( - out.stdout.is_empty(), - "{argv:?} must not emit a report on stdout: {:?}", - String::from_utf8_lossy(&out.stdout) - ); - } -} -/// Property (#175, dual-grammar discriminator keys on `is_file()`, not the suffix): -/// `aura run .json` where the file does NOT exist is refused with the same -/// usage error as a bare, blueprint-less `run` (exit 2), NOT read as a blueprint. -/// The discriminator is `ends_with(".json") && Path::is_file()`; a regression that -/// dropped the `is_file()` conjunct (suffix-only) would send this down the -/// blueprint-read branch and fail with a file-read error instead — a different, -/// misleading refusal. (#159 cut 4: the built-in `--harness` branch retired, so the -/// no-blueprint arm now emits the loaded-blueprint usage line unconditionally; the -/// assertion is repointed onto that line, still distinct from a file-read error.) -/// The positive `.json`-file path stays covered by -/// `aura_run_loads_and_runs_a_blueprint_file`. -#[test] -fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() { - let out = Command::new(BIN) - .args(["run", "definitely-not-a-real-file.json"]) - .output() - .expect("spawn aura run .json"); - assert_eq!( - out.status.code(), - Some(2), - "a .json name that is not a file must be refused (exit 2), got {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - assert!(out.stdout.is_empty(), "no report on stdout: {:?}", String::from_utf8_lossy(&out.stdout)); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("Usage: aura run "), - "the no-blueprint usage arm (not the blueprint-read path) must handle it: {stderr:?}" - ); -} -/// The exit-code partition (#175 iteration 2, deviation #8): a USAGE error (a -/// malformed command line) exits 2; a RUNTIME failure (a well-formed command -/// whose needed data/state is missing) exits 1. Pins the partition as a property. -/// (#159 cut 4: `run` is blueprint-only now, so the runtime-failure arm needs a -/// shipped closed blueprint to clear the usage grammar before the no-geometry -/// refusal fires.) -#[test] -fn exit_codes_partition_usage_two_from_runtime_one() { - // usage: an unknown flag is a command-line error → exit 2 - let usage = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--bogus"]).output().expect("spawn"); - assert_eq!(usage.status.code(), Some(2), - "unknown flag is a usage error → 2; stderr: {}", String::from_utf8_lossy(&usage.stderr)); - // runtime: a well-formed command whose symbol has no recorded data → exit 1 - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let runtime = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", &fixture, "--real", "NONEXISTENT"]).output().expect("spawn"); - assert_eq!(runtime.status.code(), Some(1), - "no data for a valid command is a runtime failure → 1; stderr: {}", - String::from_utf8_lossy(&runtime.stderr)); -} /// Characterization pin (byte-identity anchor for the mc R-bootstrap dissolution, /// #210, the last verb; argv since migrated to the generic form by #220). @@ -5905,45 +3362,67 @@ fn exit_codes_partition_usage_two_from_runtime_one() { /// cleanly on a data refusal. #[test] fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8): the grade is the terminal stage's own + // native `bootstrap.pooled_oos` record (`aura_backtest::RBootstrap`, the + // SAME struct the retired `mc_r_bootstrap_json` CLI wrapper printed under + // identical field names) — no client-side reconstruction needed. The + // campaign `seed` carries the retired sugar's `--seed` (`translate_mc`'s + // mapping). let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "1000", "--seed", "42", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "mc-pin-seed"); + // The fixed real-archive roller (`aura_backtest::wf_ms_sizes`, 90d IS / 30d + // OOS / 30d step) the retired sugar's `fit_wf_ms_sizes` used — this + // window's span (~1 year) comfortably exceeds IS+OOS, so the fixed sizes + // apply unscaled (the `fit_wf_ms_sizes` fast-path every year-plus e2e + // anchor pins). + let mc_process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc-pin", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 } + ] +}"#; + let proc_id = register_process_doc(&cwd, "mcpin.process.json", mc_process_doc); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "mcpin", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 42, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "mcpin.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "mcpin.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out .lines() - .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) - .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON"); - let mc = &v["mc_r_bootstrap"]; - assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len: {grade_line}"); - assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples: {grade_line}"); - assert_eq!(mc["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {grade_line}"); + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let mc = &v["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"]; + assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len: {record_line}"); + assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples: {record_line}"); + assert_eq!(mc["n_trades"].as_u64(), Some(20681), "pooled OOS trade count: {record_line}"); // EXACT bootstrap floats -- the byte-identity anchor the dissolution must preserve. - assert_eq!(mc["e_r"]["mean"].as_f64(), Some(-0.0025753095301594307), "E[R] mean: {grade_line}"); - assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {grade_line}"); - assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}"); + assert_eq!(mc["e_r"]["mean"].as_f64(), Some(-0.0025753095301594307), "E[R] mean: {record_line}"); + assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {record_line}"); + assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {record_line}"); } /// Property (#220 mc vertical): `aura mc --real` R-bootstraps ANY sweepable @@ -5962,45 +3441,59 @@ fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() { /// data refusal. #[test] fn mc_dissolves_a_non_r_sma_blueprint() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8), same clipped window + fixed 90/30/30 + // roller as `mc_r_bootstrap_real_e2e_pins_the_exact_current_grade`, over + // the unrelated r-breakout candidate. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "channel_length=10,20", - "--stop-length", "14", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "1000", "--seed", "42", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint_file(&cwd, &fixture, "breakout-mcpin-seed"); + let mc_process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc-breakout", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 } + ] +}"#; + let proc_id = register_process_doc(&cwd, "breakoutmcpin.process.json", mc_process_doc); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "breakoutmcpin", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [10, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 42, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "breakoutmcpin.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "breakoutmcpin.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out .lines() - .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) - .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON"); - let mc = &v["mc_r_bootstrap"]; - assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len (argv echo): {grade_line}"); - assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples (argv echo): {grade_line}"); - assert!(mc["n_trades"].as_u64().is_some_and(|n| n > 0), "pooled OOS trade count present: {grade_line}"); - assert!(mc["e_r"]["mean"].as_f64().is_some_and(|m| m.is_finite()), "E[R] mean present + finite: {grade_line}"); + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let mc = &v["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"]; + assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len (argv echo): {record_line}"); + assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples (argv echo): {record_line}"); + assert!(mc["n_trades"].as_u64().is_some_and(|n| n > 0), "pooled OOS trade count present: {record_line}"); + assert!(mc["e_r"]["mean"].as_f64().is_some_and(|m| m.is_finite()), "E[R] mean present + finite: {record_line}"); assert!( mc["prob_le_zero"].as_f64().is_some_and(|p| (0.0..=1.0).contains(&p)), - "P(E[R] <= 0) present + in [0,1]: {grade_line}" + "P(E[R] <= 0) present + in [0,1]: {record_line}" ); } @@ -6018,38 +3511,61 @@ fn mc_dissolves_a_non_r_sma_blueprint() { /// skips cleanly on a data refusal. #[test] fn mc_dissolves_through_the_campaign_path() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8): the retired `mc_r_bootstrap` line + // format is gone (deleted Task 8 Step 2), but the equivalent property — + // a single native summary line (`emit: []` suppresses the per-member + // family_table lines, leaving only the always-on final + // `{"campaign_run":…}` line) — survives, plus the auto-registration and + // family-count properties. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "1000", "--seed", "42", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "mc-dissolve-seed"); + let mc_process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc-dissolve", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 } + ] +}"#; + let proc_id = register_process_doc(&cwd, "mcdissolve.process.json", mc_process_doc); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "mc", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 42, + "presentation": {{ "persist_taps": [], "emit": [] }} +}}"#, + ); + write_doc(&cwd, "mc.campaign.json", &doc); + let raw = Command::new(BIN).args(["exec", "mc.campaign.json"]).current_dir(&cwd).output().expect("spawn exec"); + let stdout = String::from_utf8_lossy(&raw.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&raw.stderr).into_owned(); + let out = format!("{stdout}{stderr}"); + if raw.status.code() == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + assert_eq!(raw.status.code(), Some(0), "exit: {out}"); - // the single grade line is emitted (and nothing else on stdout). - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert_eq!(stdout.lines().count(), 1, "mc prints exactly one grade line: {stdout:?}"); + // the single native summary line is emitted on STDOUT (and nothing else + // there, since `emit: []` suppresses the per-member family_table lines) — + // the informational "aura: campaign run N recorded: …" tally rides + // stderr, not stdout, so it is not counted here. + assert_eq!(stdout.lines().count(), 1, "mc prints exactly one summary line on stdout: {stdout:?}"); assert!( - stdout.lines().next().is_some_and(|l| l.starts_with("{\"mc_r_bootstrap\":")), - "the one line is the mc_r_bootstrap grade: {stdout}" + stdout.lines().next().is_some_and(|l| l.starts_with("{\"campaign_run\":")), + "the one line is the native campaign_run summary: {stdout}" ); let count = |sub: &str| { @@ -6060,7 +3576,7 @@ fn mc_dissolves_through_the_campaign_path() { assert_eq!(count("processes"), 1, "one generated process document registered"); assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) - .expect("campaign_runs.jsonl exists after a sugar run"); + .expect("campaign_runs.jsonl exists after a run"); assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); let campaigns_dir = cwd.join("runs").join("campaigns"); @@ -6073,7 +3589,7 @@ fn mc_dissolves_through_the_campaign_path() { let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); assert!( campaign_doc_json.contains("\"name\":\"mc\""), - "the generated campaign document carries the constant mc name: {campaign_doc_json}" + "the campaign document carries the authored mc name: {campaign_doc_json}" ); assert!( campaign_doc_json.contains("\"risk\":[") @@ -6082,12 +3598,8 @@ fn mc_dissolves_through_the_campaign_path() { "the stop rides a non-empty risk regime: {campaign_doc_json}" ); - let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["runs", "families"]) - .current_dir(&cwd) - .output() - .expect("spawn families"); - let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + let (fams_out, fams_code) = run_code_in(&cwd, &["runs", "families"]); + assert_eq!(fams_code, Some(0), "families exit: {fams_out}"); assert_eq!( fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), 0, @@ -6124,102 +3636,86 @@ fn mc_real_fits_the_injected_roller_to_a_short_window() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8): the retired CLI's `fit_wf_ms_sizes` + // scaling (`aura_backtest::scaffold::fit_wf_ms_sizes`, still a real + // library function) is now invoked by the TEST itself, at document- + // construction time, to size the `std::walk_forward` stage — a + // hand-authored document has no in-executor auto-fit (that scaling was + // pure CLI-side preprocessing before generating the document); this + // reproduces the SAME fitted roller the retired sugar generated. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let bp_id = seed_blueprint(&cwd, "mc-short-window-seed"); - // ~30 days, far shorter than the fixed 90+30-day roller, so the injected - // walk_forward cannot fit unless it scales down to the window. - const SUB_FROM_MS: &str = "1744761600000"; - const SUB_TO_MS: &str = "1747353600000"; + // ~30 days, far shorter than the fixed 90+30-day roller, so the roller + // must scale down to the window (the same fit that lets this complete). + const SUB_FROM_MS: i64 = 1744761600000; + const SUB_TO_MS: i64 = 1747353600000; + let (is_ms, oos_ms, step_ms) = aura_backtest::scaffold::fit_wf_ms_sizes(SUB_FROM_MS, SUB_TO_MS); + // Sanity: the fixed 90d+30d roller does NOT fit this ~30-day span + // unscaled, so `fit_wf_ms_sizes` must actually be exercising its + // scale-down branch here — else this test would be vacuous. + assert!( + 7_776_000_000u64 + 2_592_000_000u64 > (SUB_TO_MS - SUB_FROM_MS) as u64, + "the fixed 90/30 roller must NOT fit this window unscaled, or the fit branch goes untested" + ); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "100", "--seed", "42", - "--from", SUB_FROM_MS, "--to", SUB_TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura mc"); + let mc_process_doc = format!( + r#"{{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc-shortwindow", + "pipeline": [ + {{ "block": "std::grid" }}, + {{ "block": "std::walk_forward", "in_sample_ms": {is_ms}, "out_of_sample_ms": {oos_ms}, + "step_ms": {step_ms}, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }}, + {{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 }} + ] +}}"#, + ); + let proc_id = register_process_doc(&cwd, "mcshort.process.json", &mc_process_doc); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "mcshort", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": 42, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = SUB_FROM_MS, to = SUB_TO_MS, + ); + write_doc(&cwd, "mcshort.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "mcshort.campaign.json"]); - let stderr = String::from_utf8_lossy(&out.stderr); // Defensive: a genuine data refusal (never the late window-fit refusal this test // pins) still means "no usable data on this host" — skip, don't fail. - if out.status.code() == Some(1) - && (stderr.contains("no local data") || stderr.contains("no recorded geometry")) - { + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } assert_eq!( - out.status.code(), Some(0), + code, Some(0), "a short real window must fit the injected roller and run to completion, \ - not refuse late; stderr: {stderr}" + not refuse late: {out}" ); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); assert!( - stdout.lines().any(|l| l.starts_with("{\"mc_r_bootstrap\":")), - "the mc_r_bootstrap grade line must be emitted for the fitted short window: {stdout}" + v["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"].is_object(), + "the mc bootstrap grade must be emitted for the fitted short window: {record_line}" ); } -/// Fork A: the stop is a single risk regime on the dissolved mc path — a multi-value -/// `--stop-length`/`--stop-k` is a usage refusal (exit 2), not a swept axis. Mirrors -/// `walkforward_dissolved_refuses_a_multi_value_stop`. NOT gated — the refusal is pure -/// argument parsing, before any data access. -#[test] -fn mc_dissolved_refuses_a_multi_value_stop() { - let cwd = temp_cwd("mc-multi-stop"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14,20", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("invalid value"), "stop-regime refusal: {stderr}"); -} -/// Property: an `--axis` name that is not among the loaded blueprint's sweepable -/// axes is refused before the raw-namespace strip or any archive access (exit 2), -/// echoing exactly the name the user typed — mc shares the identical WRAPPED-probe -/// preflight `walkforward_dissolved_refuses_an_unknown_axis_name` pins for -/// walkforward (both dispatchers now call the same `validate_and_register_axes` -/// helper); this test pins mc's own copy so a future edit cannot silently break it -/// while walkforward's stays green. Data-free: no archive/store access. -#[test] -fn mc_dissolved_refuses_an_unknown_axis_name() { - let cwd = temp_cwd("mc-unknown-axis"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), - "must name the unresolved axis exactly as typed: {stderr}" - ); - assert!( - out.stdout.is_empty(), - "the refusal must not leak an aggregate to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) - ); - assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run"); -} /// Property: the one load-bearing decision new to the mc dissolution — `translate_mc` /// maps `campaign.seed` to the mc `--seed` (unlike `translate_walkforward`, which @@ -6236,56 +3732,75 @@ fn mc_dissolved_refuses_an_unknown_axis_name() { /// the GER40 2025 archive; skips cleanly on a data refusal. #[test] fn mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8), same clipped window + fixed 90/30/30 + // roller as `mc_r_bootstrap_real_e2e_pins_the_exact_current_grade` — only + // the document's own `seed` field varies (the campaign seed carries the + // retired sugar's `--seed`, `translate_mc`'s mapping). let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let run = |seed: &str| { - std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "fast.length=3,5", "--axis", "slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "1000", "--seed", seed, - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura") + let bp_id = seed_blueprint(&cwd, "mc-seed-diff-seed"); + let mc_process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward-then-mc-seeddiff", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000, + "step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }, + { "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 } + ] +}"#; + let proc_id = register_process_doc(&cwd, "mcseeddiff.process.json", mc_process_doc); + let doc_for = |seed: u64| { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "mcseeddiff-{seed}", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3, 5] }}, + "slow.length": {{ "kind": "I64", "values": [12, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 14, "k": 2.0 }} }} ], + "seed": {seed}, + "presentation": {{ "persist_taps": [], "emit": [] }} +}}"#, + ) }; - let out42 = run("42"); - if out42.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out42.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let run = |seed: u64| { + let file = format!("mcseeddiff-{seed}.campaign.json"); + write_doc(&cwd, &file, &doc_for(seed)); + run_code_in(&cwd, &["exec", &file]) + }; + let (out42, code42) = run(42); + if code42 == Some(3) && (out42.contains("no local data") || out42.contains("no recorded geometry") || out42.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - let out7 = run("7"); - assert_eq!(out42.status.code(), Some(0), "exit (seed 42): {:?}", out42.status); - assert_eq!(out7.status.code(), Some(0), "exit (seed 7): {:?}", out7.status); + let (out7, code7) = run(7); + assert_eq!(code42, Some(0), "exit (seed 42): {out42}"); + assert_eq!(code7, Some(0), "exit (seed 7): {out7}"); - let parse = |out: &std::process::Output| -> serde_json::Value { - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let line = stdout + let parse = |out: &str| -> serde_json::Value { + let line = out .lines() - .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) - .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")) - .to_string(); - serde_json::from_str(&line).expect("grade line parses as JSON") + .find(|l| l.starts_with("{\"campaign_run\":")) + .unwrap_or_else(|| panic!("the always-on final campaign_run line: {out}")); + serde_json::from_str(line).expect("campaign_run line parses as JSON") }; let v42 = parse(&out42); let v7 = parse(&out7); + let mc42 = &v42["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"]; + let mc7 = &v7["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"]; assert_eq!( - v42["mc_r_bootstrap"]["n_trades"], v7["mc_r_bootstrap"]["n_trades"], - "the pooled OOS trade-R series is seed-independent (argmax winners): {v42} vs {v7}" + mc42["n_trades"], mc7["n_trades"], + "the pooled OOS trade-R series is seed-independent (argmax winners): {mc42} vs {mc7}" ); assert_ne!( - v42["mc_r_bootstrap"]["e_r"]["mean"], v7["mc_r_bootstrap"]["e_r"]["mean"], - "a different --seed must move the resampled E[R] mean: {v42} vs {v7}" + mc42["e_r"]["mean"], mc7["e_r"]["mean"], + "a different seed must move the resampled E[R] mean: {mc42} vs {mc7}" ); } @@ -6299,47 +3814,48 @@ fn walkforward_campaign_runs_an_arbitrary_blueprint() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward]` campaign document + // (#319 Task 8) over the SAME real GER40 window/axis the sugar used — + // the `{"walkforward":…}`/`param_stability` aggregate was the retired + // CLI verb's own post-processing (deleted Task 8 Step 2, no surviving + // reconstruction); the surviving property is that the campaign path + // realizes a `std::walk_forward` stage (>= 1 window, family persisted) + // over an arbitrary user blueprint with axis names the CLI has never + // hardcoded — the r-sma weld is structurally gone. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "channel_length=10,20", - "--stop-length", "3", "--stop-k", "2.0", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint_file(&cwd, &fixture, "breakout-wf-seed"); + let proc_id = register_process_doc(&cwd, "breakoutwf.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "breakout-wf", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735689600000, "to_ms": 1767225599000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [10, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 3, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "breakoutwf.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "breakoutwf.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out .lines() - .find(|l| l.starts_with("{\"walkforward\":")) - .unwrap_or_else(|| panic!("the walkforward summary line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON"); - let wf = &v["walkforward"]; - assert!(wf["windows"].as_u64().is_some_and(|w| w >= 1), "at least one window: {grade_line}"); - // param_stability rows: channel_length, stop_length, stop_k. - let ps = wf["param_stability"].as_array().expect("param_stability is an array"); - assert_eq!(ps.len(), 3, "one ganged channel axis + the stop pair: {grade_line}"); + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let stages = &v["campaign_run"]["cells"][0]["stages"]; + assert_eq!(stages[0]["block"], "std::grid", "leading grid stage: {record_line}"); + assert_eq!(stages[1]["block"], "std::walk_forward", "walk_forward realized: {record_line}"); + assert!(stages[1]["family_id"].as_str().is_some(), "walk-forward persists its family: {record_line}"); } /// #220 weld-gone proof for mc: the R-bootstrap pipeline runs the same @@ -6350,45 +3866,44 @@ fn mc_campaign_runs_an_arbitrary_blueprint() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a `[std::grid, std::walk_forward, std::monte_carlo]` + // campaign document (#319 Task 8): the terminal stage's own native + // `bootstrap.pooled_oos` record (`aura_backtest::RBootstrap`, the SAME + // struct the retired CLI's `mc_r_bootstrap_json` wrapper printed) is read + // directly — no client-side reconstruction needed. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "channel_length=10,20", - "--stop-length", "3", "--stop-k", "2.0", - "--block-len", "5", "--resamples", "100", "--seed", "7", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint_file(&cwd, &fixture, "breakout-mc-seed"); + let proc_id = register_process_doc(&cwd, "breakoutmc.process.json", GRID_THEN_WF_THEN_MC_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "breakout-mc", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735689600000, "to_ms": 1767225599000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "channel_length": {{ "kind": "I64", "values": [10, 20] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 3, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + ); + write_doc(&cwd, "breakoutmc.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "breakoutmc.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let grade_line = stdout + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out .lines() - .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) - .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")); - let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON"); - assert_eq!(v["mc_r_bootstrap"]["n_resamples"].as_u64(), Some(100), "resamples flow: {grade_line}"); - assert!(v["mc_r_bootstrap"]["e_r"]["mean"].is_number(), "bootstrap E[R] present: {grade_line}"); + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let pooled = &v["campaign_run"]["cells"][0]["stages"][2]["bootstrap"]["pooled_oos"]; + assert_eq!(pooled["n_resamples"].as_u64(), Some(100), "resamples flow: {record_line}"); + assert!(pooled["e_r"]["mean"].is_number(), "bootstrap E[R] present: {record_line}"); } /// #220 weld-gone proof for generalize: one mean-reversion candidate graded @@ -6401,342 +3916,60 @@ fn generalize_campaign_runs_an_arbitrary_blueprint() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } + // Ported onto a `[std::sweep, std::generalize]` campaign document + // (#319 Task 8): the grade is the campaign's own native + // `generalizations[].generalization` record (`aura_registry:: + // Generalization`, computed inside `aura-campaign::exec` — the SAME + // struct the retired `generalize_json` CLI wrapper printed), read + // directly off the final `{"campaign_run":…}` line. let (cwd, _g) = fresh_project(); let fixture = format!("{}/tests/fixtures/r_meanrev_open.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "generalize", &fixture, "--real", "GER40,USDJPY", - "--axis", "window=20", - "--axis", "band.factor=2.0", - "--stop-length", "3", "--stop-k", "2.0", - "--from", GER40_SEPT2024_FROM_MS, "--to", GER40_SEPT2024_TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint_file(&cwd, &fixture, "meanrev-gen-seed"); + let proc_id = register_process_doc(&cwd, "meanrevgen.process.json", SWEEP_THEN_GENERALIZE_PROCESS_DOC); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "meanrev-gen", + "data": {{ "instruments": ["GER40", "USDJPY"], + "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "window": {{ "kind": "I64", "values": [20] }}, + "band.factor": {{ "kind": "F64", "values": [2.0] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "risk": [ {{ "vol": {{ "length": 3, "k": 2.0 }} }} ], + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_SEPT2024_FROM_MS.parse::().unwrap(), + to = GER40_SEPT2024_TO_MS.parse::().unwrap(), + ); + write_doc(&cwd, "meanrevgen.campaign.json", &doc); + let (out, code) = run_code_in(&cwd, &["exec", "meanrevgen.campaign.json"]); + if code == Some(3) && (out.contains("no local data") || out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40/USDJPY data"); return; } - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("\"generalize\":"), "aggregate object: {stdout}"); - assert!(stdout.contains("\"n_instruments\":2"), "two instruments graded: {stdout}"); - assert!(stdout.contains("\"worst_case\":"), "worst-case floor present: {stdout}"); + assert_eq!(code, Some(0), "exit: {out}"); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + let v: serde_json::Value = serde_json::from_str(record_line).expect("campaign_run line parses as JSON"); + let grade = &v["campaign_run"]["generalizations"][0]["generalization"]; + assert!(grade.is_object(), "the aggregate object is present: {record_line}"); + assert_eq!(grade["n_instruments"].as_u64(), Some(2), "two instruments graded: {record_line}"); + assert!(grade["worst_case"].is_number(), "worst-case floor present: {record_line}"); } -/// #220: the walkforward campaign path requires >= 1 --axis (exit 2, data-free). -#[test] -fn walkforward_campaign_refuses_missing_axis() { - let cwd = temp_cwd("walkforward-campaign-no-axis"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", &fixture, "--real", "GER40", "--stop-length", "14", "--stop-k", "2.0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "missing --axis is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("requires >= 1 --axis"), "axis-arity refusal: {stderr}"); -} -/// #220: mc's two modes are disjoint — --seeds (synthetic seed family) with -/// --real (campaign pipeline) is a cross-mode usage error (exit 2, data-free). -#[test] -fn mc_campaign_refuses_seeds_with_real() { - let cwd = temp_cwd("mc-campaign-seeds-mix"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", "--seeds", "3", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "--seeds with --real is a cross-mode usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}"); -} -/// #328 (supersedes the pre-#328 "raw form is refused" pin, inverted by the -/// reconciliation): a WRAPPED-form axis name is refused before any data -/// access on the migrated verbs, exactly as sweep's shipped refusal — -/// echoing what the user typed plus the translated raw candidate, with no -/// store litter (mirrors -/// aura_sweep_real_blueprint_refuses_a_wrapped_form_axis_name_before_data_access). -#[test] -fn walkforward_campaign_refuses_a_wrapped_form_axis_name() { - let cwd = temp_cwd("walkforward-campaign-wrapped-axis"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "a wrapped-form axis name is refused before any data access"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("axis \"sma_signal.fast.length\""), "echoes the typed name verbatim: {stderr}"); - assert!( - stderr.contains("axis names are raw node.param paths") && stderr.contains("\"fast.length\""), - "translates to the raw candidate: {stderr}" - ); - assert!(!cwd.join("runs").exists(), "no store litter on the refusal path"); -} -/// #220: an unknown axis name is refused at the dispatch boundary on the -/// migrated verbs (mirrors aura_sweep_rejects_an_unknown_axis). -#[test] -fn mc_campaign_refuses_an_unknown_axis() { - let cwd = temp_cwd("mc-campaign-unknown-axis"); - let fixture = format!("{}/examples/r_breakout.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "mc", &fixture, "--real", "GER40", - "--axis", "r_breakout_signal.nope.length=1", - "--stop-length", "14", "--stop-k", "2.0", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "an unknown axis is refused at the dispatch boundary"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("axis \"r_breakout_signal.nope.length\""), - "names the offending axis: {stderr}" - ); -} -/// #220: the mc campaign path also requires >= 1 --axis before any archive -/// access (exit 2, data-free) — the same arity gate walkforward enforces -/// (`walkforward_campaign_refuses_missing_axis`) and generalize enforces -/// (`generalize_refuses_no_axis`), but which Task 5 left untested for mc: the -/// `axes.is_empty()` check lives in `dispatch_mc`'s `--real` arm alone, so a -/// regression dropping it would otherwise surface only once real data hits the -/// R-bootstrap pipeline, not at argv-parse time. -#[test] -fn mc_campaign_refuses_missing_axis() { - let cwd = temp_cwd("mc-campaign-no-axis"); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["mc", &fixture, "--real", "GER40", "--stop-length", "14", "--stop-k", "2.0"]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - assert_eq!(out.status.code(), Some(2), "missing --axis is a usage refusal"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}"); - assert!(!cwd.join("runs").exists(), "no store litter on the refusal path"); -} -/// `aura run` on the shipped closed example reproduces the built-in r-sma grade -/// (#159): the example is the proven data successor to `--harness r-sma`. Survives -/// the Cut-1b builder deletion (depends only on the example + generic run path). -#[test] -fn shipped_r_sma_example_reproduces_the_builtin_grade() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_sma.json"]) - .output() - .expect("spawn aura run example"); - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr={}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - // the same metrics r_sma_single_run_output_golden pins for --harness r-sma - assert!(stdout.contains("\"expectancy_r\":1.2710005136982836"), "{stdout}"); - assert!(stdout.contains("\"sqn\":3.141496526818299"), "{stdout}"); - assert!(stdout.contains("\"total_pips\":0.34185000000002036"), "{stdout}"); - assert!(stdout.contains("\"n_trades\":3"), "{stdout}"); -} -/// Characterization pin (#252, byte-identity anchor before `probe_window`'s -/// internals swap from a full-window drain to `aura_ingest::archive_extent`'s -/// boundary-month derivation). Captured against the PRE-#252 drain-based code -/// over `fresh_project_with_data`'s hermetic two-symbol synthetic archive -/// (SYMA spans 2024-01..08, SYMB 2024-03..06, weekday 08:00-16:00 UTC bars): -/// the resolved `campaign_window_ms(full_window)` span persists verbatim into -/// the generated campaign document's `data.windows[0]`, exactly like the -/// sibling `generalize_without_explicit_window_resolves_the_intersection_of_all_symbols` -/// probe. Three cases: SYMA's and SYMB's full (no-window) spans, and an -/// explicit `--from`/`--to` sub-window straddling three month files -/// (2024-03-15..2024-05-15, so `filter_files` alone would load 3 files under -/// the old drain). The first bar of each captured span carries a known -1ms -/// float round-trip artifact (the synthetic fixture's Delphi-`TDateTime` -/// pack/unpack in `data-server`'s own record format, not a `probe_window` bug) -/// — this test does not explain it, only pins it exactly, since the -/// post-swap code must reproduce the identical bytes (same underlying decoder, -/// only fewer files touched to reach them). -#[test] -fn probe_window_resolves_the_pinned_span_before_the_252_swap() { - let (cwd, _g) = fresh_project_with_data(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let window_of = |name: &str| -> (i64, i64) { - let dir = cwd.join("runs").join("campaigns"); - let path = std::fs::read_dir(&dir) - .expect("campaigns dir exists after a run") - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .find(|p| { - let doc: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(p).expect("read campaign doc")) - .expect("campaign doc parses as JSON"); - doc["name"].as_str() == Some(name) - }) - .unwrap_or_else(|| panic!("no campaign document named {name:?} in {}", dir.display())); - let doc: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read campaign doc")) - .expect("campaign doc parses as JSON"); - let w = &doc["data"]["windows"][0]; - ( - w["from_ms"].as_i64().expect("from_ms is an integer"), - w["to_ms"].as_i64().expect("to_ms is an integer"), - ) - }; - let sweep = |symbol: &str, name: &str, from: Option<&str>, to: Option<&str>| { - let mut args = vec![ - "sweep", fixture.as_str(), "--real", symbol, - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--name", name, - ]; - if let Some(f) = from { - args.push("--from"); - args.push(f); - } - if let Some(t) = to { - args.push("--to"); - args.push(t); - } - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(&args) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep"); - assert_eq!( - out.status.code(), Some(0), - "window-probe sweep {name:?} must run to exit 0: {}", - String::from_utf8_lossy(&out.stderr) - ); - }; - sweep("SYMA", "probe-252-no-window-syma", None, None); - assert_eq!( - window_of("probe-252-no-window-syma"), - (1_704_095_999_999, 1_725_033_540_000), - "SYMA's full (no-window) resolved span" - ); - sweep("SYMB", "probe-252-no-window-symb", None, None); - assert_eq!( - window_of("probe-252-no-window-symb"), - (1_709_279_999_999, 1_719_590_340_000), - "SYMB's full (no-window) resolved span" - ); - - // 2024-03-15 00:00 UTC .. 2024-05-15 00:00 UTC: straddles the March, April, - // and May files (three boundary months under the old file-range drain). - sweep("SYMA", "probe-252-straddle", Some("1710460800000"), Some("1715731200000")); - assert_eq!( - window_of("probe-252-straddle"), - (1_710_489_599_999, 1_715_702_340_000), - "explicit sub-window straddling month boundaries, clipped to the archive's actual bars" - ); -} - -/// Characterization pin (#252, hostless zero-bar case): a window that falls -/// entirely on a weekend inside a COVERED month (2024-03-02/03, both inside -/// SYMA's Jan-Aug 2024 span, weekday-only bars) still refuses with the SAME -/// exit-1 "no data ... in the requested window" message `probe_window`'s -/// drain-based emptiness check raises today — `archive_extent` must reach the -/// identical conclusion by finding zero matching months in range, not by -/// draining a stream, and the refusal wording is part of what must not move -/// (mirrors `run_real_empty_window_refusal_names_the_window_not_the_symbol`, -/// hostless here since the property (an empty resolved sub-window still -/// refuses) needs no full archive, only a covered-but-empty range). -#[test] -fn probe_window_still_refuses_a_hostless_zero_bar_window() { - let (cwd, _g) = fresh_project_with_data(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "sweep", &fixture, "--real", "SYMA", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--name", "probe-252-zero-bars", - "--from", "1709337600000", // 2024-03-02 00:00 UTC (Saturday) - "--to", "1709424000000", // 2024-03-03 00:00 UTC (Sunday) - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep"); - assert_eq!( - out.status.code(), Some(1), - "a zero-bar window inside covered archive months still refuses with exit 1: stderr={}", - String::from_utf8_lossy(&out.stderr) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no data") && stderr.contains("window"), - "must name the empty window, not a missing symbol: {stderr}" - ); - assert!( - !cwd.join("runs").join("campaigns").exists() - || std::fs::read_dir(cwd.join("runs").join("campaigns")).unwrap().count() == 0, - "a refused zero-bar window must not register a generated campaign document" - ); -} - -/// `aura run` on the shipped closed r-meanrev example (#159 cut 3) actually loads -/// and grades through the real, separately-linked `aura` binary — not merely -/// in-process, as `r_meanrev_example_loaded_runs_identically_to_the_carved_signal` -/// (main.rs) and this test's own grade-equivalence check both do by calling -/// `run_signal_r`/`blueprint_from_json` inside the *test* binary. This is the -/// CLI-seam companion `shipped_r_sma_example_reproduces_the_builtin_grade` has for -/// r-sma: it pins that the `Scale`-based band, freshly added to the closed -/// vocabulary this iteration, resolves and runs cleanly through the production -/// vocabulary lookup + arg-parsing path a real invocation takes, not just through -/// the test harness's direct function calls. The synthetic stream never crosses -/// the band at this window/k, so the grade is genuinely all-zero (no trade) — -/// still a deterministic, meaningful pin: a regression that broke `Scale`'s CLI -/// wiring (e.g. a roster/registration mismatch) would surface as a non-zero exit -/// or a load error here, not as a metrics drift. -#[test] -fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "examples/r_meanrev.json"]) - .output() - .expect("spawn aura run example"); - assert_eq!( - out.status.code(), - Some(0), - "exit: {:?} stderr={}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}"); - assert!(stdout.contains("\"expectancy_r\":0.0"), "{stdout}"); - assert!(stdout.contains("\"n_trades\":0"), "{stdout}"); - assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}"); -} /// Property (#273, list reshape): `aura data list` enumerates the archive's /// symbols as NDJSON, sorted — the discovery step a campaign author (or an @@ -6930,36 +4163,35 @@ fn data_info_on_an_unknown_symbol_refuses() { /// `walkforward_stopless_defaults_the_regime_byte_identically_to_explicit_3_2`). #[test] fn campaign_show_round_trips_the_registered_document() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a one-cell campaign document run through `exec` (#319 Task + // 8) — the `campaign show`/`process show` verbs themselves are untouched + // by the retirement; only the SETUP that used to mint a registered + // document via `walkforward --real` is replaced with a hand-authored + // document over the same real GER40 window. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--select", "plateau:mean", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if run.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&run.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "campaign-show-seed"); + let proc_id = register_process_doc(&cwd, "campaignshow.process.json", GRID_THEN_WF_PROCESS_DOC_90_30_30); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "campaignshow", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": [] }} +}}"#, + ); + write_doc(&cwd, "campaignshow.campaign.json", &doc); + let (run_out, run_code) = run_code_in(&cwd, &["exec", "campaignshow.campaign.json"]); + if run_code == Some(3) && (run_out.contains("no local data") || run_out.contains("no recorded geometry") || run_out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!( - run.status.code(), - Some(0), - "walkforward must succeed: {}", - String::from_utf8_lossy(&run.stderr) - ); + assert_eq!(run_code, Some(0), "exec must succeed: {run_out}"); let campaigns_dir = cwd.join("runs").join("campaigns"); let cid = std::fs::read_dir(&campaigns_dir) @@ -7027,36 +4259,33 @@ fn campaign_show_round_trips_the_registered_document() { /// process register`, with the store staying deduplicated at one document. #[test] fn process_show_round_trips_the_registered_document() { - const FROM_MS: &str = "1735689600000"; - const TO_MS: &str = "1767225599000"; + // Ported onto a one-cell campaign document run through `exec` (#319 Task + // 8) — same setup rationale as the sibling `campaign_show_round_trips_ + // the_registered_document` above. let (cwd, _g) = fresh_project(); - let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - - let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "walkforward", &fixture, "--real", "GER40", - "--axis", "fast.length=3", "--axis", "slow.length=12", - "--select", "plateau:mean", - "--from", FROM_MS, "--to", TO_MS, - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura"); - if run.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&run.stderr); - assert!( - stderr.contains("no local data") || stderr.contains("no recorded geometry"), - "exit 1 must be a data refusal, got: {stderr}" - ); + let bp_id = seed_blueprint(&cwd, "process-show-seed"); + let proc_id = register_process_doc(&cwd, "processshow.process.json", GRID_THEN_WF_PROCESS_DOC_90_30_30); + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "processshow", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1735776899999, "to_ms": 1767128220000 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [3] }}, + "slow.length": {{ "kind": "I64", "values": [12] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": [] }} +}}"#, + ); + write_doc(&cwd, "processshow.campaign.json", &doc); + let (run_out, run_code) = run_code_in(&cwd, &["exec", "processshow.campaign.json"]); + if run_code == Some(3) && (run_out.contains("no local data") || run_out.contains("no recorded geometry") || run_out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data"); return; } - assert_eq!( - run.status.code(), - Some(0), - "walkforward must succeed: {}", - String::from_utf8_lossy(&run.stderr) - ); + assert_eq!(run_code, Some(0), "exec must succeed: {run_out}"); let processes_dir = cwd.join("runs").join("processes"); let pid = std::fs::read_dir(&processes_dir) diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index a800d82..84848ec 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -61,36 +61,26 @@ const CAMPAIGN_DOC: &str = r#"{ "presentation": { "persist_taps": [], "emit": ["family_table"] } }"#; -/// Seed one blueprint (its params bound, reopened by the sweep axes per -/// #246) into the built demo project's store via a real sweep and return -/// its content id — copied verbatim from `research_docs.rs`'s recipe of the -/// same name. +/// Seed one blueprint into the built demo project's store via `aura graph +/// register` and return its content id — copied verbatim from +/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep` +/// side-effect seeding this used to ride played no role in the returned +/// content id, a pure function of the loaded blueprint's canonical bytes; +/// `name` labels the registered id, mirroring the old per-call sweep family +/// name). fn seed_blueprint(dir: &Path, name: &str) -> String { let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let (out, code) = run_code_in( - dir, - &[ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - name, - ], - ); - assert_eq!(code, Some(0), "seed sweep failed: {out}"); - std::fs::read_dir(dir.join("runs").join("blueprints")) - .expect("blueprints dir") + let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]); + assert_eq!(code, Some(0), "seed register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') .next() - .expect("one stored blueprint") - .expect("dir entry") - .path() - .file_stem() - .expect("stem") - .to_string_lossy() - .into_owned() + .expect("id") + .trim_start_matches("content:") + .to_string() } /// Register `doc` as a process document in the project store; returns its @@ -285,17 +275,6 @@ fn measurement_blueprint_json() -> String { serde_json::to_string(&v).expect("re-serialize measurement blueprint") } -/// Blank the one volatile manifest field (`manifest.commit`, the compile-time -/// build sha) before a byte comparison — the same blanking `aura-bench`'s -/// `run_line_fingerprint` applies before hashing (`fixed_cost.rs:21-31`). -fn strip_volatile(line: &str) -> String { - let mut v: serde_json::Value = serde_json::from_str(line).expect("record line parses"); - if let Some(commit) = v.get_mut("manifest").and_then(|m| m.get_mut("commit")) { - *commit = serde_json::Value::String(String::new()); - } - serde_json::to_string(&v).expect("re-serializing a parsed Value cannot fail") -} - /// Single run of a fully-bound blueprint on the synthetic stream: the record /// line is byte-shape-identical to `aura run`'s (manifest-first, one line) — /// mirrors `cli_run.rs::aura_run_loads_and_runs_a_blueprint_file`. @@ -310,20 +289,6 @@ fn exec_blueprint_file_emits_the_single_run_record_line() { assert!(v["manifest"].get("commit").is_some()); } -/// Parity: `exec` and `run` emit the identical record line for the same -/// file (both verbs alive until Task 8; the pin survives `run`'s removal by -/// keeping only the exec half and its literal expectations). -#[test] -fn exec_blueprint_record_line_matches_runs_line_bytes() { - let (run_out, run_code_) = run_code(&["run", "examples/r_sma.json"]); - let (exec_out, exec_code) = run_code(&["exec", "examples/r_sma.json"]); - assert_eq!(run_code_, Some(0), "stdout/stderr: {run_out}"); - assert_eq!(exec_code, Some(0), "stdout/stderr: {exec_out}"); - let run_line = run_out.lines().find(|l| l.starts_with('{')).expect("run record line"); - let exec_line = exec_out.lines().find(|l| l.starts_with('{')).expect("exec record line"); - assert_eq!(strip_volatile(run_line), strip_volatile(exec_line)); -} - /// The migrated root-name gate (retirement inventory): exec's blueprint /// intake refuses a bad root name with `run`'s exact prose, before any /// trace write — mirrors @@ -715,3 +680,395 @@ fn exec_campaign_walkforward_with_trades_emits_no_zero_trade_note() { assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!(!out.contains("recorded zero trades"), "a traded run emits no zero-trade note: {out}"); } + +// --------------------------------------------------------------------------- +// Task 8: `cli_run.rs` disposition — ports onto `exec` (retire+port half only; +// the port-to-campaign-document half is a separate follow-up dispatch). +// --------------------------------------------------------------------------- + +/// Property (#249, ported from `cli_run.rs::run_manifest_stamps_untouched_bound_defaults`): +/// a plain `exec` of a fully bound blueprint records the untouched bound param +/// values directly in the manifest — `params` stays `[]` (nothing varied) and +/// every bound value lands in `defaults`, RAW (#328) and ordered by +/// `bound_param_space()` (fast, slow, bias — node-declaration order). +#[test] +fn exec_manifest_stamps_untouched_bound_defaults() { + let (out, code) = run_code(&["exec", "examples/r_sma.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(out.contains("\"params\":[]"), "params stays empty on a plain exec: {out}"); + assert!( + out.contains( + r#""defaults":[["fast.length",{"I64":2}],["slow.length",{"I64":4}],["bias.scale",{"F64":0.5}]]"# + ), + "manifest must carry the untouched bound defaults: {out}", + ); +} + +/// Property (ported from `cli_run.rs::run_manifest_commit_carries_real_git_head`): +/// the RunManifest is self-identifying — its `commit` carries the real git +/// HEAD that produced the run, never the `"unknown"` placeholder. Structural +/// (contains, not equals) since the working tree may be dirty. +#[test] +fn exec_manifest_commit_carries_real_git_head() { + let head = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .expect("spawn git rev-parse HEAD"); + assert!(head.status.success(), "git rev-parse HEAD failed"); + let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha"); + let head_sha = head_sha.trim(); + assert!(!head_sha.is_empty(), "empty HEAD sha"); + + let (out, code) = run_code(&["exec", "examples/r_sma.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let key = "\"commit\":\""; + let start = out.find(key).expect("manifest has a commit field") + key.len(); + let rest = &out[start..]; + let end = rest.find('"').expect("commit field is a closed string"); + let commit = &rest[..end]; + assert_ne!(commit, "unknown", "manifest.commit is still the placeholder: {out}"); + assert!( + commit.contains(head_sha), + "manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}: {out}" + ); +} + +/// Property (#275, ported from `cli_run.rs::run_prints_json_and_exits_zero` — a +/// disposition-table gap: the plain synthetic single-run report shape was never +/// re-pinned onto `exec` although its property, a synthetic blueprint-leg +/// single run, belongs squarely to this leg): the canonical record-line shape +/// — nested manifest + metrics, stable keys, exactly one stdout line. +#[test] +fn exec_prints_json_and_exits_zero() { + let (out, code) = run_code(&["exec", "examples/r_sma.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert_eq!(out.lines().count(), 1, "stdout was: {out:?}"); + let line = out.trim_end(); + assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}"); + assert!(line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=0.0001)\""), "got: {line}"); + assert!(line.contains("\"window\":[1,18]"), "got: {line}"); + assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}"); + assert!(line.contains("\"r\":{\"expectancy_r\":"), "got: {line}"); +} + +/// A blueprint referencing a node type outside the closed vocabulary fails +/// clean at load (invariant 9), in house-style prose (mirrors +/// `cli_run.rs::aura_run_rejects_an_unknown_node_blueprint`). +#[test] +fn exec_rejects_an_unknown_node_blueprint() { + let (out, code) = run_code(&["exec", "tests/fixtures/unknown_node.json"]); + assert_ne!(code, Some(0), "an unknown node type must fail the run: {out}"); + assert!(out.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {out}"); + assert!(!out.contains("UnknownNodeType"), "does not leak the Debug variant name: {out}"); +} + +/// Property (#231 task 4): `exec`'s blueprint leg has no `--real` at all +/// (invariant 7 — a real-data single run is a one-cell campaign document), so +/// a multi-column blueprint always hits the synthetic-only refusal — mirrors +/// `cli_run.rs::run_synthetic_refuses_a_multi_column_blueprint` over the +/// shipped `examples/r_channel.json`. +#[test] +fn exec_refuses_a_multi_column_blueprint() { + let (out, code) = run_code(&["exec", "examples/r_channel.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("consumes columns beyond close"), "names the shape: {out}"); + assert!(out.contains("--real"), "names the remedy: {out}"); +} + +/// A CLOSED high/low blueprint (zero free knobs — copied verbatim from +/// `cli_run.rs::HL_RANGE_CLOSED_BLUEPRINT`) drives the exact-prose twin of the +/// refusal above, data-free (the guard fires directly after binding +/// resolution, before any archive access) — mirrors +/// `cli_run.rs::run_synthetic_refuses_a_multi_column_blueprint_before_data_access`. +const HL_RANGE_CLOSED_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 exec_refuses_a_multi_column_blueprint_before_data_access() { + let cwd = temp_cwd("exec_multicolumn_refusal"); + let bp_path = cwd.join("hl_range.json"); + std::fs::write(&bp_path, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture"); + let (out, code) = run_code_in(&cwd, &["exec", bp_path.to_str().expect("utf-8 path")]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert_eq!( + out.trim_end(), + "aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \ + data generates a close series only; run with --real " + ); +} + +/// Property (#210, dissolution — content-addressed idempotency), ported onto +/// `exec`'s campaign FILE leg (the register-then-run sugar, +/// `campaign_run.rs::run_campaign`'s target resolution): re-`exec`ing the +/// IDENTICAL campaign document file does not litter the store with a second +/// registered campaign document — `put_campaign` writes under a content-id +/// filename, so two runs of the same file collapse onto one registered +/// document while each invocation still records its own `campaign_runs.jsonl` +/// line (a run is an event, a document is content) — mirrors +/// `cli_run.rs::generalize_repeated_identical_invocation_does_not_litter_the_store`, +/// whose translator generated the document; here the document is authored and +/// `exec` performs only the file leg's own register-then-run idempotency. +#[test] +fn exec_campaign_file_repeated_identical_invocation_does_not_litter_the_store() { + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("idem.process.json")), + ScratchPath::File(dir.join("idem.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-idempotence-seed"); + let proc_id = register_process_doc(&dir, "idem.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999)); + write_doc(&dir, "idem.campaign.json", &doc); + + let (first_out, first_code) = run_code_in(&dir, &["exec", "idem.campaign.json"]); + assert_eq!(first_code, Some(0), "first run: {first_out}"); + let (second_out, second_code) = run_code_in(&dir, &["exec", "idem.campaign.json"]); + assert_eq!(second_code, Some(0), "second run: {second_out}"); + + let campaigns_count = std::fs::read_dir(dir.join("runs").join("campaigns")) + .map(|d| d.count()) + .unwrap_or(0); + assert_eq!(campaigns_count, 1, "two identical file-target invocations register one campaign document"); + let runs_log = std::fs::read_to_string(dir.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists"); + assert_eq!(runs_log.lines().count(), 2, "each invocation still records its own campaign run"); +} + +/// `campaign_doc_json_for`'s shape with the axis grid parameterized — needed +/// so the distinctness twin below can vary campaign content without touching +/// the window (keeping the known-good data-availability window fixed). +fn campaign_doc_json_for_axis_grid( + instrument: &str, + bp_id: &str, + proc_id: &str, + window: (i64, i64), + fast_values: &str, + slow_values: &str, +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam-grid", + "data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [{fast_values}] }}, + "slow.length": {{ "kind": "I64", "values": [{slow_values}] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = window.0, + to = window.1, + ) +} + +/// Property (#210, dissolution — content-addressed distinctness), ported onto +/// `exec`'s campaign FILE leg: two DIFFERENT campaign documents (differing +/// axis-grid content, same window) persist as TWO distinct registered +/// campaign documents — the twin of the idempotency test above, ruling out a +/// store that always overwrites one fixed filename regardless of content — +/// mirrors `cli_run.rs::generalize_distinct_invocations_persist_distinct_campaign_documents`. +#[test] +fn exec_campaign_file_distinct_invocations_persist_distinct_campaign_documents() { + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("distinct.process.json")), + ScratchPath::File(dir.join("distinct_a.campaign.json")), + ScratchPath::File(dir.join("distinct_b.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-distinct-seed"); + let proc_id = register_process_doc(&dir, "distinct.process.json", SWEEP_ONLY_PROCESS_DOC); + let window = (1709251200000, 1719791999999); + let doc_a = campaign_doc_json_for_axis_grid("SYMA", &bp_id, &proc_id, window, "2", "8"); + write_doc(&dir, "distinct_a.campaign.json", &doc_a); + let (out_a, code_a) = run_code_in(&dir, &["exec", "distinct_a.campaign.json"]); + assert_eq!(code_a, Some(0), "first document run: {out_a}"); + + let doc_b = campaign_doc_json_for_axis_grid("SYMA", &bp_id, &proc_id, window, "2, 4", "8, 16"); + write_doc(&dir, "distinct_b.campaign.json", &doc_b); + let (out_b, code_b) = run_code_in(&dir, &["exec", "distinct_b.campaign.json"]); + assert_eq!(code_b, Some(0), "second document run: {out_b}"); + + let campaigns_count = std::fs::read_dir(dir.join("runs").join("campaigns")) + .map(|d| d.count()) + .unwrap_or(0); + assert_eq!(campaigns_count, 2, "two distinct documents persist as two distinct campaign documents"); +} + +/// Property (C22 first-contact ergonomics, #20), ported onto the surviving +/// verb set (mirrors `cli_run.rs::help_flag_prints_usage_to_stdout_and_exits_zero`, +/// whose "the usage names the only subcommand a newcomer can run" assertion +/// named the now-retired `run` — the newcomer's reflex first command is +/// `exec` today): `--help`/`-h` prints to stdout and exits 0; an unknown +/// subcommand keeps the error path (exit 2). +#[test] +fn exec_help_flag_prints_usage_to_stdout_and_exits_zero() { + for flag in ["--help", "-h"] { + let (out, code) = run_code(&[flag]); + assert_eq!(code, Some(0), "`aura {flag}` exit: {out}"); + assert!(!out.trim().is_empty(), "`aura {flag}` should print help to stdout: {out}"); + assert!(out.contains("exec"), "`aura {flag}` help should mention the `exec` subcommand: {out}"); + } + let (out, code) = run_code(&["frobnicate"]); + assert_eq!(code, Some(2), "unknown subcommand must stay exit 2: {out}"); +} + +/// Property (post-clap migration, #175), ported onto the surviving verb set +/// (mirrors `cli_run.rs::per_subcommand_help_is_scoped_stdout_exit_zero`, +/// which enumerated the now-retired quintet): every surviving subcommand's +/// `--help`/`-h` prints its own scoped Options section to stdout, exit 0, +/// nothing on stderr. +#[test] +fn exec_per_subcommand_help_is_scoped_stdout_exit_zero() { + for sub in ["exec", "chart", "graph", "runs", "reproduce", "new", "nodes", "process", "campaign", "data", "measure"] { + for help in ["--help", "-h"] { + let (out, code) = run_code(&[sub, help]); + assert_eq!(code, Some(0), "{sub} {help} exit: {out}"); + assert!(!out.trim().is_empty(), "{sub} {help} stdout empty"); + } + } +} + +/// Property (mirrors `cli_run.rs::subcommand_help_is_scoped_not_uniform`): a +/// subcommand's `--help` is NOT byte-identical to another's — each documents +/// its own options, never one shared global blob. +#[test] +fn exec_subcommand_help_is_scoped_not_uniform() { + let (exec_help, exec_code) = run_code(&["exec", "--help"]); + let (graph_help, graph_code) = run_code(&["graph", "--help"]); + assert_eq!(exec_code, Some(0), "exec --help exit"); + assert_eq!(graph_code, Some(0), "graph --help exit"); + assert!(exec_help.contains("--override"), "exec help names its flags: {exec_help:?}"); + assert_ne!(exec_help, graph_help, "per-subcommand help must be scoped, not uniform"); +} + +/// The GNU `--flag=value` equals form is accepted (mirrors +/// `cli_run.rs::gnu_equals_form_is_accepted`; the surviving vehicle flag is +/// `--override`, since `--seed` retired with `run`'s built-in-harness +/// grammar, invariant 7). +#[test] +fn exec_gnu_equals_form_is_accepted() { + let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override=fast.length=8"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); +} + +/// The `--` end-of-options terminator is recognized (mirrors +/// `cli_run.rs::double_dash_terminator_is_recognized`). +#[test] +fn exec_double_dash_terminator_is_recognized() { + let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=8", "--"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); +} + +/// GNU long-option abbreviation (mirrors +/// `cli_run.rs::long_option_abbreviation_is_accepted`): an unambiguous prefix +/// of a long flag is accepted (`--over` -> `--override`), via clap's root +/// `infer_long_args`. +#[test] +fn exec_long_option_abbreviation_is_accepted() { + let (out, code) = run_code(&["exec", "examples/r_sma.json", "--over", "fast.length=8"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); +} + +/// exec's target classification, ported/merged from two retired dual-grammar +/// guards (`cli_run.rs::dual_grammar_stray_positional_is_a_usage_error_not_swallowed` +/// and the retired `run_with_trailing_token_is_strict_and_exits_two`'s negative +/// half): `target` is exec's one REQUIRED positional, so clap always parses +/// it — there is no more optional-positional-plus-hidden-default to guard +/// against. A bare, non-`.json` stray token is instead classified at RUNTIME +/// by the campaign leg's own target resolution, which refuses naming both +/// readings it tried, never silently defaulting to anything. +#[test] +fn exec_stray_target_that_is_neither_a_file_nor_a_content_id_refuses() { + let (dir, _fixture) = fresh_project(); + let (out, code) = run_code_in(&dir, &["exec", "bogus"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("'bogus' is neither a readable .json file nor a 64-hex content id"), + "stdout/stderr: {out}" + ); +} + +/// exec's discriminator keys on `is_file()`, not the `.json` suffix (mirrors +/// `cli_run.rs::dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix`): +/// a `.json`-suffixed name that does not exist on disk is NOT read as a +/// blueprint (which would fail with a file-read error) — it falls through to +/// the same neither-file-nor-content-id classification the bare stray token +/// above gets. The positive `.json`-file path stays covered by +/// `exec_blueprint_file_emits_the_single_run_record_line`. +#[test] +fn exec_json_suffixed_nonexistent_target_is_not_read_as_a_blueprint() { + let (dir, _fixture) = fresh_project(); + let (out, code) = run_code_in(&dir, &["exec", "definitely-not-a-real-file.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("'definitely-not-a-real-file.json' is neither a readable .json file nor a 64-hex content id"), + "stdout/stderr: {out}" + ); +} + +/// The usage/runtime exit-code partition (#175 iteration 2, deviation #8), +/// ported onto exec's own grammar (mirrors +/// `cli_run.rs::exit_codes_partition_usage_two_from_runtime_one`, whose +/// runtime half rode `run --real NONEXISTENT` — a flag exec's blueprint leg +/// no longer has, invariant 7): an unknown flag is a command-line error +/// (exit 2); `--override` naming no param of the blueprint is exec's own +/// well-formed-but-unresolvable runtime failure (exit 1). +#[test] +fn exec_exit_codes_partition_usage_two_from_runtime_one() { + let (usage_out, usage_code) = run_code(&["exec", "examples/r_sma.json", "--bogus"]); + assert_eq!(usage_code, Some(2), "unknown flag is a usage error -> 2: {usage_out}"); + let (runtime_out, runtime_code) = + run_code(&["exec", "examples/r_sma.json", "--override", "nope.knob=1"]); + assert_eq!( + runtime_code, Some(1), + "a well-formed command with an unresolvable override is a runtime failure -> 1: {runtime_out}" + ); +} + +/// `exec` on the shipped closed r-sma example reproduces the built-in r-sma +/// grade (mirrors `cli_run.rs::shipped_r_sma_example_reproduces_the_builtin_grade`). +#[test] +fn exec_shipped_r_sma_example_reproduces_the_builtin_grade() { + let (out, code) = run_code(&["exec", "examples/r_sma.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(out.contains("\"expectancy_r\":1.2710005136982836"), "{out}"); + assert!(out.contains("\"sqn\":3.141496526818299"), "{out}"); + assert!(out.contains("\"total_pips\":0.34185000000002036"), "{out}"); + assert!(out.contains("\"n_trades\":3"), "{out}"); +} + +/// `exec` on the shipped closed r-meanrev example runs end to end through the +/// real, separately-linked `aura` binary (mirrors +/// `cli_run.rs::shipped_r_meanrev_example_runs_end_to_end_via_aura_run`). The +/// synthetic stream never crosses the band at this window/k, so the grade is +/// genuinely all-zero (no trade) — still a meaningful pin: a regression that +/// broke the `Scale` band's CLI wiring would surface as a non-zero exit or a +/// load error here, not as a metrics drift. +#[test] +fn exec_shipped_r_meanrev_example_runs_end_to_end() { + let (out, code) = run_code(&["exec", "examples/r_meanrev.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(out.contains("\"topology_hash\":\""), "manifest carries the topology hash: {out}"); + assert!(out.contains("\"expectancy_r\":0.0"), "{out}"); + assert!(out.contains("\"n_trades\":0"), "{out}"); + assert!(out.contains("\"total_pips\":0.0"), "{out}"); +} diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index a3bf21d..0b1f28f 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -104,31 +104,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() { /// `/runs/`, and no stray `runs/` must appear under the /// subdirectory. This is the property that makes `[paths]` project-relative /// rather than shell-relative (C17). +/// +/// #319 vehicle note: the synthetic in-process family builder this test used +/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet — +/// no surviving CLI surface produces a family record without touching a real +/// archive (`exec`'s campaign leg always resolves against `DataServer`), so +/// the vehicle is now a one-cell campaign over the `fresh_project_with_data` +/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` + +/// `campaign_doc_json_for` recipe), executed from a project subdirectory. #[test] fn project_registry_anchors_at_discovered_root_not_invocation_cwd() { - let dir = built_project(); - let sub = dir.join("src"); + let (dir, _fixture) = common::fresh_project_with_data(); + let sub = dir.join("sub"); + std::fs::create_dir_all(&sub).expect("create invocation subdirectory"); let runs_dir = dir.join("runs"); std::fs::remove_dir_all(&runs_dir).ok(); - let closed_bp = - format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + + let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let reg_out = Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"]) + .current_dir(&dir) + .output() + .expect("spawn aura graph register"); + assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(®_out.stderr)); + let reg_text = String::from_utf8_lossy(®_out.stdout); + let bp_id = reg_text + .lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') + .next() + .expect("id") + .to_string(); + + let process_doc = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; + std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc"); + let proc_out = Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["process", "register", "anchor.process.json"]) + .current_dir(&dir) + .output() + .expect("spawn aura process register"); + assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr)); + let proc_text = String::from_utf8_lossy(&proc_out.stdout); + let proc_id = proc_text + .lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string(); + + // The established `SYMA` window (2024-03..06, fully inside its 2024-01..08 + // span) other #319 vehicle campaigns use. + let campaign_doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"# + ); + std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc"); + let out = Command::new(env!("CARGO_BIN_EXE_aura")) - .args([ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - "proj-anchor", - ]) + .args(["exec", "../anchor.campaign.json"]) .current_dir(&sub) .output() - .expect("spawn aura sweep"); + .expect("spawn aura exec"); assert!( out.status.success(), - "sweep failed: {}", + "exec failed: {}", String::from_utf8_lossy(&out.stderr) ); assert!( diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 38922f4..9472ece 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -319,34 +319,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() { ]); // Seed one real, content-addressed blueprint into the project's own - // store via a real sweep over its bound params, which the axes reopen - // as overridable defaults (#246) (mirrors `project_load.rs`'s anchor - // test). - let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let (sweep_out, sweep_code) = run_code_in( - &dir, - &[ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - "campaign-ref-seed", - ], - ); - assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}"); - let bp_id = std::fs::read_dir(runs_dir.join("blueprints")) - .expect("blueprints dir") - .next() - .expect("one stored blueprint") - .expect("dir entry") - .path() - .file_stem() - .expect("stem") - .to_string_lossy() - .into_owned(); + // store via `aura graph register` (#319 — the retired `sweep` + // side-effect seeding this used to ride). + let bp_id = seed_blueprint(&dir, "campaign-ref-seed"); // Register a valid AND executable process into the same project store: // this test's OK campaign must pass all three validate tiers, and @@ -441,32 +416,8 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit ]); // Seed one real, content-addressed blueprint (mirrors the content_id - // sibling test above). - let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let (sweep_out, sweep_code) = run_code_in( - &dir, - &[ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - "campaign-identity-seed", - ], - ); - assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}"); - let bp_id = std::fs::read_dir(runs_dir.join("blueprints")) - .expect("blueprints dir") - .next() - .expect("one stored blueprint") - .expect("dir entry") - .path() - .file_stem() - .expect("stem") - .to_string_lossy() - .into_owned(); + // sibling test above; #319 — the retired `sweep` side-effect seeding). + let bp_id = seed_blueprint(&dir, "campaign-identity-seed"); let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json")); // The identity id of the SAME stored bytes — `--content-id FILE @@ -1278,35 +1229,26 @@ fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() { ); } -/// Seed one blueprint (its params bound, reopened by the sweep axes per -/// #246) into the built demo project's store via a real sweep and return -/// its content id (the referential test's recipe). +/// Seed one blueprint into the built demo project's store via `aura graph +/// register` and return its content id (the referential test's recipe). +/// `#319` retired the `sweep`-side-effect seeding this used to ride (its +/// `--axis`/reopen mechanics played no role in the returned content id, +/// which is a pure function of the loaded blueprint's canonical bytes); +/// `name` labels the registered id for readability/uniqueness across call +/// sites, mirroring the old per-call sweep family name. fn seed_blueprint(dir: &Path, name: &str) -> String { let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); - let (out, code) = run_code_in( - dir, - &[ - "sweep", - &closed_bp, - "--axis", - "fast.length=2,4", - "--axis", - "slow.length=8,16", - "--name", - name, - ], - ); - assert_eq!(code, Some(0), "seed sweep failed: {out}"); - std::fs::read_dir(dir.join("runs").join("blueprints")) - .expect("blueprints dir") + let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]); + assert_eq!(code, Some(0), "seed register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') .next() - .expect("one stored blueprint") - .expect("dir entry") - .path() - .file_stem() - .expect("stem") - .to_string_lossy() - .into_owned() + .expect("id") + .trim_start_matches("content:") + .to_string() } /// Register `doc` as a process document in the project store; returns its id.