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