diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 4d72291..1c69c9f 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -237,6 +237,21 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe StageBlock::Generalize { .. } => "std::generalize", } } + // Duplicate instruments enumerate identical cells whose registry family + // names collide (the name embeds the raw instrument string), racing one + // name's run-index assignment under the parallel cell loop — refused here + // as defense in depth beside the CLI's validate tier (#277 audit tidy). + let mut seen_instruments = std::collections::BTreeSet::new(); + for instrument in &campaign.data.instruments { + if !seen_instruments.insert(instrument.as_str()) { + return Err(ExecFault::PipelineShape { + detail: format!( + "campaign.data.instruments lists \"{instrument}\" more than once — \ + duplicate instruments collide on registry family names" + ), + }); + } + } // shape: `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)? // (std::generalize)?` — a monotone rank walk over adjacent pairs captures // every violation: a rank drop is an out-of-order stage (an annotator diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index 9831606..19617eb 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -1177,3 +1177,25 @@ fn member_faults_stay_contained_in_the_parallel_loop() { assert!(out.record.cells.iter().all(|c| c.fault.is_some())); assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1); } + +/// Defense in depth (audit tidy, #277): `execute` itself refuses duplicate +/// instruments via `preflight`, independent of the CLI's validate tier — a +/// direct caller skipping `validate_campaign` must not reach the cell loop +/// with colliding registry family names. +#[test] +fn execute_refuses_duplicate_instruments() { + let reg = temp_registry("duplicate_instruments"); + let doc = campaign(&["AAA", "AAA"]); + let proc_doc = process(vec![sweep_stage(false)]); + let err = execute( + CAMPAIGN_ID, + &doc, + &proc_doc, + &strategies(), + &FakeRunner::clean(), + ®, + DEFAULT_PARALLEL_INSTRUMENTS, + ) + .expect_err("duplicate instruments collide on family names"); + assert!(matches!(err, ExecFault::PipelineShape { .. })); +} diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 05d5284..75978e0 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -4075,3 +4075,12 @@ fn campaign_over_a_gapped_archive_records_the_uncovered_cell_and_continues() { "the campaign-run summary names the one failed cell: {out}" ); } + +/// Acceptance pin (#277): `--parallel-instruments` rejects 0 at parse time — +/// the NonZeroUsize carrier makes a zero residency bound unrepresentable, so +/// clap refuses it as a usage error (exit 2) before any target resolution. +#[test] +fn campaign_run_rejects_a_zero_parallel_instruments_bound() { + let (out, code) = run_code(&["campaign", "run", "unused", "--parallel-instruments", "0"]); + assert_eq!(code, Some(2), "clap usage error expected, got: {out}"); +} diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 501a28e..b543f81 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -125,6 +125,25 @@ same cell re-run under `aura generalize`) may differ by floating-point reassocia operation order (IEEE-754 non-associativity). This is not a C1 violation: C1 governs the determinism of a single run, not the cross-command bit-identity of a re-derived statistic. +**Realization note (2026-07-16, #277).** Cross-sim parallelism now also spans +campaign cells: the executor flattens the cell matrix, groups it by instrument +ordinal, and walks sequential chunks of K instrument groups +(`--parallel-instruments`, default 4 — a structural bound on distinct resident +instruments, the RAM lever for the external data-server's per-reference file +retention); within a chunk, cells run concurrently on the process-global rayon +pool shared with the member/window fan-out. Results are collected into +document-order slots, so outputs stay byte-identical across worker counts and +bounds. Two deliberate scheduling-dependent carve-outs, both outside this +contract's per-run bit-identity (which governs successful runs): on the +run-fatal path (non-containable faults, e.g. a dead registry store) the +propagated fault is the lowest document-order fault among the cells that +completed before the abort flag latched, and the set of per-cell family lines +already written by then is scheduling-dependent — inert, because no +campaign-run record is written on that path and store reads are name-keyed, +never line-ordered. Duplicate campaign instruments are refused at both the +validate tier and the executor's preflight: the per-cell family name embeds +the raw instrument string, so uniqueness is what keeps concurrent appends from +racing one name's run-index assignment. **Forbids.** Concurrency *within* a single sim; any nondeterministic input that is not captured as an explicit input (see C11, C12). **Why.** Real money rides on backtest results; reproducibility and an audit