Skip to main content

polydat_nodes/
probability.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Probability modeling nodes.
5//!
6//! Deterministic building blocks for modeling probabilistic behavior in
7//! Polydat graphs. All hash-based nodes are pure functions — "randomness"
8//! comes from hashing the input, not from a stateful RNG. The same input
9//! always produces the same output.
10//!
11//! Primary use cases: model adapter result kernels (simulated latency,
12//! error injection, bimodal distributions), but usable anywhere in a
13//! Polydat pipeline.
14//!
15//! `DefaultOr` rides the rule that PolyWire (`Value`-typed) args
16//! auto-emit `accepts_none_inputs() -> true`, so the body's coalesce
17//! logic sees `Value::None` instead of the kernel's Rule 1
18//! short-circuit.
19//!
20//! `OneOf` rides the `Const<Vec<String>>` workload-list shape; its
21//! non-empty-values check fires at eval time rather than at
22//! construction (the macro-emitted `new()` is infallible).
23//! `OneOfWeighted` rides the `#[poly_const]` setup pattern, parsing
24//! the spec once into a cached `WeightedTable`.
25
26use polydat::ast::Value;
27#[cfg(test)]
28use polydat::ast::{PolydatNode, PortType};
29use polydat::derive_support::PolydatSetup;
30
31/// Convert a u64 hash to a value in the unit interval [0.0, 1.0).
32///
33/// Uses the same method as `UnitInterval`: divide by (u64::MAX + 1) as f64.
34#[inline]
35fn hash_to_unit(v: u64) -> f64 {
36    (v as f64) / ((u64::MAX as f64) + 1.0)
37}
38
39// ---------------------------------------------------------------------------
40// FairCoin: 50/50 binary outcome from a hashed input.
41// ---------------------------------------------------------------------------
42
43/// Fair coin flip: returns 0 or 1 with 50/50 probability.
44///
45/// Signature: `fair_coin(input: u64) -> u64`
46///
47/// Equivalent to `mod(hash(input), 2)`. Use when you need a simple
48/// binary decision with equal weight — for example, choosing between
49/// two data centers or two code paths during workload modeling.
50///
51/// Deterministic: the same input always produces the same output.
52///
53/// JIT level: P2 — the macro auto-emits `compiled_u64` from the
54/// scalar `u64 -> u64` body.
55#[polydat::polydat_node(category = Probability)]
56fn fair_coin(input: u64) -> u64 {
57    let h = crate::hash::splitmix64_u64(input);
58    h & 1
59}
60
61// ---------------------------------------------------------------------------
62// UnfairCoin: biased binary outcome from a hashed input.
63// ---------------------------------------------------------------------------
64
65/// Unfair coin flip: returns 1 with probability `p`, else 0.
66///
67/// Signature: `unfair_coin(input: u64, p: f64) -> u64`
68///
69/// The `p` parameter is an init-time constant in [0.0, 1.0]. The input
70/// is hashed to a unit interval and compared against `p`: if the hashed
71/// value is less than `p`, the output is 1; otherwise 0.
72///
73/// Use for modeling probabilistic events: error injection rates,
74/// cache miss ratios, slow-path probability. Compose with `select()`
75/// to branch on the outcome:
76///
77/// ```polydat
78/// is_slow := unfair_coin(cycle, 0.1)
79/// latency := select(is_slow, slow_latency, fast_latency)
80/// ```
81///
82/// Unlike `n_of`, which guarantees exact counts over a window,
83/// `unfair_coin` treats each input independently — over large
84/// sample sizes the fraction converges to `p`, but any given
85/// window may vary.
86///
87/// JIT level: P2 — macro-emitted compiled closure captures `p`.
88#[polydat::polydat_node(category = Probability)]
89fn unfair_coin(input: u64, p: Const<f64>) -> u64 {
90    if !(0.0..=1.0).contains(&*p) {
91        panic!(
92            "unfair_coin probability p must be in [0.0, 1.0], got {}",
93            *p
94        );
95    }
96    let h = crate::hash::splitmix64_u64(input);
97    let unit = hash_to_unit(h);
98    if unit < *p { 1 } else { 0 }
99}
100
101// ---------------------------------------------------------------------------
102// Select: 3-way conditional selection between two u64 values.
103// ---------------------------------------------------------------------------
104
105/// Binary conditional selection: returns `if_true` when `cond != 0`, else `if_false`.
106///
107/// Signature: `select(cond: u64, if_true: u64, if_false: u64) -> u64`
108///
109/// Three wire inputs. All inputs are always evaluated (no short-circuit)
110/// because Polydat is a DAG, not a control flow graph. Use to pick between
111/// two pre-computed alternatives based on a boolean signal:
112///
113/// ```polydat
114/// latency := select(is_slow, slow_latency, fast_latency)
115/// ```
116///
117/// Combine with `fair_coin`, `unfair_coin`, or `n_of` for the condition,
118/// and any pair of compatible values for the branches.
119///
120/// JIT level: P3 — macro-emitted compiled closure is a branchless
121/// conditional move when the LLVM optimiser folds the if.
122#[polydat::polydat_node(category = Probability)]
123fn select(cond: u64, if_true: u64, if_false: u64) -> u64 {
124    if cond != 0 { if_true } else { if_false }
125}
126
127// ---------------------------------------------------------------------------
128// Chance: like UnfairCoin but the output is an f64-bit-encoded 0.0/1.0.
129// ---------------------------------------------------------------------------
130
131/// Probability chance returning f64-bits in u64 form: returns
132/// `1.0_f64.to_bits()` with probability `p`, else `0.0_f64.to_bits()`.
133///
134/// Signature: `chance(input: u64, p: f64) -> u64`
135///
136/// Like `unfair_coin` but the u64 output carries the bit-pattern of
137/// an f64 (0.0 or 1.0). Use when the result feeds directly into f64
138/// arithmetic without an explicit type conversion step:
139///
140/// ```polydat
141/// surcharge := mul(chance(cycle, 0.3), 0.05)
142/// ```
143///
144/// The `p` parameter is an init-time constant in [0.0, 1.0].
145///
146/// JIT level: P2 — macro-emitted compiled closure captures `p`.
147#[polydat::polydat_node(category = Probability)]
148fn chance(input: u64, p: Const<f64>) -> u64 {
149    if !(0.0..=1.0).contains(&*p) {
150        panic!("chance probability p must be in [0.0, 1.0], got {}", *p);
151    }
152    let h = crate::hash::splitmix64_u64(input);
153    let unit = hash_to_unit(h);
154    let result: f64 = if unit < *p { 1.0 } else { 0.0 };
155    result.to_bits()
156}
157
158// ---------------------------------------------------------------------------
159// NofM: deterministic exact-count selection (renamed operator surface
160// stays `n_of`; struct emitted as `NOf`).
161// ---------------------------------------------------------------------------
162
163/// N-of-M deterministic fractional selection.
164///
165/// Signature: `n_of(input: u64, n: u64, m: u64) -> u64`
166///
167/// Returns 1 for exactly `n` out of every `m` consecutive inputs, 0
168/// otherwise. Which specific inputs are selected within each window
169/// is determined by hashing, so the pattern is not simply "first n".
170///
171/// This differs from `unfair_coin(input, n/m)`: unfair_coin is
172/// probabilistic (each input independently has probability n/m),
173/// while `n_of` guarantees exact counts over each window of m inputs.
174///
175/// Use for precise fraction control: exactly 3 out of every 10 cycles
176/// are "special", exactly 1 out of every 100 is an error, etc.
177///
178/// ```polydat
179/// is_special := n_of(cycle, 3, 10)
180/// ```
181///
182/// Both `n` and `m` are init-time constant parameters. Panics if
183/// `m == 0` or `n > m` (the relational check can't ride on a
184/// per-param `ParamSpec` constraint, so the assertion lives in the
185/// body and fires on the first eval).
186///
187/// JIT level: P2 — macro-emitted compiled closure captures n and m.
188#[polydat::polydat_node(category = Probability)]
189fn n_of(input: u64, n: Const<u64>, m: Const<u64>) -> u64 {
190    if *m == 0 {
191        panic!("n_of: m must be > 0");
192    }
193    if *n > *m {
194        panic!("n_of: n ({}) must be <= m ({})", *n, *m);
195    }
196    n_of_m_eval(input, *n, *m)
197}
198
199pub use polydat::numeric::n_of_m::n_of_m_eval;
200
201// ---------------------------------------------------------------------------
202// OneOf: uniform selection from a Const<Vec<String>> workload-list.
203//
204// The macro's VariadicConsts arity consumes every trailing string
205// literal in the call site as the `values` vector. The non-empty
206// check lives in the body and fires on first eval.
207// ---------------------------------------------------------------------------
208
209/// Uniform selection from N constant string values.
210///
211/// Signature: `one_of(input: u64, values...) -> String`
212///
213/// Takes one wire input (u64) and N constant string values captured at
214/// construction time. Hashes the input, takes mod N, and returns the
215/// corresponding value. All values have equal probability.
216///
217/// Use for simple uniform selection when all outcomes are equally likely —
218/// data center names, partition keys, categorical labels.
219///
220/// ```polydat
221/// color := one_of(cycle, "red", "green", "blue")
222/// ```
223///
224/// JIT level: P1 only — `Const<Vec<C>>` is JIT-ineligible by design
225/// (per derive_support), so no compiled_u64 path.
226#[polydat::polydat_node(category = Probability)]
227fn one_of(input: u64, values: Const<Vec<String>>) -> String {
228    assert!(!values.is_empty(), "one_of: values must be non-empty");
229    let h = crate::hash::splitmix64_u64(input);
230    let idx = (h % values.len() as u64) as usize;
231    values[idx].clone()
232}
233
234// ---------------------------------------------------------------------------
235// OneOfWeighted: weighted selection driven by a parsed const spec.
236//
237// The spec is parsed once at construction (`#[poly_const]` setup)
238// into a `WeightedTable`, then a borrow of the cached struct is
239// handed to the eval body. Bad specs panic inside
240// `WeightedTable::parse`, which the macro invokes from
241// `OneOfWeighted::new`, so a malformed spec fails at construction.
242// ---------------------------------------------------------------------------
243
244/// Pre-parsed value table for `one_of_weighted`. The cumulative
245/// vector is normalised so the last entry is exactly 1.0, letting
246/// the eval body locate the matching bucket with a single binary
247/// search.
248pub struct WeightedTable {
249    /// Output values in declaration order.
250    pub values: Vec<String>,
251    /// Cumulative weights, normalised to [0.0, 1.0]. The last
252    /// entry is always 1.0.
253    pub cumulative: Vec<f64>,
254}
255
256impl PolydatSetup for WeightedTable {}
257
258impl WeightedTable {
259    /// Single-call setup. The `#[polydat_node]` macro invokes
260    /// this exactly once in the generated `OneOfWeighted::new()`.
261    /// Panics on a malformed spec.
262    pub fn parse(spec: &str) -> Self {
263        let mut values = Vec::new();
264        let mut weights = Vec::new();
265        for elem in spec.split([';', ',']) {
266            let elem = elem.trim();
267            if elem.is_empty() {
268                continue;
269            }
270            let parts: Vec<&str> = elem.splitn(2, ':').collect();
271            assert_eq!(
272                parts.len(),
273                2,
274                "one_of_weighted: expected 'value:weight', got '{elem}'"
275            );
276            values.push(parts[0].to_string());
277            let w: f64 = parts[1].parse().expect("one_of_weighted: invalid weight");
278            assert!(w > 0.0, "one_of_weighted: weight must be positive, got {w}");
279            weights.push(w);
280        }
281        assert!(
282            !values.is_empty(),
283            "one_of_weighted: spec must be non-empty"
284        );
285
286        let total: f64 = weights.iter().sum();
287        assert!(total > 0.0, "one_of_weighted: total weight must be > 0");
288
289        let mut cumulative = Vec::with_capacity(weights.len());
290        let mut running = 0.0;
291        for w in &weights {
292            running += w / total;
293            cumulative.push(running);
294        }
295        // Clamp the last entry to exactly 1.0 to avoid floating-point edge cases.
296        if let Some(last) = cumulative.last_mut() {
297            *last = 1.0;
298        }
299
300        Self { values, cumulative }
301    }
302}
303
304/// Weighted selection from a spec string, returning a String.
305///
306/// Signature: `one_of_weighted(input: u64, spec: &str) -> String`
307///
308/// The `spec` parameter is an init-time constant string with the format
309/// `"value:weight,value:weight,..."`. Weights are positive numbers that
310/// do not need to sum to any particular total — they are normalised
311/// internally. Example: `"red:60,blue:30,green:10"`.
312///
313/// Implementation: at init time, weights are normalised to cumulative
314/// proportions. At eval time, the input is hashed to the unit interval
315/// and a binary search locates the matching bucket.
316///
317/// Use when outcomes have unequal probability — error codes with
318/// realistic frequency distributions, region selection weighted by
319/// traffic share, etc.
320///
321/// ```polydat
322/// status := one_of_weighted(cycle, "200:80,404:10,500:5,503:5")
323/// ```
324///
325/// JIT level: P1 only (String output prevents compiled_u64).
326#[polydat::polydat_node(category = Probability)]
327fn one_of_weighted(
328    input: u64,
329    spec: Const<&str>,
330    #[poly_const(WeightedTable::parse, from = spec)] table: &WeightedTable,
331) -> String {
332    let _ = spec;
333    let h = crate::hash::splitmix64_u64(input);
334    let unit = hash_to_unit(h);
335    // Binary search: find the first cumulative entry >= unit.
336    let idx = match table
337        .cumulative
338        .binary_search_by(|c| c.partial_cmp(&unit).unwrap())
339    {
340        Ok(i) => i,
341        Err(i) => i,
342    };
343    // Clamp to valid range (should not be needed, but defensive).
344    let idx = idx.min(table.values.len() - 1);
345    table.values[idx].clone()
346}
347
348// ---------------------------------------------------------------------------
349// Blend: weighted linear blend of two f64 values carried as u64-bits.
350// ---------------------------------------------------------------------------
351
352/// Weighted linear blend of two f64 values.
353///
354/// Signature: `blend(a: u64, b: u64, mix: f64) -> u64`
355///
356/// Computes `a * (1.0 - mix) + b * mix` where `mix` is an init-time
357/// constant in [0.0, 1.0]. Inputs `a` and `b` are f64 values carried
358/// in the u64 buffer via `to_bits` / `from_bits`.
359///
360/// Use when you need to crossfade between two signal sources —
361/// blending a fast-path latency model with a slow-path model,
362/// interpolating between two noise generators, etc.
363///
364/// ```polydat
365/// blended := blend(fast_latency, slow_latency, 0.3)
366/// ```
367///
368/// JIT level: P2 — macro-emitted compiled closure captures `mix`.
369#[polydat::polydat_node(category = Probability)]
370fn blend(a: u64, b: u64, mix: Const<f64>) -> u64 {
371    if !(0.0..=1.0).contains(&*mix) {
372        panic!("blend: mix must be in [0.0, 1.0], got {}", *mix);
373    }
374    let a_f = f64::from_bits(a);
375    let b_f = f64::from_bits(b);
376    let result = a_f * (1.0 - *mix) + b_f * *mix;
377    result.to_bits()
378}
379
380// ---------------------------------------------------------------------------
381// DefaultOr: None-aware coalesce. Migrated to `#[polydat_node]` via
382// PolyWire (`Value`-typed) args — the macro auto-emits
383// `accepts_none_inputs() -> true` because every PolyWire arg is
384// inherently None-tolerant (None is one of the polymorphic variants).
385// The return-type `Value` rides the `SameAsInput` output-type
386// dispatch keyed off the first PolyWire arg (`value`), preserving
387// the variant-preserving semantics: U64 in → U64 out, Str in → Str
388// out, etc.
389// ---------------------------------------------------------------------------
390
391/// Returns the first input if it is not `None`, otherwise the second.
392///
393/// Signature: `default_or(value: Value, fallback: Value) -> Value`
394///
395/// This is the Polydat equivalent of SQL's `COALESCE` or Rust's
396/// `Option::unwrap_or`. The node is polymorphic over the `Value`
397/// variant — it passes whatever variant comes in (U64 / F64 / Bool /
398/// Str / etc.) through unchanged. The output port type tracks the
399/// first PolyWire arg's runtime port type via
400/// `OutputType::SameAsInput`.
401#[polydat::polydat_node(category = Probability)]
402fn default_or(value: Value, fallback: Value) -> Value {
403    if matches!(value, Value::None) {
404        fallback
405    } else {
406        value
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    // --- FairCoin ---
415
416    #[test]
417    fn fair_coin_returns_0_or_1() {
418        let node = FairCoin::new();
419        let mut out = [Value::None];
420        for i in 0..100u64 {
421            node.eval(&[Value::U64(i)], &mut out);
422            let v = out[0].as_u64();
423            assert!(
424                v == 0 || v == 1,
425                "fair_coin({i}) returned {v}, expected 0 or 1"
426            );
427        }
428    }
429
430    #[test]
431    fn fair_coin_deterministic() {
432        let node = FairCoin::new();
433        let mut out1 = [Value::None];
434        let mut out2 = [Value::None];
435        node.eval(&[Value::U64(42)], &mut out1);
436        node.eval(&[Value::U64(42)], &mut out2);
437        assert_eq!(out1[0].as_u64(), out2[0].as_u64());
438    }
439
440    #[test]
441    fn fair_coin_roughly_balanced() {
442        let node = FairCoin::new();
443        let mut out = [Value::None];
444        let mut ones = 0u64;
445        let n = 10_000u64;
446        for i in 0..n {
447            node.eval(&[Value::U64(i)], &mut out);
448            ones += out[0].as_u64();
449        }
450        // Expect roughly 50%, allow 45-55% range
451        let ratio = ones as f64 / n as f64;
452        assert!(
453            (0.45..=0.55).contains(&ratio),
454            "fair_coin ratio {ratio} outside expected 0.45-0.55"
455        );
456    }
457
458    #[test]
459    fn fair_coin_compiled_u64() {
460        let node = FairCoin::new();
461        let compiled = node.compiled_u64().expect("should have compiled_u64");
462        let inputs = [42u64];
463        let mut outputs = [0u64];
464        compiled(&inputs, &mut outputs);
465        assert!(outputs[0] == 0 || outputs[0] == 1);
466
467        // Should match eval
468        let mut eval_out = [Value::None];
469        node.eval(&[Value::U64(42)], &mut eval_out);
470        assert_eq!(outputs[0], eval_out[0].as_u64());
471    }
472
473    // --- UnfairCoin ---
474
475    #[test]
476    fn unfair_coin_always_0_when_p_is_0() {
477        let node = UnfairCoin::new(0.0);
478        let mut out = [Value::None];
479        for i in 0..100u64 {
480            node.eval(&[Value::U64(i)], &mut out);
481            assert_eq!(
482                out[0].as_u64(),
483                0,
484                "unfair_coin(p=0.0) should always return 0"
485            );
486        }
487    }
488
489    #[test]
490    fn unfair_coin_always_1_when_p_is_1() {
491        let node = UnfairCoin::new(1.0);
492        let mut out = [Value::None];
493        for i in 0..100u64 {
494            node.eval(&[Value::U64(i)], &mut out);
495            assert_eq!(
496                out[0].as_u64(),
497                1,
498                "unfair_coin(p=1.0) should always return 1"
499            );
500        }
501    }
502
503    #[test]
504    fn unfair_coin_respects_probability() {
505        let node = UnfairCoin::new(0.2);
506        let mut out = [Value::None];
507        let mut ones = 0u64;
508        let n = 10_000u64;
509        for i in 0..n {
510            node.eval(&[Value::U64(i)], &mut out);
511            ones += out[0].as_u64();
512        }
513        let ratio = ones as f64 / n as f64;
514        assert!(
515            (0.15..=0.25).contains(&ratio),
516            "unfair_coin(p=0.2) ratio {ratio} outside expected 0.15-0.25"
517        );
518    }
519
520    #[test]
521    fn unfair_coin_compiled_u64() {
522        let node = UnfairCoin::new(0.5);
523        let compiled = node.compiled_u64().expect("should have compiled_u64");
524        let inputs = [42u64];
525        let mut outputs = [0u64];
526        compiled(&inputs, &mut outputs);
527        assert!(outputs[0] == 0 || outputs[0] == 1);
528
529        let mut eval_out = [Value::None];
530        node.eval(&[Value::U64(42)], &mut eval_out);
531        assert_eq!(outputs[0], eval_out[0].as_u64());
532    }
533
534    #[test]
535    #[should_panic(expected = "unfair_coin probability p must be in [0.0, 1.0]")]
536    fn unfair_coin_rejects_invalid_p() {
537        // The range assertion fires on eval rather than at
538        // construction (macro-emitted `new` is infallible).
539        let node = UnfairCoin::new(1.5);
540        let mut out = [Value::None];
541        node.eval(&[Value::U64(0)], &mut out);
542    }
543
544    // --- Select ---
545
546    #[test]
547    fn select_true_branch() {
548        let node = Select::new();
549        let mut out = [Value::None];
550        node.eval(&[Value::U64(1), Value::U64(100), Value::U64(200)], &mut out);
551        assert_eq!(out[0].as_u64(), 100);
552    }
553
554    #[test]
555    fn select_false_branch() {
556        let node = Select::new();
557        let mut out = [Value::None];
558        node.eval(&[Value::U64(0), Value::U64(100), Value::U64(200)], &mut out);
559        assert_eq!(out[0].as_u64(), 200);
560    }
561
562    #[test]
563    fn select_nonzero_is_true() {
564        let node = Select::new();
565        let mut out = [Value::None];
566        // Any nonzero value is truthy
567        node.eval(&[Value::U64(999), Value::U64(10), Value::U64(20)], &mut out);
568        assert_eq!(out[0].as_u64(), 10);
569    }
570
571    #[test]
572    fn select_compiled_u64() {
573        let node = Select::new();
574        let compiled = node.compiled_u64().expect("should have compiled_u64");
575        let mut outputs = [0u64];
576
577        compiled(&[1, 100, 200], &mut outputs);
578        assert_eq!(outputs[0], 100);
579
580        compiled(&[0, 100, 200], &mut outputs);
581        assert_eq!(outputs[0], 200);
582    }
583
584    // --- Chance ---
585
586    #[test]
587    fn chance_returns_f64_bits() {
588        let node = Chance::new(0.5);
589        let mut out = [Value::None];
590        for i in 0..100u64 {
591            node.eval(&[Value::U64(i)], &mut out);
592            let bits = out[0].as_u64();
593            let f = f64::from_bits(bits);
594            assert!(
595                f == 0.0 || f == 1.0,
596                "chance({i}) returned f64 {f}, expected 0.0 or 1.0"
597            );
598        }
599    }
600
601    #[test]
602    fn chance_always_0_when_p_is_0() {
603        let node = Chance::new(0.0);
604        let mut out = [Value::None];
605        for i in 0..100u64 {
606            node.eval(&[Value::U64(i)], &mut out);
607            let f = f64::from_bits(out[0].as_u64());
608            assert_eq!(f, 0.0);
609        }
610    }
611
612    #[test]
613    fn chance_always_1_when_p_is_1() {
614        let node = Chance::new(1.0);
615        let mut out = [Value::None];
616        for i in 0..100u64 {
617            node.eval(&[Value::U64(i)], &mut out);
618            let f = f64::from_bits(out[0].as_u64());
619            assert_eq!(f, 1.0);
620        }
621    }
622
623    #[test]
624    fn chance_compiled_u64() {
625        let node = Chance::new(0.5);
626        let compiled = node.compiled_u64().expect("should have compiled_u64");
627        let inputs = [42u64];
628        let mut outputs = [0u64];
629        compiled(&inputs, &mut outputs);
630        let f = f64::from_bits(outputs[0]);
631        assert!(f == 0.0 || f == 1.0);
632
633        let mut eval_out = [Value::None];
634        node.eval(&[Value::U64(42)], &mut eval_out);
635        assert_eq!(outputs[0], eval_out[0].as_u64());
636    }
637
638    // --- NofM (operator `n_of`, struct `NOf`) ---
639
640    #[test]
641    fn n_of_m_exact_count() {
642        let node = NOf::new(3, 10);
643        let mut out = [Value::None];
644        // Check multiple windows
645        for window in 0..10u64 {
646            let mut count = 0u64;
647            for pos in 0..10u64 {
648                let input = window * 10 + pos;
649                node.eval(&[Value::U64(input)], &mut out);
650                count += out[0].as_u64();
651            }
652            assert_eq!(
653                count, 3,
654                "n_of(3, 10) window {window}: expected exactly 3 selected, got {count}"
655            );
656        }
657    }
658
659    #[test]
660    fn n_of_m_all_selected() {
661        let node = NOf::new(5, 5);
662        let mut out = [Value::None];
663        for i in 0..20u64 {
664            node.eval(&[Value::U64(i)], &mut out);
665            assert_eq!(out[0].as_u64(), 1, "n_of(5, 5) should always return 1");
666        }
667    }
668
669    #[test]
670    fn n_of_m_none_selected() {
671        let node = NOf::new(0, 5);
672        let mut out = [Value::None];
673        for i in 0..20u64 {
674            node.eval(&[Value::U64(i)], &mut out);
675            assert_eq!(out[0].as_u64(), 0, "n_of(0, 5) should always return 0");
676        }
677    }
678
679    #[test]
680    fn n_of_m_deterministic() {
681        let node = NOf::new(2, 7);
682        let mut out1 = [Value::None];
683        let mut out2 = [Value::None];
684        for i in 0..50u64 {
685            node.eval(&[Value::U64(i)], &mut out1);
686            node.eval(&[Value::U64(i)], &mut out2);
687            assert_eq!(out1[0].as_u64(), out2[0].as_u64());
688        }
689    }
690
691    #[test]
692    fn n_of_m_compiled_u64() {
693        let node = NOf::new(3, 10);
694        let compiled = node.compiled_u64().expect("should have compiled_u64");
695
696        // Check that compiled matches eval for a full window
697        for i in 0..10u64 {
698            let mut c_out = [0u64];
699            compiled(&[i], &mut c_out);
700
701            let mut e_out = [Value::None];
702            node.eval(&[Value::U64(i)], &mut e_out);
703
704            assert_eq!(
705                c_out[0],
706                e_out[0].as_u64(),
707                "compiled/eval mismatch at input {i}"
708            );
709        }
710    }
711
712    #[test]
713    #[should_panic(expected = "n_of: m must be > 0")]
714    fn n_of_m_rejects_zero_m() {
715        // The relational check fires on eval (macro-emitted `new`
716        // is infallible).
717        let node = NOf::new(0, 0);
718        let mut out = [Value::None];
719        node.eval(&[Value::U64(0)], &mut out);
720    }
721
722    #[test]
723    #[should_panic(expected = "n_of: n (5) must be <= m (3)")]
724    fn n_of_m_rejects_n_greater_than_m() {
725        let node = NOf::new(5, 3);
726        let mut out = [Value::None];
727        node.eval(&[Value::U64(0)], &mut out);
728    }
729
730    #[test]
731    fn n_of_m_not_first_n() {
732        // Verify that the selected positions are shuffled, not just 0..n
733        let node = NOf::new(1, 10);
734        let mut out = [Value::None];
735        let mut selected_positions = Vec::new();
736        for window in 0..20u64 {
737            for pos in 0..10u64 {
738                let input = window * 10 + pos;
739                node.eval(&[Value::U64(input)], &mut out);
740                if out[0].as_u64() == 1 {
741                    selected_positions.push(pos);
742                }
743            }
744        }
745        // With 20 windows and 1-of-10, we get 20 positions.
746        // If they were all position 0, the set would be {0}.
747        // With hashing, we should see multiple distinct positions.
748        let unique: std::collections::HashSet<u64> = selected_positions.iter().copied().collect();
749        assert!(
750            unique.len() > 1,
751            "n_of should select different positions across windows, got only {:?}",
752            unique
753        );
754    }
755
756    // --- OneOf ---
757
758    #[test]
759    fn one_of_selects_from_values() {
760        let node = OneOf::new(vec!["alpha".into(), "beta".into(), "gamma".into()]);
761        let mut out = [Value::None];
762        for i in 0..100u64 {
763            node.eval(&[Value::U64(i)], &mut out);
764            let s = out[0].as_str().to_string();
765            assert!(
766                s == "alpha" || s == "beta" || s == "gamma",
767                "one_of({i}) returned '{s}', expected one of alpha/beta/gamma"
768            );
769        }
770    }
771
772    #[test]
773    fn one_of_deterministic() {
774        let node = OneOf::new(vec!["x".into(), "y".into(), "z".into()]);
775        let mut out1 = [Value::None];
776        let mut out2 = [Value::None];
777        for i in 0..50u64 {
778            node.eval(&[Value::U64(i)], &mut out1);
779            node.eval(&[Value::U64(i)], &mut out2);
780            assert_eq!(out1[0].as_str(), out2[0].as_str());
781        }
782    }
783
784    #[test]
785    fn one_of_roughly_uniform() {
786        let values = vec!["a".into(), "b".into(), "c".into()];
787        let node = OneOf::new(values);
788        let mut out = [Value::None];
789        let mut counts = [0u64; 3];
790        let n = 9_000u64;
791        for i in 0..n {
792            node.eval(&[Value::U64(i)], &mut out);
793            match out[0].as_str() {
794                "a" => counts[0] += 1,
795                "b" => counts[1] += 1,
796                "c" => counts[2] += 1,
797                other => panic!("unexpected value: {other}"),
798            }
799        }
800        // Each should be roughly n/3 = 3000, allow 25-42% range
801        for (idx, &c) in counts.iter().enumerate() {
802            let ratio = c as f64 / n as f64;
803            assert!(
804                (0.25..=0.42).contains(&ratio),
805                "one_of bucket {idx} ratio {ratio} outside expected 0.25-0.42"
806            );
807        }
808    }
809
810    #[test]
811    fn one_of_single_value() {
812        let node = OneOf::new(vec!["only".into()]);
813        let mut out = [Value::None];
814        for i in 0..20u64 {
815            node.eval(&[Value::U64(i)], &mut out);
816            assert_eq!(out[0].as_str(), "only");
817        }
818    }
819
820    #[test]
821    #[should_panic(expected = "one_of: values must be non-empty")]
822    fn one_of_rejects_empty() {
823        // The non-empty check fires on eval (macro-emitted `new` is
824        // infallible). The assembly path catches this earlier via
825        // the macro's VariadicConsts arity.
826        let node = OneOf::new(vec![]);
827        let mut out = [Value::None];
828        node.eval(&[Value::U64(0)], &mut out);
829    }
830
831    // --- OneOfWeighted ---
832
833    #[test]
834    fn one_of_weighted_selects_from_spec() {
835        let node = OneOfWeighted::new("red:60,blue:30,green:10".to_string());
836        let mut out = [Value::None];
837        for i in 0..100u64 {
838            node.eval(&[Value::U64(i)], &mut out);
839            let s = out[0].as_str().to_string();
840            assert!(
841                s == "red" || s == "blue" || s == "green",
842                "one_of_weighted({i}) returned '{s}'"
843            );
844        }
845    }
846
847    #[test]
848    fn one_of_weighted_deterministic() {
849        let node = OneOfWeighted::new("a:50,b:50".to_string());
850        let mut out1 = [Value::None];
851        let mut out2 = [Value::None];
852        for i in 0..50u64 {
853            node.eval(&[Value::U64(i)], &mut out1);
854            node.eval(&[Value::U64(i)], &mut out2);
855            assert_eq!(out1[0].as_str(), out2[0].as_str());
856        }
857    }
858
859    #[test]
860    fn one_of_weighted_respects_weights() {
861        let node = OneOfWeighted::new("heavy:90,light:10".to_string());
862        let mut out = [Value::None];
863        let mut heavy = 0u64;
864        let n = 10_000u64;
865        for i in 0..n {
866            node.eval(&[Value::U64(i)], &mut out);
867            if out[0].as_str() == "heavy" {
868                heavy += 1;
869            }
870        }
871        let ratio = heavy as f64 / n as f64;
872        // Expect ~90%, allow 80-97% range
873        assert!(
874            (0.80..=0.97).contains(&ratio),
875            "one_of_weighted heavy ratio {ratio} outside expected 0.80-0.97"
876        );
877    }
878
879    #[test]
880    fn one_of_weighted_single_value() {
881        let node = OneOfWeighted::new("only:1".to_string());
882        let mut out = [Value::None];
883        for i in 0..20u64 {
884            node.eval(&[Value::U64(i)], &mut out);
885            assert_eq!(out[0].as_str(), "only");
886        }
887    }
888
889    #[test]
890    fn one_of_weighted_semicolon_delimiter() {
891        let node = OneOfWeighted::new("x:50;y:50".to_string());
892        let mut out = [Value::None];
893        node.eval(&[Value::U64(0)], &mut out);
894        let s = out[0].as_str().to_string();
895        assert!(s == "x" || s == "y");
896    }
897
898    #[test]
899    #[should_panic(expected = "one_of_weighted: spec must be non-empty")]
900    fn one_of_weighted_rejects_empty() {
901        OneOfWeighted::new("".to_string());
902    }
903
904    #[test]
905    #[should_panic(expected = "one_of_weighted: expected 'value:weight'")]
906    fn one_of_weighted_rejects_bad_format() {
907        OneOfWeighted::new("noweight".to_string());
908    }
909
910    // --- Blend ---
911
912    #[test]
913    fn blend_pure_a_when_mix_is_0() {
914        let node = Blend::new(0.0);
915        let a: f64 = 10.0;
916        let b: f64 = 20.0;
917        let mut out = [Value::None];
918        node.eval(
919            &[Value::U64(a.to_bits()), Value::U64(b.to_bits())],
920            &mut out,
921        );
922        let result = f64::from_bits(out[0].as_u64());
923        assert!(
924            (result - 10.0).abs() < 1e-10,
925            "blend(mix=0) should return a, got {result}"
926        );
927    }
928
929    #[test]
930    fn blend_pure_b_when_mix_is_1() {
931        let node = Blend::new(1.0);
932        let a: f64 = 10.0;
933        let b: f64 = 20.0;
934        let mut out = [Value::None];
935        node.eval(
936            &[Value::U64(a.to_bits()), Value::U64(b.to_bits())],
937            &mut out,
938        );
939        let result = f64::from_bits(out[0].as_u64());
940        assert!(
941            (result - 20.0).abs() < 1e-10,
942            "blend(mix=1) should return b, got {result}"
943        );
944    }
945
946    #[test]
947    fn blend_half_mix() {
948        let node = Blend::new(0.5);
949        let a: f64 = 10.0;
950        let b: f64 = 20.0;
951        let mut out = [Value::None];
952        node.eval(
953            &[Value::U64(a.to_bits()), Value::U64(b.to_bits())],
954            &mut out,
955        );
956        let result = f64::from_bits(out[0].as_u64());
957        assert!(
958            (result - 15.0).abs() < 1e-10,
959            "blend(mix=0.5) of 10.0 and 20.0 should be 15.0, got {result}"
960        );
961    }
962
963    #[test]
964    fn blend_quarter_mix() {
965        let node = Blend::new(0.25);
966        let a: f64 = 0.0;
967        let b: f64 = 100.0;
968        let mut out = [Value::None];
969        node.eval(
970            &[Value::U64(a.to_bits()), Value::U64(b.to_bits())],
971            &mut out,
972        );
973        let result = f64::from_bits(out[0].as_u64());
974        assert!(
975            (result - 25.0).abs() < 1e-10,
976            "blend(mix=0.25) of 0.0 and 100.0 should be 25.0, got {result}"
977        );
978    }
979
980    #[test]
981    fn blend_compiled_u64() {
982        let node = Blend::new(0.5);
983        let compiled = node.compiled_u64().expect("should have compiled_u64");
984        let a: f64 = 10.0;
985        let b: f64 = 20.0;
986        let inputs = [a.to_bits(), b.to_bits()];
987        let mut outputs = [0u64];
988        compiled(&inputs, &mut outputs);
989        let result = f64::from_bits(outputs[0]);
990        assert!((result - 15.0).abs() < 1e-10);
991
992        // Should match eval
993        let mut eval_out = [Value::None];
994        node.eval(
995            &[Value::U64(a.to_bits()), Value::U64(b.to_bits())],
996            &mut eval_out,
997        );
998        assert_eq!(outputs[0], eval_out[0].as_u64());
999    }
1000
1001    #[test]
1002    #[should_panic(expected = "blend: mix must be in [0.0, 1.0]")]
1003    fn blend_rejects_invalid_mix() {
1004        // The range assertion fires on eval.
1005        let node = Blend::new(1.5);
1006        let mut out = [Value::None];
1007        node.eval(&[Value::U64(0), Value::U64(0)], &mut out);
1008    }
1009
1010    #[test]
1011    #[should_panic(expected = "blend: mix must be in [0.0, 1.0]")]
1012    fn blend_rejects_negative_mix() {
1013        let node = Blend::new(-0.1);
1014        let mut out = [Value::None];
1015        node.eval(&[Value::U64(0), Value::U64(0)], &mut out);
1016    }
1017
1018    // --- DefaultOr ---
1019
1020    #[test]
1021    fn default_or_returns_value_when_not_none() {
1022        // Macro-emitted `new(value_type, fallback_type)` — PolyWire
1023        // args contribute a `<argname>_type: PortType` ctor param
1024        // each.
1025        let node = DefaultOr::new(PortType::Str, PortType::Str);
1026        let mut out = [Value::None];
1027        node.eval(
1028            &[Value::Str("alice".into()), Value::Str("fallback".into())],
1029            &mut out,
1030        );
1031        assert_eq!(out[0].as_str(), "alice");
1032    }
1033
1034    #[test]
1035    fn default_or_returns_fallback_when_none() {
1036        let node = DefaultOr::new(PortType::Str, PortType::Str);
1037        let mut out = [Value::None];
1038        node.eval(&[Value::None, Value::Str("fallback".into())], &mut out);
1039        assert_eq!(out[0].as_str(), "fallback");
1040    }
1041
1042    #[test]
1043    fn default_or_works_with_u64() {
1044        let node = DefaultOr::new(PortType::U64, PortType::U64);
1045        let mut out = [Value::None];
1046        // Non-None u64 passes through
1047        node.eval(&[Value::U64(42), Value::U64(0)], &mut out);
1048        assert!(matches!(out[0], Value::U64(42)));
1049        // None falls back
1050        node.eval(&[Value::None, Value::U64(99)], &mut out);
1051        assert!(matches!(out[0], Value::U64(99)));
1052    }
1053
1054    #[test]
1055    fn default_or_with_extern_input() {
1056        // Full integration: build a Polydat program with an extern input,
1057        // wire through default_or, verify None→fallback and set→value.
1058        use polydat::compile::assembly::{PolydatAssembler, WireRef};
1059        use polydat::library::identity::PortPassthrough;
1060
1061        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
1062        // Add an extern input (defaults to None)
1063        asm.add_input(
1064            "captured_name",
1065            Value::None,
1066            PortType::Str,
1067            polydat::kernel::InputKind::ExternalWrite,
1068        );
1069        // Passthrough so the input is a node
1070        asm.add_node(
1071            "__port_captured_name",
1072            Box::new(PortPassthrough::new("captured_name", PortType::Str)),
1073            vec![WireRef::input("captured_name")],
1074        );
1075        // Fallback constant
1076        asm.add_node(
1077            "fallback",
1078            Box::new(polydat::library::identity::ConstStr::new(
1079                "anonymous".to_string(),
1080            )),
1081            vec![],
1082        );
1083        // default_or wired to extern input + fallback
1084        asm.add_node(
1085            "greeting",
1086            Box::new(DefaultOr::new(PortType::Str, PortType::Str)),
1087            vec![
1088                WireRef::node("__port_captured_name"),
1089                WireRef::node("fallback"),
1090            ],
1091        );
1092        asm.add_output("greeting", WireRef::node("greeting"));
1093
1094        let kernel = asm.compile().unwrap();
1095        let program = kernel.into_program();
1096        let mut state = program.create_state();
1097
1098        // Before any capture: input is None → should get fallback
1099        state.set_inputs(&[0]);
1100        let val = state.pull(&program, "greeting");
1101        assert_eq!(
1102            val.to_display_string(),
1103            "anonymous",
1104            "unset extern should produce fallback, got: {:?}",
1105            val
1106        );
1107
1108        // Set the capture input
1109        let input_idx = program.find_input("captured_name").unwrap();
1110        state.set_input(input_idx, Value::Str("alice".into()));
1111        let val = state.pull(&program, "greeting");
1112        assert_eq!(
1113            val.to_display_string(),
1114            "alice",
1115            "set extern should produce captured value, got: {:?}",
1116            val
1117        );
1118
1119        // Reset captures → back to None → fallback again
1120        state.reset_inputs_from(program.coord_count());
1121        let val = state.pull(&program, "greeting");
1122        assert_eq!(
1123            val.to_display_string(),
1124            "anonymous",
1125            "reset extern should produce fallback again, got: {:?}",
1126            val
1127        );
1128    }
1129}