feat(research,campaign,cli): std::grid — the enumerate-only leading stage

closes #256

Fork B (owner decision 2026-07-14): the dissolved walkforward/mc
translations' leading sweep executed the full grid over the whole campaign
window and persisted a Sweep family, yet only the enumerated parameter
points ever crossed the stage seam (the wf stage re-sweeps them per IS
window itself). The leading stage is now the fieldless vocabulary block
std::grid: it enumerates, executes nothing, persists nothing.

- aura-research: StageBlock::Grid ({"block":"std::grid"}), schema-strict
  parse arm (empty slot list: every key but "block" is refused by the
  generic unknown-slot check), PROCESS_BLOCKS entry, intrinsic-tier no-op
  arm; the vocabulary test's non-empty-slots guard carries a pinned
  std::grid-only exception (a nominal slot would misdescribe the
  vocabulary to describe_block consumers).
- aura-campaign: the inter-stage seam is a typed two-armed StageFlow
  (points-only vs executed members); gate / mc-per-survivor fence the
  points-only arm with defensive PipelineShape faults; preflight admits
  std::grid only as the first stage and only immediately before
  std::walk_forward (every other neighbor consumes executed reports).
- aura-cli: translate_walkforward / translate_mc lead with
  StageBlock::Grid; the two family-shape E2E pins flip to zero Sweep
  families; translate_generalize keeps its executed sweep(argmax) — its
  generalize stage consumes the argmax winner report as the cell nominee.
- docs: dated #256 amendment in the ledger's verb-dissolution narrative;
  the authoring-guide vocabulary transcript gains the std::grid line.

Behaviour preservation: the exact-grade real-data pins
(walkforward_real_e2e_pins_the_exact_current_grade,
mc_r_bootstrap_real_e2e_pins_the_exact_current_grade) pass unmodified —
survivor points reach the wf stage in the same odometer order as before.

Measured (the #256 acceptance measurement; debug build, real GER40 2025,
2x2 grid, `aura walkforward --real`, 3 runs each):
  before (a55e4cf): 6.27 / 6.18 / 6.18 s
  after:            4.52 / 4.49 / 4.45 s   (~ -27% wall clock)

Suite: cargo test --workspace green (0 failed); clippy clean.
Decision log: #256 comments (fork rationale incl. the StageFlow seam and
the slot-guard exception).
This commit is contained in:
2026-07-14 13:07:40 +02:00
parent a55e4cfd10
commit ea4e79d73f
9 changed files with 442 additions and 91 deletions
+91 -35
View File
@@ -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<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 +272,26 @@ 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,
});
}
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<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(),
@@ -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<Vec<Scalar>> =
survivors.iter().map(|(_, point, _)| point.clone()).collect();
let survivor_points: Vec<Vec<Scalar>> = 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(),
+85 -4
View File
@@ -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<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]
+46
View File
@@ -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(), &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");
+16 -38
View File
@@ -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,
+13 -12
View File
@@ -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() {
+131
View File
@@ -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
+46 -2
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"),
}
}
@@ -858,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;
@@ -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::<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());
}
+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)
+13
View File
@@ -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