3fd1cf9593
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.
1010 lines
40 KiB
Markdown
1010 lines
40 KiB
Markdown
# 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-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<SweepSelection>,
|
|
},
|
|
```
|
|
|
|
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<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):
|
|
|
|
```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<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`:
|
|
|
|
```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<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):
|
|
|
|
```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`).
|