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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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(®, &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(®, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user