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 campaign_run;
|
||||||
mod research_docs;
|
mod research_docs;
|
||||||
mod scaffold;
|
mod scaffold;
|
||||||
|
mod verb_sugar;
|
||||||
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
||||||
|
|
||||||
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
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 std::collections::BTreeMap;
|
||||||
|
|
||||||
use aura_core::Scalar;
|
use aura_core::{Scalar, ScalarKind};
|
||||||
use aura_research::{
|
use aura_research::{
|
||||||
campaign_to_json, content_id_of, process_to_json, Axis, CampaignDoc, DataSection, DocKind,
|
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,
|
FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The two generated documents of one dissolved-verb invocation.
|
/// 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(crate) struct GeneratedSweep {
|
||||||
pub process: ProcessDoc,
|
pub process: ProcessDoc,
|
||||||
pub campaign: CampaignDoc,
|
pub campaign: CampaignDoc,
|
||||||
@@ -622,10 +623,12 @@ pub(crate) fn translate_sweep(
|
|||||||
/// Returns `(process_id, campaign_id)`.
|
/// Returns `(process_id, campaign_id)`.
|
||||||
pub(crate) fn register_generated(
|
pub(crate) fn register_generated(
|
||||||
reg: &aura_registry::Registry,
|
reg: &aura_registry::Registry,
|
||||||
gen: &GeneratedSweep,
|
generated: &GeneratedSweep, // `gen` is a reserved keyword in edition 2024
|
||||||
) -> Result<(String, String), String> {
|
) -> Result<(String, String), String> {
|
||||||
let process_id = reg.put_process(&process_to_json(&gen.process)).map_err(|e| e.to_string())?;
|
let process_id =
|
||||||
let campaign_id = reg.put_campaign(&campaign_to_json(&gen.campaign)).map_err(|e| e.to_string())?;
|
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))
|
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**
|
- [ ] **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
|
In `crates/aura-cli/src/main.rs` `dispatch_sweep`, replace the tail of the
|
||||||
blueprint branch (:4471-4475):
|
blueprint branch (:4471-4475):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user