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

27 KiB
Raw Blame History

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):

{
  "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):

{
  "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):

#[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):

#[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:

/// 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

/// 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_rresolve_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_onerunner.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

/// 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

// 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: ContentIdget_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.