feat(research,campaign,cli): chunked instrument-major cell walk + duplicate-instrument refusal
Two pieces of the parallel-cell-loop iteration (spec: parallel-cell-loop), still sequential — behaviour-identical results, suite green unchanged: - validate_campaign refuses a campaign document listing the same instrument twice (DocFault::DuplicateInstrument + CLI prose). A duplicate enumerates two identical cells and is the one input that could make two cells share a registry family name; with distinct instruments the per-cell family name (all four cell axes + stage ordinal) is unique by construction, so the upcoming parallel appends can never race one name's run-index assignment. - aura_campaign::execute flattens the 4-nested cell loop into a document- order-indexed cell list, groups it by instrument ordinal, and walks sequential chunks of K instrument groups; results land in index-addressed slots and the ordered outputs (outcomes, realizations, nominee groups) are rebuilt in document order. K arrives as a new NonZeroUsize parameter (default DEFAULT_PARALLEL_INSTRUMENTS = 4), exposed as --parallel-instruments on aura campaign run and threaded through the run_campaign chain; the dissolved-verb sugar paths pass the default. The chunk walk is the structural RAM bound for the parallel flip that follows: no cell of an instrument outside the current chunk can be in flight at all (chosen over a semaphore gate — blocking inside pool tasks risks worker starvation; decision log on #277). Verified: workspace suite green unchanged, clippy -D warnings clean, E2E fixtures pin the CLI prose and the document-order persistence across K. refs #277
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
//! visible to child modules without promotion.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{mpsc, Arc};
|
||||
|
||||
@@ -478,7 +479,11 @@ pub(crate) enum RunPresentation {
|
||||
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok`
|
||||
/// carries the failed-cell count (#272) so the caller can exit 3 on a
|
||||
/// completed-with-failures run via `exit_on_campaign_result`.
|
||||
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<usize, String> {
|
||||
pub(crate) fn run_campaign(
|
||||
target: &str,
|
||||
env: &Env,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
) -> Result<usize, String> {
|
||||
// Project gate FIRST: nothing (not even the file-sugar registration)
|
||||
// touches a store outside a project.
|
||||
if env.provenance().is_none() {
|
||||
@@ -511,7 +516,7 @@ pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<usize, String> {
|
||||
));
|
||||
};
|
||||
|
||||
run_campaign_by_id(&campaign_id, env, RunPresentation::Full)
|
||||
run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments)
|
||||
}
|
||||
|
||||
/// An executed campaign plus the context its presenter and the dissolved-verb
|
||||
@@ -540,8 +545,9 @@ pub(crate) fn run_campaign_by_id(
|
||||
campaign_id: &str,
|
||||
env: &Env,
|
||||
presentation: RunPresentation,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
) -> Result<usize, String> {
|
||||
let run = run_campaign_returning(campaign_id, env)?;
|
||||
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
|
||||
present_campaign(run, presentation, env)
|
||||
}
|
||||
|
||||
@@ -556,6 +562,7 @@ pub(crate) fn run_campaign_by_id(
|
||||
pub(crate) fn run_campaign_returning(
|
||||
campaign_id: &str,
|
||||
env: &Env,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
) -> Result<CampaignRun, String> {
|
||||
let registry = env.registry();
|
||||
let campaign_text = registry
|
||||
@@ -635,6 +642,7 @@ pub(crate) fn run_campaign_returning(
|
||||
&strategies,
|
||||
&runner,
|
||||
®istry,
|
||||
parallel_instruments,
|
||||
)
|
||||
.map_err(|f| exec_fault_prose(&f))?;
|
||||
|
||||
|
||||
@@ -137,6 +137,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
format!("pipeline[{stage}]: walk_forward {field} must be > 0")
|
||||
}
|
||||
DocFault::EmptyInstruments => "data.instruments is empty".into(),
|
||||
DocFault::DuplicateInstrument { index, instrument } => {
|
||||
format!("data.instruments[{index}]: \"{instrument}\" is listed more than once")
|
||||
}
|
||||
DocFault::NoWindow => "data.windows is empty".into(),
|
||||
DocFault::BadWindow { index } => {
|
||||
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
||||
@@ -313,7 +316,13 @@ pub enum CampaignSub {
|
||||
Register { file: PathBuf },
|
||||
/// Execute a stored campaign into a realized run-set (a .json file is
|
||||
/// register-then-run sugar; the canonical address is the content id).
|
||||
Run { target: String },
|
||||
Run {
|
||||
target: String,
|
||||
/// Bound on distinct instruments resident in parallel (the RAM
|
||||
/// lever; 1 reproduces the sequential loop's footprint).
|
||||
#[arg(long, default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS)]
|
||||
parallel_instruments: std::num::NonZeroUsize,
|
||||
},
|
||||
/// List stored campaign realizations, or dump one campaign's records
|
||||
/// (the bare store lines, not the run-emit wrapper).
|
||||
Runs { campaign: Option<String>, run: Option<usize> },
|
||||
@@ -420,8 +429,12 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
||||
/// via `exit_on_campaign_result`), so it is pulled out of the unified
|
||||
/// `Result<(), String>` match the other four subcommands still share.
|
||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
if let CampaignSub::Run { target } = &cmd.sub {
|
||||
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(target, env));
|
||||
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
|
||||
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
|
||||
target,
|
||||
env,
|
||||
*parallel_instruments,
|
||||
));
|
||||
return;
|
||||
}
|
||||
let result = match &cmd.sub {
|
||||
|
||||
@@ -345,7 +345,12 @@ pub(crate) fn run_sweep_sugar(
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated(®, &generated)?;
|
||||
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
|
||||
crate::campaign_run::run_campaign_by_id(
|
||||
&campaign_id,
|
||||
env,
|
||||
crate::campaign_run::RunPresentation::MemberLinesOnly,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the `CrossInstrument` family members from an executed generalize
|
||||
@@ -386,7 +391,11 @@ pub(crate) fn run_generalize_sugar(
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_g(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
// Reprint the verb's aggregate line from the recorded cross-instrument
|
||||
// grade — byte-identical to the retired welded path's `generalize_json(&agg)`.
|
||||
@@ -508,7 +517,11 @@ pub(crate) fn run_walkforward_sugar(
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_wf(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
eprintln!("aura: walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||
@@ -668,7 +681,11 @@ pub(crate) fn run_mc_sugar(
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
eprintln!("aura: mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||
|
||||
@@ -449,6 +449,31 @@ fn campaign_validate_refuses_empty_axis_prose_exit_1() {
|
||||
assert!(!out.contains("EmptyAxis"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// Property (#277 iteration, aura-research's `validate_campaign`): a campaign
|
||||
/// whose `data.instruments` names the same instrument twice is refused at the
|
||||
/// intrinsic validate tier, naming the repeated entry's index and value — not
|
||||
/// silently accepted into a cell loop that would then double-run and
|
||||
/// double-persist that instrument's cells under two "distinct" cell indices.
|
||||
/// Data-free (no project needed): the intrinsic tier runs before any
|
||||
/// referential or executor check. Exercised end to end through the built
|
||||
/// binary (`aura campaign validate`), not just `validate_campaign` in
|
||||
/// isolation, so a regression that dropped the CLI-side wiring of the new
|
||||
/// `DocFault` variant into `doc_fault_prose` would also be caught here.
|
||||
#[test]
|
||||
fn campaign_validate_refuses_duplicate_instrument_prose_exit_1() {
|
||||
let dir = temp_cwd("campaign-validate-duplicate-instrument");
|
||||
let bad =
|
||||
CAMPAIGN_DOC.replacen(r#""instruments": ["GER40"]"#, r#""instruments": ["GER40", "GER40"]"#, 1);
|
||||
write_doc(&dir, "bad.campaign.json", &bad);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains(r#"data.instruments[1]: "GER40" is listed more than once"#),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
assert!(!out.contains("DuplicateInstrument"), "Debug leak: {out}");
|
||||
}
|
||||
|
||||
/// #210 risk-regime axis: a campaign document carrying a `risk` section
|
||||
/// round-trips through the CLI's actual `campaign validate` binary path
|
||||
/// (parse -> `validate_campaign`) without a fault. Before this iteration the
|
||||
@@ -1632,6 +1657,76 @@ fn campaign_run_synthetic_e2e_grid_then_wf_persists_no_sweep_family() {
|
||||
assert!(!stages[1]["family_id"].is_null(), "std::walk_forward persists its family: {stages}");
|
||||
}
|
||||
|
||||
/// Property (#277: the flatten/group/chunk cell-loop rework): the persisted
|
||||
/// `campaign_run.cells` order is DOCUMENT order (the `data.instruments`
|
||||
/// declaration order) regardless of `--parallel-instruments`. The executor
|
||||
/// now groups planned cells by instrument ordinal and walks sequential
|
||||
/// chunks of K instrument-groups (K = `--parallel-instruments`) so at most K
|
||||
/// instruments are ever resident at once, but that is purely an EXECUTION
|
||||
/// schedule — the final `cells` vector is rebuilt by re-zipping each
|
||||
/// execution slot back onto its original document-order index. A regression
|
||||
/// that let the chunked/grouped execution order leak into the persisted
|
||||
/// order (or that made the answer depend on K) would silently break every
|
||||
/// downstream consumer keyed by cell index: nominee grouping
|
||||
/// (`(strategy_ordinal, window_ordinal, regime_ordinal)` -> per-instrument
|
||||
/// nominees), `reproduce`, and any UI rendering cells positionally. The
|
||||
/// fixture deliberately lists instruments in REVERSE alphabetical order
|
||||
/// (`SYMB` before `SYMA`) so document order and instrument-ordinal-ascending
|
||||
/// order coincide (they always do, by construction) while a naive
|
||||
/// "output = the order cells finished running" bug would still be caught by
|
||||
/// asserting the concrete SYMB-before-SYMA position. K=1 (each instrument its
|
||||
/// own chunk, fully sequential residency) and K=2 (both instruments resident
|
||||
/// together) both must produce the identical cells[0]=SYMB, cells[1]=SYMA
|
||||
/// order. Hostless: runs over the synthetic SYMA+SYMB archive.
|
||||
#[test]
|
||||
fn campaign_run_synthetic_e2e_cell_order_is_document_order_under_parallel_instruments() {
|
||||
let (dir, _fixture) = fresh_project_with_data();
|
||||
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("cellorder.process.json")),
|
||||
ScratchPath::File(dir.join("cellorder.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "campaign-run-cellorder-seed");
|
||||
let proc_id = register_process_doc(&dir, "cellorder.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
// The window lies inside both SYMA's (2024-01..08) and SYMB's (2024-03..06)
|
||||
// synthetic span — the grid-then-wf sibling test's window.
|
||||
let doc = campaign_doc_json_two(
|
||||
("SYMB", "SYMA"),
|
||||
"",
|
||||
&bp_id,
|
||||
&proc_id,
|
||||
(1709251200000, 1719791999999),
|
||||
);
|
||||
write_doc(&dir, "cellorder.campaign.json", &doc);
|
||||
|
||||
for k in ["1", "2"] {
|
||||
let (out, code) = run_code_in(
|
||||
&dir,
|
||||
&["campaign", "run", "cellorder.campaign.json", "--parallel-instruments", k],
|
||||
);
|
||||
assert_eq!(code, Some(0), "K={k}: campaign run failed: {out}");
|
||||
let line = out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||||
.expect("the always-on final campaign_run line");
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("record parses");
|
||||
let cells = v["campaign_run"]["cells"].as_array().expect("cells array");
|
||||
assert_eq!(cells.len(), 2, "K={k}: one cell per instrument: {line}");
|
||||
assert_eq!(
|
||||
cells[0]["instrument"].as_str(),
|
||||
Some("SYMB"),
|
||||
"K={k}: cell 0 is the FIRST document-order instrument: {line}"
|
||||
);
|
||||
assert_eq!(
|
||||
cells[1]["instrument"].as_str(),
|
||||
Some("SYMA"),
|
||||
"K={k}: cell 1 is the SECOND document-order instrument: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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