3413194809
Task decomposition for PrimitiveBuilder::bind (spec 0034): T1 the method + aura-core unit tests (Probe node drives positional reconstruction RED→GREEN; three should_panic), T2 node-vehicle coverage (Sma/LinComb via eval in aura-std), T3 Composite param_space coverage + construction-layer zero-change guard + workspace gate. Crate-topology aware: aura-core unit tests use a local Probe, not Sma/LinComb (no upward dep). refs #55
386 lines
16 KiB
Markdown
386 lines
16 KiB
Markdown
# 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<Scalar>);
|
|
impl Node for Probe {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
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::<Vec<_>>(),
|
|
["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<usize> = 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::<Vec<_>>(),
|
|
["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.
|