fd9a7322a0
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
270 lines
13 KiB
Markdown
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.
|