Files
Aura/docs/plans/sweep-dissolution.md
T
Brummel 3fd1cf9593 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.
2026-07-04 16:59:44 +02:00

40 KiB

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<SweepSelection>, 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-54StageBlock::Sweep → optional flattened SweepSelection group
  • Modify: crates/aura-research/src/lib.rs:164-173PROCESS_BLOCKS sweep slots optional + doc text
  • Modify: crates/aura-research/src/lib.rs:302-307stage_from_value group-or-nothing parse
  • Modify: crates/aura-research/src/lib.rs:637validate_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-97exec_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-4476dispatch_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):

    #[serde(rename = "std::sweep")]
    Sweep {
        metric: String,
        select: SelectRule,
        #[serde(default, skip_serializing_if = "is_false")]
        deflate: bool,
    },

with:

    #[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<SweepSelection>,
    },

and add, directly after the StageBlock enum (after the is_false fn at :72-74):

/// 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:

    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:

        "std::sweep" => Ok(StageBlock::Sweep {
            metric: require_str(m, "metric", &id)?,
            select: select_from(m, &id)?,
            deflate: optional_bool(m, "deflate", &id)?,
        }),

with:

        "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

            StageBlock::Sweep { metric, .. } | StageBlock::Generalize { metric } => {
                if !is_known_metric(metric) {
                    faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
                }
            }

becomes two arms:

            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:

        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)
    #[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):

    /// 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:

            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:

            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:
                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):

        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):

    #[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:

#[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:

//! 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<Axis, String> {
    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<Scalar>)],
    name: &str,
    symbol: &str,
    from_ms: i64,
    to_ms: i64,
    blueprint_canonical: &str,
) -> Result<GeneratedSweep, String> {
    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<String, Axis> = 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):

mod verb_sugar;
  • Step 3: Unit tests (bottom of verb_sugar.rs)
#[cfg(test)]
mod tests {
    use super::*;

    fn axes() -> Vec<(String, Vec<Scalar>)> {
        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:

/// 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:

    run_campaign_by_id(&campaign_id, env, RunPresentation::Full)

Everything from the campaign fetch (:296) onward moves verbatim into:

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):

    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:

/// 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<Scalar>)],
    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(&reg, &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 &registry; 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):

            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:

            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:

/// 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:

        // 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:

        // 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);):

    // 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).