feat(aura-core): PrimitiveBuilder::bind — fix a node param as a structural constant
Add `PrimitiveBuilder::bind(slot, value)`: bind a declared param to a structural constant, removed from param_space entirely rather than pinned in the injected vector. This closes the gap #55 names — param-bearing nodes (Sma/Exposure/LinComb) had no constant path, only the no-sweep-mode SimBroker precedent. The motivating case is "SMA2-entry": the 2 is bound to the two-candle construction, so sweeping it would enumerate deformed strategies as valid family members. Approach (Option B, builder-level — the originally-planned direction): bind shrinks the builder's declared param surface (schema.params) and wraps its build closure to re-splice the captured constant at its original positional slot. The construction layer (collect_params / lower_items / param_space / compile_with_params) is byte-unchanged: both dock sites already key off builder.params(), so the shrink propagates for free. Addressing is by param name (the by-name authoring address space, C23/0032); the compilat stays wired by raw index — names never reach it. bind enforces "exactly one open param matches" itself (collect-all-then-reject, panic on zero / on duplicate), mirroring the resolve binder's posture, because a node's own schema.params is not covered by check_param_namespace_injective. Chained binds reconstruct the correct positional vector because each layer computes its slot index relative to the param list it sees (no global position table). Considered and rejected: a per-node builder_const(N) constructor (explodes combinatorially over which subset is constant, not dynamic); a ParamBinding overlay struct on Composite (would need new bookkeeping the builder-level form avoids). Verified: cargo build/clippy/test --workspace all green; new tests cover slot removal, positional reconstruction (chained + partial, via eval), the three panic paths, and the composite param_space projection; blueprint.rs construction layer confirmed byte-unchanged. Deferred (out of scope, follow-up issue): exporting a named frozen strategy as a blueprint-as-values collection — the issue's third fork, needs its own brainstorm. closes #55
This commit is contained in:
@@ -127,6 +127,46 @@ impl PrimitiveBuilder {
|
||||
pub fn label(&self) -> String {
|
||||
self.name.to_string()
|
||||
}
|
||||
/// 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` (which
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
@@ -276,4 +316,108 @@ mod tests {
|
||||
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// A node whose label echoes the param vector its constructor received — lets a
|
||||
/// test read back the positional vector `.build()` reconstructed after binds.
|
||||
struct Probe(Vec<Scalar>);
|
||||
impl Node for Probe {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![]
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
format!("{:?}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn probe3() -> PrimitiveBuilder {
|
||||
// params [a: I64, b: I64, c: F64]; build stores the received slice
|
||||
PrimitiveBuilder::new(
|
||||
"Probe",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![
|
||||
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
},
|
||||
|p| Box::new(Probe(p.to_vec())),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_removes_slot_and_reconstructs_positionally() {
|
||||
// chained bind of b then a (reverse slot order); c stays open
|
||||
let bound = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7));
|
||||
assert_eq!(
|
||||
bound.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||
["c"], // only c remains open
|
||||
);
|
||||
let built = bound.build(&[Scalar::F64(9.0)]); // inject c
|
||||
assert_eq!(
|
||||
built.label(),
|
||||
format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]),
|
||||
);
|
||||
|
||||
// partial: bind only b; a and c stay open and keep their positions
|
||||
let built2 = probe3()
|
||||
.bind("b", Scalar::I64(8))
|
||||
.build(&[Scalar::I64(7), Scalar::F64(9.0)]); // a, c injected in order
|
||||
assert_eq!(
|
||||
built2.label(),
|
||||
format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no open param named")]
|
||||
fn bind_unknown_slot_panics() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"Probe",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let _ = b.bind("width", Scalar::I64(2)); // "width" is not a declared param
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ambiguous")]
|
||||
fn bind_ambiguous_slot_panics() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"Probe",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let _ = b.bind("dup", Scalar::I64(1)); // two slots named "dup"
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "kind mismatch")]
|
||||
fn bind_kind_mismatch_panics() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"Probe",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let _ = b.bind("length", Scalar::F64(2.0)); // F64 value for an I64 slot
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user