Files
Aura/docs/plans/0034-blueprint-constant-bind.md
T
Brummel 3413194809 plan: 0034 blueprint-constant-bind
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
2026-06-12 16:20:59 +02:00

16 KiB

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.rscollect_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:

    /// 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):

    /// 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::*:

    #[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}:

    #[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:

    #[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.