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:
2026-07-16 14:19:31 +02:00
parent 98ddcd3595
commit 7fa3ef4e26
8 changed files with 325 additions and 67 deletions
+95
View File
@@ -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