From 67f98b6bc542f53a73d6194602069ab2139cf07a Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 00:52:29 +0200 Subject: [PATCH] feat(cli): swap the scaffold's starter node for a param-bearing Scale (#183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/scaffold.rs | 42 +++++++++++++--------------- crates/aura-cli/tests/project_new.rs | 6 ++-- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index a058c00..08e4e21 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -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 { 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 { 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\"")); diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs index c91fde2..bbcbb4e 100644 --- a/crates/aura-cli/tests/project_new.rs +++ b/crates/aura-cli/tests/project_new.rs @@ -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); }