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.
40 KiB
Sweep dissolution — Implementation Plan
Parent spec:
docs/specs/sweep-dissolution.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill 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-54—StageBlock::Sweep→ optional flattenedSweepSelectiongroup - Modify:
crates/aura-research/src/lib.rs:164-173—PROCESS_BLOCKSsweep slots optional + doc text - Modify:
crates/aura-research/src/lib.rs:302-307—stage_from_valuegroup-or-nothing parse - Modify:
crates/aura-research/src/lib.rs:637—validate_processsweep metric check via the group - Modify:
crates/aura-campaign/src/lib.rs:65-93— newExecFault::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_prosenew arm - Modify:
crates/aura-cli/src/campaign_run.rs:263-445— splitrun_campaign/run_campaign_by_id(…, RunPresentation) - Modify:
crates/aura-cli/src/main.rs:4417-4476—dispatch_sweepblueprint 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_familyblock (:249-256) is unchanged and stays first. Then wrap the winner/selection block (:257-282) so it runs only forSome:
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(themodblock — addmod 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_campaignand 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(®, &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):
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_sweepsynthetic-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).