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: P2 — `compiled_u64` is supplied by
257/// [`weighted_pick_jit`] (closure with captured alias-table
258/// arrays); `jit_constants` is supplied by
259/// [`weighted_pick_jit_constants`] (5-u64 slice for the
260/// `jit_weighted_pick` extern).
261///
262/// Example: `weighted_pick(hash(cycle), "100:0.5;200:0.3;300:0.2")`.
263/// `weighted_pick(input, "v0:w0;v1:w1;...")` is equivalent to
264/// `weighted_u64(input, "v0:w0;v1:w1;...")`. The two nodes share
265/// the same spec-string surface, so this decomposition is a
266/// direct re-instantiation under the canonical name. Wired into
267/// the macro via `#[polydat_node(decompose = ...)]` so the
268/// FusedNode impl is emitted alongside PolydatNode.
269fn weighted_pick_decompose(node: &WeightedPick) -> DecomposedGraph {
270    let spec: String = node
271        .state
272        .values
273        .iter()
274        .zip(node.state.weights.iter())
275        .map(|(v, w)| format!("{v}:{w}"))
276        .collect::<Vec<_>>()
277        .join(";");
278    let mut g = DecomposedGraph::new(1);
279    let wu = g.add_node(
280        Box::new(WeightedU64::new(spec)),
281        vec![DecomposedWire::Input(0)],
282    );
283    g.set_outputs(vec![DecomposedWire::Node(wu, 0)]);
284    g
285}
286
287#[polydat::polydat_node(
288    category = Weighted,
289    compiled_u64 = weighted_pick_jit,
290    jit_constants = weighted_pick_jit_constants,
291    decompose = weighted_pick_decompose,
292)]
293fn weighted_pick(
294    input: u64,
295    spec: polydat::derive_support::Const<&str>,
296    #[poly_const(parse_weighted_pick_spec, from = spec)] state: &WeightedPickState,
297) -> u64 {
298    let _ = spec; // value baked into `state` at construction
299    let idx = state.table.sample(input) as usize;
300    state.values[idx]
301}
302
303// ---------------------------------------------------------------------------
304// DynamicWeightedSelect — the spec arrives on a `Config<T>` wire. The
305// Wire trait's `WIRE_COST` const flows through `Config<Arc<str>>` to
306// the slot's `WireCost::Config` annotation, so the compiler warns
307// when the spec is bound to a cycle-time source. The last spec parsed
308// and its alias table are a memo in the evaluating kernel state's own
309// scratch (`ScratchElem::State`; compiled_handles.md §3): the node is
310// shared by every state of its program and holds nothing that
311// changes, so the memo lives beside the state's other storage, and a
312// clone of a state starts with an empty one. A spec that repeats is
313// not parsed again; a spec that changes is followed at once.
314// ---------------------------------------------------------------------------
315
316/// The memo [`DynamicWeightedSelect`] keeps per kernel state: the
317/// last spec it parsed, with the value list and alias table built
318/// from it. `table` is `None` when the spec named no values.
319#[derive(Default)]
320pub struct DynamicWeightedMemo {
321    spec: String,
322    values: Vec<String>,
323    table: Option<AliasTableU64>,
324    parsed: bool,
325}
326
327impl DynamicWeightedMemo {
328    /// The pick for `selector` under `spec`, parsing the spec only
329    /// when it differs from the last one parsed.
330    fn select(&mut self, spec: &str, selector: u64) -> &str {
331        if !self.parsed || self.spec != spec {
332            let (values, weights) = parse_weighted_str_spec(spec);
333            self.table = if values.is_empty() {
334                None
335            } else {
336                Some(AliasTableU64::from_weights(&weights))
337            };
338            self.values = values;
339            self.spec.clear();
340            self.spec.push_str(spec);
341            self.parsed = true;
342        }
343        match &self.table {
344            Some(table) => &self.values[table.sample(selector) as usize],
345            None => "",
346        }
347    }
348
349    /// The spec the memo holds, for tests.
350    #[cfg(test)]
351    fn spec(&self) -> Option<&str> {
352        self.parsed.then_some(self.spec.as_str())
353    }
354}
355
356/// The node's state module: one `State` entry holding the memo.
357pub(crate) mod dynamic_weighted_state {
358    use super::{DynamicWeightedMemo, DynamicWeightedSelect};
359    use polydat::ast::{ScratchBuf, ScratchElem, Value};
360
361    pub(crate) fn layout(_node: &DynamicWeightedSelect) -> Vec<ScratchElem> {
362        vec![ScratchElem::State]
363    }
364
365    pub(crate) fn eval(
366        _node: &DynamicWeightedSelect,
367        scratch: &mut [ScratchBuf],
368        inputs: &[Value],
369        outputs: &mut [Value],
370    ) {
371        let spec = inputs[1].to_display_string();
372        let memo = scratch[0]
373            .node_state()
374            .get_or_insert_with(DynamicWeightedMemo::default);
375        outputs[0] = Value::Str(memo.select(&spec, inputs[0].as_u64()).into());
376    }
377}
378
379/// The node's compiled form: the selector from its slot, the spec
380/// through its pair, the memo in the state's `State` entry, the pick
381/// written into the step's own string entry.
382fn dynamic_weighted_compiled(
383    _node: &DynamicWeightedSelect,
384    wire_types: &[polydat::ast::PortType],
385) -> polydat::ast::CompiledSlotKit {
386    use polydat::ast::{PortType, ScratchBuf, ScratchElem};
387    let spec_ty = wire_types.get(1).copied().unwrap_or(PortType::Str);
388    polydat::ast::CompiledSlotKit {
389        scratch: vec![ScratchElem::State, ScratchElem::Str],
390        op: Box::new(
391            move |inputs: &[u64], outputs: &mut [u64], scratch: &mut [ScratchBuf]| {
392                let selector = inputs[0];
393                let spec_value;
394                // SAFETY: the pair was published by the producing step
395                // into storage alive until it reruns (axioms S3, S4).
396                let spec: &str =
397                    match unsafe { polydat::compile::marshal::arg_ref(spec_ty, &inputs[1..]) } {
398                        polydat::ast::ValueRef::Str(s) => s,
399                        other => {
400                            spec_value = other.to_display_string();
401                            &spec_value
402                        }
403                    };
404                let (state, out) = scratch.split_at_mut(1);
405                let memo = state[0]
406                    .node_state()
407                    .get_or_insert_with(DynamicWeightedMemo::default);
408                out[0].set_str(memo.select(spec, selector));
409                let (ptr, len) = out[0].ptr_len();
410                outputs[0] = ptr;
411                outputs[1] = len;
412            },
413        ),
414    }
415}
416
417/// Dynamic weighted selection where the weight spec is a wire input.
418///
419/// Signature: `dynamic_weighted_select(selector: u64, weights_spec: Str) -> Str`
420///
421/// Unlike `WeightedStrings` (which parses weights at init time and
422/// builds the alias table once), this node accepts the weight spec
423/// as a runtime wire input. The `weights_spec` input is wrapped in
424/// `Config<Arc<str>>` to mark it as a configuration-cost wire: the
425/// compiler warns when it is bound to a cycle-time source.
426///
427/// The last parsed spec and its alias table are memoized in the
428/// evaluating kernel state's own scratch, so repeated evaluations with
429/// the same spec do no parsing and a changed spec is followed at once.
430///
431/// Typical use: wire `weights_spec` to an init-time constant or a
432/// rarely-changing captured value. Wire `selector` to a per-cycle
433/// hash for O(1) lookup once the alias table is built.
434///
435/// Spec format: `"alpha:0.3;beta:0.5;gamma:0.2"`
436#[polydat::polydat_node(
437    category = Weighted,
438    compiled_slot = dynamic_weighted_compiled,
439    state = dynamic_weighted_state
440)]
441fn dynamic_weighted_select(selector: u64, weights_spec: Config<std::sync::Arc<str>>) -> String {
442    // The stateless evaluation (a node evaluated on its own, with no
443    // kernel state to keep a memo in): parse and pick.
444    DynamicWeightedMemo::default()
445        .select(weights_spec.0.as_ref(), selector)
446        .to_string()
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use polydat::ast::{ConstValue, PolydatNode, Slot, Value};
453    use polydat::compile::fusion::FusedNode;
454    use xxhash_rust::xxh3::xxh3_64;
455
456    #[test]
457    fn weighted_strings_valid_outputs() {
458        let node = WeightedStrings::new("alpha:0.3;beta:0.5;gamma:0.2".to_string());
459        let valid = ["alpha", "beta", "gamma"];
460        let mut out = [Value::None];
461        for i in 0..1000u64 {
462            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
463            assert!(valid.contains(&out[0].as_str()));
464        }
465    }
466
467    #[test]
468    fn weighted_strings_respects_weights() {
469        let node = WeightedStrings::new("rare:0.01;common:0.99".to_string());
470        let mut common_count = 0u64;
471        let mut out = [Value::None];
472        let n = 10_000u64;
473        for i in 0..n {
474            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
475            if out[0].as_str() == "common" {
476                common_count += 1;
477            }
478        }
479        let ratio = common_count as f64 / n as f64;
480        assert!(ratio > 0.90, "common should dominate, got {ratio}");
481    }
482
483    #[test]
484    fn weighted_u64_valid_outputs() {
485        let node = WeightedU64::new("10:0.5;20:0.3;30:0.2".to_string());
486        let valid = [10u64, 20, 30];
487        let mut out = [Value::None];
488        for i in 0..1000u64 {
489            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
490            assert!(valid.contains(&out[0].as_u64()));
491        }
492    }
493
494    // --- WeightedPick tests ---
495
496    #[test]
497    fn weighted_pick_valid_outputs() {
498        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
499        let valid = [10u64, 20, 30];
500        let mut out = [Value::None];
501        for i in 0..1000u64 {
502            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
503            assert!(
504                valid.contains(&out[0].as_u64()),
505                "unexpected output {} at seed {i}",
506                out[0].as_u64()
507            );
508        }
509    }
510
511    #[test]
512    fn weighted_pick_respects_weights() {
513        let node = WeightedPick::new("1:0.99;2:0.01".to_string());
514        let mut count_1 = 0u64;
515        let mut out = [Value::None];
516        let n = 10_000u64;
517        for i in 0..n {
518            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
519            if out[0].as_u64() == 1 {
520                count_1 += 1;
521            }
522        }
523        let ratio = count_1 as f64 / n as f64;
524        assert!(
525            ratio > 0.90,
526            "value 1 (weight 0.99) should dominate, got {ratio}"
527        );
528    }
529
530    #[test]
531    fn weighted_pick_single_pair() {
532        let node = WeightedPick::new("42:1.0".to_string());
533        let mut out = [Value::None];
534        for i in 0..100u64 {
535            node.eval(&[Value::U64(i)], &mut out);
536            assert_eq!(out[0].as_u64(), 42);
537        }
538    }
539
540    #[test]
541    fn weighted_pick_equal_weights() {
542        let node = WeightedPick::new("10:1.0;20:1.0;30:1.0".to_string());
543        let mut counts = [0u64; 3];
544        let mut out = [Value::None];
545        let n = 30_000u64;
546        for i in 0..n {
547            node.eval(&[Value::U64(xxh3_64(&i.to_le_bytes()))], &mut out);
548            match out[0].as_u64() {
549                10 => counts[0] += 1,
550                20 => counts[1] += 1,
551                30 => counts[2] += 1,
552                v => panic!("unexpected value {v}"),
553            }
554        }
555        // Each should be roughly 1/3
556        for (i, c) in counts.iter().enumerate() {
557            let ratio = *c as f64 / n as f64;
558            assert!(
559                ratio > 0.25 && ratio < 0.42,
560                "value at index {i} has ratio {ratio}, expected ~0.33"
561            );
562        }
563    }
564
565    #[test]
566    fn weighted_pick_compiled_matches_eval() {
567        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
568        let compiled = node.compiled_u64().expect("should compile");
569        for i in 0..10_000u64 {
570            let input = xxh3_64(&i.to_le_bytes());
571            let mut eval_out = [Value::None];
572            node.eval(&[Value::U64(input)], &mut eval_out);
573            let mut compiled_out = [0u64];
574            compiled(&[input], &mut compiled_out);
575            assert_eq!(
576                eval_out[0].as_u64(),
577                compiled_out[0],
578                "eval vs compiled mismatch at seed {i}"
579            );
580        }
581    }
582
583    #[test]
584    fn weighted_pick_jit_constants_shape() {
585        // The 5-u64 jit_constants slice (values_ptr, biases_ptr,
586        // primaries_ptr, aliases_ptr, n) is the contract the
587        // `jit_weighted_pick` extern reads.
588        let node = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
589
590        let raw = node.jit_constants();
591        assert_eq!(raw.len(), 5); // values_ptr, biases_ptr, primaries_ptr, aliases_ptr, n
592        assert_eq!(raw[4], 3); // n = 3 entries in the spec
593
594        // The values_ptr should match the parsed value list inside state.
595        assert_eq!(raw[0], node.state.values.as_ptr() as u64);
596        assert_eq!(raw[1], node.state.table.biases().as_ptr() as u64);
597        assert_eq!(raw[2], node.state.table.primaries().as_ptr() as u64);
598        assert_eq!(raw[3], node.state.table.aliases().as_ptr() as u64);
599    }
600
601    #[test]
602    fn weighted_pick_equivalence_with_weighted_u64() {
603        // weighted_pick(input, "10:0.5;20:0.3;30:0.2") should match
604        // weighted_u64(input, "10:0.5;20:0.3;30:0.2") — the two
605        // nodes now share the same spec-string surface.
606        let fused = WeightedPick::new("10:0.5;20:0.3;30:0.2".to_string());
607        let decomposed = fused.decomposed();
608        for i in 0..10_000u64 {
609            let input = xxh3_64(&i.to_le_bytes());
610            let mut fused_out = [Value::None];
611            fused.eval(&[Value::U64(input)], &mut fused_out);
612            let decomposed_out = decomposed.eval(&[Value::U64(input)]);
613            assert_eq!(
614                fused_out[0].as_u64(),
615                decomposed_out[0].as_u64(),
616                "equivalence failed at seed {i}"
617            );
618        }
619    }
620
621    #[test]
622    #[should_panic(expected = "weighted_pick requires at least one entry in spec")]
623    fn weighted_pick_rejects_empty_spec() {
624        // Macro-emitted `new` runs the setup parser eagerly; empty
625        // spec panics at construction.
626        let _ = WeightedPick::new("".to_string());
627    }
628
629    #[test]
630    #[should_panic(expected = "weighted_pick: malformed entry")]
631    fn weighted_pick_rejects_bad_format() {
632        let _ = WeightedPick::new("noweight".to_string());
633    }
634
635    #[test]
636    #[should_panic(expected = "weighted_pick: weight must be a positive finite f64")]
637    fn weighted_pick_rejects_nonpositive_weight() {
638        let _ = WeightedPick::new("10:0.0;20:1.0".to_string());
639    }
640
641    // --- DynamicWeightedSelect tests ---
642
643    #[test]
644    fn dynamic_weighted_select_basic() {
645        let node = DynamicWeightedSelect::new();
646        let spec = "alpha:0.3;beta:0.5;gamma:0.2";
647        let valid = ["alpha", "beta", "gamma"];
648        let mut out = [Value::None];
649        for i in 0..100u64 {
650            node.eval(
651                &[
652                    Value::U64(xxh3_64(&i.to_le_bytes())),
653                    Value::Str(spec.into()),
654                ],
655                &mut out,
656            );
657            assert!(
658                valid.contains(&out[0].as_str()),
659                "unexpected: {}",
660                out[0].as_str()
661            );
662        }
663    }
664
665    #[test]
666    fn dynamic_weighted_select_follows_its_spec() {
667        let node = DynamicWeightedSelect::new();
668        let spec = "a:0.5;b:0.5";
669        let mut out = [Value::None];
670        node.eval(&[Value::U64(42), Value::Str(spec.into())], &mut out);
671        let first = out[0].as_str().to_string();
672        // The same spec and selector give the same pick.
673        node.eval(&[Value::U64(42), Value::Str(spec.into())], &mut out);
674        assert_eq!(out[0].as_str(), first);
675        // A different spec is followed at once.
676        node.eval(&[Value::U64(42), Value::Str("x:1.0".into())], &mut out);
677        assert_eq!(out[0].as_str(), "x");
678    }
679
680    /// The memo lives in the evaluating state's scratch: a repeated
681    /// spec is not parsed again, a changed one replaces the memo, and
682    /// a clone of the state starts with an empty entry.
683    #[test]
684    fn dynamic_weighted_select_memoizes_in_the_state() {
685        use polydat::ast::ScratchBuf;
686        let node = DynamicWeightedSelect::new();
687        let mut scratch: Vec<ScratchBuf> = node
688            .scratch_layout()
689            .iter()
690            .map(|e| ScratchBuf::new(*e))
691            .collect();
692        assert_eq!(scratch.len(), 1);
693        let mut out = [Value::None];
694        node.eval_in(
695            &mut scratch,
696            &[Value::U64(42), Value::Str("a:0.5;b:0.5".into())],
697            &mut out,
698        );
699        let first = out[0].as_str().to_string();
700        let memo = scratch[0]
701            .node_state()
702            .get::<DynamicWeightedMemo>()
703            .expect("filled on the first evaluation");
704        assert_eq!(memo.spec(), Some("a:0.5;b:0.5"));
705        let table_before = memo.table.as_ref().map(|t| t as *const AliasTableU64);
706        node.eval_in(
707            &mut scratch,
708            &[Value::U64(42), Value::Str("a:0.5;b:0.5".into())],
709            &mut out,
710        );
711        assert_eq!(out[0].as_str(), first);
712        let memo = scratch[0]
713            .node_state()
714            .get::<DynamicWeightedMemo>()
715            .unwrap();
716        assert_eq!(
717            memo.table.as_ref().map(|t| t as *const AliasTableU64),
718            table_before,
719            "the same spec keeps the table it built"
720        );
721        node.eval_in(
722            &mut scratch,
723            &[Value::U64(42), Value::Str("x:1.0".into())],
724            &mut out,
725        );
726        assert_eq!(out[0].as_str(), "x");
727        let memo = scratch[0]
728            .node_state()
729            .get::<DynamicWeightedMemo>()
730            .unwrap();
731        assert_eq!(memo.spec(), Some("x:1.0"));
732        let cloned = scratch[0].clone();
733        let mut cloned = cloned;
734        assert!(
735            cloned.node_state().get::<DynamicWeightedMemo>().is_none(),
736            "a clone of the state starts with an empty memo"
737        );
738    }
739
740    /// The compiled form keeps its memo in the kernel state's scratch
741    /// too, and agrees with the interpreter across a changing spec.
742    #[test]
743    fn dynamic_weighted_select_compiled_form_memoizes_and_agrees() {
744        use polydat::ast::{PortType, ScratchBuf};
745        let node = DynamicWeightedSelect::new();
746        let kit = dynamic_weighted_compiled(&node, &[PortType::U64, PortType::Str]);
747        let mut scratch: Vec<ScratchBuf> =
748            kit.scratch.iter().map(|e| ScratchBuf::new(*e)).collect();
749        let mut outputs = [0u64; 2];
750        for (spec, selector) in [("a:0.5;b:0.5", 42u64), ("a:0.5;b:0.5", 7), ("z:1.0", 3)] {
751            let inputs = [selector, spec.as_ptr() as usize as u64, spec.len() as u64];
752            (kit.op)(&inputs, &mut outputs, &mut scratch);
753            let got = scratch[1].to_value();
754            let mut want = [Value::None];
755            node.eval(&[Value::U64(selector), Value::Str(spec.into())], &mut want);
756            assert_eq!(
757                got.as_str(),
758                want[0].as_str(),
759                "spec {spec}, selector {selector}"
760            );
761            assert_eq!((outputs[0], outputs[1]), scratch[1].ptr_len());
762            assert_eq!(
763                scratch[0]
764                    .node_state()
765                    .get::<DynamicWeightedMemo>()
766                    .and_then(|m| m.spec()),
767                Some(spec)
768            );
769        }
770    }
771
772    #[test]
773    fn dynamic_weighted_select_config_wire_annotation() {
774        let node = DynamicWeightedSelect::new();
775        let meta = node.meta();
776        // Second input (weights_spec) should be marked Config
777        let wire_inputs = meta.wire_inputs();
778        assert_eq!(wire_inputs.len(), 2);
779        assert_eq!(wire_inputs[0].wire_cost, polydat::ast::WireCost::Data);
780        assert_eq!(wire_inputs[1].wire_cost, polydat::ast::WireCost::Config);
781    }
782
783    #[test]
784    fn dynamic_weighted_select_e2e_init_config() {
785        // Init-time config wire: no warning expected
786        use polydat::dsl::events::CompileEventLog;
787
788        let source = r#"
789            input cycle: u64
790            const spec := "alpha:0.3;beta:0.7"
791            result := dynamic_weighted_select(hash(cycle), spec)
792        "#;
793        let mut log = CompileEventLog::new();
794        let _k = polydat::dsl::compile::compile_polydat_with_log(source, &mut log).unwrap();
795
796        let warnings: Vec<_> = log
797            .events()
798            .iter()
799            .filter(|e| {
800                matches!(
801                    e,
802                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
803                )
804            })
805            .collect();
806        assert!(warnings.is_empty(), "init-time config should not warn");
807    }
808
809    #[test]
810    fn dynamic_weighted_select_e2e_cycle_config_warns() {
811        // Cycle-time config wire: should warn
812        use polydat::dsl::events::CompileEventLog;
813
814        // Spec derived from cycle → cycle-time → config wire warning
815        let source = r#"
816            input cycle: u64
817            spec := format_u64(hash(cycle), 10)
818            result := dynamic_weighted_select(hash(cycle), spec)
819        "#;
820        let mut log = CompileEventLog::new();
821        let _k = polydat::dsl::compile::compile_polydat_with_log(source, &mut log).unwrap();
822
823        let warnings: Vec<_> = log
824            .events()
825            .iter()
826            .filter(|e| {
827                matches!(
828                    e,
829                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
830                )
831            })
832            .collect();
833        assert_eq!(
834            warnings.len(),
835            1,
836            "cycle-time config should warn: {warnings:?}"
837        );
838    }
839
840    #[test]
841    fn dynamic_weighted_select_strict_rejects_cycle_config() {
842        // In strict mode, Config wire from cycle source is a hard error.
843        use crate::hash::Hash;
844        use polydat::compile::assembly::{PolydatAssembler, WireRef};
845        use polydat::dsl::events::CompileEventLog;
846        use polydat::library::convert::U64ToString;
847
848        let mut asm = PolydatAssembler::new(vec!["cycle".into()]);
849        asm.add_node(
850            "hashed",
851            Box::new(Hash::new()),
852            vec![WireRef::input("cycle")],
853        );
854        asm.add_node(
855            "spec",
856            Box::new(U64ToString::default()),
857            vec![WireRef::node("hashed")],
858        );
859        asm.add_node(
860            "dws",
861            Box::new(DynamicWeightedSelect::new()),
862            vec![
863                WireRef::node("hashed"), // selector ← cycle (Data, ok)
864                WireRef::node("spec"),   // weights_spec ← cycle (Config, BAD)
865            ],
866        );
867        asm.add_output("result", WireRef::node("dws"));
868
869        // Non-strict compile: should succeed with warning
870        let mut log = CompileEventLog::new();
871        let _kernel = asm.compile_with_log(Some(&mut log)).unwrap();
872        let warnings: Vec<_> = log
873            .events()
874            .iter()
875            .filter(|e| {
876                matches!(
877                    e,
878                    polydat::dsl::events::CompileEvent::ConfigWireCycleWarning { .. }
879                )
880            })
881            .collect();
882        assert_eq!(warnings.len(), 1, "should warn in non-strict");
883
884        // Strict compile: rebuild and fold with strict=true
885        let mut asm2 = PolydatAssembler::new(vec!["cycle".into()]);
886        asm2.add_node(
887            "hashed",
888            Box::new(Hash::new()),
889            vec![WireRef::input("cycle")],
890        );
891        asm2.add_node(
892            "spec",
893            Box::new(U64ToString::default()),
894            vec![WireRef::node("hashed")],
895        );
896        asm2.add_node(
897            "dws",
898            Box::new(DynamicWeightedSelect::new()),
899            vec![WireRef::node("hashed"), WireRef::node("spec")],
900        );
901        asm2.add_output("result", WireRef::node("dws"));
902
903        asm2.set_strict(true);
904        let result = asm2.compile();
905        assert!(
906            result.is_err(),
907            "strict mode should reject cycle-time config wire"
908        );
909        let msg = format!("{}", result.unwrap_err());
910        assert!(
911            msg.contains("strict") || msg.contains("config"),
912            "error should mention strict or config: {msg}"
913        );
914    }
915
916    #[test]
917    fn weighted_pick_metadata_complete() {
918        // Spec-string surface: 1 wire input + 1 Const<&str> constant.
919        let node = WeightedPick::new("10:0.5;20:0.3".to_string());
920        let meta = node.meta();
921
922        // Name
923        assert_eq!(meta.name, "weighted_pick");
924
925        // Ins: 1 wire + 1 string constant (the spec)
926        assert_eq!(meta.ins.len(), 2);
927        assert!(matches!(meta.ins[0], Slot::Wire(_)));
928        assert!(matches!(
929            &meta.ins[1],
930            Slot::Const {
931                value: ConstValue::Str(_),
932                ..
933            }
934        ));
935
936        // Outs: 1 u64
937        assert_eq!(meta.outs.len(), 1);
938
939        // Wire inputs
940        assert_eq!(meta.wire_inputs().len(), 1);
941
942        // Const slots
943        let consts = meta.const_slots();
944        assert_eq!(consts.len(), 1); // spec
945    }
946}