Files
Aura/docs/specs/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

13 KiB

Generalize dissolution — Design Spec

Date: 2026-07-06 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Dissolve aura generalize's inline execution into thin sugar over a generated, auto-registered campaign document run through the one campaign executor — the sweep-dissolution precedent (cycle 0110), now that the risk regime carries the stop. The command surface and its argument-layer refusals stay byte-identical; the inline execution path (run_generalize) is removed this cycle (old-path deletion, #210 Fork 7). Full behaviour parity (#210 Fork 5): byte-identical {"generalize":{…}} stdout + the {"family_id":"generalize-<run>"} line + a persisted FamilyKind::CrossInstrument family that aura runs family finds.

Design basis: the #210 fork triage (Forks 5/6/7 + old-path deletion) and the derived sub-fork decisions recorded on #210 (comment 2857). This is the second verb dissolution; it rides the sweep translator skeleton, differing in exactly two derived ways — a selection-bearing pipeline (generalize needs a nominee) and an aggregate-line presentation adapter (the verb prints one summary line, not member lines).

Architecture

Three layers, mirroring the sweep sugar:

  • Front-end (unchanged surface). aura generalize keeps its clap struct GeneralizeCmd and its argument validation generalize_args_from (every refusal message byte-identical). dispatch_generalize validates, then calls the new run_generalize_sugar instead of the inline run_generalize.
  • Translator. translate_generalize (a verb_sugar.rs sibling of translate_sweep) builds a campaign document: the symbols as data.instruments, the fixed candidate as single-value raw axes, the stop as a single risk regime, and a selection-bearing process [std::sweep(metric, argmax), std::generalize(metric)]. Both generated documents are auto-registered (#210 Fork 6).
  • Runner + presentation adapter. run_generalize_sugar runs the campaign and obtains its CampaignOutcome (via a run_campaign_returning refactor that exposes what run_campaign_by_id today discards), reads the recorded CampaignGeneralization, and reprints today's exact {"generalize":{…}} line through the surviving generalize_json, then persists the FamilyKind::CrossInstrument family and prints its {"family_id":…} line.

The regime carries the stop end-to-end: a single --stop-length N --stop-k K becomes risk: [{"vol":{"length":N,"k":K}}], and the CLI member runner maps that regime to StopRule::Vol{N,K} (the just-shipped #210 T4 seam).

Concrete code shapes

The user invocation (unchanged — the acceptance evidence)

aura generalize --strategy r-sma --real GER40,USDJPY \
  --fast 3 --slow 12 --stop-length 14 --stop-k 2.0 --from <ms> --to <ms>

Output today (pinned at cli_run.rs:3577-3582), and byte-identical after dissolution:

{"generalize":{"metric":"expectancy_r","n_instruments":2,"worst_case":<f>,"sign_agreement":<n>,"per_instrument":[["GER40",<f>],["USDJPY",<f>]]}}
{"family_id":"generalize-0"}

The generated campaign document (the new artifact)

{
  "format_version": 1, "kind": "campaign", "name": "generalize",
  "data": { "instruments": ["GER40","USDJPY"], "windows": [ { "from_ms": <f>, "to_ms": <t> } ] },
  "risk": [ { "vol": { "length": 14, "k": 2.0 } } ],
  "strategies": [ { "ref": { "content_id": "<blueprint-hash>" },
    "axes": { "fast.length": { "kind": "I64", "values": [3] },
              "slow.length": { "kind": "I64", "values": [12] } } } ],
  "process": { "ref": { "content_id": "<process-hash>" } },
  "seed": 0,
  "presentation": { "persist_taps": [], "emit": ["family_table"] }
}

and the generated process document:

{ "format_version": 1, "kind": "process", "name": "generalize",
  "pipeline": [ { "block": "std::sweep", "metric": "expectancy_r", "select": "argmax" },
                { "block": "std::generalize", "metric": "expectancy_r" } ] }

The single-value axes express the one fixed candidate; the std::sweep argmaxes the trivial one-point grid to that candidate (the nominee), which std::generalize grades across instruments. Axes are raw-namespaced (fast.length, not sma_signal.fast.length) — the sweep-dissolution wrapped→raw strip precedent.

translate_generalize (new, verb_sugar.rs)

// before → after: a sibling of translate_sweep, differing in the pipeline
// (selection-bearing sweep + generalize) and a non-empty single-regime risk.
pub(crate) struct GeneratedGeneralize { pub process: ProcessDoc, pub campaign: CampaignDoc }

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: name.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 axes = BTreeMap::from([
        ("fast.length".to_string(), axis_from_values("fast.length", &[Scalar::i64(fast)])?),
        ("slow.length".to_string(), axis_from_values("slow.length", &[Scalar::i64(slow)])?),
    ]);
    let campaign = CampaignDoc { /* …as translate_sweep… */
        data: DataSection { instruments: symbols.to_vec(), windows: vec![Window { from_ms, to_ms }] },
        risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }],
        strategies: vec![StrategyEntry {
            r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), axes }],
        process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) },
        seed: 0,
        presentation: Presentation { persist_taps: vec![], emit: vec!["family_table".to_string()] },
        /* format_version, kind, name, description as translate_sweep */ };
    Ok(GeneratedGeneralize { process, campaign })
}

(The exact SweepSelection / SelectRule names are those the risk-regime and 0110 cycles shipped; the planner pins them.)

Exposing the run outcome (refactor, campaign_run.rs)

// before: run_campaign_by_id runs + presents, returns Result<(), String> —
//   the CampaignOutcome (its generalizations) is discarded.
// after: factor the run into a helper that RETURNS the outcome; the presenting
//   entry point calls it then presents, unchanged for existing callers.
pub(crate) fn run_campaign_returning(campaign_id: &str, env: &Env)
    -> Result<aura_campaign::CampaignOutcome, String> { /* the validate→resolve→execute body, no present */ }

pub(crate) fn run_campaign_by_id(campaign_id: &str, env: &Env, presentation: RunPresentation)
    -> Result<(), String> {
    let outcome = run_campaign_returning(campaign_id, env)?;
    present_campaign(&outcome, presentation); // the existing member/record line emission
    Ok(())
}

run_generalize_sugar + the presentation adapter (new, verb_sugar.rs)

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: &project::Env,
) -> Result<(), String> {
    let gen = translate_generalize(fast, slow, stop_length, stop_k, metric, name,
        symbols, from_ms, to_ms, blueprint_canonical)?;
    // validate-before-register (the 0110 discipline), then auto-register both docs.
    validate_campaign_or_err(&gen.campaign)?; validate_process_or_err(&gen.process)?;
    aura_campaign::preflight(&gen.process, &gen.campaign).map_err(prose)?;
    let (_p, campaign_id) = register_generated_g(env.registry(), &gen)?;
    // run + take the outcome; the presentation adapter reprints the verb's line.
    let outcome = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
    let cg = outcome.record.generalizations.iter()
        .find_map(|g| g.generalization.as_ref())
        .ok_or("generalize produced no cross-instrument grade")?;
    println!("{}", crate::generalize_json(cg)); // byte-identical to the verb
    let family_id = env.registry()
        .append_family(name, FamilyKind::CrossInstrument, &cross_instrument_members(&outcome))
        .map_err(|e| e.to_string())?;
    println!("{}", crate::family_id_line(&family_id));
    Ok(())
}

dispatch_generalize change + the removal (main.rs)

// before (main.rs:4390-4395): dispatch_generalize -> run_generalize(inline execution)
// after: validate args (unchanged refusals), resolve the real data window + blueprint
//   (the sweep dispatch arm's put_blueprint + ns→ms precedent), then:
verb_sugar::run_generalize_sugar(fast, slow, stop_length, stop_k, &metric, &name,
    &symbols, from_ms, to_ms, &canonical, &env)

REMOVED this cycle: run_generalize (main.rs:1664 — the inline execution). KEPT and reused: generalize_json (main.rs:1925), generalize_args_from (the arg front-end + refusals), r_sma_sweep_family (still the single-run/other paths).

Components

  • verb_sugar.rs: GeneratedGeneralize, translate_generalize, register_generated_g, run_generalize_sugar + the cross_instrument_members helper.
  • campaign_run.rs: factor run_campaign_returning out of run_campaign_by_id (exposes CampaignOutcome); present_campaign holds the existing emission.
  • main.rs: dispatch_generalize calls the sugar (with the sweep dispatch arm's real-data resolution: put_blueprint, full_window, ns→ms); the inline run_generalize deleted; generalize_json / family_id_line made reachable from verb_sugar.

Data flow

aura generalize args → generalize_args_from (validate; refusals) → resolve real window + canonical blueprint → translate_generalize → validate + preflight

  • auto-register (campaign + process) → run_campaign_returning (executor runs the single candidate across instruments under the single regime; each member manifest stamps the regime's stop) → the campaign-scope generalize records one CampaignGeneralization (its Generalization = worst-case-R floor across instruments) → the sugar reprints generalize_json + persists the CrossInstrument family → byte-identical stdout.

Error handling

  • Argument-layer refusals (duplicate instrument, non-r-sma strategy, missing knob, multi-value flag) stay byte-identical in generalize_args_from.
  • The campaign preflight's own generalize guards (≥2 instruments, R-metric) are a redundant backstop behind the front-end, never the surfaced message under normal invocation.
  • A member-data refusal (no archive for a symbol) surfaces as the campaign member fault, exactly as the sweep sugar does.

Testing strategy

  • Byte-identity anchor (in tree, green today — the acceptance gate). generalize_real_e2e_pins_the_exact_current_grade (cli_run.rs, committed f3f32b8) pins the EXACT current R grade of the worked invocation (GER40 0.01056371324510624, USDJPY 0.005795903617609842, worst_case 0.005795903617609842, sign_agreement 2) against the current inline, axis-bound-stop path. This closes the one grounding gap the check flagged: the two stop mechanisms (grid axis vs. risk-regime seam) yielding identical R was pinned by no test, and the existing generalize e2e asserts shape only. This pin is the byte-identity anchor — after the dissolution deletes run_generalize and the same invocation flows through the risk-regime seam, this pin must stay green unchanged. That survival IS the equivalence proof and the cycle's acceptance gate: a stop-mechanism divergence fails here loudly, at implement, not silently in production. (The equivalence is thus a verified deliverable of this cycle, not an assumption about current behaviour.)
  • Parity (keep): the seven refusal pins (cli_run.rs:3547-3735) stay green unchanged — the front-end is preserved.
  • Parity (new): a real-data e2e asserts the dissolved aura generalize prints the byte-identical {"generalize":{…}} + {"family_id":"generalize-0"} and that the generated campaign + process documents and a CrossInstrument family are auto-registered (the sweep-dissolution e2e shape).
  • Content-id: the generated document carries a non-empty single-regime risk — a determinism pin (identical invocation → identical content id) like the sweep translator's.
  • The success-path stdout pins (generalize_grades_a_candidate…) shift from the inline path to the sugar path but assert the same bytes; the exact-grade anchor above additionally survives the shift.

Acceptance criteria

  • A researcher runs aura generalize … unchanged and gets byte-identical output, now produced through the one campaign path (the worked example above is the evidence).
  • The inline execution path is gone (no parallel executor); the command is thin sugar over an auto-registered, reproducible campaign document.
  • The stop rides the risk regime (a single --stop-length/--stop-k = one regime), stamped into every member manifest (C18); no new failure class against determinism/causality.
  • Registry-family parity holds: aura runs family generalize-0 finds the persisted CrossInstrument family.