# 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-"}` 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 --to ``` Output today (pinned at `cli_run.rs:3577-3582`), and byte-identical after dissolution: ```json {"generalize":{"metric":"expectancy_r","n_instruments":2,"worst_case":,"sign_agreement":,"per_instrument":[["GER40",],["USDJPY",]]}} {"family_id":"generalize-0"} ``` ### The generated campaign document (the new artifact) ```json { "format_version": 1, "kind": "campaign", "name": "generalize", "data": { "instruments": ["GER40","USDJPY"], "windows": [ { "from_ms": , "to_ms": } ] }, "risk": [ { "vol": { "length": 14, "k": 2.0 } } ], "strategies": [ { "ref": { "content_id": "" }, "axes": { "fast.length": { "kind": "I64", "values": [3] }, "slow.length": { "kind": "I64", "values": [12] } } } ], "process": { "ref": { "content_id": "" } }, "seed": 0, "presentation": { "persist_taps": [], "emit": ["family_table"] } } ``` and the generated process document: ```json { "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`) ```rust // 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 { 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`) ```rust // 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 { /* 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`) ```rust 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`) ```rust // 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.