Files
Aura/docs/plans/generalize-dissolution.md
T
Brummel fd9a7322a0 docs(specs,plans): generalize dissolution — spec + plan (#210)
Second verb dissolution: `aura generalize` becomes thin sugar over the one
campaign executor, byte-identical output, the stop carried by a single risk
regime. Spec grounding-check PASS (auto-signed) after the exact-grade anchor
(f3f32b8) closed the stop-mechanism R-parity block. Plan derives four
implementation forks (Q1 blueprint source, Q2 run-outcome bundle, Q3 window,
Q4 the campaign-path family set — user-visible), all recorded on #210.

refs #210
2026-07-06 16:30:36 +02:00

40 KiB

Generalize dissolution — Implementation Plan

Parent spec: docs/specs/generalize-dissolution.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Dissolve aura generalize's inline execution (run_generalize) into thin sugar over the one campaign executor, byte-identical output, the stop carried by a single risk regime — mirroring the shipped sweep dissolution.

Architecture: dispatch_generalize keeps the front-end (generalize_args_from refusals byte-identical + the data-free R-metric refusal), synthesizes the bare sma_signal blueprint as the stored strategy, resolves the window, and calls a new verb_sugar::run_generalize_sugar. That translator builds a selection-bearing campaign document ([std::sweep(argmax), std::generalize], a single risk regime carrying the stop, the candidate as single-value raw axes), auto-registers it, runs it through a new campaign_run::run_campaign_returning (the run body factored out of run_campaign_by_id so the outcome is reachable without the stdout tail), then reprints today's exact {"generalize":{…}} + {"family_id":…} lines and persists the CrossInstrument family. The inline run_generalize is deleted this cycle.

Tech Stack: crates/aura-cli/src/{verb_sugar,campaign_run,main}.rs, crates/aura-cli/tests/cli_run.rs.


Derived decisions (orchestrator, recorded on #210)

These three implementation forks are derived from the code + the sweep precedent, not user preferences:

  • Q1 — built-in candidate blueprint source. generalize has no user blueprint file, so dispatch_generalize synthesizes the bare open SMA signal sma_signal(None, None) as the stored strategy blueprint (exactly what the sweep-dissolution fixture sma_signal_open.json stores). fast/slow become single-value campaign axes; the stop (length/k) rides the risk regime. Basis: the resulting downstream graph is wrap_r(sma_signal(3,12), StopRule::Vol{14,2.0}) — identical to today's r_sma_graph(stop_open=true) path with 14/2.0 axes — so the byte-identity anchor holds.
  • Q2 — the run-outcome refactor shape. run_campaign_returning returns a CampaignRun bundle (outcome + the campaign/strategies/server the present tail needs), not a bare CampaignOutcome (the spec's illustrative signature does not typecheck — the tail needs that context). present_campaign consumes the bundle by value; run_campaign_by_id becomes present_campaign(run_campaign_returning(id, env)?, presentation, env). Basis: single-source-of-truth for the resolve logic beats duplicating ~78 lines that would drift; the tail is textually preserved so the campaign e2e stays green.
  • Q3 — window resolution. Explicit --from/--to when both present (byte-identical, anchor-verified); otherwise symbols[0]'s full_window (a single shared campaign window). Basis: the campaign model carries one Window for all instruments; the tested/anchored path always passes an explicit window, and the absent case is an untested edge given a sensible shared-window reading.
  • Q4 — the family set changes (user-visible). The campaign executor persists one FamilyKind::Sweep family per instrument cell (unconditional at exec.rs:267), so the dissolved aura generalize GER40,USDJPY persists 2 Sweep families + 1 CrossInstrument family (the sugar appends the latter — the executor makes no CrossInstrument family, only a grade). The inline path persisted only the 1 CrossInstrument family (r_sma_sweep_family(None, …) suppresses per-instrument families). Decision: accept the campaign-path families — do NOT touch the shared executor to suppress them. Basis: routing through the campaign path is the milestone thesis ("every ad-hoc invocation becomes durable, diffable, reproducible intent"), and the per-instrument Sweep families are the substance generalize's cross-instrument grade is built on — now durably auditable rather than discarded; aura generalize's own stdout stays byte-identical (Fork 5), only the auxiliary aura runs families view gains the two Sweep lines. Consequence: the existing generalize_persists_a_discoverable_cross_instrument_family count==1 pin (an inline-path artifact) relaxes to "exactly one CrossInstrument family" (Task 3 Step 6).

Files this plan creates or modifies:

  • Modify: crates/aura-cli/src/campaign_run.rs — factor run_campaign_by_id into run_campaign_returning (bundle) + present_campaign; add the CampaignRun struct.
  • Modify: crates/aura-cli/src/verb_sugar.rs — add GeneratedGeneralize, translate_generalize, register_generated_g, run_generalize_sugar, cross_instrument_members, + a determinism unit test.
  • Modify: crates/aura-cli/src/main.rs:4390-4396 — rewrite dispatch_generalize (synthesize blueprint, resolve window, R-metric refusal, call the sugar); delete run_generalize (:1664-1698); fix the generalize_args_from doc-comment (:4183).
  • Test: crates/aura-cli/tests/cli_run.rs — a new auto-registration e2e proving the dissolution runs through the campaign path; the committed anchor (generalize_real_e2e_pins_the_exact_current_grade) and the existing generalize pins stay green unchanged.

Task 1: Factor the run outcome out of the presenter (campaign_run.rs)

Behaviour-preserving extraction — no new behaviour. There is no RED test: the gate is that the existing campaign + sweep-sugar e2e stay green, and that the new run_campaign_returning / present_campaign / CampaignRun symbols exist.

Files:

  • Modify: crates/aura-cli/src/campaign_run.rs:357-524

  • Step 1: Add the CampaignRun bundle struct

Insert immediately before pub(crate) fn run_campaign_by_id (currently campaign_run.rs:357):

/// An executed campaign plus the context its presenter and the dissolved-verb
/// sugar consume: `outcome` (records + per-cell realizations) and the
/// `campaign` / `strategies` / `server` the emit + trace-persistence tail need.
/// Returned by [`run_campaign_returning`] so a caller can read the outcome
/// without the stdout tail (`verb_sugar::run_generalize_sugar`).
pub(crate) struct CampaignRun {
    pub outcome: aura_campaign::CampaignOutcome,
    pub campaign: CampaignDoc,
    pub strategies: Vec<(String, String)>,
    pub server: Arc<data_server::DataServer>,
}
  • Step 2: Replace run_campaign_by_id with the two-line composition

Replace the entire body of run_campaign_by_id (campaign_run.rs:357-524, from pub(crate) fn run_campaign_by_id( through its closing }) with:

pub(crate) fn run_campaign_by_id(
    campaign_id: &str,
    env: &Env,
    presentation: RunPresentation,
) -> Result<(), String> {
    let run = run_campaign_returning(campaign_id, env)?;
    present_campaign(run, presentation, env)
}

/// The one campaign executor path from a resolved content id up to (not
/// including) presentation: fetch the stored canonical bytes by id (so file
/// addressing and id addressing produce the same realization by construction),
/// re-run the intrinsic tier, resolve strategies, execute — returning the
/// outcome bundled with the context the presenter needs. Shared by
/// `run_campaign_by_id` (which then presents) and the dissolved-verb sugar path
/// (`verb_sugar::run_generalize_sugar`, which reads the outcome and self-prints).
pub(crate) fn run_campaign_returning(
    campaign_id: &str,
    env: &Env,
) -> Result<CampaignRun, String> {
    let registry = env.registry();
    let campaign_text = registry
        .get_campaign(campaign_id)
        .map_err(|e| e.to_string())?
        .ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?;
    let campaign = parse_campaign(&campaign_text)
        .map_err(|e| doc_error_prose("stored campaign document", &e))?;
    let faults = validate_campaign(&campaign);
    if !faults.is_empty() {
        return Err(fault_block(
            "campaign document invalid:",
            faults.iter().map(doc_fault_prose).collect(),
        ));
    }

    // Referential gate: zero faults or refuse (the campaign-validate seam).
    let resolve = |t: &str| env.resolve(t);
    let ref_faults = registry
        .validate_campaign_refs(&campaign, &resolve)
        .map_err(|e| e.to_string())?;
    if !ref_faults.is_empty() {
        return Err(fault_block(
            "campaign references do not resolve:",
            ref_faults.iter().map(ref_fault_prose).collect(),
        ));
    }

    // Process fetch + intrinsic validation (stored text, not a file path —
    // parse_valid_process is file-based, so its constituents run here).
    let DocRef::ContentId(process_id) = &campaign.process.r#ref else {
        // validate_campaign already refuses identity process refs; defensive.
        return Err("process.ref: a process is referenced by content id in this version".into());
    };
    let process_text = registry
        .get_process(process_id)
        .map_err(|e| e.to_string())?
        .ok_or_else(|| format!("no process {process_id} in the project store"))?;
    let process = parse_process(&process_text)
        .map_err(|e| doc_error_prose("stored process document", &e))?;
    let process_faults = validate_process(&process);
    if !process_faults.is_empty() {
        return Err(fault_block(
            "process document invalid:",
            process_faults.iter().map(doc_fault_prose).collect(),
        ));
    }

    // Strategies: canonical bytes from the store, index-aligned with the doc.
    // The recorded id is the content id of those bytes (== the ref id for
    // content refs; computed for identity refs).
    let mut strategies: Vec<(String, String)> = Vec::with_capacity(campaign.strategies.len());
    for entry in &campaign.strategies {
        let canonical = match &entry.r#ref {
            DocRef::ContentId(id) => registry
                .get_blueprint(id)
                .map_err(|e| e.to_string())?
                .ok_or_else(|| format!("strategy {id} not found in the blueprint store"))?,
            DocRef::IdentityId(id) => registry
                .find_blueprint_by_identity(id, &resolve)
                .map_err(|e| e.to_string())?
                .ok_or_else(|| format!("identity id {id} matches no stored blueprint"))?,
        };
        strategies.push((content_id_of(&canonical), canonical));
    }

    let runner = CliMemberRunner {
        env,
        server: Arc::new(data_server::DataServer::new(env.data_path())),
    };
    let outcome = aura_campaign::execute(
        campaign_id,
        &campaign,
        &process,
        &strategies,
        &runner,
        &registry,
    )
    .map_err(|f| exec_fault_prose(&f))?;

    Ok(CampaignRun { outcome, campaign, strategies, server: runner.server })
}

/// Emit an executed campaign's results per `presentation`: the zero-survivor
/// stderr notes, the emit-gated family/selection lines, the always-on record
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
fn present_campaign(run: CampaignRun, presentation: RunPresentation, env: &Env) -> Result<(), String> {
    let CampaignRun { outcome, campaign, strategies, server } = run;

    // Zero-survivor stderr notes (exit stays 0 — a null result is a valid
    // research result, #198 decision 8). Addressed by the fields the record
    // already carries (strategy/instrument/window_ms), not by re-deriving a
    // doc-order ordinal from the loop-nesting the executor happens to use
    // today — that duplicated invariant would silently drift if the executor
    // ever reorders its cell loop.
    for cell in &outcome.record.cells {
        for (stage_ix, st) in cell.stages.iter().enumerate() {
            if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
                eprintln!(
                    "aura: cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
                     survivors; cell realization truncated",
                    cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1
                );
            }
        }
    }

    // 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
    // indexing into the vocab slice); the debug_assert keeps the names
    // honest against `aura_research::emit_vocabulary()` so a #190
    // rename/extend of the closed set fails loudly here instead of silently
    // misrouting emission.
    debug_assert!(
        aura_research::emit_vocabulary().contains(&"family_table")
            && aura_research::emit_vocabulary().contains(&"selection_report"),
        "emit_vocabulary drifted from the names campaign_run matches by"
    );
    let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table");
    let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report");
    for cell_out in &outcome.cells {
        if emit_family {
            for fam in &cell_out.families {
                for report in &fam.reports {
                    println!("{}", crate::family_member_line(&fam.family_id, report));
                }
            }
        }
        if emit_selection {
            for sel in &cell_out.selections {
                let line = SelectionReportLine {
                    selection_report: SelectionReportBody {
                        family_id: &sel.family_id,
                        stage: sel.stage,
                        block: sel.block,
                        winner_ordinal: sel.winner_ordinal,
                        params: &sel.params,
                        selection: &sel.selection,
                    },
                };
                println!(
                    "{}",
                    serde_json::to_string(&line).expect("selection report serializes")
                );
            }
        }
    }
    if presentation == RunPresentation::Full {
        println!(
            "{}",
            serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
                .expect("campaign run record serializes")
        );
    }

    // Trace persistence runs LAST, after the always-on record line (0109):
    // stdout stays data-pure (the record line is the wire contract) and the
    // trace surface is a stderr concern; the realization record is already
    // stored by `execute`, so a trace failure exits 1 without un-recording it.
    if let Some(trace_name) = &outcome.record.trace_name {
        persist_campaign_traces(
            trace_name,
            &campaign.presentation.persist_taps,
            &outcome,
            &campaign,
            &strategies,
            &server,
            env,
        )?;
    }
    Ok(())
}
  • Step 3: Build the crate — 0 errors, 0 warnings

Run: cargo build -p aura-cli 2>&1 | tail -5 Expected: Finished with no error/warning lines (the two existing callers run_campaign and verb_sugar::run_sweep_sugar still call run_campaign_by_id unchanged; run_campaign_returning / present_campaign / CampaignRun are the new symbols).

  • Step 4: The campaign path is behaviour-preserved — existing e2e green

Run: cargo test -p aura-cli --test research_docs campaign_run_real_e2e_sweep_gate_walkforward 2>&1 | tail -6 Expected: test result: ok. 1 passed (or a clean skip: line + ok. 0 measured if the local archive is absent — never a failure).

Run: cargo test -p aura-cli --test cli_run sweep_real_blueprint_member_lines_pin_the_dissolved_contract 2>&1 | tail -6 Expected: test result: ok. 1 passed (or a clean skip).


Task 2: The generalize translator (verb_sugar.rs)

Files:

  • Modify: crates/aura-cli/src/verb_sugar.rs:13-17 (imports), + new items after translate_sweep / register_generated.

  • Test: crates/aura-cli/src/verb_sugar.rs (the #[cfg(test)] mod tests).

  • Step 1: Write the failing determinism test

Add to the #[cfg(test)] mod tests block in verb_sugar.rs (after generated_campaign_validates_intrinsically_and_preflights):

    #[test]
    fn translate_generalize_is_deterministic_and_carries_the_regime() {
        let a = translate_generalize(
            3, 12, 14, 2.0, "expectancy_r", "generalize",
            &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
        )
        .unwrap();
        let b = translate_generalize(
            3, 12, 14, 2.0, "expectancy_r", "generalize",
            &["GER40".to_string(), "USDJPY".to_string()], 100, 200, "{\"bp\":1}",
        )
        .unwrap();
        assert_eq!(
            content_id_of(&campaign_to_json(&a.campaign)),
            content_id_of(&campaign_to_json(&b.campaign)),
            "identical invocations must generate identical campaign ids"
        );
        // A selection-bearing pipeline: sweep(argmax) then generalize.
        assert_eq!(
            a.process.pipeline,
            vec![
                StageBlock::Sweep {
                    selection: Some(SweepSelection {
                        metric: "expectancy_r".to_string(),
                        select: SelectRule::Argmax,
                        deflate: false,
                    })
                },
                StageBlock::Generalize { metric: "expectancy_r".to_string() },
            ]
        );
        // The stop rides a single risk regime (the structural axis).
        assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]);
        // All instruments under one campaign; the candidate as single-value axes.
        assert_eq!(
            a.campaign.data.instruments,
            vec!["GER40".to_string(), "USDJPY".to_string()]
        );
        assert_eq!(a.campaign.strategies.len(), 1);
        assert!(a.campaign.strategies[0].axes.contains_key("fast.length"));
        assert!(a.campaign.strategies[0].axes.contains_key("slow.length"));
        assert_eq!(a.process.name, "generalize");
        assert_eq!(a.campaign.name, "generalize");
        // Intrinsically valid + preflights (like the sweep sibling).
        assert!(aura_research::validate_campaign(&a.campaign).is_empty());
        assert!(aura_research::validate_process(&a.process).is_empty());
        assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok());
    }
  • Step 2: Run the test to verify it fails (does not compile)

Run: cargo test -p aura-cli --lib translate_generalize_is_deterministic_and_carries_the_regime 2>&1 | tail -8 Expected: FAIL — compile error cannot find function 'translate_generalize' (and unresolved RiskRegime / SweepSelection / SelectRule if the import is not yet added).

  • Step 3: Extend the aura_research import

Replace the import block verb_sugar.rs:13-17:

use aura_research::{
    campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
    DocRef, Presentation, ProcessDoc, ProcessRef, StageBlock, StrategyEntry, Window,
    FORMAT_VERSION,
};

with (adding RiskRegime, SweepSelection, SelectRule):

use aura_research::{
    campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
    DocRef, Presentation, ProcessDoc, ProcessRef, RiskRegime, SelectRule, StageBlock,
    StrategyEntry, SweepSelection, Window, FORMAT_VERSION,
};
  • Step 4: Add GeneratedGeneralize + translate_generalize + register_generated_g

Insert after register_generated (verb_sugar.rs:117):

/// The two generated documents of one dissolved `generalize` invocation.
#[derive(Debug)]
pub(crate) struct GeneratedGeneralize {
    pub process: ProcessDoc,
    pub campaign: CampaignDoc,
}

/// Translate one `aura generalize` invocation into its two generated documents:
/// a **selection-bearing** process (`[std::sweep(argmax), std::generalize]` —
/// generalize needs a nominee, so the sweep stage is selection-bearing, unlike
/// the selection-free single-sweep translator) and a campaign running the one
/// fixed candidate (single-value raw axes) across all instruments under a single
/// risk regime that carries the stop. `blueprint_canonical` is the bare
/// `sma_signal` blueprint already stored by topology hash; its content id is the
/// strategy ref.
#[allow(clippy::too_many_arguments)]
pub(crate) fn translate_generalize(
    fast: i64,
    slow: i64,
    stop_length: i64,
    stop_k: f64,
    metric: &str,
    name: &str,
    symbols: &[String],
    from_ms: i64,
    to_ms: i64,
    blueprint_canonical: &str,
) -> Result<GeneratedGeneralize, String> {
    let process = ProcessDoc {
        format_version: FORMAT_VERSION,
        kind: DocKind::Process,
        name: "generalize".to_string(),
        description: None,
        pipeline: vec![
            StageBlock::Sweep {
                selection: Some(SweepSelection {
                    metric: metric.to_string(),
                    select: SelectRule::Argmax,
                    deflate: false,
                }),
            },
            StageBlock::Generalize { metric: metric.to_string() },
        ],
    };
    let mut doc_axes: BTreeMap<String, Axis> = BTreeMap::new();
    doc_axes.insert(
        "fast.length".to_string(),
        axis_from_values("fast.length", &[Scalar::i64(fast)])?,
    );
    doc_axes.insert(
        "slow.length".to_string(),
        axis_from_values("slow.length", &[Scalar::i64(slow)])?,
    );
    let campaign = CampaignDoc {
        format_version: FORMAT_VERSION,
        kind: DocKind::Campaign,
        name: name.to_string(),
        description: None,
        data: DataSection {
            instruments: symbols.to_vec(),
            windows: vec![Window { from_ms, to_ms }],
        },
        strategies: vec![StrategyEntry {
            r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)),
            axes: doc_axes,
        }],
        process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
        // The stop is a single structural risk regime (the just-shipped axis);
        // the member runner maps it back to StopRule::Vol at run time.
        risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }],
        seed: 0,
        presentation: Presentation {
            persist_taps: vec![],
            emit: vec!["family_table".to_string()],
        },
    };
    Ok(GeneratedGeneralize { process, campaign })
}

/// Auto-register both generated generalize documents (content-addressed,
/// idempotent). Returns `(process_id, campaign_id)`.
pub(crate) fn register_generated_g(
    reg: &aura_registry::Registry,
    generated: &GeneratedGeneralize,
) -> Result<(String, String), String> {
    let process_id =
        reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?;
    let campaign_id =
        reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?;
    Ok((process_id, campaign_id))
}
  • Step 5: Run the test to verify it passes

Run: cargo test -p aura-cli --lib translate_generalize_is_deterministic_and_carries_the_regime 2>&1 | tail -6 Expected: test result: ok. 1 passed.

  • Step 6: Clippy clean

Run: cargo clippy -p aura-cli --all-targets 2>&1 | tail -5 Expected: Finished with no warnings (note the #[allow(clippy::too_many_arguments)] on translate_generalize).


Task 3: The sugar runner + dispatch rewrite + inline deletion

Defines run_generalize_sugar (calls Task 1's run_campaign_returning + Task 2's translate_generalize), wires dispatch_generalize to it, and DELETES the inline run_generalize — all in one coherent diff so the crate builds clean (the sugar is defined AND called, the inline path gone). The committed anchor generalize_real_e2e_pins_the_exact_current_grade is the acceptance gate: it must stay green through the path shift.

Files:

  • Modify: crates/aura-cli/src/verb_sugar.rs (new items after run_sweep_sugar).

  • Modify: crates/aura-cli/src/main.rs:4390-4396 (dispatch), :1664-1698 (delete), :4182-4185 (doc-comment).

  • Step 1: Add run_generalize_sugar + cross_instrument_members (verb_sugar.rs)

Insert after run_sweep_sugar (verb_sugar.rs:175):

/// 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`.
fn cross_instrument_members(
    outcome: &aura_campaign::CampaignOutcome,
) -> Vec<aura_registry::RunReport> {
    outcome
        .cells
        .iter()
        .filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
        .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
/// inline path (the committed exact-grade anchor is the gate).
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_generalize_sugar(
    fast: i64,
    slow: i64,
    stop_length: i64,
    stop_k: f64,
    metric: &str,
    name: &str,
    symbols: &[String],
    from_ms: i64,
    to_ms: i64,
    blueprint_canonical: &str,
    env: &crate::project::Env,
) -> Result<(), String> {
    let generated = translate_generalize(
        fast, slow, stop_length, stop_k, metric, name, symbols, from_ms, to_ms, blueprint_canonical,
    )?;

    // Validate BEFORE registering anything (#210 c0110 "store litter"): a
    // referential refusal must never leave a dead generated document behind.
    let doc_faults = aura_research::validate_campaign(&generated.campaign);
    if !doc_faults.is_empty() {
        return Err(crate::research_docs::fault_block(
            "generated campaign document invalid:",
            doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
        ));
    }
    let process_faults = aura_research::validate_process(&generated.process);
    if !process_faults.is_empty() {
        return Err(crate::research_docs::fault_block(
            "generated process document invalid:",
            process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(),
        ));
    }
    aura_campaign::preflight(&generated.process, &generated.campaign)
        .map_err(|f| crate::campaign_run::exec_fault_prose(&f))?;

    // Axis-name coverage against the blueprint's own open param space — the same
    // pure `bind_axes` seam the sweep sugar reuses (any one grid value per axis
    // suffices; this checks NAME coverage/uniqueness, never the bound value).
    let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space();
    let strategy_id = content_id_of(blueprint_canonical);
    let probe_params: Vec<(String, Scalar)> = vec![
        ("fast.length".to_string(), Scalar::i64(fast)),
        ("slow.length".to_string(), Scalar::i64(slow)),
    ];
    crate::campaign_run::bind_axes(&space, &strategy_id, &probe_params)
        .map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))?;

    let reg = env.registry();
    let (_process_id, campaign_id) = register_generated_g(&reg, &generated)?;
    let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;

    // Reprint the verb's aggregate line from the recorded cross-instrument
    // grade — byte-identical to the inline `generalize_json(&agg)`.
    let cg = run
        .outcome
        .record
        .generalizations
        .iter()
        .find_map(|g| g.generalization.as_ref())
        .ok_or("generalize produced no cross-instrument grade")?;
    println!("{}", crate::generalize_json(cg));

    let members = cross_instrument_members(&run.outcome);
    let family_id = reg
        .append_family(name, aura_registry::FamilyKind::CrossInstrument, &members)
        .map_err(|e| e.to_string())?;
    println!("{{\"family_id\":\"{family_id}\"}}");
    Ok(())
}
  • Step 2: Rewrite dispatch_generalize (main.rs:4390-4396)

Replace the whole function (main.rs:4390-4396):

fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
    let (name, symbols, grid, metric, from_ms, to_ms) = generalize_args_from(&a).unwrap_or_else(|m| {
        eprintln!("aura: {m}");
        std::process::exit(2);
    });
    run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms, env);
}

with:

fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) {
    let (name, symbols, grid, metric, from, to) = generalize_args_from(&a).unwrap_or_else(|m| {
        eprintln!("aura: {m}");
        std::process::exit(2);
    });
    // Data-free R-metric refusal, byte-identical to the inline path (exit 2)
    // before any archive is touched.
    if let Err(e) = check_r_metric(&metric) {
        eprintln!("aura: {e}");
        std::process::exit(2);
    }
    // Synthesize the bare open SMA signal as the stored strategy blueprint (the
    // same shape the sweep-dissolution fixture `sma_signal_open.json` stores);
    // fast/slow become single-value campaign axes and the stop rides the risk
    // regime (mapped back to StopRule::Vol by the member runner). topology_hash
    // == content_id_of(canonical), so the strategy ref resolves against this one
    // blueprint-store write.
    let signal = sma_signal(None, None);
    let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes");
    let reg = env.registry();
    let topo = topology_hash(&signal);
    reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
        eprintln!("aura: {e}");
        std::process::exit(1);
    });
    // Window: the explicit --from/--to when both present (byte-identical to the
    // inline path, verified by the exact-grade anchor); otherwise the first
    // symbol's full archive window as the single shared campaign window.
    let (from_ms, to_ms) = match (from, to) {
        (Some(f), Some(t)) => (f, t),
        _ => {
            let source = DataSource::from_choice(
                DataChoice::Real { symbol: symbols[0].clone(), from_ms: from, to_ms: to },
                env,
            );
            let (from_ts, to_ts) = source.full_window(env);
            (
                aura_ingest::epoch_ns_to_unix_ms(from_ts),
                aura_ingest::epoch_ns_to_unix_ms(to_ts),
            )
        }
    };
    verb_sugar::run_generalize_sugar(
        grid.fast[0],
        grid.slow[0],
        grid.stop_length[0],
        grid.stop_k[0],
        &metric,
        &name,
        &symbols,
        from_ms,
        to_ms,
        &canonical,
        env,
    )
    .unwrap_or_else(|m| {
        eprintln!("aura: {m}");
        std::process::exit(1);
    });
}
  • Step 3: Delete the inline run_generalize (main.rs:1664-1698)

Delete the entire fn run_generalize(...) { ... } function (main.rs:1664-1698, from fn run_generalize( through its closing } at :1698). Nothing else calls it (the only caller was dispatch_generalize, rewritten in Step 2).

  • Step 4: Fix the generalize_args_from doc-comment (main.rs:4182-4185)

The doc-comment on generalize_args_from references the now-deleted run_generalize. Replace main.rs:4182-4185:

/// The old `parse_generalize_args` body minus tokenizing: convert `GeneralizeCmd`
/// into the `run_generalize` argument shape. The candidate is a single cell (clap
/// already types `--fast`/etc. as one `i64`/`f64`), all four knobs required, `--real`
/// a `>=2`-distinct comma list; every refusal reuses the old message string.

with:

/// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar
/// consumes. The candidate is a single cell (clap already types `--fast`/etc. as
/// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma
/// list; every refusal reuses the old message string (byte-identical front-end).
  • Step 5: Build + clippy — 0 errors, 0 warnings

Run: cargo build -p aura-cli 2>&1 | tail -5 Expected: Finished, no errors, no run_generalize unused/undefined references.

Run: cargo clippy -p aura-cli --all-targets 2>&1 | tail -5 Expected: Finished, no warnings.

  • Step 6: Relax the family-count pin to the campaign-path family set (Q4)

The dissolved path persists 2 per-instrument Sweep families + the 1 CrossInstrument family, so the existing count==1 pin must relax to "exactly one CrossInstrument family". Replace cli_run.rs:3788-3791:

    assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
    assert!(fams_out.contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
    assert!(fams_out.contains("\"kind\":\"CrossInstrument\""), "families: {fams_out:?}");
    assert!(fams_out.contains("\"members\":2"), "one member per instrument: {fams_out:?}");

with:

    // The dissolved path routes through the campaign executor, which persists a
    // per-instrument Sweep family per cell (each instrument's candidate run, now
    // a durable audit artifact) alongside the one CrossInstrument grade family
    // the sugar appends. Pin exactly one CrossInstrument family (the generalize
    // result) — not the total family count, which now includes those per-cell
    // Sweep families (#210 Q4).
    let cross: Vec<&str> = fams_out
        .lines()
        .filter(|l| l.contains("\"kind\":\"CrossInstrument\""))
        .collect();
    assert_eq!(cross.len(), 1, "exactly one CrossInstrument family: {fams_out:?}");
    assert!(cross[0].contains("\"family_id\":\"generalize-0\""), "families: {fams_out:?}");
    assert!(cross[0].contains("\"members\":2"), "one member per instrument: {fams_out:?}");
  • Step 7: The acceptance anchor stays green through the path shift

Run: cargo test -p aura-cli --test cli_run generalize_real_e2e_pins_the_exact_current_grade 2>&1 | tail -6 Expected: test result: ok. 1 passed (exact floats GER40 0.01056371324510624, USDJPY 0.005795903617609842, worst_case 0.005795903617609842 — now produced through the campaign path). A clean skip: + 0 measured only if the local archive is absent; NEVER a failure. A red here means the two stop mechanisms diverge — bounce, do not patch the anchor.

  • Step 8: The remaining generalize pins stay green

Run: cargo test -p aura-cli --test cli_run generalize_grades_a_candidate_across_two_instruments 2>&1 | tail -6 Expected: test result: ok. 1 passed (or clean skip — asserts the generalize-0 family_id in the verb's own STDOUT, unaffected by Q4).

Run: cargo test -p aura-cli --test cli_run generalize_persists_a_discoverable_cross_instrument_family 2>&1 | tail -6 Expected: test result: ok. 1 passed (or clean skip — now the Step-6 relaxed form).

Run: cargo test -p aura-cli --test cli_run generalize_refuses_a_non_r_metric 2>&1 | tail -6 Expected: test result: ok. 1 passed (the R-metric refusal is preserved in dispatch_generalize).


Task 4: The dissolution-proof e2e (cli_run.rs)

Proves the verb now runs through the campaign path: the generated process + campaign documents and the campaign-run record are durably auto-registered, and the campaign document carries the stop as a non-empty risk regime (the structural delta vs the sweep translator's empty risk). Mirrors the sweep-dissolution auto-registration idiom (sweep_real_blueprint_member_lines_pin_the_dissolved_contract).

Files:

  • Test: crates/aura-cli/tests/cli_run.rs (after generalize_real_e2e_pins_the_exact_current_grade).

  • Step 1: Add the auto-registration e2e

Insert after generalize_real_e2e_pins_the_exact_current_grade (before generalize_refuses_a_single_instrument):

/// Property: `aura generalize` is now thin sugar over the one campaign path — a
/// successful run durably auto-registers exactly one generated process document,
/// one generated campaign document (carrying the `--name` handle and the stop as
/// a non-empty single risk regime), and one campaign-run record. This is the
/// observable proof the inline path is gone and the dissolution runs through the
/// executor. Gated on the shared GER40/USDJPY Sept-2024 archive; skips cleanly on
/// a data refusal.
#[test]
fn generalize_dissolves_through_the_campaign_path() {
    const FROM_MS: &str = "1725148800000";
    const TO_MS: &str = "1727740799999";
    let cwd = temp_cwd("generalize-dissolves");
    let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
        .args([
            "generalize", "--strategy", "r-sma", "--real", "GER40,USDJPY",
            "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0",
            "--from", FROM_MS, "--to", TO_MS,
        ])
        .current_dir(&cwd)
        .output()
        .expect("spawn aura");
    if out.status.code() == Some(1) {
        let stderr = String::from_utf8_lossy(&out.stderr);
        assert!(
            stderr.contains("no local data") || stderr.contains("no recorded geometry"),
            "exit 1 must be a data refusal, got: {stderr}"
        );
        eprintln!("skip: no local GER40/USDJPY data");
        return;
    }
    assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status);

    let count = |sub: &str| {
        std::fs::read_dir(cwd.join("runs").join(sub))
            .map(|d| d.count())
            .unwrap_or(0)
    };
    assert_eq!(count("processes"), 1, "one generated process document registered");
    assert_eq!(count("campaigns"), 1, "one generated campaign document registered");
    let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl"))
        .expect("campaign_runs.jsonl exists after a sugar run");
    assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded");

    let campaigns_dir = cwd.join("runs").join("campaigns");
    let campaign_doc_path = std::fs::read_dir(&campaigns_dir)
        .expect("campaigns dir exists")
        .next()
        .expect("exactly one campaign document")
        .expect("readable dir entry")
        .path();
    let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc");
    assert!(
        campaign_doc_json.contains("\"name\":\"generalize\""),
        "the generated campaign document carries the --name handle: {campaign_doc_json}"
    );
    // The structural delta vs the sweep translator: the stop is a non-empty
    // single risk regime, not empty risk.
    assert!(
        campaign_doc_json.contains("\"risk\":[")
            && campaign_doc_json.contains("\"length\":14")
            && campaign_doc_json.contains("\"k\":2.0"),
        "the stop rides a non-empty risk regime: {campaign_doc_json}"
    );

    // The campaign path's family set (#210 Q4): exactly one CrossInstrument grade
    // family (the generalize result, appended by the sugar) plus one
    // per-instrument Sweep family per cell (persisted by the executor).
    let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
        .args(["runs", "families"])
        .current_dir(&cwd)
        .output()
        .expect("spawn families");
    let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned();
    assert_eq!(
        fams_out.lines().filter(|l| l.contains("\"kind\":\"CrossInstrument\"")).count(),
        1,
        "one CrossInstrument grade family: {fams_out}"
    );
    assert_eq!(
        fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(),
        2,
        "one per-instrument Sweep family per cell: {fams_out}"
    );
}
  • Step 2: Run the new e2e

Run: cargo test -p aura-cli --test cli_run generalize_dissolves_through_the_campaign_path 2>&1 | tail -6 Expected: test result: ok. 1 passed (or a clean skip: line if the archive is absent).

  • Step 3: Full crate test sweep — no regressions

Run: cargo test -p aura-cli 2>&1 | tail -15 Expected: test result: ok. for every binary; 0 failed. Data-gated tests may print skip: lines but never fail.

  • Step 4: Workspace build + clippy clean

Run: cargo build --workspace 2>&1 | tail -3 Expected: Finished, 0 errors.

Run: cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5 Expected: Finished, 0 warnings.