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