Files
Aura/crates/aura-core/src/node.rs
T
Brummel cd3d1ca9ed refactor(aura-core): split Scalar into a tag-free Cell + ScalarKind
Motivation
----------
`Scalar` was a tagged enum (I64/F64/Bool/Ts), so every scalar value
physically carried its own kind tag. But the kind is already known from
the schema/port/column the value flows through (C7: the type is a
property of the column, not of the value — the hot path is already
columnar `Column<T>`, and `AnyColumn::get` *reconstructs* the tag from
the column on the way out). The per-value tag was therefore redundant
with the kind the surrounding context already holds.

That redundancy had three costs:

  * It baked an implicit `match` (a branch) into every function that read
    a Scalar payload — even where the caller statically knew the type.
    The tag could never be exploited away.
  * Size: a tagged enum is tag + payload = 16 bytes (f64/i64 alignment),
    twice the 8 bytes the value needs. A `Column<Scalar>` would be double
    the memory and half the cache utilisation.
  * It is the shared root of several downstream papercuts we keep hitting
    — the lossy f64 manifest field, the `unreachable!` panic on a
    non-numeric param, the serde-tag question — all symptoms of "the type
    is baked into the value".

Change
------
Introduce `Cell`: a type-erased 64-bit word (`struct Cell(u64)`) that is
not readable without external type context. It is constructed per base
type (`from_i64/from_f64/from_bool/from_ts`) and read only by naming the
type at the call site (`i64()/f64()/bool()/ts()`) — each a branch-free
bit-cast. The hot path resolves the kind once at the boundary (from the
schema) and then reads natively, with no per-value branch. `Cell` knows
nothing of `Scalar` or `ScalarKind`; the dependency is strictly one-way,
and it lives in its own `cell.rs` (more is planned on top of it).

`Scalar` becomes `struct { kind: ScalarKind, cell: Cell }` — the
self-describing form for the dynamic boundaries (builder binding,
serialization, rendering), built on top of `Cell`. Its `as_*` accessors
now `debug_assert` the kind and return the native value (free in
release); calling the wrong accessor is a caller bug, not a checked
`Option`. The variant constructors `Scalar::I64(..)` become associated
fns `Scalar::i64(..)`.

`PartialEq` is hand-written (not derived) to preserve the former enum's
value semantics: kinds must match, then native payloads compare, so f64
keeps IEEE-754 behaviour (`NaN != NaN`, `+0.0 == -0.0`) and a kind
mismatch is never equal even when the raw words coincide. A fixture
(`scalar_eq_is_value_not_bitwise`) pins exactly the cases where bit- and
value-equality diverge, so it can't silently regress. `Cell`'s own
`Eq`/`Hash` stay bitwise — correct for a raw word.

The change is behaviour-preserving: Scalar's observable behaviour is
identical to the pre-Cell enum (the value-equality fixture proves it);
only the internal representation changed. The ~440 call sites across the
workspace are a mechanical constructor rename plus ~12 destructuring
sites (match-arms / `let`-patterns) rewritten to `kind()` + `as_*`.

Verified: cargo build --workspace --all-targets, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace — all green.
2026-06-16 12:12:52 +02:00

567 lines
24 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::{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.
pub fn zip_params(space: &[ParamSpec], point: &[Scalar]) -> Vec<(String, Scalar)> {
space.iter().zip(point).map(|(ps, v)| (ps.name.clone(), *v)).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.
#[derive(Clone, Debug, PartialEq)]
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(&[Scalar]) -> 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(&[Scalar]) -> 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 ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker").
pub fn node_name(&self) -> String {
self.instance_name
.clone()
.unwrap_or_else(|| self.name.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
}
/// 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 param slice (the slice is kind-checked by the
/// caller before this runs).
pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
(self.build)(params)
}
/// 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}`",
);
// [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.
let name = self.schema.params[pos].name.clone();
let kind = self.schema.params[pos].kind;
let mut orig = pos;
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;
}
}
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: &[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
/// 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<&[Scalar]>;
/// 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()
}
}
#[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<&[Scalar]> {
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 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 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<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]
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
}
}
#[cfg(test)]
mod zip_params_tests {
use super::{zip_params, ParamSpec};
use crate::{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![Scalar::i64(2), Scalar::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![Scalar::i64(7), Scalar::i64(9)];
let hand: Vec<(String, Scalar)> =
space.iter().zip(&point).map(|(ps, v)| (ps.name.clone(), *v)).collect();
assert_eq!(zip_params(&space, &point), hand);
}
}