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