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:
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user