diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 70021b1..d3ed0a8 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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}; diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs new file mode 100644 index 0000000..59dbe0a --- /dev/null +++ b/crates/aura-cli/src/verb_sugar.rs @@ -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 { + let kind = match values.first() { + Some(Scalar::I64(_)) => ScalarKind::I64, + Some(Scalar::F64(_)) => ScalarKind::F64, + Some(Scalar::Bool(_)) => ScalarKind::Bool, + Some(Scalar::Timestamp(_)) => ScalarKind::Timestamp, + None => return Err(format!("axis {name}: empty value list")), + }; + let homogeneous = values.iter().all(|v| { + matches!( + (v, kind), + (Scalar::I64(_), ScalarKind::I64) + | (Scalar::F64(_), ScalarKind::F64) + | (Scalar::Bool(_), ScalarKind::Bool) + | (Scalar::Timestamp(_), ScalarKind::Timestamp) + ) + }); + if !homogeneous { + return Err(format!( + "axis {name}: mixed value kinds in one axis (an axis declares its kind once)" + )); + } + Ok(Axis { kind, values: values.to_vec() }) +} + +/// Translate a real-data blueprint sweep invocation into its two generated +/// documents. `blueprint_canonical` is the canonical blueprint JSON already +/// stored by topology hash; its content id is the strategy ref. +pub(crate) fn translate_sweep( + axes: &[(String, Vec)], + name: &str, + symbol: &str, + from_ms: i64, + to_ms: i64, + blueprint_canonical: &str, +) -> Result { + let process = ProcessDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Process, + name: "sweep".to_string(), + description: None, + pipeline: vec![StageBlock::Sweep { selection: None }], + }; + let mut doc_axes: BTreeMap = BTreeMap::new(); + for (axis_name, values) in axes { + doc_axes.insert(axis_name.clone(), axis_from_values(axis_name, values)?); + } + let campaign = CampaignDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Campaign, + name: name.to_string(), + description: None, + data: DataSection { + instruments: vec![symbol.to_string()], + windows: vec![Window { from_ms, to_ms }], + }, + strategies: vec![StrategyEntry { + r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), + axes: doc_axes, + }], + process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, + // No stage of a selection-free single-sweep pipeline consumes the + // seed; a fixed zero keeps generated bytes deterministic so identical + // invocations dedupe onto identical content ids. + seed: 0, + presentation: Presentation { + persist_taps: vec![], + emit: vec!["family_table".to_string()], + }, + }; + Ok(GeneratedSweep { process, campaign }) +} + +/// Auto-register both generated documents (content-addressed, idempotent). +/// Returns `(process_id, campaign_id)`. +pub(crate) fn register_generated( + reg: &aura_registry::Registry, + 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)> { + 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()); + } +} diff --git a/docs/plans/sweep-dissolution.md b/docs/plans/sweep-dissolution.md index 0ffe446..04a7393 100644 --- a/docs/plans/sweep-dissolution.md +++ b/docs/plans/sweep-dissolution.md @@ -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):