9ffd1952d2
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
775 lines
33 KiB
Rust
775 lines
33 KiB
Rust
//! The node contract (C8): the interface every node implements. A node's static
|
|
//! signature (its input ports — each a scalar kind + firing policy, C6 — its output
|
|
//! record of 0..K base columns, C7, and its tunable params) is declared once on the
|
|
//! node's `PrimitiveBuilder` (`NodeSchema`), pre-build. The one signature-adjacent
|
|
//! quantity that may depend on an injected param — the per-input buffer lookback —
|
|
//! is answered by `Node::lookbacks()`, read only by bootstrap. `eval` computes one
|
|
//! cycle's row.
|
|
//! Tunable params (C12/C19) are declared via the builder's `NodeSchema.params`
|
|
//! (cycle 0015): each node's typed knobs, which `Composite::param_space` aggregates
|
|
//! into the sweep's flat, path-qualified param-space (C8/C23). Identity is
|
|
//! positional (slot); the name is a non-load-bearing debug symbol.
|
|
|
|
use crate::{Cell, Ctx, Scalar, ScalarKind};
|
|
|
|
/// The firing policy of one input (C6): how the engine decides whether a node
|
|
/// re-evaluates this cycle. A node fires when *any* of its input groups fires
|
|
/// (OR over groups).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Firing {
|
|
/// Mode A — fire-on-any-fresh + hold (as-of join): fires the node whenever
|
|
/// this input is fresh this cycle; a stale input contributes its held value.
|
|
Any,
|
|
/// Mode B — all-fresh barrier (synchronizing join): this input is a member of
|
|
/// barrier group `N`; the group fires the node only when every member shares
|
|
/// the current cycle's timestamp.
|
|
Barrier(u8),
|
|
}
|
|
|
|
/// One declared input **port** of a node: its scalar kind, firing policy (C6), and
|
|
/// a non-load-bearing name. The lookback depth is NOT here — it is a build-time
|
|
/// *sizing* concern answered by `Node::lookbacks()`, not part of the static
|
|
/// signature (a node's lookback can depend on an injected param, e.g. `Sma`'s
|
|
/// window = its `length`). The `name` is a **non-load-bearing** debug symbol (C23):
|
|
/// identity is the positional slot, the name is for tracing / graph rendering
|
|
/// (#13), never read by bootstrap or the run loop (which wire by index). A
|
|
/// `String` (as `FieldSpec.name` and `ParamSpec.name` are): a variadic node
|
|
/// generates per-slot names (`term[0]`), needing ownership, exactly as
|
|
/// `ParamSpec.name` does (`weights[0]`).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PortSpec {
|
|
pub kind: ScalarKind,
|
|
pub firing: Firing,
|
|
pub name: String,
|
|
}
|
|
|
|
/// One declared output column of a node's record: its name (metadata for sinks /
|
|
/// the playground, C18) and its scalar kind. The position of a `FieldSpec` in
|
|
/// `NodeSchema.output` is what an `Edge` binds (`Edge::from_field`); the name is
|
|
/// not load-bearing for wiring. The `name` is a `String` (not `Copy`): a composite
|
|
/// re-exports an interior field under a runtime boundary name (`OutField.name`,
|
|
/// derived in `derive_signature`), so ownership is required — the same reason
|
|
/// `PortSpec.name` and `ParamSpec.name` are owned.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct FieldSpec {
|
|
pub name: String,
|
|
pub kind: ScalarKind,
|
|
}
|
|
|
|
/// One declared tunable parameter of a node (C8/C12): its render name and scalar
|
|
/// kind. The name is a **non-load-bearing** debug symbol (path-qualified at
|
|
/// aggregation, as `FieldSpec.name` already is); a param's identity is its
|
|
/// positional slot in the blueprint's aggregated param-space (C23 — by index, not
|
|
/// by name). The name is a `String` (as `FieldSpec.name` and `PortSpec.name` are):
|
|
/// a vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the
|
|
/// composite path (`strategy.weights[0]`). Permitted kinds: `I64`/`F64`/`Bool`;
|
|
/// `Timestamp` is a structural axis (C20), never a numeric knob.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ParamSpec {
|
|
pub name: String,
|
|
pub kind: ScalarKind,
|
|
}
|
|
|
|
/// Pair each param-space name with the co-indexed positional value — the
|
|
/// inverse of positional binding (C23): names are a derived view, not identity.
|
|
/// `space` and `point` are co-indexed in `param_space()` slot order; a length
|
|
/// mismatch (contract violation) truncates to the shorter, never panics.
|
|
///
|
|
/// The point arrives as tag-free [`Cell`]s (C7): a *validated* param point no
|
|
/// longer carries a per-value kind — that kind lives once, in the co-indexed
|
|
/// `ParamSpec`. This is `AnyColumn::get` for the param plane: a bare cell meets
|
|
/// the kind from its co-present schema and becomes a self-describing [`Scalar`]
|
|
/// only here, at the render boundary.
|
|
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`] — 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,
|
|
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
|
|
/// gate). A blueprint holds these recipes, never built instances, so it stays
|
|
/// value-empty until a param-set is injected (C19/C23). The signature is declared
|
|
/// here ONCE — the built node no longer re-declares it (closes the param-declared-
|
|
/// twice drift, #36) and a value-empty recipe now exposes its full I/O interface
|
|
/// pre-build (#43).
|
|
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)]
|
|
build: Box<dyn Fn(&[Cell]) -> Box<dyn Node>>,
|
|
}
|
|
|
|
impl PrimitiveBuilder {
|
|
/// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
|
|
/// `schema` the full declared signature; `build` constructs a sized node from a
|
|
/// kind-checked param slice.
|
|
pub fn new(
|
|
name: &'static str,
|
|
schema: NodeSchema,
|
|
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
|
) -> Self {
|
|
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
|
|
/// `collect_params`) and is the `Some`/`None` switch for the graph-model
|
|
/// `"name"` field and the viewer prefix. An empty name would yield a broken
|
|
/// address segment (`sma_cross..length`) and a degenerate `Some("")` —
|
|
/// serialised as `"name":""` yet rendering no prefix, i.e. two encodings for
|
|
/// "no visible name". The `debug_assert` guards that at source.
|
|
pub fn named(mut self, name: &str) -> Self {
|
|
debug_assert!(!name.is_empty(), "node name must be non-empty");
|
|
self.instance_name = Some(name.to_string());
|
|
self
|
|
}
|
|
/// The resolved node name: the explicit instance name if set, else the
|
|
/// type label with any `::`-namespace prefix stripped, then ASCII-
|
|
/// lowercased (e.g. "SimBroker" -> "simbroker", "demo::Identity" ->
|
|
/// "identity").
|
|
pub fn node_name(&self) -> String {
|
|
self.instance_name.clone().unwrap_or_else(|| {
|
|
let bare = self.name.rsplit("::").next().unwrap_or(self.name);
|
|
bare.to_ascii_lowercase()
|
|
})
|
|
}
|
|
/// The explicit instance name if one was set via `named()`, else `None`.
|
|
/// Unlike `node_name()`, this does not resolve the default — callers that must
|
|
/// distinguish "explicitly named" from "defaulted" (the graph model) read this.
|
|
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
|
|
}
|
|
/// Map an index into the CURRENT (shrunk) open-param list back to the
|
|
/// slot's position in the ORIGINAL pre-bind param list — the coordinate
|
|
/// space `BoundParam.pos` uses: add one for each already-bound slot at an
|
|
/// original position <= it (the same reconstruction `bind` performs when
|
|
/// recording a `BoundParam`).
|
|
pub fn original_pos(&self, open_idx: usize) -> usize {
|
|
let mut orig = open_idx;
|
|
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;
|
|
}
|
|
}
|
|
orig
|
|
}
|
|
/// The full declared signature (read pre-build by `Composite::param_space`,
|
|
/// `BlueprintNode::signature`, and the renderer).
|
|
pub fn schema(&self) -> &NodeSchema {
|
|
&self.schema
|
|
}
|
|
/// The declared tunable params (a view into the signature; pre-build).
|
|
pub fn params(&self) -> &[ParamSpec] {
|
|
&self.schema.params
|
|
}
|
|
/// 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> {
|
|
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`.
|
|
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}`",
|
|
);
|
|
// 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
|
|
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.
|
|
pub fn try_bind(mut self, slot: &str, value: Scalar) -> Result<Self, BindOpError> {
|
|
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,
|
|
[] => return Err(BindOpError::UnknownParam(slot.to_string())),
|
|
_ => return Err(BindOpError::AmbiguousParam(slot.to_string())),
|
|
};
|
|
if value.kind() != self.schema.params[pos].kind {
|
|
return Err(BindOpError::KindMismatch {
|
|
param: slot.to_string(),
|
|
expected: self.schema.params[pos].kind,
|
|
got: value.kind(),
|
|
});
|
|
}
|
|
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);
|
|
Ok(self)
|
|
}
|
|
}
|
|
|
|
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
|
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
|
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
|
/// conditions as values.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum BindOpError {
|
|
/// No still-open param has this name.
|
|
UnknownParam(String),
|
|
/// More than one still-open param has this name.
|
|
AmbiguousParam(String),
|
|
/// The value's kind does not equal the param's declared kind.
|
|
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
|
}
|
|
|
|
/// A node's declared interface: its inputs (in order) and its output record — an
|
|
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
|
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
|
/// (sink role, C8) — a node with no output port. Built once at wiring, never on
|
|
/// the hot path — the `Vec`s are fine here.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
pub struct NodeSchema {
|
|
pub inputs: Vec<PortSpec>,
|
|
pub output: Vec<FieldSpec>,
|
|
pub params: Vec<ParamSpec>,
|
|
}
|
|
|
|
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
|
|
/// A producer/transformer exposes one output port carrying a record of 1..K base
|
|
/// columns; a **pure consumer (sink)** declares `output: vec![]` and records via
|
|
/// an out-of-graph side effect in `eval` (cycle 0006). The static signature is
|
|
/// declared on the node's `PrimitiveBuilder`; `eval` computes one cycle's row,
|
|
/// returning a borrowed slice into a buffer the node owns. `None` = filter /
|
|
/// not-yet-warmed-up / pure sink; a returned `Some(row)` must satisfy
|
|
/// `row.len() == signature().output.len()` (so a
|
|
/// pure consumer returns `None` or an empty `Some(&[])`) — the engine debug-asserts
|
|
/// the width and forwards the row field-wise along the node's out-edges. `&mut self`
|
|
/// because a node may keep its own derived state (and its output buffer).
|
|
pub trait Node {
|
|
/// The per-input buffer **lookback** depth (each `>= 1`), in input-slot order —
|
|
/// the only signature-adjacent quantity that may depend on an injected param
|
|
/// (e.g. `Sma`'s window = its `length`). Read once by `Harness::bootstrap` to
|
|
/// size each input column; never on the hot path. Length MUST equal the node's
|
|
/// declared `signature().inputs.len()`.
|
|
fn lookbacks(&self) -> Vec<usize>;
|
|
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]>;
|
|
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
|
|
/// tracing / graph rendering (#13), never read by the run loop and never
|
|
/// part of wiring (which is by index). Overrides SHOULD carry the node's
|
|
/// identifying params so identical node types disambiguate (`SMA(2)` vs
|
|
/// `SMA(4)`). MUST be single-line (no `\n`): the graph render draws each
|
|
/// label in one cell. The default is a placeholder; every shipped node
|
|
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
|
|
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
|
|
fn label(&self) -> String {
|
|
"node".to_string()
|
|
}
|
|
|
|
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
|
|
/// order, after the source loop drains. A folding sink overrides it to flush
|
|
/// its accumulated summary; the default is a no-op, so existing nodes are
|
|
/// unaffected. Takes `&mut self` like `eval`, so `Node` stays object-safe.
|
|
fn finalize(&mut self) {}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A minimal node with no inputs, reused across the label / builder tests.
|
|
struct Bare;
|
|
impl Node for Bare {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![]
|
|
}
|
|
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn port_spec_carries_firing() {
|
|
let a = PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() };
|
|
let b = PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() };
|
|
assert_eq!(a.firing, Firing::Any);
|
|
assert_eq!(b.firing, Firing::Barrier(0));
|
|
assert_ne!(a.firing, b.firing);
|
|
// the name is carried (non-load-bearing, but present)
|
|
assert_eq!(a.name, "lhs");
|
|
assert_eq!(b.name, "rhs");
|
|
}
|
|
|
|
#[test]
|
|
fn default_label_is_placeholder() {
|
|
// A node that does not override label() falls back to the placeholder.
|
|
assert_eq!(Bare.label(), "node");
|
|
}
|
|
|
|
#[test]
|
|
fn default_finalize_is_a_callable_noop() {
|
|
// a node that does not override finalize uses the default no-op; calling
|
|
// it compiles and does nothing (additive, non-breaking for existing nodes).
|
|
let mut b = Bare;
|
|
b.finalize();
|
|
}
|
|
|
|
#[test]
|
|
fn primitive_builder_label_is_the_bare_type() {
|
|
// param-generic: the label is just the node type, no knob suffix — the
|
|
// blueprint view is param-generic (C23), values appear only post-compile.
|
|
let with = PrimitiveBuilder::new(
|
|
"SMA",
|
|
NodeSchema {
|
|
inputs: vec![],
|
|
output: vec![],
|
|
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
|
},
|
|
|_| Box::new(Bare),
|
|
);
|
|
assert_eq!(with.label(), "SMA");
|
|
let none = PrimitiveBuilder::new(
|
|
"Sub",
|
|
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
|
|_| Box::new(Bare),
|
|
);
|
|
assert_eq!(none.label(), "Sub");
|
|
}
|
|
|
|
#[test]
|
|
fn node_name_defaults_to_lowercased_type_label() {
|
|
// unnamed → the type label, ASCII-lowercased verbatim (no snake_case)
|
|
let unnamed = PrimitiveBuilder::new(
|
|
"SimBroker",
|
|
NodeSchema::default(),
|
|
|_| panic!("not built in this test"),
|
|
);
|
|
assert_eq!(unnamed.node_name(), "simbroker");
|
|
|
|
// explicit name overrides
|
|
let named = PrimitiveBuilder::new(
|
|
"SMA",
|
|
NodeSchema::default(),
|
|
|_| panic!("not built in this test"),
|
|
)
|
|
.named("fast");
|
|
assert_eq!(named.node_name(), "fast");
|
|
}
|
|
|
|
#[test]
|
|
fn node_name_strips_namespace_prefix() {
|
|
let b = PrimitiveBuilder::new(
|
|
"demo::Identity",
|
|
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
|
|_| unreachable!("never built in this test"),
|
|
);
|
|
assert_eq!(b.node_name(), "identity");
|
|
}
|
|
|
|
#[test]
|
|
fn instance_name_is_some_only_when_explicitly_named() {
|
|
// unnamed → None (instance_name() does NOT resolve node_name()'s
|
|
// lowercased-type default — that is exactly the value the graph model
|
|
// must not surface)
|
|
let unnamed = PrimitiveBuilder::new(
|
|
"SimBroker",
|
|
NodeSchema::default(),
|
|
|_| panic!("not built in this test"),
|
|
);
|
|
assert_eq!(unnamed.instance_name(), None);
|
|
|
|
// explicitly named → Some(the raw name)
|
|
let named = PrimitiveBuilder::new(
|
|
"SMA",
|
|
NodeSchema::default(),
|
|
|_| panic!("not built in this test"),
|
|
)
|
|
.named("fast");
|
|
assert_eq!(named.instance_name(), Some("fast"));
|
|
}
|
|
|
|
#[test]
|
|
fn primitive_builder_build_runs_the_closure() {
|
|
let f = PrimitiveBuilder::new(
|
|
"Bare",
|
|
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
|
|_| Box::new(Bare),
|
|
);
|
|
assert_eq!(f.params(), Vec::<ParamSpec>::new());
|
|
assert_eq!(f.build(&[]).lookbacks(), Vec::<usize>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn schema_carries_declared_params() {
|
|
let b = PrimitiveBuilder::new(
|
|
"SMA",
|
|
NodeSchema {
|
|
inputs: vec![],
|
|
output: vec![],
|
|
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
|
},
|
|
|_| Box::new(Bare),
|
|
);
|
|
assert_eq!(
|
|
b.schema().params,
|
|
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<Cell>);
|
|
impl Node for Probe {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![]
|
|
}
|
|
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
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(&[Cell::from_f64(9.0)]); // inject c
|
|
assert_eq!(
|
|
built.label(),
|
|
format!("{:?}", vec![Cell::from_i64(7), Cell::from_i64(8), Cell::from_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(&[Cell::from_i64(7), Cell::from_f64(9.0)]); // a, c injected in order
|
|
assert_eq!(
|
|
built2.label(),
|
|
format!("{:?}", vec![Cell::from_i64(7), Cell::from_i64(8), Cell::from_f64(9.0)]),
|
|
);
|
|
}
|
|
|
|
/// original_pos maps a shrunk-schema index back to the pre-bind slot,
|
|
/// mirroring bind's own BoundParam.pos recording.
|
|
#[test]
|
|
fn original_pos_reconstructs_prebind_slots() {
|
|
let b = probe3(); // params a,b,c at 0,1,2
|
|
assert_eq!(b.original_pos(0), 0);
|
|
assert_eq!(b.original_pos(2), 2);
|
|
let b = b.bind("b", Scalar::i64(1)); // shrunk: [a, c]
|
|
assert_eq!(b.original_pos(0), 0); // a
|
|
assert_eq!(b.original_pos(1), 2); // c sat at 2 originally
|
|
}
|
|
|
|
#[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() {
|
|
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
|
|
}
|
|
|
|
/// 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;
|
|
// A probe with one i64 param `length` (a real std node like `Sma` would
|
|
// pull in aura-std, but aura-core cannot dev-depend on it without a
|
|
// dev-cycle that compiles aura-core twice — incompatible `Scalar` types).
|
|
let probe = || {
|
|
PrimitiveBuilder::new(
|
|
"Probe",
|
|
NodeSchema {
|
|
inputs: vec![],
|
|
output: vec![],
|
|
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
|
},
|
|
|_| Box::new(Bare),
|
|
)
|
|
};
|
|
// ok: exact name, matching kind
|
|
assert!(probe().try_bind("length", Scalar::i64(3)).is_ok());
|
|
// unknown param name
|
|
assert_eq!(
|
|
probe().try_bind("nope", Scalar::i64(3)).err(),
|
|
Some(BindOpError::UnknownParam("nope".into()))
|
|
);
|
|
// kind mismatch (f64 into an i64 param)
|
|
assert_eq!(
|
|
probe().try_bind("length", Scalar::f64(3.0)).err(),
|
|
Some(BindOpError::KindMismatch {
|
|
param: "length".into(),
|
|
expected: ScalarKind::I64,
|
|
got: ScalarKind::F64,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod zip_params_tests {
|
|
use super::{zip_params, ParamSpec};
|
|
use crate::{Cell, Scalar, ScalarKind};
|
|
|
|
#[test]
|
|
fn zip_params_pairs_names_with_values_in_slot_order() {
|
|
let space = vec![
|
|
ParamSpec { name: "fast".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
|
|
];
|
|
let point = vec![Cell::from_i64(2), Cell::from_f64(0.5)];
|
|
assert_eq!(
|
|
zip_params(&space, &point),
|
|
vec![
|
|
("fast".to_string(), Scalar::i64(2)),
|
|
("scale".to_string(), Scalar::f64(0.5)),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zip_params_matches_hand_zip() {
|
|
let space = vec![
|
|
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
|
|
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
|
];
|
|
let point = vec![Cell::from_i64(7), Cell::from_i64(9)];
|
|
let hand: Vec<(String, Scalar)> =
|
|
space.iter().zip(&point).map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c))).collect();
|
|
assert_eq!(zip_params(&space, &point), hand);
|
|
}
|
|
}
|