From fd9a7322a014fabe05286073fbebf1e21b80ef0b Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 6 Jul 2026 16:30:36 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs,plans):=20generalize=20dissolution?= =?UTF-8?q?=20=E2=80=94=20spec=20+=20plan=20(#210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/generalize-dissolution.md | 948 +++++++++++++++++++++++++++ docs/specs/generalize-dissolution.md | 269 ++++++++ 2 files changed, 1217 insertions(+) create mode 100644 docs/plans/generalize-dissolution.md create mode 100644 docs/specs/generalize-dissolution.md diff --git a/docs/plans/generalize-dissolution.md b/docs/plans/generalize-dissolution.md new file mode 100644 index 0000000..c48c4ae --- /dev/null +++ b/docs/plans/generalize-dissolution.md @@ -0,0 +1,948 @@ +# 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`): + +```rust +/// 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, +} +``` + +- [ ] **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: + +```rust +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 { + 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, + ®istry, + ) + .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`): + +```rust + #[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`: + +```rust +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`): + +```rust +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`): + +```rust +/// 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 { + 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 = 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`): + +```rust +/// 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 { + 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(®, &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`): + +```rust +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: + +```rust +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`: + +```rust +/// 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: + +```rust +/// 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`: + +```rust + 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: + +```rust + // 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`): + +```rust +/// 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. diff --git a/docs/specs/generalize-dissolution.md b/docs/specs/generalize-dissolution.md new file mode 100644 index 0000000..51a2639 --- /dev/null +++ b/docs/specs/generalize-dissolution.md @@ -0,0 +1,269 @@ +# 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.