Skip to main content

polydat_nodes/
weighted.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Convenience weighted output selection nodes.
5//!
6//! These are "fat" convenience nodes that combine alias sampling with
7//! value lookup in one step. They parse an inline spec string at init
8//! time and perform weighted selection at cycle time.
9//!
10//! * [`WeightedStrings`] / [`WeightedU64`] — the spec parser runs
11//!   once at construction via a `#[poly_const]` setup function,
12//!   producing a derived `WeightedStrCache` / `WeightedU64Cache`
13//!   (parallel value+alias-table). The eval body does a
14//!   constant-time lookup.
15//! * [`WeightedPick`] — the same spec-string surface as
16//!   `weighted_u64`: `weighted_pick(input, "v0:w0;v1:w1;...")`. The
17//!   `compiled_u64`/`jit_constants` overrides feed the JIT extern
18//!   (`jit_weighted_pick`), which reads a 5-u64 (values_ptr,
19//!   biases_ptr, primaries_ptr, aliases_ptr, n) constants slice.
20//! * [`DynamicWeightedSelect`] — takes its spec on a `Config<T>`
21//!   wire (the Wire trait's `WIRE_COST = Config` const flows through
22//!   `Config<Arc<str>>` to the slot's `WireCost::Config` annotation,
23//!   so the compiler warns when the spec is bound to a cycle-time
24//!   source). The evaluating kernel state memoizes the last spec
25//!   parsed and its alias table in its own scratch, so repeated
26//!   evaluations with the same spec do no parsing; only a spec
27//!   change re-parses.
28
29use crate::sampling::alias::AliasTableU64;
30use polydat::ast::CompiledU64Op;
31use polydat::compile::fusion::{DecomposedGraph, DecomposedWire};
32use polydat::derive_support::Config;
33
34/// Parse a weighted spec like "alpha:0.3;beta:0.5;gamma:0.2"
35/// into parallel vectors of values and weights.
36fn parse_weighted_str_spec(spec: &str) -> (Vec<String>, Vec<f64>) {
37    let mut values = Vec::new();
38    let mut weights = Vec::new();
39    for elem in spec.split([';', ',']) {
40        let elem = elem.trim();
41        if elem.is_empty() {
42            continue;
43        }
44        let parts: Vec<&str> = elem.splitn(2, ':').collect();
45        assert_eq!(parts.len(), 2, "expected 'value:weight', got '{elem}'");
46        values.push(parts[0].to_string());
47        weights.push(parts[1].parse::<f64>().expect("invalid weight"));
48    }
49    (values, weights)
50}
51
52fn parse_weighted_u64_spec(spec: &str) -> (Vec<u64>, Vec<f64>) {
53    let mut values = Vec::new();
54    let mut weights = Vec::new();
55    for elem in spec.split([';', ',']) {
56        let elem = elem.trim();
57        if elem.is_empty() {
58            continue;
59        }
60        let parts: Vec<&str> = elem.splitn(2, ':').collect();
61        assert_eq!(parts.len(), 2, "expected 'value:weight', got '{elem}'");
62        values.push(parts[0].parse::<u64>().expect("invalid value"));
63        weights.push(parts[1].parse::<f64>().expect("invalid weight"));
64    }
65    (values, weights)
66}
67
68// ---------------------------------------------------------------------------
69// WeightedStrings
70// ---------------------------------------------------------------------------
71
72/// Derived state for [`WeightedStrings`]: the parsed value list and
73/// the alias table, computed once at construction from the spec
74/// string and read on every cycle.
75pub struct WeightedStrCache {
76    values: Vec<String>,
77    table: AliasTableU64,
78}
79
80impl polydat::derive_support::PolydatSetup for WeightedStrCache {}
81
82fn build_weighted_str_cache(spec: &str) -> WeightedStrCache {
83    let (values, weights) = parse_weighted_str_spec(spec);
84    let table = AliasTableU64::from_weights(&weights);
85    WeightedStrCache { values, table }
86}
87
88/// Weighted string selection from an inline spec.
89///
90/// Signature: `weighted_strings(input: u64, spec: &str) -> String`
91///
92/// Spec format: `"alpha:0.3;beta:0.5;gamma:0.2"`. The input is
93/// expected to be a hashed u64 so the alias-method sampling sees
94/// a uniformly-distributed selector.
95#[polydat::polydat_node(category = Weighted)]
96fn weighted_strings(
97    input: u64,
98    spec: polydat::derive_support::Const<&str>,
99    #[poly_const(build_weighted_str_cache, from = spec)] cache: &WeightedStrCache,
100) -> String {
101    let _ = spec; // value baked into `cache` at construction
102    let idx = cache.table.sample(input) as usize;
103    cache.values[idx].clone()
104}
105
106// ---------------------------------------------------------------------------
107// WeightedU64
108// ---------------------------------------------------------------------------
109
110/// Derived state for [`WeightedU64`]: the parsed value list and
111/// the alias table, computed once at construction from the spec
112/// string and read on every cycle.
113pub struct WeightedU64Cache {
114    values: Vec<u64>,
115    table: AliasTableU64,
116}
117
118impl polydat::derive_support::PolydatSetup for WeightedU64Cache {}
119
120fn build_weighted_u64_cache(spec: &str) -> WeightedU64Cache {
121    let (values, weights) = parse_weighted_u64_spec(spec);
122    let table = AliasTableU64::from_weights(&weights);
123    WeightedU64Cache { values, table }
124}
125
126/// Weighted u64 selection from an inline spec.
127///
128/// Signature: `weighted_u64(input: u64, spec: &str) -> u64`
129///
130/// Spec format: `"10:0.5;20:0.3;30:0.2"`.
131#[polydat::polydat_node(category = Weighted)]
132fn weighted_u64(
133    input: u64,
134    spec: polydat::derive_support::Const<&str>,
135    #[poly_const(build_weighted_u64_cache, from = spec)] cache: &WeightedU64Cache,
136) -> u64 {
137    let _ = spec; // value baked into `cache` at construction
138    let idx = cache.table.sample(input) as usize;
139    cache.values[idx]
140}
141
142// ---------------------------------------------------------------------------
143// WeightedPick — the spec-string surface
144//
145// The DSL form is `weighted_pick(input, "v0:w0;v1:w1;...")`. The
146// `compiled_u64`/`jit_constants` overrides feed the JIT extern
147// `jit_weighted_pick`, which reads a 5-u64 constants slice
148// (values_ptr, biases_ptr, primaries_ptr, aliases_ptr, n).
149// ---------------------------------------------------------------------------
150
151/// Derived state for [`WeightedPick`]: parsed values, weights, and the
152/// alias table, built once at construction from the spec string and
153/// read on every cycle (both in the eval path and the compiled
154/// closure). `weights` is retained alongside `values` so
155/// `FusedNode::decomposed` can reconstruct the equivalent
156/// `weighted_u64` spec string.
157pub struct WeightedPickState {
158    /// The alias table sampled.
159    pub table: AliasTableU64,
160    /// The value per outcome.
161    pub values: Vec<u64>,
162    /// The weight per outcome, kept to reconstruct the spec.
163    pub weights: Vec<f64>,
164}
165
166impl polydat::derive_support::PolydatSetup for WeightedPickState {}
167
168/// Spec syntax: `"<value>:<weight>;<value>:<weight>;..."` —
169/// e.g. `"100:1.0;200:2.0;300:1.5"` → values `[100, 200, 300]`,
170/// weights `[1.0, 2.0, 1.5]`. Panics on malformed spec;
171/// construction-time failure is the right signal (matches the
172/// `weighted_u64` parser).
173fn parse_weighted_pick_spec(spec: &str) -> WeightedPickState {
174    let mut weights = Vec::new();
175    let mut values = Vec::new();
176    for entry in spec.split([';', ',']) {
177        let entry = entry.trim();
178        if entry.is_empty() {
179            continue;
180        }
181        let (v, w) = entry.split_once(':').unwrap_or_else(|| {
182            panic!("weighted_pick: malformed entry '{entry}', expected 'value:weight'")
183        });
184        let value: u64 = v
185            .trim()
186            .parse()
187            .unwrap_or_else(|_| panic!("weighted_pick: invalid value '{v}' in entry '{entry}'"));
188        let weight: f64 = w
189            .trim()
190            .parse()
191            .unwrap_or_else(|_| panic!("weighted_pick: invalid weight '{w}' in entry '{entry}'"));
192        assert!(
193            weight.is_finite() && weight > 0.0,
194            "weighted_pick: weight must be a positive finite f64, got {weight}",
195        );
196        values.push(value);
197        weights.push(weight);
198    }
199    assert!(
200        !weights.is_empty(),
201        "weighted_pick requires at least one entry in spec",
202    );
203    WeightedPickState {
204        table: AliasTableU64::from_weights(&weights),
205        values,
206        weights,
207    }
208}
209
210/// `compiled_u64` override for [`WeightedPick`]. Captures the
211/// parsed value list and alias-table arrays by clone so the
212/// returned closure is independent of `self`'s lifetime.
213fn weighted_pick_jit(node: &WeightedPick) -> CompiledU64Op {
214    let values = node.state.values.clone();
215    let biases = node.state.table.biases().to_vec();
216    let primaries = node.state.table.primaries().to_vec();
217    let aliases = node.state.table.aliases().to_vec();
218    let n = values.len();
219    Box::new(move |inputs, outputs| {
220        let input = inputs[0];
221        let slot = (input as usize) % n;
222        let bias_test = ((input >> 32) as f64) / (u32::MAX as f64);
223        let index = if bias_test < biases[slot] {
224            primaries[slot]
225        } else {
226            aliases[slot]
227        };
228        outputs[0] = values[index as usize];
229    })
230}
231
232/// `jit_constants` override for [`WeightedPick`]. Publishes the
233/// pointer/length quintuple the `jit_weighted_pick` extern reads.
234/// Safety: pointers live in the parsed state which is owned by
235/// `PolydatProgram` behind an `Arc` — never moved or freed during
236/// the JIT kernel's lifetime.
237fn weighted_pick_jit_constants(node: &WeightedPick) -> Vec<u64> {
238    vec![
239        node.state.values.as_ptr() as u64,
240        node.state.table.biases().as_ptr() as u64,
241        node.state.table.primaries().as_ptr() as u64,
242        node.state.table.aliases().as_ptr() as u64,
243        node.state.values.len() as u64,
244    ]
245}
246
247/// Weighted u64 selection from a spec string.
248///
249/// Signature: `weighted_pick(input: u64, spec: &str) -> u64`
250///
251/// Spec format: `"value:weight;value:weight;..."` — e.g.
252/// `"100:1.0;200:2.0;300:1.5"`. Weights are relative (need not
253/// sum to 1); each must be positive and finite. Internally builds
254/// an alias table at construction for O(1) sampling.
255///
256/// JIT level: P3 (extern call `jit_weighted_pick` fed by the
257/// 5-u64 slice from [`weighted_pick_jit_constants`]);
258/// [`weighted_pick_jit`] (closure with captured alias-table
259/// arrays) serves the closure tier.
260///
261/// Example: `weighted_pick(hash(cycle), "100:0.5;200:0.3;300:0.2")`.
262/// `weighted_pick(input, "v0:w0;v1:w1;...")` is equivalent to
263/// `weighted_u64(input, "v0:w0;v1:w1;...")`. The two nodes share
264/// the same spec-string surface, so this decomposition is a
265/// direct re-instantiation under the canonical name. Wired into
266/// the macro via `#[polydat_node(decompose = ...)]` so the
267/// FusedNode impl is emitted alongside PolydatNode.
268fn weighted_pick_decompose(node: &WeightedPick) -> DecomposedGraph {
269    let spec: String = node
270        .state
271        .values
272        .iter()
273        .zip(node.state.weights.iter())
274        .map(|(v, w)| format!("{v}:{w}"))
275        .collect::<Vec<_>>()
276        .join(";");
277    let mut g = DecomposedGraph::new(1);
278    let wu = g.add_node(
279        Box::new(WeightedU64::new(spec)),
280        vec![DecomposedWire::Input(0)],
281    );
282    g.set_outputs(vec![DecomposedWire::Node(wu, 0)]);
283    g
284}
285
286#[polydat::polydat_node(
287    category = Weighted,
288    compiled_u64 = weighted_pick_jit,
289    jit_constants = weighted_pick_jit_constants,
290    decompose = weighted_pick_decompose,
291)]
292fn weighted_pick(
293    input: u64,
294    spec: polydat::derive_support::Const<&str>,
295    #[poly_const(parse_weighted_pick_spec, from = spec)] state: &WeightedPickState,
296) -> u64 {
297    let _ = spec; // value baked into `state` at construction
298    let idx = state.table.sample(input) as usize;
299    state.values[idx]
300}
301
302// ---------------------------------------------------------------------------
303// DynamicWeightedSelect — the spec arrives on a `Config<T>` wire. The
304// Wire trait's `WIRE_COST` const flows through `Config<Arc<str>>` to
305// the slot's `WireCost::Config` annotation, so the compiler warns
306// when the spec is bound to a cycle-time source. The last spec parsed
307// and its alias table are a memo in the evaluating kernel state's own
308// scratch (`ScratchElem::State`; compiled_handles.md §3): the node is
309// shared by every state of its program and holds nothing that
310// changes, so the memo lives beside the state's other storage, and a
311// clone of a state starts with an empty one. A spec that repeats is
312// not parsed again; a spec that changes is followed at once.
313// ---------------------------------------------------------------------------
314
315/// The memo [`DynamicWeightedSelect`] keeps per kernel state: the
316/// last spec it parsed, with the value list and alias table built
317/// from it. `table` is `None` when the spec named no values.
318#[derive(Default)]
319pub struct DynamicWeightedMemo {
320    spec: String,
321    values: Vec<String>,
322    table: Option<AliasTableU64>,
323    parsed: bool,
324}
325
326impl DynamicWeightedMemo {
327    /// The pick for `selector` under `spec`, parsing the spec only
328    /// when it differs from the last one parsed.
329    fn select(&mut self, spec: &str, selector: u64) -> &str {
330        if !self.parsed || self.spec != spec {
331            let (values, weights) = parse_weighted_str_spec(spec);
332            self.table = if values.is_empty() {
333                None
334            } else {
335                Some(AliasTableU64::from_weights(&weights))
336            };
337            self.values = values;
338            self.spec.clear();
339            self.spec.push_str(spec);
340            self.parsed = true;
341        }
342        match &self.table {
343            Some(table) => &self.values[table.sample(selector) as usize],
344            None => "",
345        }
346    }
347
348    /// The spec the memo holds, for tests.
349    #[cfg(test)]
350    fn spec(&self) -> Option<&str> {
351        self.parsed.then_some(self.spec.as_str())
352    }
353}
354
355/// The node's state module: one `State` entry holding the memo.
356pub(crate) mod dynamic_weighted_state {
357    use super::{DynamicWeightedMemo, DynamicWeightedSelect};
358    use polydat::ast::{ScratchBuf, ScratchElem, Value};
359
360    pub(crate) fn layout(_node: &DynamicWeightedSelect) -> Vec<ScratchElem> {
361        vec![ScratchElem::State]
362    }
363
364    pub(crate) fn eval(
365        _node: &DynamicWeightedSelect,
366        scratch: &mut [ScratchBuf],
367        inputs: &[Value],
368        outputs: &mut [Value],
369    ) {
370        let spec = inputs[1].to_display_string();
371        let memo = scratch[0]
372            .node_state()
373            .get_or_insert_with(DynamicWeightedMemo::default);
374        outputs[0] = Value::Str(memo.select(&spec, inputs[0].as_u64()).into());
375    }
376}
377
378/// The node's compiled form: the selector from its slot, the spec
379/// through its pair, the memo in the state's `State` entry, the pick
380/// written into the step's own string entry.
381fn dynamic_weighted_compiled(
382    _node: &DynamicWeightedSelect,
383    wire_types: &[polydat::ast::PortType],
384) -> polydat::ast::CompiledSlotKit {
385    use polydat::ast::{PortType, ScratchBuf, ScratchElem};
386    let spec_ty = wire_types.get(1).copied().unwrap_or(PortType::Str);
387    polydat::ast::CompiledSlotKit {
388        scratch: vec![ScratchElem::State, ScratchElem::Str],
389        op: Box::new(
390            move |inputs: &[u64], outputs: &mut [u64], scratch: &mut [ScratchBuf]| {
391                let selector = inputs[0];
392                let spec_value;
393                // SAFETY: the pair was published by the producing step
394                // into storage alive until it reruns (axioms S3, S4).
395                let spec: &str =
396                    match unsafe { polydat::compile::marshal::arg_ref(spec_ty, &inputs[1..]) } {
397                        polydat::ast::ValueRef::Str(s) => s,
398                        other => {
399                            spec_value = other.to_display_string();
400                            &spec_value
401                        }
402                    };
403                let (state, out) = scratch.split_at_mut(1);
404                let memo = state[0]
405                    .node_state()
406                    .get_or_insert_with(DynamicWeightedMemo::default);
407                out[0].set_str(memo.select(spec, selector));
408                let (ptr, len) = out[0].ptr_len();
409                outputs[0] = ptr;
410                outputs[1] = len;
411            },
412        ),
413    }
414}
415
416/// Dynamic weighted selection where the weight spec is a wire input.
417///
418/// Signature: `dynamic_weighted_select(selector: u64, weights_spec: Str) -> Str`
419///
420/// Unlike `WeightedStrings` (which parses weights at init time and
421/// builds the alias table once), this node accepts the weight spec
422/// as a runtime wire input. The `weights_spec` input is wrapped in
423/// `Config<Arc<str>>` to mark it as a configuration-cost wire: the
424/// compiler warns when it is bound to a cycle-time source.
425///
426/// The last parsed spec and its alias table are memoized in the
427/// evaluating kernel state's own scratch, so repeated evaluations with
428/// the same spec do no parsing and a changed spec is followed at once.
429///
430/// Typical use: wire `weights_spec` to an init-time constant or a
431/// rarely-changing captured value. Wire `selector` to a per-cycle
432/// hash for O(1) lookup once the alias table is built.
433///
434/// Spec format: `"alpha:0.3;beta:0.5;gamma:0.2"`
435#[polydat::polydat_node(
436    category = Weighted,
437    compiled_slot = dynamic_weighted_compiled,
438    state = dynamic_weighted_state
439)]
440fn dynamic_weighted_select(selector: u64, weights_spec: Config<std::sync::Arc<str>>) -> String {
441    // The stateless evaluation (a node evaluated on its own, with no
442    // kernel state to keep a memo in): parse and pick.
443    DynamicWeightedMemo::default()
444        .select(weights_spec.0.as_ref(), selector)
445        .to_string()
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use polydat::ast::{ConstValue, PolydatNode, Slot, Value};
452    use polydat::compile::fusion::FusedNode;
453    use xxhash_rust::xxh3::xxh3_64;
454
455    #[test]
456    fn weighted_strings_valid_outputs() {
457        let node = WeightedStrings::new("alpha:0.3;beta:0.5;gamma:0.2".to_string());
458        let valid = ["alpha", "beta", "gamma"];
459        let mut out = [Value::None];
460        for i in 0..1000u64 {
461            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
462            assert!(valid.contains(&out[0].as_str()));
463        }
464    }
465
466    #[test]
467    fn weighted_strings_respects_weights() {
468        let node = WeightedStrings::new("rare:0.01;common:0.99".to_string());
469        let mut common_count = 0u64;
470        let mut out = [Value::None];
471        let n = 10_000u64;
472        for i in 0..n {
473            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
474            if out[0].as_str() == "common" {
475                common_count += 1;
476            }
477        }
478        let ratio = common_count as f64 / n as f64;
479        assert!(ratio > 0.90, "common should dominate, got {ratio}");
480    }
481
482    #[test]
483    fn weighted_u64_valid_outputs() {
484        let node = WeightedU64::new("10:0.5;20:0.3;30:0.2".to_string());
485        let valid = [10u64, 20, 30];
486        let mut out = [Value::None];
487        for i in 0..1000u64 {
488            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
489            assert!(valid.contains(&out[0].as_u64()));
490        }
491    }
492
493    // --- WeightedPick tests ---
494
495    #[test]
496    fn weighted_pick_valid_outputs() {
497        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
498        let valid = [10u64, 20, 30];
499        let mut out = [Value::None];
500        for i in 0..1000u64 {
501            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
502            assert!(
503                valid.contains(&out[0].as_u64()),
504                "unexpected output {} at seed {i}",
505                out[0].as_u64()
506            );
507        }
508    }
509
510    #[test]
511    fn weighted_pick_respects_weights() {
512        let node = WeightedPick::new("1:0.99;2:0.01".to_string());
513        let mut count_1 = 0u64;
514        let mut out = [Value::None];
515        let n = 10_000u64;
516        for i in 0..n {
517            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
518            if out[0].as_u64() == 1 {
519                count_1 += 1;
520            }
521        }
522        let ratio = count_1 as f64 / n as f64;
523        assert!(
524            ratio > 0.90,
525            "value 1 (weight 0.99) should dominate, got {ratio}"
526        );
527    }
528
529    #[test]
530    fn weighted_pick_single_pair() {
531        let node = WeightedPick::new("42:1.0".to_string());
532        let mut out = [Value::None];
533        for i in 0..100u64 {
534            node.eval(&[Value::U64(i)], &mut out);
535            assert_eq!(out[0].as_u64(), 42);
536        }
537    }
538
539    #[test]
540    fn weighted_pick_equal_weights() {
541        let node = WeightedPick::new("10:1.0;20:1.0;30:1.0".to_string());
542        let mut counts = [0u64; 3];
543        let mut out = [Value::None];
544        let n = 30_000u64;
545        for i in 0..n {
546            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
547            match out[0].as_u64() {
548                10 => counts[0] += 1,
549                20 => counts[1] += 1,
550                30 => counts[2] += 1,
551                v => panic!("unexpected value {v}"),
552            }
553        }
554        // Each should be roughly 1/3
555        for (i, c) in counts.iter().enumerate() {
556            let ratio = *c as f64 / n as f64;
557            assert!(
558                ratio > 0.25 && ratio < 0.42,
559                "value at index {i} has ratio {ratio}, expected ~0.33"
560            );
561        }
562    }
563
564    #[test]
565    fn weighted_pick_compiled_matches_eval() {
566        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
567        let compiled = node.compiled_u64().expect("should compile");
568        for i in 0..10_000u64 {
569            let input = xxh3_64(&i.to_le_bytes());
570            let mut eval_out = [Value::None];
571            node.eval(&[Value::U64(input)], &mut eval_out);
572            let mut compiled_out = [0u64];
573            compiled(&[input], &mut compiled_out);
574            assert_eq!(
575                eval_out[0].as_u64(),
576                compiled_out[0],
577                "eval vs compiled mismatch at seed {i}"
578            );
579        }
580    }
581
582    #[test]
583    fn weighted_pick_jit_constants_shape() {
584        // The 5-u64 jit_constants slice (values_ptr, biases_ptr,
585        // primaries_ptr, aliases_ptr, n) is the contract the
586        // `jit_weighted_pick` extern reads.
587        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
588
589        let raw = node.jit_constants();
590        assert_eq!(raw.len(), 5); // values_ptr, biases_ptr, primaries_ptr, aliases_ptr, n
591        assert_eq!(raw[4], 3); // n = 3 entries in the spec
592
593        // The values_ptr should match the parsed value list inside state.
594        assert_eq!(raw[0], node.state.values.as_ptr() as u64);
595        assert_eq!(raw[1], node.state.table.biases().as_ptr() as u64);
596        assert_eq!(raw[2], node.state.table.primaries().as_ptr() as u64);
597        assert_eq!(raw[3], node.state.table.aliases().as_ptr() as u64);
598    }
599
600    #[test]
601    fn weighted_pick_equivalence_with_weighted_u64() {
602        // weighted_pick(input, "10:0.5;20:0.3;30:0.2") should match
603        // weighted_u64(input, "10:0.5;20:0.3;30:0.2") — the two
604        // nodes now share the same spec-string surface.
605        let fused = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
606        let decomposed = fused.decomposed();
607        for i in 0..10_000u64 {
608            let input = xxh3_64(&i.to_le_bytes());
609            let mut fused_out = [Value::None];
610            fused.eval(&[Value::U64(input)], &mut fused_out);
611            let decomposed_out = decomposed.eval(&[Value::U64(input)]);
612            assert_eq!(
613                fused_out[0].as_u64(),
614                decomposed_out[0].as_u64(),
615                "equivalence failed at seed {i}"
616            );
617        }
618    }
619
620    #[test]
621    #[should_panic(expected = "weighted_pick requires at least one entry in spec")]
622    fn weighted_pick_rejects_empty_spec() {
623        // Macro-emitted `new` runs the setup parser eagerly; empty
624        // spec panics at construction.
625        let _ = WeightedPick::new("".to_string());
626    }
627
628    #[test]
629    #[should_panic(expected = "weighted_pick: malformed entry")]
630    fn weighted_pick_rejects_bad_format() {
631        let _ = WeightedPick::new("noweight".to_string());
632    }
633
634    #[test]
635    #[should_panic(expected = "weighted_pick: weight must be a positive finite f64")]
636    fn weighted_pick_rejects_nonpositive_weight() {
637        let _ = WeightedPick::new("10:0.0;20:1.0".to_string());
638    }
639
640    // --- DynamicWeightedSelect tests ---
641
642    #[test]
643    fn dynamic_weighted_select_basic() {
644        let node = DynamicWeightedSelect::new();
645        let spec = "alpha:0.3;beta:0.5;gamma:0.2";
646        let valid = ["alpha", "beta", "gamma"];
647        let mut out = [Value::None];
648        for i in 0..100u64 {
649            node.eval(
650                &[
651                    Value::U64(xxh3_64(&i.to_le_bytes())),
652                    Value::Str(spec.into()),
653                ],
654                &mut out,
655            );
656            assert!(
657                valid.contains(&out[0].as_str()),
658                "unexpected: {}",
659                out[0].as_str()
660            );
661        }
662    }
663
664    #[test]
665    fn dynamic_weighted_select_follows_its_spec() {
666        let node = DynamicWeightedSelect::new();
667        let spec = "a:0.5;b:0.5";
668        let mut out = [Value::None];
669        node.eval(&[Value::U64(42), Value::Str(spec.into())], &mut out);
670        let first = out[0].as_str().to_string();
671        // The same spec and selector give the same pick.
672        node.eval(&[Value::U64(42), Value::Str(spec.into())], &mut out);
673        assert_eq!(out[0].as_str(), first);
674        // A different spec is followed at once.
675        node.eval(&[Value::U64(42), Value::Str("x:1.0".into())], &mut out);
676        assert_eq!(out[0].as_str(), "x");
677    }
678
679    /// The memo lives in the evaluating state's scratch: a repeated
680    /// spec is not parsed again, a changed one replaces the memo, and
681    /// a clone of the state starts with an empty entry.
682    #[test]
683    fn dynamic_weighted_select_memoizes_in_the_state() {
684        use polydat::ast::ScratchBuf;
685        let node = DynamicWeightedSelect::new();
686        let mut scratch: Vec<ScratchBuf> = node
687            .scratch_layout()
688            .iter()
689            .map(|e| ScratchBuf::new(*e))
690            .collect();
691        assert_eq!(scratch.len(), 1);
692        let mut out = [Value::None];
693        node.eval_in(
694            &mut scratch,
695            &[Value::U64(42), Value::Str("a:0.5;b:0.5".into())],
696            &mut out,
697        );
698        let first = out[0].as_str().to_string();
699        let memo = scratch[0]
700            .node_state()
701            .get::<DynamicWeightedMemo>()
702            .expect("filled on the first evaluation");
703        assert_eq!(memo.spec(), Some("a:0.5;b:0.5"));
704        let table_before = memo.table.as_ref().map(|t| t as *const AliasTableU64);
705        node.eval_in(
706            &mut scratch,
707            &[Value::U64(42), Value::Str("a:0.5;b:0.5".into())],
708            &mut out,
709        );
710        assert_eq!(out[0].as_str(), first);
711        let memo = scratch[0]
712            .node_state()
713            .get::<DynamicWeightedMemo>()
714            .unwrap();
715        assert_eq!(
716            memo.table.as_ref().map(|t| t as *const AliasTableU64),
717            table_before,
718            "the same spec keeps the table it built"
719        );
720        node.eval_in(
721            &mut scratch,
722            &[Value::U64(42), Value::Str("x:1.0".into())],
723            &mut out,
724        );
725        assert_eq!(out[0].as_str(), "x");
726        let memo = scratch[0]
727            .node_state()
728            .get::<DynamicWeightedMemo>()
729            .unwrap();
730        assert_eq!(memo.spec(), Some("x:1.0"));
731        let cloned = scratch[0].clone();
732        let mut cloned = cloned;
733        assert!(
734            cloned.node_state().get::<DynamicWeightedMemo>().is_none(),
735            "a clone of the state starts with an empty memo"
736        );
737    }
738
739    /// The compiled form keeps its memo in the kernel state's scratch
740    /// too, and agrees with the interpreter across a changing spec.
741    #[test]
742    fn dynamic_weighted_select_compiled_form_memoizes_and_agrees() {
743        use polydat::ast::{PortType, ScratchBuf};
744        let node = DynamicWeightedSelect::new();
745        let kit = dynamic_weighted_compiled(&node, &[PortType::U64, PortType::Str]);
746        let mut scratch: Vec<ScratchBuf> =
747            kit.scratch.iter().map(|e| ScratchBuf::new(*e)).collect();
748        let mut outputs = [0u64; 2];
749        for (spec, selector) in [("a:0.5;b:0.5", 42u64), ("a:0.5;b:0.5", 7), ("z:1.0", 3)] {
750            let inputs = [selector, spec.as_ptr() as usize as u64, spec.len() as u64];
751            (kit.op)(&inputs, &mut outputs, &mut scratch);
752            let got = scratch[1].to_value();
753            let mut want = [Value::None];
754            node.eval(&[Value::U64(selector), Value::Str(spec.into())], &mut want);
755            assert_eq!(
756                got.as_str(),
757                want[0].as_str(),
758                "spec {spec}, selector {selector}"
759            );
760            assert_eq!((outputs[0], outputs[1]), scratch[1].ptr_len());
761            assert_eq!(
762                scratch[0]
763                    .node_state()
764                    .get::<DynamicWeightedMemo>()
765                    .and_then(|m| m.spec()),
766                Some(spec)
767            );
768        }
769    }
770
771    #[test]
772    fn dynamic_weighted_select_config_wire_annotation() {
773        let node = DynamicWeightedSelect::new();
774        let meta = node.meta();
775        // Second input (weights_spec) should be marked Config
776        let wire_inputs = meta.wire_inputs();
777        assert_eq!(wire_inputs.len(), 2);
778        assert_eq!(wire_inputs[0].wire_cost, polydat::ast::WireCost::Data);
779        assert_eq!(wire_inputs[1].wire_cost, polydat::ast::WireCost::Config);
780    }
781
782    #[test]
783    fn dynamic_weighted_select_e2e_init_config() {
784        // Init-time config wire: no warning expected
785        use polydat::dsl::events::CompileEventLog;
786
787        let source = r#"
788            input cycle: u64
789            const spec := "alpha:0.3;beta:0.7"
790            result := dynamic_weighted_select(hash(cycle), spec)
791        "#;
792        let mut log = CompileEventLog::new();
793        let _k = polydat::dsl::compile::compile_polydat_with_log(source, &mut log).unwrap();
794
795        let warnings: Vec<_> = log
796            .events()
797            .iter()
798            .filter(|e| {
799                matches!(
800                    e,
801                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
802                )
803            })
804            .collect();
805        assert!(warnings.is_empty(), "init-time config should not warn");
806    }
807
808    #[test]
809    fn dynamic_weighted_select_e2e_cycle_config_warns() {
810        // Cycle-time config wire: should warn
811        use polydat::dsl::events::CompileEventLog;
812
813        // Spec derived from cycle → cycle-time → config wire warning
814        let source = r#"
815            input cycle: u64
816            spec := format_u64(hash(cycle), 10)
817            result := dynamic_weighted_select(hash(cycle), spec)
818        "#;
819        let mut log = CompileEventLog::new();
820        let _k = polydat::dsl::compile::compile_polydat_with_log(source, &mut log).unwrap();
821
822        let warnings: Vec<_> = log
823            .events()
824            .iter()
825            .filter(|e| {
826                matches!(
827                    e,
828                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
829                )
830            })
831            .collect();
832        assert_eq!(
833            warnings.len(),
834            1,
835            "cycle-time config should warn: {warnings:?}"
836        );
837    }
838
839    #[test]
840    fn dynamic_weighted_select_strict_rejects_cycle_config() {
841        // In strict mode, Config wire from cycle source is a hard error.
842        use crate::hash::Hash;
843        use polydat::compile::assembly::{PolydatAssembler, WireRef};
844        use polydat::dsl::events::CompileEventLog;
845        use polydat::library::convert::U64ToString;
846
847        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
848        asm.add_node(
849            "hashed",
850            Box::new(Hash::new()),
851            vec![WireRef::input("cycle")],
852        );
853        asm.add_node(
854            "spec",
855            Box::new(U64ToString::default()),
856            vec![WireRef::node("hashed")],
857        );
858        asm.add_node(
859            "dws",
860            Box::new(DynamicWeightedSelect::new()),
861            vec![
862                WireRef::node("hashed"), // selector ← cycle (Data, ok)
863                WireRef::node("spec"),   // weights_spec ← cycle (Config, BAD)
864            ],
865        );
866        asm.add_output("result", WireRef::node("dws"));
867
868        // Non-strict compile: should succeed with warning
869        let mut log = CompileEventLog::new();
870        let _kernel = asm.compile_with_log(Some(&mut log)).unwrap();
871        let warnings: Vec<_> = log
872            .events()
873            .iter()
874            .filter(|e| {
875                matches!(
876                    e,
877                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
878                )
879            })
880            .collect();
881        assert_eq!(warnings.len(), 1, "should warn in non-strict");
882
883        // Strict compile: rebuild and fold with strict=true
884        let mut asm2 = PolydatAssembler::new(vec!["cycle".into()]);
885        asm2.add_node(
886            "hashed",
887            Box::new(Hash::new()),
888            vec![WireRef::input("cycle")],
889        );
890        asm2.add_node(
891            "spec",
892            Box::new(U64ToString::default()),
893            vec![WireRef::node("hashed")],
894        );
895        asm2.add_node(
896            "dws",
897            Box::new(DynamicWeightedSelect::new()),
898            vec![WireRef::node("hashed"), WireRef::node("spec")],
899        );
900        asm2.add_output("result", WireRef::node("dws"));
901
902        asm2.set_strict(true);
903        let result = asm2.compile();
904        assert!(
905            result.is_err(),
906            "strict mode should reject cycle-time config wire"
907        );
908        let msg = format!("{}", result.unwrap_err());
909        assert!(
910            msg.contains("strict") || msg.contains("config"),
911            "error should mention strict or config: {msg}"
912        );
913    }
914
915    #[test]
916    fn weighted_pick_metadata_complete() {
917        // Spec-string surface: 1 wire input + 1 Const<&str> constant.
918        let node = WeightedPick::new("10:0.5;20:0.3".to_string());
919        let meta = node.meta();
920
921        // Name
922        assert_eq!(meta.name, "weighted_pick");
923
924        // Ins: 1 wire + 1 string constant (the spec)
925        assert_eq!(meta.ins.len(), 2);
926        assert!(matches!(meta.ins[0], Slot::Wire(_)));
927        assert!(matches!(
928            &meta.ins[1],
929            Slot::Const {
930                value: ConstValue::Str(_),
931                ..
932            }
933        ));
934
935        // Outs: 1 u64
936        assert_eq!(meta.outs.len(), 1);
937
938        // Wire inputs
939        assert_eq!(meta.wire_inputs().len(), 1);
940
941        // Const slots
942        let consts = meta.const_slots();
943        assert_eq!(consts.len(), 1); // spec
944    }
945}