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