21c1621bd0
"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.
313 lines
16 KiB
Markdown
313 lines
16 KiB
Markdown
# 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<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 `¶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 *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)
|
|
|
|
```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::<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:
|
|
|
|
```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<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])`:
|
|
|
|
```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 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 \`<slot>\``.
|
|
- **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 \`<slot>\``. `.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 \`<slot>\``. 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 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).
|