feat(cli): verb_sugar translator — argv to generated campaign documents (#210 T3)

The pure half of the sweep re-cut: translate_sweep maps a real-data
blueprint-sweep invocation (axes, name, symbol, window, canonical
blueprint bytes) to a selection-free single-stage process document plus
a campaign document carrying exactly the invocation's intent (seed 0 —
no stage consumes it — so identical invocations dedupe onto identical
content ids); register_generated puts both into the content-addressed
stores. Unit tests pin determinism, name flow, mixed-kind-axis refusal,
and that generated documents pass intrinsic validation + preflight.

Dead-code-allowed until the dispatch rewire wires the module (next
task removes the bridge attribute). Plan bytes reconciled with three
necessary deviations the spec gate surfaced: derive(Debug) for
unwrap_err, ScalarKind lives in aura_core, and 'gen' is reserved in
edition 2024.

refs #210
This commit is contained in:
2026-07-04 18:01:51 +02:00
parent c37abb2a27
commit fd0b21d070
3 changed files with 181 additions and 5 deletions
+1
View File
@@ -17,6 +17,7 @@ mod project;
mod campaign_run;
mod research_docs;
mod scaffold;
mod verb_sugar;
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
+168
View File
@@ -0,0 +1,168 @@
//! 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).
// Temporary until the dispatch rewire wires this module (plan Task 4, which
// removes this line): the translator lands one commit ahead of its caller.
#![allow(dead_code)]
use std::collections::BTreeMap;
use aura_core::{Scalar, ScalarKind};
use aura_research::{
campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
DocRef, Presentation, ProcessDoc, ProcessRef, StageBlock, StrategyEntry, Window,
FORMAT_VERSION,
};
/// The two generated documents of one dissolved-verb invocation.
#[derive(Debug)]
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,
generated: &GeneratedSweep,
) -> Result<(String, String), String> {
let process_id =
reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?;
let campaign_id =
reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?;
Ok((process_id, campaign_id))
}
#[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());
}
}
+12 -5
View File
@@ -529,14 +529,15 @@ Create `crates/aura-cli/src/verb_sugar.rs`:
use std::collections::BTreeMap;
use aura_core::Scalar;
use aura_core::{Scalar, ScalarKind};
use aura_research::{
campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
DocRef, Presentation, ProcessDoc, ProcessRef, ScalarKind, StageBlock, StrategyEntry, Window,
DocRef, Presentation, ProcessDoc, ProcessRef, StageBlock, StrategyEntry, Window,
FORMAT_VERSION,
};
/// The two generated documents of one dissolved-verb invocation.
#[derive(Debug)] // unwrap_err() in the unit tests needs T: Debug
pub(crate) struct GeneratedSweep {
pub process: ProcessDoc,
pub campaign: CampaignDoc,
@@ -622,10 +623,12 @@ pub(crate) fn translate_sweep(
/// Returns `(process_id, campaign_id)`.
pub(crate) fn register_generated(
reg: &aura_registry::Registry,
gen: &GeneratedSweep,
generated: &GeneratedSweep, // `gen` is a reserved keyword in edition 2024
) -> 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())?;
let process_id =
reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?;
let campaign_id =
reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?;
Ok((process_id, campaign_id))
}
```
@@ -804,6 +807,10 @@ pub(crate) fn run_sweep_sugar(
- [ ] **Step 3: Dispatch rewire — the real arm routes through sugar**
First, remove the temporary `#![allow(dead_code)]` inner attribute (and its
two comment lines) near the top of `crates/aura-cli/src/verb_sugar.rs` — the
module gains its caller in this step, so the bridge attribute must go.
In `crates/aura-cli/src/main.rs` `dispatch_sweep`, replace the tail of the
blueprint branch (:4471-4475):