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:
@@ -148,4 +148,38 @@ mod tests {
|
||||
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_bind_reconstructs_positional_vector() {
|
||||
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
|
||||
let builder = LinComb::builder(2)
|
||||
.bind("weights[1]", Scalar::F64(2.0))
|
||||
.bind("weights[0]", Scalar::F64(0.5));
|
||||
assert!(builder.params().is_empty());
|
||||
let mut lc = builder.build(&[]);
|
||||
let mut inputs = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
];
|
||||
inputs[0].push(Scalar::F64(10.0)).unwrap();
|
||||
inputs[1].push(Scalar::F64(3.0)).unwrap();
|
||||
// 0.5*10 + 2.0*3 = 11.0 — holds ONLY if each weight landed in its right slot
|
||||
// (a swap would give 2.0*10 + 0.5*3 = 21.5)
|
||||
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice()));
|
||||
|
||||
// partial: bind weights[0], leave weights[1] open → inject it at build
|
||||
let partial = LinComb::builder(2).bind("weights[0]", Scalar::F64(0.5));
|
||||
assert_eq!(
|
||||
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||
["weights[1]"],
|
||||
);
|
||||
let mut lc2 = partial.build(&[Scalar::F64(2.0)]); // weights[1] = 2.0 injected
|
||||
let mut inputs2 = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
];
|
||||
inputs2[0].push(Scalar::F64(10.0)).unwrap();
|
||||
inputs2[1].push(Scalar::F64(3.0)).unwrap();
|
||||
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,4 +155,21 @@ mod tests {
|
||||
fn input_slot_is_named_series() {
|
||||
assert_eq!(Sma::builder().schema().inputs[0].name, "series");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_removes_slot_from_param_space() {
|
||||
// a bound param-bearing node reports an empty param surface — parity with the
|
||||
// SimBroker precedent (nodes_declare_expected_params, this file)
|
||||
let sma2 = Sma::builder().named("bias").bind("length", Scalar::I64(2));
|
||||
assert!(sma2.schema().params.is_empty());
|
||||
// contrast: the length-generic SMA keeps `length` open
|
||||
assert_eq!(Sma::builder().named("bias").params().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_node_builds_with_injected_value() {
|
||||
// built with an empty open slice, the bound builder yields SMA(2)
|
||||
let node = Sma::builder().bind("length", Scalar::I64(2)).build(&[]);
|
||||
assert_eq!(node.label(), "SMA(2)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user