diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index d5403e6..c2938d8 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -243,7 +243,7 @@ fn run_cell( for (stage_ordinal, stage) in process.pipeline.iter().enumerate() { match stage { - StageBlock::Sweep { metric, select, deflate } => { + StageBlock::Sweep { selection } => { // Deterministic, self-describing family name (the "-{run}" // suffix is appended by the registry). let family_name = format!( @@ -254,32 +254,55 @@ fn run_cell( let family_id = registry .append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family)) .map_err(ExecFault::Registry)?; - let (winner, selection) = - select_sweep_winner(&family, &grid.axis_lens, metric, *select, *deflate, seed)?; - let winner_ordinal = family - .points - .iter() - .position(|p| p == &winner) - .expect("the winner is a member of its own family"); - let params = zip_params(&family.space, &winner.params); - selections.push(StageSelectionOut { - stage: stage_ordinal, - block: "std::sweep", - family_id: family_id.clone(), - winner_ordinal, - params: params.clone(), - selection: selection.clone(), - }); - // Nominate the sweep winner (superseded by a later - // walk_forward stage; cleared by an empty gate). - nominee = Some((params.clone(), winner.report.clone())); - stages.push(StageRealization { - block: "std::sweep".to_string(), - family_id: Some(family_id.clone()), - survivor_ordinals: None, - selection: Some(StageSelection { winner_ordinal, params, selection }), - bootstrap: None, - }); + match selection { + Some(sel) => { + let (winner, selection) = select_sweep_winner( + &family, + &grid.axis_lens, + &sel.metric, + sel.select, + sel.deflate, + seed, + )?; + let winner_ordinal = family + .points + .iter() + .position(|p| p == &winner) + .expect("the winner is a member of its own family"); + let params = zip_params(&family.space, &winner.params); + selections.push(StageSelectionOut { + stage: stage_ordinal, + block: "std::sweep", + family_id: family_id.clone(), + winner_ordinal, + params: params.clone(), + selection: selection.clone(), + }); + // Nominate the sweep winner (superseded by a later + // walk_forward stage; cleared by an empty gate). + nominee = Some((params.clone(), winner.report.clone())); + stages.push(StageRealization { + block: "std::sweep".to_string(), + family_id: Some(family_id.clone()), + survivor_ordinals: None, + selection: Some(StageSelection { winner_ordinal, params, selection }), + bootstrap: None, + }); + } + None => { + // Selection-free sweep (terminal, preflight-checked): + // the family is the whole result — no StageSelection + // is recorded (recording one would fabricate intent), + // no nominee is produced. + stages.push(StageRealization { + block: "std::sweep".to_string(), + family_id: Some(family_id.clone()), + survivor_ordinals: None, + selection: None, + bootstrap: None, + }); + } + } // The whole family flows on (decision 3: `select` names the // recorded selection, never a filter). survivors = family diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 4de876e..7464a71 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -77,6 +77,13 @@ pub enum ExecFault { PlateauInWalkForward { stage: usize }, /// sweep `deflate: true` with a non-argmax select rule. DeflatePlateauConflict { stage: usize }, + /// A selection-free sweep (no metric/select group) anywhere but the + /// pipeline's terminal stage: gate/walk_forward/monte_carlo run fine off + /// `survivors` (a selection-free sweep still produces the whole family), + /// but no winner/nominee is recorded — a stage that needs the cell's + /// final candidate (`generalize`, campaign-scope) would have nothing to + /// grade. + SelectionFreeSweepNotTerminal { 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 @@ -235,14 +242,24 @@ 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::Sweep { metric, select, deflate } => { - if !RANKABLE_METRICS.contains(&metric.as_str()) { - return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); + StageBlock::Sweep { selection } => match selection { + Some(sel) => { + if !RANKABLE_METRICS.contains(&sel.metric.as_str()) { + return Err(ExecFault::UnrankableMetric { + stage: i, + metric: sel.metric.clone(), + }); + } + if sel.deflate && sel.select != SelectRule::Argmax { + return Err(ExecFault::DeflatePlateauConflict { stage: i }); + } } - if *deflate && *select != SelectRule::Argmax { - return Err(ExecFault::DeflatePlateauConflict { stage: i }); + None => { + if i + 1 != process.pipeline.len() { + return Err(ExecFault::SelectionFreeSweepNotTerminal { stage: i }); + } } - } + }, StageBlock::Gate { all } => { for p in all { if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) { @@ -305,6 +322,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe mod tests { use super::*; use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp}; + use aura_research::SweepSelection; /// A report with every per-member scalar planted to a distinct value /// (1..=14 in `PER_MEMBER_METRICS` order), r block present. @@ -467,7 +485,9 @@ mod tests { } fn sweep_stage(metric: &str, select: SelectRule, deflate: bool) -> StageBlock { - StageBlock::Sweep { metric: metric.to_string(), select, deflate } + StageBlock::Sweep { + selection: Some(SweepSelection { metric: metric.to_string(), select, deflate }), + } } fn gate_stage(metric: &str) -> StageBlock { @@ -690,6 +710,30 @@ mod tests { )); } + /// A selection-free sweep is permitted as the terminal (and here, only) + /// stage of a process: the family itself is the result, no winner needed. + #[test] + fn preflight_accepts_a_terminal_selection_free_sweep() { + let c = campaign(); + let process = process_of(vec![StageBlock::Sweep { selection: None }]); + assert!(preflight(&process, &c).is_ok()); + } + + /// A selection-free sweep followed by any other stage is refused: a + /// downstream stage would have no selection/nominee to consume. + #[test] + fn preflight_refuses_a_non_terminal_selection_free_sweep() { + let c = campaign(); + let process = process_of(vec![ + StageBlock::Sweep { selection: None }, + gate_stage("sqn"), + ]); + match preflight(&process, &c) { + Err(ExecFault::SelectionFreeSweepNotTerminal { stage: 0 }) => {} + other => panic!("expected SelectionFreeSweepNotTerminal, got {other:?}"), + } + } + #[test] fn preflight_refuses_non_sweep_first_and_double_sweep() { let c = campaign(); @@ -804,7 +848,8 @@ mod wf_tests { use aura_registry::{FamilyKind, Registry}; use aura_research::{ Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, - ProcessDoc, ProcessRef, SelectRule, StageBlock, StrategyEntry, WfMode, Window, + ProcessDoc, ProcessRef, SelectRule, StageBlock, StrategyEntry, SweepSelection, WfMode, + Window, }; use super::{execute, CampaignOutcome, CellSpec, ExecFault, MemberFault, MemberRunner}; @@ -857,9 +902,11 @@ mod wf_tests { /// stages on total_pips / argmax, rolling mode. fn wf_process(gate_gt: Option, is_ms: u64, oos_ms: u64, step_ms: u64) -> ProcessDoc { let mut pipeline = vec![StageBlock::Sweep { - metric: "total_pips".to_string(), - select: SelectRule::Argmax, - deflate: false, + selection: Some(SweepSelection { + metric: "total_pips".to_string(), + select: SelectRule::Argmax, + deflate: false, + }), }]; if let Some(threshold) = gate_gt { pipeline.push(StageBlock::Gate { @@ -1175,9 +1222,11 @@ mod wf_tests { name: "wf-plateau-proc".to_string(), description: None, pipeline: vec![StageBlock::Sweep { - metric: "total_pips".to_string(), - select, - deflate: false, + selection: Some(SweepSelection { + metric: "total_pips".to_string(), + select, + deflate: false, + }), }], }; let runner = WfFakeRunner::new(); @@ -1205,9 +1254,11 @@ mod wf_tests { description: None, pipeline: vec![ StageBlock::Sweep { - metric: "total_pips".to_string(), - select: SelectRule::Argmax, - deflate: false, + selection: Some(SweepSelection { + metric: "total_pips".to_string(), + select: SelectRule::Argmax, + deflate: false, + }), }, StageBlock::WalkForward { in_sample_ms: 40, diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index 8c3d614..35d2fd3 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -10,7 +10,7 @@ use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, Sel use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap}; use aura_research::{ Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc, - ProcessRef, SelectRule, StageBlock, StrategyEntry, WfMode, Window, + ProcessRef, SelectRule, StageBlock, StrategyEntry, SweepSelection, WfMode, Window, }; const CAMPAIGN_ID: &str = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999"; @@ -162,12 +162,18 @@ fn campaign(instruments: &[&str]) -> CampaignDoc { fn sweep_stage(deflate: bool) -> StageBlock { StageBlock::Sweep { - metric: "net_expectancy_r".to_string(), - select: SelectRule::Argmax, - deflate, + selection: Some(SweepSelection { + metric: "net_expectancy_r".to_string(), + select: SelectRule::Argmax, + deflate, + }), } } +fn selection_free_sweep_stage() -> StageBlock { + StageBlock::Sweep { selection: None } +} + fn gate_stage(value: f64) -> StageBlock { StageBlock::Gate { all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }], @@ -276,6 +282,46 @@ fn execute_sweep_only_records_family_and_selection() { assert_eq!(stored.selection.seed, None, "no deflation annotation without deflate"); } +/// A selection-free sweep records the family (the whole grid, exactly as a +/// selected sweep does) but never a selection or a nominee: there is no +/// winner to name, and nothing downstream to nominate for. +#[test] +fn execute_selection_free_sweep_records_family_without_selection() { + let reg = temp_registry("selection_free_sweep"); + let doc = campaign(&["EURUSD"]); + let proc_doc = process(vec![selection_free_sweep_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("selection-free sweep campaign executes"); + + // outcome payloads: one cell, one family of 4 members, NO selection + assert_eq!(out.cells.len(), 1); + assert_eq!(out.cells[0].families.len(), 1); + assert_eq!(out.cells[0].families[0].reports.len(), 4); + assert_eq!(out.cells[0].families[0].block, "std::sweep"); + let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]); + assert_eq!(out.cells[0].families[0].family_id, expected_family_id); + assert!(out.cells[0].selections.is_empty(), "no winner is named without a selection group"); + + // family persisted: 4 sweep members under the derived id, same as a + // selected sweep — the selection-free path differs only in what it + // records ABOUT the family, never in the family itself. + let members = reg.load_family_members().expect("load members"); + assert_eq!(members.len(), 4); + assert!(members.iter().all(|m| m.kind == FamilyKind::Sweep)); + + // realization: the stage carries a family_id but no selection; no + // nominee-dependent stage ran, so no generalization is recorded either. + let runs = reg.load_campaign_runs().expect("load campaign runs"); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0], out.record); + assert_eq!(runs[0].cells[0].stages.len(), 1); + let stage = &runs[0].cells[0].stages[0]; + assert_eq!(stage.block, "std::sweep"); + assert_eq!(stage.family_id.as_deref(), Some(expected_family_id.as_str())); + assert!(stage.selection.is_none(), "a selection-free sweep records no selection"); + assert!(runs[0].generalizations.is_empty()); +} + #[test] fn execute_gate_filters_per_member() { let reg = temp_registry("gate_filters"); diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 58e7c1b..189a60b 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -67,6 +67,10 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String { ExecFault::DeflatePlateauConflict { stage } => format!( "process stage {stage}: sweep deflate: true composes only with select \"argmax\"" ), + ExecFault::SelectionFreeSweepNotTerminal { stage } => format!( + "process stage {stage}: a sweep without a selection group (metric + \ + select) must be the last stage of its process" + ), ExecFault::GeneralizeNeedsInstruments { available } => format!( "campaign has {available} instrument(s); std::generalize needs at least 2" ), diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 2750bda..fd0702f 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -138,7 +138,9 @@ fn process_introspect_vocabulary_block_and_content_id() { let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]); assert_eq!(code, Some(0)); assert!(out.contains("metric")); - assert!(out.contains("required")); + // The selection group (metric/select/deflate) is optional — a sweep with + // no selection is a terminal, selection-free stage (#210). + assert!(out.contains("optional")); let (out, code) = run_code(&["process", "introspect", "--block", "std::walk_forward"]); assert_eq!(code, Some(0)); @@ -170,13 +172,16 @@ fn process_introspect_unknown_block_is_prose_exit_1() { #[test] fn process_introspect_unwired_lists_open_slots() { let dir = temp_cwd("process-introspect-unwired"); + // std::gate's "all" slot is required (unlike std::sweep's now-optional + // selection group, #210), so it still exercises the pipeline-level open + // slot listing alongside the top-level "name" slot. let partial = r#"{ "format_version": 1, "kind": "process", - "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; + "pipeline": [ { "block": "std::gate" } ] }"#; write_doc(&dir, "partial.process.json", partial); let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]); assert_eq!(code, Some(0)); assert!(out.contains("open slot: name")); - assert!(out.contains("open slot: pipeline[0].select")); + assert!(out.contains("open slot: pipeline[0].all")); } #[test] @@ -1033,6 +1038,98 @@ fn campaign_run_refuses_mc_before_walk_forward() { assert!(!out.contains("PipelineShape"), "Debug leak: {out}"); } +/// A v2 process doc whose `std::sweep` carries no selection group (no +/// `metric`/`select`) followed by a `std::gate` stage: intrinsically valid +/// (`process register` accepts a selection-free sweep as any other stage +/// shape) but not an executable v2 shape, since a selection-free sweep is +/// permitted ONLY as the terminal stage of its process (#210's terminal +/// rule — the executor's preflight rule, not the intrinsic validator's). +const SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-then-gate-no-selection", + "pipeline": [ + { "block": "std::sweep" }, + { "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] } + ] +}"#; + +/// Property (#210, sweep-dissolution-a): a selection-free `std::sweep` (no +/// metric/select group) anywhere but the LAST stage of its process is +/// refused by `campaign run`'s data-free preflight — the same seam that +/// already refuses `[sweep, monte_carlo, walk_forward]`'s ordering fault +/// above — before any member runs (the [1, 2] 1970-epoch-ms window is never +/// reached), naming the offending stage index, Debug-free. A regression +/// that let a non-terminal selection-free sweep through would silently +/// drop a stage's nominee downstream (nothing to select a winner from, +/// nothing for a following stage to consume). +#[test] +fn campaign_run_refuses_a_non_terminal_selection_free_sweep() { + let _fixture = project_lock(); + let dir = built_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("selfree.process.json")), + ScratchPath::File(dir.join("selfree.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-run-selfree-seed"); + let proc_id = + register_process_doc(dir, "selfree.process.json", SELECTION_FREE_SWEEP_THEN_GATE_PROCESS_DOC); + write_doc(dir, "selfree.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", "")); + let (out, code) = run_code_in(dir, &["campaign", "run", "selfree.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains( + "process stage 0: a sweep without a selection group (metric + select) must be \ + the last stage of its process" + ), + "the detail names the terminal-only rule: {out}" + ); + assert!(!out.contains("SelectionFreeSweepNotTerminal"), "Debug leak: {out}"); +} + +/// The same process shape, but the selection-free sweep IS the (only, hence +/// terminal) stage: `campaign validate`'s third (executor-preflight) tier +/// accepts it, data-free, exit 0 — the selection-free arm is a legitimate +/// executable shape, not merely a document-tier-only accept. Pairs with the +/// refusal above: together they pin the exact placement boundary (terminal: +/// yes / anywhere else: no) the executor preflight enforces. +const SELECTION_FREE_SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only-no-selection", + "pipeline": [ { "block": "std::sweep" } ] +}"#; + +#[test] +fn campaign_validate_accepts_a_terminal_selection_free_sweep() { + let _fixture = project_lock(); + let dir = built_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("selfreeonly.process.json")), + ScratchPath::File(dir.join("selfreeonly.campaign.json")), + ]); + let bp_id = seed_blueprint(dir, "campaign-validate-selfreeonly-seed"); + let proc_id = + register_process_doc(dir, "selfreeonly.process.json", SELECTION_FREE_SWEEP_ONLY_PROCESS_DOC); + write_doc( + dir, + "selfreeonly.campaign.json", + &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""), + ); + let (out, code) = run_code_in(dir, &["campaign", "validate", "selfreeonly.campaign.json"]); + assert_eq!(code, Some(0), "a terminal selection-free sweep validates as executable: {out}"); + assert!( + out.contains("campaign document valid (executable): pipeline shape and static guards pass"), + "stdout/stderr: {out}" + ); +} + /// The v2 boundary, campaign side: a generalize-bearing process is an /// executable shape, but `std::generalize` needs >= 2 instruments in the /// campaign — a STATIC preflight fact (the referential gate has run, no data diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 70f009d..caaa930 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -47,10 +47,14 @@ pub struct ProcessDoc { pub enum StageBlock { #[serde(rename = "std::sweep")] Sweep { - metric: String, - select: SelectRule, - #[serde(default, skip_serializing_if = "is_false")] - deflate: bool, + /// The selection triple is one optional group: `None` is a + /// selection-free sweep (permitted only as the terminal stage — the + /// executor's preflight rule), `Some` carries metric+select(+deflate). + /// Flattened so the wire form stays the flat `metric`/`select`/ + /// `deflate` slots and every existing document's canonical bytes — + /// and content id — are unchanged. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + selection: Option, }, #[serde(rename = "std::gate")] Gate { all: Vec }, @@ -73,6 +77,16 @@ fn is_false(b: &bool) -> bool { !*b } +/// The sweep stage's selection group — all-or-nothing (a half-populated +/// group is unrepresentable; the schema-strict parser refuses it). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SweepSelection { + pub metric: String, + pub select: SelectRule, + #[serde(default, skip_serializing_if = "is_false")] + pub deflate: bool, +} + /// Winner-selection rule; wire strings mirror the CLI `--select` values. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SelectRule { @@ -164,10 +178,12 @@ pub struct BlockSchema { pub const PROCESS_BLOCKS: &[BlockSchema] = &[ BlockSchema { id: "std::sweep", - doc: "evaluate the campaign's axes-space; reduce members to R metrics; select a winner", + doc: "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)", slots: &[ - SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }, - SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true }, + SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false }, + SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false }, SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false }, ], }, @@ -300,11 +316,39 @@ fn stage_from_value(v: &serde_json::Value) -> Result { } } match id.as_str() { - "std::sweep" => Ok(StageBlock::Sweep { - metric: require_str(m, "metric", &id)?, - select: select_from(m, &id)?, - deflate: optional_bool(m, "deflate", &id)?, - }), + "std::sweep" => { + let has_metric = m.contains_key("metric"); + let has_select = m.contains_key("select"); + let selection = match (has_metric, has_select) { + (true, true) => Some(SweepSelection { + metric: require_str(m, "metric", &id)?, + select: select_from(m, &id)?, + deflate: optional_bool(m, "deflate", &id)?, + }), + (false, false) => { + if m.contains_key("deflate") { + return Err(format!( + "block {id}: slot \"deflate\" needs the selection \ + group (\"metric\" + \"select\")" + )); + } + None + } + (true, false) => { + return Err(format!( + "block {id}: slot \"metric\" without \"select\" — the \ + selection group is all-or-nothing" + )); + } + (false, true) => { + return Err(format!( + "block {id}: slot \"select\" without \"metric\" — the \ + selection group is all-or-nothing" + )); + } + }; + Ok(StageBlock::Sweep { selection }) + } "std::gate" => { let all = m .get("all") @@ -634,7 +678,14 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec { let mut terminal_seen = false; for (i, stage) in doc.pipeline.iter().enumerate() { match stage { - StageBlock::Sweep { metric, .. } | StageBlock::Generalize { metric } => { + StageBlock::Sweep { selection } => { + if let Some(sel) = selection + && !is_known_metric(&sel.metric) + { + faults.push(DocFault::UnknownMetric { stage: i, metric: sel.metric.clone() }); + } + } + StageBlock::Generalize { metric } => { if !is_known_metric(metric) { faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); } @@ -981,10 +1032,11 @@ mod tests { assert_eq!(doc.name, "wf-deflated-screen"); assert_eq!(doc.pipeline.len(), 3); match &doc.pipeline[0] { - StageBlock::Sweep { metric, select, deflate } => { - assert_eq!(metric, "net_expectancy_r"); - assert_eq!(*select, SelectRule::PlateauWorst); - assert!(*deflate); + StageBlock::Sweep { selection } => { + let sel = selection.as_ref().expect("fixture sweep carries a selection"); + assert_eq!(sel.metric, "net_expectancy_r"); + assert_eq!(sel.select, SelectRule::PlateauWorst); + assert!(sel.deflate); } other => panic!("stage 0 is not a sweep: {other:?}"), } @@ -1262,6 +1314,22 @@ mod tests { .contains(&DocFault::GateAfterTerminalStage { stage: 2 })); } + /// The intrinsic tier's metric-validation branch for `std::sweep` must + /// skip a `None` selection cleanly (no metric to resolve, so no spurious + /// `UnknownMetric`) — the terminal-placement rule itself is the + /// executor preflight's job, not this validator's. + #[test] + fn validate_process_accepts_a_selection_free_sweep() { + let doc = ProcessDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Process, + name: "sweep-only".to_string(), + description: None, + pipeline: vec![StageBlock::Sweep { selection: None }], + }; + assert_eq!(validate_process(&doc), Vec::new()); + } + #[test] fn walk_forward_parses_both_modes_and_refuses_bad_slots() { // "anchored" is the other accepted wire mode @@ -1474,7 +1542,7 @@ mod tests { } } let sweep = describe_block("std::sweep").expect("sweep describable"); - assert!(sweep.slots.iter().any(|s| s.name == "metric" && s.required)); + assert!(sweep.slots.iter().any(|s| s.name == "metric" && !s.required)); assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required)); let wf = describe_block("std::walk_forward").expect("walk_forward describable"); let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect(); @@ -1532,7 +1600,7 @@ mod tests { "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; let slots = open_slots_process(partial).unwrap(); assert!(slots.iter().any(|s| s.path == "name")); - assert!(slots.iter().any(|s| s.path == "pipeline[0].select")); + assert!(!slots.iter().any(|s| s.path == "pipeline[0].select")); assert!(!slots.iter().any(|s| s.path == "pipeline[0].metric")); let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", @@ -1583,4 +1651,52 @@ mod tests { "process `ref: {{}}` placeholder not reported as an open slot: {slots:?}" ); } + + #[test] + fn sweep_selection_group_is_all_or_nothing() { + let free = serde_json::json!({ "block": "std::sweep" }); + assert_eq!( + stage_from_value(&free).unwrap(), + StageBlock::Sweep { selection: None } + ); + let metric_only = serde_json::json!({ "block": "std::sweep", "metric": "sqn" }); + let err = stage_from_value(&metric_only).unwrap_err(); + assert!(err.contains("all-or-nothing"), "{err}"); + let select_only = serde_json::json!({ "block": "std::sweep", "select": "argmax" }); + let err = stage_from_value(&select_only).unwrap_err(); + assert!(err.contains("all-or-nothing"), "{err}"); + let deflate_only = serde_json::json!({ "block": "std::sweep", "deflate": true }); + let err = stage_from_value(&deflate_only).unwrap_err(); + assert!(err.contains("needs the selection group"), "{err}"); + } + + #[test] + fn selection_free_sweep_round_trips_and_hashes_distinctly() { + let doc = ProcessDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Process, + name: "sweep".to_string(), + description: None, + pipeline: vec![StageBlock::Sweep { selection: None }], + }; + let text = process_to_json(&doc); + // the absent group leaves ONLY the block id on the wire + assert!(text.contains("\"block\":\"std::sweep\""), "{text}"); + assert!(!text.contains("metric"), "{text}"); + assert!(!text.contains("select"), "{text}"); + let back = parse_process(&text).expect("selection-free sweep parses"); + assert_eq!(back, doc); + // a selected sibling hashes differently + let selected = ProcessDoc { + pipeline: vec![StageBlock::Sweep { + selection: Some(SweepSelection { + metric: "sqn_normalized".to_string(), + select: SelectRule::Argmax, + deflate: false, + }), + }], + ..doc.clone() + }; + assert_ne!(content_id_of(&text), content_id_of(&process_to_json(&selected))); + } }