feat(campaign,registry,engine): record widenings + preflight v2 fault set (0108 tasks 1-2)
Task 1: RBootstrap and Generalization gain serde derives; lineage.rs gains StageBootstrap (per_survivor | pooled_oos) and CampaignGeneralization; StageRealization.bootstrap and CampaignRunRecord.generalizations land serde-default sparse (a pre-0108 campaign_runs.jsonl line still parses — pinned), threaded None/empty through every literal site. No behaviour change. Task 2: ExecFault::UnsupportedStage is gone; the preflight now admits the v2 shape sweep [gate]* [wf]? [mc]? [generalize]? via a monotone rank walk (precise per-pair PipelineShape prose) and statically refuses: single-instrument generalize (GeneralizeNeedsInstruments), non-R generalize metrics via check_r_metric (GeneralizeNonRMetric — no fourth roster site), zero mc params (ZeroBootstrapParam). CLI prose swapped accordingly. The loop's E2E phase added a tier-boundary pin: process validate accepts [sweep, mc, walk_forward] while campaign run refuses it data-free. KNOWN RED (documented in the plan, flips in task 5): crates/aura-cli/tests/research_docs.rs::campaign_run_v1_boundary_refuses_mc_process still pins the retired 'not executable in v1' prose. Interim gates: registry 57/0, campaign 33/0, cli --bin 111/0, workspace build clean. refs #200
This commit is contained in:
@@ -44,10 +44,6 @@ pub(crate) fn is_content_id(s: &str) -> bool {
|
||||
/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register.
|
||||
fn exec_fault_prose(f: &ExecFault) -> String {
|
||||
match f {
|
||||
ExecFault::UnsupportedStage { stage, block } => format!(
|
||||
"process stage {stage} ({block}) is not executable in v1; executable \
|
||||
pipeline shape: std::sweep [std::gate]* [std::walk_forward]"
|
||||
),
|
||||
ExecFault::PipelineShape { detail } => {
|
||||
format!("process pipeline is not executable: {detail}")
|
||||
}
|
||||
@@ -68,6 +64,16 @@ fn exec_fault_prose(f: &ExecFault) -> String {
|
||||
ExecFault::DeflatePlateauConflict { stage } => format!(
|
||||
"process stage {stage}: sweep deflate: true composes only with select \"argmax\""
|
||||
),
|
||||
ExecFault::GeneralizeNeedsInstruments { available } => format!(
|
||||
"campaign has {available} instrument(s); std::generalize needs at least 2"
|
||||
),
|
||||
ExecFault::GeneralizeNonRMetric { metric } => format!(
|
||||
"std::generalize metric \"{metric}\" is not an R metric (gross/net R \
|
||||
families generalize; pip metrics do not)"
|
||||
),
|
||||
ExecFault::ZeroBootstrapParam { stage, field } => format!(
|
||||
"process stage {stage}: monte_carlo {field} must be > 0"
|
||||
),
|
||||
ExecFault::Window { stage, detail } => format!(
|
||||
"process stage {stage}: walk_forward windows do not fit the campaign \
|
||||
window: {detail}"
|
||||
@@ -484,6 +490,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// generalize's static instrument-arity refusal is Debug-free and
|
||||
/// names the available count plus the required floor.
|
||||
fn generalize_needs_instruments_prose_names_the_shortfall() {
|
||||
let prose = exec_fault_prose(&ExecFault::GeneralizeNeedsInstruments { available: 1 });
|
||||
assert_eq!(prose, "campaign has 1 instrument(s); std::generalize needs at least 2");
|
||||
assert!(!prose.contains("GeneralizeNeedsInstruments"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// generalize's R-metric refusal names the metric and the R-only
|
||||
/// rationale (pip metrics are not comparable across instruments).
|
||||
fn generalize_non_r_metric_prose_names_the_metric() {
|
||||
let prose = exec_fault_prose(&ExecFault::GeneralizeNonRMetric {
|
||||
metric: "total_pips".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
prose,
|
||||
"std::generalize metric \"total_pips\" is not an R metric (gross/net R \
|
||||
families generalize; pip metrics do not)"
|
||||
);
|
||||
assert!(!prose.contains("GeneralizeNonRMetric"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// the zero-bootstrap-param refusal is path-addressed (stage index)
|
||||
/// and names the offending field.
|
||||
fn zero_bootstrap_param_prose_names_stage_and_field() {
|
||||
let prose = exec_fault_prose(&ExecFault::ZeroBootstrapParam {
|
||||
stage: 3,
|
||||
field: "block_len",
|
||||
});
|
||||
assert_eq!(prose, "process stage 3: monte_carlo block_len must be > 0");
|
||||
assert!(!prose.contains("ZeroBootstrapParam"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The only shape treated as a direct store address is a bare 64-char
|
||||
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
|
||||
|
||||
@@ -794,6 +794,153 @@ fn campaign_run_v1_boundary_refuses_mc_process() {
|
||||
assert!(!out.contains("UnsupportedStage"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// A v2 process doc with `std::walk_forward` following the annotator
|
||||
/// `std::monte_carlo` — intrinsically valid (order across population vs.
|
||||
/// annotator stages is not a document-tier concern), refused only by the
|
||||
/// executor's v2 preflight shape guard.
|
||||
const WF_AFTER_MC_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "wf-after-mc",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
|
||||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// Property (0108 task 2, mirroring aura-research's characterization pin
|
||||
/// `validate_process_permits_walk_forward_after_an_annotator`): the
|
||||
/// intrinsic/executor tier boundary is observable across TWO CLI commands.
|
||||
/// `process validate` accepts `[sweep, monte_carlo, walk_forward]` (exit 0 —
|
||||
/// the document tier does not order population vs. annotator stages) while
|
||||
/// `campaign run`'s v2 preflight refuses the very same document, before any
|
||||
/// member executes (data-free: the [1, 2] 1970-epoch-ms window is never
|
||||
/// reached), because `std::walk_forward` cannot follow the annotator
|
||||
/// `std::monte_carlo`. A regression that let the executor silently accept the
|
||||
/// intrinsic tier's looser order (or that widened the intrinsic tier to
|
||||
/// match) would collapse this two-command divergence.
|
||||
#[test]
|
||||
fn process_validate_permits_wf_after_mc_but_campaign_run_refuses_it() {
|
||||
// intrinsic tier: no project needed, and it accepts the doc outright.
|
||||
let vdir = temp_cwd("wf-after-mc-validate");
|
||||
write_doc(&vdir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||||
let (vout, vcode) = run_code_in(&vdir, &["process", "validate", "wfmc.process.json"]);
|
||||
assert_eq!(vcode, Some(0), "intrinsic validate must accept sweep->mc->walk_forward: {vout}");
|
||||
assert!(
|
||||
vout.contains("process document valid (intrinsic): 3 pipeline blocks"),
|
||||
"stdout/stderr: {vout}"
|
||||
);
|
||||
|
||||
// executor tier: campaign run's v2 preflight refuses the same shape.
|
||||
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("wfmc.process.json")),
|
||||
ScratchPath::File(dir.join("wfmc.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(dir, "campaign-run-wfmc-seed");
|
||||
let proc_id = register_process_doc(dir, "wfmc.process.json", WF_AFTER_MC_PROCESS_DOC);
|
||||
write_doc(dir, "wfmc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "wfmc.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("std::walk_forward cannot follow std::monte_carlo"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(!out.contains("PipelineShape {"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// A v2 process doc with a static `std::monte_carlo resamples: 0` defect.
|
||||
const ZERO_RESAMPLES_MC_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "zero-resamples-mc",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 0, "block_len": 5 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// Property (0108 task 2): `std::monte_carlo`'s `resamples > 0` guard is a
|
||||
/// STATIC executor-preflight refusal, wired end to end from the on-disk
|
||||
/// document through to CLI stdout, firing before any member runs (data-free:
|
||||
/// the [1, 2] 1970-epoch-ms window is never reached) and naming the stage
|
||||
/// index and the offending field. A regression that dropped the static guard
|
||||
/// (deferring to the engine's runtime all-zero-resamples degenerate instead)
|
||||
/// would let this refusal disappear or drift to a different message.
|
||||
#[test]
|
||||
fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
|
||||
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("zeromc.process.json")),
|
||||
ScratchPath::File(dir.join("zeromc.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(dir, "campaign-run-zeromc-seed");
|
||||
let proc_id = register_process_doc(dir, "zeromc.process.json", ZERO_RESAMPLES_MC_PROCESS_DOC);
|
||||
write_doc(dir, "zeromc.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "zeromc.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("process stage 1: monte_carlo resamples must be > 0"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(!out.contains("ZeroBootstrapParam"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// A v2 process doc that ends in `std::generalize`.
|
||||
const GENERALIZE_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "generalize-screen",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::generalize", "metric": "net_expectancy_r" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// Property (0108 task 2): `std::generalize`'s campaign-level instrument-
|
||||
/// arity guard (>= 2 instruments) is a STATIC executor-preflight refusal,
|
||||
/// wired end to end from the campaign document's `data.instruments` through
|
||||
/// to CLI stdout, firing before any member runs (data-free) and naming the
|
||||
/// actual instrument count. `campaign_doc_json`'s fixture campaign carries
|
||||
/// exactly one instrument (`GER40`), so this exercises the campaign
|
||||
/// parameter `preflight` newly reads (previously unused in v1) rather than
|
||||
/// anything reachable from the process document alone. A regression that
|
||||
/// left the campaign parameter unread would silently accept a single-
|
||||
/// instrument `std::generalize` and only fail later, opaquely, inside the
|
||||
/// generalization math.
|
||||
#[test]
|
||||
fn campaign_run_refuses_single_instrument_generalize() {
|
||||
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("gen.process.json")),
|
||||
ScratchPath::File(dir.join("gen.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(dir, "campaign-run-gen-seed");
|
||||
let proc_id = register_process_doc(dir, "gen.process.json", GENERALIZE_PROCESS_DOC);
|
||||
write_doc(dir, "gen.campaign.json", &campaign_doc_json(&bp_id, &proc_id, (1, 2), "", ""));
|
||||
let (out, code) = run_code_in(dir, &["campaign", "run", "gen.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("campaign has 1 instrument(s); std::generalize needs at least 2"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(!out.contains("GeneralizeNeedsInstruments"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// Non-empty persist_taps defers LOUDLY, and the note's ORDER is pinned: it
|
||||
/// prints before member execution, so it is asserted here on a run
|
||||
/// that refuses at the member-data seam. The [1, 2] epoch-ms window (1970)
|
||||
|
||||
Reference in New Issue
Block a user