feat(core,engine): bound params become data-merged defaults; Composite::reopen

Task 1-2 of the bound-override cycle: PrimitiveBuilder::bind/try_bind stop
capturing the bound value in a wrapped build closure and store it only as
BoundParam data; build() merges bound values back into the full-arity vector
at call time (ascending original position — behaviour-identical to the
retired closure chain). This makes the reversal possible: unbind(slot)
returns the param to the declared surface at its original order.

aura-engine gains the path-addressed Composite::reopen (mirroring
collect_params' prefix rules, lockstep with expansion_map) plus the
read-only bound_param_space() enumeration (path-qualified BoundSpec with
values) and the ReopenError/BoundSpec exports. An e2e proves a reopened
param rebuilds from the freshly supplied value through compile_with_params,
not the stale default.

refs #246
This commit is contained in:
2026-07-12 23:42:10 +02:00
parent 0ad8fc6ad2
commit 9ffd1952d2
4 changed files with 425 additions and 27 deletions
+91 -25
View File
@@ -84,10 +84,13 @@ pub fn zip_params(space: &[ParamSpec], point: &[Cell]) -> Vec<(String, Scalar)>
space.iter().zip(point).map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c))).collect()
}
/// 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.
/// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — the
/// render/debug surface (C23) AND, since #246, what [`PrimitiveBuilder::build`]
/// merges back into the full-arity vector the pristine build closure expects
/// (the value it holds is the default supplied at bind time). `pos` is the
/// slot's position in the node's ORIGINAL (pre-bind) param list, so the model
/// serializer, the viewer, and `build`'s merge can all place it back into slot
/// order regardless of bind sequence.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BoundParam {
pub pos: usize,
@@ -187,11 +190,26 @@ impl PrimitiveBuilder {
pub fn params(&self) -> &[ParamSpec] {
&self.schema.params
}
/// Build a sized node from its param slice — tag-free [`Cell`]s, already
/// kind-checked against the declared slots by the caller (the param-plane
/// frontend), so the closure reads each by its own declared kind.
/// Build a sized node from its OPEN param slice — tag-free [`Cell`]s,
/// already kind-checked against the declared slots by the caller (the
/// param-plane frontend). The pristine build closure always expects the
/// FULL declared arity, so any bound values are merged back in first, at
/// their original slot positions, before the closure runs.
pub fn build(&self, params: &[Cell]) -> Box<dyn Node> {
(self.build)(params)
if self.bound.is_empty() {
return (self.build)(params);
}
// Merge the bound values back into the full-arity vector the pristine
// closure expects. Inserting in ascending ORIGINAL position reconstructs
// the pre-bind order exactly as the retired per-bind closure chain did
// (each wrap inserted at its own pre-bind coordinate).
let mut full = params.to_vec();
let mut by_pos: Vec<&BoundParam> = self.bound.iter().collect();
by_pos.sort_unstable_by_key(|b| b.pos);
for b in by_pos {
full.insert(b.pos, b.value.cell());
}
(self.build)(&full)
}
/// The param-generic render label for the blueprint view (C22): just the node
/// type, e.g. `SMA`.
@@ -229,25 +247,47 @@ 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.
// record the bound slot 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. Load-bearing since #246: `build` merges these
// values back into the full-arity vector (and the model/viewer still
// reads them for render).
let name = self.schema.params[pos].name.clone();
let kind = self.schema.params[pos].kind;
let orig = self.original_pos(pos);
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: &[Cell]| {
let mut full = open.to_vec();
full.insert(pos, value.cell()); // bound value (kind-checked above) → Cell, at its original slot
inner(&full)
});
self
}
/// Reverse a [`bind`](Self::bind): re-open the bound param named `slot`,
/// returning it to the declared surface (`params()` / `schema().params` /
/// the aggregated `param_space`) at the position its original slot
/// dictates. The formerly bound value is discarded by this call — a
/// re-opened param is an ordinary open param again (#246: a bound value is
/// a default; callers that need it back reload the authored document).
/// Panics if `slot` is not currently bound. Unlike `bind` (which guards
/// against a genuinely ambiguous *externally-supplied* `schema.params`),
/// `self.bound` can never hold two entries sharing a name — `bind`/
/// `try_bind` only ever record a slot after it uniquely matched an OPEN
/// param name, so no defensive "ambiguous" branch is needed here.
/// `&mut self` rather than fluent: the engine's `reopen` walk operates in
/// place.
pub fn unbind(&mut self, slot: &str) {
let idx = self
.bound
.iter()
.position(|b| b.name == slot)
.unwrap_or_else(|| panic!("unbind: no bound param named `{slot}`"));
let b = self.bound.remove(idx);
// current-coordinates insert position: the original slot minus one for
// each still-bound slot at an earlier original position (the inverse
// of the reconstruction `original_pos` performs).
let cur = b.pos - self.bound.iter().filter(|x| x.pos < b.pos).count();
self.schema.params.insert(cur, ParamSpec { name: b.name, kind: b.kind });
}
/// The fallible twin of [`bind`](Self::bind): same exactly-one-match + kind
/// rule, returning [`BindOpError`] instead of panicking. Used by the op-script
/// construction surface, where a bad param is rejected at the op, not a panic.
@@ -277,12 +317,6 @@ impl PrimitiveBuilder {
let orig = self.original_pos(pos);
self.bound.push(BoundParam { pos: orig, name, kind, value });
self.schema.params.remove(pos);
let inner = self.build;
self.build = Box::new(move |open: &[Cell]| {
let mut full = open.to_vec();
full.insert(pos, value.cell());
inner(&full)
});
Ok(self)
}
}
@@ -637,6 +671,38 @@ mod tests {
let _ = b.bind("length", Scalar::f64(2.0)); // F64 value for an I64 slot
}
/// unbind reverses bind: the slot returns to the declared surface at its
/// original order, the BoundParam entry disappears, and a subsequent build
/// receives the re-opened value from the open cells again.
#[test]
fn unbind_reopens_the_slot_at_its_original_position() {
// bind b then a, then unbind b: a stays bound, b re-opens before c.
let mut b = probe3().bind("b", Scalar::i64(8)).bind("a", Scalar::i64(7));
b.unbind("b");
assert_eq!(
b.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["b", "c"], // b back at its original slot order; a still bound
);
assert_eq!(
b.bound_params(),
[BoundParam { pos: 0, name: "a".into(), kind: ScalarKind::I64, value: Scalar::i64(7) }]
.as_slice(),
);
// build after unbind ≡ the never-bound build over the same full cells
let built = b.build(&[Cell::from_i64(8), Cell::from_f64(9.0)]); // inject b, c
assert_eq!(
built.label(),
format!("{:?}", vec![Cell::from_i64(7), Cell::from_i64(8), Cell::from_f64(9.0)]),
);
}
#[test]
#[should_panic(expected = "no bound param named")]
fn unbind_unknown_slot_panics() {
let mut b = probe3().bind("b", Scalar::i64(8));
b.unbind("a"); // a is open, not bound
}
#[test]
fn try_bind_ok_and_typed_errors() {
use super::BindOpError;