Files
Aura/docs/specs/0034-blueprint-constant-bind.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

16 KiB

Blueprint constant bind — .bind() a node param as a structural constant — Design Spec

Date: 2026-06-12 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Give a param-bearing node a way to fix one of its declared params to a structural constant — a value removed from param_space entirely, not merely pinned in the injected vector. This closes the gap named in issue #55: today a tunable lives only as a param_space knob (#31), correct for tuning params (values a run supplies and a sweep varies) but wrong for structural constants (values whose variation does not yield another valid point of the same strategy but deforms it into a different one). An "SMA2-entry" strategy binds length = 2 to its two-candle construction; sweeping that length does not explore the strategy, it breaks it. Leaving the knob open and fixing it in the vector would declare a degree of freedom that does not exist, and a sweep over it would enumerate deformed strategies as valid family members.

The discriminator (from the issue):

  • variation → another valid point → tuning paramparam_space / vector (#31, exists)
  • variation → deformed strategy → structural constant → bound, not in param_space (this spec)

Scope. This cycle ships the .bind() overlay only. The issue's third fork — exporting a named frozen strategy (a future blueprint-as-values collection) — is deferred and out of scope (see the issue's reconciliation comment, 2026-06-12).

Architecture

The settled direction is Option B: builder-level bind (reconciliation comment, 2026-06-12 — "B was originally planned"). .bind() is a method on PrimitiveBuilder, uniform for every param-bearing node and dynamic per knob. It returns a PrimitiveBuilder whose declared param surface (params() / schema().params) is shrunk by the bound slot, and whose build closure (Fn(&[Scalar]) -> Box<dyn Node>) is wrapped to re-splice the captured constant back into its original positional slot before delegating to the inner closure.

The load-bearing property is that the rest of the construction layer needs zero changes. Both sites the issue update named as "dock sites" already key off builder.params():

  • collect_params (blueprint.rs:546-569) iterates for p in b.params() and pushes one ParamSpec per declared param. A bound slot is gone from params(), so it is never pushed into param_space — the param_space side, for free.
  • lower_items (blueprint.rs:585-628) takes n = builder.params().len(), slices &params[cursor..cursor+n], kind-checks it, calls builder.build(slice), and advances cursor += n. A bound slot shrinks n, so the injected slice is correspondingly shorter and the cursor advances correctly — the value side, for free.

This inverts the issue update's "docks at collect_params AND lower_items" framing: under B those functions are read-only beneficiaries of the shrink, not edit sites. The only real change site is the new PrimitiveBuilder::bind plus its closure-wrapping.

Addressing is by param name (.bind(slot: &str, value)), matching the slot against the node's declared ParamSpec.name. This follows the C23 authoring-address-space convention as amended in cycle 0032 (check_param_namespace_injective): the by-name authoring address space is injective, while the flat graph stays wired by raw index (C23 unchanged). Name at the authoring surface, index in the flat graph; .bind() sits on the authoring side and resolves the name to a position immediately — the name is not persisted into the flat graph, so C23's "names are non-load-bearing in the flat graph" holds.

.bind() is not the builder_const(2) per-node constructor the issue rejects (which "explodes combinatorially — which subset is constant?"): it is one method, naming the slot dynamically, applicable to any subset of a multi-param node.

Precedent already in tree

SimBroker::builder(0.0001) (sim_broker.rs:61-74) already captures a construction value with params: vec![]; SimBroker::builder(0.0001).schema() .params.is_empty() is asserted at sma.rs:144. So "a value in the blueprint, outside param_space" is already legitimate — but only for nodes that offer no sweep mode at all. .bind() extends exactly that posture to param-bearing nodes (Sma, Exposure, LinComb), which today have no constant path.

Concrete code shapes

Worked author example (the empirical evidence for the acceptance criterion)

use aura_core::Scalar;
use aura_std::Sma;

// "SMA2-entry": the 2 is structural (bound to the two-candle construction),
// not a tuning knob. Binding removes `length` from param_space entirely.
let sma2 = Sma::builder().named("bias").bind("length", Scalar::I64(2));
assert!(sma2.params().is_empty());            // length is GONE, not just fixed

// Contrast — the length-generic SMA keeps `length` open → still sweepable:
let sma_generic = Sma::builder().named("bias");
assert_eq!(sma_generic.params().len(), 1);    // [length]

At the harness level, a strategy whose one node is a bound constant contributes nothing to the sweep's param_space, so the sweep family contains only valid points — never deformed ones:

// "sma2_entry" composite: bound `bias.length` is absent from param_space;
// only `exp.scale` remains as the harness's open knob.
let strat = Composite::new(
    "sma2_entry",
    vec![
        Sma::builder().named("bias").bind("length", Scalar::I64(2)).into(),
        Exposure::builder().named("exp").into(),     // scale stays open
    ],
    edges, roles, output,
);
// param_space() on the root composite qualifies each leaf by its OWN node
// segment, not by the root composite's name (collect_params is called with an
// empty prefix; the root name is not a path segment) — so `exp.scale`, not
// `sma2_entry.exp.scale`. The load-bearing point holds either way: the bound
// `bias.length` is gone. (Nesting `strat` into a larger harness would prefix
// the leaves further, but the bound knob stays absent.)
assert_eq!(
    strat.param_space().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
    ["exp.scale"],                                    // bias.length is gone
);

Implementation shape (secondary) — PrimitiveBuilder::bind

The build closure type is unchanged (Fn(&[Scalar]) -> Box<dyn Node>). bind shrinks schema.params and wraps the closure; Scalar is Copy (scalar.rs:20) so the captured constant re-splices on every call.

before — PrimitiveBuilder today (node.rs:77-130): params() returns &self.schema.params; build() calls (self.build)(params).

after — add one method:

// aura-core/src/node.rs — new method on PrimitiveBuilder
impl PrimitiveBuilder {
    /// 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`
        // (that 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
    }
}

Why chaining is positionally correct (the load-bearing invariant)

Each .bind() captures pos relative to the param list it sees, which is exactly the inner closure's input space. The inserts compose without any global original-position table. For a node declaring [a, b, c] with inner(p) = Node::new(p[0], p[1], p[2]):

// .bind("b", X): pos("b" in [a,b,c]) = 1 → params [a,c]
//   wrap1(open) = inner( open.insert(1, X) )
// .bind("a", Y): pos("a" in [a,c])   = 0 → params [c]
//   wrap2(open) = wrap1( open.insert(0, Y) )
//
// at build time lower_items hands the open slice in params() order = [v_c]:
//   wrap2([v_c]) = wrap1([Y, v_c]) = inner([Y, X, v_c])   // full 3-vector, right order

The internal representation choice — closure-nesting (this fold of single inserts) vs a splice-table held as data on the builder (Vec<(pos, value)>, one merge pass) — is behaviourally equivalent and left to the implementer; it is not a design fork.

Components

  • aura-core/src/node.rsPrimitiveBuilder::bind (new): the only production change. Shrinks schema.params, wraps build. Validates slot existence and value kind eagerly (authoring-time panic, consistent with Sma::new's assert!).
  • aura-engine/src/blueprint.rscollect_params, lower_items, Composite::param_space, compile_with_params: unchanged. They already key off builder.params(); the shrink propagates transparently. (Listed as components only to make the zero-change claim explicit and testable.)
  • aura-std nodes (Sma, Exposure, LinComb): unchanged. Their builders already declare params and build through their constructors; .bind() composes over them without per-node code.

Data flow

Authoring: Node::builder().bind(name, value) resolves name → pos, removes the ParamSpec, wraps the closure capturing (pos, value). The returned builder has a smaller params().

Construction (unchanged): Composite::param_space()collect_params iterates builder.params() (bound slot absent) → aggregated path-qualified space omits the bound knob. compile_with_params(params) checks arity against the (shrunk) param_space, then lower_items slices n = builder.params().len() open values and calls builder.build(open_slice); the wrapped closure reconstructs the full positional vector and delegates to the node constructor. The flat graph is wired by raw index exactly as before (C23).

Error handling

All .bind() validation is authoring-time and eager, by hard panic (the builder chain returns Self, so a Result would break .bind(..).bind(..); panics match the existing Sma::new / LinComb::new assert! posture for authoring bugs):

  • Unknown slot name (typo, or a name already bound and thus gone): panic bind: no open param named \``.
  • Ambiguous slot name (a node declaring two params of the same name — not pinned out elsewhere, since check_param_namespace_injective guards only the aggregated path-qualified space, after bind): panic bind: ambiguous — multiple open params named \`. .bind()` enforces the "exactly one match" precondition itself (collect-all-then-reject), so the docstring contract and the code agree.
  • Kind mismatch (value kind ≠ declared ParamSpec.kind): panic bind: kind mismatch for param \`. This preserves the kind guarantee that lower_items' per-slice check (blueprint.rs:599-607`) gives injected params — moved to bind time for bound slots, which bypass that check.
  • Binding every param: leaves params().is_empty() — a fully constant param-bearing node, structurally identical to the SimBroker precedent. Valid.

Testing strategy

New tests (the GREEN target for implement), co-located with the builder / nodes:

  1. bind_removes_slot_from_param_spaceSma::builder().bind("length", Scalar::I64(2)).schema().params.is_empty(). Parity with the SimBroker precedent (sma.rs:144): a bound param-bearing node now reports an empty param surface just like a param-less node.
  2. bound_node_builds_with_injected_value — the bound builder, built with an empty open slice, yields a node behaving as if the value were injected: Sma::builder().bind("length", Scalar::I64(2)).build(&[]).label() == "SMA(2)".
  3. chained_bind_reconstructs_positional_vector — the load-bearing invariant. On LinComb::builder(2) (params weights[0], weights[1]), bind the two slots to distinct values in either order, build, and assert via eval that each value landed in its correct positional slot (Σ weights[i]·input[i] computes with the right coefficients). Also bind only weights[0], leave weights[1] open, build with one injected value, and assert the open value lands in slot 1.
  4. param_space_reflects_only_open_knobs — a Composite with a bound node: param_space() omits the bound slot's path-qualified name and keeps the open ones (the harness-level assertion from the worked example).
  5. bind_unknown_slot_panics / bind_ambiguous_slot_panics / bind_kind_mismatch_panics#[should_panic] on a typo'd name, on a duplicate-named slot (a builder hand-constructed with two same-named ParamSpecs), and on a wrong-kind value.

Grounding (current-behaviour assumptions the spec rests on, each ratified by a currently-green test):

  • Each node declares its params by name pre-build → green: nodes_declare_expected_params (sma.rs:123).
  • param_space aggregates those declared params into a flat, path-qualified space (top-level leaf qualified by its own node segment, not the root name) → green: param_space_is_flat_path_qualified_and_slot_disambiguated and param_space_mirrors_compiled_flat_node_param_order (blueprint.rs).
  • lower_items keys arity / slice / cursor off builder.params().len() → green: wrong_arity_is_a_param_arity_error (blueprint.rs).
  • A construction value can sit outside param_space → green: SimBroker::builder(0.0001).schema().params.is_empty() (sma.rs:144).

Acceptance criteria

  • A strategy author can write Node::builder().bind("param", value) for any param-bearing node and get a builder whose param_space no longer contains that param, while the node still constructs with the bound value (the worked author example compiles and its assertions hold).
  • A Composite containing a bound node reports a param_space that omits the bound knob, so a sweep over the harness enumerates only valid points — never a deformed strategy (criterion: measurably removes a class of invalid sweep members the issue identifies as wrong).
  • The construction layer (collect_params, lower_items, param_space, compile_with_params) is unmodified; the only production change is PrimitiveBuilder::bind.
  • Chained and single binds reconstruct the correct positional argument vector for multi-param nodes (invariant test 3 green).
  • C23 holds: the flat graph is still wired by raw index; bound param names are resolved to positions at authoring time and never reach the flat graph.
  • No new dependency (C16); no node registry or by-name runtime lookup (C9/C10).