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