feat(research,campaign): optional selection group on the sweep stage (#210 T1-T2)

The std::sweep selection triple (metric/select/deflate) becomes ONE
optional group, Option<SweepSelection>, serde-flattened so the wire
form keeps the flat slots: every existing document parses to Some and
its canonical bytes — and content id — are unchanged (golden pin
byte-untouched). A half-populated group is refused by the schema-strict
parser (all-or-nothing); a bare {"block":"std::sweep"} is the
selection-free sweep.

Campaign side: preflight permits a selection-free sweep only as the
pipeline's terminal stage (new ExecFault::SelectionFreeSweepNotTerminal,
prose in aura-cli's exhaustive exec_fault_prose); the executor's
selection-free arm appends the family exactly as before but records no
StageSelection and produces no nominee (recording one would fabricate
intent — #210 fork decision 1).

Introspection follows: the sweep block's schema table marks the group
optional; CLI introspection pins updated. New tests: group-or-nothing
refusals, selection-free round-trip + distinct content id, terminal-only
preflight accept/refuse, selection-free execute realization.

refs #210
This commit is contained in:
2026-07-04 17:43:53 +02:00
parent 3fd1cf9593
commit c37abb2a27
6 changed files with 407 additions and 70 deletions
+50 -27
View File
@@ -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
+68 -17
View File
@@ -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<f64>, 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,
+50 -4
View File
@@ -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(), &reg)
.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");