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() {
|
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
|
||||||
match stage {
|
match stage {
|
||||||
StageBlock::Sweep { metric, select, deflate } => {
|
StageBlock::Sweep { selection } => {
|
||||||
// Deterministic, self-describing family name (the "-{run}"
|
// Deterministic, self-describing family name (the "-{run}"
|
||||||
// suffix is appended by the registry).
|
// suffix is appended by the registry).
|
||||||
let family_name = format!(
|
let family_name = format!(
|
||||||
@@ -254,32 +254,55 @@ fn run_cell(
|
|||||||
let family_id = registry
|
let family_id = registry
|
||||||
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||||
.map_err(ExecFault::Registry)?;
|
.map_err(ExecFault::Registry)?;
|
||||||
let (winner, selection) =
|
match selection {
|
||||||
select_sweep_winner(&family, &grid.axis_lens, metric, *select, *deflate, seed)?;
|
Some(sel) => {
|
||||||
let winner_ordinal = family
|
let (winner, selection) = select_sweep_winner(
|
||||||
.points
|
&family,
|
||||||
.iter()
|
&grid.axis_lens,
|
||||||
.position(|p| p == &winner)
|
&sel.metric,
|
||||||
.expect("the winner is a member of its own family");
|
sel.select,
|
||||||
let params = zip_params(&family.space, &winner.params);
|
sel.deflate,
|
||||||
selections.push(StageSelectionOut {
|
seed,
|
||||||
stage: stage_ordinal,
|
)?;
|
||||||
block: "std::sweep",
|
let winner_ordinal = family
|
||||||
family_id: family_id.clone(),
|
.points
|
||||||
winner_ordinal,
|
.iter()
|
||||||
params: params.clone(),
|
.position(|p| p == &winner)
|
||||||
selection: selection.clone(),
|
.expect("the winner is a member of its own family");
|
||||||
});
|
let params = zip_params(&family.space, &winner.params);
|
||||||
// Nominate the sweep winner (superseded by a later
|
selections.push(StageSelectionOut {
|
||||||
// walk_forward stage; cleared by an empty gate).
|
stage: stage_ordinal,
|
||||||
nominee = Some((params.clone(), winner.report.clone()));
|
block: "std::sweep",
|
||||||
stages.push(StageRealization {
|
family_id: family_id.clone(),
|
||||||
block: "std::sweep".to_string(),
|
winner_ordinal,
|
||||||
family_id: Some(family_id.clone()),
|
params: params.clone(),
|
||||||
survivor_ordinals: None,
|
selection: selection.clone(),
|
||||||
selection: Some(StageSelection { winner_ordinal, params, selection }),
|
});
|
||||||
bootstrap: None,
|
// 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
|
// The whole family flows on (decision 3: `select` names the
|
||||||
// recorded selection, never a filter).
|
// recorded selection, never a filter).
|
||||||
survivors = family
|
survivors = family
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ pub enum ExecFault {
|
|||||||
PlateauInWalkForward { stage: usize },
|
PlateauInWalkForward { stage: usize },
|
||||||
/// sweep `deflate: true` with a non-argmax select rule.
|
/// sweep `deflate: true` with a non-argmax select rule.
|
||||||
DeflatePlateauConflict { stage: usize },
|
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).
|
/// generalize needs >= 2 instruments in the campaign (static).
|
||||||
GeneralizeNeedsInstruments { available: usize },
|
GeneralizeNeedsInstruments { available: usize },
|
||||||
/// generalize's metric must be an R metric (static — the intrinsic tier
|
/// 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).
|
// per-stage slot rules (shape already established above).
|
||||||
for (i, stage) in process.pipeline.iter().enumerate() {
|
for (i, stage) in process.pipeline.iter().enumerate() {
|
||||||
match stage {
|
match stage {
|
||||||
StageBlock::Sweep { metric, select, deflate } => {
|
StageBlock::Sweep { selection } => match selection {
|
||||||
if !RANKABLE_METRICS.contains(&metric.as_str()) {
|
Some(sel) => {
|
||||||
return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() });
|
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 {
|
None => {
|
||||||
return Err(ExecFault::DeflatePlateauConflict { stage: i });
|
if i + 1 != process.pipeline.len() {
|
||||||
|
return Err(ExecFault::SelectionFreeSweepNotTerminal { stage: i });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
StageBlock::Gate { all } => {
|
StageBlock::Gate { all } => {
|
||||||
for p in all {
|
for p in all {
|
||||||
if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) {
|
if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) {
|
||||||
@@ -305,6 +322,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
|
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
|
||||||
|
use aura_research::SweepSelection;
|
||||||
|
|
||||||
/// A report with every per-member scalar planted to a distinct value
|
/// A report with every per-member scalar planted to a distinct value
|
||||||
/// (1..=14 in `PER_MEMBER_METRICS` order), r block present.
|
/// (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 {
|
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 {
|
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]
|
#[test]
|
||||||
fn preflight_refuses_non_sweep_first_and_double_sweep() {
|
fn preflight_refuses_non_sweep_first_and_double_sweep() {
|
||||||
let c = campaign();
|
let c = campaign();
|
||||||
@@ -804,7 +848,8 @@ mod wf_tests {
|
|||||||
use aura_registry::{FamilyKind, Registry};
|
use aura_registry::{FamilyKind, Registry};
|
||||||
use aura_research::{
|
use aura_research::{
|
||||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation,
|
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};
|
use super::{execute, CampaignOutcome, CellSpec, ExecFault, MemberFault, MemberRunner};
|
||||||
@@ -857,9 +902,11 @@ mod wf_tests {
|
|||||||
/// stages on total_pips / argmax, rolling mode.
|
/// stages on total_pips / argmax, rolling mode.
|
||||||
fn wf_process(gate_gt: Option<f64>, is_ms: u64, oos_ms: u64, step_ms: u64) -> ProcessDoc {
|
fn wf_process(gate_gt: Option<f64>, is_ms: u64, oos_ms: u64, step_ms: u64) -> ProcessDoc {
|
||||||
let mut pipeline = vec![StageBlock::Sweep {
|
let mut pipeline = vec![StageBlock::Sweep {
|
||||||
metric: "total_pips".to_string(),
|
selection: Some(SweepSelection {
|
||||||
select: SelectRule::Argmax,
|
metric: "total_pips".to_string(),
|
||||||
deflate: false,
|
select: SelectRule::Argmax,
|
||||||
|
deflate: false,
|
||||||
|
}),
|
||||||
}];
|
}];
|
||||||
if let Some(threshold) = gate_gt {
|
if let Some(threshold) = gate_gt {
|
||||||
pipeline.push(StageBlock::Gate {
|
pipeline.push(StageBlock::Gate {
|
||||||
@@ -1175,9 +1222,11 @@ mod wf_tests {
|
|||||||
name: "wf-plateau-proc".to_string(),
|
name: "wf-plateau-proc".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
pipeline: vec![StageBlock::Sweep {
|
pipeline: vec![StageBlock::Sweep {
|
||||||
metric: "total_pips".to_string(),
|
selection: Some(SweepSelection {
|
||||||
select,
|
metric: "total_pips".to_string(),
|
||||||
deflate: false,
|
select,
|
||||||
|
deflate: false,
|
||||||
|
}),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let runner = WfFakeRunner::new();
|
let runner = WfFakeRunner::new();
|
||||||
@@ -1205,9 +1254,11 @@ mod wf_tests {
|
|||||||
description: None,
|
description: None,
|
||||||
pipeline: vec![
|
pipeline: vec![
|
||||||
StageBlock::Sweep {
|
StageBlock::Sweep {
|
||||||
metric: "total_pips".to_string(),
|
selection: Some(SweepSelection {
|
||||||
select: SelectRule::Argmax,
|
metric: "total_pips".to_string(),
|
||||||
deflate: false,
|
select: SelectRule::Argmax,
|
||||||
|
deflate: false,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
StageBlock::WalkForward {
|
StageBlock::WalkForward {
|
||||||
in_sample_ms: 40,
|
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_registry::{generalization, FamilyKind, Registry, StageBootstrap};
|
||||||
use aura_research::{
|
use aura_research::{
|
||||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
|
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";
|
const CAMPAIGN_ID: &str = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999";
|
||||||
@@ -162,12 +162,18 @@ fn campaign(instruments: &[&str]) -> CampaignDoc {
|
|||||||
|
|
||||||
fn sweep_stage(deflate: bool) -> StageBlock {
|
fn sweep_stage(deflate: bool) -> StageBlock {
|
||||||
StageBlock::Sweep {
|
StageBlock::Sweep {
|
||||||
metric: "net_expectancy_r".to_string(),
|
selection: Some(SweepSelection {
|
||||||
select: SelectRule::Argmax,
|
metric: "net_expectancy_r".to_string(),
|
||||||
deflate,
|
select: SelectRule::Argmax,
|
||||||
|
deflate,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn selection_free_sweep_stage() -> StageBlock {
|
||||||
|
StageBlock::Sweep { selection: None }
|
||||||
|
}
|
||||||
|
|
||||||
fn gate_stage(value: f64) -> StageBlock {
|
fn gate_stage(value: f64) -> StageBlock {
|
||||||
StageBlock::Gate {
|
StageBlock::Gate {
|
||||||
all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }],
|
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");
|
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]
|
#[test]
|
||||||
fn execute_gate_filters_per_member() {
|
fn execute_gate_filters_per_member() {
|
||||||
let reg = temp_registry("gate_filters");
|
let reg = temp_registry("gate_filters");
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String {
|
|||||||
ExecFault::DeflatePlateauConflict { stage } => format!(
|
ExecFault::DeflatePlateauConflict { stage } => format!(
|
||||||
"process stage {stage}: sweep deflate: true composes only with select \"argmax\""
|
"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!(
|
ExecFault::GeneralizeNeedsInstruments { available } => format!(
|
||||||
"campaign has {available} instrument(s); std::generalize needs at least 2"
|
"campaign has {available} instrument(s); std::generalize needs at least 2"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ fn process_introspect_vocabulary_block_and_content_id() {
|
|||||||
let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]);
|
let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]);
|
||||||
assert_eq!(code, Some(0));
|
assert_eq!(code, Some(0));
|
||||||
assert!(out.contains("metric"));
|
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"]);
|
let (out, code) = run_code(&["process", "introspect", "--block", "std::walk_forward"]);
|
||||||
assert_eq!(code, Some(0));
|
assert_eq!(code, Some(0));
|
||||||
@@ -170,13 +172,16 @@ fn process_introspect_unknown_block_is_prose_exit_1() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn process_introspect_unwired_lists_open_slots() {
|
fn process_introspect_unwired_lists_open_slots() {
|
||||||
let dir = temp_cwd("process-introspect-unwired");
|
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",
|
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);
|
write_doc(&dir, "partial.process.json", partial);
|
||||||
let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]);
|
let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]);
|
||||||
assert_eq!(code, Some(0));
|
assert_eq!(code, Some(0));
|
||||||
assert!(out.contains("open slot: name"));
|
assert!(out.contains("open slot: name"));
|
||||||
assert!(out.contains("open slot: pipeline[0].select"));
|
assert!(out.contains("open slot: pipeline[0].all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1033,6 +1038,98 @@ fn campaign_run_refuses_mc_before_walk_forward() {
|
|||||||
assert!(!out.contains("PipelineShape"), "Debug leak: {out}");
|
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
|
/// The v2 boundary, campaign side: a generalize-bearing process is an
|
||||||
/// executable shape, but `std::generalize` needs >= 2 instruments in the
|
/// executable shape, but `std::generalize` needs >= 2 instruments in the
|
||||||
/// campaign — a STATIC preflight fact (the referential gate has run, no data
|
/// campaign — a STATIC preflight fact (the referential gate has run, no data
|
||||||
|
|||||||
+135
-19
@@ -47,10 +47,14 @@ pub struct ProcessDoc {
|
|||||||
pub enum StageBlock {
|
pub enum StageBlock {
|
||||||
#[serde(rename = "std::sweep")]
|
#[serde(rename = "std::sweep")]
|
||||||
Sweep {
|
Sweep {
|
||||||
metric: String,
|
/// The selection triple is one optional group: `None` is a
|
||||||
select: SelectRule,
|
/// selection-free sweep (permitted only as the terminal stage — the
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
/// executor's preflight rule), `Some` carries metric+select(+deflate).
|
||||||
deflate: bool,
|
/// 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<SweepSelection>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "std::gate")]
|
#[serde(rename = "std::gate")]
|
||||||
Gate { all: Vec<Predicate> },
|
Gate { all: Vec<Predicate> },
|
||||||
@@ -73,6 +77,16 @@ fn is_false(b: &bool) -> bool {
|
|||||||
!*b
|
!*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.
|
/// Winner-selection rule; wire strings mirror the CLI `--select` values.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum SelectRule {
|
pub enum SelectRule {
|
||||||
@@ -164,10 +178,12 @@ pub struct BlockSchema {
|
|||||||
pub const PROCESS_BLOCKS: &[BlockSchema] = &[
|
pub const PROCESS_BLOCKS: &[BlockSchema] = &[
|
||||||
BlockSchema {
|
BlockSchema {
|
||||||
id: "std::sweep",
|
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: &[
|
slots: &[
|
||||||
SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true },
|
SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false },
|
||||||
SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true },
|
SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false },
|
||||||
SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false },
|
SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -300,11 +316,39 @@ fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match id.as_str() {
|
match id.as_str() {
|
||||||
"std::sweep" => Ok(StageBlock::Sweep {
|
"std::sweep" => {
|
||||||
metric: require_str(m, "metric", &id)?,
|
let has_metric = m.contains_key("metric");
|
||||||
select: select_from(m, &id)?,
|
let has_select = m.contains_key("select");
|
||||||
deflate: optional_bool(m, "deflate", &id)?,
|
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" => {
|
"std::gate" => {
|
||||||
let all = m
|
let all = m
|
||||||
.get("all")
|
.get("all")
|
||||||
@@ -634,7 +678,14 @@ pub fn validate_process(doc: &ProcessDoc) -> Vec<DocFault> {
|
|||||||
let mut terminal_seen = false;
|
let mut terminal_seen = false;
|
||||||
for (i, stage) in doc.pipeline.iter().enumerate() {
|
for (i, stage) in doc.pipeline.iter().enumerate() {
|
||||||
match stage {
|
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) {
|
if !is_known_metric(metric) {
|
||||||
faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
|
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.name, "wf-deflated-screen");
|
||||||
assert_eq!(doc.pipeline.len(), 3);
|
assert_eq!(doc.pipeline.len(), 3);
|
||||||
match &doc.pipeline[0] {
|
match &doc.pipeline[0] {
|
||||||
StageBlock::Sweep { metric, select, deflate } => {
|
StageBlock::Sweep { selection } => {
|
||||||
assert_eq!(metric, "net_expectancy_r");
|
let sel = selection.as_ref().expect("fixture sweep carries a selection");
|
||||||
assert_eq!(*select, SelectRule::PlateauWorst);
|
assert_eq!(sel.metric, "net_expectancy_r");
|
||||||
assert!(*deflate);
|
assert_eq!(sel.select, SelectRule::PlateauWorst);
|
||||||
|
assert!(sel.deflate);
|
||||||
}
|
}
|
||||||
other => panic!("stage 0 is not a sweep: {other:?}"),
|
other => panic!("stage 0 is not a sweep: {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1314,22 @@ mod tests {
|
|||||||
.contains(&DocFault::GateAfterTerminalStage { stage: 2 }));
|
.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]
|
#[test]
|
||||||
fn walk_forward_parses_both_modes_and_refuses_bad_slots() {
|
fn walk_forward_parses_both_modes_and_refuses_bad_slots() {
|
||||||
// "anchored" is the other accepted wire mode
|
// "anchored" is the other accepted wire mode
|
||||||
@@ -1474,7 +1542,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sweep = describe_block("std::sweep").expect("sweep describable");
|
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));
|
assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required));
|
||||||
let wf = describe_block("std::walk_forward").expect("walk_forward describable");
|
let wf = describe_block("std::walk_forward").expect("walk_forward describable");
|
||||||
let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
|
let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
|
||||||
@@ -1532,7 +1600,7 @@ mod tests {
|
|||||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#;
|
"pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#;
|
||||||
let slots = open_slots_process(partial).unwrap();
|
let slots = open_slots_process(partial).unwrap();
|
||||||
assert!(slots.iter().any(|s| s.path == "name"));
|
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"));
|
assert!(!slots.iter().any(|s| s.path == "pipeline[0].metric"));
|
||||||
|
|
||||||
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
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:?}"
|
"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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user