diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index b25d9ac..2f7f1c8 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -123,11 +123,34 @@ struct CampaignRunLine<'a> { campaign_run: &'a CampaignRunRecord, } -/// Suffix-join each raw campaign axis onto exactly one wrapped param (wrapped -/// == raw, or wrapped ends with ".{raw}" — the established suffix-match -/// pattern), then require every wrapped slot to be covered. Pure and -/// independent of the env/data seam so the three fault arms are unit-testable -/// without a project, a store, or a loaded blueprint. +/// Strip the wrapper's exactly-one leading node segment from a WRAPPED param +/// path, yielding the RAW campaign-axis name the doc speaks (#203): the bind +/// convention prefixes a strategy's own param paths with one root-composite +/// segment before they enter the runnable (wrapped) param space, so the raw +/// axis is everything after that segment's dot. Returns `name` unchanged if +/// there is no dot (defensive; every wrapped param space produced by +/// `blueprint_axis_probe` has one). This is the single definition of "the +/// wrapper segment" — [`raw_matches_wrapped`] is built on it so a wrap-depth +/// change cannot desync the two directions of the #203 convention. +pub(crate) fn wrapped_to_raw_axis(name: &str) -> &str { + name.split_once('.').map(|(_, rest)| rest).unwrap_or(name) +} + +/// Does a RAW campaign-axis name (the doc's own namespace) address this ONE +/// wrapped param path? True for an exact match (an unwrapped param, if one +/// ever exists) or when stripping the wrapper's one node segment +/// ([`wrapped_to_raw_axis`]) yields exactly `raw`. Inverse of +/// `wrapped_to_raw_axis`, co-located and built on it (#203): the two are the +/// only two places the wrapper-segment convention is expressed. +pub(crate) fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool { + wrapped == raw || wrapped_to_raw_axis(wrapped) == raw +} + +/// Suffix-join each raw campaign axis onto exactly one wrapped param +/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one +/// node segment yields raw), then require every wrapped slot to be covered. +/// Pure and independent of the env/data seam so the three fault arms are +/// unit-testable without a project, a store, or a loaded blueprint. fn bind_axes( space: &[ParamSpec], strategy_id: &str, @@ -138,7 +161,7 @@ fn bind_axes( let hits: Vec = space .iter() .enumerate() - .filter(|(_, p)| p.name == *raw || p.name.ends_with(&format!(".{raw}"))) + .filter(|(_, p)| raw_matches_wrapped(raw, &p.name)) .map(|(i, _)| i) .collect(); match hits.as_slice() { @@ -166,8 +189,7 @@ fn bind_axes( // Speak the RAW campaign-axis namespace (the doc's own): the // wrapped space prefixes the signal's params with one node // segment, so the raw path is everything after it (#203). - let raw = - spec.name.split_once('.').map(|(_, rest)| rest).unwrap_or(&spec.name); + let raw = wrapped_to_raw_axis(&spec.name); return Err(MemberFault::Bind(format!( "open param \"{raw}\" of strategy {strategy_id} is bound by no \ campaign axis (wrapped path: {})", @@ -803,6 +825,18 @@ mod tests { assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); } + #[test] + /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse + /// pair of one wrapper-segment convention — stripping the wrapper off a + /// wrapped name must satisfy the match predicate against that SAME + /// wrapped name, and a raw name from a DIFFERENT wrapped param must not. + fn raw_matches_wrapped_and_wrapped_to_raw_axis_are_inverses() { + let wrapped = "sma_signal.fast.length"; + assert_eq!(wrapped_to_raw_axis(wrapped), "fast.length"); + assert!(raw_matches_wrapped(wrapped_to_raw_axis(wrapped), wrapped)); + assert!(!raw_matches_wrapped("fast.length", "sma_signal.slow.length")); + } + #[test] /// bind_axes resolves a raw axis name against the ONE wrapped param whose /// path ends with ".{raw}" (or equals it), producing a co-indexed point. diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0397e43..4004c35 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -4529,11 +4529,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { // `param_space`) refuses every axis as unknown. let raw_axes: Vec<(String, Vec)> = axes .iter() - .map(|(n, v)| { - let raw = - n.split_once('.').map(|(_, rest)| rest.to_string()).unwrap_or_else(|| n.clone()); - (raw, v.clone()) - }) + .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) .collect(); verb_sugar::run_sweep_sugar( &raw_axes, &name, &symbol, from_ms, to_ms, &canonical, env, diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index b410efc..3b4b27b 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -122,18 +122,40 @@ block's typed slots headlessly: ``` $ aura process introspect --vocabulary -std::sweep evaluate the campaign's axes-space; reduce members to R metrics; select a winner +std::sweep evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only) std::gate filter survivors by a conjunction of typed metric predicates std::walk_forward rolling in-sample optimize + out-of-sample test std::monte_carlo R-bootstrap over realised R (terminal annotator): ... std::generalize cross-instrument worst-case floor (terminal annotator): ... $ aura process introspect --block std::sweep -std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; select a winner - metric required, metric name (see metric_vocabulary) - select required, select rule: argmax | plateau:mean | plateau:worst +std::sweep — evaluate the campaign's axes-space; reduce members to R metrics; optionally select a winner (the selection group metric+select is all-or-nothing; omitted = selection-free, terminal-stage-only) + metric optional, metric name (see metric_vocabulary) + select optional, select rule: argmax | plateau:mean | plateau:worst deflate optional, bool ``` +`metric` and `select` form one **selection group**: all-or-nothing (a +document naming one without the other is refused, "the selection group is +all-or-nothing"), `deflate` composes only when the group is present. Omit +the group entirely for a **selection-free sweep** — the family itself is +the result, no winner is chosen. A selection-free sweep is only legal as +the pipeline's *last* stage (the executor's preflight refuses one followed +by any other block, since a downstream stage would have no nominee to +consume): + +```json +{ + "format_version": 1, + "kind": "process", + "name": "explore-only-sweep", + "pipeline": [ { "block": "std::sweep" } ] +} +``` + +The executor records this stage's family (every member run) but no +`StageSelection` and no nominee — recording a winner here would fabricate a +selection intent the document never expressed. + ### Worked example: full v2 pipeline (sweep → gate → walk-forward → Monte-Carlo → generalize) ```json diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 142cad2..ff3e1bf 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -2317,11 +2317,21 @@ the project layout and docs-by-role are open (#192 context). two-tier validation, the op-script-precedent introspection contract (`aura process|campaign introspect --vocabulary/--block/--unwired/--content-id`), and content-addressed stores (C18 realization note) — authorable and checkable headless, no - compile, no run. Still open here: the executor question (deliberately unscoped until the - intent-persistence diagnosis is re-tested, #189), the "once it carries" dissolution of the - hard-wired verbs into the campaign path (user decision 2026-07-03 on #188), and the - remaining #188 amendment package (role-model contract entry; C20 "plain Rust loops" - clause; C22 wording; invariant-10 clarification). + compile, no run. **Status update (cycles 0107–0110).** The executor question resolved and + shipped (cycles 0107/0108, #198/#200: `aura-campaign` executes the v2 pipeline shape over + the `MemberRunner` seam); the #188 amendment package landed 2026-07-03 (C25 role-model + entry, C20/C22 refinements, invariant-10 clarification). The "once it carries" dissolution + of the hard-wired verbs (user decision 2026-07-03 on #188) is **running as the milestone + "Verb dissolution" (#210)**: the fork triage of 2026-07-04 decided its design (dissolve + the real-data blueprint branches only, built-in/synthetic branches stay verb-wired until + #159; generated documents auto-registered; full behaviour parity modulo the recorded + additive instrument stamp), and cycle 0110 dissolved the first verb — `aura sweep + --real …` now runs as sugar over a generated, content-addressed campaign + document through the one executor, enabled by the `std::sweep` selection group becoming + optional (all-or-nothing, selection-free = terminal-stage-only; wire form and every + stored content id unchanged). Ad-hoc sweep intent no longer evaporates into shell + history — the #188 diagnosis's cure applied to the verbs themselves. Remaining there: + generalize, walkforward, and mc's R-bootstrap path (#210 decision 7 order). - **Inferential honesty of the World — family-selection without false-discovery control (tracked: milestone "Inferential validation (defend against false discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's diff --git a/docs/glossary.md b/docs/glossary.md index c7b6f07..83a8462 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -45,7 +45,7 @@ A downstream consumer node, never part of the strategy: the signal-quality side ### campaign document **Avoid:** experiment doc, campaign file -The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored, validated, and executed headless (`aura campaign validate|introspect|register|run`), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura campaign run` (v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. +The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist — the closed `tap` vocabulary — and tables to emit). Authored, validated, and executed headless (`aura campaign validate|introspect|register|run`), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent, executed by `aura campaign run` (v2 pipeline shape `std::sweep [std::gate]* [std::walk_forward]? [std::monte_carlo]? [std::generalize]?` — the two annotators terminal, cycles 0107/0108, #198/#200) into a `campaign run` realization. `std::sweep`'s own selection group (`metric`+`select`) is optional, all-or-nothing, and permitted only as the pipeline's terminal stage when omitted (a selection-free sweep, #210). ### campaign run **Avoid:** campaign execution record, realized campaign diff --git a/docs/plans/sweep-dissolution.md b/docs/plans/sweep-dissolution.md deleted file mode 100644 index 04a7393..0000000 --- a/docs/plans/sweep-dissolution.md +++ /dev/null @@ -1,1016 +0,0 @@ -# Sweep dissolution — Implementation Plan - -> **Parent spec:** `docs/specs/sweep-dissolution.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Re-cut the real-data blueprint branch of `aura sweep` as thin sugar -over a generated, auto-registered campaign document run through the one -campaign executor; make the `std::sweep` selection triple an optional group; -add the terminal-only preflight rule. - -**Architecture:** Three layers. `aura-research` gets the vocabulary change -(`Option`, flattened so the wire form is unchanged). -`aura-campaign` gets the terminal-only preflight rule and the selection-free -executor arm (family appended, no `StageSelection`, no nominee). `aura-cli` -gets a new `verb_sugar` module (argv → generated documents → content-addressed -registration → campaign run in sugar presentation mode) plus the dispatch -rewire that deletes the inline real-data arm. - -**Tech Stack:** Rust workspace; crates `aura-research`, `aura-campaign`, -`aura-cli`; serde (flatten group), content-addressed doc stores, gated -real-data integration tests (GER40 Sept-2024). - -**Files this plan creates or modifies:** - -- Create: `crates/aura-cli/src/verb_sugar.rs` — argv→document translator + registration + sugar run entry -- Modify: `crates/aura-research/src/lib.rs:47-54` — `StageBlock::Sweep` → optional flattened `SweepSelection` group -- Modify: `crates/aura-research/src/lib.rs:164-173` — `PROCESS_BLOCKS` sweep slots optional + doc text -- Modify: `crates/aura-research/src/lib.rs:302-307` — `stage_from_value` group-or-nothing parse -- Modify: `crates/aura-research/src/lib.rs:637` — `validate_process` sweep metric check via the group -- Modify: `crates/aura-campaign/src/lib.rs:65-93` — new `ExecFault::SelectionFreeSweepNotTerminal` -- Modify: `crates/aura-campaign/src/lib.rs:236-245` — preflight sweep arm: group destructure + terminal rule -- Modify: `crates/aura-campaign/src/exec.rs:244-297` — executor sweep arm: selection-free path -- Modify: `crates/aura-cli/src/campaign_run.rs:48-97` — `exec_fault_prose` new arm -- Modify: `crates/aura-cli/src/campaign_run.rs:263-445` — split `run_campaign` / `run_campaign_by_id(…, RunPresentation)` -- Modify: `crates/aura-cli/src/main.rs:4417-4476` — `dispatch_sweep` blueprint branch: real → sugar -- Modify: `crates/aura-cli/tests/cli_run.rs:1421-…` — characterization pin: the one sanctioned assertion flip + store assertions -- Test: `crates/aura-research/src/lib.rs` — selection-free round-trip, half-group refusals, no-drift pin untouched -- Test: `crates/aura-campaign/src/lib.rs` + `tests/execute.rs` — terminal rule, selection-free realization -- Test: `crates/aura-cli/src/verb_sugar.rs` — generated-document bytes, determinism, kind derivation - ---- - -### Task 1: aura-research — the optional selection group - -**Files:** -- Modify: `crates/aura-research/src/lib.rs:47-54, 164-173, 302-307, 637, 984` (+ tests) - -- [ ] **Step 1: Replace the Sweep variant with the flattened optional group** - -In `crates/aura-research/src/lib.rs`, replace (at :47-54): - -```rust - #[serde(rename = "std::sweep")] - Sweep { - metric: String, - select: SelectRule, - #[serde(default, skip_serializing_if = "is_false")] - deflate: bool, - }, -``` - -with: - -```rust - #[serde(rename = "std::sweep")] - Sweep { - /// The selection triple is one optional group: `None` is a - /// selection-free sweep (permitted only as the terminal stage — the - /// executor's preflight rule), `Some` carries metric+select(+deflate). - /// Flattened so the wire form stays the flat `metric`/`select`/ - /// `deflate` slots and every existing document's canonical bytes — - /// and content id — are unchanged. - #[serde(flatten, skip_serializing_if = "Option::is_none")] - selection: Option, - }, -``` - -and add, directly after the `StageBlock` enum (after the `is_false` fn at :72-74): - -```rust -/// The sweep stage's selection group — all-or-nothing (a half-populated -/// group is unrepresentable; the schema-strict parser refuses it). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SweepSelection { - pub metric: String, - pub select: SelectRule, - #[serde(default, skip_serializing_if = "is_false")] - pub deflate: bool, -} -``` - -- [ ] **Step 2: Schema table — selection slots optional** - -At :164-173 replace the `std::sweep` `BlockSchema` entry with: - -```rust - BlockSchema { - id: "std::sweep", - doc: "evaluate the campaign's axes-space; reduce members to R metrics; \ - optionally select a winner (the selection group metric+select is \ - all-or-nothing; omitted = selection-free, terminal-stage-only)", - slots: &[ - SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false }, - SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false }, - SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false }, - ], - }, -``` - -- [ ] **Step 3: Parser — group-or-nothing** - -At :302-307 replace the `"std::sweep"` arm of `stage_from_value`: - -```rust - "std::sweep" => Ok(StageBlock::Sweep { - metric: require_str(m, "metric", &id)?, - select: select_from(m, &id)?, - deflate: optional_bool(m, "deflate", &id)?, - }), -``` - -with: - -```rust - "std::sweep" => { - let has_metric = m.contains_key("metric"); - let has_select = m.contains_key("select"); - let selection = match (has_metric, has_select) { - (true, true) => Some(SweepSelection { - metric: require_str(m, "metric", &id)?, - select: select_from(m, &id)?, - deflate: optional_bool(m, "deflate", &id)?, - }), - (false, false) => { - if m.contains_key("deflate") { - return Err(format!( - "block {id}: slot \"deflate\" needs the selection \ - group (\"metric\" + \"select\")" - )); - } - None - } - (true, false) => { - return Err(format!( - "block {id}: slot \"metric\" without \"select\" — the \ - selection group is all-or-nothing" - )); - } - (false, true) => { - return Err(format!( - "block {id}: slot \"select\" without \"metric\" — the \ - selection group is all-or-nothing" - )); - } - }; - Ok(StageBlock::Sweep { selection }) - } -``` - -- [ ] **Step 4: validate_process — metric check via the group** - -At :637 the combined arm - -```rust - StageBlock::Sweep { metric, .. } | StageBlock::Generalize { metric } => { - if !is_known_metric(metric) { - faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); - } - } -``` - -becomes two arms: - -```rust - StageBlock::Sweep { selection } => { - if let Some(sel) = selection { - if !is_known_metric(&sel.metric) { - faults.push(DocFault::UnknownMetric { - stage: i, - metric: sel.metric.clone(), - }); - } - } - } - StageBlock::Generalize { metric } => { - if !is_known_metric(metric) { - faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); - } - } -``` - -- [ ] **Step 5: Fix the in-crate compile breaks (tests)** - -`git grep -n 'StageBlock::Sweep' crates/aura-research/` — every named-field -construct/destructure adapts to the group. At :984 (the -`process_fixture_parses_to_typed_stages` destructure) replace the -`Sweep { metric, select, deflate }` destructure with: - -```rust - StageBlock::Sweep { selection } => { - let sel = selection.as_ref().expect("fixture sweep carries a selection"); - assert_eq!(sel.metric, "sqn_normalized"); - assert_eq!(sel.select, SelectRule::Argmax); - assert!(sel.deflate); - } -``` - -(adapt the three asserted values to what the existing test asserted — keep the -asserted VALUES identical, only the access path changes). - -- [ ] **Step 6: Adjust the two schema-table-dependent introspection tests** - -At ~:1476 (`vocabularies_enumerate…`, asserts the sweep `metric` slot is -`required`) and ~:1535 (`open_slots_report_partial_documents_by_path`, expects -`pipeline[0].select` reported as a required open slot): flip the expectations -to `required: false` / not-reported-as-open. Keep every other assertion. - -- [ ] **Step 7: New vocabulary tests** (append to the crate's test module) - -```rust - #[test] - fn sweep_selection_group_is_all_or_nothing() { - let free = serde_json::json!({ "block": "std::sweep" }); - assert_eq!( - stage_from_value(&free).unwrap(), - StageBlock::Sweep { selection: None } - ); - let metric_only = serde_json::json!({ "block": "std::sweep", "metric": "sqn" }); - let err = stage_from_value(&metric_only).unwrap_err(); - assert!(err.contains("all-or-nothing"), "{err}"); - let select_only = serde_json::json!({ "block": "std::sweep", "select": "argmax" }); - let err = stage_from_value(&select_only).unwrap_err(); - assert!(err.contains("all-or-nothing"), "{err}"); - let deflate_only = serde_json::json!({ "block": "std::sweep", "deflate": true }); - let err = stage_from_value(&deflate_only).unwrap_err(); - assert!(err.contains("needs the selection group"), "{err}"); - } - - #[test] - fn selection_free_sweep_round_trips_and_hashes_distinctly() { - let doc = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "sweep".to_string(), - description: None, - pipeline: vec![StageBlock::Sweep { selection: None }], - }; - let text = process_to_json(&doc); - // the absent group leaves ONLY the block id on the wire - assert!(text.contains("\"block\":\"std::sweep\""), "{text}"); - assert!(!text.contains("metric"), "{text}"); - assert!(!text.contains("select"), "{text}"); - let back = parse_process(&text).expect("selection-free sweep parses"); - assert_eq!(back, doc); - // a selected sibling hashes differently - let selected = ProcessDoc { - pipeline: vec![StageBlock::Sweep { - selection: Some(SweepSelection { - metric: "sqn_normalized".to_string(), - select: SelectRule::Argmax, - deflate: false, - }), - }], - ..doc.clone() - }; - assert_ne!(content_id_of(&text), content_id_of(&process_to_json(&selected))); - } -``` - -(If `process_to_json` / `parse_process` / `content_id_of` are named differently -in the crate, use the crate's actual canonicalize/parse/id functions — they -exist; the golden-pin test at :1141 uses them.) - -- [ ] **Step 8: Verify (partial gate — this crate only)** - -Run: `cargo test -p aura-research` -Expected: PASS, including `process_canonical_form_is_pinned_and_id_stable` -**byte-untouched** (that test is NOT edited — it proves no stored content id -moves). The workspace does NOT build yet (aura-campaign still destructures the -old shape) — that is Task 2's gate, not this one. - ---- - -### Task 2: aura-campaign + fault prose — terminal rule and selection-free arm - -**Files:** -- Modify: `crates/aura-campaign/src/lib.rs:65-93, 236-245` (+ test helpers :470, :859, :1177, :1207) -- Modify: `crates/aura-campaign/src/exec.rs:244-297` (+ `tests/execute.rs:164`) -- Modify: `crates/aura-cli/src/campaign_run.rs:48-97` (`exec_fault_prose` — compile-lockstep, same task) - -- [ ] **Step 1: New fault variant** - -In `crates/aura-campaign/src/lib.rs`, inside `pub enum ExecFault` (after the -`DeflatePlateauConflict` variant at :79): - -```rust - /// A selection-free sweep (no metric/select group) anywhere but the - /// pipeline's terminal stage: every downstream stage consumes the - /// selection state or the nominee. - SelectionFreeSweepNotTerminal { stage: usize }, -``` - -- [ ] **Step 2: Preflight — group destructure + terminal rule** - -At :236-245 replace the sweep arm of the per-stage loop: - -```rust - StageBlock::Sweep { metric, select, deflate } => { - if !RANKABLE_METRICS.contains(&metric.as_str()) { - return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() }); - } - if *deflate && *select != SelectRule::Argmax { - return Err(ExecFault::DeflatePlateauConflict { stage: i }); - } - } -``` - -with: - -```rust - StageBlock::Sweep { selection } => match selection { - Some(sel) => { - if !RANKABLE_METRICS.contains(&sel.metric.as_str()) { - return Err(ExecFault::UnrankableMetric { - stage: i, - metric: sel.metric.clone(), - }); - } - if sel.deflate && sel.select != SelectRule::Argmax { - return Err(ExecFault::DeflatePlateauConflict { stage: i }); - } - } - None => { - if i + 1 != process.pipeline.len() { - return Err(ExecFault::SelectionFreeSweepNotTerminal { stage: i }); - } - } - }, -``` - -(the `use aura_research::SweepSelection;` import is only needed where tests -construct it — the arm itself needs none). - -- [ ] **Step 3: Executor — selection-free path** - -In `crates/aura-campaign/src/exec.rs` at :246, the sweep arm destructure -becomes `StageBlock::Sweep { selection } => {`. The family name + `run_members` -+ `append_family` block (:249-256) is unchanged and stays first. Then wrap the -winner/selection block (:257-282) so it runs only for `Some`: - -```rust - match selection { - Some(sel) => { - let (winner, selection) = select_sweep_winner( - &family, - &grid.axis_lens, - &sel.metric, - sel.select, - sel.deflate, - seed, - )?; - let winner_ordinal = family - .points - .iter() - .position(|p| p == &winner) - .expect("the winner is a member of its own family"); - let params = zip_params(&family.space, &winner.params); - selections.push(StageSelectionOut { - stage: stage_ordinal, - block: "std::sweep", - family_id: family_id.clone(), - winner_ordinal, - params: params.clone(), - selection: selection.clone(), - }); - // Nominate the sweep winner (superseded by a later - // walk_forward stage; cleared by an empty gate). - nominee = Some((params.clone(), winner.report.clone())); - stages.push(StageRealization { - block: "std::sweep".to_string(), - family_id: Some(family_id.clone()), - survivor_ordinals: None, - selection: Some(StageSelection { winner_ordinal, params, selection }), - bootstrap: None, - }); - } - None => { - // Selection-free sweep (terminal, preflight-checked): - // the family is the whole result — no StageSelection - // is recorded (recording one would fabricate intent), - // no nominee is produced. - stages.push(StageRealization { - block: "std::sweep".to_string(), - family_id: Some(family_id.clone()), - survivor_ordinals: None, - selection: None, - bootstrap: None, - }); - } - } -``` - -The survivors assignment and `families.push` (:283-296) stay after the match, -common to both arms, unchanged. - -- [ ] **Step 4: Thread the in-crate constructions** - -`git grep -n 'StageBlock::Sweep' crates/aura-campaign/` — adapt each -named-field site: the `sweep_stage` test helper at lib.rs:470 keeps its -signature and wraps its fields into -`selection: Some(SweepSelection { metric, select, deflate })` (add -`use aura_research::SweepSelection;` to the test module); the constructs at -lib.rs:859, :1177, :1207 and tests/execute.rs:164 route through the helper or -wrap the same way. The four `{ .. }` matches (lib.rs:185, 194, 206, 213) need -no edit. - -- [ ] **Step 5: exec_fault_prose — the new arm (compile-lockstep)** - -In `crates/aura-cli/src/campaign_run.rs`, `exec_fault_prose`'s exhaustive -match gains (next to the `DeflatePlateauConflict` arm, matching its style): - -```rust - ExecFault::SelectionFreeSweepNotTerminal { stage } => format!( - "stage {stage}: a sweep without a selection group (metric + select) \ - must be the last stage of its process" - ), -``` - -- [ ] **Step 6: New tests** - -In `crates/aura-campaign/src/lib.rs` tests (reuse the existing preflight test -scaffolding — a minimal campaign + process pair; mirror -`preflight_refuses_non_sweep_first_and_double_sweep`'s construction): - -```rust - #[test] - fn preflight_accepts_a_terminal_selection_free_sweep() { - let process = ProcessDoc { - format_version: aura_research::FORMAT_VERSION, - kind: DocKind::Process, - name: "sweep".into(), - description: None, - pipeline: vec![StageBlock::Sweep { selection: None }], - }; - assert!(preflight(&process, &campaign_fixture()).is_ok()); - } - - #[test] - fn preflight_refuses_a_non_terminal_selection_free_sweep() { - let process = ProcessDoc { - format_version: aura_research::FORMAT_VERSION, - kind: DocKind::Process, - name: "sweep-gate".into(), - description: None, - pipeline: vec![ - StageBlock::Sweep { selection: None }, - StageBlock::Gate { - all: vec![Predicate { - metric: "sqn".into(), - cmp: Cmp::Gt, - value: 0.0, - }], - }, - ], - }; - match preflight(&process, &campaign_fixture()) { - Err(ExecFault::SelectionFreeSweepNotTerminal { stage: 0 }) => {} - other => panic!("expected SelectionFreeSweepNotTerminal, got {other:?}"), - } - } -``` - -(`campaign_fixture()` = whatever minimal `CampaignDoc` helper the existing -preflight tests use — reuse it verbatim; if it is named differently, use that -name.) In `crates/aura-campaign/tests/execute.rs`, add a sibling of -`execute_sweep_only_records_family_and_selection` running a selection-free -single-sweep process over the same `FakeRunner` fixture: - -```rust -#[test] -fn execute_selection_free_sweep_records_family_without_selection() { - // same scaffolding as execute_sweep_only_records_family_and_selection, - // with the process's sweep stage carrying `selection: None`. - // Assertions: - // - exactly one family appended, FamilyKind::Sweep, full grid membership; - // - outcome.record.cells[0].stages[0].selection.is_none(); - // - outcome.cells[0].selections.is_empty(); - // - record.generalizations is empty and no nominee-dependent stage ran. -} -``` - -(fill the body by copying the existing test and flipping the process fixture + -assertions as listed — the existing test at tests/execute.rs:164 is the -template; keep its runner and registry setup byte-identical). - -- [ ] **Step 7: Verify (workspace gate)** - -Run: `cargo test -p aura-campaign` -Expected: PASS (new tests included). -Run: `cargo test -p aura-cli --test research_docs` -Expected: PASS unchanged — every stored fixture carries the full selection -group, which still parses to `Some`. -Run: `cargo build --workspace` -Expected: 0 errors (the vocabulary change is now threaded everywhere). - ---- - -### Task 3: verb_sugar — the argv→document translator (pure) - -**Files:** -- Create: `crates/aura-cli/src/verb_sugar.rs` -- Modify: `crates/aura-cli/src/main.rs:14-19` (the `mod` block — add `mod verb_sugar;`) - -- [ ] **Step 1: The module** - -Create `crates/aura-cli/src/verb_sugar.rs`: - -```rust -//! Verb sugar: the dissolved orchestration verbs' argv → generated campaign -//! documents (docs/specs/sweep-dissolution.md; #210 fork decisions). -//! -//! A dissolved verb invocation is translated into a selection-free process -//! document plus a campaign document expressing exactly the invocation's -//! intent, both auto-registered content-addressed (every ad-hoc invocation -//! becomes durable, diffable, reproducible intent), then run through the one -//! campaign executor in sugar presentation mode (member lines only). - -use std::collections::BTreeMap; - -use aura_core::{Scalar, ScalarKind}; -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, -}; - -/// The two generated documents of one dissolved-verb invocation. -#[derive(Debug)] // unwrap_err() in the unit tests needs T: Debug -pub(crate) struct GeneratedSweep { - pub process: ProcessDoc, - pub campaign: CampaignDoc, -} - -/// Map one verb axis (`--axis name=csv`, already scalar-parsed) to a -/// document `Axis`: the kind is the first value's kind; a mixed-kind CSV is -/// refused (the document axis declares its kind ONCE — #189). -fn axis_from_values(name: &str, values: &[Scalar]) -> Result { - let kind = match values.first() { - Some(Scalar::I64(_)) => ScalarKind::I64, - Some(Scalar::F64(_)) => ScalarKind::F64, - Some(Scalar::Bool(_)) => ScalarKind::Bool, - Some(Scalar::Timestamp(_)) => ScalarKind::Timestamp, - None => return Err(format!("axis {name}: empty value list")), - }; - let homogeneous = values.iter().all(|v| { - matches!( - (v, kind), - (Scalar::I64(_), ScalarKind::I64) - | (Scalar::F64(_), ScalarKind::F64) - | (Scalar::Bool(_), ScalarKind::Bool) - | (Scalar::Timestamp(_), ScalarKind::Timestamp) - ) - }); - if !homogeneous { - return Err(format!( - "axis {name}: mixed value kinds in one axis (an axis declares its kind once)" - )); - } - Ok(Axis { kind, values: values.to_vec() }) -} - -/// Translate a real-data blueprint sweep invocation into its two generated -/// documents. `blueprint_canonical` is the canonical blueprint JSON already -/// stored by topology hash; its content id is the strategy ref. -pub(crate) fn translate_sweep( - axes: &[(String, Vec)], - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, -) -> Result { - let process = ProcessDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Process, - name: "sweep".to_string(), - description: None, - pipeline: vec![StageBlock::Sweep { selection: None }], - }; - let mut doc_axes: BTreeMap = BTreeMap::new(); - for (axis_name, values) in axes { - doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?); - } - let campaign = CampaignDoc { - format_version: FORMAT_VERSION, - kind: DocKind::Campaign, - name: name.to_string(), - description: None, - data: DataSection { - instruments: vec![symbol.to_string()], - 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))) }, - // No stage of a selection-free single-sweep pipeline consumes the - // seed; a fixed zero keeps generated bytes deterministic so identical - // invocations dedupe onto identical content ids. - seed: 0, - presentation: Presentation { - persist_taps: vec![], - emit: vec!["family_table".to_string()], - }, - }; - Ok(GeneratedSweep { process, campaign }) -} - -/// Auto-register both generated documents (content-addressed, idempotent). -/// Returns `(process_id, campaign_id)`. -pub(crate) fn register_generated( - reg: &aura_registry::Registry, - generated: &GeneratedSweep, // `gen` is a reserved keyword in edition 2024 -) -> 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)) -} -``` - -If any imported name differs in `aura-research`'s public surface (e.g. -`ProcessRef` vs an inline field type, `FORMAT_VERSION` visibility, the exact -`Presentation` field names), use the crate's actual names — `campaign_run.rs` -and `research_docs.rs` already import most of them and are the reference. -If `put_process`/`put_campaign` do not return the content id, compute it with -`content_id_of(..)` before the put and return that. - -- [ ] **Step 2: Register the module** - -In `crates/aura-cli/src/main.rs`, in the `mod` block at :14-19, add -(alphabetically ordered with its siblings): - -```rust -mod verb_sugar; -``` - -- [ ] **Step 3: Unit tests** (bottom of `verb_sugar.rs`) - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn axes() -> Vec<(String, Vec)> { - vec![ - ("fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(4)]), - ("slow.length".to_string(), vec![Scalar::I64(8), Scalar::I64(16)]), - ] - } - - #[test] - fn translate_sweep_is_deterministic_and_names_flow() { - let a = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap(); - let b = translate_sweep(&axes(), "probe", "GER40", 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" - ); - assert_eq!(a.campaign.name, "probe"); - assert_eq!(a.campaign.seed, 0); - assert_eq!(a.process.name, "sweep"); - assert_eq!(a.process.pipeline, vec![StageBlock::Sweep { selection: None }]); - assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]); - assert_eq!(a.campaign.data.windows, vec![Window { from_ms: 100, to_ms: 200 }]); - assert_eq!(a.campaign.presentation.emit, vec!["family_table".to_string()]); - assert!(a.campaign.presentation.persist_taps.is_empty()); - // the process ref is the content id of the generated process bytes - assert_eq!( - a.campaign.process.r#ref, - DocRef::ContentId(content_id_of(&process_to_json(&a.process))) - ); - } - - #[test] - fn translate_sweep_refuses_a_mixed_kind_axis() { - let mixed = vec![("k".to_string(), vec![Scalar::I64(1), Scalar::F64(2.5)])]; - let err = translate_sweep(&mixed, "n", "GER40", 1, 2, "{}").unwrap_err(); - assert!(err.contains("mixed value kinds"), "{err}"); - } - - #[test] - fn generated_campaign_validates_intrinsically_and_preflights() { - let g = translate_sweep(&axes(), "probe", "GER40", 100, 200, "{\"bp\":1}").unwrap(); - assert!(aura_research::validate_campaign(&g.campaign).is_empty()); - assert!(aura_research::validate_process(&g.process).is_empty()); - assert!(aura_campaign::preflight(&g.process, &g.campaign).is_ok()); - } -} -``` - -(Adjust `DocRef`'s `PartialEq` usage if needed — compare via matches! if the -type has no PartialEq; `CampaignDoc` derives PartialEq per :381, so it does.) - -- [ ] **Step 4: Verify** - -Run: `cargo test -p aura-cli translate_sweep` -Expected: PASS (2 tests). -Run: `cargo test -p aura-cli generated_campaign_validates` -Expected: PASS (1 test). - ---- - -### Task 4: the re-cut — presentation mode, dispatch rewire, deletion, pin flip - -**Files:** -- Modify: `crates/aura-cli/src/campaign_run.rs:263-445` -- Modify: `crates/aura-cli/src/main.rs:4417-4476` (dispatch), `:3203-3242` (doc comment) -- Modify: `crates/aura-cli/src/verb_sugar.rs` (the run entry) -- Modify: `crates/aura-cli/tests/cli_run.rs` (the sanctioned pin flip + store assertions) - -- [ ] **Step 1: Split `run_campaign` and thread the presentation mode** - -In `crates/aura-cli/src/campaign_run.rs`: - -```rust -/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated -/// member/selection lines + the always-on final `campaign_run` record line). -/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's -/// `emit: ["family_table"]` already limits emission to member lines; the mode -/// additionally suppresses the final record line so the verb's stdout -/// contract is reproduced byte-for-byte. The record append is identical in -/// both modes — presentation changes, the record does not. -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum RunPresentation { - Full, - MemberLinesOnly, -} -``` - -`run_campaign(target, env)` keeps its signature, provenance gate, and -target-resolution block (:263-294) and ends by delegating: - -```rust - run_campaign_by_id(&campaign_id, env, RunPresentation::Full) -``` - -Everything from the campaign fetch (:296) onward moves verbatim into: - -```rust -pub(crate) fn run_campaign_by_id( - campaign_id: &str, - env: &Env, - presentation: RunPresentation, -) -> Result<(), String> { -``` - -with exactly one behavioural gate added around the final record line -(:436-440): - -```rust - if presentation == RunPresentation::Full { - println!( - "{}", - serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record }) - .expect("campaign run record serializes") - ); - } -``` - -The provenance gate does NOT move into `run_campaign_by_id` — the sugar path -must work project-less exactly as the inline verb does today (the store -mechanics run against `env.registry()` in both cases; a std-only blueprint -resolves against the std vocabulary anywhere). Everything else (referential -gate, zero-survivor notes, trace persistence tail) moves unchanged. - -- [ ] **Step 2: The sugar run entry** - -Append to `crates/aura-cli/src/verb_sugar.rs`: - -```rust -/// Run one dissolved sweep invocation end-to-end: register the generated -/// documents, then execute through the one campaign path in sugar mode. -pub(crate) fn run_sweep_sugar( - axes: &[(String, Vec)], - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, - env: &crate::project::Env, -) -> Result<(), String> { - let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?; - let reg = env.registry(); - let (_process_id, campaign_id) = register_generated(®, &generated)?; - crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly) -} -``` - -(match `env.registry()`'s actual return-type ownership — `run_campaign` at -:275 does `let registry = env.registry();` and passes `®istry`; mirror it.) - -- [ ] **Step 3: Dispatch rewire — the real arm routes through sugar** - -First, remove the temporary `#![allow(dead_code)]` inner attribute (and its -two comment lines) near the top of `crates/aura-cli/src/verb_sugar.rs` — the -module gains its caller in this step, so the bridge attribute must go. - -In `crates/aura-cli/src/main.rs` `dispatch_sweep`, replace the tail of the -blueprint branch (:4471-4475): - -```rust - let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data, env), env); -``` - -with: - -```rust - let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - match data { - DataChoice::Real { .. } => { - // The dissolved branch (docs/specs/sweep-dissolution.md): - // real-data blueprint sweeps run as sugar over a generated - // campaign document through the one campaign executor. The - // blueprint store write (by topology hash) is kept — the - // canonical bytes are also the strategy ref's content. - let source = DataSource::from_choice(data, env); - let (from_ts, to_ts) = source.full_window(env); - let DataSource::Real { symbol, .. } = &source else { - unreachable!("DataChoice::Real maps to DataSource::Real"); - }; - let canonical = blueprint_to_json( - &blueprint_from_json(&doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"), - ) - .expect("a loaded blueprint re-serializes"); - let reg = env.registry(); - let topo = topology_hash( - &blueprint_from_json(&doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"), - ); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - // Content-addressed twin: the campaign strategy ref - // resolves against the blueprint store by content id. - reg.put_blueprint(&aura_research::content_id_of(&canonical), &canonical) - .unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - verb_sugar::run_sweep_sugar( - &axes, &name, symbol, from_ts.0, to_ts.0, &canonical, env, - ) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - } - DataChoice::Synthetic => { - run_blueprint_sweep( - &doc, &axes, &name, persist, - DataSource::from_choice(data, env), env, - ); - } - } -``` - -**Store-key check before coding this:** read how `run_campaign` resolves a -strategy `DocRef::ContentId` (campaign_run.rs:350-354 — -`registry.get_blueprint(id)`), and how the blueprint store keys entries -(`put_blueprint(key, bytes)` — aura-registry lib.rs:106-138). If -`get_blueprint(content_id)` already finds blueprints stored under their -topology hash (i.e. the store is keyed by the caller's key ONLY), the second -`put_blueprint` under the content id above is REQUIRED (as written). If the -store self-keys by content id, DELETE the second put and keep one. Decide by -reading, not both. - -- [ ] **Step 4: Mark `run_blueprint_sweep` synthetic-only** - -Replace its doc comment (above :3203) with one line added: - -```rust -/// Synthetic-only since the sweep dissolution (docs/specs/sweep-dissolution.md): -/// real-data invocations route through `verb_sugar::run_sweep_sugar` at the -/// dispatch boundary and never reach this fn. -``` - -(keep the existing comment lines; this is an addition, not a replacement of -the whole block). - -- [ ] **Step 5: The sanctioned pin flip + store assertions** - -In `crates/aura-cli/tests/cli_run.rs`, test -`sweep_real_blueprint_member_lines_pin_the_inline_contract`: - -(a) Rename it `sweep_real_blueprint_member_lines_pin_the_dissolved_contract` -(the contract it pins is now the dissolved branch's). - -(b) Flip exactly one assertion: - -```rust - // the load-bearing pin: the inline real-data path never stamps `instrument`. - assert!( - manifest.get("instrument").is_none(), - "the inline blueprint sweep must NOT stamp an instrument key: {line}" - ); -``` - -becomes: - -```rust - // the sanctioned additive delta (spec Goal 5): the campaign substrate - // stamps every member's manifest with the instrument. - assert_eq!( - manifest["instrument"].as_str(), - Some("GER40"), - "the dissolved sweep stamps the instrument: {line}" - ); -``` - -(c) Append store assertions before the cleanup line -(`let _ = std::fs::remove_dir_all(&dir);`): - -```rust - // Auto-registration (spec: generated documents are durable intent): - // exactly one process doc, one campaign doc, one campaign run record. - let count = |sub: &str| { - std::fs::read_dir(dir.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(dir.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"); - assert!(runs_log.contains("\"brp\""), "the --name handle lands on the campaign record"); - - // Dedup: a second identical invocation reuses both documents (content- - // addressed) and appends a second run record. - let out2 = std::process::Command::new(BIN) - .args([ - "sweep", &fixture, - "--real", "GER40", - "--from", GER40_SEPT2024_FROM_MS, - "--to", GER40_SEPT2024_TO_MS, - "--axis", "sma_signal.fast.length=2,4", - "--axis", "sma_signal.slow.length=8,16", - "--name", "brp", - ]) - .current_dir(&dir) - .output() - .unwrap(); - assert!(out2.status.success(), "second run stderr: {}", String::from_utf8_lossy(&out2.stderr)); - assert_eq!( - String::from_utf8_lossy(&out2.stdout), - stdout, - "identical invocations are byte-identical on stdout (C1)" - ); - assert_eq!(count("processes"), 1, "second identical run dedupes the process doc"); - assert_eq!(count("campaigns"), 1, "second identical run dedupes the campaign doc"); -``` - -(`dir` is a `PathBuf` per `temp_cwd`; if it is a `String`, build paths with -`format!` + `Path::new` instead of `.join`. The `stdout` variable from the -first run is already in scope. NOTE: the family count assertion already in the -test (`exactly one family persisted`) runs BEFORE the second invocation — -leave it where it is.) - -- [ ] **Step 6: Verify — the gated pin survives the re-cut** - -Run: `cargo test -p aura-cli --test cli_run sweep_real_blueprint_member_lines` -Expected: PASS (the flipped pin + store assertions, gated paths executing on -this host). -Run: `cargo test -p aura-cli --test research_docs campaign_run` -Expected: PASS unchanged (`aura campaign run` keeps `RunPresentation::Full` — -its final record line and e2e assertions are untouched). -Run: `cargo test -p aura-cli --test cli_run sweep` -Expected: PASS — every synthetic/built-in sweep test unchanged. - ---- - -### Task 5: Full-suite gate - -- [ ] **Step 1: Workspace suite** - -Run: `cargo test --workspace` -Expected: PASS, 0 failures (count >= 1045; the exact number grows by this -plan's new tests). - -- [ ] **Step 2: Lint** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean, 0 warnings. - -- [ ] **Step 3: No stray references** - -Run: `git grep -n "PreflightFault" crates/` -Expected: no matches (the spec's early draft named a type that never existed; -nothing in the tree may introduce it). -Run: `git grep -n "sweep_real_blueprint_member_lines_pin_the_inline_contract" crates/` -Expected: no matches (the pin was renamed to `…_the_dissolved_contract`). diff --git a/docs/specs/sweep-dissolution.md b/docs/specs/sweep-dissolution.md deleted file mode 100644 index ee1f836..0000000 --- a/docs/specs/sweep-dissolution.md +++ /dev/null @@ -1,290 +0,0 @@ -# Sweep dissolution — the real-data blueprint sweep as campaign sugar — Design Spec - -**Date:** 2026-07-04 -**Status:** Draft — awaiting sign-off -**Authors:** orchestrator + Claude - -Cycle 1 of the milestone "Verb dissolution — the campaign path as canonical -orchestration" (anchor issue #210; design basis: the #210 coverage map + the -fork-triage comment of 2026-07-04, all forks decided). This cycle re-cuts the -**real-data blueprint branch** of `aura sweep` as thin sugar over a generated, -auto-registered campaign document executed by the one campaign executor. The -built-in and synthetic branches stay verb-wired (#210 decision 2); the other -three verbs follow in later cycles riding on this cycle's translator skeleton -(#210 decision 7). - -## Goal - -`aura sweep --real SYM --from A --to B --axis k=v,… [--name N]` -stops executing through its inline `run_blueprint_sweep` path and instead: - -1. registers the blueprint (already shipped behaviour, kept), -2. generates a selection-free single-stage **process document** and a - **campaign document** expressing exactly the invocation's intent, -3. auto-registers both into the content-addressed stores (#210 decision 6), -4. runs them through `aura-campaign`'s executor via the existing - `CliMemberRunner`, -5. prints the same member lines as today's verb output, modulo one additive - field: the campaign substrate stamps `"instrument":""` into each - member's manifest (`campaign_run.rs` stamps `manifest.instrument`; the - inline path leaves it `None` and serde omits it). The stamp is kept — - it is provenance the substrate deliberately records; stripping it in - sugar mode would fork the substrate's behaviour for presentation - cosmetics. No existing test pins the field's absence on the dissolved - branch (#210 decision 5, refined by the grounding-check finding of - 2026-07-04). - -The invocation's intent — instrument, window, axes, strategy — thereby becomes -durable, diffable, reproducible data instead of evaporating into shell history -(#188 diagnosis). The inline real-data execution arm is deleted in the same -cycle; "thin sugar" replaces the path, it never adds a parallel one. - -Prerequisite vocabulary change (#210 decision 1): the `std::sweep` stage's -selection becomes optional — permitted only when the sweep is the terminal -stage of its pipeline. - -## Architecture - -Three layers change; the seams between them are all shipped: - -- **`aura-research`** — the `std::sweep` block's selection triple - (`metric`, `select`, `deflate`) becomes an optional *group*: either all - absent (a selection-free sweep) or `metric`+`select` present (`deflate` - defaulting as today). A half-populated triple is unrepresentable — the - hand-rolled schema-strict deserializer refuses `metric` without `select` - and vice versa. Introspection (schema tables, `--node` describe) and the - canonical form follow: the absent group serializes as absent fields, so - every existing document's canonical bytes and content id are unchanged. -- **`aura-campaign`** — preflight gains one structural rule: a selection-free - sweep is permitted iff it is the pipeline's terminal stage (every downstream - stage consumes the family's selection state or the nominee; refusing - non-terminal selection-free sweeps keeps that contract intact). The executor - skips `select_sweep_winner` for a selection-free sweep: no `StageSelection` - is recorded (recording one would fabricate intent — the audit-lie all triage - lenses rejected), no nominee is produced, the family is appended exactly as - today. -- **`aura-cli`** — a new translator module (the shared skeleton later verb - cycles extend) maps the verb's argv to the two generated documents, - auto-registers them, invokes the campaign run path, and presents the result - through the verb's own stdout contract (member lines only; the campaign - path's `selection_report` and final `campaign_run` stdout lines are - suppressed in sugar mode — the run record itself is still appended to - `campaign_runs.jsonl`). The `--real` arm of `run_blueprint_sweep`'s dispatch - is replaced by this path; the synthetic arm and `--list-axes` stay untouched. - -## Concrete code shapes - -### The user-facing invocation (unchanged surface, new substrate) - -```console -$ aura sweep blueprints/signal.json --real GER40 \ - --from 1725148800000 --to 1727740800000 \ - --axis sma_fast.LENGTH=8,12,16 --name probe -{"family_id":"…","report":{…}} -{"family_id":"…","report":{…}} -{"family_id":"…","report":{…}} -``` - -Stdout: one member line per grid point, the same `family_member_line` shape -as today plus the additive `"instrument":"GER40"` manifest stamp (see Goal, -point 5). New observable effects, all additive: - -```console -$ ls runs/processes runs/campaigns # both docs now exist, content-addressed -runs/processes/.json -runs/campaigns/.json -$ aura campaign runs # the realized run is recorded -{"campaign_run":{"campaign":"","name":"probe",…}} -``` - -### The generated documents (exact intent, closed vocabulary) - -Process document (constant for every selection-free sweep — repeated ad-hoc -sweeps dedupe onto one stored doc by content id): - -```json -{ - "format_version": 1, - "kind": "process", - "name": "sweep", - "pipeline": [ { "block": "std::sweep" } ] -} -``` - -Campaign document (one per distinct invocation shape; `--name` becomes the -campaign name, defaulting as the verb defaults today): - -```json -{ - "format_version": 1, - "kind": "campaign", - "name": "probe", - "data": { "instruments": ["GER40"], - "windows": [ { "from_ms": 1725148800000, "to_ms": 1727740800000 } ] }, - "strategies": [ { "ref": { "content_id": "" }, - "axes": { "sma_fast.LENGTH": { "kind": "I64", "values": [8, 12, 16] } } } ], - "process": { "ref": { "content_id": "" } }, - "seed": 0, - "presentation": { "persist_taps": [], "emit": ["family_table"] } -} -``` - -`seed: 0`: no stage of a selection-free single-sweep pipeline consumes the -seed (no deflation, no bootstrap); a fixed zero keeps the generated bytes -deterministic so identical invocations produce identical content ids. - -### Vocabulary change, before → after (`aura-research`) - -```rust -// before -StageBlock::Sweep { metric: String, select: SelectRule, deflate: bool } - -// after — the selection triple is one optional group -StageBlock::Sweep { selection: Option } -pub struct SweepSelection { pub metric: String, pub select: SelectRule, pub deflate: bool } -``` - -Wire form is unchanged for existing documents: `{"block":"std::sweep", -"metric":"…","select":"…"}` parses into `Some(SweepSelection{…})`; -`{"block":"std::sweep"}` parses into `None`; `metric` without `select` (or -vice versa) is a schema error naming the incomplete group. Serialization -omits the absent group, so canonical bytes of every existing document are -byte-identical and no stored content id moves. - -### Preflight rule, before → after (`aura-campaign`) - -```rust -// before: sweep always selects; nothing to check. -// after: a selection-free sweep must be terminal. -if sweep.selection.is_none() && !is_terminal_stage { - return Err(ExecFault::SelectionFreeSweepNotTerminal { … }); -} -``` - -There is no separate preflight fault type — `preflight` returns `ExecFault` -(`aura-campaign/src/lib.rs`), whose prose lives in `aura-cli`'s exhaustive -`exec_fault_prose` match; the new variant extends both (compiler-enforced). - -Executor arm: `selection: None` → append the family, emit `family_table` -lines per the presentation, record the stage realization **without** a -`StageSelection`, produce no nominee. - -### Verb dispatch, before → after (`aura-cli`) - -```rust -// before (dispatch_sweep, blueprint branch, --real present): -run_blueprint_sweep(doc, axes, name, persist, DataSource::Real{…}) // inline exec - -// after: -let gen = translate_sweep(&argv)?; // argv → (ProcessDoc, CampaignDoc) -let ids = register_generated(®, &gen)?; // auto-register both, content-addressed -run_generated_campaign(®, env, ids, RunPresentation::MemberLinesOnly)?; -// The presentation mode is a parameter threaded through the existing -// campaign-run entry (`campaign_run.rs`): `aura campaign run` keeps the -// full mode (member lines + selection_report + final campaign_run line); -// sugar mode suppresses everything but the family_table member lines. -// The record append is untouched in both modes. -``` - -The synthetic arm (`--real` absent) keeps calling the existing inline path -unchanged; `--list-axes` keeps its probe path unchanged. - -## Components - -1. **`aura-research`**: `SweepSelection` extraction; hand-rolled deserializer - update (group-or-nothing rule + refusal prose); canonical-form - serialization keeps omit-absent; introspection contract updated (the - sweep block's schema table marks the selection group optional-terminal); - intrinsic validation unchanged otherwise. -2. **`aura-campaign`**: preflight terminal-only rule + fault variant with - house-style prose; executor selection-free arm (no `StageSelection`, no - nominee); `PER_MEMBER_METRICS`/`RANKABLE_METRICS` untouched. -3. **`aura-cli`**: `verb_sugar` translator module — `translate_sweep` - (argv → documents; CSV axis parsing reuses the shipped `--axis` parser and - the axis-kind derivation from the blueprint's open params), generated-doc - registration, sugar-mode presentation (suppress `selection_report` + - `campaign_run` stdout lines; member lines pass through), dispatch rewire - of the `--real` blueprint arm, deletion of the now-dead inline real-data - execution arm of `run_blueprint_sweep`. - -## Data flow - -``` -argv ──translate_sweep──▶ ProcessDoc + CampaignDoc (canonical JSON) - ──register_generated──▶ runs/processes/.json, runs/campaigns/.json - ──campaign run path──▶ preflight ▶ executor ▶ CliMemberRunner (real archive) - ├─▶ families.jsonl (FamilyKind::Sweep, as today) - ├─▶ campaign_runs.jsonl (the realized-intent record) - └─▶ stdout: family_table member lines ONLY (sugar mode) -``` - -Family naming follows the campaign convention (`{campaign8}-…-w0-s0`); the -user's `--name` handle lives on the campaign document and its run record. The -member stdout lines never carried the family name; their only delta against -the inline path is the additive `instrument` manifest stamp (Goal, point 5). - -## Error handling - -- Verb-surface refusals keep the shipped exit-code contract: usage errors - exit 2 (clap, unchanged), runtime refusals exit 1 with `aura: …` prose. -- Translator refusals (unknown axis name, malformed CSV, missing geometry) - reuse the existing refusal prose paths — the pinned rejection tests keep - their bytes. -- Preflight faults surface through the existing campaign fault prose; - the new `SelectionFreeSweepNotTerminal` prose names the rule and the fix - ("a sweep without a selection must be the last stage"). -- A generated document that fails validation is a bug, not a user error — - the translator's output is preflighted before any member runs, so a fault - aborts with exit 1 before touching the archive. - -## Testing strategy - -1. **Characterization first (the reproduction evidence) — SHIPPED.** The pin - test `sweep_real_blueprint_member_lines_pin_the_inline_contract` - (`crates/aura-cli/tests/cli_run.rs`, gated, green on this host) pins the - inline path's observable behaviour: member count (exactly 4 stdout lines) - and odometer order, the swept bindings in `manifest.params`, exactly one - `FamilyKind::Sweep` family with 4 members — and, as of today, the - *absence* of the `instrument` manifest key on the inline path. The re-cut - task flips **exactly that one assertion** (absence → equals the `--real` - symbol, the sanctioned additive delta); every other assertion survives - unchanged, including the exactly-4-lines pin, which forces sugar mode to - suppress the campaign path's final `campaign_run` stdout line. The sibling - pin (the campaign runner's stamp) lives in - `campaign_run_real_e2e_sweep_gate_walkforward` and is untouched. -2. **Translator unit tests (ungated, pure).** argv → exact canonical JSON - bytes of both generated documents (including `seed: 0`, axis kinds, the - `content_id` refs); identical invocations → identical content ids; `--name` - flows to the campaign name. -3. **Vocabulary tests (ungated, `aura-research`).** Selection-free sweep - parses/serializes/round-trips; half-populated group refused with named - prose; existing fixture documents' canonical bytes and content ids - unchanged (the no-drift pin). -4. **Preflight tests (`aura-campaign`).** Selection-free terminal sweep - accepted; selection-free sweep followed by any stage refused; executor - records no `StageSelection` and produces no nominee for the - selection-free arm. -5. **Existing suite green unchanged.** All current sweep tests (synthetic, - built-in, `--list-axes`, rejection prose) stay untouched and green — they - pin the branches this cycle does not dissolve. - -## Acceptance criteria - -- The `--real` blueprint form of `aura sweep` executes through the campaign - executor; its inline real-data execution arm is deleted (no parallel path). -- Both generated documents are auto-registered content-addressed on every - sugar run; a `campaign_run` record is appended; repeated identical - invocations dedupe onto the same document ids. -- Member-line stdout of the dissolved form is identical to the pre-change - verb modulo the additive `instrument` manifest stamp: the shipped - characterization pin survives the re-cut with exactly one sanctioned - assertion flip (instrument-absence → instrument-equals-symbol); all its - other assertions — including exactly 4 stdout lines — are untouched. -- A selection-free `std::sweep` is a valid terminal stage of the document - vocabulary — parseable, introspectable, content-addressable — and refused - with named prose anywhere else in a pipeline; no existing document's - content id moves. -- Synthetic/built-in sweep branches, `--list-axes`, and all their pinned - tests are untouched. -- `cargo test --workspace` green; `cargo clippy --workspace --all-targets - -- -D warnings` clean.