From 9e57caebb8f29714b2f6734b83aa2a2d45499545 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 12 Jun 2026 16:12:09 +0200 Subject: [PATCH] spec: 0034 blueprint-constant-bind (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PrimitiveBuilder::bind(slot, value): fix a node param as a structural constant, removed from param_space entirely (not pinned in the injected vector). Closes the gap #55 names — param-bearing nodes (Sma/Exposure/LinComb) had no constant path, only the no-sweep-mode SimBroker precedent. Settled direction (Option B, builder-level): .bind() shrinks the builder's declared param surface and wraps its build closure to re-splice the constant; the construction layer (collect_params/lower_items/param_space) is unchanged because both already key off builder.params(). Addressing is by-name (authoring address space, C23/0032); compilat stays index-wired. Auto-signed under /boss spec_auto_sign: objective gates green, grounding-check PASS, unanimous five-lens spec-skeptic panel SOUND (one editorial repair round: corrected the harness param_space assertion and pinned the exactly-one-match bind contract). refs #55 --- docs/specs/0034-blueprint-constant-bind.md | 312 +++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/specs/0034-blueprint-constant-bind.md diff --git a/docs/specs/0034-blueprint-constant-bind.md b/docs/specs/0034-blueprint-constant-bind.md new file mode 100644 index 0000000..87b691d --- /dev/null +++ b/docs/specs/0034-blueprint-constant-bind.md @@ -0,0 +1,312 @@ +# 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 param** → `param_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`) 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 `¶ms[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 *compilat* stays wired by raw index (C23 unchanged). Name at +the authoring surface, index in the compilat; `.bind()` sits on the authoring +side and resolves the name to a position immediately — the name is not persisted +into the compilat, so C23's "names are non-load-bearing in the compilat" 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) + +```rust +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: + +```rust +// "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::>(), + ["exp.scale"], // bias.length is gone +); +``` + +### Implementation shape (secondary) — `PrimitiveBuilder::bind` + +The build closure type is unchanged (`Fn(&[Scalar]) -> Box`). `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: + +```rust +// 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 = 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])`: + +```rust +// .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.rs` — `PrimitiveBuilder::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.rs` — `collect_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 compilat 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_space`** — `Sma::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 + `ParamSpec`s), 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 compilat is still wired by raw index; bound param names are + resolved to positions at authoring time and never reach the compilat. +- No new dependency (C16); no node registry or by-name runtime lookup (C9/C10).