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

270 lines
13 KiB
Markdown

# 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:
```json
{"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)
```json
{
"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:
```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<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`)
```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<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`)
```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.