diff --git a/crates/aura-analysis/src/lib.rs b/crates/aura-analysis/src/lib.rs index a78107c..c3af246 100644 --- a/crates/aura-analysis/src/lib.rs +++ b/crates/aura-analysis/src/lib.rs @@ -100,12 +100,15 @@ pub struct RMetrics { pub sqn_normalized: f64, pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0] - /// Realised R per closed trade, in trade order — an in-memory conduit for the - /// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire - /// shape is unchanged, and from `PartialEq` (below) so every existing + /// Cost-netted realised R per closed trade (`r − cost_in_r`), in trade + /// order — an in-memory conduit for the OOS R-series bootstrap. Equals + /// the gross series bit-for-bit when no cost model is bound (an empty + /// cost stream is the documented gross-R baseline: cost `0.0` per + /// trade). Excluded from serde (`skip`) so the C18 wire shape is + /// unchanged, and from `PartialEq` (below) so every existing /// `RMetrics`/`RunReport` equality assertion and round-trip stays green. #[serde(skip)] - pub trade_rs: Vec, + pub net_trade_rs: Vec, } impl PartialEq for RMetrics { @@ -122,7 +125,7 @@ impl PartialEq for RMetrics { && self.sqn_normalized == o.sqn_normalized && self.net_expectancy_r == o.net_expectancy_r && self.conviction_terciles_r == o.conviction_terciles_r - // trade_rs deliberately excluded + // net_trade_rs deliberately excluded } } @@ -229,7 +232,7 @@ pub fn summarize_r( sqn_normalized: 0.0, net_expectancy_r: 0.0, conviction_terciles_r: [0.0; 3], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), }; } let rs: Vec = trades.iter().map(|t| t.r).collect(); @@ -277,6 +280,10 @@ pub fn summarize_r( // an empty stream is the gross-R baseline — every trade's cost is 0.0). let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum(); let net_expectancy_r = net_sum / n as f64; + // The per-trade net series the OOS bootstrap conduit carries (#259). A + // separate materialization, deliberately NOT factored through `net_sum`: + // that expression's tokens are byte-pinned (see the SQN note above). + let net_rs: Vec = trades.iter().map(|t| t.r - t.cost).collect(); // conviction terciles: sort by conviction_at_entry ascending, split into three contiguous // near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s. let conviction_terciles_r = if n < 3 { @@ -311,7 +318,7 @@ pub fn summarize_r( sqn_normalized, net_expectancy_r, conviction_terciles_r, - trade_rs: rs, + net_trade_rs: net_rs, } } @@ -324,7 +331,8 @@ pub fn summarize_r( /// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy /// without the other and that test is the sole tripwire. The /// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`, -/// `net_expectancy_r = expectancy_r` (exact under the cost = 0 invariant), +/// `net_expectancy_r = expectancy_r` (exact: the input series is already +/// cost-netted — the conduit carries `r − cost_in_r`), /// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty /// input -> a well-defined all-zero `RMetrics`. pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { @@ -334,7 +342,7 @@ pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0, - conviction_terciles_r: [0.0; 3], trade_rs: Vec::new(), + conviction_terciles_r: [0.0; 3], net_trade_rs: Vec::new(), }; } let sum: f64 = rs.iter().sum(); @@ -375,9 +383,9 @@ pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { n_open_at_end: 0, sqn, sqn_normalized, - net_expectancy_r: mean, // cost = 0 -> net == gross (frictionless) + net_expectancy_r: mean, // input series is already net -> net == mean conviction_terciles_r: [0.0; 3], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), } } @@ -832,6 +840,30 @@ mod tests { assert!((m.net_expectancy_r - 0.0).abs() < 1e-12); } + /// Property (#259): the bootstrap conduit `net_trade_rs` carries the + /// COST-NETTED per-trade R (`r − cost_in_r`) in trade order — the same + /// per-trade values `net_expectancy_r` averages — while every gross + /// scalar stays cost-free. With an empty cost stream the conduit equals + /// the gross series bit-for-bit (cost 0.0 per trade), which is what + /// keeps uncosted campaigns byte-identical. + #[test] + fn summarize_r_net_trade_rs_is_the_cost_netted_series_in_trade_order() { + let c = 2.0_f64; + let record = vec![ + (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)), + (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)), + ]; + let cost = vec![ + (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]), + (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]), + ]; + let m = summarize_r(&record, &cost); + assert_eq!(m.net_trade_rs, vec![1.0 - 0.5, 0.5 - 1.0], "net conduit = r − cost, trade order"); + assert!((m.expectancy_r - 0.75).abs() < 1e-12, "gross scalars stay cost-free"); + let gross = summarize_r(&record, &[]); + assert_eq!(gross.net_trade_rs, vec![1.0, 0.5], "empty cost stream ⇒ conduit == gross series"); + } + // One PositionManagement dense record row for the derive tests: only the columns // derive_position_events reads (closed, direction, size, open) are set; the rest // default to 0. Distinct per-row timestamps (the derive keys events on the row's @@ -949,7 +981,7 @@ mod tests { sqn_normalized: 1.0, net_expectancy_r: 0.4, conviction_terciles_r: [-0.5, 0.5, 1.5], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), }), }; let json = serde_json::to_string(&m).expect("serialize"); @@ -997,25 +1029,25 @@ mod tests { } #[test] - fn summarize_r_populates_trade_rs_in_trade_order() { + fn summarize_r_populates_net_trade_rs_in_trade_order() { // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement - // record; trade_rs must carry [2.0, -1.0] in trade order. + // record; net_trade_rs must carry [2.0, -1.0] in trade order. let rec = pm_record_two_closed_trades(); // helper below let m = summarize_r(&rec, &[]); - assert_eq!(m.trade_rs, vec![2.0, -1.0]); + assert_eq!(m.net_trade_rs, vec![2.0, -1.0]); assert_eq!(m.n_trades, 2); } #[test] - fn summarize_r_empty_record_has_empty_trade_rs() { + fn summarize_r_empty_record_has_empty_net_trade_rs() { let m = summarize_r(&[], &[]); - assert!(m.trade_rs.is_empty()); + assert!(m.net_trade_rs.is_empty()); assert_eq!(m.n_trades, 0); } /// Property: a position still open on the last row is folded into the trade /// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s - /// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it. + /// `net_trade_rs` carries that synthetic open trade's R and `n_trades` counts it. /// This is the one case where the two reducers' inputs differ in meaning — it /// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`, /// so the two must agree on the R-distribution arithmetic for an @@ -1024,12 +1056,12 @@ mod tests { fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() { let rec = pm_record_closed_then_open_at_end(); let m = summarize_r(&rec, &[]); - assert_eq!(m.trade_rs, vec![2.0, 0.5]); + assert_eq!(m.net_trade_rs, vec![2.0, 0.5]); assert_eq!(m.n_trades, 2); assert_eq!(m.n_open_at_end, 1); // Feed the open-at-end pooled series through the flat reducer: the // R-distribution fields (the verbatim-copied arithmetic) must agree. - let pooled = r_metrics_from_rs(&m.trade_rs); + let pooled = r_metrics_from_rs(&m.net_trade_rs); assert_eq!(pooled.n_trades, m.n_trades); assert_eq!(pooled.expectancy_r, m.expectancy_r); assert_eq!(pooled.win_rate, m.win_rate); @@ -1042,25 +1074,25 @@ mod tests { } #[test] - fn rmetrics_partial_eq_ignores_trade_rs() { - // two RMetrics equal in every metric but differing in trade_rs compare EQUAL - // (trade_rs is an in-memory conduit, excluded from equality) — this is what - // keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped, + fn rmetrics_partial_eq_ignores_net_trade_rs() { + // two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL + // (net_trade_rs is an in-memory conduit, excluded from equality) — this is what + // keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped, // so it deserializes empty). let a = summarize_r(&pm_record_two_closed_trades(), &[]); let mut b = a.clone(); - b.trade_rs = Vec::new(); + b.net_trade_rs = Vec::new(); assert_eq!(a, b); } #[test] - fn populated_trade_rs_is_absent_from_serialized_json() { - // the in-memory conduit must never reach the wire: a populated trade_rs is + fn populated_net_trade_rs_is_absent_from_serialized_json() { + // the in-memory conduit must never reach the wire: a populated net_trade_rs is // dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139). let m = summarize_r(&pm_record_two_closed_trades(), &[]); - assert_eq!(m.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated"); + assert_eq!(m.net_trade_rs, vec![2.0, -1.0], "precondition: net_trade_rs is populated"); let json = serde_json::to_string(&m).expect("RMetrics serializes"); - assert!(!json.contains("trade_rs"), "trade_rs must not reach the wire: {json}"); + assert!(!json.contains("net_trade_rs"), "net_trade_rs must not reach the wire: {json}"); } /// A minimal dense PositionManagement record with two closed trades at R = +2, -1. diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index 6949450..0466ae9 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -18,9 +18,8 @@ use aura_engine::{ }; use aura_registry::{ derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau, - sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, - CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization, - StageSelection, + sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind, + PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection, }; use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode}; @@ -232,6 +231,31 @@ pub fn execute( Ok(CampaignOutcome { record, run, cells: cells_out }) } +/// The inter-stage population crossing `run_cell`'s seam: parameter points +/// only (from `std::grid`) or executed members (from `std::sweep`). Preflight +/// guarantees report-consuming stages (`std::gate`, `std::monte_carlo`'s +/// per-survivor arm) never see `Points`; the defensive faults below keep that +/// invariant loud if the shape rules ever drift. +enum StageFlow { + /// (ordinal into the nearest preceding family_id-bearing stage's family, + /// param point, member report) — the shape preflight established before + /// std::sweep populated it. + Members(Vec<(usize, Vec, RunReport)>), + Points(Vec>), +} + +impl StageFlow { + /// Every flow can cross the seam as bare points — the walk_forward need. + fn points(&self) -> Vec> { + match self { + StageFlow::Points(points) => points.clone(), + StageFlow::Members(members) => { + members.iter().map(|(_, point, _)| point.clone()).collect() + } + } + } +} + /// Run one cell's pipeline: stages in doc order, a realized prefix that stops /// at an empty gate (decision 8 — the cell truncates, the campaign continues). fn run_cell( @@ -247,15 +271,27 @@ fn run_cell( let mut families: Vec = Vec::new(); let mut selections: Vec = Vec::new(); let mut stages: Vec = Vec::new(); - // The surviving population: (ordinal into the nearest preceding - // family_id-bearing stage's family, param point, member report). - let mut survivors: Vec<(usize, Vec, RunReport)> = Vec::new(); + // The surviving population crossing the stage seam (see `StageFlow`). + let mut flow = StageFlow::Members(Vec::new()); // The cell's nominated generalize candidate: last wf window's OOS report // when a walk_forward ran, else the sweep winner; None when truncated. let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None; for (stage_ordinal, stage) in process.pipeline.iter().enumerate() { match stage { + StageBlock::Grid => { + // Enumerate-only (#256 fork B): the grid's parameter points + // cross the seam; nothing executes, nothing persists. + flow = StageFlow::Points(grid.points.clone()); + stages.push(StageRealization { + block: "std::grid".to_string(), + family_id: None, + survivor_ordinals: None, + selection: None, + bootstrap: None, + window_faults: vec![], + }); + } StageBlock::Sweep { selection } => { // Deterministic, self-describing family name (the "-{run}" // suffix is appended by the registry). @@ -263,7 +299,14 @@ fn run_cell( "{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}", cell.strategy_ordinal, cell.instrument, cell.regime_ordinal, ); - let family = run_members(cell, &grid, runner)?; + let family = match run_members(cell, &grid, runner) { + Ok(family) => family, + Err(fault) => { + // Registry etc. still aborts (contain's Err arm). + let cf = contain(fault, stage_ordinal)?; + return Ok(failed_cell(cell, stages, families, selections, cf, runner)); + } + }; let family_id = registry .append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family)) .map_err(ExecFault::Registry)?; @@ -300,6 +343,7 @@ fn run_cell( survivor_ordinals: None, selection: Some(StageSelection { winner_ordinal, params, selection }), bootstrap: None, + window_faults: vec![], }); } None => { @@ -313,17 +357,20 @@ fn run_cell( survivor_ordinals: None, selection: None, bootstrap: None, + window_faults: vec![], }); } } // The whole family flows on (decision 3: `select` names the // recorded selection, never a filter). - survivors = family - .points - .iter() - .enumerate() - .map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone())) - .collect(); + flow = StageFlow::Members( + family + .points + .iter() + .enumerate() + .map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone())) + .collect(), + ); families.push(StageFamily { stage: stage_ordinal, block: "std::sweep", @@ -338,13 +385,21 @@ fn run_cell( // None`) fails the member — conservative and deterministic // (the metric NAME was already preflighted against // `PER_MEMBER_METRICS`). - survivors.retain(|(_, _, report)| { + let StageFlow::Members(members) = &mut flow else { + return Err(ExecFault::PipelineShape { + detail: format!( + "stage {stage_ordinal}: std::gate needs executed \ + members (preflight admits no std::grid predecessor)" + ), + }); + }; + members.retain(|(_, _, report)| { all.iter().all(|p| { member_metric(report, &p.metric) .is_some_and(|value| predicate_holds(&p.cmp, value, p.value)) }) }); - let ordinals: Vec = survivors.iter().map(|(i, _, _)| *i).collect(); + let ordinals: Vec = members.iter().map(|(i, _, _)| *i).collect(); let empty = ordinals.is_empty(); stages.push(StageRealization { block: "std::gate".to_string(), @@ -352,6 +407,7 @@ fn run_cell( survivor_ordinals: Some(ordinals), selection: None, bootstrap: None, + window_faults: vec![], }); if empty { nominee = None; // a truncated cell nominates no candidate @@ -368,9 +424,8 @@ fn run_cell( // The seam crossing is plain points: the walk-forward stage // re-sweeps the surviving param points and never needs the // gate bookkeeping's ordinals or reports. - let survivor_points: Vec> = - survivors.iter().map(|(_, point, _)| point.clone()).collect(); - let fam = run_walk_forward_stage( + let survivor_points: Vec> = flow.points(); + match run_walk_forward_stage( seed, cell, stage_ordinal, @@ -381,22 +436,31 @@ fn run_cell( &family_name, runner, registry, - )?; - stages.push(StageRealization { - block: "std::walk_forward".to_string(), - family_id: Some(fam.family_id.clone()), - survivor_ordinals: None, - selection: None, - bootstrap: None, - }); - // Nominate the LAST window's OOS report (roll order per - // walkforward_member_reports) with its chosen params — the wf - // stage stamps them on the fresh OOS report's manifest. - nominee = fam - .reports - .last() - .map(|last| (last.manifest.params.clone(), last.clone())); - families.push(fam); + ) { + Ok(WfStageOutcome::Ran { family: fam, window_faults }) => { + stages.push(StageRealization { + block: "std::walk_forward".to_string(), + family_id: Some(fam.family_id.clone()), + survivor_ordinals: None, + selection: None, + bootstrap: None, + window_faults, + }); + // Nominate the LAST window's OOS report (roll order, + // over the surviving folds) with its chosen params — + // the wf stage stamps them on the fresh OOS report's + // manifest. + nominee = fam + .reports + .last() + .map(|last| (last.manifest.params.clone(), last.clone())); + families.push(fam); + } + Ok(WfStageOutcome::CellFailed(cf)) => { + return Ok(failed_cell(cell, stages, families, selections, cf, runner)); + } + Err(e) => return Err(e), // Registry / global refusal + } } StageBlock::MonteCarlo { resamples, block_len } => { // Terminal annotator (#200 d1/d3): nothing flows out. Dual @@ -407,7 +471,7 @@ fn run_cell( let bootstrap = match families.iter().rev().find(|f| f.block == "std::walk_forward") { Some(fam) => StageBootstrap::PooledOos(r_bootstrap( - &pooled_trade_rs(&fam.reports), + &pooled_net_trade_rs(&fam.reports), *resamples as usize, *block_len as usize, seed, @@ -415,28 +479,39 @@ fn run_cell( // Zero-trade members (r: None) feed an empty slice — // the engine's defined all-zero degenerate, recorded // explicitly (a null result is a valid result). - None => StageBootstrap::PerSurvivor( - survivors - .iter() - .map(|(ordinal, _, report)| { - let rs = report - .metrics - .r - .as_ref() - .map(|r| r.trade_rs.as_slice()) - .unwrap_or(&[]); - ( - *ordinal, - r_bootstrap( - rs, - *resamples as usize, - *block_len as usize, - seed, - ), - ) - }) - .collect(), - ), + None => { + let StageFlow::Members(members) = &flow else { + return Err(ExecFault::PipelineShape { + detail: format!( + "stage {stage_ordinal}: std::monte_carlo \ + per-survivor bootstrap needs executed members \ + (preflight admits no std::grid predecessor)" + ), + }); + }; + StageBootstrap::PerSurvivor( + members + .iter() + .map(|(ordinal, _, report)| { + let rs = report + .metrics + .r + .as_ref() + .map(|r| r.net_trade_rs.as_slice()) + .unwrap_or(&[]); + ( + *ordinal, + r_bootstrap( + rs, + *resamples as usize, + *block_len as usize, + seed, + ), + ) + }) + .collect(), + ) + } }; stages.push(StageRealization { block: "std::monte_carlo".to_string(), @@ -444,6 +519,7 @@ fn run_cell( survivor_ordinals: None, selection: None, bootstrap: Some(bootstrap), + window_faults: vec![], }); } StageBlock::Generalize { .. } => { @@ -462,6 +538,8 @@ fn run_cell( window_ms: cell.window_ms, regime: cell.regime, regime_ordinal: cell.regime_ordinal, + fault: None, + coverage: runner.window_coverage(cell), stages, }, )) @@ -500,6 +578,54 @@ fn enumerate_grid(axes: &BTreeMap) -> SweepGrid { SweepGrid { specs, axis_lens, points } } +/// Ref-counted panic-hook silencer: while at least one `SilencedPanic` guard +/// is alive, the process's panic hook is a no-op, so a member panic CONTAINED +/// by one of this module's three `catch_unwind` sites (recorded as a +/// `MemberFault`/`WindowFault`, the run continuing past it) does not also +/// paint a crash-looking default backtrace to stderr — "recorded, campaign +/// continues" stays observably true, not just true in the record. Ref-counted +/// rather than a bare set/restore because `sweep`/`walk_forward` run these +/// closures on multiple OS threads concurrently (C1 disjoint-parallel +/// members): a naive per-closure set_hook/take_hook pair would race two +/// threads' install/restore against each other. The previous hook (whatever +/// it was — default or a caller's own) is saved on the 0→1 transition and +/// restored on the 1→0 transition, so this never permanently clobbers a +/// hook installed above this module. +struct SilencedPanic; + +#[allow(clippy::type_complexity)] +static PANIC_HOOK_STATE: Mutex<(usize, Option) + Sync + Send>>)> = + Mutex::new((0, None)); + +impl SilencedPanic { + /// Enter the silenced section; drop the returned guard to leave it. Hold + /// this only around the `catch_unwind` call itself, never the whole + /// member-run closure, so a genuinely uncontained escape (this module's + /// own bug, not the member's) still surfaces normally once the guard has + /// dropped and unwinding continues past `catch_unwind`. + fn enter() -> Self { + let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock"); + if state.0 == 0 { + state.1 = Some(std::panic::take_hook()); + std::panic::set_hook(Box::new(|_| {})); + } + state.0 += 1; + SilencedPanic + } +} + +impl Drop for SilencedPanic { + fn drop(&mut self) { + let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock"); + state.0 -= 1; + if state.0 == 0 + && let Some(prev) = state.1.take() + { + std::panic::set_hook(prev); + } + } +} + /// Run every grid point through the consumer's `MemberRunner` via the engine /// `sweep` (disjoint parallel members, C1 enumeration order). Faults are /// captured per slot (the closure must return a report), and the LOWEST @@ -526,9 +652,15 @@ fn run_members( let faults: Mutex> = Mutex::new(Vec::new()); let family = sweep(&space, |point: &[Cell]| { let params = zip_params(&grid.specs, point); - match runner.run_member(cell, ¶ms, cell.window_ms) { - Ok(report) => report, - Err(fault) => { + let called = { + let _silence = SilencedPanic::enter(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runner.run_member(cell, ¶ms, cell.window_ms) + })) + }; + match called { + Ok(Ok(report)) => report, + Ok(Err(fault)) => { let index = cells .iter() .position(|c| c.as_slice() == point) @@ -536,6 +668,17 @@ fn run_members( faults.lock().expect("fault capture lock").push((index, fault)); placeholder_report(¶ms, cell.window_ms) } + Err(payload) => { + let index = cells + .iter() + .position(|c| c.as_slice() == point) + .expect("the sweep only enumerates the grid's own points"); + faults + .lock() + .expect("fault capture lock") + .push((index, MemberFault::Panic(panic_message(payload)))); + placeholder_report(¶ms, cell.window_ms) + } } }); let mut captured = faults.into_inner().expect("fault capture lock"); @@ -546,6 +689,97 @@ fn run_members( Ok(family) } +/// #272: which executor faults are per-cell outcomes vs campaign-wide +/// refusals. Member and window faults are data-and-cell-local; everything else +/// (doc-shape, registry) stays a global refusal returned to the caller. +fn contain(fault: ExecFault, stage: usize) -> Result { + match fault { + ExecFault::Member(m) => Ok(aura_registry::CellFault { + stage, + kind: member_kind(&m), + detail: member_fault_prose(&m), + }), + ExecFault::Window { stage: s, detail } => Ok(aura_registry::CellFault { + stage: s, + kind: aura_registry::CellFaultKind::Window, + detail, + }), + other => Err(other), + } +} + +fn member_kind(m: &MemberFault) -> aura_registry::CellFaultKind { + use aura_registry::CellFaultKind as K; + match m { + MemberFault::NoData { .. } => K::NoData, + MemberFault::Bind(_) => K::Bind, + MemberFault::Run(_) => K::Run, + MemberFault::Panic(_) => K::Panic, + } +} + +/// Prose for a member fault. `pub` (re-exported at the crate root) so the +/// CLI's `exec_fault_prose` phrases the `ExecFault::Member` arm by calling +/// this directly instead of carrying its own duplicate of the wording. +/// Debug-leak-free, self-describing. +pub fn member_fault_prose(m: &MemberFault) -> String { + match m { + MemberFault::NoData { instrument, window_ms } => format!( + "no data for instrument {instrument} in window [{}, {}] (epoch-ms)", + window_ms.0, window_ms.1 + ), + MemberFault::Bind(detail) => format!("a member failed to bind: {detail}"), + MemberFault::Run(detail) => format!("a member failed to run: {detail}"), + MemberFault::Panic(detail) => format!("a member panicked: {detail}"), + } +} + +/// Best-effort panic-payload message (String / &str payloads; else a fixed +/// marker). `AssertUnwindSafe` at the call sites is a documented judgement: the +/// runner is reused for later cells; the CLI runner holds read-only state. +fn panic_message(payload: Box) -> String { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "member panicked (non-string payload)".to_string()) +} + +/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a +/// stage (#272): the realized stage prefix survives in `stages`, `families`/ +/// `selections` carry whatever already-persisted stages produced (empty for a +/// sweep-first fault; the sweep family/selection for a later wf-stage fault), +/// no nominee, the fault stamped on both, coverage from the runner. +fn failed_cell( + cell: &CellSpec, + stages: Vec, + families: Vec, + selections: Vec, + fault: aura_registry::CellFault, + runner: &dyn MemberRunner, +) -> (CellOutcome, CellRealization) { + ( + CellOutcome { families, selections, nominee: None }, + CellRealization { + strategy: cell.strategy_id.clone(), + instrument: cell.instrument.clone(), + window_ms: cell.window_ms, + stages, + regime: cell.regime, + regime_ordinal: cell.regime_ordinal, + fault: Some(fault), + coverage: runner.window_coverage(cell), + }, + ) +} + +/// Sentinel `RunManifest::broker` value stamped onto [`placeholder_report`]'s +/// output — the single source both the setter and the survivor filter +/// (`run_members`'s `... != FAULTED_MEMBER_PLACEHOLDER_BROKER` sibling) read, +/// so the two can never drift apart into silently pooling a faulted +/// placeholder into a real family/OOS selection. +const FAULTED_MEMBER_PLACEHOLDER_BROKER: &str = "faulted-member-placeholder"; + /// Slot filler for a faulted member: the engine sweep contract needs one /// report per slot, but a captured fault aborts `execute` before any family /// write, so this report is never persisted and never ranked. @@ -557,7 +791,7 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run defaults: Vec::new(), window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), seed: 0, - broker: "faulted-member-placeholder".to_string(), + broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(), selection: None, instrument: None, topology_hash: None, @@ -627,15 +861,16 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec { space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect() } -/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs` +/// The pooled walk-forward OOS trade-R series: the family reports' `net_trade_rs` /// concatenated in report order — which IS roll order (the wf stage builds its -/// family via `walkforward_member_reports`, per-window OOS reports in roll -/// order). Reports without an R block contribute nothing. -fn pooled_trade_rs(reports: &[RunReport]) -> Vec { +/// family via `walkforward_member_reports_of`, per-window OOS reports in roll +/// order, over the surviving folds). Reports without an R block contribute +/// nothing. +fn pooled_net_trade_rs(reports: &[RunReport]) -> Vec { let mut pooled = Vec::new(); for report in reports { if let Some(r) = &report.metrics.r { - pooled.extend_from_slice(&r.trade_rs); + pooled.extend_from_slice(&r.net_trade_rs); } } pooled @@ -678,7 +913,7 @@ fn run_walk_forward_stage( family_name: &str, runner: &dyn MemberRunner, registry: &Registry, -) -> Result { +) -> Result { let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } = block else { @@ -757,9 +992,16 @@ fn run_walk_forward_stage( // with the same member fault capture the sweep stage uses. let is_faults: Mutex> = Mutex::new(Vec::new()); let is_family = sweep(&is_space, |cells| { - match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) { - Ok(report) => report, - Err(fault) => { + let params = zip_params(specs, cells); + let called = { + let _silence = SilencedPanic::enter(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runner.run_member(cell, ¶ms, (w.is.0 .0, w.is.1 .0)) + })) + }; + match called { + Ok(Ok(report)) => report, + Ok(Err(fault)) => { let midx = is_points .iter() .position(|p| p.as_slice() == cells) @@ -767,6 +1009,14 @@ fn run_walk_forward_stage( is_faults.lock().unwrap().push((midx, fault)); placeholder_report(&[], (0, 0)) } + Err(payload) => { + let midx = is_points + .iter() + .position(|p| p.as_slice() == cells) + .expect("sweep enumerates exactly is_points"); + is_faults.lock().unwrap().push((midx, MemberFault::Panic(panic_message(payload)))); + placeholder_report(&[], (0, 0)) + } } }); let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock"); @@ -789,29 +1039,93 @@ fn run_walk_forward_stage( // OOS: run the winner over the out-of-sample bounds; the selection is // stamped on the fresh OOS report (a new record, never a mutation of // a stored one). - let mut oos_report = match runner.run_member( - cell, - &zip_params(specs, &winner.params), - (w.oos.0 .0, w.oos.1 .0), - ) { - Ok(report) => report, - Err(fault) => { + let oos_params = zip_params(specs, &winner.params); + let oos_called = { + let _silence = SilencedPanic::enter(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runner.run_member(cell, &oos_params, (w.oos.0 .0, w.oos.1 .0)) + })) + }; + let mut oos_report = match oos_called { + Ok(Ok(report)) => report, + Ok(Err(fault)) => { faults.lock().unwrap().push((widx, ExecFault::Member(fault))); return placeholder_window_run(specs); } + Err(payload) => { + faults + .lock() + .unwrap() + .push((widx, ExecFault::Member(MemberFault::Panic(panic_message(payload))))); + return placeholder_window_run(specs); + } }; oos_report.manifest.selection = Some(selection); WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report } }); - let window_faults = faults.into_inner().expect("no thread panicked holding the lock"); - if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) { - return Err(fault); + // Registry faults (optimizer/persistence broken) stay a global abort; + // Member/Window faults become per-fold WindowFaults via the same + // `contain` mapping the cell-level containment uses. One drain, in + // ascending window-index order — ExecFault/RegistryError are not Clone, + // so this is a single ownership-respecting pass rather than iter()-then- + // into_iter(); the first Registry fault encountered (ascending order = + // lowest index) wins, matching the existing lowest-index-fault convention. + let mut captured = faults.into_inner().expect("no thread panicked holding the lock"); + captured.sort_by_key(|(idx, _)| *idx); + let mut window_faults: Vec = Vec::with_capacity(captured.len()); + for (widx, fault) in captured { + match contain(fault, stage) { + Ok(cf) => window_faults.push(aura_registry::WindowFault { + window_ordinal: widx, + kind: cf.kind, + detail: cf.detail, + }), + Err(global) => return Err(global), + } } - let reports = walkforward_member_reports(&result); + // Keep only real OOS reports (drop faulted-fold placeholders by the broker + // sentinel) before building the family. + let surviving: Vec<&aura_engine::WindowOutcome> = result + .windows + .iter() + .filter(|w| w.run.oos_report.manifest.broker != FAULTED_MEMBER_PLACEHOLDER_BROKER) + .collect(); + if surviving.is_empty() { + // Every fold failed — the cell fails at this stage. + let first = window_faults.first().cloned(); + return Ok(WfStageOutcome::CellFailed(aura_registry::CellFault { + stage, + kind: first.as_ref().map(|w| w.kind).unwrap_or(aura_registry::CellFaultKind::Run), + detail: format!( + "all {} walk_forward folds failed{}", + result.windows.len(), + first.map(|w| format!("; first: {}", w.detail)).unwrap_or_default() + ), + })); + } + let reports = walkforward_member_reports_of(&surviving); let family_id = registry .append_family(family_name, FamilyKind::WalkForward, &reports) .map_err(ExecFault::Registry)?; - Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports }) + Ok(WfStageOutcome::Ran { + family: StageFamily { stage, block: "std::walk_forward", family_id, reports }, + window_faults, + }) +} + +/// #272: a wf stage either ran (its family + any per-fold holes) or the whole +/// cell failed (every fold faulted). A Registry fault is a separate global Err +/// (returned directly from `run_walk_forward_stage`, never wrapped here). +enum WfStageOutcome { + Ran { family: StageFamily, window_faults: Vec }, + CellFailed(aura_registry::CellFault), +} + +/// The surviving (non-faulted) walk-forward windows' OOS reports, in roll +/// order — the same mapping `walkforward_member_reports` does over a full +/// result, applied to the filtered survivor slice. +fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome]) -> Vec { + windows.iter().map(|w| w.run.oos_report.clone()).collect() } diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 0907e87..b430738 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -12,7 +12,9 @@ //! and no UI. mod exec; -pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut}; +pub use exec::{ + execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut, +}; use std::collections::BTreeMap; @@ -54,6 +56,13 @@ pub trait MemberRunner: Sync { params: &[(String, Scalar)], window_ms: (i64, i64), ) -> Result; + + /// The archive coverage of this cell's (instrument, window), when the + /// runner can know it and it deviates from the requested window (#272). + /// One call per cell — coverage is a property of the cell, not a member. + fn window_coverage(&self, _cell: &CellSpec) -> Option { + None + } } /// Display-free member faults (the consumer phrases them — the RefFault @@ -63,6 +72,9 @@ pub enum MemberFault { NoData { instrument: String, window_ms: (i64, i64) }, Bind(String), Run(String), + /// A panic that unwound out of `MemberRunner::run_member`, caught at the + /// member boundary (#272) — the payload's best-effort message. + Panic(String), } /// Preflight + runtime refusals. Display-free and by-identifier: the @@ -196,6 +208,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe fn shape_rank(stage: &StageBlock) -> usize { match stage { StageBlock::Sweep { .. } => 0, + StageBlock::Grid => 0, StageBlock::Gate { .. } => 1, StageBlock::WalkForward { .. } => 2, StageBlock::MonteCarlo { .. } => 3, @@ -205,6 +218,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe fn block_id(stage: &StageBlock) -> &'static str { match stage { StageBlock::Sweep { .. } => "std::sweep", + StageBlock::Grid => "std::grid", StageBlock::Gate { .. } => "std::gate", StageBlock::WalkForward { .. } => "std::walk_forward", StageBlock::MonteCarlo { .. } => "std::monte_carlo", @@ -216,16 +230,33 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe // every violation: a rank drop is an out-of-order stage (an annotator // before a population stage, anything after std::generalize), an adjacent // equal rank above the gate tier is a duplicate suffix stage. - if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) { + if !matches!( + process.pipeline.first(), + Some(StageBlock::Sweep { .. } | StageBlock::Grid) + ) { return Err(ExecFault::PipelineShape { - detail: "the first stage must be std::sweep".to_string(), + detail: "the first stage must be std::sweep or std::grid".to_string(), + }); + } + // #256 fork B: an enumerate-only first stage yields points, not executed + // members — every stage except std::walk_forward consumes member reports, + // so std::grid must be immediately followed by std::walk_forward (in + // particular it is never terminal). + if matches!(process.pipeline.first(), Some(StageBlock::Grid)) + && !matches!(process.pipeline.get(1), Some(StageBlock::WalkForward { .. })) + { + return Err(ExecFault::PipelineShape { + detail: "a std::grid first stage must be immediately followed by \ + std::walk_forward (every other stage consumes executed \ + member reports)" + .to_string(), }); } let mut prev = &process.pipeline[0]; for (i, stage) in process.pipeline.iter().enumerate().skip(1) { - if matches!(stage, StageBlock::Sweep { .. }) { + if matches!(stage, StageBlock::Sweep { .. } | StageBlock::Grid) { return Err(ExecFault::PipelineShape { - detail: format!("stage {i}: only the first stage may be std::sweep"), + detail: format!("stage {i}: only the first stage may be {}", block_id(stage)), }); } let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev)); @@ -248,6 +279,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe // per-stage slot rules (shape already established above). for (i, stage) in process.pipeline.iter().enumerate() { match stage { + StageBlock::Grid => {} StageBlock::Sweep { selection } => match selection { Some(sel) => { if !RANKABLE_METRICS.contains(&sel.metric.as_str()) { @@ -365,7 +397,7 @@ mod tests { sqn: 13.0, net_expectancy_r: 14.0, conviction_terciles_r: [0.0; 3], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), }), }, } @@ -731,6 +763,67 @@ mod tests { assert!(preflight(&process, &c).is_ok()); } + /// #256 fork B: the enumerate-only leading stage is accepted exactly in + /// the dissolved walkforward/mc shapes — first, immediately before + /// std::walk_forward. + #[test] + fn preflight_accepts_a_grid_first_stage_followed_by_walk_forward() { + let c = campaign(); + let wf_shape = + process_of(vec![StageBlock::Grid, wf_stage("sqn_normalized", SelectRule::Argmax)]); + assert!(preflight(&wf_shape, &c).is_ok()); + let mc_shape = process_of(vec![ + StageBlock::Grid, + wf_stage("sqn_normalized", SelectRule::Argmax), + mc_stage(1000, 5), + ]); + assert!(preflight(&mc_shape, &c).is_ok()); + } + + /// #256 fork B: every other placement is refused — grid terminal, grid + /// before a report-consuming stage (gate / monte_carlo / generalize), + /// grid anywhere but first. Every refusal is a PipelineShape prose fault. + #[test] + fn preflight_refuses_grid_placement_violations() { + let c = campaign(); + let c2 = campaign_two_instruments(); + let adjacency = "immediately followed by std::walk_forward"; + let cases: Vec<(Vec, &str, &CampaignDoc)> = vec![ + (vec![StageBlock::Grid], adjacency, &c), + ( + vec![ + StageBlock::Grid, + gate_stage("net_expectancy_r"), + wf_stage("sqn_normalized", SelectRule::Argmax), + ], + adjacency, + &c, + ), + (vec![StageBlock::Grid, mc_stage(1000, 5)], adjacency, &c), + ( + vec![StageBlock::Grid, generalize_stage("net_expectancy_r")], + adjacency, + &c2, + ), + ( + vec![ + sweep_stage("sqn_normalized", SelectRule::Argmax, false), + StageBlock::Grid, + ], + "only the first stage may be std::grid", + &c, + ), + ]; + for (pipeline, needle, camp) in cases { + let err = + preflight(&process_of(pipeline), camp).expect_err("placement violation"); + let ExecFault::PipelineShape { detail } = &err else { + panic!("expected PipelineShape, got {err:?}"); + }; + assert!(detail.contains(needle), "detail {detail:?} misses {needle:?}"); + } + } + /// A selection-free sweep followed by any other stage is refused: a /// downstream stage would have no selection/nominee to consume. #[test] @@ -885,8 +978,10 @@ mod wf_tests { /// A per-test registry in a fresh temp dir (a Registry's family store /// isolates per DIRECTORY, not per filename — see Registry::open). fn wf_registry(name: &str) -> Registry { - let dir = std::env::temp_dir() - .join(format!("aura-campaign-wf-{}-{}", std::process::id(), name)); + // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the + // pre-create wipe below then genuinely reclaims the previous run. + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join(format!("aura-campaign-wf-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); Registry::open(dir.join("runs.jsonl")) @@ -1163,75 +1258,161 @@ mod wf_tests { } } - /// A member fault raised INSIDE a window's in-sample sweep aborts the - /// walk_forward stage with that fault, and among several faulted windows - /// the LOWEST roll index wins (the per-window `min_by_key` attribution - /// mirrors the sweep stage's own lowest-enumeration-index convention) — - /// never the faulted stage's family, since a captured fault returns - /// before any registry write. + /// #272: a member fault raised INSIDE one window's in-sample sweep is + /// contained as that fold's `WindowFault` — the surviving windows still + /// run, pool into the walk_forward family, and the campaign run record + /// persists. Faulting w1's IS run out of 3 windows (w0, w1, w2) leaves + /// exactly 2 surviving OOS reports and one recorded window fault at + /// ordinal 1. #[test] - fn wf_is_member_fault_lowest_window_wins() { + fn wf_one_faulted_fold_is_contained_survivors_pool() { let registry = wf_registry("is-fault"); let campaign = wf_campaign((0, 99)); let process = wf_process(None, 40, 20, 20); - // IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault both w1's and - // w2's IS runs; the widx-1 fault must win over widx-2's. + // IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault only w1's IS + // run; w0 and w2 survive and pool. + let runner = WfFakeRunner::faulty(vec![( + (20, 59), + vec![("len".to_string(), Scalar::i64(4))], + MemberFault::Run("is-w1".to_string()), + )]); + let outcome = run_wf(&campaign, &process, &runner, ®istry) + .expect("a faulted fold is contained, not a stage abort"); + + let fam = outcome.cells[0] + .families + .iter() + .find(|f| f.block == "std::walk_forward") + .expect("wf family present despite the faulted fold"); + assert_eq!(fam.reports.len(), 2, "only the 2 surviving windows pool"); + + let cell = &outcome.record.cells[0]; + let wf_stage = cell + .stages + .iter() + .find(|s| s.block == "std::walk_forward") + .expect("wf stage realized"); + assert_eq!(wf_stage.window_faults.len(), 1); + assert_eq!(wf_stage.window_faults[0].window_ordinal, 1); + assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Run); + assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell"); + + assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1); + let members = registry.load_family_members().expect("load members"); + assert!(members.iter().any(|m| m.kind == FamilyKind::WalkForward)); + } + + /// #272: every fold faulting (both the IS sweep AND the OOS run, across + /// all 3 windows) leaves zero surviving OOS reports — the whole cell + /// fails at the wf stage, with no WalkForward family persisted, while the + /// campaign run record still records the failed cell. + #[test] + fn wf_all_folds_faulted_fails_the_cell() { + let registry = wf_registry("all-folds-fault"); + let campaign = wf_campaign((0, 99)); + let process = wf_process(None, 40, 20, 20); + // IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault every window's + // IS run so no window reaches its OOS call. let runner = WfFakeRunner::faulty(vec![ + ( + (0, 39), + vec![("len".to_string(), Scalar::i64(4))], + MemberFault::Run("is-w0".to_string()), + ), ( (20, 59), - vec![("len".to_string(), Scalar::i64(3))], + vec![("len".to_string(), Scalar::i64(4))], MemberFault::Run("is-w1".to_string()), ), ( (40, 79), - vec![("len".to_string(), Scalar::i64(2))], + vec![("len".to_string(), Scalar::i64(4))], MemberFault::Run("is-w2".to_string()), ), ]); - let err = run_wf(&campaign, &process, &runner, ®istry) - .expect_err("a faulted IS member aborts the walk_forward stage"); - match err { - ExecFault::Member(fault) => assert_eq!( - fault, - MemberFault::Run("is-w1".to_string()), - "the LOWEST window index's fault wins, not w2's later one", - ), - other => panic!("expected ExecFault::Member, got {other:?}"), - } + let outcome = run_wf(&campaign, &process, &runner, ®istry) + .expect("an all-folds-faulted stage fails the cell, not the process"); + + assert!( + outcome.cells[0].families.iter().all(|f| f.block != "std::walk_forward"), + "no WalkForward family is produced when every fold fails" + ); + let cell = &outcome.record.cells[0]; + let fault = cell.fault.as_ref().expect("the cell fails at the wf stage"); + assert_eq!(fault.stage, 1, "walk_forward is pipeline stage 1 here"); + assert!(fault.detail.contains("all 3 walk_forward folds failed")); + + assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1); let members = registry.load_family_members().expect("load members"); assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward)); - assert!(registry.load_campaign_runs().expect("load campaign runs").is_empty()); } - /// A member fault raised running the IS winner over its OUT-of-sample - /// bounds (a call distinct from every IS-sweep call, since the OOS - /// window's bounds differ from every window's IS bounds) also aborts the - /// walk_forward stage with that fault, and — like the IS-sweep path — - /// never persists the faulted stage's family. + /// #272: a member PANIC inside a window's in-sample sweep is caught at the + /// wf member boundary (`catch_unwind`) and contained as that fold's + /// `WindowFault` with kind `panic` — the process does not abort, the + /// surviving folds pool, exactly as an `Err` fault is contained. Guards the + /// wf-path panic arm (the sweep-path arm is covered by `PanicRunner` in + /// tests/execute.rs; this is its walk_forward mirror). #[test] - fn wf_oos_member_fault_aborts_walk_forward() { - let registry = wf_registry("oos-fault"); + fn wf_member_panic_in_a_fold_is_contained() { + // Panics on w1's IS bounds (20,59); every other call returns a report. + struct WfPanicRunner; + impl MemberRunner for WfPanicRunner { + fn run_member( + &self, + _cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + assert_ne!(window_ms, (20, 59), "planted panic on w1's in-sample run"); + let total: f64 = params + .iter() + .map(|(_, s)| match s { + Scalar::I64(v) => *v as f64, + Scalar::F64(v) => *v, + _ => 0.0, + }) + .sum(); + Ok(RunReport { + manifest: RunManifest { + commit: "wf-panic-fake".to_string(), + params: params.to_vec(), + defaults: Vec::new(), + window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), + seed: 0, + broker: "fake".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: RunMetrics { + total_pips: total, + max_drawdown: 0.0, + bias_sign_flips: 0, + r: None, + }, + }) + } + } + let registry = wf_registry("is-panic"); let campaign = wf_campaign((0, 99)); let process = wf_process(None, 40, 20, 20); - // total_pips == len, so the IS winner is always len=4 (argmax over - // [1,2,3,4]); fault only window 0's OOS run of that winner (OOS - // bounds (40,59) match no window's IS bounds and no other window's - // OOS bounds). - let runner = WfFakeRunner::faulty(vec![( - (40, 59), - vec![("len".to_string(), Scalar::i64(4))], - MemberFault::Run("oos-w0".to_string()), - )]); - let err = run_wf(&campaign, &process, &runner, ®istry) - .expect_err("a faulted OOS member aborts the walk_forward stage"); - match err { - ExecFault::Member(fault) => { - assert_eq!(fault, MemberFault::Run("oos-w0".to_string())) - } - other => panic!("expected ExecFault::Member, got {other:?}"), - } - let members = registry.load_family_members().expect("load members"); - assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward)); + let strategies = vec![("s".repeat(64), "{}".to_string())]; + let outcome = execute(&"e".repeat(64), &campaign, &process, &strategies, &WfPanicRunner, ®istry) + .expect("a member panic in a fold is contained, not a process abort"); + + let cell = &outcome.record.cells[0]; + let wf_stage = cell + .stages + .iter() + .find(|s| s.block == "std::walk_forward") + .expect("wf stage realized despite the panicked fold"); + assert_eq!(wf_stage.window_faults.len(), 1); + assert_eq!(wf_stage.window_faults[0].window_ordinal, 1); + assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Panic); + assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell"); + assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1); } /// select_sweep_winner's Plateau arms dispatch through the public diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index abab2d6..68d6b40 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -41,9 +41,9 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 { /// The deterministic 4-trade R series a planted report carries (matches /// `n_trades: 4`; non-constant so block resampling has structure). In-memory -/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every +/// only: `net_trade_rs` is serde-skipped and excluded from `PartialEq`, so every /// existing equality and round-trip assertion stays green. -fn planted_trade_rs(net: f64) -> Vec { +fn planted_net_trade_rs(net: f64) -> Vec { vec![net, -net, 2.0 * net, net] } @@ -84,7 +84,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, sqn_normalized: net, net_expectancy_r: net, conviction_terciles_r: [0.0, 0.0, 0.0], - trade_rs: planted_trade_rs(net), + net_trade_rs: planted_net_trade_rs(net), }), }, } @@ -126,6 +126,22 @@ impl MemberRunner for ZeroTradeRunner { } } +/// A runner whose `run_member` always panics — the #272 panic-containment +/// fixture (`catch_unwind` at the member boundary must turn this into a +/// `MemberFault::Panic`, never an unwind out of `execute`). +struct PanicRunner; + +impl MemberRunner for PanicRunner { + fn run_member( + &self, + _cell: &CellSpec, + _params: &[(String, Scalar)], + _window_ms: (i64, i64), + ) -> Result { + panic!("planted member panic") + } +} + /// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest): /// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39. fn axes_2x2() -> BTreeMap { @@ -219,8 +235,8 @@ fn strategies() -> Vec<(String, String)> { /// Per-test registry DIRECTORY (family/campaign stores are per-directory /// siblings of the runs path — the aura-registry temp-dir idiom). fn temp_registry(name: &str) -> Registry { - let dir = std::env::temp_dir() - .join(format!("aura-campaign-exec-{}-{}", std::process::id(), name)); + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("aura-campaign-exec-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp registry dir"); Registry::open(dir.join("runs.jsonl")) @@ -400,34 +416,57 @@ fn execute_is_deterministic_twice() { assert_eq!(runs[1], second.record); } +/// #272: a member fault is contained as a failed cell, not a global abort — +/// the campaign run record still persists (the whole point of containment), +/// while a sweep-faulted cell writes no family (`run_members` returns before +/// `append_family`). #[test] -fn execute_member_fault_aborts_with_lowest_index() { +fn execute_member_fault_records_a_failed_cell_and_continues() { let reg = temp_registry("member_fault"); - let doc = campaign(&["EURUSD"]); + // two instruments; the planted fault is keyed by param point, so both + // cells' sweeps hit it and both fail — the containment property under test + // is that execute returns Ok and records every cell, not a global abort. + let doc = campaign(&["AAA", "BBB"]); let proc_doc = process(vec![sweep_stage(false)]); - // faults planted at enumeration indices 1 (2,9) and 2 (3,6) let point = |fast: i64, slow: i64| { vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))] }; + // FakeRunner faults are matched by param point regardless of instrument; + // plant one on the grid so AAA's (and BBB's) sweep hit it — the whole + // sweep-faulted cell fails (grid hole compromises selection). let runner = FakeRunner { - faults: vec![ - (point(2, 9), MemberFault::Run("boom at index 1".to_string())), - (point(3, 6), MemberFault::Run("boom at index 2".to_string())), - ], + faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))], }; - let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®) - .expect_err("a faulted member aborts the run"); - match err { - ExecFault::Member(fault) => assert_eq!( - fault, - MemberFault::Run("boom at index 1".to_string()), - "the LOWEST enumeration index's fault is reported", - ), - other => panic!("expected ExecFault::Member, got {other:?}"), + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®) + .expect("a faulted member is recorded, not a global abort"); + assert_eq!(out.record.cells.len(), 2, "both cells are recorded"); + for cell in &out.record.cells { + let fault = cell.fault.as_ref().expect("sweep-fault fails the cell"); + assert_eq!(fault.stage, 0); + assert_eq!(fault.kind, aura_registry::CellFaultKind::Run); } - // the fault aborted before any family or realization write + // The run record persisted (the whole point of containment); a sweep-fault + // cell writes no family (run_members returns before append_family). + assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1); assert!(reg.load_family_members().expect("load members").is_empty()); - assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty()); +} + +/// #272: a panic that unwinds out of `MemberRunner::run_member` is caught at +/// the member boundary, not propagated — the cell is recorded failed with a +/// `Panic` kind, and the run record persists. +#[test] +fn execute_member_panic_is_contained_as_a_failed_cell() { + let reg = temp_registry("member_panic"); + let doc = campaign(&["AAA", "BBB"]); + let proc_doc = process(vec![sweep_stage(false)]); + let runner = PanicRunner; + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®) + .expect("a member panic is contained, not a process abort"); + assert!(out.record.cells.iter().all(|c| c + .fault + .as_ref() + .is_some_and(|f| f.kind == aura_registry::CellFaultKind::Panic))); + assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1); } #[test] @@ -483,11 +522,11 @@ fn execute_mc_after_gate_bootstraps_each_survivor() { assert_eq!(mc.selection, None); // each survivor's bootstrap == a hand-called r_bootstrap on its planted - // trade_rs, seeded from the campaign doc (the deflation convention) + // net_trade_rs, seeded from the campaign doc (the deflation convention) let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0; let expected = StageBootstrap::PerSurvivor(vec![ - (2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)), - (3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)), + (2, r_bootstrap(&planted_net_trade_rs(net(3, 6)), 200, 2, 7)), + (3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)), ]); assert_eq!(mc.bootstrap, Some(expected)); @@ -496,6 +535,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() { assert_eq!(runs[0], out.record); } +/// Property (#256 fork B): a `std::grid` first stage crosses the `run_cell` +/// seam as bare parameter points (`StageFlow::Points`) rather than executed +/// members — it persists no family of its own — and the immediately-following +/// `std::walk_forward` stage still sweeps exactly the full axis grid, picking +/// the same per-window winner a selection-carrying `std::sweep` feeding the +/// same wf stage would (`execute_mc_after_wf_pools_the_oos_series`'s +/// `sweep_stage()` + `wf_stage()` shape, minus the intermediate sweep family). +/// A regression that let `StageFlow::Points` drop or mis-order a point before +/// crossing the seam would flip the wf winner or the per-window report count +/// here even though the preflight placement tests (lib.rs) stay green. +#[test] +fn execute_grid_first_stage_feeds_walk_forward_with_all_grid_points() { + let reg = temp_registry("grid_then_wf"); + let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000] + let proc_doc = process(vec![StageBlock::Grid, wf_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("grid-then-wf campaign executes"); + + // std::grid persists no family of its own — only std::walk_forward's does + assert_eq!(out.cells[0].families.len(), 1); + let wf = &out.cells[0].families[0]; + assert_eq!(wf.block, "std::walk_forward"); + + // stage realization: std::grid first (no family/selection), then wf + let cell = &out.record.cells[0]; + assert_eq!(cell.stages.len(), 2); + assert_eq!(cell.stages[0].block, "std::grid"); + assert_eq!(cell.stages[0].family_id, None); + assert_eq!(cell.stages[0].survivor_ordinals, None); + assert_eq!(cell.stages[0].selection, None); + assert_eq!(cell.stages[1].block, "std::walk_forward"); + + // the roller yields 2 windows over the FULL 2x2 grid (nothing filtered + // the population before wf); both pick the planted argmax (3,9) + assert_eq!(wf.reports.len(), 2); + let winner_params = + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))]; + for report in &wf.reports { + assert_eq!(report.manifest.params, winner_params); + } + + // the record round-trips through the store unchanged + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs[0], out.record); +} + #[test] fn execute_mc_after_wf_pools_the_oos_series() { let reg = temp_registry("mc_pooled"); @@ -513,15 +598,15 @@ fn execute_mc_after_wf_pools_the_oos_series() { assert_eq!(wf.block, "std::walk_forward"); assert_eq!(wf.reports.len(), 2); - // pooled input = the wf family reports' trade_rs concatenated in report + // pooled input = the wf family reports' net_trade_rs concatenated in report // order (roll order per walkforward_member_reports) let mut pooled: Vec = Vec::new(); for report in &wf.reports { - pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs); + pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs); } let net = (3 * 10 + 9) as f64 / 100.0; - let mut expected_pool = planted_trade_rs(net); - expected_pool.extend(planted_trade_rs(net)); + let mut expected_pool = planted_net_trade_rs(net); + expected_pool.extend(planted_net_trade_rs(net)); assert_eq!(pooled, expected_pool); let mc = &out.record.cells[0].stages[2]; @@ -677,7 +762,7 @@ fn execute_generalize_only_after_sweep() { /// annotators compose in ONE pipeline without disturbing each other or the /// population stages' generalize-nominee. `std::monte_carlo` sits between /// `std::walk_forward` and `std::generalize` in the pipeline, yet its -/// per-cell bootstrap still pools exactly the wf family's OOS `trade_rs` +/// per-cell bootstrap still pools exactly the wf family's OOS `net_trade_rs` /// (unaffected by generalize running after it), and `std::generalize`'s /// campaign-scope nominee is still the wf stage's last-window winner /// (unaffected by mc having annotated the cell first). A regression that @@ -706,13 +791,13 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() { assert_eq!(cell.stages[3].block, "std::monte_carlo"); } - // mc's bootstrap pools the wf family's OOS trade_rs exactly as it does + // mc's bootstrap pools the wf family's OOS net_trade_rs exactly as it does // with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series) let wf = &out.cells[0].families[1]; assert_eq!(wf.block, "std::walk_forward"); let mut pooled: Vec = Vec::new(); for report in &wf.reports { - pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs); + pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs); } let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7)); assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc)); diff --git a/crates/aura-campaign/tests/member_seam_e2e.rs b/crates/aura-campaign/tests/member_seam_e2e.rs index 8f724a2..121ca40 100644 --- a/crates/aura-campaign/tests/member_seam_e2e.rs +++ b/crates/aura-campaign/tests/member_seam_e2e.rs @@ -125,7 +125,7 @@ fn rankable_metrics_are_all_per_member_resolvable() { sqn: 13.0, net_expectancy_r: 14.0, conviction_terciles_r: [0.0; 3], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), }), }, }; diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index c50942c..c68010f 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -59,6 +59,9 @@ fn stop_rule_for_regime(regime: Option) -> aura_compo Some(aura_research::RiskRegime::Vol { length, k }) => { aura_composites::StopRule::Vol { length, k } } + Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => { + aura_composites::StopRule::VolTf { period_minutes, length, k } + } } } @@ -69,11 +72,11 @@ fn stop_rule_for_regime(regime: Option) -> aura_compo /// cannot diverge from the run-side cost model (the #219 divergence class, /// cost edition). The bound knob names are the builders' own `ParamSpec` names /// — the `CostSpec` serde vocabulary conforms to them. -pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec { +pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec { specs .iter() .map(|s| { - let (knob, v) = cost_knob(s); + let (knob, v) = cost_knob(s, instrument); match s { CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)), CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)), @@ -88,12 +91,19 @@ pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec { /// (main.rs): the stamp key must equal the bind key for reproduce to /// re-derive a `CostSpec` from a stored manifest, so both sites read the /// name off this single function rather than each carrying its own literal. -pub(crate) fn cost_knob(spec: &CostSpec) -> (&'static str, f64) { - match spec { - CostSpec::Constant { cost_per_trade } => ("cost_per_trade", *cost_per_trade), - CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", *slip_vol_mult), - CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", *carry_per_cycle), - } +pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) { + let (knob, value) = match spec { + CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), + CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult), + CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), + }; + let v = value.resolve(instrument).unwrap_or_else(|| { + // Unreachable after intrinsic validation (map keys ≡ instruments); + // refuse loudly rather than charging 0 silently if it ever surfaces. + eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); + std::process::exit(1); + }); + (knob, v) } /// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed @@ -139,16 +149,7 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String { "process stage {stage}: walk_forward windows do not fit the campaign \ window: {detail}" ), - ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!( - "no data for instrument {instrument} in window [{}, {}] (epoch-ms)", - window_ms.0, window_ms.1 - ), - ExecFault::Member(MemberFault::Bind(detail)) => { - format!("a member failed to bind: {detail}") - } - ExecFault::Member(MemberFault::Run(detail)) => { - format!("a member failed to run: {detail}") - } + ExecFault::Member(m) => aura_campaign::member_fault_prose(m), ExecFault::Registry(e) => e.to_string(), } } @@ -397,10 +398,67 @@ impl MemberRunner for CliMemberRunner<'_> { stop, &binding, &self.cost, + &cell.instrument, ); report.manifest.instrument = Some(cell.instrument.clone()); Ok(report) } + + /// #272: derive the cell's archive coverage from the #264 primitives — + /// `None` when the archive has no file for the instrument at all (the + /// `NoData` member fault's job, not coverage) or when the effective + /// evaluated span covers the requested window exactly with no interior + /// gap month. One call per cell (not per member): coverage is a property + /// of the cell's (instrument, window), never a swept param point. + fn window_coverage(&self, cell: &CellSpec) -> Option { + let data_path = PathBuf::from(self.env.data_path()); + let months = aura_ingest::list_m1_months(&data_path, &cell.instrument); + if months.is_empty() { + return None; // no archive at all is the NoData fault's job, not coverage + } + let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent( + &self.server, + &data_path, + &cell.instrument, + Some(cell.window_ms.0), + Some(cell.window_ms.1), + )?; + let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts); + let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts); + let gap_months = interior_gap_months(&months, cell.window_ms); + if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() { + return None; // fully covered — nothing to annotate + } + Some(aura_registry::CellCoverage { + effective_from_ms: eff_from, + effective_to_ms: eff_to, + gap_months, + }) + } +} + +/// The whole `(year, month)`s missing INSIDE `months`' own span (mirrors the +/// interior-gap walk `data_coverage_report`, main.rs, performs over a full +/// archive listing) that also fall inside `window_ms` (Unix-ms) — one +/// `"YYYY-MM"` entry per missing month, never a collapsed range: the +/// registry's `CellCoverage::gap_months` is a flat list an aggregate counts +/// directly. `months` is assumed sorted ([`aura_ingest::list_m1_months`]'s own +/// contract). +fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec { + let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0); + let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1); + let mut out = Vec::new(); + for pair in months.windows(2) { + let (prev, next) = (pair[0], pair[1]); + let mut ym = crate::next_year_month(prev); + while ym != next { + if ym >= from_ym && ym <= to_ym { + out.push(crate::fmt_year_month(ym)); + } + ym = crate::next_year_month(ym); + } + } + out } /// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated @@ -417,8 +475,10 @@ pub(crate) enum RunPresentation { } /// `aura campaign run `: resolve, gate, execute, emit. Every `Err` -/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1. -pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> { +/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok` +/// carries the failed-cell count (#272) so the caller can exit 3 on a +/// completed-with-failures run via `exit_on_campaign_result`. +pub(crate) fn run_campaign(target: &str, env: &Env) -> Result { // Project gate FIRST: nothing (not even the file-sugar registration) // touches a store outside a project. if env.provenance().is_none() { @@ -480,7 +540,7 @@ pub(crate) fn run_campaign_by_id( campaign_id: &str, env: &Env, presentation: RunPresentation, -) -> Result<(), String> { +) -> Result { let run = run_campaign_returning(campaign_id, env)?; present_campaign(run, presentation, env) } @@ -589,7 +649,7 @@ fn present_campaign( run: CampaignRun, presentation: RunPresentation, env: &Env, -) -> Result<(), String> { +) -> Result { let CampaignRun { outcome, campaign, strategies, server } = run; // Zero-survivor stderr notes (exit stays 0 — a null result is a valid @@ -610,6 +670,22 @@ fn present_campaign( } } + // #272: failed-cell notes (the gate-empty idiom's sibling) — a failed + // cell never aborts the campaign; its fault is recorded and the run + // continues over the remaining cells. + let mut failed = 0usize; + let mut fail_labels: Vec = Vec::new(); + for cell in &outcome.record.cells { + if let Some(f) = &cell.fault { + failed += 1; + fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind))); + eprintln!( + "aura: cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues", + cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail + ); + } + } + // Emission: emit-gated family/selection lines per cell, then the // always-on final record line. Matched by NAME against the two closed- // vocabulary terms (self-evident at the call site, unlike positional @@ -657,6 +733,16 @@ fn present_campaign( serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record }) .expect("campaign run record serializes") ); + eprintln!( + "campaign run {} recorded: {} cells{}", + outcome.record.run, + outcome.record.cells.len(), + if failed == 0 { + String::new() + } else { + format!(", {failed} failed ({})", fail_labels.join(", ")) + } + ); } // Trace persistence runs LAST, after the always-on record line (0109): @@ -674,7 +760,22 @@ fn present_campaign( env, )?; } - Ok(()) + Ok(failed) +} + +/// #272: the fault-kind label the failed-cell summary line names — the same +/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl` +/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug +/// frame of the enum variant). +fn cell_fault_kind_label(kind: aura_registry::CellFaultKind) -> &'static str { + use aura_registry::CellFaultKind as K; + match kind { + K::NoData => "no_data", + K::Bind => "bind", + K::Run => "run", + K::Panic => "panic", + K::Window => "window", + } } /// Which drained wrap-convention channel a requested tap persists from on a @@ -975,7 +1076,7 @@ pub(crate) fn persist_campaign_traces( let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel(); let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg { - nodes: cost_nodes_for(&campaign.cost), + nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), tx_cost, tx_net, }); @@ -1157,6 +1258,21 @@ mod tests { assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); } + #[test] + /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to + /// `StopRule::VolTf` field-for-field — the resolve-side half of the + /// write/resolve pair the manifest round-trip test (main.rs) covers from + /// the stamp side; together the two arms this regime touches + /// (`campaign_run.rs`'s bind and `main.rs`'s stamp) are both exercised. + fn stop_rule_for_regime_binds_vol_tf_field_for_field() { + let regime = + Some(aura_research::RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }); + assert_eq!( + stop_rule_for_regime(regime), + aura_composites::StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + } + #[test] /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse /// pair of one wrapper-segment convention — stripping the wrapper off a @@ -1393,18 +1509,27 @@ mod tests { /// param, so the wrapped param_space stays cost-invariant (the probe/member /// space equivalence the campaign path relies on). fn cost_nodes_for_maps_each_component_to_its_bound_builder() { - let nodes = cost_nodes_for(&[ - aura_research::CostSpec::Constant { cost_per_trade: 2.0 }, - aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 }, - aura_research::CostSpec::Carry { carry_per_cycle: 0.1 }, - ]); + let nodes = cost_nodes_for( + &[ + aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(2.0), + }, + aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(0.5), + }, + aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(0.1), + }, + ], + "GER40", + ); let labels: Vec = nodes.iter().map(|n| n.label()).collect(); assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); assert!( nodes.iter().all(|n| n.params().is_empty()), "every component must be fully bound (no open param leaks into param_space)" ); - assert!(cost_nodes_for(&[]).is_empty(), "an empty model binds no nodes"); + assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes"); } #[test] @@ -1431,4 +1556,42 @@ mod tests { dedup.dedup(); assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}"); } + + #[test] + /// #272: `interior_gap_months` names a whole calendar month missing + /// BETWEEN two present archive months (the Copper failure shape) when the + /// requested window spans across it — not just when the window's own + /// bounds fall short of full coverage. GAPSYM-shaped input (files at + /// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June + /// window must name exactly the two missing interior months. + /// #272: the summary label is a CHECKED mirror of the persisted serde form + /// — an aggregate over `campaign_runs.jsonl` counts by the serialized + /// `CellFaultKind` string, so the human-facing label must be exactly that + /// string (no second source of truth that could silently drift). + fn cell_fault_kind_label_matches_the_serialized_kind() { + use aura_registry::CellFaultKind as K; + for kind in [K::NoData, K::Bind, K::Run, K::Panic, K::Window] { + let serde_form = serde_json::to_string(&kind).expect("serialize kind"); + let serde_form = serde_form.trim_matches('"'); + assert_eq!( + cell_fault_kind_label(kind), + serde_form, + "the summary label must equal the persisted serde string" + ); + } + } + + #[test] + fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() { + let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)]; + // 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every + // present month plus the March/April hole. + let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64); + let gaps = interior_gap_months(&months, window_ms); + assert_eq!( + gaps, + vec!["2024-03".to_string(), "2024-04".to_string()], + "both interior gap months must be named, in order" + ); + } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6a5b17e..65cd52f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -125,7 +125,7 @@ fn sim_optimal_manifest( // Typed params pass straight through: the manifest carries self-describing // Scalars, so a length stays `i64` and a scale `f64` in the record. RunManifest { - commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), + commit: ENGINE_COMMIT.to_string(), params, defaults: Vec::new(), window, @@ -875,11 +875,11 @@ fn select_winner( /// (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_trade_rs(result: &WalkForwardResult) -> Vec { +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.trade_rs.clone()).unwrap_or_default()) + .flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.net_trade_rs.clone()).unwrap_or_default()) .collect() } @@ -888,14 +888,14 @@ fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec { /// (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_trade_rs(result); + 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 (trade_rs is serde-skipped, so the + // 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"); @@ -914,7 +914,7 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String { /// (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 `trade_rs` through `r_metrics_from_rs`. +/// `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 @@ -954,7 +954,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) .collect(); let pooled_rs: Vec = reports .iter() - .flat_map(|r| r.metrics.r.as_ref().map(|m| m.trade_rs.clone()).unwrap_or_default()) + .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(), @@ -1141,17 +1141,25 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec StopRule { + let period_minutes = + params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64()); let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64()); - match (length, k) { - (Some(length), Some(k)) => StopRule::Vol { length, k }, + match (period_minutes, length, k) { + (Some(period_minutes), Some(length), Some(k)) => { + StopRule::VolTf { period_minutes, length, k } + } + (None, Some(length), Some(k)) => StopRule::Vol { length, k }, _ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, } } @@ -1175,9 +1183,15 @@ fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec aura_research::CostSpec::Constant { cost_per_trade: v.as_f64() }, - "slip_vol_mult" => aura_research::CostSpec::VolSlippage { slip_vol_mult: v.as_f64() }, - "carry_per_cycle" => aura_research::CostSpec::Carry { carry_per_cycle: v.as_f64() }, + "cost_per_trade" => aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()), + }, + "slip_vol_mult" => aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()), + }, + "carry_per_cycle" => aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()), + }, other => { // Corrupted-on-disk manifest data — the reproduce path's clean // exit register (`aura:` + exit 1), like point_from_params. @@ -1347,6 +1361,10 @@ fn reproduce_family_in( stop, &binding, &cost, + // The re-run specs come from the stamp and are scalar by + // construction (`cost_specs_from_params`), so the instrument is + // inert; the fallback is never resolved against a map. + stored.manifest.instrument.as_deref().unwrap_or(""), ); outcomes.push((label, rerun.metrics == stored.metrics)); } @@ -1730,6 +1748,14 @@ fn run_signal_r( RunReport { manifest, metrics } } +/// #260: the r-sma sugar/MC paths below run with either no cost model (empty +/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params` +/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never +/// originate on these paths and the instrument context is genuinely inert. +/// Named once so every such call site states its intent by reference instead +/// of repeating the justifying comment. +const NO_INSTRUMENT_CONTEXT: &str = ""; + /// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path /// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the /// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a @@ -1750,6 +1776,7 @@ fn run_blueprint_member( stop: StopRule, binding: &binding::ResolvedBinding, cost: &[aura_research::CostSpec], + instrument: &str, ) -> RunReport { let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below let (tx_eq, rx_eq) = mpsc::channel(); @@ -1762,7 +1789,7 @@ fn run_blueprint_member( let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = (!cost.is_empty()).then(|| CostLeg { - nodes: campaign_run::cost_nodes_for(cost), + nodes: campaign_run::cost_nodes_for(cost, instrument), tx_cost, tx_net, }); @@ -1771,12 +1798,20 @@ fn run_blueprint_member( .expect("member bootstraps (point kind-checked against param_space)"); h.run(sources); let mut named = zip_params(space, point); // by-name params for the manifest record - // `if let` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, - // which stamps no vol knobs. The campaign/single-run paths only pass `Vol`, - // so the `Fixed` arm is inert here. - if let StopRule::Vol { length, k } = stop { - named.push(("stop_length".to_string(), Scalar::i64(length))); - named.push(("stop_k".to_string(), Scalar::f64(k))); + // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, + // which stamps no vol knobs. The campaign/single-run paths only pass `Vol` + // or `VolTf`, so the `Fixed` arm is inert here. + match stop { + StopRule::Vol { length, k } => { + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::VolTf { period_minutes, length, k } => { + named.push(("stop_period_minutes".to_string(), Scalar::i64(period_minutes))); + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::Fixed(_) => {} } // Stamp the cost model the member ran under, beside the stop knobs (#234, // the #233 pattern): one `cost[k].` param per component, in @@ -1788,7 +1823,7 @@ fn run_blueprint_member( // reads this stamp back via `cost_specs_from_params` (the #233 stop-regime // pattern), so a costed family reproduces net, not gross. for (k, spec) in cost.iter().enumerate() { - let (knob, v) = campaign_run::cost_knob(spec); + let (knob, v) = campaign_run::cost_knob(spec, instrument); named.push((format!("cost[{k}].{knob}"), Scalar::f64(v))); } let mut manifest = sim_optimal_manifest(named, window, seed, pip); @@ -1915,6 +1950,48 @@ fn override_paths( Ok(overrides) } +/// Renders a [`BindError`] as one-line prose in `override_paths`' sibling +/// register above (#247) — never the raw Rust `Debug` struct name +/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep +/// terminal actually raises past `override_paths`' own pre-flight (which +/// already rejects an unresolvable axis name as prose before either call +/// site below ever reaches the terminal). `UnknownKnob` already carries a +/// fully-prosed message string (wrapped from `override_paths`' own +/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so +/// the walkforward path's rejection reaches stderr as bare prose too. +fn render_bind_error(e: &BindError) -> String { + match e { + BindError::MissingKnob(name) => format!( + "axis {name}: an open param with no axis and no bound default — \ + bind it with `--axis {name}=` — see `aura sweep --list-axes`" + ), + BindError::KindMismatch { knob, expected, got } => format!( + "axis {knob}: expected {expected:?}, supplied {got:?} — \ + see `aura sweep --list-axes`" + ), + BindError::UnknownKnob(msg) => msg.clone(), + BindError::DuplicateBinding(name) => format!( + "axis {name}: bound twice — each param takes exactly one axis — \ + see `aura sweep --list-axes`" + ), + BindError::EmptyAxis(name) => format!( + "axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`" + ), + BindError::EmptyRange(name) => format!( + "axis {name}: the named range is empty — give it at least one value" + ), + // A blueprint defect, not an axis usage error: the point passed name + // resolution but its bootstrap failed. Prose frame with the compile + // detail explicitly labelled as internal — per-variant prose for + // CompileError belongs to the graph-build surface, not this boundary. + BindError::Compile(e) => format!( + "the resolved axis point failed to bootstrap — the blueprint is \ + defective at this point, re-validate it with `aura graph build` \ + (internal detail: {e:?})" + ), + } +} + /// Apply a validated override set to a freshly loaded strategy (#246). /// Infallible by contract: the set was derived against this same document at /// the family boundary. @@ -2013,11 +2090,11 @@ fn blueprint_sweep_family( .sweep(|point| { // fresh per-member graph (Composite is !Clone, reload per member) run through // the shared reduce-mode member path — the same fn reproduction re-runs. - run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[]) + run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) }) - // render the sweep terminal's BindError to a message (the fn's String error contract), - // so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper. - .map_err(|e| format!("{e:?}")) + // render the sweep terminal's BindError to prose (#247), the fn's String error + // contract — never the raw Debug struct. + .map_err(|e| render_bind_error(&e)) } /// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window @@ -2059,7 +2136,7 @@ fn blueprint_sweep_over( binder.sweep_with_lattice(|point| { let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]) + run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT) }) } @@ -2085,7 +2162,7 @@ fn run_oos_blueprint( let pip = data.pip_size(); let sources = data.windowed_sources(from, to, env, &binding.columns()); let window = window_of(&sources).expect("non-empty out-of-sample window"); - let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[]); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT); (Vec::new(), report) } @@ -2188,7 +2265,7 @@ fn blueprint_walkforward_family( // (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without // running a single member — no IS window (or a second roller) is needed here at all. if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) { - eprintln!("aura: {e:?}"); + eprintln!("aura: {}", render_bind_error(&e)); std::process::exit(2); } walk_forward(roller, space.clone(), |w: WindowBounds| { @@ -2265,7 +2342,7 @@ fn blueprint_mc_family( let family = monte_carlo(&base_point, &seeds, |seed, _base| { let sources = synthetic_walk_sources(seed); let window = window_of(&sources).expect("non-empty synthetic walk"); - run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[]) + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) }); // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's // metrics are bit-identical to the first, no seed reached a distinguishable realization — @@ -2637,13 +2714,39 @@ fn parse_scalar_csv(csv: &str) -> Option> { // selects the loaded-blueprint branch. There is no second built-in grammar anymore // (#159 demo retirement / #220 axis generalization): without a blueprint the // dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to -// the campaign path. Usage errors (clap parse + argv-applicability guards) exit 2; -// runtime failures exit 1. +// the campaign path. Usage errors exit 2; runtime refusals exit 1; a run that +// completes with ≥1 failed cell exits 3 (#272); a clean run exits 0. -/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION` -/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`. +/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, +/// falling back to `"unknown"`): both `--version` (via `version_string`) and +/// `sim_optimal_manifest`'s `RunManifest.commit` read this one const, so a +/// stale binary is distinguishable from a fresh one at the CLI surface, +/// before any run, without a second commit-sourcing mechanism (#266). +const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") { + Some(c) => c, + None => "unknown", +}; + +/// `aura 0.1.0 ()` as a process-lifetime `&'static str` — computed once +/// (clap's `version` builder method wants `Into`, and `clap::builder::Str` +/// (clap 4.6) only implements `From<&'static str>`, not `From`, so the +/// one owned `String` this formats is leaked for the process's lifetime, same +/// as any other CLI one-shot startup string). +fn version_string() -> &'static str { + static VERSION: std::sync::OnceLock = std::sync::OnceLock::new(); + VERSION.get_or_init(|| format!("{} ({ENGINE_COMMIT})", env!("CARGO_PKG_VERSION"))) +} + +/// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the +/// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so +/// `aura --version` prints `aura 0.1.0 ()`. #[derive(Parser)] -#[command(name = "aura", version, about = "Author, backtest, and validate trading strategies — research CLI", infer_long_args = true)] +#[command( + name = "aura", + version = version_string(), + about = "Author, backtest, and validate trading strategies — research CLI", + infer_long_args = true +)] struct Cli { #[command(subcommand)] command: Command, @@ -2684,6 +2787,8 @@ enum Command { Process(research_docs::ProcessCmd), /// Validate, introspect, and register campaign documents (experiment intent). Campaign(research_docs::CampaignCmd), + /// Inspect the project's data archive. + Data(DataCmd), } #[derive(Args)] @@ -2728,6 +2833,26 @@ struct NodesNewCmd { namespace: Option, } +#[derive(Args)] +struct DataCmd { + #[command(subcommand)] + command: DataCommand, +} + +#[derive(Subcommand)] +enum DataCommand { + /// Report a symbol's monthly file-index coverage (span + interior gaps). + Coverage(DataCoverageCmd), + /// List the archive's known symbols, sorted, one per line. + List, +} + +#[derive(Args)] +struct DataCoverageCmd { + /// The symbol to inventory (e.g. `GER40`). + symbol: String, +} + #[derive(Args)] #[command(args_conflicts_with_subcommands = true)] struct GraphCmd { @@ -3301,6 +3426,20 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! { } } +/// Terminate per a campaign-path result (#272): a String error is a refusal +/// (exit 1); an Ok carrying the failed-cell count exits 3 when any cell failed +/// ("completed with failed cells"), else returns cleanly (exit 0). +pub(crate) fn exit_on_campaign_result(r: Result) { + match r { + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + Ok(0) => {} + Ok(_) => std::process::exit(3), + } +} + /// The shared campaign window in Unix-ms, clipped to the archive. `full_window` /// probes the ARCHIVE's actual first/last bar in range (never the literal ms /// request) and returns aura's native epoch-ns `Timestamp` (the ms->ns crossing @@ -3544,10 +3683,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { // sweep + walkforward only); trace-writing is out of scope here. trace: false, }; - verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env)); } fn dispatch_runs(a: RunsCmd, env: &project::Env) { @@ -3651,6 +3787,105 @@ fn dispatch_nodes_new(a: NodesNewCmd) { ); } +fn dispatch_data(cmd: DataCmd, env: &project::Env) { + match cmd.command { + DataCommand::Coverage(a) => dispatch_data_coverage(a, env), + DataCommand::List => dispatch_data_list(env), + } +} + +/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted +/// ascending, one per line — the discovery step before `aura data coverage` +/// or scoping a campaign's instrument matrix. Resolves the archive root +/// through the normal project path ([`dispatch_data_coverage`]'s +/// `Env::data_path`), then reuses `DataServer`'s own symbol index +/// (`DataServer::symbols()`, already sorted) rather than re-deriving a +/// directory scan. An empty or absent archive is informational absence, not a +/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established +/// empty-result register — cf. `runs family`'s unknown-id empty exit 0). +fn dispatch_data_list(env: &project::Env) { + let data_path = std::path::PathBuf::from(env.data_path()); + let server = aura_ingest::DataServer::new(&data_path); + for line in data_list_report(&server.symbols()) { + println!("{line}"); + } +} + +/// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one +/// line per symbol, or a single `no symbols` line when the archive is empty +/// or absent — informational absence, never a fault (#264). +fn data_list_report(symbols: &[std::sync::Arc]) -> Vec { + if symbols.is_empty() { + return vec!["no symbols".to_string()]; + } + symbols.iter().map(|s| s.to_string()).collect() +} + +/// `aura data coverage ` (#264 cut 1): print the archive's month +/// coverage for `SYMBOL` — the Copper failure mode (files present at both +/// ends of a window while an interior month is missing, so a first/last-bounds +/// check passes but a campaign run aborts mid-window) made visible up front. +/// Resolves the archive root through the normal project path (`Env::data_path` +/// — a project-local `[paths] data` override when set, the data-server default +/// otherwise), so a project-scoped archive is inventoried, not the host one. +fn dispatch_data_coverage(a: DataCoverageCmd, env: &project::Env) { + let data_path = std::path::PathBuf::from(env.data_path()); + let months = aura_ingest::list_m1_months(&data_path, &a.symbol); + match data_coverage_report(&a.symbol, &months) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + } + Err(e) => { + eprintln!("aura: {e} at {}", env.data_path()); + std::process::exit(1); + } + } +} + +/// Pure: render `symbol`'s coverage report from its sorted `(year, month)` file +/// list — one `span:` line framing the first/last present month, then either a +/// `no gaps` line or one `missing: YYYY-MM..YYYY-MM` line per interior +/// contiguous gap (a ten-month hole is one line, not ten). `Err` exactly when +/// `months` is empty — no archive file exists for `symbol` at all (the +/// caller's "unknown symbol" refusal, stderr + exit 1). +fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result, String> { + let Some(&first) = months.first() else { + return Err(format!("no archive files found for symbol \"{symbol}\"")); + }; + let last = *months.last().expect("non-empty checked above"); + let mut lines = vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))]; + let gaps: Vec<((u16, u8), (u16, u8))> = months + .windows(2) + .filter_map(|pair| { + let (prev, next) = (pair[0], pair[1]); + let expected = next_year_month(prev); + (expected != next).then(|| (expected, prev_year_month(next))) + }) + .collect(); + if gaps.is_empty() { + lines.push(format!("{symbol} no gaps")); + } else { + for (from, to) in gaps { + lines.push(format!("{symbol} missing: {}..{}", fmt_year_month(from), fmt_year_month(to))); + } + } + Ok(lines) +} + +fn fmt_year_month((y, m): (u16, u8)) -> String { + format!("{y:04}-{m:02}") +} + +fn next_year_month((y, m): (u16, u8)) -> (u16, u8) { + if m == 12 { (y + 1, 1) } else { (y, m + 1) } +} + +fn prev_year_month((y, m): (u16, u8)) -> (u16, u8) { + if m == 1 { (y - 1, 12) } else { (y, m - 1) } +} + /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. fn dispatch_sweep(a: SweepCmd, env: &project::Env) { @@ -3741,10 +3976,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { // `name_persist`'s bool (true iff `--trace` was given). trace: persist, }; - verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + exit_on_campaign_result(verb_sugar::run_sweep_sugar(&inv, env)); } DataChoice::Synthetic => { // The synthetic in-process family path is still reduce-only @@ -3845,7 +4077,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { // (unchanged since 0109 — walkforward always nominates). trace, }; - verb_sugar::run_walkforward_sugar( + let result = verb_sugar::run_walkforward_sugar( &inv, WINNER_SELECTION_METRIC, verb_sugar::WfWindows { @@ -3855,11 +4087,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { }, select, env, - ) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + ); + exit_on_campaign_result(result); return; } // Synthetic in-process family path — unchanged (#220 non-goal); it @@ -3960,7 +4189,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { // is out of #224's scope here. trace: false, }; - verb_sugar::run_mc_sugar( + let result = verb_sugar::run_mc_sugar( &inv, WINNER_SELECTION_METRIC, verb_sugar::WfWindows { @@ -3970,11 +4199,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { }, verb_sugar::McKnobs { resamples, block_len, seed }, env, - ) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + ); + exit_on_campaign_result(result); return; } // Synthetic seed family (unchanged, #220 non-goal): closed @@ -4053,6 +4279,7 @@ fn main() { Command::Nodes(a) => dispatch_nodes(a), Command::Process(a) => research_docs::process_cmd(a, &env), Command::Campaign(a) => research_docs::campaign_cmd(a, &env), + Command::Data(a) => dispatch_data(a, &env), } } @@ -4078,6 +4305,88 @@ mod tests { assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200))); } + /// Audit follow-up to #247/#269: `render_bind_error` is exhaustive — the + /// axis-usage variants the old catch-all Debug-framed render as prose + /// naming the axis, with no Rust identifier on the user's stderr. + #[test] + fn render_bind_error_prose_covers_the_axis_usage_variants() { + let dup = render_bind_error(&aura_engine::BindError::DuplicateBinding("a.b".into())); + assert!(dup.contains("a.b") && dup.contains("bound twice"), "{dup}"); + assert!(!dup.contains("DuplicateBinding"), "Debug leak: {dup}"); + let empty = render_bind_error(&aura_engine::BindError::EmptyAxis("a.b".into())); + assert!(empty.contains("a.b") && empty.contains("no values"), "{empty}"); + assert!(!empty.contains("EmptyAxis"), "Debug leak: {empty}"); + let range = render_bind_error(&aura_engine::BindError::EmptyRange("a.b".into())); + assert!(range.contains("a.b") && !range.contains("EmptyRange"), "Debug leak: {range}"); + } + + /// Audit follow-up to #247/#269: a `Compile` fault at the sweep boundary — + /// a blueprint defect, not an axis usage error — renders as a prose frame + /// that names the situation and labels the embedded compile detail as + /// internal, instead of leaking the bare Debug struct as the whole message. + #[test] + fn render_bind_error_frames_a_compile_fault_as_prose_with_labeled_detail() { + let msg = render_bind_error(&aura_engine::BindError::Compile( + aura_engine::CompileError::BadInteriorIndex, + )); + assert!(msg.contains("failed to bootstrap"), "{msg}"); + assert!(msg.contains("internal detail"), "{msg}"); + assert!(!msg.starts_with("Compile"), "the frame must lead with prose: {msg}"); + } + + /// An unknown symbol (no archive files at all — an empty month list) + /// refuses rather than reporting a bogus empty-span coverage (#264): the + /// caller eprintln's the message and exits 1. + #[test] + fn data_coverage_report_refuses_an_unknown_symbol() { + let err = data_coverage_report("GHOST", &[]).unwrap_err(); + assert!(err.contains("GHOST"), "names the unknown symbol: {err}"); + } + + /// A symbol with a fully contiguous file index reports its span plus an + /// explicit `no gaps` line — never a bare span with no gap-status line at + /// all, which would leave "no gaps" indistinguishable from "gaps not yet + /// checked" (#264). + #[test] + fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() { + let months = [(2024, 1), (2024, 2), (2024, 3)]; + let lines = data_coverage_report("SYMA", &months).expect("known symbol"); + assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]); + } + + /// The Copper failure shape itself: one interior gap collapses to a single + /// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not + /// one line per missing month (#264). + #[test] + fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() { + let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)]; + let lines = data_coverage_report("GAPSYM", &months).expect("known symbol"); + assert_eq!( + lines, + vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"] + ); + } + + /// An empty (or absent) archive is informational absence, not a fault + /// (#264): `data_list_report` returns the single `no symbols` prose line + /// rather than an empty `Vec`, so the caller's exit-0 println always has + /// something to say. + #[test] + fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() { + let symbols: Vec> = vec![]; + assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]); + } + + /// A non-empty archive renders one line per symbol, in the order handed + /// in (`DataServer::symbols()` already sorts) — no `no symbols` line + /// mixed in (#264). + #[test] + fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() { + let symbols: Vec> = + vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")]; + assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]); + } + /// Disjoint archives (no shared instant across all listed symbols) refuse /// rather than silently pooling a floor over different periods per /// instrument (#213); the error names each symbol next to its own span, @@ -4556,10 +4865,13 @@ mod tests { stop, &binding, cost, + "GER40", ) }; let gross = run(&[]); - let netted = run(&[aura_research::CostSpec::Constant { cost_per_trade: CPT }]); + let netted = run(&[aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(CPT), + }]); // The trade geometry, independently: a non-reduce cost-less wrap over // the same realization, draining the dense R record whose ledger @@ -4646,7 +4958,9 @@ mod tests { let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; let window = data.full_window(&env); let pip = data.pip_size(); - let cost = [aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }]; + let cost = [aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(0.0005), + }]; // The recorded member: reduce-mode, cost-bound — `run_blueprint_member`, // the exact path `CliMemberRunner::run_member` calls. @@ -4663,6 +4977,7 @@ mod tests { stop, &binding, &cost, + "GER40", ); // The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` @@ -4676,7 +4991,7 @@ mod tests { let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = - Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost), tx_cost, tx_net }); + Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg) .compile_with_params(&[]) .expect("the persist-side wrap builds"); @@ -4738,10 +5053,10 @@ mod tests { let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let report_a = run_blueprint_member( - signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], + signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40", ); let report_b = run_blueprint_member( - r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], + r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40", ); // Guard: the run must have actually traded, else the invariant is vacuous. assert!( @@ -5321,7 +5636,8 @@ mod tests { #[test] fn reproduce_family_re_derives_every_member_bit_identically() { // a unique temp runs store so the on-disk family + blueprint store do not collide. - let dir = std::env::temp_dir().join(format!("aura-repro-{}", std::process::id())); + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); @@ -5366,7 +5682,8 @@ mod tests { // under a NON-default vol-stop regime (a campaign risk-regime cell, or wf/mc/ // generalize with --stop-length/--stop-k) must still reproduce bit-identically; // silently re-running it under the default Vol{3,2.0} reports a spurious DIVERGED. - let dir = std::env::temp_dir().join(format!("aura-repro-stop-{}", std::process::id())); + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-stop"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); @@ -5402,6 +5719,7 @@ mod tests { non_default, &binding, &[], + "GER40", ); // persist exactly as the sweep/campaign paths do: store the blueprint, append the family. @@ -5434,7 +5752,8 @@ mod tests { /// vol proxy). #[test] fn reproduce_family_re_derives_a_costed_member_bit_identically() { - let dir = std::env::temp_dir().join(format!("aura-repro-cost-{}", std::process::id())); + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-cost"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); @@ -5450,9 +5769,15 @@ mod tests { let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let cost = vec![ - aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }, - aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 }, - aura_research::CostSpec::Carry { carry_per_cycle: 0.0001 }, + aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(0.0005), + }, + aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(0.5), + }, + aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(0.0001), + }, ]; let report = run_blueprint_member( reload(), @@ -5467,6 +5792,7 @@ mod tests { StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &cost, + "GER40", ); // Non-vacuity: the cost model must actually bite, else a DIVERGED // verdict could never be observed and this pin proves nothing. @@ -5489,7 +5815,8 @@ mod tests { #[test] fn reproduce_family_re_derives_every_mc_member_bit_identically() { - let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", std::process::id())); + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-mc"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); @@ -5590,7 +5917,8 @@ mod tests { /// must show. Sweep / walk-forward labels still echo their params (covered elsewhere). #[test] fn reproduce_mc_member_labels_carry_the_seed() { - let dir = std::env::temp_dir().join(format!("aura-repro-mc-seed-{}", std::process::id())); + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-mc-seed"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); @@ -5818,6 +6146,77 @@ mod tests { assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}"); } + /// #262 round-trip: a manifest carrying stop_period_minutes re-derives + /// the VolTf stop; one without it keeps the Vol path — a stored VolTf + /// member must never silently reproduce under a default Vol stop. + #[test] + fn stop_rule_from_params_round_trips_the_vol_tf_stamp() { + let vol_tf = vec![ + ("stop_period_minutes".to_string(), Scalar::i64(60)), + ("stop_length".to_string(), Scalar::i64(14)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!( + stop_rule_from_params(&vol_tf), + StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + let vol = vec![ + ("stop_length".to_string(), Scalar::i64(3)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); + } + + /// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually + /// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm, + /// end-to-end over a real synthetic realization, not a hand-built value) + /// and stamps all three knobs into the manifest under the keys + /// `stop_rule_from_params`'s round-trip above reads back — the write half + /// of the manifest round-trip pair. + #[test] + fn run_blueprint_member_stamps_the_vol_tf_stop_knobs() { + let env = project::Env::std(); + let data = DataSource::Synthetic; + let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let binding = binding::resolve_binding("voltfstamp", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::VolTf { period_minutes: 60, length: 3, k: 2.0 }; + let window = data.full_window(&env); + let pip = data.pip_size(); + let run = crate::run_blueprint_member( + reload(), + &[], + &space, + data.run_sources(&env, &binding.columns()), + window, + 0, + pip, + "topo", + &env, + stop, + &binding, + &[], + "GER40", + ); + assert!( + run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))), + "manifest must stamp stop_period_minutes: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_length".to_string(), Scalar::i64(3))), + "manifest must stamp stop_length: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_k".to_string(), Scalar::f64(2.0))), + "manifest must stamp stop_k: {:?}", + run.manifest.params + ); + } + /// 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 { diff --git a/crates/aura-cli/src/project.rs b/crates/aura-cli/src/project.rs index d4c026d..182aa0f 100644 --- a/crates/aura-cli/src/project.rs +++ b/crates/aura-cli/src/project.rs @@ -588,7 +588,9 @@ mod tests { #[test] fn discover_walks_up_to_aura_toml() { - let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-disc"); + let _ = std::fs::remove_dir_all(&tmp); let nested = tmp.join("a/b/c"); std::fs::create_dir_all(&nested).unwrap(); assert_eq!(discover_from(&nested), None); @@ -611,7 +613,9 @@ mod tests { /// and stamps commit-only provenance. #[test] fn data_only_project_loads_without_a_crate() { - let tmp = std::env::temp_dir().join(format!("aura-dataonly-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-dataonly"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap(); let p = load(&tmp, false).expect("data-only load"); @@ -632,7 +636,9 @@ mod tests { /// changes the data location depending on the caller's working directory. #[test] fn data_path_resolves_a_relative_paths_data_against_the_project_root() { - let tmp = std::env::temp_dir().join(format!("aura-datarel-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-datarel"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap(); let p = load(&tmp, false).expect("data-only load"); @@ -643,7 +649,9 @@ mod tests { #[test] fn two_nodes_pointers_refuse_with_multi_crate() { - let tmp = std::env::temp_dir().join(format!("aura-multi-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-multi"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap(); let e = load(&tmp, false).unwrap_err(); @@ -653,7 +661,9 @@ mod tests { #[test] fn a_broken_nodes_pointer_names_the_pointer() { - let tmp = std::env::temp_dir().join(format!("aura-badptr-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-badptr"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap(); let e = load(&tmp, false).unwrap_err(); @@ -671,7 +681,9 @@ mod tests { /// that (`load` still refuses → CLI exit 1). #[test] fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() { - let tmp = std::env::temp_dir().join(format!("aura-missingptr-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-missingptr"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); // `missing-node-crate` is never created — the pointer target is absent. std::fs::write( diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index ec8bd19..520f4ab 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -147,6 +147,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { DocFault::BadCost { index } => { format!("cost[{index}]: the component's price-unit knob must be finite and >= 0") } + DocFault::CostInstrumentKeys { index, missing, extra } => { + let mut parts = Vec::new(); + if !missing.is_empty() { + parts.push(format!("misses campaign instrument(s) {}", missing.join(", "))); + } + if !extra.is_empty() { + parts.push(format!("names no campaign instrument(s): {}", extra.join(", "))); + } + format!("cost[{index}]: instrument map {}", parts.join("; ")) + } DocFault::NoStrategy => "strategies is empty".into(), DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"), DocFault::EmptyAxis { strategy, axis } => { @@ -278,6 +288,9 @@ fn describe_one_block(id: &str) -> Result<(), String> { fn print_open_slots(slots: &[aura_research::OpenSlot]) { for s in slots { println!("open slot: {} ({})", s.path, s.hint); + for note in &s.notes { + println!(" {note}"); + } } if slots.is_empty() { println!("no open slots"); @@ -403,7 +416,14 @@ fn campaign_runs(campaign: Option<&str>, run: Option, env: &Env) -> Resul Ok(()) } +/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures, +/// via `exit_on_campaign_result`), so it is pulled out of the unified +/// `Result<(), String>` match the other four subcommands still share. pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { + if let CampaignSub::Run { target } = &cmd.sub { + crate::exit_on_campaign_result(crate::campaign_run::run_campaign(target, env)); + return; + } let result = match &cmd.sub { CampaignSub::Validate { file } => validate_campaign_file(file, env), CampaignSub::Introspect(i) => { @@ -411,8 +431,8 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { introspect_campaign(i) } CampaignSub::Register { file } => register_campaign(file, env), - CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env), CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env), + CampaignSub::Run { .. } => unreachable!("handled above"), }; if let Err(m) = result { eprintln!("aura: {m}"); @@ -651,4 +671,23 @@ mod tests { ); assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}"); } + + /// #260: the instrument-map key-mismatch fault is path-addressed and names + /// both directions — the campaign instruments the map misses AND the map + /// keys naming no campaign instrument — in one line, no Debug leak. The + /// extra-keys branch is otherwise unpinned (the e2e covers only missing). + #[test] + fn cost_instrument_keys_prose_names_both_directions() { + let prose = doc_fault_prose(&DocFault::CostInstrumentKeys { + index: 1, + missing: vec!["EURUSD".into()], + extra: vec!["GER40.cash".into()], + }); + assert_eq!( + prose, + "cost[1]: instrument map misses campaign instrument(s) EURUSD; \ + names no campaign instrument(s): GER40.cash" + ); + assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}"); + } } diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 4ad85de..324794c 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -488,7 +488,9 @@ mod tests { #[test] fn scaffold_project_refuses_existing_destination() { - let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-pscaf"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(tmp.join("x")).unwrap(); let spec = project_scaffold_spec("x", &tmp).unwrap(); let e = scaffold_project(&spec).unwrap_err(); @@ -498,7 +500,9 @@ mod tests { #[test] fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() { - let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id())); + let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-scaf"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); // existing destination let mut spec = scaffold_spec("x", None, None, &tmp).unwrap(); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index cd327cf..b57e450 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -333,11 +333,13 @@ fn validate_before_register( } /// Run one dissolved sweep invocation end-to-end: register the generated -/// documents, then execute through the one campaign path in sugar mode. +/// 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: &crate::project::Env, -) -> Result<(), String> { +) -> 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)?; @@ -349,37 +351,36 @@ pub(crate) fn run_sweep_sugar( /// 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`. Debug-asserts one member per -/// cell: generalize's pipeline has no gate stage, so every cell nominates — -/// a cell silently missing its nominee here would be a campaign-executor -/// regression, not a legitimate generalize outcome, and the filter_map below -/// must not swallow it unnoticed. +/// 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 { - let members: Vec = outcome + outcome .cells .iter() .filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone())) - .collect(); - debug_assert_eq!( - members.len(), - outcome.cells.len(), - "generalize has no gate stage — every cell must nominate" - ); - members + .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). +/// 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: &crate::project::Env, -) -> Result<(), String> { +) -> 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)?; @@ -403,7 +404,8 @@ pub(crate) fn run_generalize_sugar( .append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members) .map_err(|e| e.to_string())?; println!("{{\"family_id\":\"{family_id}\"}}"); - Ok(()) + 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. @@ -414,12 +416,13 @@ pub(crate) struct GeneratedWalkforward { } /// Translate one `aura walkforward --real` invocation into its two generated -/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep -/// only enumerates the IS-refit survivor grid for the wf stage; its recorded -/// argmax selection is not part of the summary) 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. +/// 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, @@ -432,13 +435,7 @@ pub(crate) fn translate_walkforward( name: "walkforward".to_string(), description: None, pipeline: vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: metric.to_string(), - select: SelectRule::Argmax, - deflate: false, - }), - }, + StageBlock::Grid, StageBlock::WalkForward { in_sample_ms: w.in_sample_ms, out_of_sample_ms: w.out_of_sample_ms, @@ -494,14 +491,18 @@ pub(crate) fn register_generated_wf( /// 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). +/// 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: &crate::project::Env, -) -> Result<(), String> { +) -> 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)?; @@ -509,6 +510,11 @@ pub(crate) fn run_walkforward_sugar( let (_process_id, campaign_id) = register_generated_wf(®, &generated)?; let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; + if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { + eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail); + return Ok(1); + } + // The single cell's walk_forward StageFamily. let wf = run .outcome @@ -554,7 +560,7 @@ pub(crate) fn run_walkforward_sugar( env, )?; } - Ok(()) + Ok(0) } /// The two generated documents of one dissolved `mc` invocation. @@ -565,8 +571,9 @@ pub(crate) struct GeneratedMc { } /// Translate one `aura mc --real` invocation into its two generated documents: -/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the -/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal +/// 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 @@ -587,13 +594,7 @@ pub(crate) fn translate_mc( name: "mc".to_string(), description: None, pipeline: vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: metric.to_string(), - select: SelectRule::Argmax, - deflate: false, - }), - }, + StageBlock::Grid, StageBlock::WalkForward { in_sample_ms: w.in_sample_ms, out_of_sample_ms: w.out_of_sample_ms, @@ -652,13 +653,16 @@ pub(crate) fn register_generated_mc( /// 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: &crate::project::Env, -) -> Result<(), String> { +) -> 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)?; @@ -666,6 +670,11 @@ pub(crate) fn run_mc_sugar( let (_process_id, campaign_id) = register_generated_mc(®, &generated)?; let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; + if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { + eprintln!("aura: 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 @@ -680,7 +689,7 @@ pub(crate) fn run_mc_sugar( }) .ok_or("mc produced no pooled-OOS bootstrap")?; println!("{}", crate::mc_r_bootstrap_json(boot)); - Ok(()) + Ok(0) } #[cfg(test)] @@ -895,10 +904,9 @@ mod tests { /// 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::env::temp_dir().join(format!( - "aura-verb-sugar-generalize-registry-test-{}", - std::process::id() - )); + 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")); @@ -934,17 +942,11 @@ mod tests { content_id_of(&campaign_to_json(&b.campaign)), "identical invocations must generate identical campaign ids" ); - // pipeline: sweep(argmax) then walk_forward(argmax, rolling) + // pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) assert_eq!( a.process.pipeline, vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: "sqn_normalized".to_string(), - select: SelectRule::Argmax, - deflate: false, - }) - }, + StageBlock::Grid, StageBlock::WalkForward { in_sample_ms: 7_776_000_000, out_of_sample_ms: 2_592_000_000, @@ -1047,10 +1049,9 @@ mod tests { #[test] fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() { - let dir = std::env::temp_dir().join(format!( - "aura-verb-sugar-walkforward-registry-test-{}", - std::process::id() - )); + 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(); @@ -1084,17 +1085,11 @@ mod tests { content_id_of(&campaign_to_json(&b.campaign)), "identical invocations must generate identical campaign ids" ); - // pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo + // pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo assert_eq!( a.process.pipeline, vec![ - StageBlock::Sweep { - selection: Some(SweepSelection { - metric: "sqn_normalized".to_string(), - select: SelectRule::Argmax, - deflate: false, - }) - }, + StageBlock::Grid, StageBlock::WalkForward { in_sample_ms: 7_776_000_000, out_of_sample_ms: 2_592_000_000, @@ -1140,10 +1135,9 @@ mod tests { #[test] fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() { - let dir = std::env::temp_dir().join(format!( - "aura-verb-sugar-mc-registry-test-{}", - std::process::id() - )); + 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(); @@ -1165,4 +1159,73 @@ mod tests { 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_engine::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_engine::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 2942e3a..cbdd9c4 100644 --- a/crates/aura-cli/tests/cli_broken_pipe.rs +++ b/crates/aura-cli/tests/cli_broken_pipe.rs @@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura"); /// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique /// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`). fn temp_cwd(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name)); + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 0b34354..85f1023 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::process::Command; mod common; -use common::{fresh_project, fresh_project_with_data}; +use common::{fresh_project, fresh_project_with_data, fresh_project_with_gapped_data}; /// Path to the freshly-built `aura` binary (Cargo sets this env var for the test /// crate; the binary is named `aura` in `Cargo.toml`). @@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura"); /// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo). /// Unique per test + per process; no external tempfile dependency. fn temp_cwd(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name)); + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir @@ -2181,6 +2181,84 @@ fn aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access() { let _ = std::fs::remove_dir_all(&dir); } +/// Property (#247): 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. 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 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", "sma_signal.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("sma_signal.fast.length"), + "the prose names the offending axis exactly as typed: {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", "sma_signal.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("sma_signal.slow.length"), + "the prose names the unbound knob no axis supplies: {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 @@ -3215,8 +3293,8 @@ fn walkforward_dissolved_refuses_name_and_trace_together() { /// 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 -/// `Sweep` family (the pipeline's leading `std::sweep(argmax)` stage) plus one -/// `WalkForward` family (the per-window OOS reports) — the observable proof that +/// `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. @@ -3285,8 +3363,8 @@ fn walkforward_dissolves_through_the_campaign_path() { let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); assert_eq!( fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), - 1, - "one Sweep family from the pipeline's leading argmax stage: {fams_out}" + 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(), @@ -4485,8 +4563,8 @@ fn aura_walkforward_over_a_blueprint_rejects_an_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("Knob") && stderr.contains("nope"), - "names the unresolved axis via BindError: {stderr}" + 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}"); } @@ -4518,7 +4596,7 @@ fn aura_walkforward_emits_an_unknown_axis_rejection_once() { 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("UnknownKnob").count(); + 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:?}", @@ -4526,6 +4604,61 @@ fn aura_walkforward_emits_an_unknown_axis_rejection_once() { } } +/// 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}" + ); +} + /// 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] @@ -4866,6 +4999,42 @@ fn version_flag_prints_version_to_stdout_and_exits_zero() { } } +/// `--version` carries the ENGINE COMMIT beside the semver, drawn from the SAME +/// `AURA_COMMIT` build-time provenance the run manifest stamps (the +/// `option_env!("AURA_COMMIT")` that fills `RunManifest.commit`), so a stale +/// binary is distinguishable at the CLI surface *before* any run (#266). Shape: +/// `aura 0.1.0 ()`, exit 0. +/// +/// Property, not a specific sha: the expected commit is read from the same +/// provenance source the built binary was compiled with — this integration test +/// receives aura-cli's build-script `rustc-env`, so it sees the identical value +/// (dirty/`unknown` suffixes included) rather than today's HEAD, keeping the +/// assertion honest against a lagging or unclean tree. The load-bearing claim is +/// that the parenthesized commit rides beside the still-present semver. +#[test] +fn version_flag_carries_the_engine_commit_from_manifest_provenance() { + // The single source of truth — the same env the manifest's commit is sourced + // from; falls back to "unknown" identically to `sim_optimal_manifest`. + let commit = option_env!("AURA_COMMIT").unwrap_or("unknown"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .arg("--version") + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(0), "--version exit {:?}", out.status); + let stdout = String::from_utf8_lossy(&out.stdout); + // Semver stays present (one source of truth, CARGO_PKG_VERSION). + assert!( + stdout.contains(env!("CARGO_PKG_VERSION")), + "--version still prints the semver: {stdout:?}" + ); + // ...and the engine commit rides beside it, parenthesized, from the shared + // manifest provenance — the load-bearing assertion #266 adds. + assert!( + stdout.contains(&format!("({commit})")), + "--version prints the engine commit ({commit}) beside the semver: {stdout:?}" + ); +} + /// 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 @@ -5143,9 +5312,10 @@ fn mc_dissolves_a_non_r_sma_blueprint() { /// path — a successful run durably auto-registers exactly one generated process /// document, one generated campaign document (carrying the constant "mc" name and the /// stop as a non-empty single risk regime), and one campaign-run record, and emits the -/// single `mc_r_bootstrap` grade line. The pipeline's leading argmax sweep and its -/// walk_forward stage each persist one family; the terminal monte_carlo stage is an -/// annotator and adds NO family. This is the observable proof the inline path is gone +/// single `mc_r_bootstrap` grade line. The pipeline's walk_forward stage persists +/// the one family; the leading `std::grid` stage only enumerates (#256) and the +/// terminal monte_carlo stage is an annotator — neither adds a family. This is +/// the observable proof the inline path is gone /// and the dissolution runs through the executor. Gated on the GER40 2025 archive; /// skips cleanly on a data refusal. #[test] @@ -5222,8 +5392,8 @@ fn mc_dissolves_through_the_campaign_path() { let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); assert_eq!( fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), - 1, - "one Sweep family from the pipeline's leading argmax stage: {fams_out}" + 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(), @@ -5247,9 +5417,9 @@ fn mc_dissolves_through_the_campaign_path() { /// data-carrying window far shorter than the roller", which a fixed recent window /// satisfies without the full-archive probe sweep a live-span derivation costs — /// the archive only grows forward, so the window never falls off its end. The -/// leading sweep stage runs first over the short window; the refusal today fires at -/// stage 1 (the walk_forward roller), so this bites only when the window genuinely has -/// data. Gated on the GER40 archive; skips cleanly on a data-less host. +/// leading std::grid stage only enumerates (nothing runs over the short window); the +/// refusal fires at stage 1 (the walk_forward roller), so this bites only when the +/// window genuinely has data. Gated on the GER40 archive; skips cleanly on a data-less host. #[test] fn mc_real_fits_the_injected_roller_to_a_short_window() { if !local_data_present() { @@ -5862,3 +6032,78 @@ fn shipped_r_meanrev_example_runs_end_to_end_via_aura_run() { assert!(stdout.contains("\"n_trades\":0"), "{stdout}"); assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}"); } + +/// Property (#264, coverage cut 1): `aura data coverage ` surfaces the +/// archive's month COVERAGE from the monthly file index — the first and last +/// present month AND every interior missing-month range (a gap) — so the Copper +/// failure mode is visible up front. Copper had files from 2016-08 but was +/// missing 2018-01..2018-10, so a first/last-bounds check passed while a +/// window opening in the hole had no data, aborting a multi-hour campaign +/// mid-run; a per-symbol gap report would have caught it before the run. Over a +/// hermetic synthetic archive whose `GAPSYM` has 2024-01..02 and 2024-05..06 +/// present and 2024-03..04 absent, the verb exits 0, frames the span by its +/// first (2024-01) and last (2024-06) present month, and reports the interior +/// hole as a single collapsed month-range line `missing: 2024-03..2024-04` +/// (one line for the whole contiguous gap, as Copper's ten-month hole would be +/// one line — not one line per missing month). +#[test] +fn data_coverage_reports_month_span_and_interior_gap_range() { + let (cwd, _g) = fresh_project_with_gapped_data(); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["data", "coverage", "GAPSYM"]) + .current_dir(&cwd) + .output() + .expect("spawn aura data coverage"); + 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"); + // The interior gap is the headline — the Copper failure the verb exists to + // surface — reported as one collapsed range for the whole contiguous hole. + assert!( + stdout.contains("missing: 2024-03..2024-04"), + "interior gap month-range reported as one line: {stdout}" + ); + // The span bounds frame the gap: first and last present month. + assert!( + stdout.contains("2024-01") && stdout.contains("2024-06"), + "first (2024-01) and last (2024-06) present month reported: {stdout}" + ); +} + +/// Property (#264, list cut 2): `aura data list` enumerates the symbols the +/// archive contains, one per line, sorted — the discovery step a campaign +/// author (or an agent operating through the CLI alone, the deployment +/// posture) needs before `aura data coverage `, since without it the +/// inventory is obtainable only by listing the archive directory outside +/// aura. The archive root resolves through the same project path as `coverage` +/// (`Env::data_path` — a project-local `[paths] data` override when set). Over +/// a hermetic synthetic archive holding two symbols (`SYMA` 2024-01..08 and +/// `SYMB` 2024-03..06), the verb exits 0 and prints EXACTLY the two symbol +/// names in ascending order, one per line — `SYMA` before `SYMB`, nothing +/// else. +#[test] +fn data_list_enumerates_archive_symbols_one_per_line_sorted() { + let (cwd, _g) = fresh_project_with_data(); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["data", "list"]) + .current_dir(&cwd) + .output() + .expect("spawn aura data list"); + 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"); + // The whole contract: the two archive symbols, sorted, one per line, and + // nothing else — this is the inventory a campaign author reads before + // scoping an instrument matrix. + assert_eq!(stdout, "SYMA\nSYMB\n", "sorted symbol enumeration: {stdout:?}"); +} diff --git a/crates/aura-cli/tests/common/mod.rs b/crates/aura-cli/tests/common/mod.rs index 7ac6fff..8b3697b 100644 --- a/crates/aura-cli/tests/common/mod.rs +++ b/crates/aura-cli/tests/common/mod.rs @@ -55,6 +55,15 @@ impl Drop for FreshProject { /// Mints a unique, empty tempdir under `std::env::temp_dir()`, named /// `aura-e2e--` so concurrent test threads (same process) and /// concurrent `cargo test` processes never collide. +/// +/// #258 sweep note: this site keeps `std::process::id()` deliberately, unlike +/// the fixed, pid-free sandbox names the rest of #258's fix uses — it has no +/// pre-create wipe to defeat in the first place. `FreshProject::drop` (above) +/// unconditionally removes the returned root when the owning test ends (pass, +/// fail, or panic-unwind), so nothing strands across runs; the pid instead +/// disambiguates concurrent `cargo test` *processes* racing this one function +/// from dozens of call sites, which a fixed tag-keyed name cannot do without +/// plumbing a unique tag through every one of them. fn mint_tempdir() -> PathBuf { static COUNTER: AtomicUsize = AtomicUsize::new(0); let root = std::env::temp_dir().join(format!( @@ -118,6 +127,26 @@ pub fn fresh_project_with_data() -> (PathBuf, FreshProject) { (root.clone(), FreshProject { root }) } +/// Like [`fresh_project_with_data`], but the single symbol `GAPSYM` carries a +/// DELIBERATE interior month gap: monthly files exist for 2024-01..2024-02 and +/// 2024-05..2024-06, while 2024-03..2024-04 are absent. This is the Copper +/// failure shape (#264 — files present at both ends while an interior window +/// has no data, so a first/last-bounds check passes but a campaign aborts +/// mid-run). For tests whose property is per-symbol month-gap reporting: the +/// two `write_symbol_archive` calls write disjoint contiguous segments into the +/// same `data/` dir (the second re-writes GAPSYM's identical geometry sidecar), +/// leaving March and April as a real hole in the monthly file index. +#[allow(dead_code)] +pub fn fresh_project_with_gapped_data() -> (PathBuf, FreshProject) { + let root = mint_tempdir(); + write_project_files(&root, Some("data")); + let data_dir = root.join("data"); + std::fs::create_dir_all(&data_dir).expect("create synthetic gapped archive dir"); + synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 1), (2024, 2)); + synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 5), (2024, 6)); + (root.clone(), FreshProject { root }) +} + /// A scratch filesystem entry a test writes under the git-tracked /// demo-project fixture root (only `runs/` there is fixture-gitignored), /// removed on drop — including during a mid-test panic — so a failed diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index bbc3e28..e5844d6 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() { let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( lines.len(), - 28, - "the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}" + 29, + "the std-only (no project) vocabulary has exactly the roster's 29 entries: {stdout}" ); } @@ -382,7 +382,7 @@ fn graph_introspect_no_flag_is_usage_exit_2() { /// A fresh, unique working directory for a test that persists content-addressed /// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`). fn temp_cwd(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name)); + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index f979325..46023b8 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -368,7 +368,8 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() { /// "binds no node crate" hint (not the outside-project "no Aura.toml" text). #[test] fn data_only_project_hints_attach_for_namespaced_ids() { - let tmp = std::env::temp_dir().join(format!("aura-dataonly-hint-{}", std::process::id())); + let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap(); let bp = tmp.join("bp.json"); @@ -388,7 +389,8 @@ fn data_only_project_hints_attach_for_namespaced_ids() { #[test] fn missing_artifact_refuses_with_build_hint() { // A minimal never-built project: valid cargo metadata, no dylib on disk. - let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id())); + let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild"); + let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(tmp.join("src")).unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap(); std::fs::write(tmp.join("src/lib.rs"), "").unwrap(); diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs index 6fbae50..a7f3da9 100644 --- a/crates/aura-cli/tests/project_new.rs +++ b/crates/aura-cli/tests/project_new.rs @@ -14,8 +14,19 @@ fn aura(args: &[&str], cwd: &Path) -> Output { .expect("run aura") } +/// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()` +/// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other +/// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`) +/// require a base genuinely detached from any git work tree, and +/// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`. +/// The checkout discriminator keeps concurrently running checkouts/worktrees +/// (which share `env::temp_dir()`) off each other's fixed names while the +/// name stays deterministic per checkout, so each run still reclaims the last. fn tmp(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id())); + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + env!("CARGO_MANIFEST_DIR").hash(&mut h); + let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32)); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d diff --git a/crates/aura-cli/tests/project_nodes.rs b/crates/aura-cli/tests/project_nodes.rs index cccba91..ba2bcb8 100644 --- a/crates/aura-cli/tests/project_nodes.rs +++ b/crates/aura-cli/tests/project_nodes.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::Command; fn temp_cwd(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id())); + let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}")); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d @@ -18,6 +18,39 @@ fn aura(args: &[&str], cwd: &Path) -> std::process::Output { .expect("spawn aura") } +/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must +/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so +/// its own pre-create `remove_dir_all` reclaims the previous run instead of +/// stranding one `aura-nodes-*` directory per test-binary invocation. +/// +/// The helper today keys the name on `std::process::id()`, so every run mints a +/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs +/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling +/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This +/// pins that property at one representative site — the assertion reads the path +/// value only, so it is deterministic and does not depend on any external state. +#[test] +fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() { + let sandbox = temp_cwd("leakguard"); + // Read the path value, then reclaim immediately so a red run of this very + // test does not itself add to the leak it describes. + let path = sandbox.clone(); + let _ = std::fs::remove_dir_all(&sandbox); + + let pid = std::process::id().to_string(); + assert!( + !path.to_string_lossy().contains(&pid), + "sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \ + can reclaim it — one directory leaks per run: {}", + path.display(), + ); + assert!( + !path.starts_with(std::env::temp_dir()), + "sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}", + path.display(), + ); +} + #[test] fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() { let cwd = temp_cwd("attach"); diff --git a/crates/aura-cli/tests/project_sweep_campaign.rs b/crates/aura-cli/tests/project_sweep_campaign.rs index 35d848d..a7d1dcd 100644 --- a/crates/aura-cli/tests/project_sweep_campaign.rs +++ b/crates/aura-cli/tests/project_sweep_campaign.rs @@ -331,7 +331,7 @@ fn project_node_open_param_runs_one_campaign_cell() { // Archive-gated: skip cleanly on a data-less machine (the member-data // refusal, never a panic) — mirroring campaign_run_real_e2e_*. - if run.status.code() == Some(1) + if run.status.code() == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign cell"); diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 4318fb4..49f5ed1 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -5,14 +5,17 @@ use std::path::{Path, PathBuf}; mod common; -use common::{ScratchGuard, ScratchPath, fresh_project}; +use common::{ + ScratchGuard, ScratchPath, fresh_project, fresh_project_with_data, + fresh_project_with_gapped_data, +}; /// A fresh, unique working directory for a process test that persists /// content-addressed documents under `./runs/` (so `aura process register` /// never dirties the repo). Unique per test + per process; no external /// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`). fn temp_cwd(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name)); + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir @@ -493,6 +496,30 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() { assert!(!out.contains("BadRegime"), "Debug leak: {out}"); } +/// #262: the `vol_tf` regime variant is refused end-to-end by `campaign +/// validate` on a non-positive `period_minutes`, the same CLI seam #210's +/// `vol`-regime refusal test pins — before this, `RiskRegime::VolTf`'s +/// `period_minutes < 1` check was proven only inside `aura-research`'s own +/// unit tests (`validate_campaign_flags_non_positive_period_minutes_regime`); +/// this pins that a malformed vol_tf regime is actually unreachable at the +/// CLI seam a data author drives: exit 1, no bare `BadRegime` Debug leak. +#[test] +fn campaign_validate_refuses_bad_vol_tf_regime_prose_exit_1() { + let dir = temp_cwd("campaign-validate-vol-tf-bad"); + let with_bad_risk = CAMPAIGN_DOC.replacen( + "\"seed\": 1,", + "\"seed\": 1,\n \"risk\": [ { \"vol_tf\": { \"period_minutes\": 0, \"length\": 3, \"k\": 1.5 } } ],", + 1, + ); + assert_ne!(with_bad_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field"); + write_doc(&dir, "risk-bad-vol-tf.campaign.json", &with_bad_risk); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad-vol-tf.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("risk[0]:"), "stdout/stderr: {out}"); + assert!(!out.contains("BadRegime"), "Debug leak: {out}"); + assert!(!out.contains("VolTf"), "Debug leak: {out}"); +} + /// #216: the intrinsic validate summary reports the realized matrix — the /// exec cross-product strategies × instruments × windows × regimes — so an /// 8-cell document no longer reads as regime-less. Points (the axis grid) are @@ -558,11 +585,11 @@ fn campaign_introspect_block_risk_describes_the_regime_shape() { let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( - out.contains("list of stop regimes { vol: { length, k } }; absent = one default regime"), + out.contains("list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime"), "the block description must name the regime shape: {out}" ); assert!( - out.contains("optional, list of stop regimes { vol: { length, k } }; absent = one default regime"), + out.contains("optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime"), "the risk slot must render as optional: {out}" ); assert!( @@ -603,7 +630,7 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() { assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( out.contains( - "open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)" + "open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)" ), "stdout/stderr: {out}" ); @@ -618,7 +645,7 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() { let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]); assert_eq!(code, Some(0)); assert!( - out.contains("open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)"), + out.contains("open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)"), "stdout/stderr: {out}" ); } @@ -661,12 +688,81 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_explicitly_empty() { assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!( out.contains( - "open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)" + "open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime)" ), "an explicit empty risk array must still count as an open slot: {out}" ); } +/// #265: the cost and risk slots name their knobs but not their UNITS or +/// SEMANTICS, and the two natural misreadings are silent (wrong by orders of +/// magnitude / change an experiment's meaning, with no error). Property: when +/// `campaign introspect --unwired` lists the open cost and risk slots, each of +/// the four silently-misreadable knobs carries a one-line unit/semantics note +/// that blocks its misreading — `constant.cost_per_trade` is in PRICE units +/// (charged per close as cost/|entry−stop| in R), `vol_slippage.slip_vol_mult` +/// is a MULTIPLIER on the per-cycle vol estimate (charged in R likewise), +/// `vol.length` SMOOTHS the estimator and does NOT set the stop's timescale, +/// and `vol.k` SCALES the stop distance. Autonomous: a bare `{}` envelope +/// (both slots open) written inline, no project, hostless. Wording stays free +/// (matched case-insensitively on load-bearing fragments, not whole lines); it +/// fails today because the annotations are absent, not on scaffolding. +#[test] +fn campaign_introspect_unwired_annotates_cost_and_risk_knob_units() { + let dir = temp_cwd("campaign-introspect-unwired-knob-units"); + write_doc(&dir, "bare.json", "{}"); + let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let low = out.to_lowercase(); + + // constant.cost_per_trade — price units, not R (the order-of-magnitude trap). + assert!(low.contains("cost_per_trade"), "the cost knob must still be named: {out}"); + assert!( + low.contains("price units"), + "cost_per_trade must be annotated as a PRICE-unit numerator (its misreading is a cost in R): {out}" + ); + + // vol_slippage.slip_vol_mult — a multiplier on the per-cycle vol estimate. + assert!(low.contains("slip_vol_mult"), "the vol-slippage knob must still be named: {out}"); + assert!( + low.contains("multiplier on the per-cycle vol"), + "slip_vol_mult must be annotated as a MULTIPLIER on the per-cycle vol estimate: {out}" + ); + + // vol.length — smooths the estimator; it does NOT set the stop's timescale. + assert!( + low.contains("smooths"), + "vol.length must be annotated as SMOOTHING the per-cycle vol estimator: {out}" + ); + assert!( + low.contains("does not set the stop's timescale"), + "vol.length must state it does NOT set the stop's timescale (its exact misreading): {out}" + ); + + // vol.k — scales the stop distance (the risk-unit lever). + assert!( + low.contains("scales the stop distance"), + "vol.k must be annotated as SCALING the stop distance (the risk-unit lever): {out}" + ); + + // vol_tf — period_minutes IS the timescale (the #262 knob the vol notes + // say vol.length is NOT). + assert!(low.contains("vol_tf"), "the vol_tf regime must be annotated: {out}"); + assert!( + low.contains("period_minutes is the stop's timescale"), + "vol_tf must state that period_minutes sets the timescale: {out}" + ); + + // carry.carry_per_cycle — the identical price-unit-vs-R trap, accruing per + // held cycle (audit follow-up to #265: the note set must cover ALL three + // cost components, not two of three). + assert!(low.contains("carry_per_cycle"), "the carry knob must still be named: {out}"); + assert!( + low.contains("price units per held cycle"), + "carry_per_cycle must be annotated as a PRICE-unit per-held-cycle accrual: {out}" + ); +} + #[test] fn campaign_introspect_unwired_reports_the_spec_example_slots() { let dir = temp_cwd("campaign-introspect-unwired"); @@ -1010,7 +1106,10 @@ fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String { /// the RAW `param_space` names (`fast.length` / `slow.length` — see the /// naming note in the referential test above). `persist_taps`/`emit` are /// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.). -fn campaign_doc_json( +/// [`campaign_doc_json`] with the instrument spliced — the synthetic-archive +/// e2e targets `SYMA` (tests/common/mod.rs) instead of the hardcoded GER40. +fn campaign_doc_json_for( + instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64), @@ -1022,7 +1121,7 @@ fn campaign_doc_json( "format_version": 1, "kind": "campaign", "name": "run-seam", - "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "data": {{ "instruments": ["{instrument}"], "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, 16] }} }} }} ], @@ -1035,6 +1134,47 @@ fn campaign_doc_json( ) } +fn campaign_doc_json( + bp_id: &str, + proc_id: &str, + window: (i64, i64), + persist_taps: &str, + emit: &str, +) -> String { + campaign_doc_json_for("GER40", bp_id, proc_id, window, persist_taps, emit) +} + +/// [`campaign_doc_json_for`] over TWO instruments plus a verbatim cost block +/// (#260) — the per-instrument-cost e2es need both synthetic symbols in one +/// document. `cost` is spliced verbatim (a full `"cost": [...],` line +/// including the trailing comma, or `""` for none). +fn campaign_doc_json_two( + instruments: (&str, &str), + cost: &str, + bp_id: &str, + proc_id: &str, + window: (i64, i64), +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam", + "data": {{ "instruments": ["{a}", "{b}"], "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}" }} }}, + {cost}"seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + a = instruments.0, + b = instruments.1, + from = window.0, + to = window.1, + ) +} + /// An mc-bearing process: intrinsically valid AND (since the v2 executor) an /// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep /// survivor. The two addressing-mode tests below run it over the [1, 2] 1970 @@ -1361,6 +1501,137 @@ fn campaign_validate_accepts_a_terminal_selection_free_sweep() { ); } +/// A hand-authored (non-sugar) `[std::grid, std::walk_forward]` process — the +/// #256 fork B shape the `walkforward`/`mc` verb sugar now generates +/// internally, but authored here directly as an op-script the way a data-only +/// project author would reach it. +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": "net_expectancy_r", "select": "argmax" } + ] +}"#; + +/// The same leading `std::grid` stage, but with a `std::gate` interposed +/// before `std::walk_forward` — the one placement `campaign run`'s preflight +/// must refuse (#256 fork B: a `std::grid` first stage yields bare parameter +/// points, not executed member reports, so it is legal ONLY immediately +/// before `std::walk_forward`). +const GRID_THEN_GATE_THEN_WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-gate-then-walkforward", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" } + ] +}"#; + +/// Property (#256 fork B): `campaign run`'s data-free preflight, exercised +/// through the real CLI binary over a hand-authored op-script (not the +/// verb-sugar translation), refuses a `std::grid` first stage that is not +/// immediately followed by `std::walk_forward` — here, a `std::gate` +/// interposed between them — before any member runs (the [1, 2] 1970-epoch-ms +/// window is never reached). A regression that let this placement through +/// would hand `std::gate` bare parameter points instead of the executed +/// member reports it retains on, silently corrupting the gate's survivor +/// filter. The detail is Debug-free prose, not the `PipelineShape`/`ExecFault` +/// identifier. +#[test] +fn campaign_run_refuses_a_grid_stage_not_immediately_before_walk_forward() { + let (dir, _fixture) = fresh_project(); + 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("gridgate.process.json")), + ScratchPath::File(dir.join("gridgate.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-gridgate-seed"); + let proc_id = register_process_doc( + &dir, + "gridgate.process.json", + GRID_THEN_GATE_THEN_WF_PROCESS_DOC, + ); + write_doc( + &dir, + "gridgate.campaign.json", + &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""), + ); + let (out, code) = run_code_in(&dir, &["campaign", "run", "gridgate.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("process pipeline is not executable:"), "stdout/stderr: {out}"); + assert!( + out.contains( + "a std::grid first stage must be immediately followed by std::walk_forward" + ), + "the detail names the adjacency rule: {out}" + ); + assert!(!out.contains("PipelineShape"), "Debug leak: {out}"); +} + +/// Property (#256 fork B): a hand-authored `[std::grid, std::walk_forward]` +/// op-script — not the verb-sugar translation, which already pins this shape +/// in `cli_run.rs` — runs to completion over the synthetic SYMA archive, and +/// the persisted `CampaignRunRecord`'s leading `std::grid` stage carries no +/// `family_id`: the enumerate-only leading stage crosses the stage seam as +/// bare points and never executes or persists a family of its own, while the +/// following `std::walk_forward` stage does. (`aura runs families` is not +/// used here: `seed_blueprint` itself runs a real `aura sweep` to register +/// the blueprint, which stamps its own incidental Sweep family and would +/// confound a registry-wide family count — the record's own stage array is +/// the unconfounded source of truth.) A regression that made a hand-authored +/// `std::grid` stage execute or persist a family would flip +/// `stages[0]["family_id"]` from null here even though the sugar-generated +/// shape (a distinct code path) stayed green. +#[test] +fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() { + 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("gridwf.process.json")), + ScratchPath::File(dir.join("gridwf.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-gridwf-seed"); + let proc_id = register_process_doc(&dir, "gridwf.process.json", GRID_THEN_WF_PROCESS_DOC); + // SYMA's synthetic archive spans 2024-01..08; Mar-01..Jun-30 (~17 weeks) + // gives the (14d, 7d, 7d) roller a comfortable tiling (the cost-e2e + // siblings' window). + write_doc( + &dir, + "gridwf.campaign.json", + &campaign_doc_json_for( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1719791999999), + "", + "", + ), + ); + let (out, code) = run_code_in(&dir, &["campaign", "run", "gridwf.campaign.json"]); + assert_eq!(code, Some(0), "campaign run failed: {out}"); + + let 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(line).expect("record parses"); + let stages = &v["campaign_run"]["cells"][0]["stages"]; + assert_eq!(stages[0]["block"], "std::grid"); + assert!(stages[0]["family_id"].is_null(), "std::grid persists no family: {stages}"); + assert_eq!(stages[1]["block"], "std::walk_forward"); + assert!(!stages[1]["family_id"].is_null(), "std::walk_forward persists its family: {stages}"); +} + /// The v2 boundary, campaign side: a generalize-bearing process is an /// executable shape, but `std::generalize` needs >= 2 instruments in the /// campaign — a STATIC preflight fact (the referential gate has run, no data @@ -1731,7 +2002,7 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() { /// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` + /// `doc_fault_prose` composition `campaign validate` shares), before /// registration, before the referential tier, before any member runs — and -/// therefore before the (Task-3-doomed) loud-deferral note, whose absence is +/// therefore before the retired loud-deferral note, whose absence is /// pinned here. #[test] fn campaign_run_refuses_unknown_tap_at_validate() { @@ -1769,14 +2040,13 @@ fn campaign_run_refuses_unknown_tap_at_validate() { /// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails /// through the new intrinsic gate (no UnknownTap prose) and the run proceeds -/// to refuse at the member-data seam, exactly as a tap-less run over the -/// [1, 2] 1970 epoch-ms window does (data-less host: missing geometry; -/// data-ful host: no archive file overlaps) — exit 1 either way, and no -/// trace output (tracing never starts on a run that dies before members; -/// there is no "traces persisted" summary line). NOTE: the 0107 -/// loud-deferral eprintln still fires in this task's tree and is -/// deliberately NOT asserted either way here — Task 3 deletes it and -/// tightens this test to pin its absence. +/// to the member-data seam, exactly as a tap-less run over the [1, 2] 1970 +/// epoch-ms window does (data-less host: missing geometry; data-ful host: no +/// archive file overlaps) — #272: the member-data refusal is now a CONTAINED +/// cell fault, not a campaign abort, so the run completes (exit 3, "completed +/// with failed cells") and the always-on record line still carries the +/// failed cell's fault. The retired 0107 loud-deferral note ("persist_taps not +/// yet honored") must not appear — its absence is asserted below. #[test] fn campaign_run_valid_tap_reaches_the_member_data_seam() { let (dir, _fixture) = fresh_project(); @@ -1795,7 +2065,7 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() { &campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""), ); let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert_eq!(code, Some(3), "stdout/stderr: {out}"); assert!( !out.contains("unknown tap"), "\"equity\" is in the closed vocabulary and must pass validate: {out}" @@ -1804,17 +2074,25 @@ fn campaign_run_valid_tap_reaches_the_member_data_seam() { out.contains("no recorded geometry") || out.contains("no data for instrument"), "the refusal names the data condition: {out}" ); - assert!( - !out.contains("traces persisted"), - "no trace summary on a run that never reached a member: {out}" - ); assert!( !out.contains("persist_taps not yet honored"), "the 0107 deferral line is retired; persist_taps is honored from 0109 on: {out}" ); + // The record shows the cell as failed and the summary names the count — + // not the brittle "no traces persisted" absence: a contained fault still + // reaches (and passes through) the trace-persistence tail, which honestly + // reports 0 cells written, so "traces persisted:" DOES appear now. assert!( - !out.contains("traces persisted:"), - "a member-data refusal aborts inside execute, before any tracing: {out}" + out.contains("1 failed"), + "the campaign-run summary names the one failed cell: {out}" + ); + let record_line = out + .lines() + .find(|l| l.starts_with("{\"campaign_run\":")) + .expect("the always-on final campaign_run line"); + assert!( + record_line.contains("\"fault\""), + "the recorded cell carries its fault: {record_line}" ); } @@ -1859,7 +2137,7 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() { let (out, code) = run_code_in(&dir, &["campaign", "run", "wf.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign e2e"); @@ -1990,6 +2268,340 @@ fn campaign_run_real_e2e_sweep_gate_walkforward() { ); } +/// Property (#259): a campaign's cost block reaches the walk-forward → +/// monte-carlo evidence chain — the pooled-OOS bootstrap resamples the +/// COST-NETTED per-trade series, so the same matrix run with and without a +/// cost block records DIFFERENT `pooled_oos` stats (today's bug: identical to +/// every digit). Hostless: runs over the synthetic SYMA archive, no +/// data-mount skip-guard. +#[test] +fn campaign_run_synthetic_e2e_cost_block_nets_the_pooled_oos_bootstrap() { + 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("netmc.process.json")), + ScratchPath::File(dir.join("gross.campaign.json")), + ScratchPath::File(dir.join("net.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-netmc-seed"); + let proc_id = register_process_doc(&dir, "netmc.process.json", WF_PROCESS_DOC); + // SYMA's synthetic archive spans 2024-01..08; Mar-01..Jun-30 (~17 weeks) + // gives the (14d, 7d, 7d) roller a comfortable tiling. + let base = campaign_doc_json_for( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1719791999999), + "", + "", + ); + // The costed twin: the SAME document plus a constant cost block — the + // only difference (the replacen pattern of the cost e2e siblings). + let with_cost = base.replacen( + "\"seed\": 7,", + "\"seed\": 7,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 0.5 } } ],", + 1, + ); + assert_ne!(with_cost, base, "replacen must actually match the seed field"); + write_doc(&dir, "gross.campaign.json", &base); + write_doc(&dir, "net.campaign.json", &with_cost); + + // Both runs share the store (the cost-e2e sibling pattern) — the record + // line is read from each run's own stdout, so no isolation is needed. + let pooled = |doc: &str| -> serde_json::Value { + let (out, code) = run_code_in(&dir, &["campaign", "run", doc]); + assert_eq!(code, Some(0), "campaign run failed: {out}"); + let 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(line).expect("record parses"); + v["campaign_run"]["cells"][0]["stages"][3]["bootstrap"]["pooled_oos"].clone() + }; + let gross = pooled("gross.campaign.json"); + let net = pooled("net.campaign.json"); + + let n_gross = gross["n_trades"].as_u64().expect("gross n_trades"); + let n_net = net["n_trades"].as_u64().expect("net n_trades"); + assert!(n_gross > 0, "the synthetic window must produce OOS trades: {gross}"); + assert_eq!( + n_gross, n_net, + "cost is a feed-forward subtraction — it must not change the trade population" + ); + let mean_gross = gross["e_r"]["mean"].as_f64().expect("gross mean"); + let mean_net = net["e_r"]["mean"].as_f64().expect("net mean"); + assert!( + mean_net < mean_gross, + "the costed bootstrap must resample the NET series (#259): gross {mean_gross} vs net {mean_net}" + ); + let p_gross = gross["prob_le_zero"].as_f64().expect("gross prob_le_zero"); + let p_net = net["prob_le_zero"].as_f64().expect("net prob_le_zero"); + assert!( + p_net >= p_gross, + "a strictly-lower resample mean cannot lower prob_le_zero: gross {p_gross} vs net {p_net}" + ); +} + +/// Property (#259), the OTHER mc bootstrap input shape: with no +/// `walk_forward` in the pipeline (`sweep -> monte_carlo`), each surviving +/// sweep member gets its OWN bootstrap (`PerSurvivor`, keyed by ordinal) — +/// a distinct code path from `PooledOos` above (`member_net_trade_rs` / +/// `SweepPoint` in aura-registry, not the wf-family pooling in +/// aura-campaign). That path must resample the same cost-netted +/// `net_trade_rs` conduit: a cost block must shift EVERY surviving member's +/// resampled mean down, never leave any member's bootstrap gross-identical. +/// Hostless: runs over the synthetic SYMA archive, no data-mount skip-guard. +/// The gated real-data sibling (`campaign_run_real_e2e_sweep_monte_carlo_per_survivor`) +/// exercises this shape but never with a cost block, so it cannot pin this property. +#[test] +fn campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap() { + 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("persurvivor.process.json")), + ScratchPath::File(dir.join("gross_ps.campaign.json")), + ScratchPath::File(dir.join("net_ps.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-persurvivor-seed"); + let proc_id = register_process_doc(&dir, "persurvivor.process.json", MC_PROCESS_DOC); + // Same SYMA window as the pooled-OOS sibling above — known to produce + // trades over the synthetic archive. + let base = campaign_doc_json_for( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1719791999999), + "", + "", + ); + let with_cost = base.replacen( + "\"seed\": 7,", + "\"seed\": 7,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 0.5 } } ],", + 1, + ); + assert_ne!(with_cost, base, "replacen must actually match the seed field"); + write_doc(&dir, "gross_ps.campaign.json", &base); + write_doc(&dir, "net_ps.campaign.json", &with_cost); + + let per_survivor = |doc: &str| -> Vec<(u64, serde_json::Value)> { + let (out, code) = run_code_in(&dir, &["campaign", "run", doc]); + assert_eq!(code, Some(0), "campaign run failed: {out}"); + let 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(line).expect("record parses"); + v["campaign_run"]["cells"][0]["stages"][1]["bootstrap"]["per_survivor"] + .as_array() + .expect("no walk_forward precedes: the PerSurvivor arm, not pooled_oos") + .iter() + .map(|pair| (pair[0].as_u64().expect("ordinal"), pair[1].clone())) + .collect() + }; + let gross = per_survivor("gross_ps.campaign.json"); + let net = per_survivor("net_ps.campaign.json"); + + assert_eq!(gross.len(), 4, "2x2 axes -> four surviving members: {gross:?}"); + assert_eq!(gross.len(), net.len(), "cost must not change the surviving-member population"); + for ((g_ord, g), (n_ord, n)) in gross.iter().zip(net.iter()) { + assert_eq!(g_ord, n_ord, "the two runs must enumerate members in the same ordinal order"); + let n_trades_g = g["n_trades"].as_u64().expect("gross n_trades"); + let n_trades_n = n["n_trades"].as_u64().expect("net n_trades"); + assert!(n_trades_g > 0, "member {g_ord} must have produced trades: {g}"); + assert_eq!( + n_trades_g, n_trades_n, + "member {g_ord}: cost is a feed-forward subtraction — it must not change the trade population" + ); + let mean_g = g["e_r"]["mean"].as_f64().expect("gross mean"); + let mean_n = n["e_r"]["mean"].as_f64().expect("net mean"); + assert!( + mean_n < mean_g, + "member {g_ord}: the costed PerSurvivor bootstrap must resample the NET series (#259): gross {mean_g} vs net {mean_n}" + ); + } +} + +/// Property (#260): an instrument-keyed cost knob resolves PER CELL — each +/// emitted member's manifest stamps the value for ITS instrument under the +/// unchanged cost[k]. key. Hostless over the synthetic SYMA+SYMB +/// archive (SYMB's 2024-03..06 span contains the window). +#[test] +fn campaign_run_synthetic_e2e_per_instrument_cost_resolves_per_cell() { + 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("percost.process.json")), + ScratchPath::File(dir.join("percost.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-percost-seed"); + let proc_id = register_process_doc(&dir, "percost.process.json", SWEEP_ONLY_PROCESS_DOC); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + &bp_id, + &proc_id, + (1709251200000, 1717199999999), + ); + write_doc(&dir, "percost.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "run", "percost.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + + // Member emit lines carry report.manifest; group the stamped resolved + // value by the member's instrument stamp (the #220-era assertion idiom). + let mut seen = std::collections::BTreeMap::new(); + for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses"); + let inst = v["report"]["manifest"]["instrument"] + .as_str() + .expect("instrument stamped") + .to_string(); + let params = v["report"]["manifest"]["params"].as_array().expect("params array"); + let cost_param = params + .iter() + .find(|p| p[0].as_str() == Some("cost[0].cost_per_trade")) + .unwrap_or_else(|| panic!("cost stamp missing on {inst} member: {line}")); + let val = cost_param[1]["F64"].as_f64().expect("stamped as F64"); + seen.insert(inst, val); + } + assert_eq!( + seen, + std::collections::BTreeMap::from([ + ("SYMA".to_string(), 2.5), + ("SYMB".to_string(), 0.25), + ]), + "each cell must stamp ITS instrument's resolved cost: {out}" + ); +} + +/// Property (#262): a two-regime risk matrix realizes one cell per regime +/// with distinct ordinals, and each cell's members stamp THEIR regime's stop +/// knobs — the vol_tf cells carry stop_period_minutes (the round-trip pin), +/// the vol cells do not. Hostless over the synthetic SYMA archive. +#[test] +fn campaign_run_synthetic_e2e_vol_tf_regime_realizes_and_stamps() { + 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("voltf.process.json")), + ScratchPath::File(dir.join("voltf.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-voltf-seed"); + let proc_id = register_process_doc(&dir, "voltf.process.json", SWEEP_ONLY_PROCESS_DOC); + let base = campaign_doc_json_for( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1717199999999), + "", + "\"family_table\"", + ); + let with_risk = base.replacen( + "\"seed\": 7,", + "\"seed\": 7,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } }, { \"vol_tf\": { \"period_minutes\": 60, \"length\": 6, \"k\": 2.0 } } ],", + 1, + ); + assert_ne!(with_risk, base, "replacen must actually match the seed field"); + write_doc(&dir, "voltf.campaign.json", &with_risk); + let (out, code) = run_code_in(&dir, &["campaign", "run", "voltf.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {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("record parses"); + let cells = v["campaign_run"]["cells"].as_array().expect("cells"); + assert_eq!(cells.len(), 2, "one cell per regime: {record_line}"); + // regime_ordinal 0 (the first/default-slot regime) is skip_serialize'd + // (CellRealization::is_zero_ordinal, byte-stability for pre-#219 stored + // lines) — absent means 0, not a missing field. + let ordinals: Vec = + cells.iter().map(|c| c["regime_ordinal"].as_u64().unwrap_or(0)).collect(); + assert_eq!(ordinals, vec![0, 1], "distinct regime ordinals: {record_line}"); + + // Member emit lines: vol_tf members stamp stop_period_minutes, vol members do not. + let (mut tf_members, mut vol_members) = (0usize, 0usize); + for line in out.lines().filter(|l| l.starts_with("{\"family_id\":")) { + let m: serde_json::Value = serde_json::from_str(line).expect("member line parses"); + let params = m["report"]["manifest"]["params"].as_array().expect("params"); + let has_tf = params.iter().any(|p| p[0].as_str() == Some("stop_period_minutes")); + if has_tf { + let pm = params + .iter() + .find(|p| p[0].as_str() == Some("stop_period_minutes")) + .expect("present"); + assert_eq!(pm[1]["I64"].as_i64(), Some(60), "the stamped timescale: {line}"); + tf_members += 1; + } else { + vol_members += 1; + } + } + assert!(tf_members > 0, "vol_tf members must exist and stamp their timescale: {out}"); + assert!(vol_members > 0, "vol members must exist without the vol_tf stamp: {out}"); +} + +/// Property (#260): map-key mismatches refuse at validate with prose naming +/// the component and instruments — never mid-run, never a Debug struct. +#[test] +fn campaign_validate_refuses_a_cost_map_missing_an_instrument() { + let dir = temp_cwd("campaign-validate-percost-missing"); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + "9f3a", + "9f3a", + (1709251200000, 1717199999999), + ); + write_doc(&dir, "bad.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]); + assert_eq!(code, Some(1), "a key-set mismatch is a validate fault: {out}"); + assert!( + out.contains("cost[0]") && out.contains("SYMB"), + "prose names the component and the missing instrument: {out}" + ); + assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}"); +} + +/// Property (#260): an instrument map naming a key that is NOT one of the +/// campaign's instruments (a typo'd symbol) is refused too — the sibling +/// defect to the missing-instrument case above, and a distinct prose branch +/// (`doc_fault_prose`'s `extra` arm) that was otherwise never exercised past +/// the raw `DocFault` value in the aura-research unit tests. +#[test] +fn campaign_validate_refuses_a_cost_map_with_an_unknown_instrument_key() { + let dir = temp_cwd("campaign-validate-percost-extra"); + let cost = "\"cost\": [ { \"constant\": { \"cost_per_trade\": { \"SYMA\": 2.5, \"SYMB\": 0.25, \"SYMA.cash\": 1.0 } } } ],\n "; + let doc = campaign_doc_json_two( + ("SYMA", "SYMB"), + cost, + "9f3a", + "9f3a", + (1709251200000, 1717199999999), + ); + write_doc(&dir, "bad_extra.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad_extra.campaign.json"]); + assert_eq!(code, Some(1), "an unmatched map key is a validate fault: {out}"); + assert!( + out.contains("cost[0]") && out.contains("SYMA.cash"), + "prose names the component and the unmatched key: {out}" + ); + assert!( + out.contains("names no campaign instrument"), + "prose uses the extra-key phrasing, not the missing-instrument one: {out}" + ); + assert!(!out.contains("CostInstrumentKeys"), "Debug leak: {out}"); +} + /// The campaign `data.bindings` override end to end (#231, 6b): the same /// seeded strategy, window, and seed run twice — bare (price<-close default) /// and with `price` rebound to the open column. Both exit 0; the realized @@ -2025,7 +2637,7 @@ fn campaign_run_real_e2e_bindings_override_rebinds_price_to_open() { write_doc(&dir, "open.campaign.json", &rebound); let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]); - if code_close == Some(1) + if code_close == Some(3) && (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the binding-override e2e"); @@ -2089,7 +2701,7 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() { write_doc(&dir, "regime.campaign.json", &with_regime); let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]); - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign regime e2e"); @@ -2159,7 +2771,7 @@ fn campaign_run_real_e2e_two_regimes_each_stamp_their_own_stop() { write_doc(&dir, "tworegimes.campaign.json", &with_regimes); let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]); - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign two-regime e2e"); @@ -2247,7 +2859,7 @@ fn campaign_persist_non_default_regime_does_not_false_fail_c1() { let (out, code) = run_code_in(&dir, &["campaign", "run", "regime-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign regime-persist e2e"); @@ -2327,7 +2939,7 @@ fn campaign_run_real_e2e_costed_campaign_persists_without_drift_alarm() { let (out, code) = run_code_in(&dir, &["campaign", "run", "cost-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign cost-persist e2e"); @@ -2422,7 +3034,7 @@ fn campaign_run_real_e2e_net_r_equity_tap_persists_the_cost_adjusted_curve() { let (out, code) = run_code_in(&dir, &["campaign", "run", "net-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign net-persist e2e"); @@ -2536,7 +3148,7 @@ fn campaign_run_real_e2e_vol_slippage_cost_persists_without_drift_alarm() { let (out, code) = run_code_in(&dir, &["campaign", "run", "volcost-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign vol-slippage-persist e2e"); @@ -2625,7 +3237,7 @@ fn campaign_persist_two_regimes_land_in_distinct_trace_dirs() { let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the two-regime persist e2e"); @@ -2703,7 +3315,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() { let (out, code) = run_code_in(&dir, &["campaign", "run", "mixedtap.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign e2e"); @@ -2817,7 +3429,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() { // (a) the cost-less baseline: net == gross, and net_r_equity skips with // the remedy notice while equity still persists. let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]); - if code_gross == Some(1) + if code_gross == Some(3) && (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the cost e2e"); @@ -2940,7 +3552,7 @@ fn campaign_run_real_e2e_carry_cost_persists_without_drift_alarm() { let (out, code) = run_code_in(&dir, &["campaign", "run", "carrycost-persist.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign carry-cost-persist e2e"); @@ -3012,7 +3624,7 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() { let (out, code) = run_code_in(&dir, &["campaign", "run", "mc.campaign.json"]); // Skip on a data-less machine: the member-data refusal, never a panic. - if code == Some(1) + if code == Some(3) && (out.contains("no recorded geometry") || out.contains("no data for instrument")) { eprintln!("skip: no local GER40 data for the campaign e2e"); @@ -3060,10 +3672,12 @@ fn campaign_run_real_e2e_sweep_monte_carlo_per_survivor() { /// by FILE and once by its own resulting content ID must refuse with the /// byte-identical prose — proof the two addressing modes are not two /// independently drifting code paths. No real data needed: with the [1, 2] -/// 1970 window, both runs pass the (now mc-permitting) preflight and refuse -/// at the member-data seam — no recorded geometry on a data-less host, no -/// overlapping archive window on a data-ful one — exit 1 and byte-identical -/// prose either way (the message depends only on env/instrument/window). +/// 1970 window, both runs pass the (now mc-permitting) preflight and the one +/// cell fails at the member-data seam — no recorded geometry on a data-less +/// host, no overlapping archive window on a data-ful one — #272: the failed +/// cell is contained, so the campaign run still completes (exit 3, "completed +/// with failed cells") and byte-identical prose either way (the message +/// depends only on env/instrument/window). #[test] fn campaign_run_by_content_id_matches_file_sugar_refusal() { let (dir, _fixture) = fresh_project(); @@ -3079,7 +3693,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() { write_doc(&dir, "mc2.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); let (file_out, file_code) = run_code_in(&dir, &["campaign", "run", "mc2.campaign.json"]); - assert_eq!(file_code, Some(1), "stdout/stderr: {file_out}"); + assert_eq!(file_code, Some(3), "stdout/stderr: {file_out}"); let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]); assert_eq!(reg_code, Some(0), "register failed: {reg_out}"); @@ -3094,7 +3708,7 @@ fn campaign_run_by_content_id_matches_file_sugar_refusal() { .trim_start_matches("content:") .to_string(); let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]); - assert_eq!(id_code, Some(1), "stdout/stderr: {id_out}"); + assert_eq!(id_code, Some(3), "stdout/stderr: {id_out}"); let file_line = file_out.lines().find(|l| l.starts_with("aura: ")).expect("file refusal line"); let id_line = id_out.lines().find(|l| l.starts_with("aura: ")).expect("id refusal line"); @@ -3195,7 +3809,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() { let proc_id = register_process_doc(&dir, "onramp.process.json", SWEEP_ONLY_PROCESS_DOC); write_doc(&dir, "onramp.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); let (out, code) = run_code_in(&dir, &["campaign", "run", "onramp.campaign.json"]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert_eq!(code, Some(3), "stdout/stderr: {out}"); assert!( out.contains("no recorded geometry") || out.contains("no data for instrument"), "referencing + axis-binding must succeed, reaching the member-data \ @@ -3239,3 +3853,76 @@ fn campaign_run_invalid_file_refuses_before_touching_store() { "an invalid document must not create a store entry" ); } + +/// #272: a two-window campaign over GAPSYM where one window falls inside the +/// March-April hole records that cell as failed (no_data) while the covered +/// window's cell completes and persists — exit 3, both cells in one run record. +#[test] +fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() { + let (dir, _fixture) = fresh_project_with_gapped_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("gap.process.json")), + ScratchPath::File(dir.join("gap.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-gap-seed"); + let proc_id = register_process_doc(&dir, "gap.process.json", SWEEP_ONLY_PROCESS_DOC); + // Window 0 (2024-01, Unix-ms [1704067200000, 1706745600000)) is fully + // archived by `fresh_project_with_gapped_data` (GAPSYM 2024-01..02); + // window 1 (2024-03, [1709251200000, 1711929600000)) falls entirely + // inside the deliberate March-April hole. + let doc = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "gap-cells", + "data": {{ "instruments": ["GAPSYM"], + "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745600000 }}, + {{ "from_ms": 1709251200000, "to_ms": 1711929600000 }} ] }}, + "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": [] }} +}}"#, + ); + write_doc(&dir, "gap.campaign.json", &doc); + let (out, code) = run_code_in(&dir, &["campaign", "run", "gap.campaign.json"]); + assert_eq!(code, Some(3), "stdout/stderr: {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 cells = v["campaign_run"]["cells"].as_array().expect("cells array"); + assert_eq!(cells.len(), 2, "one cell per window, both in one run record: {record_line}"); + + let jan_cell = &cells[0]; + let mar_cell = &cells[1]; + assert!(jan_cell["fault"].is_null(), "the January cell completes: {record_line}"); + assert!( + !jan_cell["stages"].as_array().expect("stages").is_empty(), + "the January cell realizes a walk-through stage set: {record_line}" + ); + assert!( + jan_cell["coverage"].is_object(), + "the covered window's cell is still only trading-hours-covered inside \ + a calendar-month request, so it carries a coverage annotation: {record_line}" + ); + + assert_eq!( + mar_cell["fault"]["kind"].as_str(), + Some("no_data"), + "the March cell (entirely inside the gap) fails as no_data: {record_line}" + ); + + assert!( + out.contains("1 failed"), + "the campaign-run summary names the one failed cell: {out}" + ); +} diff --git a/crates/aura-composites/src/lib.rs b/crates/aura-composites/src/lib.rs index d025fda..1e9e628 100644 --- a/crates/aura-composites/src/lib.rs +++ b/crates/aura-composites/src/lib.rs @@ -19,7 +19,7 @@ use aura_core::{PrimitiveBuilder, Scalar}; use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_std::{ cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul, - PositionManagement, Sizer, Sqrt, Sub, COST_FIELD_NAMES, GEOMETRY_WIDTH, + PositionManagement, Sizer, Sqrt, Sub, VolTfStop, COST_FIELD_NAMES, GEOMETRY_WIDTH, PM_FIELD_NAMES, }; @@ -75,6 +75,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite { pub enum StopRule { Fixed(f64), Vol { length: i64, k: f64 }, + VolTf { period_minutes: i64, length: i64, k: f64 }, } /// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal @@ -88,6 +89,12 @@ pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite { Box::new(move |g| match stop { StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), StopRule::Vol { length, k } => g.add(vol_stop(length, k)), + StopRule::VolTf { period_minutes, length, k } => g.add( + VolTfStop::builder() + .bind("period_minutes", Scalar::i64(period_minutes)) + .bind("length", Scalar::i64(length)) + .bind("k", Scalar::f64(k)), + ), }), risk_budget, ) diff --git a/crates/aura-composites/tests/risk_executor.rs b/crates/aura-composites/tests/risk_executor.rs index 7120ffe..f5647a2 100644 --- a/crates/aura-composites/tests/risk_executor.rs +++ b/crates/aura-composites/tests/risk_executor.rs @@ -180,6 +180,46 @@ fn risk_executor_vol_stop_arm_bootstraps_and_folds() { assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn); } +/// Property (#262): the RiskExecutor embeds the `VolTfStop` primitive (the new +/// `StopRule::VolTf` arm) and folds to a finite RMetric, proving the arm's +/// `price -> stop_distance` port/kind/firing wiring actually bootstraps and +/// runs — not merely compiles. Timestamps are real epoch-ns minute ticks +/// (`i * 60s` in ns) so `period_minutes: 1` rolls a bucket every cycle, +/// matching the node's own bucket-rollover contract; a short k·σ stop over a +/// rising-then-falling path opens a trade and closes at least one. +#[test] +fn risk_executor_vol_tf_stop_arm_bootstraps_and_folds() { + const MINUTE_NS: i64 = 60 * 1_000_000_000; + let (tx, rx) = channel(); + let mut g = GraphBuilder::new("vol_tf_harness"); + let price = g.source_role("price", ScalarKind::F64); + let strat = g.add(ConstLongBias::builder()); + let exec = g.add(risk_executor( + StopRule::VolTf { period_minutes: 1, length: 1, k: 2.0 }, + 1.0, + )); + let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx)); + g.feed(price, [strat.input("price"), exec.input("price")]); + g.connect(strat.output("bias"), exec.input("bias")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + let col: &'static str = format!("col[{i}]").leak(); + g.connect(exec.output(field), rec.input(col)); + } + let mut h = g + .build() + .expect("vol_tf_harness wires") + .bootstrap_with_params(vec![]) + .expect("bootstraps"); + let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0]; + let stream: Vec<(Timestamp, Scalar)> = + path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64 * MINUTE_NS), Scalar::f64(p))).collect(); + h.run(vec![Box::new(VecSource::new(stream))]); + let ledger: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + let m = summarize_r(&ledger, &[]); + assert!(m.n_trades >= 1, "the vol_tf-stop executor must fold at least one trade"); + assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn); +} + /// Drain one `risk_executor`-variant harness over the shared rising-then-falling path, /// folding the dense R-record. `exec` is the variant under test (bound or open); `params` /// is the positional point the open variant needs (empty for the bound one). Shared by the diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index f601e9a..ffa93e7 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -659,6 +659,51 @@ mod tests { assert_eq!(names, ["length"]); } + /// #261 — the Frankfurt `Session` preset is reachable from a blueprint. The + /// property this pins: a data-only op-script (a blueprint) that names the + /// preset's closed-roster type-id resolves it through `std_vocabulary`, + /// carries its one scalar `period_minutes` knob and the `bars_since_open: + /// i64` session-index output, wires its `trigger` input through the ordinary + /// edge convention, and builds to a finished `Composite`. Reachability is the + /// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned + /// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset + /// id is absent from the roster (the base `Session` builder takes structural + /// construction args, so the zero-arg macro cannot host it), so the lookup + /// returns `None` and the op-script's `Add` would fail `UnknownNodeType`. + #[test] + fn session_frankfurt_preset_is_reachable_from_a_blueprint() { + // (a) the preset resolves through the closed roster as the Session preset + // — its one scalar knob and its i64 session-index output, not some + // other node parked under the id. + let preset = std_vocabulary("SessionFrankfurt") + .expect("the Frankfurt Session preset is in the std vocabulary roster"); + assert_eq!(preset.label(), "SessionFrankfurt"); + let schema = preset.schema(); + assert_eq!(schema.params.len(), 1, "exactly one scalar knob"); + assert_eq!(schema.params[0].name, "period_minutes"); + assert_eq!(schema.params[0].kind, ScalarKind::I64); + assert_eq!(schema.inputs.len(), 1); + assert_eq!(schema.inputs[0].name, "trigger"); + assert_eq!(schema.output[0].name, "bars_since_open"); + assert_eq!(schema.output[0].kind, ScalarKind::I64); + + // (b) an op-script naming that id builds: a bound trigger source feeds the + // preset's `trigger`, its `period_minutes` knob binds, and the i64 bar + // index exposes — a data-only blueprint reaching the session stream. + let ops = vec![ + Op::Source { role: "trigger".into(), kind: ScalarKind::F64 }, + Op::Add { + type_id: "SessionFrankfurt".into(), + as_name: Some("session".into()), + bind: vec![("period_minutes".into(), Scalar::i64(15))], + }, + Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] }, + Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() }, + ]; + super::replay("session_blueprint", ops, &std_vocabulary) + .expect("the preset resolves, wires and builds through an op-script"); + } + /// Eager refusals: unknown member param (a member bound at `Add` has left the /// open schema), kind mismatch across members, a doubly-ganged member, a /// single-member gang, and an unknown node identifier. diff --git a/crates/aura-ingest/src/lib.rs b/crates/aura-ingest/src/lib.rs index ceb3dcf..5193180 100644 --- a/crates/aura-ingest/src/lib.rs +++ b/crates/aura-ingest/src/lib.rs @@ -453,8 +453,11 @@ fn month_end_ms_exclusive(year: u16, month: u8) -> i64 { /// needs no general symbol-boundary regex and reads no private index (#252: /// no new data-server surface). Empty when `data_path` doesn't exist or holds /// no matching file for `symbol` — the same "no archive for this symbol" -/// condition an absent `DataServer` symbol represents. -fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> { +/// condition an absent `DataServer` symbol represents. `pub` (#264): unlike +/// [`archive_extent`], which deliberately walks *past* an interior gap to find +/// the window's first/last bar, a coverage report needs the raw month list +/// itself to detect and render that gap. +pub fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> { let prefix = format!("{symbol}_"); let mut months = Vec::new(); let Ok(entries) = std::fs::read_dir(data_path) else { @@ -551,6 +554,12 @@ mod tests { /// mirrors `aura-cli`'s own `tests/common::mint_tempdir` pattern (unique /// name via pid + an atomic counter) so this crate's tests need no /// `tempfile` dev-dependency for a handful of throwaway fixture dirs. + /// + /// #258 sweep note: deliberately kept pid-suffixed like the sibling it + /// mirrors — there is no pre-create wipe here for a stale pid to defeat; + /// `Drop` (below) unconditionally reclaims the directory when the owning + /// test ends, so nothing strands across runs. The pid instead + /// disambiguates concurrent `cargo test` processes. struct ScratchDir(std::path::PathBuf); impl ScratchDir { diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 22048d8..cd308df 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -30,8 +30,9 @@ mod compat; mod lineage; pub use lineage::{ derive_trace_name, group_families, mc_member_reports, sweep_member_reports, - walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, - Family, FamilyKind, FamilyRunRecord, StageBootstrap, StageRealization, StageSelection, + walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellCoverage, + CellFault, CellFaultKind, CellRealization, Family, FamilyKind, FamilyRunRecord, + StageBootstrap, StageRealization, StageSelection, WindowFault, }; mod trace_store; @@ -505,8 +506,8 @@ fn closed_neighbourhood(i: usize, axis_lens: &[usize]) -> Vec { out } -fn member_trade_rs(rep: &RunReport) -> &[f64] { - rep.metrics.r.as_ref().map(|r| r.trade_rs.as_slice()).unwrap_or(&[]) +fn member_net_trade_rs(rep: &RunReport) -> &[f64] { + rep.metrics.r.as_ref().map(|r| r.net_trade_rs.as_slice()).unwrap_or(&[]) } /// Recompute the selected R metric from a trade-R slice (reuses the engine's @@ -522,13 +523,13 @@ fn member_metric_from_rs(rs: &[f64], m: Metric) -> f64 { } } -/// The centred best-of-K null-max distribution: each member's `trade_rs` is +/// The centred best-of-K null-max distribution: each member's `net_trade_rs` is /// mean-subtracted (the no-edge null), then for each resample every member is /// moving-block-resampled (in odometer order, from a per-iteration seed) and the /// max recomputed metric across members is taken. Deterministic given `seed` (C1). fn null_best_of_k(family: &SweepFamily, m: Metric, n_resamples: usize, block_len: usize, seed: u64) -> Vec { let centred: Vec> = family.points.iter().map(|p| { - let rs = member_trade_rs(&p.report); + let rs = member_net_trade_rs(&p.report); if rs.is_empty() { return Vec::new(); } let mean = rs.iter().sum::() / rs.len() as f64; rs.iter().map(|x| x - mean).collect() @@ -657,8 +658,8 @@ pub fn optimize_deflated( let null_max = null_best_of_k(family, m, n_resamples, block_len, seed); // Keep only the finite best-of-K draws. The null is *not computable* in two // degenerate cases: zero resamples (`null_max` empty), or no member carries - // `trade_rs` (every member's centred series is empty, so each iteration's - // best-of-K folds to `NEG_INFINITY`). The latter is reachable — `trade_rs` + // `net_trade_rs` (every member's centred series is empty, so each iteration's + // best-of-K folds to `NEG_INFINITY`). The latter is reachable — `net_trade_rs` // is `#[serde(skip)]`, so a family loaded back from the registry has empty // conduits even when its `r` block (hence `raw`) is finite. Filtering to // finite values collapses both into one "no usable null" branch, instead of @@ -847,7 +848,7 @@ mod tests { sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here net_expectancy_r, conviction_terciles_r: [0.0, 0.0, 0.0], - trade_rs: Vec::new(), + net_trade_rs: Vec::new(), }); rep } @@ -909,8 +910,11 @@ mod tests { } fn temp_path(name: &str) -> PathBuf { - // unique per test + per process; no external tempfile dependency - std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name)) + // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): + // no external tempfile dependency, and every call site already wipes + // before use, so the fixed name genuinely reclaims the previous run. + std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join(format!("aura-registry-{name}.jsonl")) } #[test] @@ -1081,8 +1085,10 @@ mod tests { fn temp_family_dir(name: &str) -> PathBuf { // a unique per-test directory so the families.jsonl sibling never collides // across tests sharing the temp dir; the registry binds to runs.jsonl inside. - let dir = - std::env::temp_dir().join(format!("aura-registry-fam-{}-{}", std::process::id(), name)); + // Fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the + // pre-create wipe below then genuinely reclaims the previous run. + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join(format!("aura-registry-fam-{name}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create temp family dir"); dir.join("runs.jsonl") @@ -1169,16 +1175,16 @@ mod tests { assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family } - fn member(total_pips: f64, trade_rs: Vec) -> SweepPoint { + fn member(total_pips: f64, net_trade_rs: Vec) -> SweepPoint { // Every RMetrics field is derived from the R slice by `r_metrics_from_rs` // (the same arithmetic production members carry), then the per-trade - // `trade_rs` conduit is restored: `r_metrics_from_rs` empties it - // (`trade_rs: Vec::new()`), whereas a production member is built by + // `net_trade_rs` conduit is restored: `r_metrics_from_rs` empties it + // (`net_trade_rs: Vec::new()`), whereas a production member is built by // `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm // resamples exactly that conduit, so a fixture without it cannot reach // the R-arm path under test. - let mut r = aura_engine::r_metrics_from_rs(&trade_rs); - r.trade_rs = trade_rs; + let mut r = aura_engine::r_metrics_from_rs(&net_trade_rs); + r.net_trade_rs = net_trade_rs; SweepPoint { params: vec![], report: RunReport { @@ -1214,7 +1220,7 @@ mod tests { #[test] fn optimize_deflated_winner_is_byte_identical_to_optimize() { - let fam = fixture_family_with_r(); // helper: ≥3 members, varied sqn_normalized + trade_rs + let fam = fixture_family_with_r(); // helper: ≥3 members, varied sqn_normalized + net_trade_rs for metric in ["total_pips", "sqn_normalized", "expectancy_r"] { let plain = optimize(&fam, metric).unwrap(); let (defl, _) = optimize_deflated(&fam, metric, 200, 3, 7).unwrap(); @@ -1254,18 +1260,18 @@ mod tests { assert_eq!(sel.overfit_probability, Some(1.0), "(0+1)/(0+1) Laplace floor"); } - /// Regression: a family whose members carry an `r` block but NO `trade_rs` — + /// Regression: a family whose members carry an `r` block but NO `net_trade_rs` — /// the serde-skipped state of a family `load`ed back from the registry — must /// NOT yield a `+inf` deflated score on the R arm with positive resamples. With /// no member contributing a centred series, every best-of-K draw is /// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor /// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`). #[test] - fn optimize_deflated_no_trade_rs_floors_instead_of_infinity() { + fn optimize_deflated_no_net_trade_rs_floors_instead_of_infinity() { let mut fam = fixture_family_with_r(); for p in &mut fam.points { if let Some(r) = p.report.metrics.r.as_mut() { - r.trade_rs.clear(); // mimic a serde-loaded member (trade_rs is #[serde(skip)]) + r.net_trade_rs.clear(); // mimic a serde-loaded member (net_trade_rs is #[serde(skip)]) } } let sel = optimize_deflated(&fam, "expectancy_r", 500, 3, 1).unwrap().1; @@ -1958,6 +1964,8 @@ mod tests { window_ms: (1_136_073_600_000, 1_154_390_400_000), regime: None, regime_ordinal: 0, + fault: None, + coverage: None, stages: vec![ StageRealization { block: "std::sweep".to_string(), @@ -1984,6 +1992,7 @@ mod tests { }, }), bootstrap: None, + window_faults: vec![], }, StageRealization { block: "std::gate".to_string(), @@ -1991,6 +2000,7 @@ mod tests { survivor_ordinals: Some(vec![0, 3, 4, 7]), selection: None, bootstrap: None, + window_faults: vec![], }, ], }], @@ -2024,6 +2034,8 @@ mod tests { window_ms: (0, 1000), regime: None, regime_ordinal: 0, + fault: None, + coverage: None, stages: vec![StageRealization { block: "std::monte_carlo".to_string(), family_id: None, @@ -2033,6 +2045,7 @@ mod tests { (0, per_a), (2, per_b), ])), + window_faults: vec![], }], }, CellRealization { @@ -2041,12 +2054,15 @@ mod tests { window_ms: (0, 1000), regime: None, regime_ordinal: 0, + fault: None, + coverage: None, stages: vec![StageRealization { block: "std::monte_carlo".to_string(), family_id: None, survivor_ordinals: None, selection: None, bootstrap: Some(StageBootstrap::PooledOos(pooled)), + window_faults: vec![], }], }, ], @@ -2108,6 +2124,62 @@ mod tests { ); } + /// #272: a pre-fault campaign_runs line (no fault/coverage/window_faults + /// keys) parses and re-serializes byte-identical — the additive-widening + /// guarantee for the new per-cell-fault fields. + #[test] + fn campaign_run_line_without_fault_fields_round_trips() { + let line = r#"{"campaign":"c","process":"p","run":0,"seed":7,"cells":[{"strategy":"s","instrument":"EURUSD","window_ms":[0,10],"stages":[{"block":"std::sweep","family_id":"f-0"}]}]}"#; + let rec: CampaignRunRecord = serde_json::from_str(line).expect("parse pre-fault line"); + assert_eq!(rec.cells[0].fault, None); + assert_eq!(rec.cells[0].coverage, None); + assert!(rec.cells[0].stages[0].window_faults.is_empty()); + assert_eq!(serde_json::to_string(&rec).expect("re-serialize"), line); + } + + /// #272: a cell that failed serializes its fault, and a fold-fault list + /// serializes on the stage — both absent-by-default, present when set. + #[test] + fn cell_fault_and_window_faults_serialize_when_present() { + let fault = CellFault { stage: 0, kind: CellFaultKind::NoData, detail: "no data".into() }; + assert_eq!( + serde_json::to_string(&fault).expect("fault"), + r#"{"stage":0,"kind":"no_data","detail":"no data"}"# + ); + let wf = WindowFault { window_ordinal: 2, kind: CellFaultKind::Run, detail: "boom".into() }; + assert_eq!( + serde_json::to_string(&wf).expect("window fault"), + r#"{"window_ordinal":2,"kind":"run","detail":"boom"}"# + ); + } + + /// #272: a `CellCoverage` with gap months serializes them, and its + /// effective-bounds round-trip (parse-then-reserialize) is byte-identical + /// — pinning both the populated `gap_months` shape and the + /// `skip_serializing_if = "Vec::is_empty"` omission when there are none. + #[test] + fn cell_coverage_serializes_gap_months_and_round_trips() { + let with_gaps = CellCoverage { + effective_from_ms: 0, + effective_to_ms: 1000, + gap_months: vec!["2024-02".into()], + }; + let json = serde_json::to_string(&with_gaps).expect("coverage with gaps"); + assert_eq!( + json, + r#"{"effective_from_ms":0,"effective_to_ms":1000,"gap_months":["2024-02"]}"# + ); + let parsed: CellCoverage = serde_json::from_str(&json).expect("parse coverage"); + assert_eq!(parsed, with_gaps); + + let no_gaps = + CellCoverage { effective_from_ms: 0, effective_to_ms: 1000, gap_months: vec![] }; + assert_eq!( + serde_json::to_string(&no_gaps).expect("coverage without gaps"), + r#"{"effective_from_ms":0,"effective_to_ms":1000}"# + ); + } + /// Property (#210 T3, C14/C23 widening convention): a stored /// `campaign_runs.jsonl` line whose `generalizations` entries predate the /// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/ diff --git a/crates/aura-registry/src/lineage.rs b/crates/aura-registry/src/lineage.rs index d51469a..ec97c24 100644 --- a/crates/aura-registry/src/lineage.rs +++ b/crates/aura-registry/src/lineage.rs @@ -98,6 +98,48 @@ pub struct CampaignRunRecord { pub trace_name: Option, } +/// The recorded fault of a failed cell (#272): the pipeline stage it fired in, +/// a closed kind tag aggregations count on, and the prose detail. `no_data` is +/// one kind among others, not a special path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CellFault { + pub stage: usize, + pub kind: CellFaultKind, + pub detail: String, +} + +/// Closed fault-kind vocabulary (#272). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CellFaultKind { + NoData, + Bind, + Run, + Panic, + Window, +} + +/// One failed walk-forward fold (#272): the roll ordinal that produced no OOS +/// report, with the fault that killed it. The surviving folds still pool; an +/// aggregate over this stage must name the ratio. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowFault { + pub window_ordinal: usize, + pub kind: CellFaultKind, + pub detail: String, +} + +/// A cell whose requested window is only partially covered by the archive +/// (#272): the effective evaluated bounds and any whole months missing inside +/// them. Recorded only when it deviates from the requested window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CellCoverage { + pub effective_from_ms: i64, + pub effective_to_ms: i64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gap_months: Vec, +} + /// One realized (strategy, instrument, window) cell: the pipeline prefix that /// actually ran (`stages` stops after an empty gate). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -125,6 +167,13 @@ pub struct CellRealization { /// same pre-#219 byte-identical round-trip reason. #[serde(default, skip_serializing_if = "is_zero_ordinal")] pub regime_ordinal: usize, + /// Set when the cell failed (#272): the pipeline prefix in `stages` ran, + /// the faulting stage did not complete, no nominee was produced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fault: Option, + /// Set when the archive only partially covers the requested window (#272). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage: Option, } /// `CellRealization::regime_ordinal`'s serialize-skip predicate: the default @@ -151,6 +200,10 @@ pub struct StageRealization { /// serde-default widening keeps existing stored lines parseable. #[serde(default, skip_serializing_if = "Option::is_none")] pub bootstrap: Option, + /// std::walk_forward stages only (#272): folds that produced no OOS report; + /// the surviving folds pooled, and aggregates must name the ratio. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub window_faults: Vec, } /// The recorded winner of a selection-bearing stage: its ordinal in the stage's diff --git a/crates/aura-registry/src/trace_store.rs b/crates/aura-registry/src/trace_store.rs index 4bade17..01e14df 100644 --- a/crates/aura-registry/src/trace_store.rs +++ b/crates/aura-registry/src/trace_store.rs @@ -288,8 +288,10 @@ mod tests { use aura_core::{Scalar, ScalarKind, Timestamp}; fn temp_traces_root(name: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("aura-tracestore-{}-{}", std::process::id(), name)); + // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the + // pre-create wipe below then genuinely reclaims the previous run. + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join(format!("aura-tracestore-{name}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create temp traces root"); dir diff --git a/crates/aura-registry/tests/campaign_run_store_e2e.rs b/crates/aura-registry/tests/campaign_run_store_e2e.rs index 1cf01a8..100c26e 100644 --- a/crates/aura-registry/tests/campaign_run_store_e2e.rs +++ b/crates/aura-registry/tests/campaign_run_store_e2e.rs @@ -18,8 +18,8 @@ use aura_registry::{ }; fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir() - .join(format!("aura-registry-campaign-e2e-{}-{}", std::process::id(), name)); + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("aura-registry-campaign-e2e-{name}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("create temp dir"); dir.join("runs.jsonl") @@ -55,6 +55,8 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord { window_ms: (0, 1000), regime: None, regime_ordinal: 0, + fault: None, + coverage: None, stages: vec![StageRealization { block: "std::sweep".to_string(), family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")), @@ -77,6 +79,7 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord { }, }), bootstrap: None, + window_faults: vec![], }], }], generalizations: vec![], diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 21f5ad9..42ad605 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -71,6 +71,11 @@ pub enum StageBlock { MonteCarlo { resamples: u32, block_len: u32 }, #[serde(rename = "std::generalize")] Generalize { metric: String }, + /// Enumerate-only leading stage (#256 fork B): yields the axis grid's + /// parameter points to the next stage; executes and persists nothing. + /// Fieldless — the axes live in the campaign document. + #[serde(rename = "std::grid")] + Grid, } fn is_false(b: &bool) -> bool { @@ -224,6 +229,14 @@ pub const PROCESS_BLOCKS: &[BlockSchema] = &[ needs >= 2 instruments and an R-expectancy metric to execute", slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }], }, + BlockSchema { + id: "std::grid", + doc: "enumerate the axis grid as parameter points for the next stage \ + (enumerate-only leading stage): executes and persists nothing; \ + legal only as the first stage, immediately before \ + std::walk_forward", + slots: &[], + }, ]; // --------------------------------------------------------------------------- @@ -374,6 +387,7 @@ fn stage_from_value(v: &serde_json::Value) -> Result { "std::generalize" => Ok(StageBlock::Generalize { metric: require_str(m, "metric", &id)?, }), + "std::grid" => Ok(StageBlock::Grid), _ => unreachable!("schema membership checked above"), } } @@ -501,6 +515,7 @@ pub struct Axis { #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum RiskRegime { Vol { length: i64, k: f64 }, + VolTf { period_minutes: i64, length: i64, k: f64 }, } /// One component of the campaign's cost model (#234): a closed, additive @@ -509,12 +524,77 @@ pub enum RiskRegime { /// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc /// vocabulary conforms to the node vocabulary. Externally tagged so a future /// component is additive — no content-id churn on stored components. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum CostSpec { - Constant { cost_per_trade: f64 }, - VolSlippage { slip_vol_mult: f64 }, - Carry { carry_per_cycle: f64 }, + Constant { cost_per_trade: CostValue }, + VolSlippage { slip_vol_mult: CostValue }, + Carry { carry_per_cycle: CostValue }, +} + +/// One cost knob's value (#260): one number for every cell, or an +/// instrument-keyed map resolved per cell at member construction. Custom +/// serde keeps the scalar form byte-identical to the historical bare number +/// (content-id parity for stored docs — the `Axis` precedent); the map form +/// is a plain JSON object of numbers (BTreeMap: deterministic key order). +#[derive(Clone, Debug, PartialEq)] +pub enum CostValue { + Scalar(f64), + PerInstrument(std::collections::BTreeMap), +} + +impl CostValue { + /// The per-cell value: the scalar, or the instrument's map entry. `None` + /// only for an uncovered instrument — unreachable after intrinsic + /// validation; callers refuse with prose rather than defaulting. + pub fn resolve(&self, instrument: &str) -> Option { + match self { + CostValue::Scalar(v) => Some(*v), + CostValue::PerInstrument(m) => m.get(instrument).copied(), + } + } + /// Every numeric value the validator finiteness/sign-checks. + pub fn values(&self) -> Vec { + match self { + CostValue::Scalar(v) => vec![*v], + CostValue::PerInstrument(m) => m.values().copied().collect(), + } + } + /// The map's key set (empty for a scalar) — the validate cross-check input. + pub fn instrument_keys(&self) -> Vec<&str> { + match self { + CostValue::Scalar(_) => Vec::new(), + CostValue::PerInstrument(m) => m.keys().map(String::as_str).collect(), + } + } +} + +impl Serialize for CostValue { + fn serialize(&self, s: S) -> Result { + match self { + CostValue::Scalar(v) => v.serialize(s), + CostValue::PerInstrument(m) => m.serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for CostValue { + fn deserialize>(d: D) -> Result { + use serde::de::Error as _; + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Scalar(f64), + PerInstrument(std::collections::BTreeMap), + } + match Raw::deserialize(d)? { + Raw::Scalar(v) => Ok(CostValue::Scalar(v)), + Raw::PerInstrument(m) if m.is_empty() => { + Err(D::Error::custom("instrument map is empty")) + } + Raw::PerInstrument(m) => Ok(CostValue::PerInstrument(m)), + } + } } /// Bare wire value of one Scalar (strips the tagged form via Scalar's own @@ -724,6 +804,7 @@ pub enum DocFault { BadWindow { index: usize }, BadRegime { index: usize }, BadCost { index: usize }, + CostInstrumentKeys { index: usize, missing: Vec, extra: Vec }, NoStrategy, EmptyAxes { strategy: usize }, EmptyAxis { strategy: usize, axis: String }, @@ -791,6 +872,7 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec { } } StageBlock::MonteCarlo { .. } => {} + StageBlock::Grid => {} } if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) { terminal_seen = true; @@ -826,12 +908,21 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { } } for (i, r) in doc.risk.iter().enumerate() { - let RiskRegime::Vol { length, k } = r; - // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches - // a non-positive or NaN multiplier (clippy-clean vs `!(k > 0.0)`; +inf - // is unreachable via JSON, so it need not be excluded). - if *length < 1 || *k <= 0.0 || k.is_nan() { - faults.push(DocFault::BadRegime { index: i }); + match r { + RiskRegime::Vol { length, k } => { + // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` + // catches a non-positive or NaN multiplier (clippy-clean vs + // `!(k > 0.0)`; +inf is unreachable via JSON, so it need not + // be excluded). + if *length < 1 || *k <= 0.0 || k.is_nan() { + faults.push(DocFault::BadRegime { index: i }); + } + } + RiskRegime::VolTf { period_minutes, length, k } => { + if *period_minutes < 1 || *length < 1 || *k <= 0.0 || k.is_nan() { + faults.push(DocFault::BadRegime { index: i }); + } + } } } for (i, c) in doc.cost.iter().enumerate() { @@ -839,14 +930,35 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { // knob (`>= 0`); the doc tier refuses the same bounds earlier, // path-addressed (the BadRegime precedent). NaN/inf are unreachable // via JSON but refused defensively. - let v = match c { - CostSpec::Constant { cost_per_trade } => *cost_per_trade, - CostSpec::VolSlippage { slip_vol_mult } => *slip_vol_mult, - CostSpec::Carry { carry_per_cycle } => *carry_per_cycle, + let value = match c { + CostSpec::Constant { cost_per_trade } => cost_per_trade, + CostSpec::VolSlippage { slip_vol_mult } => slip_vol_mult, + CostSpec::Carry { carry_per_cycle } => carry_per_cycle, }; - if !v.is_finite() || v < 0.0 { + if value.values().iter().any(|v| !v.is_finite() || *v < 0.0) { faults.push(DocFault::BadCost { index: i }); } + // #260: an instrument map must cover the campaign's instruments + // exactly — a missing instrument silently un-costs its cells, an + // extra key is almost always a symbol typo; both refuse up front. + let keys = value.instrument_keys(); + if !keys.is_empty() { + let missing: Vec = doc + .data + .instruments + .iter() + .filter(|inst| !keys.contains(&inst.as_str())) + .cloned() + .collect(); + let extra: Vec = keys + .iter() + .filter(|k| !doc.data.instruments.iter().any(|inst| inst == *k)) + .map(|k| k.to_string()) + .collect(); + if !missing.is_empty() || !extra.is_empty() { + faults.push(DocFault::CostInstrumentKeys { index: i, missing, extra }); + } + } } if doc.strategies.is_empty() { faults.push(DocFault::NoStrategy); @@ -945,7 +1057,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str { SlotKind::EmitKinds => "list of: family_table | selection_report", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", SlotKind::Regimes => { - "list of stop regimes { vol: { length, k } }; absent = one default regime" + "list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime" } SlotKind::Costs => { "list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)" @@ -978,10 +1090,22 @@ pub struct OpenSlot { pub path: String, /// Short human hint, e.g. "required, one of: content_id, identity_id". pub hint: String, + /// Additional unit/semantics notes rendered as indented sub-lines under + /// the slot's main line (#265) — additive, so the `hint` string itself + /// (pinned verbatim by earlier tests) never changes. + pub notes: Vec, } fn open(path: impl Into, hint: impl Into) -> OpenSlot { - OpenSlot { path: path.into(), hint: hint.into() } + OpenSlot { path: path.into(), hint: hint.into(), notes: Vec::new() } +} + +/// Like `open`, but carries additional unit/semantics notes (#265) rendered +/// as indented sub-lines under the slot — the `hint` string stays exactly as +/// `open` would have produced it, so sibling tests pinning `hint` verbatim +/// are unaffected. +fn open_with_notes(path: impl Into, hint: impl Into, notes: Vec) -> OpenSlot { + OpenSlot { path: path.into(), hint: hint.into(), notes } } /// List the open slots of a (possibly partial) process document. Only the @@ -1064,18 +1188,29 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { // how it went undiscovered (#216). Absent, empty, or non-array = open. let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty()); if !risk_bound { - slots.push(open( + slots.push(open_with_notes( "risk", - "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime", + vec![ + "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), + "vol.k scales the stop distance (the risk-unit lever).".to_string(), + "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(), + ], )); } // The optional cost slot, mirroring the risk axis (#216's discoverability // lesson applied to #234): absent, empty, or non-array = open. let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty()); if !cost_bound { - slots.push(open( + slots.push(open_with_notes( "cost", "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", + vec![ + "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), + "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), + "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), + ], )); } match v.get("strategies").and_then(|s| s.as_array()) { @@ -1395,6 +1530,18 @@ mod tests { assert_eq!(back, r); } + /// #262: the second variant's wire form is pinned the same way its Vol + /// sibling is — `vol_tf` snake_case tag, three named fields — so a stored + /// VolTf document's parse path doesn't silently rot. + #[test] + fn risk_regime_round_trips_as_externally_tagged_vol_tf() { + let r = RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }; + let j = serde_json::to_string(&r).unwrap(); + assert_eq!(j, r#"{"vol_tf":{"period_minutes":60,"length":14,"k":2.0}}"#); + let back: RiskRegime = serde_json::from_str(&j).unwrap(); + assert_eq!(back, r); + } + #[test] fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); @@ -1438,6 +1585,21 @@ mod tests { assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); } + /// #262: a non-positive `period_minutes` must be refused as a graceful + /// `DocFault::BadRegime` at the doc tier — otherwise it reaches + /// `VolTfStop::new`'s assert and panics instead of a validation error. + #[test] + fn validate_campaign_flags_non_positive_period_minutes_regime() { + let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); + doc.risk = vec![ + RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }, // 0: valid + RiskRegime::VolTf { period_minutes: 0, length: 14, k: 2.0 }, // 1: period_minutes < 1 + ]; + let faults = validate_campaign(&doc); + assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}"); + assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); + } + #[test] fn validate_campaign_accepts_a_valid_risk_section() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); @@ -1452,15 +1614,15 @@ mod tests { // vocabulary conforms to the node vocabulary, not the other way round. for (spec, wire) in [ ( - CostSpec::Constant { cost_per_trade: 2.0 }, + CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) }, r#"{"constant":{"cost_per_trade":2.0}}"#, ), ( - CostSpec::VolSlippage { slip_vol_mult: 0.5 }, + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) }, r#"{"vol_slippage":{"slip_vol_mult":0.5}}"#, ), ( - CostSpec::Carry { carry_per_cycle: 0.1 }, + CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.1) }, r#"{"carry":{"carry_per_cycle":0.1}}"#, ), ] { @@ -1472,6 +1634,86 @@ mod tests { assert!(serde_json::from_str::(r#"{"constant":{"per_close_r":2.0}}"#).is_err()); } + /// Property (#260): a cost knob's map form round-trips as a plain JSON + /// object keyed by instrument, and the scalar form stays the bare number + /// (content-id parity for stored docs is pinned by the sibling scalar + /// round-trip test, which must stay green untouched). + #[test] + fn cost_value_map_form_round_trips_and_scalar_stays_bare() { + let json = r#"{"constant":{"cost_per_trade":{"EURUSD":0.00012,"GER40":2.0}}}"#; + let spec: CostSpec = serde_json::from_str(json).expect("map form parses"); + assert_eq!(serde_json::to_string(&spec).expect("serializes"), json); + let bare: CostSpec = serde_json::from_str(r#"{"constant":{"cost_per_trade":2.0}}"#) + .expect("scalar form parses"); + assert_eq!( + serde_json::to_string(&bare).expect("serializes"), + r#"{"constant":{"cost_per_trade":2.0}}"#, + "scalar wire form must stay the bare number byte-identically" + ); + let CostSpec::Constant { cost_per_trade } = &spec else { panic!("constant") }; + assert_eq!(cost_per_trade.resolve("GER40"), Some(2.0)); + assert_eq!(cost_per_trade.resolve("EURUSD"), Some(0.00012)); + assert_eq!(cost_per_trade.resolve("XAUUSD"), None); + } + + /// Property (#260): an empty instrument map (`{}`) is refused at + /// deserialize time — `validate_campaign`'s cross-check skips a knob + /// whose `instrument_keys()` is empty (that shape also means "scalar"), + /// so an empty map that slipped past this guard would silently escape + /// the instrument-coverage check entirely. + #[test] + fn cost_value_empty_map_is_refused_at_deserialize() { + let err = serde_json::from_str::(r#"{"constant":{"cost_per_trade":{}}}"#) + .expect_err("an empty instrument map must not parse"); + assert!( + err.to_string().contains("instrument map is empty"), + "error names the defect: {err}" + ); + } + + /// Property (#260): the intrinsic tier cross-checks every instrument map + /// against the doc's own data.instruments — a missing instrument and an + /// unknown extra key are each a fault naming the component; a complete + /// map is valid. No silent default, no partial coverage. + #[test] + fn validate_campaign_cross_checks_cost_instrument_maps() { + let mk = |cost: &str| { + let doc = format!( + r#"{{ "format_version": 1, "kind": "campaign", "name": "t", + "data": {{ "instruments": ["GER40", "EURUSD"], + "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "9f" }}, + "axes": {{ "a": {{ "kind": "I64", "values": [1] }} }} }} ], + "process": {{ "ref": {{ "content_id": "9f" }} }}, + "cost": [ {cost} ], + "presentation": {{ "persist_taps": [], "emit": [] }}, + "seed": 1 }}"# + ); + let parsed = parse_campaign(&doc).expect("parses"); + validate_campaign(&parsed) + }; + assert!( + mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 0.00012 } } }"#) + .is_empty(), + "a complete map must validate" + ); + let missing = mk(r#"{ "constant": { "cost_per_trade": { "GER40": 2.0 } } }"#); + assert!( + missing.iter().any(|f| matches!( + f, + DocFault::CostInstrumentKeys { index: 0, .. } + )), + "fault names the component: {missing:?}" + ); + let extra = mk( + r#"{ "constant": { "cost_per_trade": { "GER40": 2.0, "EURUSD": 1.0, "GER40.cash": 3.0 } } }"#, + ); + assert!( + extra.iter().any(|f| matches!(f, DocFault::CostInstrumentKeys { index: 0, .. })), + "fault names the component: {extra:?}" + ); + } + #[test] fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() { // The risk precedent's sibling: cost-less documents keep their content ids. @@ -1492,8 +1734,8 @@ mod tests { fn campaign_with_cost_serializes_and_round_trips_the_components() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); doc.cost = vec![ - CostSpec::Constant { cost_per_trade: 2.0 }, - CostSpec::VolSlippage { slip_vol_mult: 0.5 }, + CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) }, + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) }, ]; let out = campaign_to_json(&doc); assert!( @@ -1508,9 +1750,9 @@ mod tests { fn validate_campaign_flags_each_bad_cost_component() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); doc.cost = vec![ - CostSpec::Constant { cost_per_trade: 0.0 }, // 0: valid (zero cost is a legal stress point) - CostSpec::VolSlippage { slip_vol_mult: -0.5 }, // 1: negative - CostSpec::Carry { carry_per_cycle: f64::NAN }, // 2: non-finite (unreachable via JSON; defensive) + CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) }, // 0: valid (zero cost is a legal stress point) + CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) }, // 1: negative + CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) }, // 2: non-finite (unreachable via JSON; defensive) ]; let faults = validate_campaign(&doc); assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}"); @@ -1518,10 +1760,28 @@ mod tests { assert!(!faults.contains(&DocFault::BadCost { index: 0 }), "valid component not flagged: {faults:?}"); } + /// Property (#260): the finiteness/sign check applies to EVERY value + /// inside a `PerInstrument` map, not only the scalar form — a negative + /// entry nested in an otherwise fully-keyed map is refused exactly like + /// a bad scalar (quality-review gap: the map cross-check test above only + /// exercised valid map values, leaving this path unexercised). + #[test] + fn validate_campaign_flags_a_bad_value_inside_an_instrument_map() { + let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); + doc.cost = vec![CostSpec::Constant { + cost_per_trade: CostValue::PerInstrument(std::collections::BTreeMap::from([( + "GER40".to_string(), + -1.0, + )])), + }]; + let faults = validate_campaign(&doc); + assert!(faults.contains(&DocFault::BadCost { index: 0 }), "{faults:?}"); + } + #[test] fn validate_campaign_accepts_a_valid_cost_section() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); - doc.cost = vec![CostSpec::Carry { carry_per_cycle: 0.25 }]; + doc.cost = vec![CostSpec::Carry { carry_per_cycle: CostValue::Scalar(0.25) }]; assert!(validate_campaign(&doc).is_empty(), "{:?}", validate_campaign(&doc)); } @@ -1860,16 +2120,45 @@ mod tests { ); } + /// #256 fork B: the fieldless enumerate-only stage round-trips through + /// the schema-strict parser, and any slot on it is refused (its slot + /// list is empty, so the generic unknown-slot check covers every key). + #[test] + fn grid_block_round_trips_and_refuses_slots() { + let json = serde_json::to_string(&StageBlock::Grid).expect("serialize"); + assert_eq!(json, r#"{"block":"std::grid"}"#); + let back: StageBlock = serde_json::from_str(&json).expect("parse back"); + assert_eq!(back, StageBlock::Grid); + let err = serde_json::from_str::( + r#"{"block":"std::grid","metric":"sqn"}"#, + ) + .expect_err("a slot on the fieldless std::grid must be refused"); + assert!(err.to_string().contains("unknown slot"), "got: {err}"); + } + #[test] fn vocabularies_enumerate_every_block_with_typed_slots() { let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect(); assert_eq!( ids, - vec!["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] + vec![ + "std::sweep", + "std::gate", + "std::walk_forward", + "std::monte_carlo", + "std::generalize", + "std::grid" + ] ); for b in process_vocabulary().iter().chain(campaign_vocabulary()) { assert!(!b.doc.is_empty(), "block {} has no doc line", b.id); - assert!(!b.slots.is_empty(), "block {} has no slots", b.id); + // std::grid is the one legitimate fieldless block (#256); every + // other block keeps its documented-slots guard. + assert!( + b.id == "std::grid" || !b.slots.is_empty(), + "block {} has no slots", + b.id + ); for s in b.slots { assert!(!slot_kind_label(s.kind).is_empty()); } @@ -1932,13 +2221,24 @@ mod tests { assert_eq!( open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(), vec![ - open( + open_with_notes( "risk", - "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + "optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime", + vec![ + "vol.length smooths the per-cycle vol estimator; it does not set the stop's timescale.".to_string(), + "vol.k scales the stop distance (the risk-unit lever).".to_string(), + "vol_tf computes the same estimator over completed period_minutes buckets: period_minutes IS the stop's timescale; length smooths in buckets; k scales the distance.".to_string(), + ], ), - open( + open_with_notes( "cost", "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", + vec![ + "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), + "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), + "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), + ], ), ] ); @@ -2012,9 +2312,15 @@ mod tests { assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field"); assert_eq!( open_slots_campaign(&with_risk).unwrap(), - vec![open( + vec![open_with_notes( "cost", "optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", + vec![ + "constant.cost_per_trade is in price units, not R (charged per close as cost/|entry-stop| in R).".to_string(), + "vol_slippage.slip_vol_mult is a multiplier on the per-cycle vol estimate (charged in R likewise).".to_string(), + "carry.carry_per_cycle is in price units per held cycle, not R (accrues over the hold, emitted in R likewise).".to_string(), + "each knob takes one number for all instruments, or an instrument-keyed map { \"GER40\": 2.0, ... } covering exactly the campaign's instruments.".to_string(), + ], )] ); let with_both = CAMPAIGN_FIXTURE.replacen( diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 9dcbb08..46c540c 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -54,6 +54,7 @@ mod stop_rule; mod sub; mod vocabulary; mod vol_slippage_cost; +mod vol_tf_stop; pub use abs::Abs; pub use add::Add; pub use and::And; @@ -88,7 +89,7 @@ pub use rolling_max::RollingMax; pub use rolling_min::RollingMin; pub use scale::Scale; pub use series_reducer::SeriesReducer; -pub use session::Session; +pub use session::{Session, SessionFrankfurt}; pub use sim_broker::SimBroker; pub use sizer::Sizer; pub use sma::Sma; @@ -97,3 +98,4 @@ pub use stop_rule::FixedStop; pub use sub::Sub; pub use vocabulary::{std_vocabulary, std_vocabulary_types}; pub use vol_slippage_cost::VolSlippageCost; +pub use vol_tf_stop::VolTfStop; diff --git a/crates/aura-std/src/session.rs b/crates/aura-std/src/session.rs index 83102d2..c1c1a2e 100644 --- a/crates/aura-std/src/session.rs +++ b/crates/aura-std/src/session.rs @@ -32,7 +32,9 @@ //! //! **Cold guard.** If the trigger column is empty (not yet fired) → `None`. -use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, +}; use chrono::{TimeZone, Timelike}; /// Frankfurt-session bar indexer. Holds the open offset (minutes past local @@ -76,6 +78,33 @@ impl Session { } } +/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open +/// (09:00 `Europe/Berlin`) baked as a literal, so the closed `std_vocabulary` +/// roster can reach a `Session` without a structural construction argument +/// (`Session::builder` itself stays absent from the roster — #155 scope). +/// The one remaining knob, `period_minutes` (i64, conventionally 15 — the +/// resampler's bar width), is declared as the node's sole `ParamSpec` and +/// bound through it via the single-knob `CarryCost`/`ConstantCost`/ +/// `VolSlippageCost` pattern. This is additive: `Session::builder` is +/// untouched and stays the vehicle for other timezones/opens. +pub struct SessionFrankfurt; + +impl SessionFrankfurt { + /// The param-generic recipe: one `period_minutes` I64 knob, the Frankfurt + /// open/timezone baked in the closure. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "SessionFrankfurt", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }], + output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }], + params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }], + }, + |p| Box::new(Session::new(9, 0, chrono_tz::Europe::Berlin, p[0].i64())), + ) + } +} + impl Node for Session { fn lookbacks(&self) -> Vec { vec![1] diff --git a/crates/aura-std/src/stop_rule.rs b/crates/aura-std/src/stop_rule.rs index d07de74..6c6c723 100644 --- a/crates/aura-std/src/stop_rule.rs +++ b/crates/aura-std/src/stop_rule.rs @@ -2,13 +2,16 @@ //! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped); //! position-management latches the entry-cycle distance as the frozen R-denominator. //! -//! `FixedStop` is the only stop-rule PRIMITIVE: a constant distance gated on its price -//! input (a source-less `Const` has no firing trigger in the push model, so the -//! triggered-constant shape is the honest primitive). The volatility stop is NOT a -//! primitive — it is a COMPOSITION of primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))` -//! with `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built with -//! `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`. True-range ATR -//! (a richer stop) is deferred — it needs OHLC. +//! `FixedStop` and `VolTfStop` (`vol_tf_stop.rs`) are the fused stop-rule +//! PRIMITIVES: `FixedStop` is a constant distance gated on its price input (a +//! source-less `Const` has no firing trigger in the push model, so the +//! triggered-constant shape is the honest primitive); `VolTfStop` folds +//! `k · √EMA(Δ², length)` over completed timescale-matched buckets. The +//! per-cycle volatility stop remains NOT a primitive — it is a COMPOSITION of +//! primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))` with +//! `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built +//! with `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`. +//! True-range ATR (a richer stop) is deferred — it needs OHLC. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs index 6f888e8..355ef0d 100644 --- a/crates/aura-std/src/vocabulary.rs +++ b/crates/aura-std/src/vocabulary.rs @@ -20,7 +20,7 @@ use crate::{ Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, - Sizer, Sma, Sqrt, Sub, VolSlippageCost, + SessionFrankfurt, Sizer, Sma, Sqrt, Sub, VolSlippageCost, }; use aura_core::PrimitiveBuilder; @@ -81,6 +81,7 @@ std_vocabulary_roster! { "RollingMax" => RollingMax, "RollingMin" => RollingMin, "Scale" => Scale, + "SessionFrankfurt" => SessionFrankfurt, "Sizer" => Sizer, "SMA" => Sma, "Sqrt" => Sqrt, @@ -129,7 +130,7 @@ mod tests { assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); - // count guard: pins the roster at exactly 28 entries - assert_eq!(std_vocabulary_types().len(), 28); + // count guard: pins the roster at exactly 29 entries + assert_eq!(std_vocabulary_types().len(), 29); } } diff --git a/crates/aura-std/src/vol_tf_stop.rs b/crates/aura-std/src/vol_tf_stop.rs new file mode 100644 index 0000000..31a5134 --- /dev/null +++ b/crates/aura-std/src/vol_tf_stop.rs @@ -0,0 +1,217 @@ +//! `VolTfStop` — timescale-matched volatility stop (#262): +//! `k · √EMA(Δ², length)` over completed `period_minutes` buckets — the vol +//! regime on a coarser clock. A fused primitive in the `Resample` mold: the +//! in-progress bucket lives in node state; on rollover the finished bucket's +//! close is differenced against the previous bucket's close, Δ² folds into an +//! `Ema`-convention EMA (SMA seed over the first `length` deltas, +//! `alpha = 2/(length+1)`), and the stop distance is emitted ONCE (C2: a +//! partial bucket never exists downstream). Mid-bucket the node emits +//! nothing, so the `Firing::Any` consumers (`Sizer`, `PositionManagement`) +//! hold the last completed bucket's value; the R-latch samples at entry. + +/// Nanoseconds per minute — the bucket-id divisor's one scale factor. +const NS_PER_MINUTE: i64 = 60 * 1_000_000_000; + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +pub struct VolTfStop { + period_ns: i64, + k: f64, + // EMA(Δ²) over completed buckets — `Ema`'s exact convention. + alpha: f64, + length: usize, + seeded: bool, + warmup_sum: f64, + count: usize, + ema: f64, + // Bucket accumulator (`Resample`'s mold): current id, its running close, + // and the previous COMPLETED bucket's close. + bucket: Option, + close_in_bucket: f64, + prev_close: Option, + out: [Cell; 1], +} + +impl VolTfStop { + pub fn new(period_minutes: i64, length: i64, k: f64) -> Self { + assert!(period_minutes >= 1, "VolTfStop period_minutes must be >= 1"); + assert!(length >= 1, "VolTfStop length must be >= 1"); + assert!(k > 0.0, "VolTfStop k must be > 0"); + Self { + period_ns: period_minutes * NS_PER_MINUTE, + k, + alpha: 2.0 / (length as f64 + 1.0), + length: length as usize, + seeded: false, + warmup_sum: 0.0, + count: 0, + ema: 0.0, + bucket: None, + close_in_bucket: 0.0, + prev_close: None, + out: [Cell::from_f64(0.0)], + } + } + + /// The param-generic recipe (#262): a graph builder adds this fused + /// primitive the same way it adds any other node — all three knobs are + /// structural (baked into the accumulator/EMA state at construction), so + /// `risk_executor`'s `VolTf` arm binds all three via `.bind(..)` (the + /// `FixedStop::builder().bind("distance", ..)` idiom, chained thrice). + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "VolTfStop", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }], + output: vec![FieldSpec { name: "stop_distance".into(), kind: ScalarKind::F64 }], + params: vec![ + ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "length".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "k".into(), kind: ScalarKind::F64 }, + ], + }, + |p| Box::new(VolTfStop::new(p[0].i64(), p[1].i64(), p[2].f64())), + ) + } +} + +impl Node for VolTfStop { + // The in-progress bucket lives in node state, not the input columns. + fn lookbacks(&self) -> Vec { + vec![1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; // fire with price (warm-up filter) + } + let price = w[0]; + let bucket = ctx.now().0 / self.period_ns; + match self.bucket { + // First ever sample: open the accumulator, nothing complete yet. + None => { + self.bucket = Some(bucket); + self.close_in_bucket = price; + None + } + // Same bucket: the running close advances (last close wins). + Some(b) if bucket == b => { + self.close_in_bucket = price; + None + } + // Rollover: the finished bucket's close is final — difference it + // against the previous completed close, fold, emit once. + Some(_) => { + let finished_close = self.close_in_bucket; + self.bucket = Some(bucket); + self.close_in_bucket = price; + let Some(prev) = self.prev_close.replace(finished_close) else { + return None; // first completed bucket: no delta yet + }; + let d = finished_close - prev; + let d2 = d * d; + if !self.seeded { + // SMA-seed over the first `length` deltas (Ema's convention). + self.warmup_sum += d2; + self.count += 1; + if self.count < self.length { + return None; + } + self.ema = self.warmup_sum / self.length as f64; + self.seeded = true; + } else { + // O(1) recurrence, exactly Ema's. + self.ema += self.alpha * (d2 - self.ema); + } + self.out[0] = Cell::from_f64(self.k * self.ema.sqrt()); + Some(&self.out) + } + } + } + + fn label(&self) -> String { + format!( + "VolTfStop({}m,{},{})", + self.period_ns / NS_PER_MINUTE, + self.length, + self.k + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, ScalarKind, Timestamp}; + + // Drive the node like the engine does: one eval per (minute, price) sample, + // mirroring the `feed`/`step` eval-harness idiom of `stop_rule.rs` / + // `resample.rs` — a capacity-1 ring column whose push overwrites the single + // slot, re-presenting the newest sample each cycle. + fn drive(node: &mut VolTfStop, samples: &[(i64, f64)]) -> Vec> { + let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + samples + .iter() + .map(|&(ts_min, price)| { + col[0].push(Scalar::f64(price)).unwrap(); + node.eval(Ctx::new(&col, Timestamp(ts_min * NS_PER_MINUTE))) + .map(|c| c[0].f64()) + }) + .collect() + } + + /// Property (#262): mid-bucket cycles emit NOTHING — the stop exists only + /// at bucket rollover (C2: a partial bucket never reaches downstream). + #[test] + fn emits_only_on_bucket_rollover() { + let mut n = VolTfStop::new(60, 1, 2.0); + // Minutes 0..59 are one bucket; minute 60 rolls it over; 61..119 the + // next; 120 rolls again (first delta -> first emission). + let out = drive( + &mut n, + &[(0, 100.0), (30, 101.0), (59, 102.0), (60, 103.0), (90, 104.0), (120, 105.0)], + ); + assert_eq!(out[0], None, "first sample opens the accumulator"); + assert_eq!(out[1], None, "mid-bucket"); + assert_eq!(out[2], None, "mid-bucket"); + assert_eq!(out[3], None, "first rollover: a close exists, no delta yet"); + assert_eq!(out[4], None, "mid-bucket"); + assert!(out[5].is_some(), "second rollover: first delta -> first stop"); + } + + /// Property (#262): the emitted value is k · √EMA(Δ², length) over the + /// BUCKET closes. length=1 (alpha=1, the Ema identity) makes each stop + /// exactly k·|Δ| of the last completed bucket pair. + #[test] + fn stop_is_k_times_root_ema_of_bucket_close_deltas() { + let mut n = VolTfStop::new(60, 1, 2.0); + // Bucket closes: 102 (bucket 0), 104 (bucket 1), 109 (bucket 2). + let out = drive( + &mut n, + &[(0, 100.0), (59, 102.0), (61, 103.0), (119, 104.0), (121, 108.0), (179, 109.0), (181, 110.0)], + ); + // Rollover at 61 (close 102, no delta), 121 (Δ=2 -> 2·|2|=4), 181 (Δ=5 -> 2·|5|=10). + assert_eq!(out[2], None, "first completed bucket seeds prev_close only"); + assert_eq!(out[4], Some(4.0), "k·|Δ| with length=1: 2·(104−102)"); + assert_eq!(out[6], Some(10.0), "2·(109−104)"); + } + + /// Correspondence pin (#262 acceptance): on strictly 1-minute-spaced + /// input with CONSTANT |Δ| per minute, vol_tf{1, length, k} converges to + /// exactly what the composite vol stop computes — k·|Δ| — the constant + /// input makes the one-cycle emission lag immaterial. + #[test] + fn one_minute_periods_match_the_vol_regime_on_constant_deltas() { + let mut n = VolTfStop::new(1, 3, 2.0); + // Alternating ±1 prices: every minute-delta has |Δ| = 1, Δ² = 1. + let samples: Vec<(i64, f64)> = + (0..12).map(|m| (m, if m % 2 == 0 { 100.0 } else { 101.0 })).collect(); + let out = drive(&mut n, &samples); + let last = out.last().copied().flatten().expect("steady state reached"); + assert!((last - 2.0).abs() < 1e-12, "k·√EMA(1) = 2.0, got {last}"); + } +} diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 158dfd4..74c09fb 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -350,6 +350,7 @@ std::gate filter survivors by a conjunction of typed metric predicates std::walk_forward rolling in-sample optimize + out-of-sample test std::monte_carlo R-bootstrap over realised R (terminal annotator): ... std::generalize cross-instrument worst-case floor (terminal annotator): ... +std::grid enumerate the axis grid as parameter points for the next stage (enumerate-only leading stage): ... $ aura process introspect --block std::sweep std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only) metric optional, metric name (see metric_vocabulary) @@ -468,7 +469,7 @@ open slot: format_version (required, must be 1) open slot: kind (required, must be "campaign") open slot: name (required, string) open slot: data (required section: instruments + windows) -open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime) +open slot: risk (optional, list of stop regimes { vol: { length, k } } | { vol_tf: { period_minutes, length, k } }; absent = one default regime) open slot: cost (optional, list of cost models { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero cost, net = gross) open slot: strategies (required, non-empty list of { ref, axes }) open slot: process.ref (required, content id of a process document) @@ -603,3 +604,9 @@ family charted the same way, by the handle the run prints (members keyed uniquely names one recorded run (`aura chart ` resolves it against the stored campaign documents; a name reused across runs refuses rather than guessing which one you mean). + +Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any +cell runs (invalid document, missing project, unresolvable strategy) exits 1; +a run that **completes with one or more failed cells** exits 3 — the run record +and every healthy cell persist, and the failed cells are named on stderr +(#272). diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 7d2581a..b4582e4 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -993,8 +993,9 @@ blueprint-data (#159, cuts 1b-4); `run` is now blueprint-driven — `aura run **Realization (2026-07-06 — the risk regime as a structural campaign axis, #210).** The `StopRule{Fixed,Vol}` structural axis is realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable -mirror of the runtime enum — `aura-research`, sole variant `Vol{length,k}`, the -fixed-stop rule additive when needed). It is a **kept-separate** matrix axis, a +mirror of the runtime enum — `aura-research`, variants `Vol{length,k}` and +`VolTf{period_minutes,length,k}` (#262), the fixed-stop rule additive when +needed). It is a **kept-separate** matrix axis, a peer of instruments and windows: the executor keys the nominee map by `(strategy, window, regime)`, so generalize aggregates across instruments *within* a regime, never across regimes. Regimes are therefore **compared** at @@ -1564,11 +1565,13 @@ boundary is test-pinned on both sides), plus three new static guards (unanimous #200 triage): nothing flows out of them; filtering stays the gate's monopoly. `std::monte_carlo` bootstraps the stage's *incoming R-evidence* with one semantics, input-shaped by position — after a walk_forward, ONE `r_bootstrap` over the wf family's -pooled per-window OOS `trade_rs` in roll order (`PooledOos`); after sweep/gates, one -`r_bootstrap` per surviving member's fresh in-memory series (`PerSurvivor`, ordinals into -the population family; a zero-trade member records the engine's defined all-zero -degenerate) — seeded from the campaign doc's `seed` (C1; `trade_rs` is `#[serde(skip)]`, -so annotators run in-executor or not at all). `std::generalize` executes at **campaign +pooled per-window OOS `net_trade_rs` in roll order (`PooledOos`; #259 materialized this +conduit as the cost-netted per-trade series `r − cost_in_r`, equal to the gross series +bit-for-bit when no cost model is bound); after sweep/gates, one `r_bootstrap` per +surviving member's fresh in-memory series (`PerSurvivor`, ordinals into the population +family; a zero-trade member records the engine's defined all-zero degenerate) — seeded +from the campaign doc's `seed` (C1; `net_trade_rs` is `#[serde(skip)]`, so annotators run +in-executor or not at all). `std::generalize` executes at **campaign scope**: after all cells, per (strategy, window) the per-cell *nominees* (last wf window's OOS report, else the sweep winner; none on gate truncation) across instruments feed the shipped `generalization()` when ≥ 2 exist — divergent per-instrument winners @@ -1602,7 +1605,31 @@ the retired deferral: per-cell no-nominee skip, per-run unproducible-tap skip pure name derivation). Noted debt: `aura chart` over the campaign family ROOT (cells spanning instruments) is untested/semantically undefined — only per-cell read-back is pinned. -**Guarantee.** Construction is a distinct phase, recursive at every level. Each +**Realization (#272, 2026-07-14 — per-cell fault isolation).** A member +fault (no-data, bind, run, or a caught panic) is a recorded per-cell outcome, +never a global abort: `run_cell` returns a fault-annotated `CellRealization` +(`fault: Option`, closed `CellFaultKind`) instead of `Err`, so +`execute`'s existing accumulate-then-append-once tail persists every healthy +cell and the one run record. Containment granularity is the cell for a sweep +stage (a grid hole compromises selection) and the fold for walk_forward +(surviving folds pool; failed folds recorded as `StageRealization. +window_faults`, the summary naming the ratio). `ExecFault::Registry` and +doc-shape preflight faults stay global. The CLI declares holes (per-cell +notes + a completion summary) and a run with ≥1 failed cell exits **3** +("completed with failed cells" — distinct from 0/1/2). A partially-covered +window carries a `CellCoverage` annotation (effective bounds + interior gap +months, from the #264 archive primitives). Generalize already treats a +no-nominee cell as `missing`, so a failed cell surfaces there unchanged. +Member panics are caught with `catch_unwind`(`AssertUnwindSafe`) at the three +member-run sites and recorded as `MemberFault::Panic`; a ref-counted +`SilencedPanic` guard (a process-global panic-hook save/no-op/restore behind a +`static Mutex`, held only around each `catch_unwind`) suppresses the default +crash backtrace so "recorded, campaign continues" is observably true on stderr, +not merely true in the record. The guard's mutex serialises only the +ref-count/hook-swap (O(1)), never the member computation, so C1 disjoint-parallel +execution and determinism are preserved; ref-counting (save on 0→1, restore on +1→0) keeps concurrent sweep/walk-forward threads and any caller-installed hook +correct. **Guarantee.** Construction is a distinct phase, recursive at every level. Each node type has a **factory** `params → sized concrete node` (e.g. `SMA(length)` sizes its ring buffer). A **blueprint** is the param-generic, input-role-generic graph-as-data produced by running a Rust builder (C9); it carries *free* numeric @@ -2548,6 +2575,19 @@ plan so the two halves cannot drift (C1/C4 determinism). (0 bugs; behaviour preservation, campaign-substrate reach-through, and the risk-regime axis all confirmed); residual findings are discoverability/ergonomics follow-ups (#216 risk-axis discoverability, #217 verb knob asymmetry, #218 no-project store litter). + **Amendment (2026-07-14, #256 fork B):** the dissolved walkforward/mc + translations' leading sweep became the enumerate-only **`std::grid`** stage — + a fieldless vocabulary block, legal only as the first stage and immediately + before `std::walk_forward`. Only the grid's parameter points ever crossed the + stage seam (the wf stage re-sweeps them per IS window itself), so the leading + stage no longer executes members or persists a `Sweep` registry family: + persisted dissolved-walkforward/mc campaign documents lose that family, while + the visible grades are byte-identical (the exact-grade E2E pins are + unchanged). The executor's inter-stage seam is now a typed two-armed flow + (points-only vs executed members); report-consuming stages are fenced by + preflight. `translate_generalize` keeps its executed `std::sweep(argmax)` + leading stage — its generalize stage consumes the argmax winner report as the + cell nominee. - **Inferential honesty of the World — family-selection without false-discovery control (tracked: milestone "Inferential validation (defend against false discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's diff --git a/docs/glossary.md b/docs/glossary.md index 51cfcf1..3a18fca 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -73,7 +73,7 @@ A feed-forward, order-independent research axis for scaling risk by bias strengt ### cost model **Avoid:** realistic broker -A composable downstream **C9 graph of cost nodes**, in **R**, that **approximates** (never claims) a broker's cost: each cost node reads the state it depends on (price, realized-volatility, a C11-recorded rate source, the executor's per-trade R-records) and emits a **cost-in-R** stream subtracted from gross R to yield **net R** (per-trade factors deduct at close, per-cycle-held factors accrue over the hold). It generalizes / subsumes the scalar `round_trip_cost` (its degenerate constant-per-trade case), lives in `aura-std` / `aura-composites`, is optional (zero-cost baseline), and demands every factor be a labelled stress-parameter **or** data-grounded (over-modelling is the anti-pattern). +A composable downstream **C9 graph of cost nodes**, in **R**, that **approximates** (never claims) a broker's cost: each cost node reads the state it depends on (price, realized-volatility, a C11-recorded rate source, the executor's per-trade R-records) and emits a **cost-in-R** stream subtracted from gross R to yield **net R** (per-trade factors deduct at close, per-cycle-held factors accrue over the hold). It generalizes / subsumes the scalar `round_trip_cost` (its degenerate constant-per-trade case — `cost_per_trade` is a **price-unit** numerator, R-normalized per trade as `cost/|entry−stop|`, not a cost in R), lives in `aura-std` / `aura-composites`, is optional (zero-cost baseline), and demands every factor be a labelled stress-parameter **or** data-grounded (over-modelling is the anti-pattern). ### cross-instrument generalization **Avoid:** cross-symbol pooling, pooled generalization @@ -232,10 +232,14 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete ### risk regime **Avoid:** risk section One entry of a campaign document's structural risk axis (`risk`): a -serializable protective-stop regime (sole variant `vol{length,k}`) the matrix +serializable protective-stop regime (variants `vol{length,k}` — per-cycle — +and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix runs every cell under, so cells differ by execution discipline, never by signal. Absent or empty = one implicit default regime; the regime's stop -defines the risk unit R. +defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over +m1 cycles) `length` only smooths the vol estimator while `k` scales the stop +distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's +timescale via `period_minutes`). ### run **Avoid:** —