diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index 6eb9ec0..14bdf9a 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -232,6 +232,31 @@ pub fn execute( Ok(CampaignOutcome { record, run, cells: cells_out }) } +/// The inter-stage population crossing `run_cell`'s seam: parameter points +/// only (from `std::grid`) or executed members (from `std::sweep`). Preflight +/// guarantees report-consuming stages (`std::gate`, `std::monte_carlo`'s +/// per-survivor arm) never see `Points`; the defensive faults below keep that +/// invariant loud if the shape rules ever drift. +enum StageFlow { + /// (ordinal into the nearest preceding family_id-bearing stage's family, + /// param point, member report) — the shape preflight established before + /// std::sweep populated it. + Members(Vec<(usize, Vec, RunReport)>), + Points(Vec>), +} + +impl StageFlow { + /// Every flow can cross the seam as bare points — the walk_forward need. + fn points(&self) -> Vec> { + match self { + StageFlow::Points(points) => points.clone(), + StageFlow::Members(members) => { + members.iter().map(|(_, point, _)| point.clone()).collect() + } + } + } +} + /// Run one cell's pipeline: stages in doc order, a realized prefix that stops /// at an empty gate (decision 8 — the cell truncates, the campaign continues). fn run_cell( @@ -247,15 +272,26 @@ fn run_cell( let mut families: Vec = Vec::new(); let mut selections: Vec = Vec::new(); let mut stages: Vec = Vec::new(); - // The surviving population: (ordinal into the nearest preceding - // family_id-bearing stage's family, param point, member report). - let mut survivors: Vec<(usize, Vec, RunReport)> = Vec::new(); + // The surviving population crossing the stage seam (see `StageFlow`). + let mut flow = StageFlow::Members(Vec::new()); // The cell's nominated generalize candidate: last wf window's OOS report // when a walk_forward ran, else the sweep winner; None when truncated. let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None; for (stage_ordinal, stage) in process.pipeline.iter().enumerate() { match stage { + StageBlock::Grid => { + // Enumerate-only (#256 fork B): the grid's parameter points + // cross the seam; nothing executes, nothing persists. + flow = StageFlow::Points(grid.points.clone()); + stages.push(StageRealization { + block: "std::grid".to_string(), + family_id: None, + survivor_ordinals: None, + selection: None, + bootstrap: None, + }); + } StageBlock::Sweep { selection } => { // Deterministic, self-describing family name (the "-{run}" // suffix is appended by the registry). @@ -318,12 +354,14 @@ fn run_cell( } // 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 +376,21 @@ fn run_cell( // None`) fails the member — conservative and deterministic // (the metric NAME was already preflighted against // `PER_MEMBER_METRICS`). - survivors.retain(|(_, _, report)| { + let StageFlow::Members(members) = &mut flow else { + return Err(ExecFault::PipelineShape { + detail: format!( + "stage {stage_ordinal}: std::gate needs executed \ + members (preflight admits no std::grid predecessor)" + ), + }); + }; + members.retain(|(_, _, report)| { all.iter().all(|p| { member_metric(report, &p.metric) .is_some_and(|value| predicate_holds(&p.cmp, value, p.value)) }) }); - let ordinals: Vec = survivors.iter().map(|(i, _, _)| *i).collect(); + let ordinals: Vec = members.iter().map(|(i, _, _)| *i).collect(); let empty = ordinals.is_empty(); stages.push(StageRealization { block: "std::gate".to_string(), @@ -368,8 +414,7 @@ fn run_cell( // The seam crossing is plain points: the walk-forward stage // re-sweeps the surviving param points and never needs the // gate bookkeeping's ordinals or reports. - let survivor_points: Vec> = - survivors.iter().map(|(_, point, _)| point.clone()).collect(); + let survivor_points: Vec> = flow.points(); let fam = run_walk_forward_stage( seed, cell, @@ -415,28 +460,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.net_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(), diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 43ca48c..876ee8d 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -196,6 +196,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 +206,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 +218,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 +267,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()) { @@ -731,6 +751,67 @@ mod tests { assert!(preflight(&process, &c).is_ok()); } + /// #256 fork B: the enumerate-only leading stage is accepted exactly in + /// the dissolved walkforward/mc shapes — first, immediately before + /// std::walk_forward. + #[test] + fn preflight_accepts_a_grid_first_stage_followed_by_walk_forward() { + let c = campaign(); + let wf_shape = + process_of(vec![StageBlock::Grid, wf_stage("sqn_normalized", SelectRule::Argmax)]); + assert!(preflight(&wf_shape, &c).is_ok()); + let mc_shape = process_of(vec![ + StageBlock::Grid, + wf_stage("sqn_normalized", SelectRule::Argmax), + mc_stage(1000, 5), + ]); + assert!(preflight(&mc_shape, &c).is_ok()); + } + + /// #256 fork B: every other placement is refused — grid terminal, grid + /// before a report-consuming stage (gate / monte_carlo / generalize), + /// grid anywhere but first. Every refusal is a PipelineShape prose fault. + #[test] + fn preflight_refuses_grid_placement_violations() { + let c = campaign(); + let c2 = campaign_two_instruments(); + let adjacency = "immediately followed by std::walk_forward"; + let cases: Vec<(Vec, &str, &CampaignDoc)> = vec![ + (vec![StageBlock::Grid], adjacency, &c), + ( + vec![ + StageBlock::Grid, + gate_stage("net_expectancy_r"), + wf_stage("sqn_normalized", SelectRule::Argmax), + ], + adjacency, + &c, + ), + (vec![StageBlock::Grid, mc_stage(1000, 5)], adjacency, &c), + ( + vec![StageBlock::Grid, generalize_stage("net_expectancy_r")], + adjacency, + &c2, + ), + ( + vec![ + sweep_stage("sqn_normalized", SelectRule::Argmax, false), + StageBlock::Grid, + ], + "only the first stage may be std::grid", + &c, + ), + ]; + for (pipeline, needle, camp) in cases { + let err = + preflight(&process_of(pipeline), camp).expect_err("placement violation"); + let ExecFault::PipelineShape { detail } = &err else { + panic!("expected PipelineShape, got {err:?}"); + }; + assert!(detail.contains(needle), "detail {detail:?} misses {needle:?}"); + } + } + /// A selection-free sweep followed by any other stage is refused: a /// downstream stage would have no selection/nominee to consume. #[test] diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index 5b5e3d0..aa42501 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -496,6 +496,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() { assert_eq!(runs[0], out.record); } +/// Property (#256 fork B): a `std::grid` first stage crosses the `run_cell` +/// seam as bare parameter points (`StageFlow::Points`) rather than executed +/// members — it persists no family of its own — and the immediately-following +/// `std::walk_forward` stage still sweeps exactly the full axis grid, picking +/// the same per-window winner a selection-carrying `std::sweep` feeding the +/// same wf stage would (`execute_mc_after_wf_pools_the_oos_series`'s +/// `sweep_stage()` + `wf_stage()` shape, minus the intermediate sweep family). +/// A regression that let `StageFlow::Points` drop or mis-order a point before +/// crossing the seam would flip the wf winner or the per-window report count +/// here even though the preflight placement tests (lib.rs) stay green. +#[test] +fn execute_grid_first_stage_feeds_walk_forward_with_all_grid_points() { + let reg = temp_registry("grid_then_wf"); + let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000] + let proc_doc = process(vec![StageBlock::Grid, wf_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("grid-then-wf campaign executes"); + + // std::grid persists no family of its own — only std::walk_forward's does + assert_eq!(out.cells[0].families.len(), 1); + let wf = &out.cells[0].families[0]; + assert_eq!(wf.block, "std::walk_forward"); + + // stage realization: std::grid first (no family/selection), then wf + let cell = &out.record.cells[0]; + assert_eq!(cell.stages.len(), 2); + assert_eq!(cell.stages[0].block, "std::grid"); + assert_eq!(cell.stages[0].family_id, None); + assert_eq!(cell.stages[0].survivor_ordinals, None); + assert_eq!(cell.stages[0].selection, None); + assert_eq!(cell.stages[1].block, "std::walk_forward"); + + // the roller yields 2 windows over the FULL 2x2 grid (nothing filtered + // the population before wf); both pick the planted argmax (3,9) + assert_eq!(wf.reports.len(), 2); + let winner_params = + vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))]; + for report in &wf.reports { + assert_eq!(report.manifest.params, winner_params); + } + + // the record round-trips through the store unchanged + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs[0], out.record); +} + #[test] fn execute_mc_after_wf_pools_the_oos_series() { let reg = temp_registry("mc_pooled"); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index b7537b9..9cbf3f5 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -414,12 +414,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 +433,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, @@ -565,8 +560,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 +583,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, @@ -933,17 +923,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, @@ -1082,17 +1066,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, diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 010047d..85f1023 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3293,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. @@ -3363,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(), @@ -5312,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] @@ -5391,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(), @@ -5416,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() { diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 16ff497..cb4c9dd 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -1498,6 +1498,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 diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 78d3e57..42ad605 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -71,6 +71,11 @@ pub enum StageBlock { MonteCarlo { resamples: u32, block_len: u32 }, #[serde(rename = "std::generalize")] Generalize { metric: String }, + /// Enumerate-only leading stage (#256 fork B): yields the axis grid's + /// parameter points to the next stage; executes and persists nothing. + /// Fieldless — the axes live in the campaign document. + #[serde(rename = "std::grid")] + Grid, } fn is_false(b: &bool) -> bool { @@ -224,6 +229,14 @@ pub const PROCESS_BLOCKS: &[BlockSchema] = &[ needs >= 2 instruments and an R-expectancy metric to execute", slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }], }, + BlockSchema { + id: "std::grid", + doc: "enumerate the axis grid as parameter points for the next stage \ + (enumerate-only leading stage): executes and persists nothing; \ + legal only as the first stage, immediately before \ + std::walk_forward", + slots: &[], + }, ]; // --------------------------------------------------------------------------- @@ -374,6 +387,7 @@ fn stage_from_value(v: &serde_json::Value) -> Result { "std::generalize" => Ok(StageBlock::Generalize { metric: require_str(m, "metric", &id)?, }), + "std::grid" => Ok(StageBlock::Grid), _ => unreachable!("schema membership checked above"), } } @@ -858,6 +872,7 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec { } } StageBlock::MonteCarlo { .. } => {} + StageBlock::Grid => {} } if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) { terminal_seen = true; @@ -2105,16 +2120,45 @@ mod tests { ); } + /// #256 fork B: the fieldless enumerate-only stage round-trips through + /// the schema-strict parser, and any slot on it is refused (its slot + /// list is empty, so the generic unknown-slot check covers every key). + #[test] + fn grid_block_round_trips_and_refuses_slots() { + let json = serde_json::to_string(&StageBlock::Grid).expect("serialize"); + assert_eq!(json, r#"{"block":"std::grid"}"#); + let back: StageBlock = serde_json::from_str(&json).expect("parse back"); + assert_eq!(back, StageBlock::Grid); + let err = serde_json::from_str::( + r#"{"block":"std::grid","metric":"sqn"}"#, + ) + .expect_err("a slot on the fieldless std::grid must be refused"); + assert!(err.to_string().contains("unknown slot"), "got: {err}"); + } + #[test] fn vocabularies_enumerate_every_block_with_typed_slots() { let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect(); assert_eq!( ids, - vec!["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] + vec![ + "std::sweep", + "std::gate", + "std::walk_forward", + "std::monte_carlo", + "std::generalize", + "std::grid" + ] ); for b in process_vocabulary().iter().chain(campaign_vocabulary()) { assert!(!b.doc.is_empty(), "block {} has no doc line", b.id); - assert!(!b.slots.is_empty(), "block {} has no slots", b.id); + // std::grid is the one legitimate fieldless block (#256); every + // other block keeps its documented-slots guard. + assert!( + b.id == "std::grid" || !b.slots.is_empty(), + "block {} has no slots", + b.id + ); for s in b.slots { assert!(!slot_kind_label(s.kind).is_empty()); } diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 4aebf04..5f32815 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -350,6 +350,7 @@ std::gate filter survivors by a conjunction of typed metric predicates std::walk_forward rolling in-sample optimize + out-of-sample test std::monte_carlo R-bootstrap over realised R (terminal annotator): ... std::generalize cross-instrument worst-case floor (terminal annotator): ... +std::grid enumerate the axis grid as parameter points for the next stage (enumerate-only leading stage): ... $ aura process introspect --block std::sweep std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only) metric optional, metric name (see metric_vocabulary) diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index a25dc7f..fdd25e8 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -2551,6 +2551,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