//! 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, schema: NodeSchema, bound: Vec, // 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 Box>, } 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 + '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 (`..`, 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 = 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 { 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 = 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 { let matches: Vec = 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, pub output: Vec, pub params: Vec, } /// 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; 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::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 { 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::::new()); assert_eq!(f.build(&[]).lookbacks(), Vec::::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); impl Node for Probe { fn lookbacks(&self) -> Vec { 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::>(), ["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::>(), ["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); } }