feat(std): CumSum and When cells; roster complete at 33

Second half of the #281 clock-enable set. CumSum is the generic
run-length accumulator with O(1) Neumaier-compensated state — the
branching Neumaier variant, not Sma's plain Kahan, so it stays exact
when a large running total absorbs a tiny sample (the standalone
accumulator has no windowed-magnitude guarantee; module doc explains
why the helper is not reused). When is the clock-enable driver: forwards
value iff gate, else a quiet cycle (eval->None), so every stateful
reducer composes gated unmodified — SMA(When(x,g),N) is the gated SMA.

Rosters CumSum and When (both count pins 31->33) and ships two engine
E2E fixtures: When forwarding/quiet-downstream through the real
GraphBuilder->compile->run seam, and a quiet When leg suppressing a
whole Resample Barrier(0) group (the deliberate stall semantics).

refs #281
This commit is contained in:
2026-07-17 20:39:56 +02:00
parent 40e962db89
commit c8dc57e2aa
7 changed files with 484 additions and 7 deletions
+2 -2
View File
@@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
31,
"the std-only (no project) vocabulary has exactly the roster's 31 entries: {stdout}"
33,
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
);
}
+54
View File
@@ -0,0 +1,54 @@
//! End-to-end coverage for the `CumSum` standard cell (#281): the generic
//! running-total accumulator.
//!
//! The in-module tests in `aura-std/src/cumsum.rs` drive `eval` directly —
//! they never go through a `PrimitiveBuilder` schema, a `GraphBuilder`-resolved
//! wiring, or a compiled+bootstrapped `FlatGraph`. That is the real seam a
//! data-authored op-script or a Rust builder actually exercises (C24); this
//! file proves `CumSum` is reachable from the public `aura_engine`/`aura_std`
//! construction surface, wires cleanly by declared port name/kind, and
//! accumulates the right running total once actually driven cycle-by-cycle
//! through a real `Harness::run` — the same bar the Sign/Select pair set in
//! `select_sign_e2e.rs`.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{GraphBuilder, VecSource};
use aura_std::{CumSum, Recorder};
/// A deterministic tick stream, no timestamps/randomness beyond a fixed
/// literal sequence.
fn ticks(values: &[f64]) -> Vec<(Timestamp, Scalar)> {
values.iter().enumerate().map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))).collect()
}
/// Property: **`CumSum` correctly registers its `PrimitiveBuilder` schema
/// (one `f64` input, one `f64` output) and dispatches through a
/// compiled+bootstrapped `FlatGraph`, accumulating the running total across
/// real engine cycles** — not merely via a direct `eval` call (the in-module
/// `cumsum.rs` unit tests' seam). A schema/wiring regression (wrong port
/// kind, wrong output arity) would fail at `GraphBuilder::build` or
/// `compile_with_params`, before a single cycle runs; a dispatch or
/// state-carry regression would show up in the recorded running totals below.
#[test]
fn cumsum_dispatches_through_a_compiled_graph_and_accumulates() {
let (tx, rx) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let series = g.source_role("series", ScalarKind::F64);
let cumsum = g.add(CumSum::builder());
let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx));
g.feed(series, [cumsum.input("series")]);
g.connect(cumsum.output("value"), rec.input("col[0]"));
let mut h = g.build().expect("resolves by name").bootstrap_with_params(vec![]).expect("bootstraps");
h.run(vec![Box::new(VecSource::new(ticks(&[2.0, -0.5, 3.5, 0.0])))]);
let trace: Vec<f64> = rx.try_iter().map(|(_, row)| row[0].as_f64()).collect();
assert_eq!(
trace,
vec![2.0, 1.5, 5.0, 5.0],
"running total accumulated across real engine cycles, dispatched through a compiled FlatGraph"
);
}
+133
View File
@@ -0,0 +1,133 @@
//! End-to-end coverage for the `When` standard cell (#281): the clock-enable
//! `f64 x bool -> f64` driver.
//!
//! The in-module tests in `aura-std/src/when.rs` drive `eval` directly — they
//! never go through a `PrimitiveBuilder` schema, a `GraphBuilder`-resolved
//! wiring, or a compiled+bootstrapped `FlatGraph`. That is the real seam a
//! data-authored op-script or a Rust builder actually exercises (C24); this
//! file proves `When` is reachable from the public `aura_engine`/`aura_std`
//! construction surface, wires cleanly by declared port name/kind (`value:
//! F64`, `gate: Bool`), and — the load-bearing property — that a quiet
//! (gate-false) cycle never reaches a downstream sink: the recorder's own
//! input only advances on a fresh push from `When`, so a quiet cycle leaves
//! it un-fired rather than recording a held/zero value.
//!
//! One `price` source feeds both `When`'s `value` leg and, via
//! `Gt(|price|, price)`, its `gate` leg (true exactly when `price < 0`) — the
//! same single-source-per-cycle shape `select_sign_e2e.rs` uses, so `gate`
//! and `value` are always computed in the same engine cycle (no cross-source
//! phase skew between two independently-clocked inputs).
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{GraphBuilder, VecSource};
use aura_std::{Abs, Gt, Recorder, Resample, When};
/// A deterministic `f64` tick stream, no timestamps/randomness beyond a fixed
/// literal sequence.
fn ticks(values: &[f64]) -> Vec<(Timestamp, Scalar)> {
values.iter().enumerate().map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))).collect()
}
/// Property: **`When` correctly registers its `PrimitiveBuilder` schema
/// (`value: F64`, `gate: Bool` in, one `f64` out) and dispatches through a
/// compiled+bootstrapped `FlatGraph`, forwarding `value` on gate-true cycles
/// and going quiet — not merely holding or zeroing — on gate-false cycles,
/// all the way to a downstream sink.** A schema/wiring regression (wrong port
/// kind, wrong output arity) would fail at `GraphBuilder::build` or
/// `compile_with_params`, before a single cycle runs; a dispatch regression
/// would show up in the recorded trace below — either the wrong values or,
/// were quiet cycles wrongly forwarding a held/zero value, extra rows.
#[test]
fn when_dispatches_through_a_compiled_graph_and_is_quiet_downstream_on_gate_false() {
let (tx, rx) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let price = g.source_role("price", ScalarKind::F64);
let abs = g.add(Abs::builder());
let gt = g.add(Gt::builder());
let when = g.add(When::builder());
let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx));
g.feed(price, [abs.input("value"), gt.input("b"), when.input("value")]);
g.connect(abs.output("value"), gt.input("a"));
g.connect(gt.output("value"), when.input("gate"));
g.connect(when.output("value"), rec.input("col[0]"));
let mut h = g.build().expect("resolves by name").bootstrap_with_params(vec![]).expect("bootstraps");
h.run(vec![Box::new(VecSource::new(ticks(&[-3.0, 2.5, -1.0, 4.0])))]);
let trace: Vec<f64> = rx.try_iter().map(|(_, row)| row[0].as_f64()).collect();
assert_eq!(
trace,
vec![-3.0, -1.0],
"only the two negative-price (gate-true) rows reach the sink; the positive-price rows are quiet, not recorded as held/zero"
);
}
/// Property: **a quiet `When` (gate false) driving one leg of a `Firing::Barrier(N)`
/// group suppresses the WHOLE group that cycle — not merely its own leg.** This is
/// the composition the `when.rs` module doc calls out directly ("a quiet `When`
/// feeding a `Firing::Barrier(N)` group means the group does not fire that cycle").
/// `Resample`'s four OHLC inputs are `aura-std`'s only `Barrier(0)` consumer; here
/// `When` drives `close` while `open`/`high`/`low` are wired straight from the same
/// source (fresh every cycle, gate or no gate). On a gate-false tick the other three
/// legs still receive a fresh push, but `close` does not — since `Barrier(0)`
/// requires all four co-fresh, the node does not fire at all that cycle, so the
/// gate-false tick's price never enters the OHLC accumulator (not even via the
/// ungated legs), and a bucket rollover due exactly on a gate-false tick is
/// deferred to the next gate-true tick rather than firing early or partially.
#[test]
fn quiet_when_leg_suppresses_the_whole_barrier_group() {
let (tx, rx) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let price = g.source_role("price", ScalarKind::F64);
let abs = g.add(Abs::builder());
let gt = g.add(Gt::builder());
let when = g.add(When::builder());
let resample = g.add(Resample::builder());
let rec = g.add(Recorder::builder(vec![ScalarKind::F64; 4], Firing::Any, tx));
g.feed(price, [
abs.input("value"),
gt.input("b"),
when.input("value"),
resample.input("open"),
resample.input("high"),
resample.input("low"),
]);
g.connect(abs.output("value"), gt.input("a"));
g.connect(gt.output("value"), when.input("gate"));
g.connect(when.output("value"), resample.input("close"));
g.connect(resample.output("open"), rec.input("col[0]"));
g.connect(resample.output("high"), rec.input("col[1]"));
g.connect(resample.output("low"), rec.input("col[2]"));
g.connect(resample.output("close"), rec.input("col[3]"));
let mut h = g
.build()
.expect("resolves by name")
.bootstrap_with_params(vec![Scalar::i64(1)]) // Resample's sole open param: period_minutes=1
.expect("bootstraps");
// gate = price < 0 (Gt(|price|, price)); 1-minute buckets (period_ns = 60e9).
let feed: [(i64, f64); 5] = [
(0, -1.0), // gate true: bucket 0 opens (o=h=l=c=-1.0)
(10_000_000_000, 2.0), // gate false: When quiet -> Resample does not fire at all
(20_000_000_000, -3.0), // gate true, still bucket 0: high stays -1.0, low->-3.0, close->-3.0
(60_000_000_000, 4.0), // gate false exactly on the bucket-1 boundary: rollover DEFERRED
(70_000_000_000, -6.0), // gate true, bucket 1: fires -> rolls over the completed bucket-0 bar
];
let ticks: Vec<(Timestamp, Scalar)> =
feed.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect();
h.run(vec![Box::new(VecSource::new(ticks))]);
let trace: Vec<Vec<f64>> =
rx.try_iter().map(|(_, row)| row.iter().map(|c| c.as_f64()).collect()).collect();
assert_eq!(
trace,
vec![vec![-1.0, -1.0, -3.0, -3.0]],
"the two gate-false ticks (price 2.0, 4.0) must never enter the OHLC accumulator: \
a quiet When leg blocks the whole Barrier(0) group, not just its own field, and the \
bucket-1 rollover due at t=60e9 is deferred to the next gate-true tick (t=70e9)"
);
}
+145
View File
@@ -0,0 +1,145 @@
//! `CumSum` — running total of one `f64` input with O(1) scalar state.
//!
//! The generic run-length accumulator (#281). The domain-embedded precedents
//! (`SimBroker.cum`, `PositionManagement.cum_realized_r`, `CostRunner.cum`)
//! stay as they are; this cell is the vocabulary form, so counters compose:
//! `count_true = CumSum(Select(cond, 1.0, 0.0))`.
//!
//! The running sum carries a compensation term for the same reason
//! [`Sma`](crate::Sma) documents: an accumulative running sum has nowhere for
//! rounding error to go, so over millions of adds an uncompensated total
//! drifts. The compensated total is folded in at the read (`sum + comp`); a
//! `NaN` input poisons the total (same posture as `Sma`, not guarded).
//!
//! **Neumaier, not Sma's plain Kahan.** `Sma`'s private `kahan()` helper uses
//! the branchless Kahan formula, sound because a windowed mean's addends stay
//! the same order of magnitude as the running window sum. A free-standing
//! accumulator has no such guarantee — a large running total can absorb a
//! comparatively tiny sample (`1e16 + 1.0`), or the reverse — so this cell uses
//! the branching Neumaier variant, which stays exact in *both* magnitude orders
//! (`|sum| >= |x|` and `|x| > |sum|`). Plain Kahan drops the small addend in the
//! latter case (`[1e16, 1.0, -1e16]` -> `0.0`, not `1.0`); Neumaier keeps it.
//! That is why the helper is not reused here — the algorithms differ, and this
//! one is deliberately the more robust of the two.
//!
//! One `f64` input (`Firing::Any`), one `f64` output, no params. Emits from
//! the first sample (`cum = x0`); state advances once per fired eval (the
//! engine's freshness gating guarantees one eval per fresh sample).
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
/// Neumaier-compensated running total: emits `x0 + x1 + ... + xn` at each
/// fired cycle. Emits `None` until the input is warm (C8).
pub struct CumSum {
// Running sum and its Neumaier compensation term (the low-order bits each
// add dropped, folded into the emitted total at the read).
sum: f64,
comp: f64,
out: [Cell; 1],
}
impl CumSum {
/// Build a `CumSum` node, initially zero.
pub fn new() -> Self {
Self { sum: 0.0, comp: 0.0, out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe for a blueprint primitive: paramless, builds
/// through `CumSum::new`.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"CumSum",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(CumSum::new()),
)
}
}
impl Default for CumSum {
fn default() -> Self {
Self::new()
}
}
impl Node for CumSum {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let v = ctx.f64_in(0);
if v.is_empty() {
return None; // warm-up gate (C8)
}
let x = v[0];
// Neumaier add: capture the low-order bits the naive add drops.
let t = self.sum + x;
if self.sum.abs() >= x.abs() {
self.comp += (self.sum - t) + x;
} else {
self.comp += (x - t) + self.sum;
}
self.sum = t;
self.out[0] = Cell::from_f64(self.sum + self.comp);
Some(&self.out)
}
fn label(&self) -> String {
"CumSum".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn cumsum_accumulates_from_the_first_sample() {
// PROPERTY: emits the running total from the very first sample on,
// advancing once per fired eval. Exact-representable values keep the
// compensation term at zero, so the expectations are exact.
let mut node = CumSum::new();
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let feed = [2.0, -0.5, 3.5, 0.0];
let want = [2.0, 1.5, 5.0, 5.0];
for (x, w) in feed.iter().zip(want) {
inputs[0].push(Scalar::f64(*x)).unwrap();
let got = node.eval(Ctx::new(&inputs, Timestamp(0)));
assert_eq!(got, Some([Cell::from_f64(w)].as_slice()));
}
}
#[test]
fn cumsum_is_none_until_warm() {
let mut node = CumSum::new();
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn cumsum_compensation_survives_magnitude_spread() {
// The Neumaier pin: 1e16 + 1.0 == 1e16 in naive f64 (the 1.0 is below
// the ULP), so a naive running sum over [1e16, 1.0, -1e16] ends at 0.0.
// The compensated total keeps the small addend and ends at exactly 1.0.
let mut node = CumSum::new();
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let mut last = 0.0;
for x in [1e16, 1.0, -1e16] {
inputs[0].push(Scalar::f64(x)).unwrap();
last = node.eval(Ctx::new(&inputs, Timestamp(0))).unwrap()[0].f64();
}
assert_eq!(last, 1.0, "the compensated running sum keeps the small addend");
}
#[test]
fn input_slot_is_named_series() {
let names: Vec<String> =
CumSum::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["series"]);
}
}
+4
View File
@@ -26,6 +26,7 @@ mod const_node;
mod constant_cost;
mod cost;
mod cost_sum;
mod cumsum;
mod delay;
mod div;
mod ema;
@@ -57,6 +58,7 @@ mod sub;
mod vocabulary;
mod vol_slippage_cost;
mod vol_tf_stop;
mod when;
pub use abs::Abs;
pub use add::Add;
pub use and::And;
@@ -69,6 +71,7 @@ pub use cost::{
COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH,
};
pub use cost_sum::CostSum;
pub use cumsum::CumSum;
pub use delay::Delay;
pub use div::Div;
pub use ema::Ema;
@@ -103,3 +106,4 @@ pub use sub::Sub;
pub use vocabulary::{std_vocabulary, std_vocabulary_types};
pub use vol_slippage_cost::VolSlippageCost;
pub use vol_tf_stop::VolTfStop;
pub use when::When;
+7 -5
View File
@@ -18,9 +18,9 @@
//! later, additive extension (#156/C20).
use crate::{
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt,
Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale,
Select, SessionFrankfurt, Sign, Sizer, Sma, Sqrt, Sub, VolSlippageCost,
Abs, Add, And, Bias, CarryCost, Const, ConstantCost, CumSum, Delay, Div, Ema, EqConst,
FixedStop, Gt, Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax,
RollingMin, Scale, Select, SessionFrankfurt, Sign, Sizer, Sma, Sqrt, Sub, VolSlippageCost, When,
};
use aura_core::PrimitiveBuilder;
@@ -65,6 +65,7 @@ std_vocabulary_roster! {
"CarryCost" => CarryCost,
"Const" => Const,
"ConstantCost" => ConstantCost,
"CumSum" => CumSum,
"Delay" => Delay,
"Div" => Div,
"EMA" => Ema,
@@ -89,6 +90,7 @@ std_vocabulary_roster! {
"Sqrt" => Sqrt,
"Sub" => Sub,
"VolSlippageCost" => VolSlippageCost,
"When" => When,
}
#[cfg(test)]
@@ -132,7 +134,7 @@ mod tests {
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 31 entries
assert_eq!(std_vocabulary_types().len(), 31);
// count guard: pins the roster at exactly 33 entries
assert_eq!(std_vocabulary_types().len(), 33);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `When` — the clock-enable driver `f64 × bool -> f64`: forwards `value`
//! verbatim when `gate` is true, and is **quiet** (`eval -> None`, the
//! `Resample` precedent) when it is false.
//!
//! The single clock-enable standard cell of the RTL metaphor (#281): gating
//! belongs in the *stream*, not in per-node gated variants. Because the run
//! loop is freshness-gated, every existing stateful reducer composes gated,
//! unmodified: `SMA(When(x, gate), N)` IS the gated SMA — under
//! `Firing::Any` a consumer holds its last forwarded value across quiet
//! cycles and its state advances per *firing*, not per cycle. A quiet
//! `When` feeding a `Firing::Barrier(N)` group means the group does not
//! fire that cycle — the quiet member is not at the current timestamp,
//! which is the correct reading of the barrier contract (deliberately
//! pinned by an engine test).
//!
//! Two inputs (`value: F64`, `gate: Bool`, each `Firing::Any` — the enable
//! is level-sensitive: the gate *level* is sampled whenever the cell fires,
//! however long ago it was driven). One `f64` output, no params, stateless.
//! Emits `None` while either leg is cold (warm-up), and on every gate-false
//! cycle (the quiet cycle that IS the semantics).
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
/// Clock-enable forwarder: `value` iff `gate`, else quiet (`None`). A false
/// gate is a quiet cycle, not a held or zero output — downstream freshness
/// does not advance.
pub struct When {
out: [Cell; 1],
}
impl When {
/// Build a `When` node.
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
/// The param-generic recipe for a blueprint primitive: paramless, builds
/// through `When::new`.
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"When",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() },
PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "gate".into() },
],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(When::new()),
)
}
}
impl Default for When {
fn default() -> Self {
Self::new()
}
}
impl Node for When {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let value = ctx.f64_in(0);
let gate = ctx.bool_in(1);
if value.is_empty() || gate.is_empty() {
return None; // cold warm-up (C8) — both legs required
}
if !gate[0] {
return None; // gate low -> QUIET cycle (the semantics, not warm-up)
}
self.out[0] = Cell::from_f64(value[0]);
Some(&self.out)
}
fn label(&self) -> String {
"When".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn gate_inputs() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::Bool, 1),
]
}
#[test]
fn when_forwards_on_true_and_is_quiet_on_false() {
// PROPERTY: gate true -> the value passes through verbatim (bit-equal);
// gate false -> a QUIET cycle (None), never a held or zero output.
let mut node = When::new();
let mut inputs = gate_inputs();
inputs[0].push(Scalar::f64(42.5)).unwrap();
inputs[1].push(Scalar::bool(true)).unwrap();
assert_eq!(
node.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(42.5)].as_slice())
);
inputs[0].push(Scalar::f64(7.0)).unwrap();
inputs[1].push(Scalar::bool(false)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
inputs[0].push(Scalar::f64(-3.25)).unwrap();
inputs[1].push(Scalar::bool(true)).unwrap();
assert_eq!(
node.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(-3.25)].as_slice())
);
}
#[test]
fn when_is_none_while_either_leg_is_cold() {
// Cold warm-up is distinct from the quiet cycle: before both legs have
// seen a value the cell is cold (C8), regardless of the gate.
let mut node = When::new();
let mut inputs = gate_inputs();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); // both cold
inputs[0].push(Scalar::f64(1.0)).unwrap();
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); // gate cold
}
#[test]
fn input_slots_are_named_value_gate() {
let names: Vec<String> =
When::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["value", "gate"]);
}
}