Skip to main content

polydat_nodes/
arithmetic.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Arithmetic function nodes.
5//!
6//! Core integer operations for the Polydat DAG. These are the building blocks
7//! that most workloads compose: hash → mod → add for bounded IDs,
8//! mixed_radix for coordinate decomposition, interleave for combining
9//! independent dimensions.
10
11use polydat::ast::CompiledU64Op;
12
13/// Add a constant to a u64 value (wrapping).
14///
15/// Signature: `add(input: u64, addend: u64) -> (u64)`
16///
17/// Use for offsetting a bounded range: `mod(h, 100)` gives [0,100),
18/// `add(mod(h, 100), 500)` gives [500,600). Also common with timestamps:
19/// `add(base_epoch, offset)`.
20///
21/// JIT level: P3 (single `iadd` instruction).
22// SRD-80 PR B.7 — Phase 3 const-arg arithmetic family
23// migrated to `#[polydat_node]`. The macro derives `Add`,
24// `Mul`, `Div`, `Mod` from snake_case → PascalCase; the
25// `r#mod` raw identifier is stripped to "mod" for the DSL
26// name and PascalCased to `Mod` for the struct.
27//
28// `classify_node` matches by DSL name ("add", "mul", "div",
29// "mod") and reads `jit_constants()` — the macro auto-emits
30// both, so Phase 3 dispatch is preserved verbatim.
31
32#[polydat::polydat_node(category = Arithmetic)]
33fn add(input: u64, addend: Const<u64>) -> u64 {
34    input.wrapping_add(*addend)
35}
36
37#[polydat::polydat_node(category = Arithmetic)]
38fn mul(input: u64, factor: Const<u64>) -> u64 {
39    input.wrapping_mul(*factor)
40}
41
42#[polydat::polydat_node(category = Arithmetic)]
43fn div(input: u64, divisor: Const<u64>) -> u64 {
44    // Greenfield posture: zero-divisor panics at cycle time
45    // (matching the body's `/`). The original `new()` assert
46    // is retired with the migration; if early-fail is needed
47    // again, it lands via a const-constraint attribute later.
48    input / *divisor
49}
50
51#[polydat::polydat_node(category = Arithmetic)]
52fn r#mod(input: u64, modulus: Const<u64>) -> u64 {
53    input % *modulus
54}
55
56/// Modulo of a u64 value by a *wire-fed* divisor.
57///
58/// Signature: `mod_wire(input: u64, divisor: u64) -> (u64)`
59///
60/// The divisor is computed at cycle time from another node — for
61/// example, a control read or a runtime-derived shard count. The
62/// divisor port declares a `NonZeroU64` constraint, so under
63/// `// @pragma: strict_values` the compiler auto-inserts an
64/// `assert_u64_nonzero` between the source and the divisor input
65/// (SRD 15 §"Strict Wire Mode"). Without strict mode, the node
66/// trusts the divisor and a zero value will panic at cycle time —
67/// the canonical "panic at hour 14" hazard, opt-out by design.
68///
69/// Use this when the modulus genuinely varies across cycles. For
70/// the const case, prefer [`Mod`] which is faster (the divisor
71/// is baked into the JIT closure as a constant).
72///
73/// JIT level: P3 (`urem` on two wire slots; `JitOp::U64ModWire`).
74/// Modulo of a u64 by a wire-fed divisor. SRD-80 PR B.14
75/// migration — the `#[constraint(NonZeroU64)]` attribute carries
76/// the strict-wire-mode assertion contract.
77#[polydat::polydat_node(category = Arithmetic)]
78fn mod_wire(input: u64, #[constraint(NonZeroU64)] divisor: u64) -> u64 {
79    input % divisor
80}
81
82/// Division of a u64 by a wire-fed divisor. SRD-80 PR B.14
83/// migration — same NonZeroU64 contract as mod_wire.
84#[polydat::polydat_node(category = Arithmetic)]
85fn div_wire(input: u64, #[constraint(NonZeroU64)] divisor: u64) -> u64 {
86    input / divisor
87}
88
89/// Smallest multiple of `multiple` that is ≥ `value`.
90///
91/// Signature: `ceil_to_multiple(value: u64, multiple: u64) -> (u64)`
92///
93/// Workload-author shorthand for "round this value up to the
94/// next whole multiple of base." Eliminates the
95/// `(v + m - 1) / m * m` / `div_ceil` idiom from bindings.
96/// `multiple == 0` is a soft no-op: returns `value` unchanged
97/// rather than trapping, so a transient zero from a wire-bound
98/// extern doesn't break a binding mid-evaluation.
99///
100/// Use cases:
101///   - cycle counts: `ceil_to_multiple(min_cycles, base)` gives
102///     the smallest whole-pass cycle count meeting a minimum
103///   - alignment: pad an offset up to a chunk boundary
104///   - bucketing: snap a value up to the next bin edge
105///
106/// JIT level: P3 (`JitOp::CeilToMultiple`, inline).
107/// Smallest multiple of `multiple` that is ≥ `value`. SRD-80
108/// PR B.13. `multiple == 0` is a soft no-op (returns value
109/// unchanged) so a transient zero from a wire-bound extern
110/// doesn't break a binding mid-evaluation.
111#[polydat::polydat_node(category = Arithmetic)]
112fn ceil_to_multiple(value: u64, multiple: u64) -> u64 {
113    if multiple == 0 {
114        value
115    } else {
116        value.div_ceil(multiple).saturating_mul(multiple)
117    }
118}
119
120/// Count of multiples of `multiple` needed to cover `value`.
121///
122/// Signature: `multiples_at_least(value: u64, multiple: u64) -> (u64)`
123///
124/// Companion to [`CeilToMultiple`] that returns the *count*
125/// instead of the product — i.e. `ceil(value / multiple)`. The
126/// invariant `multiples_at_least(v, m) * m == ceil_to_multiple(v, m)`
127/// holds whenever `multiple > 0` and the multiplication doesn't
128/// overflow.
129///
130/// Use cases:
131///   - calibration: `multiples_at_least(min_cycles, base)` gives
132///     the pass count so the workload can both apply the
133///     multiplier and report "ran N passes" for diagnostics
134///   - bucket arithmetic: count of fixed-size buckets needed
135///     to hold N items
136///
137/// `multiple == 0` returns `0` — there is no count that covers
138/// a positive value with zero-sized multiples; rather than
139/// trap, the function quietly yields the only honest answer.
140///
141/// JIT level: P3 (single `udiv_ceil`).
142/// Count of multiples needed to cover `value`. SRD-80 PR B.13.
143/// `multiple == 0` returns 0 (no count covers positive value
144/// with zero-sized multiples). JIT P3.
145#[polydat::polydat_node(category = Arithmetic)]
146fn multiples_at_least(value: u64, multiple: u64) -> u64 {
147    if multiple == 0 {
148        0
149    } else {
150        value.div_ceil(multiple)
151    }
152}
153
154/// "Set-or-get" memoizer: returns `current` if non-zero,
155/// otherwise returns `fallback`.
156///
157/// Signature: `set_or_get(current: u64, fallback: u64) -> (u64)`
158///
159/// Functionally `if current == 0 { fallback } else { current }`
160/// — a simple conditional. The name reflects its intended use
161/// alongside SRD-13f cross-scope shared wires:
162///
163/// ```text
164///   shared query_passes := set_or_get(
165///       query_passes,
166///       multiples_at_least(min_cycles, base),
167///   )
168/// ```
169///
170/// First phase to evaluate this: `query_passes` reads 0 (the
171/// unset sentinel), `set_or_get` returns the computed fallback,
172/// the `shared :=` broadcast writes the value to the parent
173/// scope's SharedCell. Every subsequent phase reads the
174/// already-set value and the fallback computation is
175/// effectively a no-op (it still evaluates, but its result is
176/// discarded). The write-back is idempotent — writing the
177/// already-cached value back doesn't change anything.
178///
179/// Concurrency: first-writer-wins is provided by the SharedCell
180/// mutex, not by this node. The node itself is pure — given
181/// the same inputs it returns the same output. Concurrent
182/// phases evaluating it simultaneously will compute the same
183/// fallback and race on the cell write; whichever writes last
184/// wins, but they're writing the same value anyway.
185///
186/// JIT level: P3 through its slot kit (no named JitOp); the body
187/// is the compare+select.
188//
189// SRD-80b Phase E: migrated to `#[polydat_node]`. Struct
190// renamed from `SetOrGetU64` to `SetOrGet` (greenfield
191// posture — no cross-crate callers reference the old name)
192// to match the macro's snake_case → PascalCase derivation.
193#[polydat::polydat_node(category = Arithmetic)]
194fn set_or_get(current: u64, fallback: u64) -> u64 {
195    if current == 0 { fallback } else { current }
196}
197
198/// Clamp an unsigned integer to [min, max].
199///
200/// Signature: `clamp(input: u64, min: u64, max: u64) -> (u64)`
201///
202/// Unlike mod (which wraps), clamp saturates at the boundary. Use when
203/// you want values to pile up at the edges rather than wrap around.
204///
205/// JIT level: P3 (`umax` + `umin`).
206//
207// SRD-80b Phase E: migrated to `#[polydat_node]`. Struct
208// renamed from `ClampU64` to `Clamp` (greenfield posture —
209// no cross-crate callers reference the old name).
210#[polydat::polydat_node(category = Arithmetic)]
211fn clamp(input: u64, min: Const<u64>, max: Const<u64>) -> u64 {
212    input.clamp(*min, *max)
213}
214
215/// Decompose a u64 into mixed-radix digits.
216///
217/// Signature: `mixed_radix(input: u64, radixes...) -> (d0: u64, d1: u64, ...)`
218///
219/// The primary tool for coordinate decomposition. Maps a flat cycle
220/// counter into a multi-dimensional space. Each radix defines the size
221/// of that dimension. A trailing radix of 0 means unbounded (consumes
222/// the remainder).
223///
224/// Example: `(device, reading) := mixed_radix(cycle, 10000, 0)` gives
225/// 10,000 devices with unbounded readings per device.
226///
227/// Traversal is nested-loop, innermost first: d0 increments every cycle,
228/// d1 increments every `radix[0]` cycles, etc.
229///
230/// JIT level: P3 (unrolled urem/udiv chain).
231//
232// Migrated to `#[polydat_node]` via the `Const<Vec<u64>>` +
233// `DynamicOutputs<T>` shape; the output port count comes from
234// `radixes.len()` at construction. The `compiled_u64` /
235// `jit_constants` overrides feed `JitOp::MixedRadixConst`.
236fn mixed_radix_jit(node: &MixedRadix) -> CompiledU64Op {
237    let radixes = node.radixes.clone();
238    Box::new(move |inputs, outputs| {
239        let mut remainder = inputs[0];
240        for (i, &radix) in radixes.iter().enumerate() {
241            if radix == 0 {
242                outputs[i] = remainder;
243                remainder = 0;
244            } else {
245                outputs[i] = remainder % radix;
246                remainder /= radix;
247            }
248        }
249    })
250}
251
252fn mixed_radix_jit_constants(node: &MixedRadix) -> Vec<u64> {
253    node.radixes.clone()
254}
255
256/// Decompose `value` into mixed-radix digits using the given
257/// `radixes`. The output is a vector of N digits where N =
258/// `radixes.len()`. A radix of 0 in the trailing position
259/// captures the remainder verbatim.
260#[polydat::polydat_node(
261    category = Arithmetic,
262    compiled_u64 = mixed_radix_jit,
263    jit_constants = mixed_radix_jit_constants,
264)]
265fn mixed_radix(
266    input: u64,
267    radixes: polydat::derive_support::Const<Vec<u64>>,
268) -> polydat::derive_support::DynamicOutputs<u64> {
269    let mut remainder = input;
270    let mut result = Vec::with_capacity(radixes.len());
271    for &radix in radixes.iter() {
272        if radix == 0 {
273            result.push(remainder);
274            remainder = 0;
275        } else {
276            result.push(remainder % radix);
277            remainder /= radix;
278        }
279    }
280    polydat::derive_support::DynamicOutputs(result)
281}
282
283/// Sum N u64 inputs (wrapping). Variadic: accepts 0..N wire inputs.
284///
285/// Signature: `sum(in_0: u64, ..., in_N: u64) -> (u64)`
286///
287/// Group theory: identity element is 0 (additive identity).
288/// `sum()` = 0, `sum(a)` = a, `sum(a, b, c)` = a + b + c.
289///
290/// Use for combining multiple values into a single aggregate.
291///
292/// JIT level: P3 (unrolled chain, `JitOp::VariadicSum`; likewise
293/// product/min/max).
294// SRD-80 PR B.9 — variadic N-ary u64 reductions migrated to
295// `#[polydat_node]`. Macro generates Sum/Product/Min/Max
296// structs with `new(n_wires)` ctors and auto-emits Phase 2
297// closures that pass the JIT `&[u64]` buffer directly to the
298// body. AllCommutative declared via attribute.
299
300#[polydat::polydat_node(category = Variadic, identity = 0u64, commutativity = AllCommutative)]
301fn sum(values: &[u64]) -> u64 {
302    values.iter().fold(0u64, |a, b| a.wrapping_add(*b))
303}
304
305#[polydat::polydat_node(category = Variadic, identity = 1u64, commutativity = AllCommutative)]
306fn product(values: &[u64]) -> u64 {
307    values.iter().fold(1u64, |a, b| a.wrapping_mul(*b))
308}
309
310#[polydat::polydat_node(category = Variadic, identity = u64::MAX, commutativity = AllCommutative)]
311fn min(values: &[u64]) -> u64 {
312    values.iter().copied().fold(u64::MAX, std::cmp::min)
313}
314
315#[polydat::polydat_node(category = Variadic, identity = 0u64, commutativity = AllCommutative)]
316fn max(values: &[u64]) -> u64 {
317    values.iter().copied().fold(0u64, std::cmp::max)
318}
319
320/// Interleave the bits of two u64 values into one (Morton code).
321///
322/// Signature: `interleave(a: u64, b: u64) -> (u64)`
323///
324/// Bit 0 of a → bit 0 of output, bit 0 of b → bit 1, bit 1 of a → bit 2,
325/// etc. This preserves locality from both dimensions — essential for
326/// combining two independent coordinates into a single hash input:
327/// `hash(interleave(device_id, reading_idx))` produces a value that
328/// changes when either dimension changes, with spatial correlation.
329///
330/// JIT level: P3 (extern call).
331//
332// SRD-80b Phase E: migrated to `#[polydat_node]`. Struct
333// name `Interleave` matches snake_case → PascalCase of `interleave`.
334#[polydat::polydat_node(category = Arithmetic)]
335fn interleave(a: u64, b: u64) -> u64 {
336    let mut result: u64 = 0;
337    for i in 0..32 {
338        result |= ((a >> i) & 1) << (2 * i);
339        result |= ((b >> i) & 1) << (2 * i + 1);
340    }
341    result
342}
343
344// ---------------------------------------------------------------------------
345// Signature declarations for the DSL registry
346// ---------------------------------------------------------------------------
347
348use polydat::dsl::registry::FuncSig;
349
350/// Signatures for arithmetic and variadic nodes.
351///
352/// SRD-80b Phase E: every arithmetic node routes through the
353/// proc-macro NodeRegistration — `mixed_radix` included, via the
354/// `Const<Vec<C>>` + `DynamicOutputs<T>` shape. The hand-written
355/// `FuncSig`/`build_node` pair that predated that migration was
356/// removed: it duplicated the macro's registration under the same
357/// name, leaving `lookup("mixed_radix")`'s answer to inventory
358/// link order. Only `validate_node` stays hand-written (its
359/// positional rule can't ride on a per-param constraint).
360pub fn signatures() -> &'static [FuncSig] {
361    &[]
362}
363
364/// No hand-built arithmetic nodes remain — construction goes
365/// through the proc-macro registration (see [`signatures`]).
366pub(crate) fn build_node(
367    name: &str,
368    _wires: &[polydat::compile::assembly::WireRef],
369    _wire_types: &[polydat::ast::PortType],
370    consts: &[polydat::dsl::factory::ConstArg],
371) -> Option<Result<Box<dyn polydat::ast::PolydatNode>, String>> {
372    let _ = (name, consts);
373    None
374}
375
376/// Assembly-time constant validation. See SRD 15 §"Const Constraint Metadata".
377///
378/// The variadic positional rule for `mixed_radix` — non-terminal
379/// radixes must each be non-zero, but the last one is allowed to
380/// be `0` as the "everything left" sentinel — can't ride on a
381/// per-param `ParamSpec.constraint`, so it stays here as a
382/// hand-written validator.
383pub(crate) fn validate_node(
384    name: &str,
385    consts: &[polydat::dsl::factory::ConstArg],
386) -> Result<(), String> {
387    match name {
388        "mixed_radix" => {
389            for (i, c) in consts
390                .iter()
391                .enumerate()
392                .take(consts.len().saturating_sub(1))
393            {
394                if c.as_u64() == 0 {
395                    return Err(format!("radix {i} must be non-zero"));
396                }
397            }
398            Ok(())
399        }
400        _ => Ok(()),
401    }
402}
403
404polydat::register_nodes!(signatures, build_node, validate_node);
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use polydat::ast::{PolydatNode, Value};
410
411    #[test]
412    fn add_wrapping() {
413        let node = Add::new(10);
414        let mut out = [Value::None];
415        node.eval(&[Value::U64(5)], &mut out);
416        assert_eq!(out[0].as_u64(), 15);
417    }
418
419    #[test]
420    fn mod_basic() {
421        let node = Mod::new(100);
422        let mut out = [Value::None];
423        node.eval(&[Value::U64(542)], &mut out);
424        assert_eq!(out[0].as_u64(), 42);
425    }
426
427    #[test]
428    fn mixed_radix_decompose() {
429        let node = MixedRadix::new(vec![100, 1000, 0]);
430        let mut out = [Value::None, Value::None, Value::None];
431        // 4201337 → (37, 13, 42)
432        // 4201337 % 100 = 37
433        // 4201337 / 100 = 42013; 42013 % 1000 = 13
434        // 42013 / 1000 = 42
435        node.eval(&[Value::U64(4_201_337)], &mut out);
436        assert_eq!(out[0].as_u64(), 37);
437        assert_eq!(out[1].as_u64(), 13);
438        assert_eq!(out[2].as_u64(), 42);
439    }
440
441    #[test]
442    fn mixed_radix_cartesian() {
443        // 100 tenants × 1000 devices × unbounded readings
444        let node = MixedRadix::new(vec![100, 1000, 0]);
445        let mut out = [Value::None, Value::None, Value::None];
446
447        // cycle 0 → tenant 0, device 0, reading 0
448        node.eval(&[Value::U64(0)], &mut out);
449        assert_eq!(out[0].as_u64(), 0);
450        assert_eq!(out[1].as_u64(), 0);
451        assert_eq!(out[2].as_u64(), 0);
452
453        // cycle 100_000 → tenant 0, device 0, reading 1
454        node.eval(&[Value::U64(100_000)], &mut out);
455        assert_eq!(out[0].as_u64(), 0);
456        assert_eq!(out[1].as_u64(), 0);
457        assert_eq!(out[2].as_u64(), 1);
458    }
459
460    #[test]
461    fn interleave_basic() {
462        let node = Interleave::new();
463        let mut out = [Value::None];
464        node.eval(&[Value::U64(0b101), Value::U64(0b010)], &mut out);
465        // a=101, b=010
466        // bit 0: a0=1, b0=0 → positions 0,1 = 01
467        // bit 1: a1=0, b1=1 → positions 2,3 = 10
468        // bit 2: a2=1, b2=0 → positions 4,5 = 01
469        // result = 0b01_10_01 = 0b011001 = 25
470        assert_eq!(out[0].as_u64(), 0b01_10_01);
471    }
472
473    #[test]
474    fn div_basic() {
475        let node = Div::new(100);
476        let mut out = [Value::None];
477        node.eval(&[Value::U64(4_201_337)], &mut out);
478        assert_eq!(out[0].as_u64(), 42013);
479    }
480
481    // --- Variadic N-ary tests ---
482
483    #[test]
484    fn sum_variadic() {
485        // 0 inputs → identity = 0
486        let node = Sum::new(0);
487        let mut out = [Value::None];
488        node.eval(&[], &mut out);
489        assert_eq!(out[0].as_u64(), 0);
490
491        // 1 input → passthrough
492        let node = Sum::new(1);
493        node.eval(&[Value::U64(42)], &mut out);
494        assert_eq!(out[0].as_u64(), 42);
495
496        // 3 inputs → fold
497        let node = Sum::new(3);
498        node.eval(&[Value::U64(10), Value::U64(20), Value::U64(30)], &mut out);
499        assert_eq!(out[0].as_u64(), 60);
500    }
501
502    #[test]
503    fn product_variadic() {
504        // 0 inputs → identity = 1
505        let node = Product::new(0);
506        let mut out = [Value::None];
507        node.eval(&[], &mut out);
508        assert_eq!(out[0].as_u64(), 1);
509
510        // 1 input → passthrough
511        let node = Product::new(1);
512        node.eval(&[Value::U64(7)], &mut out);
513        assert_eq!(out[0].as_u64(), 7);
514
515        // 3 inputs → fold
516        let node = Product::new(3);
517        node.eval(&[Value::U64(2), Value::U64(3), Value::U64(7)], &mut out);
518        assert_eq!(out[0].as_u64(), 42);
519    }
520
521    #[test]
522    fn min_variadic() {
523        // 0 inputs → identity = u64::MAX
524        let node = Min::new(0);
525        let mut out = [Value::None];
526        node.eval(&[], &mut out);
527        assert_eq!(out[0].as_u64(), u64::MAX);
528
529        // 3 inputs → min
530        let node = Min::new(3);
531        node.eval(&[Value::U64(50), Value::U64(10), Value::U64(30)], &mut out);
532        assert_eq!(out[0].as_u64(), 10);
533    }
534
535    #[test]
536    fn max_variadic() {
537        // 0 inputs → identity = 0
538        let node = Max::new(0);
539        let mut out = [Value::None];
540        node.eval(&[], &mut out);
541        assert_eq!(out[0].as_u64(), 0);
542
543        // 3 inputs → max
544        let node = Max::new(3);
545        node.eval(&[Value::U64(50), Value::U64(10), Value::U64(30)], &mut out);
546        assert_eq!(out[0].as_u64(), 50);
547    }
548
549    // --- Slot model consistency ---
550
551    /// Verify that `meta().jit_constants_from_slots()` matches
552    /// `jit_constants()` for all arithmetic nodes with constants.
553    #[test]
554    fn slot_constants_match_jit_constants() {
555        use polydat::ast::PolydatNode;
556
557        let nodes: Vec<Box<dyn PolydatNode>> = vec![
558            Box::new(Add::new(42)),
559            Box::new(Mul::new(7)),
560            Box::new(Div::new(100)),
561            Box::new(Mod::new(256)),
562            Box::new(Clamp::new(10, 90)),
563            Box::new(MixedRadix::new(vec![100, 1000, 0])),
564        ];
565
566        for node in &nodes {
567            let from_trait = node.jit_constants();
568            let from_slots = node.meta().jit_constants_from_slots();
569            assert_eq!(
570                from_trait,
571                from_slots,
572                "constant mismatch for node '{}': trait={from_trait:?}, slots={from_slots:?}",
573                node.meta().name,
574            );
575        }
576    }
577
578    // ── ceil_to_multiple ──────────────────────────────────
579
580    fn run_binary(node: &dyn PolydatNode, a: u64, b: u64) -> u64 {
581        let mut out = [Value::None];
582        node.eval(&[Value::U64(a), Value::U64(b)], &mut out);
583        out[0].as_u64()
584    }
585
586    #[test]
587    fn ceil_to_multiple_returns_value_when_already_a_multiple() {
588        let n = CeilToMultiple::default();
589        assert_eq!(run_binary(&n, 800, 100), 800);
590    }
591
592    #[test]
593    fn ceil_to_multiple_rounds_up_to_next_boundary() {
594        let n = CeilToMultiple::default();
595        assert_eq!(run_binary(&n, 801, 100), 900);
596    }
597
598    #[test]
599    fn ceil_to_multiple_zero_value_is_zero() {
600        let n = CeilToMultiple::default();
601        assert_eq!(run_binary(&n, 0, 100), 0);
602    }
603
604    #[test]
605    fn ceil_to_multiple_below_one_multiple_rounds_to_multiple() {
606        let n = CeilToMultiple::default();
607        assert_eq!(run_binary(&n, 50, 100), 100);
608        assert_eq!(run_binary(&n, 1, 100), 100);
609    }
610
611    #[test]
612    fn ceil_to_multiple_zero_multiple_is_soft_no_op() {
613        let n = CeilToMultiple::default();
614        assert_eq!(
615            run_binary(&n, 42, 0),
616            42,
617            "multiple=0 must not trap; passes value through"
618        );
619    }
620
621    // ── multiples_at_least ────────────────────────────────
622
623    #[test]
624    fn multiples_at_least_exact_division() {
625        let n = MultiplesAtLeast::default();
626        assert_eq!(run_binary(&n, 800, 100), 8);
627    }
628
629    #[test]
630    fn multiples_at_least_rounds_up_partial() {
631        let n = MultiplesAtLeast::default();
632        assert_eq!(run_binary(&n, 801, 100), 9);
633        assert_eq!(run_binary(&n, 1, 100), 1);
634    }
635
636    #[test]
637    fn multiples_at_least_zero_value_is_zero() {
638        let n = MultiplesAtLeast::default();
639        assert_eq!(run_binary(&n, 0, 100), 0);
640    }
641
642    #[test]
643    fn multiples_at_least_zero_multiple_is_zero() {
644        let n = MultiplesAtLeast::default();
645        assert_eq!(run_binary(&n, 42, 0), 0);
646    }
647
648    // ── set_or_get ────────────────────────────────────────
649
650    #[test]
651    fn set_or_get_returns_current_when_non_zero() {
652        let n = SetOrGet::default();
653        assert_eq!(run_binary(&n, 7, 99), 7);
654        assert_eq!(run_binary(&n, u64::MAX, 99), u64::MAX);
655    }
656
657    #[test]
658    fn set_or_get_returns_fallback_when_current_is_zero() {
659        let n = SetOrGet::default();
660        assert_eq!(run_binary(&n, 0, 99), 99);
661    }
662
663    #[test]
664    fn set_or_get_zero_fallback_is_zero() {
665        // If both inputs are zero, output is zero — soft default
666        // for the degenerate case (caller's choice not to seed
667        // a meaningful fallback).
668        let n = SetOrGet::default();
669        assert_eq!(run_binary(&n, 0, 0), 0);
670    }
671
672    #[test]
673    fn set_or_get_idempotent_on_already_set() {
674        // The "every subsequent phase" path: current is the
675        // cached value, fallback is the (still-evaluated but
676        // discarded) recomputation. Returning current preserves
677        // the cached state across phases.
678        let n = SetOrGet::default();
679        for v in [1u64, 42, 1000, u64::MAX] {
680            // Even if the fallback differs each call (e.g., a
681            // recomputation that picked a slightly different
682            // value due to a different base), the cached value
683            // wins.
684            assert_eq!(run_binary(&n, v, 999), v);
685        }
686    }
687
688    #[test]
689    fn ceil_to_multiple_and_count_satisfy_invariant() {
690        // Documented invariant: ceil_to_multiple(v, m) == multiples_at_least(v, m) * m
691        // whenever m > 0 and the multiplication doesn't overflow.
692        let ceil = CeilToMultiple::default();
693        let count = MultiplesAtLeast::default();
694        for (v, m) in [
695            (0u64, 100),
696            (1, 100),
697            (50, 100),
698            (100, 100),
699            (101, 100),
700            (10000, 7),
701            (10000, 64),
702            (12345, 256),
703        ] {
704            let c_val = run_binary(&ceil, v, m);
705            let n_val = run_binary(&count, v, m);
706            assert_eq!(
707                c_val,
708                n_val * m,
709                "invariant violated for (v={v}, m={m}): ceil={c_val}, count={n_val}"
710            );
711        }
712    }
713
714    /// Verify wire_inputs() returns correct count for all arithmetic nodes.
715    #[test]
716    fn slot_wire_inputs_match_inputs() {
717        use polydat::ast::PolydatNode;
718
719        let nodes: Vec<Box<dyn PolydatNode>> = vec![
720            Box::new(Add::new(0)),
721            Box::new(Mod::new(1)),
722            Box::new(Sum::new(3)),
723            Box::new(Product::new(2)),
724            Box::new(Interleave::new()),
725            Box::new(MixedRadix::new(vec![10, 20])),
726            Box::new(CeilToMultiple::default()),
727            Box::new(MultiplesAtLeast::default()),
728            Box::new(SetOrGet::default()),
729        ];
730
731        for node in &nodes {
732            let old_count = node.meta().wire_inputs().len();
733            let new_count = node.meta().wire_inputs().len();
734            assert_eq!(
735                old_count,
736                new_count,
737                "wire input count mismatch for '{}': inputs={old_count}, wire_inputs()={new_count}",
738                node.meta().name,
739            );
740        }
741    }
742}