feat(cli): swap the scaffold's starter node for a param-bearing Scale (#183)

The `aura new` scaffold shipped one node, a param-less Identity, so a
downstream author had no public example of a build closure consuming a
bound param — the highest-value gap the project-environment fieldtest found
(F2): a newcomer could copy a param-less node but had to read engine source
to write their first *tunable* one, which the consumer contract forbids.
Swap that single starter node for a Scale node (mirroring the std gain
operator): its schema declares a `factor` ParamSpec and its build closure
reads the bound value (`|p| Box::new(Scale::new(p[0].f64()))`), and the
starter blueprint binds `factor` so `aura run` exercises the bound-param
path with zero hand-editing.

factor is bound to 1.0 — a neutral identity default the newcomer then tunes,
not the only C7-safe value (only |factor|>1 would push the terminal `bias`
output past the bounded [-1,+1] contract; the knob is genuinely read and
multiplied each cycle). The param-reading closure is the copyable template
content #183 delivers; a hypothetical `|_|` regression is caught by the
scaffold unit test's source guard, and tests/project_new.rs re-validates the
swap by building and running the scaffold end to end.

Template-side only (#181): the frozen 0102 demo-project fixture is untouched.

closes #183
This commit is contained in:
2026-07-10 00:52:29 +02:00
parent 93a2e2bb60
commit 67f98b6bc5
2 changed files with 22 additions and 26 deletions
+19 -23
View File
@@ -115,22 +115,24 @@ const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic +
//! this cdylib per invocation and merges it with the std vocabulary.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through. Emits `None` until its input has a value.
pub struct Identity {
/// One-input scalar gain: emits `input * factor`. Emits `None` until its
/// input has a value (warm-up filter, C8).
pub struct Scale {
factor: f64,
out: [Cell; 1],
}
impl Identity {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"__NS__::Identity",
"__NS__::Scale",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
@@ -138,20 +140,14 @@ impl Identity {
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
},
|_| Box::new(Identity::new()),
|p| Box::new(Scale::new(p[0].f64())),
)
}
}
impl Default for Identity {
fn default() -> Self {
Self::new()
}
}
impl Node for Identity {
impl Node for Scale {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
@@ -160,23 +156,23 @@ impl Node for Identity {
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
self.out[0] = Cell::from_f64(w[0] * self.factor);
Some(&self.out)
}
fn label(&self) -> String {
"__NS__::Identity".to_string()
format!("__NS__::Scale({})", self.factor)
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"__NS__::Identity" => Some(Identity::builder()),
"__NS__::Scale" => Some(Scale::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["__NS__::Identity"]
&["__NS__::Scale"]
}
aura_core::aura_project! {
@@ -186,7 +182,7 @@ aura_core::aura_project! {
}
"#;
const SIGNAL_JSON: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"__NS__::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const SIGNAL_JSON: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"__NS__::Scale","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
const CLAUDE_MD: &str = r#"# __NAME__ — an aura research project
@@ -311,10 +307,10 @@ mod tests {
assert!(!out.contains("__"), "unsubstituted token in: {out}");
}
let lib = render(LIB_RS, &spec);
assert!(lib.contains("\"demo_lab::Identity\""));
assert!(lib.contains("\"demo_lab::Scale\""));
assert!(lib.contains("namespace: \"demo_lab\""));
let json = render(SIGNAL_JSON, &spec);
assert!(json.contains("\"type\":\"demo_lab::Identity\""));
assert!(json.contains("\"type\":\"demo_lab::Scale\""));
assert!(json.contains("\"name\":\"demo_lab_signal\""));
let cargo = render(CARGO_TOML, &spec);
assert!(cargo.contains("name = \"demo-lab\""));
+3 -3
View File
@@ -64,7 +64,7 @@ fn new_scaffold_builds_and_runs_the_authoring_loop() {
let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(introspect.status.success());
let text = String::from_utf8_lossy(&introspect.stdout);
assert!(text.contains("demo_lab::Identity"), "project id listed");
assert!(text.contains("demo_lab::Scale"), "project id listed");
let _ = std::fs::remove_dir_all(&base);
}
@@ -136,8 +136,8 @@ fn new_namespace_override_reaches_the_built_vocabulary() {
let introspect = aura(&["graph", "introspect", "--vocabulary"], &proj);
assert!(introspect.status.success());
let text = String::from_utf8_lossy(&introspect.stdout);
assert!(text.contains("custom_ns::Identity"), "overridden namespace listed: {text}");
assert!(!text.contains("demo_lab::Identity"), "default-derived namespace must not leak");
assert!(text.contains("custom_ns::Scale"), "overridden namespace listed: {text}");
assert!(!text.contains("demo_lab::Scale"), "default-derived namespace must not leak");
let _ = std::fs::remove_dir_all(&base);
}