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:
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user