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
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 generalizekeeps its clap structGeneralizeCmdand its argument validationgeneralize_args_from(every refusal message byte-identical).dispatch_generalizevalidates, then calls the newrun_generalize_sugarinstead of the inlinerun_generalize. - Translator.
translate_generalize(averb_sugar.rssibling oftranslate_sweep) builds a campaign document: the symbols asdata.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_sugarruns the campaign and obtains itsCampaignOutcome(via arun_campaign_returningrefactor that exposes whatrun_campaign_by_idtoday discards), reads the recordedCampaignGeneralization, and reprints today's exact{"generalize":{…}}line through the survivinggeneralize_json, then persists theFamilyKind::CrossInstrumentfamily 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+ thecross_instrument_membershelper.campaign_run.rs: factorrun_campaign_returningout ofrun_campaign_by_id(exposesCampaignOutcome);present_campaignholds the existing emission.main.rs:dispatch_generalizecalls the sugar (with the sweep dispatch arm's real-data resolution:put_blueprint,full_window, ns→ms); the inlinerun_generalizedeleted;generalize_json/family_id_linemade reachable fromverb_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 oneCampaignGeneralization(itsGeneralization= worst-case-R floor across instruments) → the sugar reprintsgeneralize_json+ persists theCrossInstrumentfamily → 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, committedf3f32b8) pins the EXACT current R grade of the worked invocation (GER400.01056371324510624, USDJPY0.005795903617609842,worst_case0.005795903617609842,sign_agreement2) 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 deletesrun_generalizeand 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 generalizeprints the byte-identical{"generalize":{…}}+{"family_id":"generalize-0"}and that the generated campaign + process documents and aCrossInstrumentfamily 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-0finds the persistedCrossInstrumentfamily.