feat(campaign,registry,cli): per-cell fault isolation — a failed cell is recorded, never a global abort

closes #272

A member fault (no-data, bind, run, or a caught panic) is now a recorded
per-cell outcome instead of aborting the whole campaign and discarding every
already-computed cell. The incident that motivated this (a 22-instrument
campaign lost ~36 healthy cells ~6.7 min in because Copper had an archive gap)
now completes: the healthy cells persist, the gap cell is recorded as failed,
and the run exits 3.

Direction (owner decision 2026-07-14): run to completion and report
compromised results; no coverage preflight, no window synthesis.

Containment granularity:
- The CELL for a sweep-stage member fault (a grid hole structurally
  compromises winner selection, so the whole cell fails).
- The FOLD for a walk_forward member fault (independent time windows): the
  surviving folds pool into the family, failed folds are recorded as
  StageRealization.window_faults, and the summary names the ratio.

- aura-registry: additive CellFault / CellFaultKind (closed:
  no_data|bind|run|panic|window) / WindowFault / CellCoverage, plus
  fault/coverage fields on CellRealization and window_faults on
  StageRealization — all serde-default-skipped, so pre-#272 campaign_runs
  lines parse and round-trip byte-identical.
- aura-campaign: run_cell returns a fault-annotated CellRealization instead of
  Err (execute's accumulate-then-append-once tail is unchanged and now
  persists every healthy cell + the one run record); a `contain` split keeps
  ExecFault::Registry and doc-shape preflight faults global while Member/Window
  become per-cell/per-fold. Member panics are caught with
  catch_unwind(AssertUnwindSafe) at all three member-run sites (sweep IS/OOS)
  and recorded as MemberFault::Panic — a member panic no longer aborts the
  process. The wf stage partitions Registry faults (global) from Member/Window
  (per-fold) and filters faulted-fold placeholders (the
  "faulted-member-placeholder" broker sentinel) out of the persisted family.
- aura-cli: exec_fault_prose gains the Panic arm; CliMemberRunner::window_coverage
  derives effective bounds + interior gap months from the #264 archive
  primitives; present_campaign prints per-cell failure notes + a completion
  summary and threads the failed-cell count; a run with >=1 failed cell exits 3
  ("completed with failed cells") uniformly across `aura campaign run` and the
  dissolved sweep/walkforward/mc/generalize verbs (exit_on_campaign_result).
  Usage stays 2, refused-before-running stays 1, clean stays 0.

Tests: the global-abort pins flip to containment (execute + the two wf fault
tests → fold-containment + all-folds-fail-the-cell); new panic-containment
tests on both the sweep path (PanicRunner) and the wf path (this commit adds
the wf mirror the loop left uncovered); a new gapped-archive e2e (one covered
cell + one gap cell → exit 3); the ~14 CLI exit-1 pins move to the exit-3
register; a pre-#272-line byte-identical round-trip guard.

Suite: cargo test --workspace green (1309 tests, 0 failed); clippy clean.
Decision log: #272 comments (fork rationale, the fold Registry/Member split,
the placeholder sentinel, uniform exit-3).

Follow-up (minor, not blocking): the plan under-scoped Task 1 to aura-registry
though the additive fields also touch aura-campaign's exec.rs literals — the
loop absorbed it mechanically; a future plan for a cross-crate additive-field
change should scope every crate's construction sites in the first task.
This commit is contained in:
2026-07-14 16:51:31 +02:00
parent ea4e79d73f
commit d3b1a1aead
14 changed files with 1042 additions and 211 deletions
+125 -15
View File
@@ -149,16 +149,7 @@ pub(crate) fn exec_fault_prose(f: &ExecFault) -> String {
"process stage {stage}: walk_forward windows do not fit the campaign \
window: {detail}"
),
ExecFault::Member(MemberFault::NoData { instrument, window_ms }) => format!(
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
window_ms.0, window_ms.1
),
ExecFault::Member(MemberFault::Bind(detail)) => {
format!("a member failed to bind: {detail}")
}
ExecFault::Member(MemberFault::Run(detail)) => {
format!("a member failed to run: {detail}")
}
ExecFault::Member(m) => aura_campaign::member_fault_prose(m),
ExecFault::Registry(e) => e.to_string(),
}
}
@@ -412,6 +403,62 @@ impl MemberRunner for CliMemberRunner<'_> {
report.manifest.instrument = Some(cell.instrument.clone());
Ok(report)
}
/// #272: derive the cell's archive coverage from the #264 primitives —
/// `None` when the archive has no file for the instrument at all (the
/// `NoData` member fault's job, not coverage) or when the effective
/// evaluated span covers the requested window exactly with no interior
/// gap month. One call per cell (not per member): coverage is a property
/// of the cell's (instrument, window), never a swept param point.
fn window_coverage(&self, cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
let data_path = PathBuf::from(self.env.data_path());
let months = aura_ingest::list_m1_months(&data_path, &cell.instrument);
if months.is_empty() {
return None; // no archive at all is the NoData fault's job, not coverage
}
let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent(
&self.server,
&data_path,
&cell.instrument,
Some(cell.window_ms.0),
Some(cell.window_ms.1),
)?;
let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts);
let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts);
let gap_months = interior_gap_months(&months, cell.window_ms);
if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() {
return None; // fully covered — nothing to annotate
}
Some(aura_registry::CellCoverage {
effective_from_ms: eff_from,
effective_to_ms: eff_to,
gap_months,
})
}
}
/// The whole `(year, month)`s missing INSIDE `months`' own span (mirrors the
/// interior-gap walk `data_coverage_report`, main.rs, performs over a full
/// archive listing) that also fall inside `window_ms` (Unix-ms) — one
/// `"YYYY-MM"` entry per missing month, never a collapsed range: the
/// registry's `CellCoverage::gap_months` is a flat list an aggregate counts
/// directly. `months` is assumed sorted ([`aura_ingest::list_m1_months`]'s own
/// contract).
fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
let mut out = Vec::new();
for pair in months.windows(2) {
let (prev, next) = (pair[0], pair[1]);
let mut ym = crate::next_year_month(prev);
while ym != next {
if ym >= from_ym && ym <= to_ym {
out.push(crate::fmt_year_month(ym));
}
ym = crate::next_year_month(ym);
}
}
out
}
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
@@ -428,8 +475,10 @@ pub(crate) enum RunPresentation {
}
/// `aura campaign run <target>`: resolve, gate, execute, emit. Every `Err`
/// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1.
pub(crate) fn run_campaign(target: &str, env: &Env) -> Result<(), String> {
/// 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> {
// Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project.
if env.provenance().is_none() {
@@ -491,7 +540,7 @@ pub(crate) fn run_campaign_by_id(
campaign_id: &str,
env: &Env,
presentation: RunPresentation,
) -> Result<(), String> {
) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env)?;
present_campaign(run, presentation, env)
}
@@ -600,7 +649,7 @@ fn present_campaign(
run: CampaignRun,
presentation: RunPresentation,
env: &Env,
) -> Result<(), String> {
) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
@@ -621,6 +670,22 @@ fn present_campaign(
}
}
// #272: failed-cell notes (the gate-empty idiom's sibling) — a failed
// cell never aborts the campaign; its fault is recorded and the run
// continues over the remaining cells.
let mut failed = 0usize;
let mut fail_labels: Vec<String> = Vec::new();
for cell in &outcome.record.cells {
if let Some(f) = &cell.fault {
failed += 1;
fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind)));
eprintln!(
"aura: cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail
);
}
}
// Emission: emit-gated family/selection lines per cell, then the
// always-on final record line. Matched by NAME against the two closed-
// vocabulary terms (self-evident at the call site, unlike positional
@@ -668,6 +733,16 @@ fn present_campaign(
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
}
// Trace persistence runs LAST, after the always-on record line (0109):
@@ -685,7 +760,22 @@ fn present_campaign(
env,
)?;
}
Ok(())
Ok(failed)
}
/// #272: the fault-kind label the failed-cell summary line names — the same
/// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl`
/// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug
/// frame of the enum variant).
fn cell_fault_kind_label(kind: aura_registry::CellFaultKind) -> &'static str {
use aura_registry::CellFaultKind as K;
match kind {
K::NoData => "no_data",
K::Bind => "bind",
K::Run => "run",
K::Panic => "panic",
K::Window => "window",
}
}
/// Which drained wrap-convention channel a requested tap persists from on a
@@ -1466,4 +1556,24 @@ mod tests {
dedup.dedup();
assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}");
}
#[test]
/// #272: `interior_gap_months` names a whole calendar month missing
/// BETWEEN two present archive months (the Copper failure shape) when the
/// requested window spans across it — not just when the window's own
/// bounds fall short of full coverage. GAPSYM-shaped input (files at
/// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June
/// window must name exactly the two missing interior months.
fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() {
let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)];
// 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every
// present month plus the March/April hole.
let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64);
let gaps = interior_gap_months(&months, window_ms);
assert_eq!(
gaps,
vec!["2024-03".to_string(), "2024-04".to_string()],
"both interior gap months must be named, in order"
);
}
}
+24 -22
View File
@@ -2714,8 +2714,8 @@ fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
// selects the loaded-blueprint branch. There is no second built-in grammar anymore
// (#159 demo retirement / #220 axis generalization): without a blueprint the
// dispatcher prints a usage error and exits 2; a blueprint with `--real` routes to
// the campaign path. Usage errors (clap parse + argv-applicability guards) exit 2;
// runtime failures exit 1.
// the campaign path. Usage errors exit 2; runtime refusals exit 1; a run that
// completes with ≥1 failed cell exits 3 (#272); a clean run exits 0.
/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`,
/// falling back to `"unknown"`): both `--version` (via `version_string`) and
@@ -3426,6 +3426,20 @@ fn exit_axis_register_error(e: AxisRegisterError) -> ! {
}
}
/// Terminate per a campaign-path result (#272): a String error is a refusal
/// (exit 1); an Ok carrying the failed-cell count exits 3 when any cell failed
/// ("completed with failed cells"), else returns cleanly (exit 0).
pub(crate) fn exit_on_campaign_result(r: Result<usize, String>) {
match r {
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(1);
}
Ok(0) => {}
Ok(_) => std::process::exit(3),
}
}
/// The shared campaign window in Unix-ms, clipped to the archive. `full_window`
/// probes the ARCHIVE's actual first/last bar in range (never the literal ms
/// request) and returns aura's native epoch-ns `Timestamp` (the ms->ns crossing
@@ -3669,10 +3683,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
// sweep + walkforward only); trace-writing is out of scope here.
trace: false,
};
verb_sugar::run_generalize_sugar(&inv, &metric, env).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env));
}
fn dispatch_runs(a: RunsCmd, env: &project::Env) {
@@ -3965,10 +3976,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) {
// `name_persist`'s bool (true iff `--trace` was given).
trace: persist,
};
verb_sugar::run_sweep_sugar(&inv, env).unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
exit_on_campaign_result(verb_sugar::run_sweep_sugar(&inv, env));
}
DataChoice::Synthetic => {
// The synthetic in-process family path is still reduce-only
@@ -4069,7 +4077,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
// (unchanged since 0109 — walkforward always nominates).
trace,
};
verb_sugar::run_walkforward_sugar(
let result = verb_sugar::run_walkforward_sugar(
&inv,
WINNER_SELECTION_METRIC,
verb_sugar::WfWindows {
@@ -4079,11 +4087,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) {
},
select,
env,
)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
);
exit_on_campaign_result(result);
return;
}
// Synthetic in-process family path — unchanged (#220 non-goal); it
@@ -4184,7 +4189,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
// is out of #224's scope here.
trace: false,
};
verb_sugar::run_mc_sugar(
let result = verb_sugar::run_mc_sugar(
&inv,
WINNER_SELECTION_METRIC,
verb_sugar::WfWindows {
@@ -4194,11 +4199,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) {
},
verb_sugar::McKnobs { resamples, block_len, seed },
env,
)
.unwrap_or_else(|m| {
eprintln!("aura: {m}");
std::process::exit(1);
});
);
exit_on_campaign_result(result);
return;
}
// Synthetic seed family (unchanged, #220 non-goal): closed
+8 -1
View File
@@ -416,7 +416,14 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(())
}
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// 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));
return;
}
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
@@ -424,8 +431,8 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
introspect_campaign(i)
}
CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
};
if let Err(m) = result {
eprintln!("aura: {m}");
+111 -23
View File
@@ -333,11 +333,13 @@ fn validate_before_register(
}
/// Run one dissolved sweep invocation end-to-end: register the generated
/// documents, then execute through the one campaign path in sugar mode.
/// documents, then execute through the one campaign path in sugar mode. The
/// `Ok` carries the failed-cell count (#272), propagated verbatim from the
/// one campaign executor.
pub(crate) fn run_sweep_sugar(
inv: &SugarInvocation,
env: &crate::project::Env,
) -> Result<(), String> {
) -> Result<usize, String> {
let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
@@ -349,37 +351,36 @@ pub(crate) fn run_sweep_sugar(
/// Build the `CrossInstrument` family members from an executed generalize
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
/// the same order + shape the inline `run_generalize` built its `members` in,
/// each already carrying `manifest.instrument`. Debug-asserts one member per
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
/// a cell silently missing its nominee here would be a campaign-executor
/// regression, not a legitimate generalize outcome, and the filter_map below
/// must not swallow it unnoticed.
/// each already carrying `manifest.instrument`. #272: a failed cell nominates
/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
/// outcome (the caller counts and surfaces the failed cells separately, via
/// `outcome.record.cells[].fault`) — no longer an invariant this function
/// itself enforces.
fn cross_instrument_members(
outcome: &aura_campaign::CampaignOutcome,
) -> Vec<aura_engine::RunReport> {
let members: Vec<aura_engine::RunReport> = outcome
outcome
.cells
.iter()
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
.collect();
debug_assert_eq!(
members.len(),
outcome.cells.len(),
"generalize has no gate stage — every cell must nominate"
);
members
.collect()
}
/// Run one dissolved `generalize` invocation end-to-end: register the generated
/// documents, run through the one campaign path, then reprint today's exact
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
/// retired welded path (the committed exact-grade anchor is the gate).
/// retired welded path (the committed exact-grade anchor is the gate). The
/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing
/// (the existing no-nominee -> `missing` path already handles it), counted
/// from the recorded cells' own `fault` field rather than from the missing
/// nominee, so the count is right even when some OTHER member of the cell's
/// pipeline is what actually failed.
pub(crate) fn run_generalize_sugar(
inv: &SugarInvocation,
metric: &str,
env: &crate::project::Env,
) -> Result<(), String> {
) -> Result<usize, String> {
let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
@@ -403,7 +404,8 @@ pub(crate) fn run_generalize_sugar(
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
.map_err(|e| e.to_string())?;
println!("{{\"family_id\":\"{family_id}\"}}");
Ok(())
let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count();
Ok(failed)
}
/// The two generated documents of one dissolved `walkforward` invocation.
@@ -489,14 +491,18 @@ pub(crate) fn register_generated_wf(
/// documents, run through the one campaign path, then reprint the per-window member
/// lines + the summary line from the recorded `WalkForward` family. The stdout
/// summary line is byte-identical to the retired welded path (the committed
/// exact-grade anchor is the gate).
/// exact-grade anchor is the gate). #272: the invocation's one cell either
/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an
/// error, since a completed-with-failed-cell run is the campaign path's own
/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap"
/// refusal the retired welded path's `.ok_or` would have raised.
pub(crate) fn run_walkforward_sugar(
inv: &SugarInvocation,
metric: &str,
w: WfWindows,
select: SelectRule,
env: &crate::project::Env,
) -> Result<(), String> {
) -> Result<usize, String> {
let generated = translate_walkforward(inv, metric, w, select)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
@@ -504,6 +510,11 @@ pub(crate) fn run_walkforward_sugar(
let (_process_id, campaign_id) = register_generated_wf(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
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);
return Ok(1);
}
// The single cell's walk_forward StageFamily.
let wf = run
.outcome
@@ -549,7 +560,7 @@ pub(crate) fn run_walkforward_sugar(
env,
)?;
}
Ok(())
Ok(0)
}
/// The two generated documents of one dissolved `mc` invocation.
@@ -642,13 +653,16 @@ pub(crate) fn register_generated_mc(
/// byte-identical to the retired welded path (the committed exact-grade anchor is
/// the gate). mc prints ONLY the one summary line — no per-window member lines
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
/// #272: the invocation's one cell either completed (`Ok(0)`) or failed
/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s
/// twin note).
pub(crate) fn run_mc_sugar(
inv: &SugarInvocation,
metric: &str,
w: WfWindows,
mc: McKnobs,
env: &crate::project::Env,
) -> Result<(), String> {
) -> Result<usize, String> {
let generated = translate_mc(inv, metric, w, mc)?;
let probe_params = probe_params_from(inv.axes);
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
@@ -656,6 +670,11 @@ pub(crate) fn run_mc_sugar(
let (_process_id, campaign_id) = register_generated_mc(&reg, &generated)?;
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
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);
return Ok(1);
}
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
let boot = run
@@ -670,7 +689,7 @@ pub(crate) fn run_mc_sugar(
})
.ok_or("mc produced no pooled-OOS bootstrap")?;
println!("{}", crate::mc_r_bootstrap_json(boot));
Ok(())
Ok(0)
}
#[cfg(test)]
@@ -1140,4 +1159,73 @@ mod tests {
assert_eq!(stored_process, process_to_json(&generated.process));
let _ = std::fs::remove_dir_all(&dir);
}
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
/// members list by nothing but that stamp — the field
/// `cross_instrument_members` preserves verbatim from a nominating cell.
fn fake_report(instrument: &str) -> aura_engine::RunReport {
aura_engine::RunReport {
manifest: aura_engine::RunManifest {
commit: String::new(),
params: vec![],
defaults: vec![],
window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: Some(instrument.to_string()),
topology_hash: None,
project: None,
},
metrics: aura_engine::RunMetrics {
total_pips: 0.0,
max_drawdown: 0.0,
bias_sign_flips: 0,
r: None,
},
}
}
fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome {
aura_campaign::CellOutcome {
families: vec![],
selections: vec![],
nominee: nominee_instrument.map(|i| (vec![], fake_report(i))),
}
}
/// #272: `cross_instrument_members` skips a cell with no nominee (a
/// failed cell nominates nothing) rather than padding the members list or
/// aborting — the surviving cells' reports still reach the persisted
/// `CrossInstrument` family, in cell order, with the failed cell simply
/// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC
/// nominates) must yield exactly the two survivors' reports, in order.
#[test]
fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() {
let outcome = aura_campaign::CampaignOutcome {
record: aura_registry::CampaignRunRecord {
campaign: String::new(),
process: String::new(),
run: 0,
seed: 0,
cells: vec![],
generalizations: vec![],
trace_name: None,
},
run: 0,
cells: vec![
cell_outcome(Some("AAA")),
cell_outcome(None),
cell_outcome(Some("CCC")),
],
};
let members = cross_instrument_members(&outcome);
let instruments: Vec<Option<String>> =
members.iter().map(|r| r.manifest.instrument.clone()).collect();
assert_eq!(
instruments,
vec![Some("AAA".to_string()), Some("CCC".to_string())],
"the failed (no-nominee) cell is skipped, the two survivors keep their order"
);
}
}