# 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`).