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