feat(campaign,registry,engine): record widenings + preflight v2 fault set (0108 tasks 1-2)

Task 1: RBootstrap and Generalization gain serde derives;
lineage.rs gains StageBootstrap (per_survivor | pooled_oos) and
CampaignGeneralization; StageRealization.bootstrap and
CampaignRunRecord.generalizations land serde-default sparse (a
pre-0108 campaign_runs.jsonl line still parses — pinned), threaded
None/empty through every literal site. No behaviour change.

Task 2: ExecFault::UnsupportedStage is gone; the preflight now admits
the v2 shape sweep [gate]* [wf]? [mc]? [generalize]? via a monotone
rank walk (precise per-pair PipelineShape prose) and statically
refuses: single-instrument generalize (GeneralizeNeedsInstruments),
non-R generalize metrics via check_r_metric (GeneralizeNonRMetric —
no fourth roster site), zero mc params (ZeroBootstrapParam). CLI
prose swapped accordingly. The loop's E2E phase added a tier-boundary
pin: process validate accepts [sweep, mc, walk_forward] while campaign
run refuses it data-free.

KNOWN RED (documented in the plan, flips in task 5):
crates/aura-cli/tests/research_docs.rs::campaign_run_v1_boundary_refuses_mc_process
still pins the retired 'not executable in v1' prose. Interim gates:
registry 57/0, campaign 33/0, cli --bin 111/0, workspace build clean.

refs #200
This commit is contained in:
2026-07-04 00:38:25 +02:00
parent 3454459e6d
commit ab778f2361
8 changed files with 607 additions and 60 deletions
+4
View File
@@ -145,6 +145,7 @@ pub fn execute(
run: 0,
seed: campaign.seed,
cells: cells_rec,
generalizations: Vec::new(),
};
let run = registry.append_campaign_run(&record).map_err(ExecFault::Registry)?;
record.run = run;
@@ -204,6 +205,7 @@ fn run_cell(
family_id: Some(family_id.clone()),
survivor_ordinals: None,
selection: Some(StageSelection { winner_ordinal, params, selection }),
bootstrap: None,
});
// The whole family flows on (decision 3: `select` names the
// recorded selection, never a filter).
@@ -240,6 +242,7 @@ fn run_cell(
family_id: None,
survivor_ordinals: Some(ordinals),
selection: None,
bootstrap: None,
});
if empty {
break; // decision 8: truncate this cell, keep running cells
@@ -273,6 +276,7 @@ fn run_cell(
family_id: Some(fam.family_id.clone()),
survivor_ordinals: None,
selection: None,
bootstrap: None,
});
families.push(fam);
}
+260 -50
View File
@@ -18,7 +18,7 @@ use std::collections::BTreeMap;
use aura_core::Scalar;
use aura_engine::RunReport;
use aura_registry::RegistryError;
use aura_registry::{check_r_metric, RegistryError};
use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, SelectRule, StageBlock};
/// One structural cell of the campaign matrix: (strategy, instrument,
@@ -63,9 +63,8 @@ pub enum MemberFault {
/// consumer phrases them (the DocFault/RefFault pattern).
#[derive(Debug)]
pub enum ExecFault {
/// v1 boundary: `std::monte_carlo` / `std::generalize` are not executable.
UnsupportedStage { stage: usize, block: String },
/// The pipeline is not `std::sweep (std::gate)* (std::walk_forward)?`.
/// The pipeline is not `std::sweep (std::gate)* (std::walk_forward)?
/// (std::monte_carlo)? (std::generalize)?`.
PipelineShape { detail: String },
/// A sweep/walk_forward selection metric outside the registry's rankable
/// roster.
@@ -78,6 +77,15 @@ pub enum ExecFault {
PlateauInWalkForward { stage: usize },
/// sweep `deflate: true` with a non-argmax select rule.
DeflatePlateauConflict { stage: usize },
/// generalize needs >= 2 instruments in the campaign (static).
GeneralizeNeedsInstruments { available: usize },
/// generalize's metric must be an R metric (static — the intrinsic tier
/// accepts any vocabulary name; the shipped generalization() would only
/// refuse at runtime).
GeneralizeNonRMetric { metric: String },
/// monte_carlo with resamples == 0 or block_len == 0 (static, instead of
/// the engine's defined all-zero degenerate).
ZeroBootstrapParam { stage: usize, field: &'static str },
/// `WindowRoller` construction refusals at runtime.
Window { stage: usize, detail: String },
Member(MemberFault),
@@ -156,46 +164,73 @@ fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool {
}
/// Statically refuse everything refusable before any member runs (the F7
/// lesson applied forward): the v1 executable pipeline shape is exactly
/// `std::sweep (std::gate)* (std::walk_forward)?`; every sweep/walk_forward
/// selection metric is in [`RANKABLE_METRICS`]; every gate predicate metric
/// is in [`PER_MEMBER_METRICS`]; walk_forward must not select `plateau:*`
/// (a gated survivor subset has no grid lattice); sweep `deflate: true`
/// composes only with `argmax`; walk_forward lengths must fit `i64` (the
/// roller's Timestamp unit). The campaign parameter is the seam for
/// campaign-level static checks (none in v1, hence unused).
pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> {
// v1 boundary first: an mc/generalize stage refuses wherever it sits,
// before any shape complaint about the same stage.
for (i, stage) in process.pipeline.iter().enumerate() {
let block = match stage {
/// lesson applied forward): the v2 executable pipeline shape is exactly
/// `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)?
/// (std::generalize)?` — each suffix stage at most once, order fixed,
/// `std::generalize` strictly last; every sweep/walk_forward selection
/// metric is in [`RANKABLE_METRICS`]; every gate predicate metric is in
/// [`PER_MEMBER_METRICS`]; walk_forward must not select `plateau:*` (a gated
/// survivor subset has no grid lattice); sweep `deflate: true` composes only
/// with `argmax`; walk_forward lengths must fit `i64` (the roller's
/// Timestamp unit); monte_carlo `resamples` and `block_len` must be > 0
/// (static, instead of the engine's defined all-zero degenerate); generalize
/// needs >= 2 campaign instruments and an R selection metric (the registry's
/// `check_r_metric`). The campaign parameter carries the campaign-level
/// static checks (generalize's instrument arity).
pub(crate) fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), ExecFault> {
// Position of a stage in the fixed v2 shape; gates (rank 1) may repeat,
// every other rank appears at most once and ranks never decrease.
fn shape_rank(stage: &StageBlock) -> usize {
match stage {
StageBlock::Sweep { .. } => 0,
StageBlock::Gate { .. } => 1,
StageBlock::WalkForward { .. } => 2,
StageBlock::MonteCarlo { .. } => 3,
StageBlock::Generalize { .. } => 4,
}
}
fn block_id(stage: &StageBlock) -> &'static str {
match stage {
StageBlock::Sweep { .. } => "std::sweep",
StageBlock::Gate { .. } => "std::gate",
StageBlock::WalkForward { .. } => "std::walk_forward",
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
StageBlock::Generalize { .. } => "std::generalize",
_ => continue,
};
return Err(ExecFault::UnsupportedStage { stage: i, block: block.to_string() });
}
}
// shape: exactly `std::sweep (std::gate)* (std::walk_forward)?`.
// shape: `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)?
// (std::generalize)?` — a monotone rank walk over adjacent pairs captures
// 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 { .. })) {
return Err(ExecFault::PipelineShape {
detail: "the first stage must be std::sweep".to_string(),
});
}
let last = process.pipeline.len() - 1;
let mut prev = &process.pipeline[0];
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
match stage {
StageBlock::Sweep { .. } => {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: only the first stage may be std::sweep"),
});
}
StageBlock::WalkForward { .. } if i != last => {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: std::walk_forward must be the final stage"),
});
}
_ => {}
if matches!(stage, StageBlock::Sweep { .. }) {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: only the first stage may be std::sweep"),
});
}
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
if rank < prev_rank {
return Err(ExecFault::PipelineShape {
detail: format!(
"stage {i}: {} cannot follow {}",
block_id(stage),
block_id(prev)
),
});
}
if rank == prev_rank && rank > 1 {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: {} may appear at most once", block_id(stage)),
});
}
prev = stage;
}
// per-stage slot rules (shape already established above).
for (i, stage) in process.pipeline.iter().enumerate() {
@@ -244,8 +279,22 @@ pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result
}
}
}
StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => {
unreachable!("refused by the v1 scan above")
StageBlock::MonteCarlo { resamples, block_len } => {
if *resamples == 0 {
return Err(ExecFault::ZeroBootstrapParam { stage: i, field: "resamples" });
}
if *block_len == 0 {
return Err(ExecFault::ZeroBootstrapParam { stage: i, field: "block_len" });
}
}
StageBlock::Generalize { metric } => {
let available = campaign.data.instruments.len();
if available < 2 {
return Err(ExecFault::GeneralizeNeedsInstruments { available });
}
if check_r_metric(metric).is_err() {
return Err(ExecFault::GeneralizeNonRMetric { metric: metric.clone() });
}
}
}
}
@@ -438,16 +487,36 @@ mod tests {
}
}
fn mc_stage(resamples: u32, block_len: u32) -> StageBlock {
StageBlock::MonteCarlo { resamples, block_len }
}
fn generalize_stage(metric: &str) -> StageBlock {
StageBlock::Generalize { metric: metric.to_string() }
}
/// [`campaign`] with a second instrument — generalize's static arity
/// guard needs >= 2 to pass.
fn campaign_two_instruments() -> CampaignDoc {
let mut c = campaign();
c.data.instruments.push("GER40".to_string());
c
}
#[test]
fn preflight_accepts_the_v1_shape() {
fn preflight_accepts_the_v2_shapes() {
let c = campaign();
// the full v1 shape: sweep (gate)* (walk_forward)?
let c2 = campaign_two_instruments();
// the full v2 shape: sweep (gate)* (walk_forward)? (monte_carlo)?
// (generalize)?
let full = process_of(vec![
sweep_stage("sqn_normalized", SelectRule::Argmax, true),
gate_stage("net_expectancy_r"),
wf_stage("sqn_normalized", SelectRule::Argmax),
mc_stage(1000, 5),
generalize_stage("net_expectancy_r"),
]);
assert!(preflight(&full, &c).is_ok());
assert!(preflight(&full, &c2).is_ok());
// degenerate accepted shapes: bare sweep (plateau select without
// deflate is legal on a sweep); sweep + gates without walk_forward.
let bare = process_of(vec![sweep_stage("total_pips", SelectRule::PlateauMean, false)]);
@@ -458,26 +527,166 @@ mod tests {
gate_stage("win_rate"),
]);
assert!(preflight(&gated, &c).is_ok());
// suffix stages compose independently: mc without wf (single
// instrument fine), generalize without mc (needs 2 instruments),
// wf + mc without generalize.
let mc_only = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
mc_stage(1000, 5),
]);
assert!(preflight(&mc_only, &c).is_ok());
let gen_only = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
generalize_stage("expectancy_r"),
]);
assert!(preflight(&gen_only, &c2).is_ok());
let wf_mc = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", SelectRule::Argmax),
mc_stage(100, 3),
]);
assert!(preflight(&wf_mc, &c).is_ok());
}
/// v2 shape: an annotator may not precede a population stage, and
/// std::generalize is strictly last. `[sweep, mc, walk_forward]` is
/// intrinsically valid at the document tier yet refused here.
#[test]
fn preflight_refuses_mc_and_generalize_stages() {
let c = campaign();
let mc = process_of(vec![
fn preflight_refuses_annotator_order_violations() {
let c2 = campaign_two_instruments();
let cases: Vec<(Vec<StageBlock>, &str)> = vec![
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
mc_stage(100, 5),
wf_stage("sqn", SelectRule::Argmax),
],
"stage 2: std::walk_forward cannot follow std::monte_carlo",
),
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
mc_stage(100, 5),
gate_stage("expectancy_r"),
],
"stage 2: std::gate cannot follow std::monte_carlo",
),
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
generalize_stage("expectancy_r"),
mc_stage(100, 5),
],
"stage 2: std::monte_carlo cannot follow std::generalize",
),
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", SelectRule::Argmax),
gate_stage("expectancy_r"),
],
"stage 2: std::gate cannot follow std::walk_forward",
),
];
for (pipeline, want) in cases {
match preflight(&process_of(pipeline), &c2) {
Err(ExecFault::PipelineShape { detail }) => assert_eq!(detail, want),
other => panic!("expected PipelineShape({want}), got {other:?}"),
}
}
}
/// Each v2 suffix stage appears at most once.
#[test]
fn preflight_refuses_duplicate_suffix_stages() {
let c2 = campaign_two_instruments();
let cases: Vec<(Vec<StageBlock>, &str)> = vec![
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", SelectRule::Argmax),
wf_stage("sqn", SelectRule::Argmax),
],
"stage 2: std::walk_forward may appear at most once",
),
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
mc_stage(100, 5),
mc_stage(100, 5),
],
"stage 2: std::monte_carlo may appear at most once",
),
(
vec![
sweep_stage("sqn", SelectRule::Argmax, false),
generalize_stage("expectancy_r"),
generalize_stage("expectancy_r"),
],
"stage 2: std::generalize may appear at most once",
),
];
for (pipeline, want) in cases {
match preflight(&process_of(pipeline), &c2) {
Err(ExecFault::PipelineShape { detail }) => assert_eq!(detail, want),
other => panic!("expected PipelineShape({want}), got {other:?}"),
}
}
}
/// generalize's static arity guard: the campaign must carry >= 2
/// instruments (the shipped generalization() would only refuse at
/// runtime, after members ran).
#[test]
fn preflight_refuses_single_instrument_generalize() {
let c = campaign(); // one instrument
let p = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
StageBlock::MonteCarlo { resamples: 100, block_len: 5 },
generalize_stage("expectancy_r"),
]);
assert!(matches!(
preflight(&mc, &c),
Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::monte_carlo"
preflight(&p, &c),
Err(ExecFault::GeneralizeNeedsInstruments { available: 1 })
));
let g = process_of(vec![
}
/// generalize's metric guard: check_r_metric refuses pip metrics AND
/// unknown names — both surface as GeneralizeNonRMetric.
#[test]
fn preflight_refuses_non_r_generalize_metric() {
let c2 = campaign_two_instruments();
for bad in ["total_pips", "no_such_metric"] {
let p = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
generalize_stage(bad),
]);
assert!(matches!(
preflight(&p, &c2),
Err(ExecFault::GeneralizeNonRMetric { metric }) if metric == bad
));
}
}
/// monte_carlo zero params are static refusals (instead of the engine's
/// defined all-zero degenerate at runtime), naming the offending field.
#[test]
fn preflight_refuses_zero_bootstrap_params() {
let c = campaign();
let zero_resamples = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
StageBlock::Generalize { metric: "expectancy_r".to_string() },
mc_stage(0, 5),
]);
assert!(matches!(
preflight(&g, &c),
Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::generalize"
preflight(&zero_resamples, &c),
Err(ExecFault::ZeroBootstrapParam { stage: 1, field: "resamples" })
));
let zero_block = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
mc_stage(100, 0),
]);
assert!(matches!(
preflight(&zero_block, &c),
Err(ExecFault::ZeroBootstrapParam { stage: 1, field: "block_len" })
));
}
@@ -493,7 +702,8 @@ mod tests {
sweep_stage("sqn", SelectRule::Argmax, false),
]);
assert!(matches!(preflight(&double, &c), Err(ExecFault::PipelineShape { .. })));
// walk_forward anywhere but last is a shape refusal too.
// a gate after walk_forward is still a shape refusal (v2 relaxes
// wf-final only for the annotator suffix: mc/generalize may follow).
let wf_mid = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", SelectRule::Argmax),