Generalized per-cell fault isolation: a failing campaign cell is a recorded outcome, never a global abort #272
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
One cell's failure aborts the whole campaign: the execute loop in
aura-campaignpropagates anyMemberFaultglobally (run_members(cell, ...)?, crates/aura-campaign/src/exec.rs). Concrete incident (recorded on #264): a 22-instrument campaign aborted ~6.7 minutes in onno data for instrument Copper in window [...], discarding ~36 already-computed cells. The same global abort fires for ANY member fault — archive data gap, network/source failure yielding no data, bind failure, a genuine panic — not just gaps. What is missing is generalized cell-fault handling, not a gap-specific guard.Decision (user, 2026-07-14)
The user weighed three directions — (1) a coverage preflight that refuses the run, (2) run to completion and report compromised results, (3) make the run "cope" with empty windows — and decided for (2), generalized:
Behaviour sketch (for the spec)
ok/failed { fault }— no-data is one fault kind among others, not a special path. A panic at the member boundary is contained: the cell records it, the campaign continues.Copper: 7/10 folds, 3 failed (no data)) — never silently pool unequal member sets.Non-goals
run. The inventory verbs from #264 (aura data list/aura data coverage) remain the authoring-time tool and are unaffected.Implementation-fork decisions (derived) — realizing the decided direction (2)
The decided direction (a failing cell is a recorded outcome, never a global abort) leaves several implementation forks; they resolve as follows. Basis for each: derived, rationale inline.
window_faultson the stage realization), the surviving folds pool, and the summary names the ratio ("7/10 folds, 3 failed (no data)" — the issue's own example). A cell fails only when a stage can deliver nothing at all (sweep fault, or zero surviving folds).ExecFault::MemberandExecFault::Windowbecome per-cell recorded outcomes; everything else stays a campaign-wide refusal. Rationale: member/window faults are data-and-cell-local by construction, while the doc-shape faults fire in preflight before any cell runs and aRegistryfault means persistence itself is broken — recording a cell outcome into a broken registry is self-contradictory.CellRealization.fault: Option<CellFault>,StageRealization.window_faults, both skip-serialized when absent) with a small closedCellFaultKindenum (no_data | bind | run | panic | window) plus prose detail. Rationale: existingcampaign_runs.jsonllines stay readable unchanged (serde defaults), the kind tag is a closed vocabulary aggregations can count on, and prose carries the specifics — no record-format fork, no migration.catch_unwind(withAssertUnwindSafe) inside the member-run closures, recorded as fault kindpanic. Rationale: the sweep dispatches members on scoped worker threads whose join re-raises panics intoexecute(today: whole-process abort, exit 101), so the only place a panic is still attributable to its member is inside the closure; the runner is re-used across cells afterwards, whichAssertUnwindSafemakes an explicit, documented judgement (the CLI runner holds read-only state).&&-chained scripts (rules out 0), yet it is materially different from a refusal — the run record and every healthy cell's evidence exist and are usable (rules out 1); the zero-survivor precedent ("a null result is a valid result", exit 0) does not apply because a fault is not a null result. This holds even when every cell failed: the run ran and persisted its record.missing), with the fault distinguishable via the cell record; the single-cell walkforward/mc sugar verbs surface the recorded fault as their own message and exit 3 (their one cell failing leaves nothing to summarize); the generalize sugar's "every cell must nominate" debug assertion is retired (it was only ever true in a fault-free world).MemberRunnergains a default-Nonewindow_coveragehook the executor calls once per cell; the CLI runner derives effective bounds and in-window gap months from the existingarchive_extent/list_m1_monthsprimitives) persisted as an optional field on the cell realization. Rationale: coverage is a property of (instrument, window) — one lookup per cell, not per member — and the #264 primitives already compute exactly the needed facts; a default-Nonehook leaves every synthetic runner untouched.Consequences confirmed against the tree before speccing:
executealready accumulates all cell outcomes and appends the one campaign-run record after the loop, so containment localizes torun_cell/run_members/run_walk_forward_stagereturning fault-annotated outcomes instead ofErr; the generalize stage already declares holes (winners/missingpartition) and gate-emptied truncation is the in-place precedent for "record and continue". The current global-abort behaviour is pinned byexecute_member_fault_aborts_with_lowest_index(flips to record-and-continue), the two wf fault tests, and a family of ~15 CLI e2e assertions (exit 1 + "no data …" prose) that move to the new exit-3 register where the run now completes.Mechanism refinements settled before planning (derived)
Plan-recon surfaced three mechanism facts the spec's decided direction implies but did not spell out; recorded here so the implementing plan is unambiguous. Basis for each: derived.
ExecFault::Registryfromoptimize/optimize_deflated/optimize_plateau), and OOS member faults. Containment partitions at drain time: aRegistryfault aborts the whole run (persistence/optimizer machinery is broken — it stays global, exactly asRegistryis excluded fromcontain), whileMember/Windowfaults become per-foldWindowFaults. The surviving folds pool; zero surviving folds fail the cell.placeholder_window_run, whose report carriesmanifest.broker == "faulted-member-placeholder". The stage filters placeholders out ofresultbeforewalkforward_member_reports, so the persistedWalkForwardfamily holds only real OOS reports and the pooled series / MC bootstrap operate on survivors; the filtered-out count is thewindow_faultslen named in the summary ratio.present_campaignand the walkforward/mc/generalize sugar runners return it), and every dispatch site maps "≥1 failed cell" to exit 3 — includingrun_sweep_sugar(which sharespresent_campaign): any campaign that completes with a failed cell exits 3, whether run viaaura campaign runor a dissolved verb. The othercampaignsub-verbs (validate/introspect/register/runs) keep 0/1. One consequence pinned for the plan:campaign_run_valid_tap_reaches_the_member_data_seamrequestspersist_taps: ["equity"]; under containment the run now reaches the trace tail and its no-nominee branch prints "no nominee; no traces persisted", so that test moves wholesale to the exit-3 register (its!contains("traces persisted")negation is replaced, not merely re-signed).