feat(aura): surface bind-bound params in the graph model + viewer signature
A node built with `.bind(slot, value)` fixes a declared param to a structural
constant: the slot correctly leaves the tunable surface (param_space / sweep axes),
but it also vanished from the RENDERED signature, because prim_record emitted only
schema.params. The cycle-0036 `blend` (a LinComb(3) with weights[2] bound to 0.5)
rendered `LinComb[weights[0], weights[1]]` — a 2-arity signature over a 3-port box,
with the fixed 0.5 invisible. The signature misrepresented the node.
bind now ANNOTATES instead of erasing. All slots are shown; the bound one renders
`name=value`, dimmed. The blend renders:
blend: LinComb[weights[0], weights[1], weights[2]=0.5]
Structurally a twin of cycle 0035's instance-name thread (commit 0ae8320): a
conditional field threaded engine -> model -> viewer.
- aura-core: a `BoundParam { pos, name, kind, value }` recorded by bind alongside
the unchanged closure capture, plus a `bound_params()` accessor (twin of
instance_name()). `pos` is the slot's ORIGINAL pre-bind position (the compressed
index is mapped back over earlier-bound positions), so the render is faithful for
any bind pattern, not just a trailing one.
- aura-engine: a conditional `"bound"` field in prim_record (present only when the
node has binds, mirroring the conditional "name"), each entry [pos,"name","kind",
"value"] via a new scalar_str helper (f64 via {:?} — keeps a decimal point so a
bound f64 never reads as an i64).
- graph-viewer.js: adaptNodes/genDot carry `bound`; cellLabel merges free + bound
params back into slot order by position and renders the bound slot dimmed
(reusing the 0035 separator grey #9399b2).
Render notation settled with the user (issue #63, reconciliation comment): keep the
[...] convention and name=value (not parens, not positional value-only).
Render/debug surface only: dropped at lowering (C23 — bind still resolves the name
to a fixed position and the compilat is unchanged), model stays deterministic (C14
— the inline no-bind golden is byte-unchanged; only the sample fixture's blend node
gains "bound"). The tunable surface is untouched: the eight-param sweep pins stay
byte-identical (C12/C19). New unit tests pin the recorded original position and the
conditional model field; a new headless render guard pins the dimmed name=value
render and slot-order faithfulness for both a trailing and a non-trailing bind.
Verified: cargo build/test (0 failed) + clippy -D warnings (0) all green.
closes #63
This commit is contained in:
@@ -66,6 +66,18 @@ pub struct ParamSpec {
|
||||
pub kind: ScalarKind,
|
||||
}
|
||||
|
||||
/// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — a
|
||||
/// render/debug surface only (C23), dropped at lowering. `pos` is the slot's
|
||||
/// position in the node's ORIGINAL (pre-bind) param list, so the model serializer
|
||||
/// and viewer can place it back into slot order regardless of bind sequence.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BoundParam {
|
||||
pub pos: usize,
|
||||
pub name: String,
|
||||
pub kind: ScalarKind,
|
||||
pub value: Scalar,
|
||||
}
|
||||
|
||||
/// A param-generic blueprint **primitive** recipe (C19): a node's full declared
|
||||
/// signature (`NodeSchema` — inputs/output/params) plus a closure that builds a
|
||||
/// sized instance through the node's own constructor (the single sizing/validation
|
||||
@@ -78,6 +90,7 @@ pub struct PrimitiveBuilder {
|
||||
name: &'static str,
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
bound: Vec<BoundParam>,
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -93,7 +106,7 @@ impl PrimitiveBuilder {
|
||||
schema: NodeSchema,
|
||||
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, instance_name: None, schema, build: Box::new(build) }
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
|
||||
}
|
||||
/// Set this node instance's explicit name. Must be non-empty: the name forms
|
||||
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||
@@ -120,6 +133,14 @@ impl PrimitiveBuilder {
|
||||
pub fn instance_name(&self) -> Option<&str> {
|
||||
self.instance_name.as_deref()
|
||||
}
|
||||
/// The params bound to structural constants by [`bind`](Self::bind), in
|
||||
/// bind-call order, each carrying its ORIGINAL slot position. The render-
|
||||
/// surface twin of [`instance_name`](Self::instance_name): the model
|
||||
/// serializer reads it to show a bound slot's fixed value (the value captured
|
||||
/// in the build closure is otherwise unreachable to it). Empty when no binds.
|
||||
pub fn bound_params(&self) -> &[BoundParam] {
|
||||
&self.bound
|
||||
}
|
||||
/// The full declared signature (read pre-build by `Composite::param_space`,
|
||||
/// `BlueprintNode::signature`, and the renderer).
|
||||
pub fn schema(&self) -> &NodeSchema {
|
||||
@@ -170,6 +191,22 @@ impl PrimitiveBuilder {
|
||||
self.schema.params[pos].kind,
|
||||
"bind: kind mismatch for param `{slot}`",
|
||||
);
|
||||
// [render side] record the bound slot for the model/viewer at its ORIGINAL
|
||||
// pre-bind position. `pos` indexes the already-shrunk array (earlier binds
|
||||
// removed), so map it back to the full-arity index by adding one for each
|
||||
// earlier-bound slot at an original position <= it. Render/debug only (C23);
|
||||
// the closure capture below is what bootstrap actually reads.
|
||||
let name = self.schema.params[pos].name.clone();
|
||||
let kind = self.schema.params[pos].kind;
|
||||
let mut orig = pos;
|
||||
let mut prior: Vec<usize> = self.bound.iter().map(|b| b.pos).collect();
|
||||
prior.sort_unstable();
|
||||
for p in prior {
|
||||
if p <= orig {
|
||||
orig += 1;
|
||||
}
|
||||
}
|
||||
self.bound.push(BoundParam { pos: orig, name, kind, value });
|
||||
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]| {
|
||||
@@ -407,6 +444,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_records_bound_param_at_original_position() {
|
||||
// middle-of-three: binding `b` records its ORIGINAL slot position (1).
|
||||
let mid = probe3().bind("b", Scalar::I64(8));
|
||||
assert_eq!(
|
||||
mid.bound_params(),
|
||||
[BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) }]
|
||||
.as_slice(),
|
||||
);
|
||||
|
||||
// chained reverse-order bind (b then a): each entry carries its ORIGINAL
|
||||
// position regardless of the shrinking array — positions {1, 0} in call order.
|
||||
let pair = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7));
|
||||
assert_eq!(
|
||||
pair.bound_params(),
|
||||
[
|
||||
BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) },
|
||||
BoundParam { pos: 0, name: "a".into(), kind: ScalarKind::I64, value: Scalar::I64(7) },
|
||||
]
|
||||
.as_slice(),
|
||||
);
|
||||
|
||||
// an unbound builder records nothing.
|
||||
assert!(probe3().bound_params().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no open param named")]
|
||||
fn bind_unknown_slot_panics() {
|
||||
|
||||
Reference in New Issue
Block a user