From 3fd1cf9593873c2f9a43666fc0110014350774e5 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 4 Jul 2026 16:59:44 +0200 Subject: [PATCH] docs(cycle): sweep-dissolution spec + plan (cycle-live working artifacts) Cycle 1 of the verb-dissolution milestone (refs #210): the real-data blueprint sweep re-cut as sugar over a generated, auto-registered campaign document; optional selection group in the sweep stage vocabulary; terminal-only preflight rule. Spec auto-signed on a grounding-check PASS (decision log on #210); ephemeral per convention, removed at cycle close. --- docs/plans/sweep-dissolution.md | 1009 +++++++++++++++++++++++++++++++ docs/specs/sweep-dissolution.md | 290 +++++++++ 2 files changed, 1299 insertions(+) create mode 100644 docs/plans/sweep-dissolution.md create mode 100644 docs/specs/sweep-dissolution.md diff --git a/docs/plans/sweep-dissolution.md b/docs/plans/sweep-dissolution.md new file mode 100644 index 0000000..0ffe446 --- /dev/null +++ b/docs/plans/sweep-dissolution.md @@ -0,0 +1,1009 @@ +# 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; +use aura_research::{ + campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind, + DocRef, Presentation, ProcessDoc, ProcessRef, ScalarKind, StageBlock, StrategyEntry, Window, + FORMAT_VERSION, +}; + +/// The two generated documents of one dissolved-verb invocation. +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, + gen: &GeneratedSweep, +) -> Result<(String, String), String> { + let process_id = reg.put_process(&process_to_json(&gen.process)).map_err(|e| e.to_string())?; + let campaign_id = reg.put_campaign(&campaign_to_json(&gen.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** + +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 new file mode 100644 index 0000000..ee1f836 --- /dev/null +++ b/docs/specs/sweep-dissolution.md @@ -0,0 +1,290 @@ +# 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.