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:
@@ -20,6 +20,20 @@ fn kind_str(k: ScalarKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64`
|
||||
/// uses the shortest round-trippable debug form, which keeps a decimal point on
|
||||
/// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in
|
||||
/// the render. The value side of `kind_str`.
|
||||
fn scalar_str(s: aura_core::Scalar) -> String {
|
||||
use aura_core::Scalar;
|
||||
match s {
|
||||
Scalar::I64(n) => n.to_string(),
|
||||
Scalar::F64(f) => format!("{f:?}"),
|
||||
Scalar::Bool(b) => b.to_string(),
|
||||
Scalar::Ts(t) => t.0.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One input port's firing policy as a model string: `"any"` or `"barrier <N>"`.
|
||||
fn firing_str(f: Firing) -> String {
|
||||
match f {
|
||||
@@ -81,8 +95,24 @@ fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
|
||||
Some(n) => format!(r#""name":{},"#, json_str(n)),
|
||||
None => String::new(),
|
||||
};
|
||||
// bound params: present only when the node has binds (mirrors the conditional
|
||||
// "name" field). each entry: [pos,"name","kind","value"].
|
||||
let bound = if b.bound_params().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let items = join(b.bound_params().iter().map(|bp| {
|
||||
format!(
|
||||
"[{},{},{},{}]",
|
||||
bp.pos,
|
||||
json_str(&bp.name),
|
||||
json_str(kind_str(bp.kind)),
|
||||
json_str(&scalar_str(bp.value)),
|
||||
)
|
||||
}));
|
||||
format!(r#","bound":[{items}]"#)
|
||||
};
|
||||
format!(
|
||||
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
json_str(&b.label()),
|
||||
json_str(role),
|
||||
)
|
||||
@@ -393,6 +423,23 @@ mod tests {
|
||||
assert!(!unnamed.contains(r#""name""#), "{unnamed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prim_record_emits_bound_only_when_bound() {
|
||||
// a bound i64 param → a "bound" field carrying [pos,"name","kind","value"]
|
||||
let bound = prim_record(&Sma::builder().bind("length", Scalar::I64(5)));
|
||||
assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}");
|
||||
// the bound slot also leaves the tunable "params" surface (bind's tuning side)
|
||||
assert!(bound.contains(r#""params":[]"#), "{bound}");
|
||||
|
||||
// a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
|
||||
let f = prim_record(&Exposure::builder().bind("scale", Scalar::F64(0.5)));
|
||||
assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");
|
||||
|
||||
// an unbound builder → no "bound" field at all
|
||||
let unbound = prim_record(&Sma::builder());
|
||||
assert!(!unbound.contains(r#""bound""#), "{unbound}");
|
||||
}
|
||||
|
||||
// -- Task 3: scope (index keys + synthetic sources + edges) ------------------
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user