audit: cycle close generalize dissolution — drift-clean (#210)
Second verb dissolution (#210) closed drift-clean. Architect: no ledger drift. What holds (architect, verified against the diff + a live test run): - Byte-parity proven, not assumed — the exact-grade anchor runs green through the dissolved path; {"family_id":"generalize-0"} survives (the per-cell Sweep families take a distinct campaign-prefixed name). - C18 stamping preserved — the member runner maps cell.regime -> StopRule::Vol and stamps manifest.instrument; the anchor confirms regime-seam R == former grid-axis R. - run_campaign_returning/present_campaign split is behaviour-preserving — the run record is written inside execute, so the sugar path (bypassing the presenter) still records it. - Q4 is not drift — the +2 per-instrument Sweep families are the executor's uniform, reproducible audit artifacts (sweep-dissolution precedent, C18-consistent); stdout unchanged; the relaxed family pin is faithful. Regression: cargo test --workspace green (every crate 0 failed); clippy --workspace -D warnings clean. Drift resolution: - [medium] no-window generalize grades over symbols[0].full_window (one shared window) vs the inline path's per-instrument windows. RATIFIED: a single shared window is the campaign model's natural expression, the explicit-window path (both e2e + the exact-grade anchor) is byte-identical, and symbols[0] is a pragmatic pick; no value pin because a full-archive grade is fragile against archive growth. Deferred design reconsideration filed as #213. - [low] two #[allow(too_many_arguments)] in verb_sugar.rs — carry-on; the arg-bundling refactor is best done once the remaining verbs dissolve (#214). Spec + plan removed at cycle close (git-tracked in this project). refs #210
This commit is contained in:
@@ -1,948 +0,0 @@
|
||||
# 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<data_server::DataServer>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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<CampaignRun, String> {
|
||||
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<GeneratedGeneralize, String> {
|
||||
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<String, Axis> = 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<aura_registry::RunReport> {
|
||||
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.
|
||||
@@ -1,269 +0,0 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user