Merge PR #270: tracker sweep + executor robustness (12 issues)

Autonomous /boss sweep over the 2026-07-13 quadriga harvest plus two owner-decided executor-robustness follow-ups. Closes #247 #256 #258 #259 #260 #261 #262 #264 #265 #266 #269 #272.
This commit was merged in pull request #270.
This commit is contained in:
2026-07-14 17:12:56 +02:00
37 changed files with 3683 additions and 544 deletions
+61 -29
View File
@@ -100,12 +100,15 @@ pub struct RMetrics {
pub sqn_normalized: f64, pub sqn_normalized: f64,
pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty 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] 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 /// Cost-netted realised R per closed trade (`r cost_in_r`), in trade
/// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire /// order — an in-memory conduit for the OOS R-series bootstrap. Equals
/// shape is unchanged, and from `PartialEq` (below) so every existing /// 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. /// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
#[serde(skip)] #[serde(skip)]
pub trade_rs: Vec<f64>, pub net_trade_rs: Vec<f64>,
} }
impl PartialEq for RMetrics { impl PartialEq for RMetrics {
@@ -122,7 +125,7 @@ impl PartialEq for RMetrics {
&& self.sqn_normalized == o.sqn_normalized && self.sqn_normalized == o.sqn_normalized
&& self.net_expectancy_r == o.net_expectancy_r && self.net_expectancy_r == o.net_expectancy_r
&& self.conviction_terciles_r == o.conviction_terciles_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, sqn_normalized: 0.0,
net_expectancy_r: 0.0, net_expectancy_r: 0.0,
conviction_terciles_r: [0.0; 3], conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(), net_trade_rs: Vec::new(),
}; };
} }
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect(); let rs: Vec<f64> = 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). // 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_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
let net_expectancy_r = net_sum / n as f64; 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<f64> = trades.iter().map(|t| t.r - t.cost).collect();
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous // 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. // near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
let conviction_terciles_r = if n < 3 { let conviction_terciles_r = if n < 3 {
@@ -311,7 +318,7 @@ pub fn summarize_r(
sqn_normalized, sqn_normalized,
net_expectancy_r, net_expectancy_r,
conviction_terciles_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 /// `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 /// 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`, /// 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 /// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
/// input -> a well-defined all-zero `RMetrics`. /// input -> a well-defined all-zero `RMetrics`.
pub fn r_metrics_from_rs(rs: &[f64]) -> 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, 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, 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, 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(); let sum: f64 = rs.iter().sum();
@@ -375,9 +383,9 @@ pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
n_open_at_end: 0, n_open_at_end: 0,
sqn, sqn,
sqn_normalized, 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], 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); 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 // One PositionManagement dense record row for the derive tests: only the columns
// derive_position_events reads (closed, direction, size, open) are set; the rest // 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 // 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, sqn_normalized: 1.0,
net_expectancy_r: 0.4, net_expectancy_r: 0.4,
conviction_terciles_r: [-0.5, 0.5, 1.5], 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"); let json = serde_json::to_string(&m).expect("serialize");
@@ -997,25 +1029,25 @@ mod tests {
} }
#[test] #[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 // 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 rec = pm_record_two_closed_trades(); // helper below
let m = summarize_r(&rec, &[]); 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); assert_eq!(m.n_trades, 2);
} }
#[test] #[test]
fn summarize_r_empty_record_has_empty_trade_rs() { fn summarize_r_empty_record_has_empty_net_trade_rs() {
let m = summarize_r(&[], &[]); let m = summarize_r(&[], &[]);
assert!(m.trade_rs.is_empty()); assert!(m.net_trade_rs.is_empty());
assert_eq!(m.n_trades, 0); assert_eq!(m.n_trades, 0);
} }
/// Property: a position still open on the last row is folded into the trade /// 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 /// 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 /// 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`, /// 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 /// 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() { fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
let rec = pm_record_closed_then_open_at_end(); let rec = pm_record_closed_then_open_at_end();
let m = summarize_r(&rec, &[]); 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_trades, 2);
assert_eq!(m.n_open_at_end, 1); assert_eq!(m.n_open_at_end, 1);
// Feed the open-at-end pooled series through the flat reducer: the // Feed the open-at-end pooled series through the flat reducer: the
// R-distribution fields (the verbatim-copied arithmetic) must agree. // 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.n_trades, m.n_trades);
assert_eq!(pooled.expectancy_r, m.expectancy_r); assert_eq!(pooled.expectancy_r, m.expectancy_r);
assert_eq!(pooled.win_rate, m.win_rate); assert_eq!(pooled.win_rate, m.win_rate);
@@ -1042,25 +1074,25 @@ mod tests {
} }
#[test] #[test]
fn rmetrics_partial_eq_ignores_trade_rs() { fn rmetrics_partial_eq_ignores_net_trade_rs() {
// two RMetrics equal in every metric but differing in trade_rs compare EQUAL // two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL
// (trade_rs is an in-memory conduit, excluded from equality) — this is what // (net_trade_rs is an in-memory conduit, excluded from equality) — this is what
// keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped, // keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped,
// so it deserializes empty). // so it deserializes empty).
let a = summarize_r(&pm_record_two_closed_trades(), &[]); let a = summarize_r(&pm_record_two_closed_trades(), &[]);
let mut b = a.clone(); let mut b = a.clone();
b.trade_rs = Vec::new(); b.net_trade_rs = Vec::new();
assert_eq!(a, b); assert_eq!(a, b);
} }
#[test] #[test]
fn populated_trade_rs_is_absent_from_serialized_json() { fn populated_net_trade_rs_is_absent_from_serialized_json() {
// the in-memory conduit must never reach the wire: a populated trade_rs is // 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). // dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
let m = summarize_r(&pm_record_two_closed_trades(), &[]); 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"); 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. /// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
+396 -82
View File
@@ -18,9 +18,8 @@ use aura_engine::{
}; };
use aura_registry::{ use aura_registry::{
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau, derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind,
CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization, PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
StageSelection,
}; };
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode}; use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
@@ -232,6 +231,31 @@ pub fn execute(
Ok(CampaignOutcome { record, run, cells: cells_out }) 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<Scalar>, RunReport)>),
Points(Vec<Vec<Scalar>>),
}
impl StageFlow {
/// Every flow can cross the seam as bare points — the walk_forward need.
fn points(&self) -> Vec<Vec<Scalar>> {
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 /// 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). /// at an empty gate (decision 8 — the cell truncates, the campaign continues).
fn run_cell( fn run_cell(
@@ -247,15 +271,27 @@ fn run_cell(
let mut families: Vec<StageFamily> = Vec::new(); let mut families: Vec<StageFamily> = Vec::new();
let mut selections: Vec<StageSelectionOut> = Vec::new(); let mut selections: Vec<StageSelectionOut> = Vec::new();
let mut stages: Vec<StageRealization> = Vec::new(); let mut stages: Vec<StageRealization> = Vec::new();
// The surviving population: (ordinal into the nearest preceding // The surviving population crossing the stage seam (see `StageFlow`).
// family_id-bearing stage's family, param point, member report). let mut flow = StageFlow::Members(Vec::new());
let mut survivors: Vec<(usize, Vec<Scalar>, RunReport)> = Vec::new();
// The cell's nominated generalize candidate: last wf window's OOS report // The cell's nominated generalize candidate: last wf window's OOS report
// when a walk_forward ran, else the sweep winner; None when truncated. // when a walk_forward ran, else the sweep winner; None when truncated.
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None; let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() { for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
match stage { 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 } => { StageBlock::Sweep { selection } => {
// Deterministic, self-describing family name (the "-{run}" // Deterministic, self-describing family name (the "-{run}"
// suffix is appended by the registry). // suffix is appended by the registry).
@@ -263,7 +299,14 @@ fn run_cell(
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}", "{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
cell.strategy_ordinal, cell.instrument, cell.regime_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 let family_id = registry
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family)) .append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
.map_err(ExecFault::Registry)?; .map_err(ExecFault::Registry)?;
@@ -300,6 +343,7 @@ fn run_cell(
survivor_ordinals: None, survivor_ordinals: None,
selection: Some(StageSelection { winner_ordinal, params, selection }), selection: Some(StageSelection { winner_ordinal, params, selection }),
bootstrap: None, bootstrap: None,
window_faults: vec![],
}); });
} }
None => { None => {
@@ -313,17 +357,20 @@ fn run_cell(
survivor_ordinals: None, survivor_ordinals: None,
selection: None, selection: None,
bootstrap: None, bootstrap: None,
window_faults: vec![],
}); });
} }
} }
// The whole family flows on (decision 3: `select` names the // The whole family flows on (decision 3: `select` names the
// recorded selection, never a filter). // recorded selection, never a filter).
survivors = family flow = StageFlow::Members(
.points family
.iter() .points
.enumerate() .iter()
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone())) .enumerate()
.collect(); .map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
.collect(),
);
families.push(StageFamily { families.push(StageFamily {
stage: stage_ordinal, stage: stage_ordinal,
block: "std::sweep", block: "std::sweep",
@@ -338,13 +385,21 @@ fn run_cell(
// None`) fails the member — conservative and deterministic // None`) fails the member — conservative and deterministic
// (the metric NAME was already preflighted against // (the metric NAME was already preflighted against
// `PER_MEMBER_METRICS`). // `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| { all.iter().all(|p| {
member_metric(report, &p.metric) member_metric(report, &p.metric)
.is_some_and(|value| predicate_holds(&p.cmp, value, p.value)) .is_some_and(|value| predicate_holds(&p.cmp, value, p.value))
}) })
}); });
let ordinals: Vec<usize> = survivors.iter().map(|(i, _, _)| *i).collect(); let ordinals: Vec<usize> = members.iter().map(|(i, _, _)| *i).collect();
let empty = ordinals.is_empty(); let empty = ordinals.is_empty();
stages.push(StageRealization { stages.push(StageRealization {
block: "std::gate".to_string(), block: "std::gate".to_string(),
@@ -352,6 +407,7 @@ fn run_cell(
survivor_ordinals: Some(ordinals), survivor_ordinals: Some(ordinals),
selection: None, selection: None,
bootstrap: None, bootstrap: None,
window_faults: vec![],
}); });
if empty { if empty {
nominee = None; // a truncated cell nominates no candidate 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 // The seam crossing is plain points: the walk-forward stage
// re-sweeps the surviving param points and never needs the // re-sweeps the surviving param points and never needs the
// gate bookkeeping's ordinals or reports. // gate bookkeeping's ordinals or reports.
let survivor_points: Vec<Vec<Scalar>> = let survivor_points: Vec<Vec<Scalar>> = flow.points();
survivors.iter().map(|(_, point, _)| point.clone()).collect(); match run_walk_forward_stage(
let fam = run_walk_forward_stage(
seed, seed,
cell, cell,
stage_ordinal, stage_ordinal,
@@ -381,22 +436,31 @@ fn run_cell(
&family_name, &family_name,
runner, runner,
registry, registry,
)?; ) {
stages.push(StageRealization { Ok(WfStageOutcome::Ran { family: fam, window_faults }) => {
block: "std::walk_forward".to_string(), stages.push(StageRealization {
family_id: Some(fam.family_id.clone()), block: "std::walk_forward".to_string(),
survivor_ordinals: None, family_id: Some(fam.family_id.clone()),
selection: None, survivor_ordinals: None,
bootstrap: None, selection: None,
}); bootstrap: None,
// Nominate the LAST window's OOS report (roll order per window_faults,
// walkforward_member_reports) with its chosen params — the wf });
// stage stamps them on the fresh OOS report's manifest. // Nominate the LAST window's OOS report (roll order,
nominee = fam // over the surviving folds) with its chosen params —
.reports // the wf stage stamps them on the fresh OOS report's
.last() // manifest.
.map(|last| (last.manifest.params.clone(), last.clone())); nominee = fam
families.push(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 } => { StageBlock::MonteCarlo { resamples, block_len } => {
// Terminal annotator (#200 d1/d3): nothing flows out. Dual // Terminal annotator (#200 d1/d3): nothing flows out. Dual
@@ -407,7 +471,7 @@ fn run_cell(
let bootstrap = let bootstrap =
match families.iter().rev().find(|f| f.block == "std::walk_forward") { match families.iter().rev().find(|f| f.block == "std::walk_forward") {
Some(fam) => StageBootstrap::PooledOos(r_bootstrap( Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
&pooled_trade_rs(&fam.reports), &pooled_net_trade_rs(&fam.reports),
*resamples as usize, *resamples as usize,
*block_len as usize, *block_len as usize,
seed, seed,
@@ -415,28 +479,39 @@ fn run_cell(
// Zero-trade members (r: None) feed an empty slice — // Zero-trade members (r: None) feed an empty slice —
// the engine's defined all-zero degenerate, recorded // the engine's defined all-zero degenerate, recorded
// explicitly (a null result is a valid result). // explicitly (a null result is a valid result).
None => StageBootstrap::PerSurvivor( None => {
survivors let StageFlow::Members(members) = &flow else {
.iter() return Err(ExecFault::PipelineShape {
.map(|(ordinal, _, report)| { detail: format!(
let rs = report "stage {stage_ordinal}: std::monte_carlo \
.metrics per-survivor bootstrap needs executed members \
.r (preflight admits no std::grid predecessor)"
.as_ref() ),
.map(|r| r.trade_rs.as_slice()) });
.unwrap_or(&[]); };
( StageBootstrap::PerSurvivor(
*ordinal, members
r_bootstrap( .iter()
rs, .map(|(ordinal, _, report)| {
*resamples as usize, let rs = report
*block_len as usize, .metrics
seed, .r
), .as_ref()
) .map(|r| r.net_trade_rs.as_slice())
}) .unwrap_or(&[]);
.collect(), (
), *ordinal,
r_bootstrap(
rs,
*resamples as usize,
*block_len as usize,
seed,
),
)
})
.collect(),
)
}
}; };
stages.push(StageRealization { stages.push(StageRealization {
block: "std::monte_carlo".to_string(), block: "std::monte_carlo".to_string(),
@@ -444,6 +519,7 @@ fn run_cell(
survivor_ordinals: None, survivor_ordinals: None,
selection: None, selection: None,
bootstrap: Some(bootstrap), bootstrap: Some(bootstrap),
window_faults: vec![],
}); });
} }
StageBlock::Generalize { .. } => { StageBlock::Generalize { .. } => {
@@ -462,6 +538,8 @@ fn run_cell(
window_ms: cell.window_ms, window_ms: cell.window_ms,
regime: cell.regime, regime: cell.regime,
regime_ordinal: cell.regime_ordinal, regime_ordinal: cell.regime_ordinal,
fault: None,
coverage: runner.window_coverage(cell),
stages, stages,
}, },
)) ))
@@ -500,6 +578,54 @@ fn enumerate_grid(axes: &BTreeMap<String, Axis>) -> SweepGrid {
SweepGrid { specs, axis_lens, points } 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<Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + 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 /// Run every grid point through the consumer's `MemberRunner` via the engine
/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are /// `sweep` (disjoint parallel members, C1 enumeration order). Faults are
/// captured per slot (the closure must return a report), and the LOWEST /// captured per slot (the closure must return a report), and the LOWEST
@@ -526,9 +652,15 @@ fn run_members(
let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new()); let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
let family = sweep(&space, |point: &[Cell]| { let family = sweep(&space, |point: &[Cell]| {
let params = zip_params(&grid.specs, point); let params = zip_params(&grid.specs, point);
match runner.run_member(cell, &params, cell.window_ms) { let called = {
Ok(report) => report, let _silence = SilencedPanic::enter();
Err(fault) => { std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runner.run_member(cell, &params, cell.window_ms)
}))
};
match called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
let index = cells let index = cells
.iter() .iter()
.position(|c| c.as_slice() == point) .position(|c| c.as_slice() == point)
@@ -536,6 +668,17 @@ fn run_members(
faults.lock().expect("fault capture lock").push((index, fault)); faults.lock().expect("fault capture lock").push((index, fault));
placeholder_report(&params, cell.window_ms) placeholder_report(&params, 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(&params, cell.window_ms)
}
} }
}); });
let mut captured = faults.into_inner().expect("fault capture lock"); let mut captured = faults.into_inner().expect("fault capture lock");
@@ -546,6 +689,97 @@ fn run_members(
Ok(family) 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<aura_registry::CellFault, ExecFault> {
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<dyn std::any::Any + Send>) -> String {
payload
.downcast_ref::<String>()
.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<StageRealization>,
families: Vec<StageFamily>,
selections: Vec<StageSelectionOut>,
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 /// Slot filler for a faulted member: the engine sweep contract needs one
/// report per slot, but a captured fault aborts `execute` before any family /// report per slot, but a captured fault aborts `execute` before any family
/// write, so this report is never persisted and never ranked. /// 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(), defaults: Vec::new(),
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)), window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0, seed: 0,
broker: "faulted-member-placeholder".to_string(), broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(),
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
@@ -627,15 +861,16 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect() 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 /// concatenated in report order — which IS roll order (the wf stage builds its
/// family via `walkforward_member_reports`, per-window OOS reports in roll /// family via `walkforward_member_reports_of`, per-window OOS reports in roll
/// order). Reports without an R block contribute nothing. /// order, over the surviving folds). Reports without an R block contribute
fn pooled_trade_rs(reports: &[RunReport]) -> Vec<f64> { /// nothing.
fn pooled_net_trade_rs(reports: &[RunReport]) -> Vec<f64> {
let mut pooled = Vec::new(); let mut pooled = Vec::new();
for report in reports { for report in reports {
if let Some(r) = &report.metrics.r { if let Some(r) = &report.metrics.r {
pooled.extend_from_slice(&r.trade_rs); pooled.extend_from_slice(&r.net_trade_rs);
} }
} }
pooled pooled
@@ -678,7 +913,7 @@ fn run_walk_forward_stage(
family_name: &str, family_name: &str,
runner: &dyn MemberRunner, runner: &dyn MemberRunner,
registry: &Registry, registry: &Registry,
) -> Result<StageFamily, ExecFault> { ) -> Result<WfStageOutcome, ExecFault> {
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } = let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
block block
else { else {
@@ -757,9 +992,16 @@ fn run_walk_forward_stage(
// with the same member fault capture the sweep stage uses. // with the same member fault capture the sweep stage uses.
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new()); let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
let is_family = sweep(&is_space, |cells| { let is_family = sweep(&is_space, |cells| {
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) { let params = zip_params(specs, cells);
Ok(report) => report, let called = {
Err(fault) => { let _silence = SilencedPanic::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
runner.run_member(cell, &params, (w.is.0 .0, w.is.1 .0))
}))
};
match called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
let midx = is_points let midx = is_points
.iter() .iter()
.position(|p| p.as_slice() == cells) .position(|p| p.as_slice() == cells)
@@ -767,6 +1009,14 @@ fn run_walk_forward_stage(
is_faults.lock().unwrap().push((midx, fault)); is_faults.lock().unwrap().push((midx, fault));
placeholder_report(&[], (0, 0)) 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"); 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 // 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 // stamped on the fresh OOS report (a new record, never a mutation of
// a stored one). // a stored one).
let mut oos_report = match runner.run_member( let oos_params = zip_params(specs, &winner.params);
cell, let oos_called = {
&zip_params(specs, &winner.params), let _silence = SilencedPanic::enter();
(w.oos.0 .0, w.oos.1 .0), std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
) { runner.run_member(cell, &oos_params, (w.oos.0 .0, w.oos.1 .0))
Ok(report) => report, }))
Err(fault) => { };
let mut oos_report = match oos_called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
faults.lock().unwrap().push((widx, ExecFault::Member(fault))); faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
return placeholder_window_run(specs); 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); oos_report.manifest.selection = Some(selection);
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report } WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
}); });
let window_faults = faults.into_inner().expect("no thread panicked holding the lock"); // Registry faults (optimizer/persistence broken) stay a global abort;
if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) { // Member/Window faults become per-fold WindowFaults via the same
return Err(fault); // `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<aura_registry::WindowFault> = 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 let family_id = registry
.append_family(family_name, FamilyKind::WalkForward, &reports) .append_family(family_name, FamilyKind::WalkForward, &reports)
.map_err(ExecFault::Registry)?; .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<aura_registry::WindowFault> },
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<RunReport> {
windows.iter().map(|w| w.run.oos_report.clone()).collect()
} }
+237 -56
View File
@@ -12,7 +12,9 @@
//! and no UI. //! and no UI.
mod exec; 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; use std::collections::BTreeMap;
@@ -54,6 +56,13 @@ pub trait MemberRunner: Sync {
params: &[(String, Scalar)], params: &[(String, Scalar)],
window_ms: (i64, i64), window_ms: (i64, i64),
) -> Result<RunReport, MemberFault>; ) -> Result<RunReport, MemberFault>;
/// 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<aura_registry::CellCoverage> {
None
}
} }
/// Display-free member faults (the consumer phrases them — the RefFault /// Display-free member faults (the consumer phrases them — the RefFault
@@ -63,6 +72,9 @@ pub enum MemberFault {
NoData { instrument: String, window_ms: (i64, i64) }, NoData { instrument: String, window_ms: (i64, i64) },
Bind(String), Bind(String),
Run(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 /// 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 { fn shape_rank(stage: &StageBlock) -> usize {
match stage { match stage {
StageBlock::Sweep { .. } => 0, StageBlock::Sweep { .. } => 0,
StageBlock::Grid => 0,
StageBlock::Gate { .. } => 1, StageBlock::Gate { .. } => 1,
StageBlock::WalkForward { .. } => 2, StageBlock::WalkForward { .. } => 2,
StageBlock::MonteCarlo { .. } => 3, StageBlock::MonteCarlo { .. } => 3,
@@ -205,6 +218,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
fn block_id(stage: &StageBlock) -> &'static str { fn block_id(stage: &StageBlock) -> &'static str {
match stage { match stage {
StageBlock::Sweep { .. } => "std::sweep", StageBlock::Sweep { .. } => "std::sweep",
StageBlock::Grid => "std::grid",
StageBlock::Gate { .. } => "std::gate", StageBlock::Gate { .. } => "std::gate",
StageBlock::WalkForward { .. } => "std::walk_forward", StageBlock::WalkForward { .. } => "std::walk_forward",
StageBlock::MonteCarlo { .. } => "std::monte_carlo", 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 // every violation: a rank drop is an out-of-order stage (an annotator
// before a population stage, anything after std::generalize), an adjacent // before a population stage, anything after std::generalize), an adjacent
// equal rank above the gate tier is a duplicate suffix stage. // 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 { 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]; let mut prev = &process.pipeline[0];
for (i, stage) in process.pipeline.iter().enumerate().skip(1) { 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 { 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)); 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). // per-stage slot rules (shape already established above).
for (i, stage) in process.pipeline.iter().enumerate() { for (i, stage) in process.pipeline.iter().enumerate() {
match stage { match stage {
StageBlock::Grid => {}
StageBlock::Sweep { selection } => match selection { StageBlock::Sweep { selection } => match selection {
Some(sel) => { Some(sel) => {
if !RANKABLE_METRICS.contains(&sel.metric.as_str()) { if !RANKABLE_METRICS.contains(&sel.metric.as_str()) {
@@ -365,7 +397,7 @@ mod tests {
sqn: 13.0, sqn: 13.0,
net_expectancy_r: 14.0, net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3], 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()); 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<StageBlock>, &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 /// A selection-free sweep followed by any other stage is refused: a
/// downstream stage would have no selection/nominee to consume. /// downstream stage would have no selection/nominee to consume.
#[test] #[test]
@@ -885,8 +978,10 @@ mod wf_tests {
/// A per-test registry in a fresh temp dir (a Registry's family store /// A per-test registry in a fresh temp dir (a Registry's family store
/// isolates per DIRECTORY, not per filename — see Registry::open). /// isolates per DIRECTORY, not per filename — see Registry::open).
fn wf_registry(name: &str) -> Registry { fn wf_registry(name: &str) -> Registry {
let dir = std::env::temp_dir() // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
.join(format!("aura-campaign-wf-{}-{}", std::process::id(), name)); // 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
Registry::open(dir.join("runs.jsonl")) 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 /// #272: a member fault raised INSIDE one window's in-sample sweep is
/// walk_forward stage with that fault, and among several faulted windows /// contained as that fold's `WindowFault` — the surviving windows still
/// the LOWEST roll index wins (the per-window `min_by_key` attribution /// run, pool into the walk_forward family, and the campaign run record
/// mirrors the sweep stage's own lowest-enumeration-index convention) — /// persists. Faulting w1's IS run out of 3 windows (w0, w1, w2) leaves
/// never the faulted stage's family, since a captured fault returns /// exactly 2 surviving OOS reports and one recorded window fault at
/// before any registry write. /// ordinal 1.
#[test] #[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 registry = wf_registry("is-fault");
let campaign = wf_campaign((0, 99)); let campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20); let process = wf_process(None, 40, 20, 20);
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault both w1's and // IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault only w1's IS
// w2's IS runs; the widx-1 fault must win over widx-2's. // 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, &registry)
.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![ let runner = WfFakeRunner::faulty(vec![
(
(0, 39),
vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w0".to_string()),
),
( (
(20, 59), (20, 59),
vec![("len".to_string(), Scalar::i64(3))], vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w1".to_string()), MemberFault::Run("is-w1".to_string()),
), ),
( (
(40, 79), (40, 79),
vec![("len".to_string(), Scalar::i64(2))], vec![("len".to_string(), Scalar::i64(4))],
MemberFault::Run("is-w2".to_string()), MemberFault::Run("is-w2".to_string()),
), ),
]); ]);
let err = run_wf(&campaign, &process, &runner, &registry) let outcome = run_wf(&campaign, &process, &runner, &registry)
.expect_err("a faulted IS member aborts the walk_forward stage"); .expect("an all-folds-faulted stage fails the cell, not the process");
match err {
ExecFault::Member(fault) => assert_eq!( assert!(
fault, outcome.cells[0].families.iter().all(|f| f.block != "std::walk_forward"),
MemberFault::Run("is-w1".to_string()), "no WalkForward family is produced when every fold fails"
"the LOWEST window index's fault wins, not w2's later one", );
), let cell = &outcome.record.cells[0];
other => panic!("expected ExecFault::Member, got {other:?}"), 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"); let members = registry.load_family_members().expect("load members");
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward)); 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 /// #272: a member PANIC inside a window's in-sample sweep is caught at the
/// bounds (a call distinct from every IS-sweep call, since the OOS /// wf member boundary (`catch_unwind`) and contained as that fold's
/// window's bounds differ from every window's IS bounds) also aborts the /// `WindowFault` with kind `panic` — the process does not abort, the
/// walk_forward stage with that fault, and — like the IS-sweep path — /// surviving folds pool, exactly as an `Err` fault is contained. Guards the
/// never persists the faulted stage's family. /// wf-path panic arm (the sweep-path arm is covered by `PanicRunner` in
/// tests/execute.rs; this is its walk_forward mirror).
#[test] #[test]
fn wf_oos_member_fault_aborts_walk_forward() { fn wf_member_panic_in_a_fold_is_contained() {
let registry = wf_registry("oos-fault"); // 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<RunReport, MemberFault> {
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 campaign = wf_campaign((0, 99));
let process = wf_process(None, 40, 20, 20); let process = wf_process(None, 40, 20, 20);
// total_pips == len, so the IS winner is always len=4 (argmax over let strategies = vec![("s".repeat(64), "{}".to_string())];
// [1,2,3,4]); fault only window 0's OOS run of that winner (OOS let outcome = execute(&"e".repeat(64), &campaign, &process, &strategies, &WfPanicRunner, &registry)
// bounds (40,59) match no window's IS bounds and no other window's .expect("a member panic in a fold is contained, not a process abort");
// OOS bounds).
let runner = WfFakeRunner::faulty(vec![( let cell = &outcome.record.cells[0];
(40, 59), let wf_stage = cell
vec![("len".to_string(), Scalar::i64(4))], .stages
MemberFault::Run("oos-w0".to_string()), .iter()
)]); .find(|s| s.block == "std::walk_forward")
let err = run_wf(&campaign, &process, &runner, &registry) .expect("wf stage realized despite the panicked fold");
.expect_err("a faulted OOS member aborts the walk_forward stage"); assert_eq!(wf_stage.window_faults.len(), 1);
match err { assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
ExecFault::Member(fault) => { assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Panic);
assert_eq!(fault, MemberFault::Run("oos-w0".to_string())) 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);
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));
} }
/// select_sweep_winner's Plateau arms dispatch through the public /// select_sweep_winner's Plateau arms dispatch through the public
+118 -33
View File
@@ -41,9 +41,9 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
/// The deterministic 4-trade R series a planted report carries (matches /// The deterministic 4-trade R series a planted report carries (matches
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory /// `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. /// existing equality and round-trip assertion stays green.
fn planted_trade_rs(net: f64) -> Vec<f64> { fn planted_net_trade_rs(net: f64) -> Vec<f64> {
vec![net, -net, 2.0 * net, net] 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, sqn_normalized: net,
net_expectancy_r: net, net_expectancy_r: net,
conviction_terciles_r: [0.0, 0.0, 0.0], 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<RunReport, MemberFault> {
panic!("planted member panic")
}
}
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest): /// 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. /// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
fn axes_2x2() -> BTreeMap<String, Axis> { fn axes_2x2() -> BTreeMap<String, Axis> {
@@ -219,8 +235,8 @@ fn strategies() -> Vec<(String, String)> {
/// Per-test registry DIRECTORY (family/campaign stores are per-directory /// Per-test registry DIRECTORY (family/campaign stores are per-directory
/// siblings of the runs path — the aura-registry temp-dir idiom). /// siblings of the runs path — the aura-registry temp-dir idiom).
fn temp_registry(name: &str) -> Registry { fn temp_registry(name: &str) -> Registry {
let dir = std::env::temp_dir() let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
.join(format!("aura-campaign-exec-{}-{}", std::process::id(), name)); .join(format!("aura-campaign-exec-{name}"));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp registry dir"); std::fs::create_dir_all(&dir).expect("create temp registry dir");
Registry::open(dir.join("runs.jsonl")) Registry::open(dir.join("runs.jsonl"))
@@ -400,34 +416,57 @@ fn execute_is_deterministic_twice() {
assert_eq!(runs[1], second.record); 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] #[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 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)]); 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| { let point = |fast: i64, slow: i64| {
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))] 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 { let runner = FakeRunner {
faults: vec![ faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
],
}; };
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg) let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
.expect_err("a faulted member aborts the run"); .expect("a faulted member is recorded, not a global abort");
match err { assert_eq!(out.record.cells.len(), 2, "both cells are recorded");
ExecFault::Member(fault) => assert_eq!( for cell in &out.record.cells {
fault, let fault = cell.fault.as_ref().expect("sweep-fault fails the cell");
MemberFault::Run("boom at index 1".to_string()), assert_eq!(fault.stage, 0);
"the LOWEST enumeration index's fault is reported", assert_eq!(fault.kind, aura_registry::CellFaultKind::Run);
),
other => panic!("expected ExecFault::Member, got {other:?}"),
} }
// 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_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, &reg)
.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] #[test]
@@ -483,11 +522,11 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
assert_eq!(mc.selection, None); assert_eq!(mc.selection, None);
// each survivor's bootstrap == a hand-called r_bootstrap on its planted // 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 net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
let expected = StageBootstrap::PerSurvivor(vec![ let expected = StageBootstrap::PerSurvivor(vec![
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)), (2, r_bootstrap(&planted_net_trade_rs(net(3, 6)), 200, 2, 7)),
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)), (3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)),
]); ]);
assert_eq!(mc.bootstrap, Some(expected)); assert_eq!(mc.bootstrap, Some(expected));
@@ -496,6 +535,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
assert_eq!(runs[0], out.record); 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(), &reg)
.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] #[test]
fn execute_mc_after_wf_pools_the_oos_series() { fn execute_mc_after_wf_pools_the_oos_series() {
let reg = temp_registry("mc_pooled"); 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.block, "std::walk_forward");
assert_eq!(wf.reports.len(), 2); 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) // order (roll order per walkforward_member_reports)
let mut pooled: Vec<f64> = Vec::new(); let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports { 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 net = (3 * 10 + 9) as f64 / 100.0;
let mut expected_pool = planted_trade_rs(net); let mut expected_pool = planted_net_trade_rs(net);
expected_pool.extend(planted_trade_rs(net)); expected_pool.extend(planted_net_trade_rs(net));
assert_eq!(pooled, expected_pool); assert_eq!(pooled, expected_pool);
let mc = &out.record.cells[0].stages[2]; 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 /// annotators compose in ONE pipeline without disturbing each other or the
/// population stages' generalize-nominee. `std::monte_carlo` sits between /// population stages' generalize-nominee. `std::monte_carlo` sits between
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its /// `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 /// (unaffected by generalize running after it), and `std::generalize`'s
/// campaign-scope nominee is still the wf stage's last-window winner /// campaign-scope nominee is still the wf stage's last-window winner
/// (unaffected by mc having annotated the cell first). A regression that /// (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"); 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) // with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
let wf = &out.cells[0].families[1]; let wf = &out.cells[0].families[1];
assert_eq!(wf.block, "std::walk_forward"); assert_eq!(wf.block, "std::walk_forward");
let mut pooled: Vec<f64> = Vec::new(); let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports { 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)); let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc)); assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
@@ -125,7 +125,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
sqn: 13.0, sqn: 13.0,
net_expectancy_r: 14.0, net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3], conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(), net_trade_rs: Vec::new(),
}), }),
}, },
}; };
+193 -30
View File
@@ -59,6 +59,9 @@ fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> aura_compo
Some(aura_research::RiskRegime::Vol { length, k }) => { Some(aura_research::RiskRegime::Vol { length, k }) => {
aura_composites::StopRule::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_research::RiskRegime>) -> aura_compo
/// cannot diverge from the run-side cost model (the #219 divergence class, /// cannot diverge from the run-side cost model (the #219 divergence class,
/// cost edition). The bound knob names are the builders' own `ParamSpec` names /// cost edition). The bound knob names are the builders' own `ParamSpec` names
/// — the `CostSpec` serde vocabulary conforms to them. /// — the `CostSpec` serde vocabulary conforms to them.
pub(crate) fn cost_nodes_for(specs: &[CostSpec]) -> Vec<PrimitiveBuilder> { pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec<PrimitiveBuilder> {
specs specs
.iter() .iter()
.map(|s| { .map(|s| {
let (knob, v) = cost_knob(s); let (knob, v) = cost_knob(s, instrument);
match s { match s {
CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)), CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)),
CostSpec::VolSlippage { .. } => VolSlippageCost::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<PrimitiveBuilder> {
/// (main.rs): the stamp key must equal the bind key for reproduce to /// (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 /// 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. /// name off this single function rather than each carrying its own literal.
pub(crate) fn cost_knob(spec: &CostSpec) -> (&'static str, f64) { pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) {
match spec { let (knob, value) = match spec {
CostSpec::Constant { cost_per_trade } => ("cost_per_trade", *cost_per_trade), CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade),
CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", *slip_vol_mult), CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult),
CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", *carry_per_cycle), 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 /// 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 \ "process stage {stage}: walk_forward windows do not fit the campaign \
window: {detail}" window: {detail}"
), ),
ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!( ExecFault::Member(m) => aura_campaign::member_fault_prose(m),
"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::Registry(e) => e.to_string(), ExecFault::Registry(e) => e.to_string(),
} }
} }
@@ -397,10 +398,67 @@ impl MemberRunner for CliMemberRunner<'_> {
stop, stop,
&binding, &binding,
&self.cost, &self.cost,
&cell.instrument,
); );
report.manifest.instrument = Some(cell.instrument.clone()); report.manifest.instrument = Some(cell.instrument.clone());
Ok(report) 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<aura_registry::CellCoverage> {
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<String> {
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 /// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
@@ -417,8 +475,10 @@ pub(crate) enum RunPresentation {
} }
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err` /// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1. /// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> { /// 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<usize, String> {
// Project gate FIRST: nothing (not even the file-sugar registration) // Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project. // touches a store outside a project.
if env.provenance().is_none() { if env.provenance().is_none() {
@@ -480,7 +540,7 @@ pub(crate) fn run_campaign_by_id(
campaign_id: &str, campaign_id: &str,
env: &Env, env: &Env,
presentation: RunPresentation, presentation: RunPresentation,
) -> Result<(), String> { ) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env)?; let run = run_campaign_returning(campaign_id, env)?;
present_campaign(run, presentation, env) present_campaign(run, presentation, env)
} }
@@ -589,7 +649,7 @@ fn present_campaign(
run: CampaignRun, run: CampaignRun,
presentation: RunPresentation, presentation: RunPresentation,
env: &Env, env: &Env,
) -> Result<(), String> { ) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run; let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid // 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<String> = 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 // Emission: emit-gated family/selection lines per cell, then the
// always-on final record line. Matched by NAME against the two closed- // always-on final record line. Matched by NAME against the two closed-
// vocabulary terms (self-evident at the call site, unlike positional // 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 }) serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes") .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): // Trace persistence runs LAST, after the always-on record line (0109):
@@ -674,7 +760,22 @@ fn present_campaign(
env, 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 /// 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_cost, rx_cost) = mpsc::channel();
let (tx_net, rx_net) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel();
let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg { 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_cost,
tx_net, tx_net,
}); });
@@ -1157,6 +1258,21 @@ mod tests {
assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); 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] #[test]
/// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse
/// pair of one wrapper-segment convention — stripping the wrapper off a /// 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 /// param, so the wrapped param_space stays cost-invariant (the probe/member
/// space equivalence the campaign path relies on). /// space equivalence the campaign path relies on).
fn cost_nodes_for_maps_each_component_to_its_bound_builder() { fn cost_nodes_for_maps_each_component_to_its_bound_builder() {
let nodes = cost_nodes_for(&[ 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::Constant {
aura_research::CostSpec::Carry { carry_per_cycle: 0.1 }, 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<String> = nodes.iter().map(|n| n.label()).collect(); let labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]);
assert!( assert!(
nodes.iter().all(|n| n.params().is_empty()), nodes.iter().all(|n| n.params().is_empty()),
"every component must be fully bound (no open param leaks into param_space)" "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] #[test]
@@ -1431,4 +1556,42 @@ mod tests {
dedup.dedup(); dedup.dedup();
assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}"); 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"
);
}
} }
+471 -72
View File
@@ -125,7 +125,7 @@ fn sim_optimal_manifest(
// Typed params pass straight through: the manifest carries self-describing // Typed params pass straight through: the manifest carries self-describing
// Scalars, so a length stays `i64` and a scale `f64` in the record. // Scalars, so a length stays `i64` and a scale `f64` in the record.
RunManifest { RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), commit: ENGINE_COMMIT.to_string(),
params, params,
defaults: Vec::new(), defaults: Vec::new(),
window, window,
@@ -875,11 +875,11 @@ fn select_winner(
/// (window order, then within-window trade order). Windows with no `r` block /// (window order, then within-window trade order). Windows with no `r` block
/// contribute nothing. The single home of the pooling-in-roll-order semantics — /// 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. /// both the walk-forward `oos_r` summary and the `mc` R-bootstrap reduce this.
fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> { fn pooled_oos_net_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
result result
.windows .windows
.iter() .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() .collect()
} }
@@ -888,14 +888,14 @@ fn pooled_oos_trade_rs(result: &WalkForwardResult) -> Vec<f64> {
/// (C14). /// (C14).
fn walkforward_summary_json(result: &WalkForwardResult) -> String { fn walkforward_summary_json(result: &WalkForwardResult) -> String {
let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0); 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!({ let mut obj = serde_json::json!({
"windows": result.windows.len(), "windows": result.windows.len(),
"stitched_total_pips": total, "stitched_total_pips": total,
"param_stability": param_stability(result), "param_stability": param_stability(result),
}); });
if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) { 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). // 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)) obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
.expect("RMetrics serializes"); .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 /// (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 /// `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 /// 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). /// Canonical JSON (C14).
/// ///
/// `axes` carries the invocation's raw axis names in argv order, followed by the /// `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(); .collect();
let pooled_rs: Vec<f64> = reports let pooled_rs: Vec<f64> = reports
.iter() .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(); .collect();
let mut obj = serde_json::json!({ let mut obj = serde_json::json!({
"windows": reports.len(), "windows": reports.len(),
@@ -1141,17 +1141,25 @@ fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Ce
} }
/// Re-derive the `StopRule` a member was minted under from its manifest params /// Re-derive the `StopRule` a member was minted under from its manifest params
/// (`stop_length`/`stop_k`, stamped by `run_blueprint_member` — the stop rides the /// (`stop_length`/`stop_k`, or `stop_period_minutes`/`stop_length`/`stop_k` for
/// risk regime OUTSIDE the wrapped param_space, so `point_from_params` cannot /// the #262 timescale-matched variant, stamped by `run_blueprint_member` — the
/// recover it). Falls back to the default vol-stop /// stop rides the risk regime OUTSIDE the wrapped param_space, so
/// regime when the manifest carries no stop knobs (pre-#233 members), mirroring /// `point_from_params` cannot recover it). `stop_period_minutes` present
/// `stop_rule_for_regime`'s `None` arm (campaign_run.rs) for the same one-directional /// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default
/// widening `point_from_params` already applies to missing manifest params. /// vol-stop regime when the manifest carries no stop knobs at all (pre-#233
/// members), mirroring `stop_rule_for_regime`'s `None` arm (campaign_run.rs)
/// for the same one-directional widening `point_from_params` already applies
/// to missing manifest params.
fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { fn stop_rule_from_params(params: &[(String, Scalar)]) -> 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 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()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64());
match (length, k) { match (period_minutes, length, k) {
(Some(length), Some(k)) => StopRule::Vol { 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 }, _ => 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::Cos
break; break;
}; };
specs.push(match knob { specs.push(match knob {
"cost_per_trade" => aura_research::CostSpec::Constant { cost_per_trade: v.as_f64() }, "cost_per_trade" => aura_research::CostSpec::Constant {
"slip_vol_mult" => aura_research::CostSpec::VolSlippage { slip_vol_mult: v.as_f64() }, cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()),
"carry_per_cycle" => aura_research::CostSpec::Carry { carry_per_cycle: 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 => { other => {
// Corrupted-on-disk manifest data — the reproduce path's clean // Corrupted-on-disk manifest data — the reproduce path's clean
// exit register (`aura:` + exit 1), like point_from_params. // exit register (`aura:` + exit 1), like point_from_params.
@@ -1347,6 +1361,10 @@ fn reproduce_family_in(
stop, stop,
&binding, &binding,
&cost, &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)); outcomes.push((label, rerun.metrics == stored.metrics));
} }
@@ -1730,6 +1748,14 @@ fn run_signal_r(
RunReport { manifest, metrics } 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 /// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the /// (`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 /// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
@@ -1750,6 +1776,7 @@ fn run_blueprint_member(
stop: StopRule, stop: StopRule,
binding: &binding::ResolvedBinding, binding: &binding::ResolvedBinding,
cost: &[aura_research::CostSpec], cost: &[aura_research::CostSpec],
instrument: &str,
) -> RunReport { ) -> RunReport {
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
let (tx_eq, rx_eq) = mpsc::channel(); let (tx_eq, rx_eq) = mpsc::channel();
@@ -1762,7 +1789,7 @@ fn run_blueprint_member(
let (tx_cost, rx_cost) = mpsc::channel(); let (tx_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = (!cost.is_empty()).then(|| CostLeg { 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_cost,
tx_net, tx_net,
}); });
@@ -1771,12 +1798,20 @@ fn run_blueprint_member(
.expect("member bootstraps (point kind-checked against param_space)"); .expect("member bootstraps (point kind-checked against param_space)");
h.run(sources); h.run(sources);
let mut named = zip_params(space, point); // by-name params for the manifest record 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, // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant,
// which stamps no vol knobs. The campaign/single-run paths only pass `Vol`, // which stamps no vol knobs. The campaign/single-run paths only pass `Vol`
// so the `Fixed` arm is inert here. // or `VolTf`, so the `Fixed` arm is inert here.
if let StopRule::Vol { length, k } = stop { match stop {
named.push(("stop_length".to_string(), Scalar::i64(length))); StopRule::Vol { length, k } => {
named.push(("stop_k".to_string(), Scalar::f64(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, // Stamp the cost model the member ran under, beside the stop knobs (#234,
// the #233 pattern): one `cost[k].<knob>` param per component, in // the #233 pattern): one `cost[k].<knob>` 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 // reads this stamp back via `cost_specs_from_params` (the #233 stop-regime
// pattern), so a costed family reproduces net, not gross. // pattern), so a costed family reproduces net, not gross.
for (k, spec) in cost.iter().enumerate() { 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))); named.push((format!("cost[{k}].{knob}"), Scalar::f64(v)));
} }
let mut manifest = sim_optimal_manifest(named, window, seed, pip); let mut manifest = sim_optimal_manifest(named, window, seed, pip);
@@ -1915,6 +1950,48 @@ fn override_paths(
Ok(overrides) 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}=<value>` see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?} — \
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --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). /// Apply a validated override set to a freshly loaded strategy (#246).
/// Infallible by contract: the set was derived against this same document at /// Infallible by contract: the set was derived against this same document at
/// the family boundary. /// the family boundary.
@@ -2013,11 +2090,11 @@ fn blueprint_sweep_family(
.sweep(|point| { .sweep(|point| {
// fresh per-member graph (Composite is !Clone, reload per member) run through // fresh per-member graph (Composite is !Clone, reload per member) run through
// the shared reduce-mode member path — the same fn reproduction re-runs. // 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), // render the sweep terminal's BindError to prose (#247), the fn's String error
// so `UnknownKnob("nope")` still surfaces verbatim at the IO wrapper. // contract — never the raw Debug struct.
.map_err(|e| format!("{e:?}")) .map_err(|e| render_bind_error(&e))
} }
/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window /// 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| { binder.sweep_with_lattice(|point| {
let sources = data.windowed_sources(from, to, env, &binding.columns()); let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty in-sample window"); 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 pip = data.pip_size();
let sources = data.windowed_sources(from, to, env, &binding.columns()); let sources = data.windowed_sources(from, to, env, &binding.columns());
let window = window_of(&sources).expect("non-empty out-of-sample window"); 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) (Vec::new(), report)
} }
@@ -2188,7 +2265,7 @@ fn blueprint_walkforward_family(
// (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without // (#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. // 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) { 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); std::process::exit(2);
} }
walk_forward(roller, space.clone(), |w: WindowBounds| { 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 family = monte_carlo(&base_point, &seeds, |seed, _base| {
let sources = synthetic_walk_sources(seed); let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk"); 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 // 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 — // metrics are bit-identical to the first, no seed reached a distinguishable realization —
@@ -2637,13 +2714,39 @@ fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
// selects the loaded-blueprint branch. There is no second built-in grammar anymore // selects the loaded-blueprint branch. There is no second built-in grammar anymore
// (#159 demo retirement / #220 axis generalization): without a blueprint the // (#159 demo retirement / #220 axis generalization): without a blueprint the
// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to // 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; // the campaign path. Usage errors exit 2; runtime refusals exit 1; a run that
// runtime failures exit 1. // completes with ≥1 failed cell exits 3 (#272); a clean run exits 0.
/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION` /// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`. /// falling back to `"unknown"`): both `--version` (via `version_string`) and
/// `sim_optimal_manifest`'s `RunManifest.commit` read this one const, so a
/// stale binary is distinguishable from a fresh one at the CLI surface,
/// before any run, without a second commit-sourcing mechanism (#266).
const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") {
Some(c) => c,
None => "unknown",
};
/// `aura 0.1.0 (<commit>)` as a process-lifetime `&'static str` — computed once
/// (clap's `version` builder method wants `Into<Str>`, and `clap::builder::Str`
/// (clap 4.6) only implements `From<&'static str>`, not `From<String>`, so the
/// one owned `String` this formats is leaked for the process's lifetime, same
/// as any other CLI one-shot startup string).
fn version_string() -> &'static str {
static VERSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
VERSION.get_or_init(|| format!("{} ({ENGINE_COMMIT})", env!("CARGO_PKG_VERSION")))
}
/// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the
/// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so
/// `aura --version` prints `aura 0.1.0 (<commit>)`.
#[derive(Parser)] #[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 { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Command, command: Command,
@@ -2684,6 +2787,8 @@ enum Command {
Process(research_docs::ProcessCmd), Process(research_docs::ProcessCmd),
/// Validate, introspect, and register campaign documents (experiment intent). /// Validate, introspect, and register campaign documents (experiment intent).
Campaign(research_docs::CampaignCmd), Campaign(research_docs::CampaignCmd),
/// Inspect the project's data archive.
Data(DataCmd),
} }
#[derive(Args)] #[derive(Args)]
@@ -2728,6 +2833,26 @@ struct NodesNewCmd {
namespace: Option<String>, namespace: Option<String>,
} }
#[derive(Args)]
struct DataCmd {
#[command(subcommand)]
command: DataCommand,
}
#[derive(Subcommand)]
enum DataCommand {
/// Report a symbol's monthly file-index coverage (span + interior gaps).
Coverage(DataCoverageCmd),
/// List the archive's known symbols, sorted, one per line.
List,
}
#[derive(Args)]
struct DataCoverageCmd {
/// The symbol to inventory (e.g. `GER40`).
symbol: String,
}
#[derive(Args)] #[derive(Args)]
#[command(args_conflicts_with_subcommands = true)] #[command(args_conflicts_with_subcommands = true)]
struct GraphCmd { 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<usize, String>) {
match r {
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
Ok(0) => {}
Ok(_) => std::process::exit(3),
}
}
/// The shared campaign window in Unix-ms, clipped to the archive. `full_window` /// 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 /// 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 /// 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. // sweep + walkforward only); trace-writing is out of scope here.
trace: false, trace: false,
}; };
verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| { exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env));
eprintln!("aura: {m}");
std::process::exit(1);
});
} }
fn dispatch_runs(a: RunsCmd, env: &project::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<str>]) -> Vec<String> {
if symbols.is_empty() {
return vec!["no symbols".to_string()];
}
symbols.iter().map(|s| s.to_string()).collect()
}
/// `aura data coverage <SYMBOL>` (#264 cut 1): print the archive's month
/// coverage for `SYMBOL` — the Copper failure mode (files present at both
/// ends of a window while an interior month is missing, so a first/last-bounds
/// check passes but a campaign run aborts mid-window) made visible up front.
/// Resolves the archive root through the normal project path (`Env::data_path`
/// — a project-local `[paths] data` override when set, the data-server default
/// otherwise), so a project-scoped archive is inventoried, not the host one.
fn dispatch_data_coverage(a: DataCoverageCmd, env: &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<Vec<String>, 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 /// `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. /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep.
fn dispatch_sweep(a: SweepCmd, env: &project::Env) { 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). // `name_persist`'s bool (true iff `--trace` was given).
trace: persist, trace: persist,
}; };
verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| { exit_on_campaign_result(verb_sugar::run_sweep_sugar(&inv, env));
eprintln!("aura: {m}");
std::process::exit(1);
});
} }
DataChoice::Synthetic => { DataChoice::Synthetic => {
// The synthetic in-process family path is still reduce-only // 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). // (unchanged since 0109 — walkforward always nominates).
trace, trace,
}; };
verb_sugar::run_walkforward_sugar( let result = verb_sugar::run_walkforward_sugar(
&inv, &inv,
WINNER_SELECTION_METRIC, WINNER_SELECTION_METRIC,
verb_sugar::WfWindows { verb_sugar::WfWindows {
@@ -3855,11 +4087,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
}, },
select, select,
env, env,
) );
.unwrap_or_else(|m| { exit_on_campaign_result(result);
eprintln!("aura: {m}");
std::process::exit(1);
});
return; return;
} }
// Synthetic in-process family path — unchanged (#220 non-goal); it // 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. // is out of #224's scope here.
trace: false, trace: false,
}; };
verb_sugar::run_mc_sugar( let result = verb_sugar::run_mc_sugar(
&inv, &inv,
WINNER_SELECTION_METRIC, WINNER_SELECTION_METRIC,
verb_sugar::WfWindows { verb_sugar::WfWindows {
@@ -3970,11 +4199,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
}, },
verb_sugar::McKnobs { resamples, block_len, seed }, verb_sugar::McKnobs { resamples, block_len, seed },
env, env,
) );
.unwrap_or_else(|m| { exit_on_campaign_result(result);
eprintln!("aura: {m}");
std::process::exit(1);
});
return; return;
} }
// Synthetic seed family (unchanged, #220 non-goal): closed // Synthetic seed family (unchanged, #220 non-goal): closed
@@ -4053,6 +4279,7 @@ fn main() {
Command::Nodes(a) => dispatch_nodes(a), Command::Nodes(a) => dispatch_nodes(a),
Command::Process(a) => research_docs::process_cmd(a, &env), Command::Process(a) => research_docs::process_cmd(a, &env),
Command::Campaign(a) => research_docs::campaign_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))); 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<std::sync::Arc<str>> = vec![];
assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]);
}
/// A non-empty archive renders one line per symbol, in the order handed
/// in (`DataServer::symbols()` already sorts) — no `no symbols` line
/// mixed in (#264).
#[test]
fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() {
let symbols: Vec<std::sync::Arc<str>> =
vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")];
assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]);
}
/// Disjoint archives (no shared instant across all listed symbols) refuse /// Disjoint archives (no shared instant across all listed symbols) refuse
/// rather than silently pooling a floor over different periods per /// rather than silently pooling a floor over different periods per
/// instrument (#213); the error names each symbol next to its own span, /// instrument (#213); the error names each symbol next to its own span,
@@ -4556,10 +4865,13 @@ mod tests {
stop, stop,
&binding, &binding,
cost, cost,
"GER40",
) )
}; };
let gross = run(&[]); 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 trade geometry, independently: a non-reduce cost-less wrap over
// the same realization, draining the dense R record whose ledger // 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 stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
let window = data.full_window(&env); let window = data.full_window(&env);
let pip = data.pip_size(); 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 recorded member: reduce-mode, cost-bound — `run_blueprint_member`,
// the exact path `CliMemberRunner::run_member` calls. // the exact path `CliMemberRunner::run_member` calls.
@@ -4663,6 +4977,7 @@ mod tests {
stop, stop,
&binding, &binding,
&cost, &cost,
"GER40",
); );
// The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` // 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_cost, rx_cost) = mpsc::channel();
let (tx_net, _rx_net) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel();
let cost_leg = 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) let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg)
.compile_with_params(&[]) .compile_with_params(&[])
.expect("the persist-side wrap builds"); .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()) let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new())
.expect("the price role resolves"); .expect("the price role resolves");
let report_a = run_blueprint_member( 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( 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. // Guard: the run must have actually traded, else the invariant is vacuous.
assert!( assert!(
@@ -5321,7 +5636,8 @@ mod tests {
#[test] #[test]
fn reproduce_family_re_derives_every_member_bit_identically() { fn reproduce_family_re_derives_every_member_bit_identically() {
// a unique temp runs store so the on-disk family + blueprint store do not collide. // 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl")); 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/ // 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; // 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. // 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl")); let reg = Registry::open(dir.join("runs.jsonl"));
@@ -5402,6 +5719,7 @@ mod tests {
non_default, non_default,
&binding, &binding,
&[], &[],
"GER40",
); );
// persist exactly as the sweep/campaign paths do: store the blueprint, append the family. // persist exactly as the sweep/campaign paths do: store the blueprint, append the family.
@@ -5434,7 +5752,8 @@ mod tests {
/// vol proxy). /// vol proxy).
#[test] #[test]
fn reproduce_family_re_derives_a_costed_member_bit_identically() { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl")); 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()) let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new())
.expect("the price role resolves"); .expect("the price role resolves");
let cost = vec![ let cost = vec![
aura_research::CostSpec::Constant { cost_per_trade: 0.0005 }, aura_research::CostSpec::Constant {
aura_research::CostSpec::VolSlippage { slip_vol_mult: 0.5 }, cost_per_trade: aura_research::CostValue::Scalar(0.0005),
aura_research::CostSpec::Carry { carry_per_cycle: 0.0001 }, },
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( let report = run_blueprint_member(
reload(), reload(),
@@ -5467,6 +5792,7 @@ mod tests {
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K },
&binding, &binding,
&cost, &cost,
"GER40",
); );
// Non-vacuity: the cost model must actually bite, else a DIVERGED // Non-vacuity: the cost model must actually bite, else a DIVERGED
// verdict could never be observed and this pin proves nothing. // verdict could never be observed and this pin proves nothing.
@@ -5489,7 +5815,8 @@ mod tests {
#[test] #[test]
fn reproduce_family_re_derives_every_mc_member_bit_identically() { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl")); 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). /// must show. Sweep / walk-forward labels still echo their params (covered elsewhere).
#[test] #[test]
fn reproduce_mc_member_labels_carry_the_seed() { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir"); std::fs::create_dir_all(&dir).expect("temp dir");
let reg = Registry::open(dir.join("runs.jsonl")); 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}"); 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 /// 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. /// `mc_args_from` refusal test below only sets the fields its scenario needs.
fn bare_mc_cmd() -> McCmd { fn bare_mc_cmd() -> McCmd {
+18 -6
View File
@@ -588,7 +588,9 @@ mod tests {
#[test] #[test]
fn discover_walks_up_to_aura_toml() { 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"); let nested = tmp.join("a/b/c");
std::fs::create_dir_all(&nested).unwrap(); std::fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_from(&nested), None); assert_eq!(discover_from(&nested), None);
@@ -611,7 +613,9 @@ mod tests {
/// and stamps commit-only provenance. /// and stamps commit-only provenance.
#[test] #[test]
fn data_only_project_loads_without_a_crate() { 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::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap(); std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
let p = load(&tmp, false).expect("data-only load"); 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. /// changes the data location depending on the caller's working directory.
#[test] #[test]
fn data_path_resolves_a_relative_paths_data_against_the_project_root() { 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::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap(); std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
let p = load(&tmp, false).expect("data-only load"); let p = load(&tmp, false).expect("data-only load");
@@ -643,7 +649,9 @@ mod tests {
#[test] #[test]
fn two_nodes_pointers_refuse_with_multi_crate() { 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::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap(); std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err(); let e = load(&tmp, false).unwrap_err();
@@ -653,7 +661,9 @@ mod tests {
#[test] #[test]
fn a_broken_nodes_pointer_names_the_pointer() { 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::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap(); std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
let e = load(&tmp, false).unwrap_err(); let e = load(&tmp, false).unwrap_err();
@@ -671,7 +681,9 @@ mod tests {
/// that (`load` still refuses → CLI exit 1). /// that (`load` still refuses → CLI exit 1).
#[test] #[test]
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() { 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(); std::fs::create_dir_all(&tmp).unwrap();
// `missing-node-crate` is never created — the pointer target is absent. // `missing-node-crate` is never created — the pointer target is absent.
std::fs::write( std::fs::write(
+40 -1
View File
@@ -147,6 +147,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
DocFault::BadCost { index } => { DocFault::BadCost { index } => {
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0") 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::NoStrategy => "strategies is empty".into(),
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"), DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
DocFault::EmptyAxis { strategy, axis } => { DocFault::EmptyAxis { strategy, axis } => {
@@ -278,6 +288,9 @@ fn describe_one_block(id: &str) -> Result<(), String> {
fn print_open_slots(slots: &[aura_research::OpenSlot]) { fn print_open_slots(slots: &[aura_research::OpenSlot]) {
for s in slots { for s in slots {
println!("open slot: {} ({})", s.path, s.hint); println!("open slot: {} ({})", s.path, s.hint);
for note in &s.notes {
println!(" {note}");
}
} }
if slots.is_empty() { if slots.is_empty() {
println!("no open slots"); println!("no open slots");
@@ -403,7 +416,14 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(()) 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) { 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 { let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env), CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => { CampaignSub::Introspect(i) => {
@@ -411,8 +431,8 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
introspect_campaign(i) introspect_campaign(i)
} }
CampaignSub::Register { file } => register_campaign(file, env), 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::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
}; };
if let Err(m) = result { if let Err(m) = result {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
@@ -651,4 +671,23 @@ mod tests {
); );
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}"); 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}");
}
} }
+6 -2
View File
@@ -488,7 +488,9 @@ mod tests {
#[test] #[test]
fn scaffold_project_refuses_existing_destination() { 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(); std::fs::create_dir_all(tmp.join("x")).unwrap();
let spec = project_scaffold_spec("x", &tmp).unwrap(); let spec = project_scaffold_spec("x", &tmp).unwrap();
let e = scaffold_project(&spec).unwrap_err(); let e = scaffold_project(&spec).unwrap_err();
@@ -498,7 +500,9 @@ mod tests {
#[test] #[test]
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() { 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(); std::fs::create_dir_all(&tmp).unwrap();
// existing destination // existing destination
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap(); let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
+136 -73
View File
@@ -333,11 +333,13 @@ fn validate_before_register(
} }
/// Run one dissolved sweep invocation end-to-end: register the generated /// 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( pub(crate) fn run_sweep_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
env: &crate::project::Env, env: &crate::project::Env,
) -> Result<(), String> { ) -> Result<usize, String> {
let generated = translate_sweep(inv)?; let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; 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 /// Build the `CrossInstrument` family members from an executed generalize
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order — /// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
/// the same order + shape the inline `run_generalize` built its `members` in, /// the same order + shape the inline `run_generalize` built its `members` in,
/// each already carrying `manifest.instrument`. Debug-asserts one member per /// each already carrying `manifest.instrument`. #272: a failed cell nominates
/// cell: generalize's pipeline has no gate stage, so every cell nominates — /// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
/// a cell silently missing its nominee here would be a campaign-executor /// outcome (the caller counts and surfaces the failed cells separately, via
/// regression, not a legitimate generalize outcome, and the filter_map below /// `outcome.record.cells[].fault`) — no longer an invariant this function
/// must not swallow it unnoticed. /// itself enforces.
fn cross_instrument_members( fn cross_instrument_members(
outcome: &aura_campaign::CampaignOutcome, outcome: &aura_campaign::CampaignOutcome,
) -> Vec<aura_engine::RunReport> { ) -> Vec<aura_engine::RunReport> {
let members: Vec<aura_engine::RunReport> = outcome outcome
.cells .cells
.iter() .iter()
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone())) .filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
.collect(); .collect()
debug_assert_eq!(
members.len(),
outcome.cells.len(),
"generalize has no gate stage — every cell must nominate"
);
members
} }
/// Run one dissolved `generalize` invocation end-to-end: register the generated /// Run one dissolved `generalize` invocation end-to-end: register the generated
/// documents, run through the one campaign path, then reprint today's exact /// documents, run through the one campaign path, then reprint today's exact
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and /// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
/// persist the `CrossInstrument` family. The stdout is byte-identical to the /// 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( pub(crate) fn run_generalize_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
metric: &str, metric: &str,
env: &crate::project::Env, env: &crate::project::Env,
) -> Result<(), String> { ) -> Result<usize, String> {
let generated = translate_generalize(inv, metric)?; let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; 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) .append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
println!("{{\"family_id\":\"{family_id}\"}}"); 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. /// 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 /// Translate one `aura walkforward --real` invocation into its two generated
/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep /// documents: a `[std::grid, std::walk_forward]` process (the leading
/// only enumerates the IS-refit survivor grid for the wf stage; its recorded /// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
/// argmax selection is not part of the summary) and a campaign running the /// stage — it executes nothing and persists no family (#256)) and a campaign
/// invocation's axis grid over one instrument under its single risk regime. /// running the invocation's axis grid over one instrument under its single
/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's /// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical`
/// blueprint already stored by topology hash; its content id is the strategy ref. /// is the user's blueprint already stored by topology hash; its content id is
/// the strategy ref.
pub(crate) fn translate_walkforward( pub(crate) fn translate_walkforward(
inv: &SugarInvocation, inv: &SugarInvocation,
metric: &str, metric: &str,
@@ -432,13 +435,7 @@ pub(crate) fn translate_walkforward(
name: "walkforward".to_string(), name: "walkforward".to_string(),
description: None, description: None,
pipeline: vec![ pipeline: vec![
StageBlock::Sweep { StageBlock::Grid,
selection: Some(SweepSelection {
metric: metric.to_string(),
select: SelectRule::Argmax,
deflate: false,
}),
},
StageBlock::WalkForward { StageBlock::WalkForward {
in_sample_ms: w.in_sample_ms, in_sample_ms: w.in_sample_ms,
out_of_sample_ms: w.out_of_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 /// documents, run through the one campaign path, then reprint the per-window member
/// lines + the summary line from the recorded `WalkForward` family. The stdout /// lines + the summary line from the recorded `WalkForward` family. The stdout
/// summary line is byte-identical to the retired welded path (the committed /// 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( pub(crate) fn run_walkforward_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
metric: &str, metric: &str,
w: WfWindows, w: WfWindows,
select: SelectRule, select: SelectRule,
env: &crate::project::Env, env: &crate::project::Env,
) -> Result<(), String> { ) -> Result<usize, String> {
let generated = translate_walkforward(inv, metric, w, select)?; let generated = translate_walkforward(inv, metric, w, select)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; 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(&reg, &generated)?; let (_process_id, campaign_id) = register_generated_wf(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; 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. // The single cell's walk_forward StageFamily.
let wf = run let wf = run
.outcome .outcome
@@ -554,7 +560,7 @@ pub(crate) fn run_walkforward_sugar(
env, env,
)?; )?;
} }
Ok(()) Ok(0)
} }
/// The two generated documents of one dissolved `mc` invocation. /// 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: /// Translate one `aura mc --real` invocation into its two generated documents:
/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the /// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading
/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal /// `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 /// 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 /// `E[R]`) and a campaign running the invocation's axis grid over one instrument
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes /// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
@@ -587,13 +594,7 @@ pub(crate) fn translate_mc(
name: "mc".to_string(), name: "mc".to_string(),
description: None, description: None,
pipeline: vec![ pipeline: vec![
StageBlock::Sweep { StageBlock::Grid,
selection: Some(SweepSelection {
metric: metric.to_string(),
select: SelectRule::Argmax,
deflate: false,
}),
},
StageBlock::WalkForward { StageBlock::WalkForward {
in_sample_ms: w.in_sample_ms, in_sample_ms: w.in_sample_ms,
out_of_sample_ms: w.out_of_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 /// 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 /// 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. /// (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( pub(crate) fn run_mc_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
metric: &str, metric: &str,
w: WfWindows, w: WfWindows,
mc: McKnobs, mc: McKnobs,
env: &crate::project::Env, env: &crate::project::Env,
) -> Result<(), String> { ) -> Result<usize, String> {
let generated = translate_mc(inv, metric, w, mc)?; let generated = translate_mc(inv, metric, w, mc)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; 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(&reg, &generated)?; let (_process_id, campaign_id) = register_generated_mc(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; 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 // The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap). // bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
let boot = run let boot = run
@@ -680,7 +689,7 @@ pub(crate) fn run_mc_sugar(
}) })
.ok_or("mc produced no pooled-OOS bootstrap")?; .ok_or("mc produced no pooled-OOS bootstrap")?;
println!("{}", crate::mc_r_bootstrap_json(boot)); println!("{}", crate::mc_r_bootstrap_json(boot));
Ok(()) Ok(0)
} }
#[cfg(test)] #[cfg(test)]
@@ -895,10 +904,9 @@ mod tests {
/// that silently, before the dispatch rewire ever exercises the path live. /// that silently, before the dispatch rewire ever exercises the path live.
#[test] #[test]
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() { fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!( let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
"aura-verb-sugar-generalize-registry-test-{}", .join("aura-verb-sugar-generalize-registry-test");
std::process::id() let _ = std::fs::remove_dir_all(&dir);
));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
@@ -934,17 +942,11 @@ mod tests {
content_id_of(&campaign_to_json(&b.campaign)), content_id_of(&campaign_to_json(&b.campaign)),
"identical invocations must generate identical campaign ids" "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!( assert_eq!(
a.process.pipeline, a.process.pipeline,
vec![ vec![
StageBlock::Sweep { StageBlock::Grid,
selection: Some(SweepSelection {
metric: "sqn_normalized".to_string(),
select: SelectRule::Argmax,
deflate: false,
})
},
StageBlock::WalkForward { StageBlock::WalkForward {
in_sample_ms: 7_776_000_000, in_sample_ms: 7_776_000_000,
out_of_sample_ms: 2_592_000_000, out_of_sample_ms: 2_592_000_000,
@@ -1047,10 +1049,9 @@ mod tests {
#[test] #[test]
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() { fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!( let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
"aura-verb-sugar-walkforward-registry-test-{}", .join("aura-verb-sugar-walkforward-registry-test");
std::process::id() let _ = std::fs::remove_dir_all(&dir);
));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
let ax = wf_axes(); let ax = wf_axes();
@@ -1084,17 +1085,11 @@ mod tests {
content_id_of(&campaign_to_json(&b.campaign)), content_id_of(&campaign_to_json(&b.campaign)),
"identical invocations must generate identical campaign ids" "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!( assert_eq!(
a.process.pipeline, a.process.pipeline,
vec![ vec![
StageBlock::Sweep { StageBlock::Grid,
selection: Some(SweepSelection {
metric: "sqn_normalized".to_string(),
select: SelectRule::Argmax,
deflate: false,
})
},
StageBlock::WalkForward { StageBlock::WalkForward {
in_sample_ms: 7_776_000_000, in_sample_ms: 7_776_000_000,
out_of_sample_ms: 2_592_000_000, out_of_sample_ms: 2_592_000_000,
@@ -1140,10 +1135,9 @@ mod tests {
#[test] #[test]
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() { fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
let dir = std::env::temp_dir().join(format!( let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
"aura-verb-sugar-mc-registry-test-{}", .join("aura-verb-sugar-mc-registry-test");
std::process::id() let _ = std::fs::remove_dir_all(&dir);
));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
let ax = wf_axes(); let ax = wf_axes();
@@ -1165,4 +1159,73 @@ mod tests {
assert_eq!(stored_process, process_to_json(&generated.process)); assert_eq!(stored_process, process_to_json(&generated.process));
let _ = std::fs::remove_dir_all(&dir); 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<Option<String>> =
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"
);
}
} }
+1 -1
View File
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique /// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`). /// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
fn temp_cwd(name: &str) -> std::path::PathBuf { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd"); std::fs::create_dir_all(&dir).expect("create temp cwd");
dir dir
+262 -17
View File
@@ -5,7 +5,7 @@ use std::path::Path;
use std::process::Command; use std::process::Command;
mod common; 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 /// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`). /// 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). /// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
/// Unique per test + per process; no external tempfile dependency. /// Unique per test + per process; no external tempfile dependency.
fn temp_cwd(name: &str) -> std::path::PathBuf { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd"); std::fs::create_dir_all(&dir).expect("create temp cwd");
dir 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); 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 <name>: names no param of this blueprint … — see `aura sweep
/// <bp> --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 /// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register
/// their generated campaign document" — store litter, probe P3): binding only /// their generated campaign document" — store litter, probe P3): binding only
/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving /// 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 /// generated campaign document (carrying the `--name` handle and the stop as a
/// non-empty single risk regime), and one campaign-run record — mirroring the /// non-empty single risk regime), and one campaign-run record — mirroring the
/// generalize dissolution's audit trail. The persisted family set is exactly one /// 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) and NO `Sweep` family —
/// `WalkForward` family (the per-window OOS reports) — the observable proof that /// the leading `std::grid` stage only enumerates (#256) — the observable proof that
/// `dispatch_walkforward`'s real-data path runs through the one campaign /// `dispatch_walkforward`'s real-data path runs through the one campaign
/// executor rather than the deleted inline roller. Gated on the shared GER40 /// executor rather than the deleted inline roller. Gated on the shared GER40
/// archive; skips cleanly on a data refusal. /// 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(); let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
assert_eq!( assert_eq!(
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
1, 0,
"one Sweep family from the pipeline's leading argmax stage: {fams_out}" "the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
); );
assert_eq!( assert_eq!(
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(), 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"); 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"); let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!( assert!(
stderr.contains("Knob") && stderr.contains("nope"), stderr.contains("names no param") && stderr.contains("nope"),
"names the unresolved axis via BindError: {stderr}" "names the unresolved axis via the prose payload (#269): {stderr}"
); );
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {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"); 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"); let stderr = String::from_utf8(out.stderr).expect("utf-8");
assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}"); 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!( assert_eq!(
emits, 1, emits, 1,
"attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}", "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 <name>: 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 <open blueprint.json>` is rejected with a named error + exit 2 /// E2E (acc 2): `aura mc <open blueprint.json>` is rejected with a named error + exit 2
/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder. /// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder.
#[test] #[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 (<commit-ish>)`, 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 /// 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 /// NOT byte-identical to `aura run --help` — each subcommand documents its own
/// options rather than reprinting one shared global blob (the #131 uniform-help /// 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 /// path — a successful run durably auto-registers exactly one generated process
/// document, one generated campaign document (carrying the constant "mc" name and the /// 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 /// 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 /// single `mc_r_bootstrap` grade line. The pipeline's walk_forward stage persists
/// walk_forward stage each persist one family; the terminal monte_carlo stage is an /// the one family; the leading `std::grid` stage only enumerates (#256) and the
/// annotator and adds NO family. This is the observable proof the inline path is gone /// 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; /// and the dissolution runs through the executor. Gated on the GER40 2025 archive;
/// skips cleanly on a data refusal. /// skips cleanly on a data refusal.
#[test] #[test]
@@ -5222,8 +5392,8 @@ fn mc_dissolves_through_the_campaign_path() {
let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
assert_eq!( assert_eq!(
fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
1, 0,
"one Sweep family from the pipeline's leading argmax stage: {fams_out}" "the enumerate-only std::grid leading stage persists no Sweep family: {fams_out}"
); );
assert_eq!( assert_eq!(
fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(), 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 /// 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 — /// 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 /// 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 /// leading std::grid stage only enumerates (nothing runs over the short window); the
/// stage 1 (the walk_forward roller), so this bites only when the window genuinely has /// refusal fires at stage 1 (the walk_forward roller), so this bites only when the
/// data. Gated on the GER40 archive; skips cleanly on a data-less host. /// window genuinely has data. Gated on the GER40 archive; skips cleanly on a data-less host.
#[test] #[test]
fn mc_real_fits_the_injected_roller_to_a_short_window() { fn mc_real_fits_the_injected_roller_to_a_short_window() {
if !local_data_present() { 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("\"n_trades\":0"), "{stdout}");
assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}"); assert!(stdout.contains("\"total_pips\":0.0"), "{stdout}");
} }
/// Property (#264, coverage cut 1): `aura data coverage <SYMBOL>` 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 <symbol>`, 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:?}");
}
+29
View File
@@ -55,6 +55,15 @@ impl Drop for FreshProject {
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named /// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and /// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
/// concurrent `cargo test` processes never collide. /// 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 { fn mint_tempdir() -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0); static COUNTER: AtomicUsize = AtomicUsize::new(0);
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
@@ -118,6 +127,26 @@ pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
(root.clone(), FreshProject { root }) (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 /// A scratch filesystem entry a test writes under the git-tracked
/// demo-project fixture root (only `runs/` there is fixture-gitignored), /// demo-project fixture root (only `runs/` there is fixture-gitignored),
/// removed on drop — including during a mid-test panic — so a failed /// removed on drop — including during a mid-test panic — so a failed
+3 -3
View File
@@ -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(); let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!( assert_eq!(
lines.len(), lines.len(),
28, 29,
"the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}" "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 /// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`). /// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd"); std::fs::create_dir_all(&dir).expect("create temp cwd");
dir dir
+4 -2
View File
@@ -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). /// "binds no node crate" hint (not the outside-project "no Aura.toml" text).
#[test] #[test]
fn data_only_project_hints_attach_for_namespaced_ids() { 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::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("Aura.toml"), "").unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap();
let bp = tmp.join("bp.json"); let bp = tmp.join("bp.json");
@@ -388,7 +389,8 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
#[test] #[test]
fn missing_artifact_refuses_with_build_hint() { fn missing_artifact_refuses_with_build_hint() {
// A minimal never-built project: valid cargo metadata, no dylib on disk. // 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::create_dir_all(tmp.join("src")).unwrap();
std::fs::write(tmp.join("Aura.toml"), "").unwrap(); std::fs::write(tmp.join("Aura.toml"), "").unwrap();
std::fs::write(tmp.join("src/lib.rs"), "").unwrap(); std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
+12 -1
View File
@@ -14,8 +14,19 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
.expect("run aura") .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 { 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); let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap(); std::fs::create_dir_all(&d).unwrap();
d d
+34 -1
View File
@@ -4,7 +4,7 @@ use std::path::Path;
use std::process::Command; use std::process::Command;
fn temp_cwd(tag: &str) -> std::path::PathBuf { 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); let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap(); std::fs::create_dir_all(&d).unwrap();
d d
@@ -18,6 +18,39 @@ fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
.expect("spawn aura") .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] #[test]
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() { fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
let cwd = temp_cwd("attach"); let cwd = temp_cwd("attach");
@@ -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 // Archive-gated: skip cleanly on a data-less machine (the member-data
// refusal, never a panic) — mirroring campaign_run_real_e2e_*. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign cell"); eprintln!("skip: no local GER40 data for the campaign cell");
+732 -45
View File
@@ -5,14 +5,17 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
mod common; 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 /// A fresh, unique working directory for a process test that persists
/// content-addressed documents under `./runs/` (so `aura process register` /// content-addressed documents under `./runs/` (so `aura process register`
/// never dirties the repo). Unique per test + per process; no external /// never dirties the repo). Unique per test + per process; no external
/// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`). /// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`).
fn temp_cwd(name: &str) -> PathBuf { 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); let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd"); std::fs::create_dir_all(&dir).expect("create temp cwd");
dir dir
@@ -493,6 +496,30 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() {
assert!(!out.contains("BadRegime"), "Debug leak: {out}"); 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 /// #216: the intrinsic validate summary reports the realized matrix — the
/// exec cross-product strategies × instruments × windows × regimes — so an /// exec cross-product strategies × instruments × windows × regimes — so an
/// 8-cell document no longer reads as regime-less. Points (the axis grid) are /// 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"]); let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( 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}" "the block description must name the regime shape: {out}"
); );
assert!( 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}" "the risk slot must render as optional: {out}"
); );
assert!( assert!(
@@ -603,7 +630,7 @@ fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() {
assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( assert!(
out.contains( 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}" "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"]); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "bare.json"]);
assert_eq!(code, Some(0)); assert_eq!(code, Some(0));
assert!( 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}" "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_eq!(code, Some(0), "stdout/stderr: {out}");
assert!( assert!(
out.contains( 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}" "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/|entrystop| 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] #[test]
fn campaign_introspect_unwired_reports_the_spec_example_slots() { fn campaign_introspect_unwired_reports_the_spec_example_slots() {
let dir = temp_cwd("campaign-introspect-unwired"); 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 /// the RAW `param_space` names (`fast.length` / `slow.length` — see the
/// naming note in the referential test above). `persist_taps`/`emit` are /// naming note in the referential test above). `persist_taps`/`emit` are
/// spliced verbatim (pass `""` for empty, `"\"family_table\""` etc.). /// 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, bp_id: &str,
proc_id: &str, proc_id: &str,
window: (i64, i64), window: (i64, i64),
@@ -1022,7 +1121,7 @@ fn campaign_doc_json(
"format_version": 1, "format_version": 1,
"kind": "campaign", "kind": "campaign",
"name": "run-seam", "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}" }}, "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ], "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 /// An mc-bearing process: intrinsically valid AND (since the v2 executor) an
/// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep /// executable pipeline shape — `sweep -> monte_carlo` annotates each sweep
/// survivor. The two addressing-mode tests below run it over the [1, 2] 1970 /// 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 /// The v2 boundary, campaign side: a generalize-bearing process is an
/// executable shape, but `std::generalize` needs >= 2 instruments in the /// executable shape, but `std::generalize` needs >= 2 instruments in the
/// campaign — a STATIC preflight fact (the referential gate has run, no data /// 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` + /// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` +
/// `doc_fault_prose` composition `campaign validate` shares), before /// `doc_fault_prose` composition `campaign validate` shares), before
/// registration, before the referential tier, before any member runs — and /// 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. /// pinned here.
#[test] #[test]
fn campaign_run_refuses_unknown_tap_at_validate() { 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 /// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails
/// through the new intrinsic gate (no UnknownTap prose) and the run proceeds /// 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 /// to the member-data seam, exactly as a tap-less run over the [1, 2] 1970
/// [1, 2] 1970 epoch-ms window does (data-less host: missing geometry; /// epoch-ms window does (data-less host: missing geometry; data-ful host: no
/// data-ful host: no archive file overlaps) — exit 1 either way, and no /// archive file overlaps) — #272: the member-data refusal is now a CONTAINED
/// trace output (tracing never starts on a run that dies before members; /// cell fault, not a campaign abort, so the run completes (exit 3, "completed
/// there is no "traces persisted" summary line). NOTE: the 0107 /// with failed cells") and the always-on record line still carries the
/// loud-deferral eprintln still fires in this task's tree and is /// failed cell's fault. The retired 0107 loud-deferral note ("persist_taps not
/// deliberately NOT asserted either way here — Task 3 deletes it and /// yet honored") must not appear — its absence is asserted below.
/// tightens this test to pin its absence.
#[test] #[test]
fn campaign_run_valid_tap_reaches_the_member_data_seam() { fn campaign_run_valid_tap_reaches_the_member_data_seam() {
let (dir, _fixture) = fresh_project(); 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\"", ""), &campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
); );
let (out, code) = run_code_in(&dir, &["campaign", "run", "taps.campaign.json"]); 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!( assert!(
!out.contains("unknown tap"), !out.contains("unknown tap"),
"\"equity\" is in the closed vocabulary and must pass validate: {out}" "\"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"), out.contains("no recorded geometry") || out.contains("no data for instrument"),
"the refusal names the data condition: {out}" "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!( assert!(
!out.contains("persist_taps not yet honored"), !out.contains("persist_taps not yet honored"),
"the 0107 deferral line is retired; persist_taps is honored from 0109 on: {out}" "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!( assert!(
!out.contains("traces persisted:"), out.contains("1 failed"),
"a member-data refusal aborts inside execute, before any tracing: {out}" "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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign e2e"); 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].<knob> 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<u64> =
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 /// The campaign `data.bindings` override end to end (#231, 6b): the same
/// seeded strategy, window, and seed run twice — bare (price<-close default) /// seeded strategy, window, and seed run twice — bare (price<-close default)
/// and with `price` rebound to the open column. Both exit 0; the realized /// 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); write_doc(&dir, "open.campaign.json", &rebound);
let (out_close, code_close) = run_code_in(&dir, &["campaign", "run", "close.campaign.json"]); 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")) && (out_close.contains("no recorded geometry") || out_close.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the binding-override e2e"); 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); write_doc(&dir, "regime.campaign.json", &with_regime);
let (out, code) = run_code_in(&dir, &["campaign", "run", "regime.campaign.json"]); 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign regime e2e"); 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); write_doc(&dir, "tworegimes.campaign.json", &with_regimes);
let (out, code) = run_code_in(&dir, &["campaign", "run", "tworegimes.campaign.json"]); 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign two-regime e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign regime-persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign cost-persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign net-persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign vol-slippage-persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the two-regime persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign e2e"); 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 // (a) the cost-less baseline: net == gross, and net_r_equity skips with
// the remedy notice while equity still persists. // the remedy notice while equity still persists.
let (out_gross, code_gross) = run_code_in(&dir, &["campaign", "run", "costless.campaign.json"]); 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")) && (out_gross.contains("no recorded geometry") || out_gross.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the cost e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign carry-cost-persist e2e"); 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"]); 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. // 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")) && (out.contains("no recorded geometry") || out.contains("no data for instrument"))
{ {
eprintln!("skip: no local GER40 data for the campaign e2e"); 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 /// 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 /// byte-identical prose — proof the two addressing modes are not two
/// independently drifting code paths. No real data needed: with the [1, 2] /// independently drifting code paths. No real data needed: with the [1, 2]
/// 1970 window, both runs pass the (now mc-permitting) preflight and refuse /// 1970 window, both runs pass the (now mc-permitting) preflight and the one
/// at the member-data seam — no recorded geometry on a data-less host, no /// cell fails at the member-data seam — no recorded geometry on a data-less
/// overlapping archive window on a data-ful one — exit 1 and byte-identical /// host, no overlapping archive window on a data-ful one — #272: the failed
/// prose either way (the message depends only on env/instrument/window). /// 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] #[test]
fn campaign_run_by_content_id_matches_file_sugar_refusal() { fn campaign_run_by_content_id_matches_file_sugar_refusal() {
let (dir, _fixture) = fresh_project(); 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), "", "")); 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"]); 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"]); let (reg_out, reg_code) = run_code_in(&dir, &["campaign", "register", "mc2.campaign.json"]);
assert_eq!(reg_code, Some(0), "register failed: {reg_out}"); 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:") .trim_start_matches("content:")
.to_string(); .to_string();
let (id_out, id_code) = run_code_in(&dir, &["campaign", "run", &id]); 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 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"); 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); 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), "", "")); 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"]); 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!( assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"), out.contains("no recorded geometry") || out.contains("no data for instrument"),
"referencing + axis-binding must succeed, reaching the member-data \ "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" "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}"
);
}
+8 -1
View File
@@ -19,7 +19,7 @@ use aura_core::{PrimitiveBuilder, Scalar};
use aura_engine::{Composite, GraphBuilder, NodeHandle}; use aura_engine::{Composite, GraphBuilder, NodeHandle};
use aura_std::{ use aura_std::{
cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul, 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, PM_FIELD_NAMES,
}; };
@@ -75,6 +75,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
pub enum StopRule { pub enum StopRule {
Fixed(f64), Fixed(f64),
Vol { length: i64, k: f64 }, Vol { length: i64, k: f64 },
VolTf { period_minutes: i64, length: i64, k: f64 },
} }
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal /// 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 { Box::new(move |g| match stop {
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
StopRule::Vol { length, k } => g.add(vol_stop(length, k)), 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, risk_budget,
) )
@@ -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); 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<Scalar>)> = 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, /// 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` /// 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 /// is the positional point the open variant needs (empty for the bound one). Shared by the
+45
View File
@@ -659,6 +659,51 @@ mod tests {
assert_eq!(names, ["length"]); 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 /// Eager refusals: unknown member param (a member bound at `Add` has left the
/// open schema), kind mismatch across members, a doubly-ganged member, a /// open schema), kind mismatch across members, a doubly-ganged member, a
/// single-member gang, and an unknown node identifier. /// single-member gang, and an unknown node identifier.
+11 -2
View File
@@ -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: /// 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 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" /// no matching file for `symbol` — the same "no archive for this symbol"
/// condition an absent `DataServer` symbol represents. /// condition an absent `DataServer` symbol represents. `pub` (#264): unlike
fn list_m1_months(data_path: &Path, symbol: &str) -> Vec<(u16, u8)> { /// [`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 prefix = format!("{symbol}_");
let mut months = Vec::new(); let mut months = Vec::new();
let Ok(entries) = std::fs::read_dir(data_path) else { 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 /// mirrors `aura-cli`'s own `tests/common::mint_tempdir` pattern (unique
/// name via pid + an atomic counter) so this crate's tests need no /// name via pid + an atomic counter) so this crate's tests need no
/// `tempfile` dev-dependency for a handful of throwaway fixture dirs. /// `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); struct ScratchDir(std::path::PathBuf);
impl ScratchDir { impl ScratchDir {
+94 -22
View File
@@ -30,8 +30,9 @@ mod compat;
mod lineage; mod lineage;
pub use lineage::{ pub use lineage::{
derive_trace_name, group_families, mc_member_reports, sweep_member_reports, derive_trace_name, group_families, mc_member_reports, sweep_member_reports,
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellCoverage,
Family, FamilyKind, FamilyRunRecord, StageBootstrap, StageRealization, StageSelection, CellFault, CellFaultKind, CellRealization, Family, FamilyKind, FamilyRunRecord,
StageBootstrap, StageRealization, StageSelection, WindowFault,
}; };
mod trace_store; mod trace_store;
@@ -505,8 +506,8 @@ fn closed_neighbourhood(i: usize, axis_lens: &[usize]) -> Vec<usize> {
out out
} }
fn member_trade_rs(rep: &RunReport) -> &[f64] { fn member_net_trade_rs(rep: &RunReport) -> &[f64] {
rep.metrics.r.as_ref().map(|r| r.trade_rs.as_slice()).unwrap_or(&[]) 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 /// 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 /// 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 /// moving-block-resampled (in odometer order, from a per-iteration seed) and the
/// max recomputed metric across members is taken. Deterministic given `seed` (C1). /// 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<f64> { fn null_best_of_k(family: &SweepFamily, m: Metric, n_resamples: usize, block_len: usize, seed: u64) -> Vec<f64> {
let centred: Vec<Vec<f64>> = family.points.iter().map(|p| { let centred: Vec<Vec<f64>> = 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(); } if rs.is_empty() { return Vec::new(); }
let mean = rs.iter().sum::<f64>() / rs.len() as f64; let mean = rs.iter().sum::<f64>() / rs.len() as f64;
rs.iter().map(|x| x - mean).collect() 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); 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 // 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 // degenerate cases: zero resamples (`null_max` empty), or no member carries
// `trade_rs` (every member's centred series is empty, so each iteration's // `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 — `trade_rs` // 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 // 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 // conduits even when its `r` block (hence `raw`) is finite. Filtering to
// finite values collapses both into one "no usable null" branch, instead of // 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 sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here
net_expectancy_r, net_expectancy_r,
conviction_terciles_r: [0.0, 0.0, 0.0], conviction_terciles_r: [0.0, 0.0, 0.0],
trade_rs: Vec::new(), net_trade_rs: Vec::new(),
}); });
rep rep
} }
@@ -909,8 +910,11 @@ mod tests {
} }
fn temp_path(name: &str) -> PathBuf { fn temp_path(name: &str) -> PathBuf {
// unique per test + per process; no external tempfile dependency // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258):
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name)) // 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] #[test]
@@ -1081,8 +1085,10 @@ mod tests {
fn temp_family_dir(name: &str) -> PathBuf { fn temp_family_dir(name: &str) -> PathBuf {
// a unique per-test directory so the families.jsonl sibling never collides // 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. // across tests sharing the temp dir; the registry binds to runs.jsonl inside.
let dir = // Fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
std::env::temp_dir().join(format!("aura-registry-fam-{}-{}", std::process::id(), name)); // 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); let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp family dir"); fs::create_dir_all(&dir).expect("create temp family dir");
dir.join("runs.jsonl") dir.join("runs.jsonl")
@@ -1169,16 +1175,16 @@ mod tests {
assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family
} }
fn member(total_pips: f64, trade_rs: Vec<f64>) -> SweepPoint { fn member(total_pips: f64, net_trade_rs: Vec<f64>) -> SweepPoint {
// Every RMetrics field is derived from the R slice by `r_metrics_from_rs` // Every RMetrics field is derived from the R slice by `r_metrics_from_rs`
// (the same arithmetic production members carry), then the per-trade // (the same arithmetic production members carry), then the per-trade
// `trade_rs` conduit is restored: `r_metrics_from_rs` empties it // `net_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: Vec::new()`), whereas a production member is built by
// `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm // `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm
// resamples exactly that conduit, so a fixture without it cannot reach // resamples exactly that conduit, so a fixture without it cannot reach
// the R-arm path under test. // the R-arm path under test.
let mut r = aura_engine::r_metrics_from_rs(&trade_rs); let mut r = aura_engine::r_metrics_from_rs(&net_trade_rs);
r.trade_rs = trade_rs; r.net_trade_rs = net_trade_rs;
SweepPoint { SweepPoint {
params: vec![], params: vec![],
report: RunReport { report: RunReport {
@@ -1214,7 +1220,7 @@ mod tests {
#[test] #[test]
fn optimize_deflated_winner_is_byte_identical_to_optimize() { 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"] { for metric in ["total_pips", "sqn_normalized", "expectancy_r"] {
let plain = optimize(&fam, metric).unwrap(); let plain = optimize(&fam, metric).unwrap();
let (defl, _) = optimize_deflated(&fam, metric, 200, 3, 7).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"); 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 /// 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 /// 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 /// no member contributing a centred series, every best-of-K draw is
/// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor /// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor
/// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`). /// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`).
#[test] #[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(); let mut fam = fixture_family_with_r();
for p in &mut fam.points { for p in &mut fam.points {
if let Some(r) = p.report.metrics.r.as_mut() { 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; 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), window_ms: (1_136_073_600_000, 1_154_390_400_000),
regime: None, regime: None,
regime_ordinal: 0, regime_ordinal: 0,
fault: None,
coverage: None,
stages: vec![ stages: vec![
StageRealization { StageRealization {
block: "std::sweep".to_string(), block: "std::sweep".to_string(),
@@ -1984,6 +1992,7 @@ mod tests {
}, },
}), }),
bootstrap: None, bootstrap: None,
window_faults: vec![],
}, },
StageRealization { StageRealization {
block: "std::gate".to_string(), block: "std::gate".to_string(),
@@ -1991,6 +2000,7 @@ mod tests {
survivor_ordinals: Some(vec![0, 3, 4, 7]), survivor_ordinals: Some(vec![0, 3, 4, 7]),
selection: None, selection: None,
bootstrap: None, bootstrap: None,
window_faults: vec![],
}, },
], ],
}], }],
@@ -2024,6 +2034,8 @@ mod tests {
window_ms: (0, 1000), window_ms: (0, 1000),
regime: None, regime: None,
regime_ordinal: 0, regime_ordinal: 0,
fault: None,
coverage: None,
stages: vec![StageRealization { stages: vec![StageRealization {
block: "std::monte_carlo".to_string(), block: "std::monte_carlo".to_string(),
family_id: None, family_id: None,
@@ -2033,6 +2045,7 @@ mod tests {
(0, per_a), (0, per_a),
(2, per_b), (2, per_b),
])), ])),
window_faults: vec![],
}], }],
}, },
CellRealization { CellRealization {
@@ -2041,12 +2054,15 @@ mod tests {
window_ms: (0, 1000), window_ms: (0, 1000),
regime: None, regime: None,
regime_ordinal: 0, regime_ordinal: 0,
fault: None,
coverage: None,
stages: vec![StageRealization { stages: vec![StageRealization {
block: "std::monte_carlo".to_string(), block: "std::monte_carlo".to_string(),
family_id: None, family_id: None,
survivor_ordinals: None, survivor_ordinals: None,
selection: None, selection: None,
bootstrap: Some(StageBootstrap::PooledOos(pooled)), 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 /// Property (#210 T3, C14/C23 widening convention): a stored
/// `campaign_runs.jsonl` line whose `generalizations` entries predate the /// `campaign_runs.jsonl` line whose `generalizations` entries predate the
/// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/ /// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/
+53
View File
@@ -98,6 +98,48 @@ pub struct CampaignRunRecord {
pub trace_name: Option<String>, pub trace_name: Option<String>,
} }
/// 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<String>,
}
/// One realized (strategy, instrument, window) cell: the pipeline prefix that /// One realized (strategy, instrument, window) cell: the pipeline prefix that
/// actually ran (`stages` stops after an empty gate). /// actually ran (`stages` stops after an empty gate).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -125,6 +167,13 @@ pub struct CellRealization {
/// same pre-#219 byte-identical round-trip reason. /// same pre-#219 byte-identical round-trip reason.
#[serde(default, skip_serializing_if = "is_zero_ordinal")] #[serde(default, skip_serializing_if = "is_zero_ordinal")]
pub regime_ordinal: usize, 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<CellFault>,
/// Set when the archive only partially covers the requested window (#272).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage: Option<CellCoverage>,
} }
/// `CellRealization::regime_ordinal`'s serialize-skip predicate: the default /// `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 widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap: Option<StageBootstrap>, pub bootstrap: Option<StageBootstrap>,
/// 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<WindowFault>,
} }
/// The recorded winner of a selection-bearing stage: its ordinal in the stage's /// The recorded winner of a selection-bearing stage: its ordinal in the stage's
+4 -2
View File
@@ -288,8 +288,10 @@ mod tests {
use aura_core::{Scalar, ScalarKind, Timestamp}; use aura_core::{Scalar, ScalarKind, Timestamp};
fn temp_traces_root(name: &str) -> PathBuf { fn temp_traces_root(name: &str) -> PathBuf {
let dir = // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
std::env::temp_dir().join(format!("aura-tracestore-{}-{}", std::process::id(), name)); // 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); let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp traces root"); fs::create_dir_all(&dir).expect("create temp traces root");
dir dir
@@ -18,8 +18,8 @@ use aura_registry::{
}; };
fn temp_dir(name: &str) -> PathBuf { fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir() let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
.join(format!("aura-registry-campaign-e2e-{}-{}", std::process::id(), name)); .join(format!("aura-registry-campaign-e2e-{name}"));
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir"); fs::create_dir_all(&dir).expect("create temp dir");
dir.join("runs.jsonl") dir.join("runs.jsonl")
@@ -55,6 +55,8 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord {
window_ms: (0, 1000), window_ms: (0, 1000),
regime: None, regime: None,
regime_ordinal: 0, regime_ordinal: 0,
fault: None,
coverage: None,
stages: vec![StageRealization { stages: vec![StageRealization {
block: "std::sweep".to_string(), block: "std::sweep".to_string(),
family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")), family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")),
@@ -77,6 +79,7 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord {
}, },
}), }),
bootstrap: None, bootstrap: None,
window_faults: vec![],
}], }],
}], }],
generalizations: vec![], generalizations: vec![],
+341 -35
View File
@@ -71,6 +71,11 @@ pub enum StageBlock {
MonteCarlo { resamples: u32, block_len: u32 }, MonteCarlo { resamples: u32, block_len: u32 },
#[serde(rename = "std::generalize")] #[serde(rename = "std::generalize")]
Generalize { metric: String }, 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 { 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", needs >= 2 instruments and an R-expectancy metric to execute",
slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }], 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<StageBlock, String> {
"std::generalize" => Ok(StageBlock::Generalize { "std::generalize" => Ok(StageBlock::Generalize {
metric: require_str(m, "metric", &id)?, metric: require_str(m, "metric", &id)?,
}), }),
"std::grid" => Ok(StageBlock::Grid),
_ => unreachable!("schema membership checked above"), _ => unreachable!("schema membership checked above"),
} }
} }
@@ -501,6 +515,7 @@ pub struct Axis {
#[serde(deny_unknown_fields, rename_all = "snake_case")] #[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime { pub enum RiskRegime {
Vol { length: i64, k: f64 }, 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 /// 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 /// (`constant_cost.rs` / `vol_slippage_cost.rs` / `carry_cost.rs`), so the doc
/// vocabulary conforms to the node vocabulary. Externally tagged so a future /// vocabulary conforms to the node vocabulary. Externally tagged so a future
/// component is additive — no content-id churn on stored components. /// 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")] #[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum CostSpec { pub enum CostSpec {
Constant { cost_per_trade: f64 }, Constant { cost_per_trade: CostValue },
VolSlippage { slip_vol_mult: f64 }, VolSlippage { slip_vol_mult: CostValue },
Carry { carry_per_cycle: f64 }, 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<String, f64>),
}
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<f64> {
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<f64> {
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
CostValue::Scalar(v) => v.serialize(s),
CostValue::PerInstrument(m) => m.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for CostValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Scalar(f64),
PerInstrument(std::collections::BTreeMap<String, f64>),
}
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 /// Bare wire value of one Scalar (strips the tagged form via Scalar's own
@@ -724,6 +804,7 @@ pub enum DocFault {
BadWindow { index: usize }, BadWindow { index: usize },
BadRegime { index: usize }, BadRegime { index: usize },
BadCost { index: usize }, BadCost { index: usize },
CostInstrumentKeys { index: usize, missing: Vec<String>, extra: Vec<String> },
NoStrategy, NoStrategy,
EmptyAxes { strategy: usize }, EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String }, EmptyAxis { strategy: usize, axis: String },
@@ -791,6 +872,7 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
} }
} }
StageBlock::MonteCarlo { .. } => {} StageBlock::MonteCarlo { .. } => {}
StageBlock::Grid => {}
} }
if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) { if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) {
terminal_seen = true; terminal_seen = true;
@@ -826,12 +908,21 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
} }
} }
for (i, r) in doc.risk.iter().enumerate() { for (i, r) in doc.risk.iter().enumerate() {
let RiskRegime::Vol { length, k } = r; match r {
// length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()` catches RiskRegime::Vol { length, k } => {
// a non-positive or NaN multiplier (clippy-clean vs `!(k > 0.0)`; +inf // length < 1 catches 0 and negatives; `k <= 0.0 || k.is_nan()`
// is unreachable via JSON, so it need not be excluded). // catches a non-positive or NaN multiplier (clippy-clean vs
if *length < 1 || *k <= 0.0 || k.is_nan() { // `!(k > 0.0)`; +inf is unreachable via JSON, so it need not
faults.push(DocFault::BadRegime { index: i }); // 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() { for (i, c) in doc.cost.iter().enumerate() {
@@ -839,14 +930,35 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
// knob (`>= 0`); the doc tier refuses the same bounds earlier, // knob (`>= 0`); the doc tier refuses the same bounds earlier,
// path-addressed (the BadRegime precedent). NaN/inf are unreachable // path-addressed (the BadRegime precedent). NaN/inf are unreachable
// via JSON but refused defensively. // via JSON but refused defensively.
let v = match c { let value = match c {
CostSpec::Constant { cost_per_trade } => *cost_per_trade, CostSpec::Constant { cost_per_trade } => cost_per_trade,
CostSpec::VolSlippage { slip_vol_mult } => *slip_vol_mult, CostSpec::VolSlippage { slip_vol_mult } => slip_vol_mult,
CostSpec::Carry { carry_per_cycle } => *carry_per_cycle, 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 }); 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<String> = doc
.data
.instruments
.iter()
.filter(|inst| !keys.contains(&inst.as_str()))
.cloned()
.collect();
let extra: Vec<String> = 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() { if doc.strategies.is_empty() {
faults.push(DocFault::NoStrategy); 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::EmitKinds => "list of: family_table | selection_report",
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
SlotKind::Regimes => { 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 => { SlotKind::Costs => {
"list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)" "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, pub path: String,
/// Short human hint, e.g. "required, one of: content_id, identity_id". /// Short human hint, e.g. "required, one of: content_id, identity_id".
pub hint: String, 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<String>,
} }
fn open(path: impl Into<String>, hint: impl Into<String>) -> OpenSlot { fn open(path: impl Into<String>, hint: impl Into<String>) -> 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<String>, hint: impl Into<String>, notes: Vec<String>) -> OpenSlot {
OpenSlot { path: path.into(), hint: hint.into(), notes }
} }
/// List the open slots of a (possibly partial) process document. Only the /// List the open slots of a (possibly partial) process document. Only the
@@ -1064,18 +1188,29 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
// how it went undiscovered (#216). Absent, empty, or non-array = open. // 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()); let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty());
if !risk_bound { if !risk_bound {
slots.push(open( slots.push(open_with_notes(
"risk", "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 // The optional cost slot, mirroring the risk axis (#216's discoverability
// lesson applied to #234): absent, empty, or non-array = open. // 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()); let cost_bound = v.get("cost").and_then(|c| c.as_array()).is_some_and(|a| !a.is_empty());
if !cost_bound { if !cost_bound {
slots.push(open( slots.push(open_with_notes(
"cost", "cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", "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()) { match v.get("strategies").and_then(|s| s.as_array()) {
@@ -1395,6 +1530,18 @@ mod tests {
assert_eq!(back, r); 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] #[test]
fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() {
let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); 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:?}"); 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] #[test]
fn validate_campaign_accepts_a_valid_risk_section() { fn validate_campaign_accepts_a_valid_risk_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); 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. // vocabulary conforms to the node vocabulary, not the other way round.
for (spec, wire) in [ 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}}"#, 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}}"#, 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}}"#, r#"{"carry":{"carry_per_cycle":0.1}}"#,
), ),
] { ] {
@@ -1472,6 +1634,86 @@ mod tests {
assert!(serde_json::from_str::<CostSpec>(r#"{"constant":{"per_close_r":2.0}}"#).is_err()); assert!(serde_json::from_str::<CostSpec>(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::<CostSpec>(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] #[test]
fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() { fn campaign_absent_and_empty_cost_omit_from_canonical_bytes() {
// The risk precedent's sibling: cost-less documents keep their content ids. // 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() { fn campaign_with_cost_serializes_and_round_trips_the_components() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![ doc.cost = vec![
CostSpec::Constant { cost_per_trade: 2.0 }, CostSpec::Constant { cost_per_trade: CostValue::Scalar(2.0) },
CostSpec::VolSlippage { slip_vol_mult: 0.5 }, CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(0.5) },
]; ];
let out = campaign_to_json(&doc); let out = campaign_to_json(&doc);
assert!( assert!(
@@ -1508,9 +1750,9 @@ mod tests {
fn validate_campaign_flags_each_bad_cost_component() { fn validate_campaign_flags_each_bad_cost_component() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap();
doc.cost = vec![ doc.cost = vec![
CostSpec::Constant { cost_per_trade: 0.0 }, // 0: valid (zero cost is a legal stress point) CostSpec::Constant { cost_per_trade: CostValue::Scalar(0.0) }, // 0: valid (zero cost is a legal stress point)
CostSpec::VolSlippage { slip_vol_mult: -0.5 }, // 1: negative CostSpec::VolSlippage { slip_vol_mult: CostValue::Scalar(-0.5) }, // 1: negative
CostSpec::Carry { carry_per_cycle: f64::NAN }, // 2: non-finite (unreachable via JSON; defensive) CostSpec::Carry { carry_per_cycle: CostValue::Scalar(f64::NAN) }, // 2: non-finite (unreachable via JSON; defensive)
]; ];
let faults = validate_campaign(&doc); let faults = validate_campaign(&doc);
assert!(faults.contains(&DocFault::BadCost { index: 1 }), "{faults:?}"); 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:?}"); 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] #[test]
fn validate_campaign_accepts_a_valid_cost_section() { fn validate_campaign_accepts_a_valid_cost_section() {
let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); 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)); 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::<StageBlock>(
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] #[test]
fn vocabularies_enumerate_every_block_with_typed_slots() { fn vocabularies_enumerate_every_block_with_typed_slots() {
let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect(); let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect();
assert_eq!( assert_eq!(
ids, 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()) { for b in process_vocabulary().iter().chain(campaign_vocabulary()) {
assert!(!b.doc.is_empty(), "block {} has no doc line", b.id); 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 { for s in b.slots {
assert!(!slot_kind_label(s.kind).is_empty()); assert!(!slot_kind_label(s.kind).is_empty());
} }
@@ -1932,13 +2221,24 @@ mod tests {
assert_eq!( assert_eq!(
open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(), open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(),
vec![ vec![
open( open_with_notes(
"risk", "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", "cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", "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_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field");
assert_eq!( assert_eq!(
open_slots_campaign(&with_risk).unwrap(), open_slots_campaign(&with_risk).unwrap(),
vec![open( vec![open_with_notes(
"cost", "cost",
"optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)", "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( let with_both = CAMPAIGN_FIXTURE.replacen(
+3 -1
View File
@@ -54,6 +54,7 @@ mod stop_rule;
mod sub; mod sub;
mod vocabulary; mod vocabulary;
mod vol_slippage_cost; mod vol_slippage_cost;
mod vol_tf_stop;
pub use abs::Abs; pub use abs::Abs;
pub use add::Add; pub use add::Add;
pub use and::And; pub use and::And;
@@ -88,7 +89,7 @@ pub use rolling_max::RollingMax;
pub use rolling_min::RollingMin; pub use rolling_min::RollingMin;
pub use scale::Scale; pub use scale::Scale;
pub use series_reducer::SeriesReducer; pub use series_reducer::SeriesReducer;
pub use session::Session; pub use session::{Session, SessionFrankfurt};
pub use sim_broker::SimBroker; pub use sim_broker::SimBroker;
pub use sizer::Sizer; pub use sizer::Sizer;
pub use sma::Sma; pub use sma::Sma;
@@ -97,3 +98,4 @@ pub use stop_rule::FixedStop;
pub use sub::Sub; pub use sub::Sub;
pub use vocabulary::{std_vocabulary, std_vocabulary_types}; pub use vocabulary::{std_vocabulary, std_vocabulary_types};
pub use vol_slippage_cost::VolSlippageCost; pub use vol_slippage_cost::VolSlippageCost;
pub use vol_tf_stop::VolTfStop;
+30 -1
View File
@@ -32,7 +32,9 @@
//! //!
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`. //! **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}; use chrono::{TimeZone, Timelike};
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local /// 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 { impl Node for Session {
fn lookbacks(&self) -> Vec<usize> { fn lookbacks(&self) -> Vec<usize> {
vec![1] vec![1]
+10 -7
View File
@@ -2,13 +2,16 @@
//! direction-agnostic). The stop DEFINES the risk unit R (1R = the loss if stopped); //! 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. //! 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 //! `FixedStop` and `VolTfStop` (`vol_tf_stop.rs`) are the fused stop-rule
//! input (a source-less `Const` has no firing trigger in the push model, so the //! PRIMITIVES: `FixedStop` is a constant distance gated on its price input (a
//! triggered-constant shape is the honest primitive). The volatility stop is NOT a //! source-less `Const` has no firing trigger in the push model, so the
//! primitive — it is a COMPOSITION of primitives, `k · Sqrt(Ema(Mul(Δ,Δ), length))` //! triggered-constant shape is the honest primitive); `VolTfStop` folds
//! with `Δ = Sub(price, Delay(price,1))` (a rolling EWMA standard deviation), built with //! `k · √EMA(Δ², length)` over completed timescale-matched buckets. The
//! `GraphBuilder` — see `crates/aura-engine/tests/vol_stop_composite.rs`. True-range ATR //! per-cycle volatility stop remains NOT a primitive — it is a COMPOSITION of
//! (a richer stop) is deferred — it needs OHLC. //! 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::{ use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind, ScalarKind,
+4 -3
View File
@@ -20,7 +20,7 @@
use crate::{ use crate::{
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt, Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt,
Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, 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; use aura_core::PrimitiveBuilder;
@@ -81,6 +81,7 @@ std_vocabulary_roster! {
"RollingMax" => RollingMax, "RollingMax" => RollingMax,
"RollingMin" => RollingMin, "RollingMin" => RollingMin,
"Scale" => Scale, "Scale" => Scale,
"SessionFrankfurt" => SessionFrankfurt,
"Sizer" => Sizer, "Sizer" => Sizer,
"SMA" => Sma, "SMA" => Sma,
"Sqrt" => Sqrt, "Sqrt" => Sqrt,
@@ -129,7 +130,7 @@ mod tests {
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope")); assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 28 entries // count guard: pins the roster at exactly 29 entries
assert_eq!(std_vocabulary_types().len(), 28); assert_eq!(std_vocabulary_types().len(), 29);
} }
} }
+217
View File
@@ -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<i64>,
close_in_bucket: f64,
prev_close: Option<f64>,
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<usize> {
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<Option<f64>> {
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·(104102)");
assert_eq!(out[6], Some(10.0), "2·(109104)");
}
/// 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}");
}
}
+8 -1
View File
@@ -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::walk_forward rolling in-sample optimize + out-of-sample test
std::monte_carlo R-bootstrap over realised R (terminal annotator): ... std::monte_carlo R-bootstrap over realised R (terminal annotator): ...
std::generalize cross-instrument worst-case floor (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 $ 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) 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) 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: kind (required, must be "campaign")
open slot: name (required, string) open slot: name (required, string)
open slot: data (required section: instruments + windows) 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: 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: strategies (required, non-empty list of { ref, axes })
open slot: process.ref (required, content id of a process document) 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 <NAME>` resolves it against the uniquely names one recorded run (`aura chart <NAME>` resolves it against the
stored campaign documents; a name reused across runs refuses rather than stored campaign documents; a name reused across runs refuses rather than
guessing which one you mean). 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).
+48 -8
View File
@@ -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).** **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 The `StopRule{Fixed,Vol}` structural axis is realized at the campaign-document
level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable level as `CampaignDoc.risk: [RiskRegime]` (a serializable, content-addressable
mirror of the runtime enum — `aura-research`, sole variant `Vol{length,k}`, the mirror of the runtime enum — `aura-research`, variants `Vol{length,k}` and
fixed-stop rule additive when needed). It is a **kept-separate** matrix axis, a `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 peer of instruments and windows: the executor keys the nominee map by
`(strategy, window, regime)`, so generalize aggregates across instruments `(strategy, window, regime)`, so generalize aggregates across instruments
*within* a regime, never across regimes. Regimes are therefore **compared** at *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. (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, `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 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 pooled per-window OOS `net_trade_rs` in roll order (`PooledOos`; #259 materialized this
`r_bootstrap` per surviving member's fresh in-memory series (`PerSurvivor`, ordinals into conduit as the cost-netted per-trade series `r cost_in_r`, equal to the gross series
the population family; a zero-trade member records the engine's defined all-zero bit-for-bit when no cost model is bound); after sweep/gates, one `r_bootstrap` per
degenerate) — seeded from the campaign doc's `seed` (C1; `trade_rs` is `#[serde(skip)]`, surviving member's fresh in-memory series (`PerSurvivor`, ordinals into the population
so annotators run in-executor or not at all). `std::generalize` executes at **campaign 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 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 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 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 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 spanning instruments) is untested/semantically undefined — only per-cell read-back is
pinned. 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<CellFault>`, 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)` 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 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 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 (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 all confirmed); residual findings are discoverability/ergonomics follow-ups (#216 risk-axis
discoverability, #217 verb knob asymmetry, #218 no-project store litter). 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 - **Inferential honesty of the World — family-selection without false-discovery
control (tracked: milestone "Inferential validation (defend against false control (tracked: milestone "Inferential validation (defend against false
discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's
+7 -3
View File
@@ -73,7 +73,7 @@ A feed-forward, order-independent research axis for scaling risk by bias strengt
### cost model ### cost model
**Avoid:** realistic broker **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/|entrystop|`, 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 ### cross-instrument generalization
**Avoid:** cross-symbol pooling, pooled 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 ### risk regime
**Avoid:** risk section **Avoid:** risk section
One entry of a campaign document's structural risk axis (`risk`): a 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 runs every cell under, so cells differ by execution discipline, never by
signal. Absent or empty = one implicit default regime; the regime's stop 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 ### run
**Avoid:** — **Avoid:** —