diff --git a/docs/plans/0034-blueprint-constant-bind.md b/docs/plans/0034-blueprint-constant-bind.md new file mode 100644 index 0000000..50db26e --- /dev/null +++ b/docs/plans/0034-blueprint-constant-bind.md @@ -0,0 +1,385 @@ +# Blueprint constant bind — `PrimitiveBuilder::bind` — Implementation Plan + +> **Parent spec:** `docs/specs/0034-blueprint-constant-bind.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add `PrimitiveBuilder::bind(slot, value)` so a param-bearing node can fix +one declared param to a structural constant, removed from `param_space` entirely. + +**Architecture:** One new method on `PrimitiveBuilder` (`aura-core/src/node.rs`) +shrinks the builder's declared param surface (`schema.params`) and wraps its build +closure to re-splice the captured constant at its original positional slot. The +construction layer (`collect_params`, `lower_items`, `Composite::param_space`, +`compile_with_params` in `aura-engine/src/blueprint.rs`) is **unchanged** — both +dock sites already key off `builder.params()`, so the shrink propagates for free. + +**Tech Stack:** Rust workspace — `aura-core` (the method + unit tests), +`aura-std` (node-vehicle coverage: `Sma`, `LinComb`), `aura-engine` (composite +`param_space` coverage + the zero-change guard). + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-core/src/node.rs:87-130` — append `bind` into the + `impl PrimitiveBuilder` block (after `label()`, before the closing `}`). +- Test: `crates/aura-core/src/node.rs:177-` — append unit tests into the existing + `#[cfg(test)] mod tests` (after the last test, before its closing `}`). +- Test: `crates/aura-std/src/sma.rs:63-158` — append two tests into the existing + `mod tests` (after `input_slot_is_named_series`). +- Test: `crates/aura-std/src/lincomb.rs:86-151` — append one test into the + existing `mod tests` (after `input_slots_are_named_term_index`). +- Test: `crates/aura-engine/src/blueprint.rs:754-` — append one test into the + existing `mod tests` (among the `param_space_*` tests, ~line 1883). + +**Construction layer that must stay UNCHANGED (guard, not edit):** +`crates/aura-engine/src/blueprint.rs` — `collect_params` (`:546-569`), +`lower_items` (`:585-628`), `Composite::param_space` (`:179-183`), +`compile_with_params` (`:189-`). + +**Parse-the-bytes gate (self-review rule 9):** the project profile declares no +`spec_validation` slot → the inlined-body parse gate is a **no-op** (no-parser +skip). All inlined bodies are Rust source; the `implement` compile gate +(`cargo build`/`cargo test`) is their validation. + +--- + +### Task 1: `PrimitiveBuilder::bind` + aura-core unit tests (RED→GREEN) + +**Files:** +- Modify: `crates/aura-core/src/node.rs:87-130` (`impl PrimitiveBuilder`) +- Test: `crates/aura-core/src/node.rs:177-` (`mod tests`) + +- [ ] **Step 1: Write the failing tests** + +Append into `crates/aura-core/src/node.rs`'s `#[cfg(test)] mod tests` block (after +the last existing test, before the module's closing `}`). These use a hand-built +`PrimitiveBuilder::new` (the in-crate `Bare` node and a local `Probe` node) — no +dependency on `aura-std`: + +```rust + /// A node whose label echoes the param vector its constructor received — lets a + /// test read back the positional vector `.build()` reconstructed after binds. + struct Probe(Vec); + impl Node for Probe { + fn lookbacks(&self) -> Vec { + vec![] + } + fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { + None + } + fn label(&self) -> String { + format!("{:?}", self.0) + } + } + + fn probe3() -> PrimitiveBuilder { + // params [a: I64, b: I64, c: F64]; build stores the received slice + PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ + ParamSpec { name: "a".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "b".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "c".into(), kind: ScalarKind::F64 }, + ], + }, + |p| Box::new(Probe(p.to_vec())), + ) + } + + #[test] + fn bind_removes_slot_and_reconstructs_positionally() { + // chained bind of b then a (reverse slot order); c stays open + let bound = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7)); + assert_eq!( + bound.params().iter().map(|p| p.name.as_str()).collect::>(), + ["c"], // only c remains open + ); + let built = bound.build(&[Scalar::F64(9.0)]); // inject c + assert_eq!( + built.label(), + format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]), + ); + + // partial: bind only b; a and c stay open and keep their positions + let built2 = probe3() + .bind("b", Scalar::I64(8)) + .build(&[Scalar::I64(7), Scalar::F64(9.0)]); // a, c injected in order + assert_eq!( + built2.label(), + format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]), + ); + } + + #[test] + #[should_panic(expected = "no open param named")] + fn bind_unknown_slot_panics() { + let b = PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + }, + |_| Box::new(Bare), + ); + let _ = b.bind("width", Scalar::I64(2)); // "width" is not a declared param + } + + #[test] + #[should_panic(expected = "ambiguous")] + fn bind_ambiguous_slot_panics() { + let b = PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ + ParamSpec { name: "dup".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "dup".into(), kind: ScalarKind::I64 }, + ], + }, + |_| Box::new(Bare), + ); + let _ = b.bind("dup", Scalar::I64(1)); // two slots named "dup" + } + + #[test] + #[should_panic(expected = "kind mismatch")] + fn bind_kind_mismatch_panics() { + let b = PrimitiveBuilder::new( + "Probe", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + }, + |_| Box::new(Bare), + ); + let _ = b.bind("length", Scalar::F64(2.0)); // F64 value for an I64 slot + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-core` +Expected: FAIL — compile error `no method named \`bind\` found for struct \`PrimitiveBuilder\``. + +- [ ] **Step 3: Write the `bind` method** + +Append into `crates/aura-core/src/node.rs` inside `impl PrimitiveBuilder` (after +`label()` at line 127-129, before the block's closing `}` at line 130): + +```rust + /// Bind a declared param `slot` to a structural constant, removing it from the + /// node's param surface (`params()` / `schema().params` / the aggregated + /// `param_space`). Authoring-time: `slot` names the param (the by-name authoring + /// address space, C23/0032) and must match **exactly one** still-open param — + /// bind panics on zero matches (unknown / already-bound name) and on more than + /// one (an ambiguous duplicate-named slot); `value`'s kind must match the slot's + /// declared `ParamSpec.kind`. Returns `Self` so binds chain (`.bind(..).bind(..)`). + pub fn bind(mut self, slot: &str, value: Scalar) -> Self { + // Enforce the "exactly one" precondition rather than assume it: a node's own + // `schema.params` is NOT covered by `check_param_namespace_injective` (which + // guards only the aggregated path-qualified space, after `.bind()`), so + // per-node name uniqueness is not pinned elsewhere. Mirror the collect-all- + // then-reject posture of the `resolve` binder (blueprint.rs). + let matches: Vec = self + .schema + .params + .iter() + .enumerate() + .filter(|(_, p)| p.name == slot) + .map(|(i, _)| i) + .collect(); + let pos = match matches.as_slice() { + [pos] => *pos, + [] => panic!("bind: no open param named `{slot}`"), + _ => panic!("bind: ambiguous — multiple open params named `{slot}`"), + }; + assert_eq!( + value.kind(), + self.schema.params[pos].kind, + "bind: kind mismatch for param `{slot}`", + ); + self.schema.params.remove(pos); // [param_space side] shrink the declared surface + let inner = self.build; // [value side] wrap the build closure + self.build = Box::new(move |open: &[Scalar]| { + let mut full = open.to_vec(); + full.insert(pos, value); // re-splice at the slot's original position + inner(&full) + }); + self + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p aura-core` +Expected: PASS — all `aura-core` tests green, including +`bind_removes_slot_and_reconstructs_positionally`, `bind_unknown_slot_panics`, +`bind_ambiguous_slot_panics`, `bind_kind_mismatch_panics`. + +--- + +### Task 2: node-vehicle coverage (aura-std — `Sma`, `LinComb`) + +These exercise the Task-1 method from a consumer crate (real nodes). No new +production code: the method exists after Task 1, so the tests are written to PASS +(their RED — "method missing" — was shown in Task 1). They confirm `bind` composes +with real builders and that positional reconstruction is observable through a +node's own `eval` / `label`. + +**Files:** +- Test: `crates/aura-std/src/sma.rs:63-158` (`mod tests`) +- Test: `crates/aura-std/src/lincomb.rs:86-151` (`mod tests`) + +- [ ] **Step 1: Write the `Sma` coverage tests** + +Append into `crates/aura-std/src/sma.rs`'s `mod tests` (after +`input_slot_is_named_series` at `:154-157`, before the module's closing `}` at +`:158`). `Scalar` is already in scope via `use super::*`: + +```rust + #[test] + fn bind_removes_slot_from_param_space() { + // a bound param-bearing node reports an empty param surface — parity with the + // SimBroker precedent (nodes_declare_expected_params, this file) + let sma2 = Sma::builder().named("bias").bind("length", Scalar::I64(2)); + assert!(sma2.schema().params.is_empty()); + // contrast: the length-generic SMA keeps `length` open + assert_eq!(Sma::builder().named("bias").params().len(), 1); + } + + #[test] + fn bound_node_builds_with_injected_value() { + // built with an empty open slice, the bound builder yields SMA(2) + let node = Sma::builder().bind("length", Scalar::I64(2)).build(&[]); + assert_eq!(node.label(), "SMA(2)"); + } +``` + +- [ ] **Step 2: Write the `LinComb` chained-bind test** + +Append into `crates/aura-std/src/lincomb.rs`'s `mod tests` (after +`input_slots_are_named_term_index` at `:146-150`, before the closing `}` at +`:151`). `Scalar`, `ScalarKind`, `Ctx` are in scope via `use super::*`; +`AnyColumn`, `Timestamp` via the test-mod `use aura_core::{AnyColumn, Timestamp}`: + +```rust + #[test] + fn chained_bind_reconstructs_positional_vector() { + // bind BOTH weights, in reverse slot order, to DISTINCT values; build empty. + let builder = LinComb::builder(2) + .bind("weights[1]", Scalar::F64(2.0)) + .bind("weights[0]", Scalar::F64(0.5)); + assert!(builder.params().is_empty()); + let mut lc = builder.build(&[]); + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + inputs[0].push(Scalar::F64(10.0)).unwrap(); + inputs[1].push(Scalar::F64(3.0)).unwrap(); + // 0.5*10 + 2.0*3 = 11.0 — holds ONLY if each weight landed in its right slot + // (a swap would give 2.0*10 + 0.5*3 = 21.5) + assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice())); + + // partial: bind weights[0], leave weights[1] open → inject it at build + let partial = LinComb::builder(2).bind("weights[0]", Scalar::F64(0.5)); + assert_eq!( + partial.params().iter().map(|p| p.name.as_str()).collect::>(), + ["weights[1]"], + ); + let mut lc2 = partial.build(&[Scalar::F64(2.0)]); // weights[1] = 2.0 injected + let mut inputs2 = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + inputs2[0].push(Scalar::F64(10.0)).unwrap(); + inputs2[1].push(Scalar::F64(3.0)).unwrap(); + assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice())); + } +``` + +- [ ] **Step 3: Run the aura-std tests to verify they pass** + +Run: `cargo test -p aura-std` +Expected: PASS — all `aura-std` tests green, including +`bind_removes_slot_from_param_space`, `bound_node_builds_with_injected_value`, +`chained_bind_reconstructs_positional_vector`. + +--- + +### Task 3: composite `param_space` coverage + zero-change guard + workspace gate + +**Files:** +- Test: `crates/aura-engine/src/blueprint.rs:754-` (`mod tests`) + +- [ ] **Step 1: Write the composite `param_space` test** + +Append into `crates/aura-engine/src/blueprint.rs`'s `#[cfg(test)] mod tests`, among +the `param_space_*` tests (after +`param_space_is_flat_path_qualified_and_slot_disambiguated` ends at `:1883`). +`Composite`, `Scalar`, `ScalarKind` are in scope via `use super::*`; bring the +nodes in per-test as the neighbours do: + +```rust + #[test] + fn param_space_reflects_only_open_knobs() { + use aura_std::{Exposure, Sma}; + // "sma2_entry": Sma "bias" with length bound to 2 (a structural constant) + // plus Exposure "exp" whose `scale` stays open. The bound knob must be + // absent from param_space; only the open one remains. + let strat = Composite::new( + "sma2_entry", + vec![ + Sma::builder().named("bias").bind("length", Scalar::I64(2)).into(), + Exposure::builder().named("exp").into(), + ], + vec![], // edges — irrelevant to param_space() + vec![], // input_roles + vec![], // output + ); + let names: Vec<&str> = strat.param_space().iter().map(|p| p.name.as_str()).collect(); + // collect_params runs with an empty prefix, so a top-level leaf is qualified + // by its OWN node segment, not the root composite's name → "exp.scale". + // `bias.length` is GONE (bound), not present-but-fixed. + assert_eq!(names, ["exp.scale"]); + } +``` + +- [ ] **Step 2: Run the aura-engine test to verify it passes** + +Run: `cargo test -p aura-engine param_space_reflects_only_open_knobs` +Expected: PASS (`test result: ok. 1 passed`). The filter substring +`param_space_reflects_only_open_knobs` is the exact new test name — it resolves to +this one test. + +- [ ] **Step 3: Assert the construction layer is unchanged (zero-change guard)** + +Run: `git diff -- crates/aura-engine/src/blueprint.rs` +Expected: every added/changed line is inside the `#[cfg(test)] mod tests` block +(after line ~1883). Confirm `collect_params` (`:546-569`), `lower_items` +(`:585-628`), `Composite::param_space` (`:179-183`), and `compile_with_params` +(`:189-`) bodies are byte-for-byte unchanged. Any production-line change here is a +plan violation — the spec's load-bearing claim is that these four are untouched. + +- [ ] **Step 4: Full workspace gate (build, lint, test)** + +Run: `cargo build --workspace` +Expected: PASS (`Finished`), no errors. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: PASS, no warnings. + +Run: `cargo test --workspace` +Expected: PASS — the whole suite green, including the new `bind_*`, +`bound_node_builds_with_injected_value`, `chained_bind_reconstructs_positional_vector`, +and `param_space_reflects_only_open_knobs` tests, with no regression in the +existing `param_space_*` / `nodes_declare_expected_params` / arity tests.