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:
@@ -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
|
||||
flow = StageFlow::Members(
|
||||
family
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
||||
.collect();
|
||||
.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,8 +460,18 @@ 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
|
||||
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
|
||||
@@ -436,7 +491,8 @@ fn run_cell(
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
stages.push(StageRealization {
|
||||
block: "std::monte_carlo".to_string(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user