Files
Aura/docs/specs/0107-campaign-executor.md
T
Brummel 12f48001af spec: 0107 campaign executor (boss-signed)
Cycle 0107, anchored on #198 (campaign executor; #196 blueprint on-ramp
rides along). Encodes the twelve decisions recorded on #198: eleven from
the fork triage (2026-07-03) plus the user-decided executor home (a new
library crate, 2026-07-03). Scope: aura-campaign library crate
(MemberRunner seam, execute, gate evaluation, preflight, realization
assembly), std::walk_forward vocabulary correction to machinery-true
fields, engine ListSpace, registry CampaignRunRecord + campaign_runs
store, CLI campaign-run verb + driver, #196 on-ramp verbs.

Boss-signed via grounding-check PASS (17/17 load-bearing assumptions
ratified against currently-green tests). One construction note carried
to the plan: a private Registry::blueprint_path already exists and is
promoted to public rather than duplicated.

refs #198, refs #196
2026-07-03 18:00:19 +02:00

514 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Campaign Executor — Design Spec (cycle 0107)
**Date:** 2026-07-03
**Status:** Draft — /boss autonomous run; sign-off gate = grounding-check PASS
**Authors:** orchestrator + Claude
**Reference issues:** #198 (executor, this cycle's anchor), #196 (blueprint on-ramp, rides along)
**Design record:** all eleven executor forks + the home fork are decided on #198
(fork-triage comment 2026-07-03 + user decision comment 2026-07-03); this spec
encodes those decisions and adds no new forks.
## Goal
`aura campaign run <file|content-id>` turns a persisted, content-addressed
campaign document into a realized run-set: it resolves the campaign's refs,
executes its process pipeline (v1: `std::sweep [std::gate]* [std::walk_forward]?`)
once per (strategy, instrument, window) cell over the existing family machinery,
evaluates gates per-member, records a campaign-level realization linking the
per-stage family records, and honours data-level presentation. The execution
semantics live in a **new library crate `aura-campaign`**; the CLI is one of its
consumers (#198 home decision: future consumers — playground, tests — reach the
same library).
## Architecture
Three layers, one new:
1. **`aura-campaign` (new leaf library crate)** — campaign *semantics*: cell
enumeration, preflight (v1 boundary, rankable metrics, gate-metric
resolvability), stage sequencing, per-member gate evaluation, winner
selection (via `aura-registry`'s `optimize`/`optimize_deflated`/
`optimize_plateau`), walk-forward rolling (via `aura-engine`'s
`WindowRoller`/`walk_forward`), realization assembly, and the registry
writes (`append_family` per stage, `append_campaign_run` at the end).
Deps: `aura-core`, `aura-engine`, `aura-analysis`, `aura-registry`,
`aura-research`, `serde`/`serde_json`. Deliberately **no** `aura-ingest`,
no `aura-std`/`aura-composites`: harness and data binding enter through a
one-method trait (`MemberRunner`), so the deploy-condemned CLI scaffolding
never becomes a library dep and a differently-binding consumer (playground)
implements the same seam.
2. **`aura-cli`** — the `MemberRunner` implementation over the shipped
loaded-blueprint convention (`wrap_r` + reduce-mode member runs +
`M1FieldSource` windowed real-data binding — the machinery
`run_blueprint_member`/`windowed_sources` already ship), the
`campaign run` verb, and the #196 on-ramp verbs.
3. **`aura-registry`** — the new `CampaignRunRecord` sibling store
(`campaign_runs.jsonl` beside `runs.jsonl`/`families.jsonl`), following the
registry's growth pattern: a thin linking record over untouched family
records (#198 decision 4).
Plus one corrective slice in **`aura-research`**: the `std::walk_forward`
vocabulary is corrected to machinery-true fields (#198 decision 2 — the shipped
`folds` slot maps to nothing `WindowRoller::new` accepts), and one small
addition in **`aura-engine`**: a `ListSpace` (explicit point set implementing
`Space`), because a gate produces an arbitrary member subset that the cartesian
`GridSpace` cannot represent.
Determinism (C1): cells run sequentially in declared doc order; *within* a
stage, members run disjointly in parallel via the engine `sweep` primitive
(parallelism across sims, never within one). Every stochastic sub-procedure
(deflation nulls) seeds from the doc's declared `seed`, so a campaign's
realization is a pure function of the document + stores + data.
## Concrete code shapes
### The user-facing program (the acceptance-criterion evidence)
The methodology designer / campaign designer writes two documents and runs
them. `methodology.json` — note the corrected `std::walk_forward` fields
(lengths in epoch-ms, the unit the campaign's own windows already use;
90/30/30 days shown):
```json
{
"format_version": 1,
"kind": "process",
"name": "screen-then-walkforward",
"pipeline": [
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax", "deflate": true },
{ "block": "std::gate", "all": [ { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 } ] },
{ "block": "std::walk_forward", "in_sample_ms": 7776000000, "out_of_sample_ms": 2592000000,
"step_ms": 2592000000, "mode": "rolling", "metric": "sqn_normalized", "select": "argmax" }
]
}
```
`campaign.json` (schema unchanged from cycle 0106):
```json
{
"format_version": 1,
"kind": "campaign",
"name": "sma-screen-eurusd",
"data": { "instruments": ["EURUSD"], "windows": [ { "from_ms": 1136073600000, "to_ms": 1154390400000 } ] },
"strategies": [ { "ref": { "content_id": "<blueprint-content-id>" },
"axes": { "sma_cross.fast.length": { "kind": "I64", "values": [2, 3, 4] },
"sma_cross.slow.length": { "kind": "I64", "values": [6, 9, 12] } } } ],
"process": { "ref": { "content_id": "<process-content-id>" } },
"seed": 7,
"presentation": { "persist_taps": [], "emit": ["selection_report"] }
}
```
The authoring loop, inside a built project:
```
$ aura graph register strategy.json # NEW (#196): blueprint into the store
registered blueprint content:3f9c… (runs/blueprints/3f9c….json)
$ aura graph introspect --params strategy.json # NEW (#196): the campaign-axis namespace
sma_cross.fast.length:I64
sma_cross.slow.length:I64
$ aura process register methodology.json
registered process content:aa12… (runs/processes/aa12….json)
$ aura campaign run campaign.json # file is sugar: registers, then runs by id
{"selection_report":{"family_id":"bb34aa55-0-EURUSD-w0-s0-0","stage":0,"block":"std::sweep","winner_ordinal":4,"params":[["sma_cross.fast.length",{"I64":3}],["sma_cross.slow.length",{"I64":9}]],"selection":{…FamilySelection…}}}
{"campaign_run":{"campaign":"bb34aa55…","process":"aa12…","run":0,"seed":7,"cells":[{"strategy":"3f9c…","instrument":"EURUSD","window_ms":[1136073600000,1154390400000],"stages":[{"block":"std::sweep","family_id":"bb34aa55-0-EURUSD-w0-s0-0","selection":{…}},{"block":"std::gate","survivor_ordinals":[0,3,4,7]},{"block":"std::walk_forward","family_id":"bb34aa55-0-EURUSD-w0-s2-0"}]}]}}
$ echo $?
0
```
Zero survivors at a gate: the cell's realization stops after the empty gate
stage, stderr notes it, the run continues to the next cell and **exits 0**
(#198 decision 8 — a null result is a valid research result). `emit`
gating: `family_table` prints the existing per-member
`{"family_id":…,"report":…}` lines for each family-producing stage;
`selection_report` prints one line per selection-bearing stage; the final
`{"campaign_run":…}` line always prints. Non-empty `persist_taps` prints
`aura: persist_taps not yet honored (N tap(s) ignored)` on stderr once per run
(#198 decision 6 — loud deferral, never silent).
### aura-research: the walk_forward vocabulary correction
Before (shipped 0106; `folds` maps to nothing the machinery accepts —
`WindowRoller::new(span, is_len, oos_len, step, mode)` has no fold count,
`crates/aura-engine/src/walkforward.rs:73`):
```rust
#[serde(rename = "std::walk_forward")]
WalkForward { folds: u32, in_sample_bars: u64, out_of_sample_bars: u64, metric: String, select: SelectRule },
```
After (machinery-true: the roller's three lengths in epoch-ms — the campaign
window's own unit; ms→ns happens once at the driver's source seam, the
established `open_window` pattern — plus the roller's mode, both `RollMode`
variants exposed):
```rust
#[serde(rename = "std::walk_forward")]
WalkForward { in_sample_ms: u64, out_of_sample_ms: u64, step_ms: u64, mode: WfMode, metric: String, select: SelectRule },
/// Wire form: "rolling" | "anchored" — the two shipped `RollMode`s.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum WfMode { #[serde(rename = "rolling")] Rolling, #[serde(rename = "anchored")] Anchored }
```
Co-moving sites (the schema-propagation set, `crates/aura-research/src/lib.rs`):
the `PROCESS_BLOCKS` `std::walk_forward` `SlotInfo` list (new slot kind label
for `mode`: reuse `SlotKind::SelectRule`? No — add `SlotKind::WfMode` with
label `"one of: rolling | anchored"`); the `stage_from_value` arm
(`require_u64` × 3 + a `mode` parser mirroring `select_from`); the
`validate_process` WalkForward arm gains machinery-true numeric checks —
`DocFault::ZeroWalkForwardLength { stage: usize, field: &'static str }` when
any of the three lengths is 0 (`WindowRoller::new` refuses `NonPositiveLength`;
the doc tier now refuses it earlier with a path-addressed fault); canonical
field order becomes `block, in_sample_ms, out_of_sample_ms, step_ms, mode,
metric, select`. Consequences accepted: every walk_forward-bearing process doc
gets a new content id; the golden canonical pin (`lib.rs:1039-1063`), the
`PROCESS_FIXTURE`/`PROCESS_DOC` test constants, and the open-slot tests move
with it. The tracked fieldtest-corpus store files under
`fieldtests/cycle-0106-research-artifacts/` are a historical record and are
**not** touched.
### aura-engine: ListSpace
A gate's survivor set is an arbitrary point subset — no cartesian structure —
so it needs a first-class `Space` beside `GridSpace`/`RandomSpace`:
```rust
/// Explicit point set over a param space. Points are validated against
/// `space` kinds at construction (same contract as GridSpace::new).
pub struct ListSpace { space: Vec<ParamSpec>, points: Vec<Vec<Scalar>> }
impl ListSpace {
pub fn new(space: &[ParamSpec], points: Vec<Vec<Scalar>>) -> Result<Self, SweepError>
// Arity per point == space.len(); kind per slot == spec.kind; empty point
// list is allowed (a legitimately-empty survivor set never reaches sweep —
// the executor early-terminates — but the type does not forbid it).
}
impl Space for ListSpace {
fn points(&self) -> Vec<Vec<Cell>> // Scalar→Cell lowering, declared kinds
fn param_specs(&self) -> &[ParamSpec]
}
```
`sweep(&list_space, run_one)` then gives the same disjoint parallelism and
enumeration-order determinism the grid path has.
### aura-campaign: the library
```rust
/// One structural cell: (strategy, instrument, window) — #198 decision 7.
pub struct CellSpec {
pub strategy_ordinal: usize,
pub strategy_id: String, // resolved blueprint content id
pub blueprint_json: String, // canonical bytes from the store
pub axes: BTreeMap<String, Axis>, // campaign axes (raw param_space names)
pub instrument: String,
pub window_ms: (i64, i64),
}
/// The harness/data binding seam — the ONLY thing a consumer implements.
/// Params arrive as (raw axis name, value) pairs; the implementation binds
/// them to its harness convention and runs the member over `instrument`
/// restricted to `window_ms` (inclusive epoch-ms).
pub trait MemberRunner: Sync {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64), // ⊆ cell.window_ms (walk-forward sub-windows)
) -> Result<RunReport, MemberFault>;
}
/// Display-free faults (the CLI phrases them — the RefFault pattern).
pub enum MemberFault { NoData { instrument: String, window_ms: (i64, i64) }, Bind(String), Run(String) }
pub enum ExecFault { // preflight + runtime refusals
UnsupportedStage { stage: usize, block: String }, // v1 boundary (mc/generalize)
PipelineShape { detail: String }, // not sweep [gate]* [wf]?
UnrankableMetric { stage: usize, metric: String }, // not in the registry's rankable set
GateMetricNotPerMember { stage: usize, metric: String },// annotation metrics (deflated_score…)
PlateauInWalkForward { stage: usize }, // survivor subsets have no lattice
DeflatePlateauConflict { stage: usize }, // sweep: deflate=true + select plateau:*
Member(MemberFault),
Registry(RegistryError),
}
/// Everything the run produced, for the consumer to render.
pub struct CampaignOutcome {
pub record: CampaignRunRecord, // what append_campaign_run stored
pub run: usize, // assigned counter
pub cells: Vec<CellOutcome>, // per-cell stage payloads for emit rendering
}
pub struct CellOutcome { pub families: Vec<StageFamily>, pub selections: Vec<StageSelectionOut> }
pub fn execute(
campaign: &CampaignDoc,
process: &ProcessDoc,
strategies: &[(String, String)], // (content id, canonical blueprint json), resolved by the caller
runner: &dyn MemberRunner,
registry: &Registry,
) -> Result<CampaignOutcome, ExecFault>
```
`execute` in order:
1. **Preflight** (all refusals before any member runs — the F7 lesson applied
forward): v1 pipeline shape is exactly `std::sweep (std::gate)* (std::walk_forward)?`
(subsumes #198 decision 5 — `std::monte_carlo`/`std::generalize` refuse with
prose naming the v1 boundary); every `metric` slot on sweep/walk_forward is
in the registry's rankable set (`total_pips`, `max_drawdown`,
`bias_sign_flips`, `sqn`, `sqn_normalized`, `expectancy_r`,
`net_expectancy_r``resolve_metric`'s roster; the doc tier's 17-name
vocabulary is wider by design and stays untouched); every gate predicate
metric is per-member-resolvable (the 14 `RMetrics`+`RunMetrics` scalars;
the three selection-annotation names refuse); walk_forward `select` is not
`plateau:*` (a plateau needs the grid lattice, which a gated survivor
subset no longer has); sweep `deflate: true` composes only with `argmax`.
2. **Cells**: for each strategy (doc order) × instrument (doc order) × window
(doc order): run the pipeline.
3. **Sweep stage**: grid = odometer over the cell's axes (BTreeMap order, last
axis fastest — `GridSpace` order discipline); members run via engine
`sweep` over a `ListSpace` of the enumerated points, `run_one`
`runner.run_member(cell, zip-named point, cell.window_ms)`;
`append_family(name, FamilyKind::Sweep, member reports)`; selection =
`optimize` (argmax) / `optimize_plateau` (lattice = axis lens) /
`optimize_deflated(family, metric, 1000, 5, campaign.seed)` when
`deflate: true`. Winner + `FamilySelection` go into the realization record
(stage-level annotation; family member records stay plain — decision 3:
`select` names the *recorded selection*, the population flows on).
4. **Gate stage**: population = the surviving member set (initially all sweep
members); a member survives iff **all** predicates hold on its report;
`member_metric(report, name) -> Option<f64>` resolves the 14 per-member
scalars; an R-metric predicate against a report with `metrics.r == None`
fails (conservative, deterministic). Empty survivor set → record the stage
with `survivor_ordinals: []`, stop this cell's pipeline, continue to the
next cell (decision 8).
5. **Walk-forward stage**: `WindowRoller::new(cell.window_ms, in_sample_ms,
out_of_sample_ms, step_ms, mode)` — the library works entirely in ms
(`Timestamp` is unit-agnostic `i64`; the driver owns ms→ns at its source
seam). Per window (engine `walk_forward`, parallel windows): IS family =
engine `sweep` over a `ListSpace` of the survivor points restricted to
`w.is` → winner by the stage's metric/select (argmax → `optimize_deflated`
with the campaign seed — the shipped `select_winner` convention) → OOS =
`runner.run_member(cell, winner params, w.oos)` with
`manifest.selection = Some(selection)` stamped on the OOS report (the
shipped per-window convention; these are new records, not mutations).
`WindowRun { chosen_params, oos_equity: vec![], oos_report }` (empty
stitching segment — the reduce-mode precedent).
`append_family(name, FamilyKind::WalkForward, per-OOS-window reports)`.
6. **Record**: `append_campaign_run(&record)`.
Family naming convention (deterministic, self-describing):
`"{campaign_id[..8]}-{strategy_ordinal}-{instrument}-w{window_ordinal}-s{stage_ordinal}"`;
the family id is the registry-derived `"{name}-{run}"`, so re-running the same
campaign appends run 1, 2, … under the same names (decision 9: v1 re-runs
whole; C1 makes the re-run bit-identical).
### aura-registry: the realization record
```rust
/// Campaign realization: a THIN linking record over untouched family records
/// (#198 decision 4). One JSONL line per campaign run in campaign_runs.jsonl,
/// sibling of runs.jsonl/families.jsonl — the registry's growth pattern.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CampaignRunRecord {
pub campaign: String, // campaign doc content id
pub process: String, // process doc content id
pub run: usize, // per-campaign counter (next_run pattern)
pub seed: u64,
pub cells: Vec<CellRealization>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellRealization {
pub strategy: String, // blueprint content id
pub instrument: String,
pub window_ms: (i64, i64),
pub stages: Vec<StageRealization>,// realized prefix (stops after an empty gate)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StageRealization {
pub block: String, // "std::sweep" | "std::gate" | "std::walk_forward"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family_id: Option<String>, // sweep + walk_forward stages
#[serde(default, skip_serializing_if = "Option::is_none")]
pub survivor_ordinals: Option<Vec<usize>>, // gate stages; ordinals index the
// nearest preceding family_id-bearing stage's family
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<StageSelection>, // sweep stages (wf selections live
// in the wf family members' manifests)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StageSelection {
pub winner_ordinal: usize,
pub params: Vec<(String, Scalar)>,
pub selection: FamilySelection,
}
impl Registry {
/// Assigns record.run (max stored run for this campaign id + 1, else 0),
/// appends one line to campaign_runs.jsonl, returns the assigned run.
pub fn append_campaign_run(&self, record: &CampaignRunRecord) -> Result<usize, RegistryError>
/// Missing file => Ok(vec![]) — the store convention.
pub fn load_campaign_runs(&self) -> Result<Vec<CampaignRunRecord>, RegistryError>
}
```
### aura-cli: the verb + the driver
```rust
// research_docs.rs — fourth CampaignSub variant:
pub enum CampaignSub { Validate {…}, Introspect(…), Register {…}, Run { target: String } }
```
`campaign run <target>` flow: target is a file iff `Path::is_file`
(else a content id — bare 64-hex; anything else is a runtime refusal naming
both readings). File path: parse + intrinsic-validate + `put_campaign`
(register-then-run — decision 1: a file is sugar that resolves to the id).
Requires a loaded project (`env.provenance().is_some()`) — refusal otherwise
(`campaign run needs a project: strategies resolve against the project store
and vocabulary`). Referential tier must return **zero** faults (the shipped
`validate_campaign_refs` + `ref_fault_prose` seam). Process fetched via
`get_process` + parsed; strategies resolved: `ContentId` → `get_blueprint`;
`IdentityId` → the shipped identity scan. Then `aura_campaign::execute` with
the CLI's `MemberRunner`:
- **Bind**: reload `blueprint_from_json(cell.blueprint_json, env.resolve)`
(Composite is !Clone — the shipped reload-per-member pattern), wrap via the
shipped reduce-mode convention (`wrap_r(sig, …, false, true, None)`), resolve
each raw axis name against the wrapped `param_space()` by **unique
suffix-join** (wrapped name == raw name or ends with `"." + raw name` — the
established suffix-match pattern; zero or >1 matches → `MemberFault::Bind`
with precise prose), bind values, bootstrap, run.
- **Data**: real sources via the shipped windowed path
(`M1FieldSource::open_window`, ms→ns at that seam; pip via the shipped
geometry refusal). A sub-window with no overlapping bars →
`MemberFault::NoData` (refusal, exit 1 — a declared window without data is a
campaign error).
- Member manifests: `seed = 0` (seed-free real-data runs — the shipped
convention), `instrument = Some(symbol)`, `topology_hash = Some(strategy id)`,
`project = env.provenance()`.
Stdout/stderr: JSON lines on stdout (emit-gated family/selection lines + the
always-on final `campaign_run` line), prose on stderr (`aura: …`, exit 1 on
refusals, exit 2 usage — the house convention).
### aura-cli: the #196 on-ramp
- `aura graph register <blueprint.json>` — parse via `blueprint_from_json`
(project vocabulary), canonicalize (`blueprint_to_json`), id =
`content_id_of` (== the topology hash the family verbs already store),
`put_blueprint(id, canonical)`, print
`registered blueprint content:{id} ({path})` via a public
`Registry::blueprint_path` accessor (the `process_path` pattern).
- `aura graph introspect --content-id <FILE>` accepts a blueprint document
too: shape-discriminated (a blueprint envelope vs an op-list document), each
canonicalized by its own rules; a blueprint file's printed id is its store
key.
- `aura graph introspect --params <FILE|ID>` — resolve the blueprint (file, or
content id from the store), print the **raw** `param_space()` one line per
param as `{name}:{kind:?}` — exactly the namespace campaign axes are
validated against (`validate_campaign_refs` checks the raw space; the
wrapped `--list-axes` namespace on `aura sweep` is the sweep-verb view, not
the campaign view).
## Components
| Component | Crate | New/changed |
|---|---|---|
| `WalkForward` stage fields + `WfMode` + `ZeroWalkForwardLength` fault + schema tables | aura-research | changed (schema correction, #198 d2) |
| `ListSpace` | aura-engine | new |
| `CampaignRunRecord`/`CellRealization`/`StageRealization`/`StageSelection`, `append_campaign_run`/`load_campaign_runs`, `blueprint_path` | aura-registry | new |
| `CellSpec`, `MemberRunner`, `MemberFault`, `ExecFault`, `member_metric`, `execute`, `CampaignOutcome` | aura-campaign | new crate |
| `CampaignSub::Run` + driver impl + prose seam extensions | aura-cli | new |
| `graph register` / `--content-id` blueprint mode / `--params` | aura-cli | new (#196) |
`member_metric`'s 14-name roster is a third metric-roster site (beside
aura-research's 17-name vocabulary and aura-registry's 7-name rankable set) —
deliberate in v1, referenced to #190 (metric-roster single-sourcing, filed);
drift fails safe (an unknown name is a preflight refusal, never a wrong
number).
## Data flow
```
campaign.json ──file-sugar──▶ put_campaign ──▶ content id
content id ──get_campaign──▶ CampaignDoc ──referential tier (0 faults)──▶
get_process ──▶ ProcessDoc ──preflight──▶
for strategy × instrument × window (doc order): ── the cell loop
sweep: axes ──odometer──▶ ListSpace ──engine sweep──▶ runner.run_member×N
──▶ append_family(Sweep) ──▶ optimize[_deflated|_plateau] ──▶ StageSelection
gate: survivors ──all-predicates over member_metric──▶ ordinals (empty ⇒ cell stops)
wf: WindowRoller(ms) ──walk_forward──▶ per window: IS sweep of the survivor
points over w.is ──▶ winner ──▶ runner.run_member(w.oos) ──▶ append_family(WalkForward)
──▶ CampaignRunRecord ──append_campaign_run──▶ campaign_runs.jsonl
stdout: emit-gated family/selection lines + final campaign_run line; exit 0
```
## Error handling
- **Usage (exit 2):** clap parse errors; nothing new.
- **Refusals (exit 1, prose on stderr):** no project; target neither file nor
content id; unknown campaign/process id (`get_*` → `Ok(None)`); intrinsic or
referential faults (existing prose seams); every `ExecFault` (new
`exec_fault_prose` beside `doc_fault_prose`/`ref_fault_prose`, Debug-leak-free,
path-addressed: stage index + block id); `MemberFault::NoData` names
instrument + window.
- **Exit 0:** completed realization — including zero-survivor cells (decision
8) and an empty `emit` list (quiet stdout except the final record line).
- All faults fire **before** any member runs where statically checkable
(preflight), so a refused campaign burns no compute.
## Testing strategy
1. **aura-research (schema correction):** updated golden canonical pin + new
content id; parse/refusal tests for the new fields (`mode` vocabulary,
missing slots); `ZeroWalkForwardLength` faults; introspection slot lines.
2. **aura-engine:** `ListSpace` construction refusals (arity/kind), point
order preserved, `sweep` over a `ListSpace` runs exactly the given points
(report-tagged fake runner closure).
3. **aura-registry:** `append_campaign_run` run-counter semantics +
`load_campaign_runs` missing-file → empty; record round-trip.
4. **aura-campaign (the semantics, hermetic):** a fake `MemberRunner`
returning synthesized deterministic reports drives: pipeline-shape and v1
refusals; rankable-metric and gate-metric preflights; gate filtering (all
predicates, `metrics.r == None` fails an R predicate); zero-survivor early
termination (record prefix, next cell still runs); walk-forward windows ×
survivors (roller math in ms, IS restricted to survivors, OOS winner
stamped); deflation seeded from the doc seed (same doc ⇒ identical
realization, twice); family naming; realization assembly.
5. **aura-cli seam tests** (`run_code_in`/`temp_cwd` pattern): `campaign run`
refusals outside a project; file-vs-id addressing (bogus target prose);
v1-boundary prose on an mc-bearing process; persist_taps stderr line;
`graph register`/`--params`/`--content-id` blueprint mode against the demo
fixture project.
6. **Gated real-data e2e** (the `real_bars.rs` skip-pattern): where the local
data directory exists, a full sweep→gate→walk_forward campaign over a real
symbol runs, asserts exit 0, a parseable final `campaign_run` line, families
present in the registry; skips with a note elsewhere so
`cargo test --workspace` stays green anywhere.
## Acceptance criteria
1. The worked program above runs end-to-end inside a built project with local
data: `aura campaign run campaign.json` exits 0, appends one
`CampaignRunRecord` linking ≥1 sweep family id and (survivors permitting)
one walk-forward family id, and prints the emit-gated lines plus the final
record line. The F7 trail no longer ends at `runs/campaigns/<hash>.json`.
2. `aura campaign run <content-id>` over the registered id produces the same
realization (file addressing is sugar; id is canonical — decision 1).
3. A campaign whose process gates everything out exits 0 with the truncated
realization recorded (decision 8).
4. A process using `std::monte_carlo`/`std::generalize`, a non-rankable
selection metric, an annotation-metric gate, or `plateau` in walk_forward
refuses with precise prose before any member runs.
5. The corrected `std::walk_forward` vocabulary parses/validates/introspects
with machinery-true fields; `folds` is gone; zero-length fields are
path-addressed intrinsic faults.
6. `aura graph register` + `--params` + blueprint-file `--content-id` close
the F5 authoring gap: a campaign's strategy ref + axes are authorable from
the public surface without running a sweep first (#196).
7. Suite green (`cargo test --workspace`), clippy clean, doc build clean —
the standing gates.