Tracker sweep + executor robustness: net-R chain, archive inventory, std::grid, per-cell fault isolation (12 issues) #270

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