Skip to main content

polydat_core/library/
convert.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Type conversion nodes.
5//!
6//! Two categories:
7//! - **Edge adapters** (prefixed `__`): auto-inserted by the assembly
8//!   phase for common lossless coercions. Users rarely reference these.
9//! - **Explicit conversions**: user-placed nodes for lossy, formatted,
10//!   or parameterized conversions. These require deliberate intent.
11
12/// Convert u64 to its decimal string representation.
13///
14/// Signature: `__u64_to_string(input: u64) -> (String)`
15///
16/// Edge adapter auto-inserted by the assembly phase when a u64 port
17/// feeds a String port. Users rarely reference this directly; prefer
18/// `format_u64` or `zero_pad_u64` when explicit formatting is wanted.
19///
20/// Lowered natively: the digits are written straight into the step's
21/// entry (`JitOp::U64ToStr`).
22// The edge adapter family. Underscore-prefixed names denote
23// assembly-phase auto-inserted bridges, not workload-callable
24// functions; the macro preserves the leading underscore via
25// the function's identifier.
26
27#[crate::polydat_node(category = Conversions)]
28fn __u64_to_string(input: u64) -> String {
29    input.to_string()
30}
31
32#[crate::polydat_node(category = Conversions)]
33fn __f64_to_string(input: f64) -> String {
34    input.to_string()
35}
36
37#[crate::polydat_node(category = Conversions)]
38fn __u64_to_f64(input: u64) -> f64 {
39    input as f64
40}
41
42#[crate::polydat_node(category = Conversions)]
43fn __bool_to_str(input: bool) -> String {
44    if input { "true".into() } else { "false".into() }
45}
46
47#[crate::polydat_node(category = Conversions)]
48fn __bool_to_u64(input: bool) -> u64 {
49    if input { 1 } else { 0 }
50}
51
52#[crate::polydat_node(category = Conversions)]
53fn __u64_to_bool(input: u64) -> bool {
54    input != 0
55}
56
57#[crate::polydat_node(category = Conversions)]
58fn __u32_to_u64(input: u32) -> u64 {
59    input as u64
60}
61
62// Totality fill: every u32 fits in i64 (lossless), so the widening
63// is class A — see type_system.md §3.3 / adapter_catalog_invariants.
64#[crate::polydat_node(category = Conversions)]
65fn __u32_to_i64(input: u32) -> i64 {
66    input as i64
67}
68
69#[crate::polydat_node(category = Conversions)]
70fn __i32_to_i64(input: i32) -> i64 {
71    input as i64
72}
73
74#[crate::polydat_node(category = Conversions)]
75fn __f32_to_f64(input: f32) -> f64 {
76    input as f64
77}
78
79#[crate::polydat_node(category = Conversions)]
80fn __i32_to_f64(input: i32) -> f64 {
81    input as f64
82}
83
84#[crate::polydat_node(category = Conversions)]
85fn __u32_to_f64(input: u32) -> f64 {
86    input as f64
87}
88
89#[crate::polydat_node(category = Conversions)]
90fn __i64_to_f64(input: i64) -> f64 {
91    input as f64
92}
93
94#[crate::polydat_node(category = Conversions)]
95fn __i32_to_string(input: i32) -> String {
96    input.to_string()
97}
98
99#[crate::polydat_node(category = Conversions)]
100fn __i64_to_string(input: i64) -> String {
101    input.to_string()
102}
103
104#[crate::polydat_node(category = Conversions)]
105fn __f32_to_string(input: f32) -> String {
106    input.to_string()
107}
108
109#[crate::polydat_node(category = Conversions)]
110fn __u32_to_string(input: u32) -> String {
111    input.to_string()
112}
113
114// =================================================================
115// Explicit narrowing casts (F64→U64) — workload-callable
116// =================================================================
117//
118// Narrowing is never automatic (scope_model.md §"Type stability"):
119// a shared cell keeps ONE type for life, and a lossy f64→u64
120// conversion changes semantics, so it must be the author's explicit
121// act. Both casts SATURATE — negative / NaN → 0, above u64::MAX →
122// u64::MAX — so a workload expression can never panic on range;
123// an out-of-range input is a workload-logic question, not a crash.
124
125/// Truncate an `f64` toward zero into a `u64` (saturating; NaN → 0).
126/// The explicit escape hatch for writing an f64 expression (e.g.
127/// `floor_decade(...)`) into a u64-typed cell or port.
128#[crate::polydat_node(category = Conversions)]
129fn trunc_u64(input: f64) -> u64 {
130    if input.is_nan() {
131        0
132    } else {
133        input.trunc().max(0.0).min(u64::MAX as f64) as u64
134    }
135}
136
137/// Round an `f64` half-away-from-zero into a `u64` (saturating;
138/// NaN → 0). Rounding twin of `trunc_u64`.
139#[crate::polydat_node(category = Conversions)]
140fn round_u64(input: f64) -> u64 {
141    if input.is_nan() {
142        0
143    } else {
144        input.round().max(0.0).min(u64::MAX as f64) as u64
145    }
146}
147
148// =================================================================
149// String parse adapters (Str→X) — workload-param polyfill
150// =================================================================
151//
152// Workload params arrive as strings (YAML string interpolation,
153// comma-split iter-values from `for X in {X_values}`, host-
154// supplied scope values via `set:`). These edge adapters heal
155// the Str → typed-slot boundary writes so the substrate's
156// `adapt_boundary_value` boundary check finds a catalog entry
157// instead of surfacing `WriteError::TypeMismatch`.
158//
159// Three adapters cover the workload-param flow (Bool, U64, F64
160// targets). Narrow-numeric parses (U32/I32/I64/F32) are
161// deferred — see polydat/docs/design/type_system.md §4.
162//
163// Each parser trims whitespace, then calls the standard library
164// `from_str` (or for Bool, recognises "true"/"false" case-
165// insensitive and "1"/"0"). Unparseable input panics with the
166// adapter name and the offending value; eval_node's
167// catch_unwind enrichment surfaces the panic with the node,
168// inputs, and source context.
169
170/// Shared diagnostic for the auto-inserted scalar string→number/bool
171/// coercions (`__str_to_u64`/`_f64`/`_bool`). They fire when a `str`-typed
172/// wire feeds a typed port — an op field bound to a number, a `set:`/`bindings:`
173/// value used numerically, and so on. When a *non-numeric* value reaches one of
174/// them, the overwhelmingly common cause is a NAME written where its VALUE was
175/// intended — most often a bare iteration variable in a scenario `set:` block,
176/// whose values are text-templates. Returns that guidance with a worked
177/// example so the message points at the fix, not just the failed parse.
178fn coercion_diagnostic(raw: &str, target: &str, detail: &str) -> String {
179    let braced = format!("{{{raw}}}"); // e.g. "mnc" -> "{mnc}"
180    format!(
181        "value {raw:?} is not {target}.\n\n\
182         This usually means a name was written where its VALUE was intended. In a \
183         scenario `set:` block, values are text-templates, so an iteration variable \
184         must be BRACED to substitute its value:\n    \
185         set: {{ field: \"{braced}\" }}    # the value of `{raw}`\n    \
186         set: {{ field: {raw} }}        # the literal text \"{raw}\"  <-- likely the bug\n\
187         In a `bindings:` block, reference names unquoted instead: `const field := {raw}`.\n\n\
188         (coercion detail: {detail})"
189    )
190}
191
192/// Convert string to bool.
193///
194/// Signature: `__str_to_bool(input: str) -> (bool)`
195///
196/// Edge adapter auto-inserted when a str port feeds a bool port.
197/// Recognises (case-insensitive) `true` / `false` / `1` / `0`
198/// after trimming surrounding whitespace. Any other input
199/// panics with a diagnostic.
200///
201/// The adapter's parse, shared with the native helper so a failure is
202/// the same diagnostic on every engine.
203pub(crate) fn parse_bool(input: &str) -> bool {
204    let raw = input.trim();
205    match raw.to_ascii_lowercase().as_str() {
206        "true" | "1" => true,
207        "false" | "0" => false,
208        _ => panic!(
209            "{}",
210            coercion_diagnostic(
211                raw,
212                "a boolean",
213                "__str_to_bool expected case-insensitive true/false or 1/0",
214            )
215        ),
216    }
217}
218
219#[crate::polydat_node(category = Conversions)]
220fn __str_to_bool(input: &str) -> bool {
221    parse_bool(input)
222}
223
224/// The adapter's parse, shared with the native helper so a failure is
225/// the same diagnostic on every engine.
226pub(crate) fn parse_u64(input: &str) -> u64 {
227    let raw = input.trim();
228    raw.parse::<u64>().unwrap_or_else(|e| {
229        panic!(
230            "{}",
231            coercion_diagnostic(raw, "a whole number", &format!("__str_to_u64: {e}"))
232        )
233    })
234}
235
236#[crate::polydat_node(category = Conversions)]
237fn __str_to_u64(input: &str) -> u64 {
238    parse_u64(input)
239}
240
241/// The adapter's parse, shared with the native helper so a failure is
242/// the same diagnostic on every engine.
243pub(crate) fn parse_f64(input: &str) -> f64 {
244    let raw = input.trim();
245    raw.parse::<f64>().unwrap_or_else(|e| {
246        panic!(
247            "{}",
248            coercion_diagnostic(raw, "a number", &format!("__str_to_f64: {e}"))
249        )
250    })
251}
252
253#[crate::polydat_node(category = Conversions)]
254fn __str_to_f64(input: &str) -> f64 {
255    parse_f64(input)
256}
257
258// =================================================================
259// Explicit conversions (user-placed, deliberate intent)
260// =================================================================
261
262/// Truncate f64 to u64 (floor toward zero). Lossy -- requires explicit use.
263///
264/// Signature: `f64_to_u64(input: f64) -> (u64)`
265///
266/// Explicit conversion that truncates the fractional part toward zero.
267/// Use after distribution sampling or lerp when you need a discrete
268/// integer result: `f64_to_u64(lerp(t, 0.0, 1000.0))`. For
269/// round-to-nearest, floor, or ceil semantics, use the dedicated
270/// `round_to_u64`, `floor_to_u64`, or `ceil_to_u64` nodes instead.
271///
272/// JIT level: P3 (native lowering, `JitOp::F64ToU64`).
273#[crate::polydat_node(category = Conversions)]
274fn f64_to_u64(input: f64) -> u64 {
275    input as u64
276}
277
278#[crate::polydat_node(category = Conversions)]
279fn round_to_u64(input: f64) -> u64 {
280    input.round() as u64
281}
282
283#[crate::polydat_node(category = Conversions)]
284fn floor_to_u64(input: f64) -> u64 {
285    input.floor() as u64
286}
287
288/// Ceiling f64 to u64 (round toward positive infinity).
289///
290/// Signature: `ceil_to_u64(input: f64) -> (u64)`
291///
292/// Always rounds up. Use when the discrete result must be at least as
293/// large as the continuous input, for example computing a minimum
294/// allocation size or page count from a byte length.
295///
296/// JIT level: P3 (native lowering, `JitOp::CeilToU64`).
297#[crate::polydat_node(category = Conversions)]
298fn ceil_to_u64(input: f64) -> u64 {
299    input.ceil() as u64
300}
301
302/// Discretize: bin a continuous f64 into N equal-width buckets.
303///
304/// Maps [0, range) to bucket indices [0, buckets). Values outside
305/// the range are clamped.
306///
307/// Signature: `discretize(input: f64, range: f64, buckets: u64) -> (u64)`
308///
309/// Use after a continuous distribution or interpolation to collapse
310/// values into categorical bins. Example: feed a normal distribution
311/// through `discretize(100.0, 10)` to get 10 histogram bins across
312/// [0, 100). Out-of-range inputs are clamped to the first or last
313/// bucket.
314///
315/// JIT level: P3 (compiled_u64 with jit_constants for range and buckets).
316///
317/// The range is positive and finite and there is at least one bucket,
318/// declared so the build refuses anything else on every engine, and
319/// the native form's `buckets - 1` never underflows. The body clamps to
320/// the range itself rather than to `range - f64::EPSILON`, an absolute
321/// epsilon that would make any range below it a "min > max" panic; the
322/// final `min` already sends an input at or past the range to the last
323/// bucket, so the bucket is the same everywhere.
324#[crate::polydat_node(category = Conversions)]
325fn discretize(
326    input: f64,
327    #[poly_default(100.0f64)]
328    #[constraint(PositiveFiniteF64)]
329    range: crate::derive_support::Const<f64>,
330    #[poly_default(10u64)]
331    #[constraint(NonZeroU64)]
332    buckets: crate::derive_support::Const<u64>,
333) -> u64 {
334    let r = *range;
335    let b = *buckets;
336    let v = input.clamp(0.0, r);
337    let bucket = (v / r * b as f64) as u64;
338    bucket.min(b - 1)
339}
340
341/// Format a u64 as a string with a specific radix (2, 8, 10, 16).
342///
343/// Signature: `format_u64(input: u64, radix: u32) -> (String)`
344///
345/// Explicit formatting node for producing human-readable or
346/// protocol-specific numeric strings. Includes standard prefixes:
347/// `0x` for hex, `0b` for binary, `0o` for octal; no prefix for
348/// decimal. Use `FormatU64::hex()` for addresses, `::binary()` for
349/// bitmask display, or `::decimal()` for plain numeric strings.
350///
351/// JIT level: P1 (String output; no compiled_u64 path).
352#[crate::polydat_node(category = Conversions)]
353fn format_u64(
354    input: u64,
355    #[poly_default(10u64)] radix: crate::derive_support::Const<u64>,
356) -> String {
357    match *radix {
358        2 => format!("0b{input:b}"),
359        8 => format!("0o{input:o}"),
360        16 => format!("0x{input:x}"),
361        _ => input.to_string(),
362    }
363}
364
365impl FormatU64 {
366    /// Base 10.
367    pub fn decimal() -> Self {
368        Self::new(10)
369    }
370    /// Base 16, with a `0x` prefix.
371    pub fn hex() -> Self {
372        Self::new(16)
373    }
374    /// Base 8, with a `0o` prefix.
375    pub fn octal() -> Self {
376        Self::new(8)
377    }
378    /// Base 2, with a `0b` prefix.
379    pub fn binary() -> Self {
380        Self::new(2)
381    }
382    /// The given radix; anything but 2, 8, or 16 formats as base 10.
383    pub fn with_radix(radix: u32) -> Self {
384        Self::new(radix as u64)
385    }
386}
387
388// `format_f64` and `zero_pad_u64` take `Const<u64>` const args, so
389// the macro generates `new(precision: u64)` / `new(width: u64)`, and
390// the tests below construct via `FormatF64::new(2)` and
391// `ZeroPadU64::new(8)`.
392
393/// Format an f64 with controlled decimal precision.
394///
395/// Signature: `format_f64(input: f64, precision: u64) -> (String)`
396#[crate::polydat_node(category = Conversions)]
397fn format_f64(
398    input: f64,
399    #[poly_default(2)] precision: crate::derive_support::Const<u64>,
400) -> String {
401    format!("{:.prec$}", input, prec = *precision as usize)
402}
403
404/// Zero-pad a u64 to a fixed width string.
405///
406/// Signature: `zero_pad_u64(input: u64, width: u64) -> (String)`
407#[crate::polydat_node(category = Conversions)]
408fn zero_pad_u64(
409    input: u64,
410    #[poly_default(10)] width: crate::derive_support::Const<u64>,
411) -> String {
412    format!("{:0>width$}", input, width = *width as usize)
413}
414
415/// Convert u64 integer value to f64.
416#[crate::polydat_node(category = Conversions)]
417fn to_f64(input: u64) -> f64 {
418    input as f64
419}
420
421/// `to_i64(n)` — the signed reading of a `u64`.
422///
423/// The named conversion for a pair the adapter catalog refuses to
424/// insert on its own: `u64` is not strictly narrower than `i64`, so a
425/// value above `i64::MAX` has no signed reading and the catalog will
426/// not heal the wire silently ([Type System](type_system.md) §3). That
427/// makes this the only way a program reaches an `i64` port, since an
428/// integer literal is a `u64` and there is no negative literal either
429/// — `-5` is unary negation, which is `f64`.
430///
431/// Above `i64::MAX` it fails by name rather than wrapping, the same
432/// rule the boundary adapter follows.
433#[crate::polydat_node(category = Conversions)]
434fn to_i64(input: u64) -> i64 {
435    if input > i64::MAX as u64 {
436        panic!("to_i64: value {input} exceeds i64::MAX ({})", i64::MAX);
437    }
438    input as i64
439}
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::ast::{PolydatNode, Value};
444
445    #[test]
446    fn f64_to_u64_truncates() {
447        let node = F64ToU64::new();
448        let mut out = [Value::None];
449        node.eval(&[Value::F64(3.7)], &mut out);
450        assert_eq!(out[0].as_u64(), 3);
451        node.eval(&[Value::F64(3.2)], &mut out);
452        assert_eq!(out[0].as_u64(), 3);
453    }
454
455    #[test]
456    fn round_to_u64_rounds() {
457        let node = RoundToU64::new();
458        let mut out = [Value::None];
459        node.eval(&[Value::F64(3.7)], &mut out);
460        assert_eq!(out[0].as_u64(), 4);
461        node.eval(&[Value::F64(3.2)], &mut out);
462        assert_eq!(out[0].as_u64(), 3);
463    }
464
465    #[test]
466    fn floor_to_u64_floors() {
467        let node = FloorToU64::new();
468        let mut out = [Value::None];
469        node.eval(&[Value::F64(3.9)], &mut out);
470        assert_eq!(out[0].as_u64(), 3);
471    }
472
473    #[test]
474    fn ceil_to_u64_ceils() {
475        let node = CeilToU64::new();
476        let mut out = [Value::None];
477        node.eval(&[Value::F64(3.1)], &mut out);
478        assert_eq!(out[0].as_u64(), 4);
479    }
480
481    #[test]
482    fn discretize_basic() {
483        let node = Discretize::new(100.0, 10);
484        let mut out = [Value::None];
485        node.eval(&[Value::F64(0.0)], &mut out);
486        assert_eq!(out[0].as_u64(), 0);
487        node.eval(&[Value::F64(55.0)], &mut out);
488        assert_eq!(out[0].as_u64(), 5);
489        node.eval(&[Value::F64(99.0)], &mut out);
490        assert_eq!(out[0].as_u64(), 9);
491    }
492
493    #[test]
494    fn discretize_clamps() {
495        let node = Discretize::new(100.0, 10);
496        let mut out = [Value::None];
497        node.eval(&[Value::F64(-5.0)], &mut out);
498        assert_eq!(out[0].as_u64(), 0);
499        node.eval(&[Value::F64(200.0)], &mut out);
500        assert_eq!(out[0].as_u64(), 9);
501    }
502
503    #[test]
504    fn format_u64_hex() {
505        let node = FormatU64::hex();
506        let mut out = [Value::None];
507        node.eval(&[Value::U64(255)], &mut out);
508        assert_eq!(out[0].as_str(), "0xff");
509    }
510
511    #[test]
512    fn format_u64_binary() {
513        let node = FormatU64::binary();
514        let mut out = [Value::None];
515        node.eval(&[Value::U64(42)], &mut out);
516        assert_eq!(out[0].as_str(), "0b101010");
517    }
518
519    #[test]
520    fn format_u64_decimal() {
521        let node = FormatU64::decimal();
522        let mut out = [Value::None];
523        node.eval(&[Value::U64(12345)], &mut out);
524        assert_eq!(out[0].as_str(), "12345");
525    }
526
527    #[test]
528    fn format_f64_precision() {
529        let node = FormatF64::new(2);
530        let mut out = [Value::None];
531        node.eval(&[Value::F64(3.14159)], &mut out);
532        assert_eq!(out[0].as_str(), "3.14");
533    }
534
535    #[test]
536    fn format_f64_zero_precision() {
537        let node = FormatF64::new(0);
538        let mut out = [Value::None];
539        node.eval(&[Value::F64(3.7)], &mut out);
540        assert_eq!(out[0].as_str(), "4");
541    }
542
543    #[test]
544    fn zero_pad() {
545        let node = ZeroPadU64::new(8);
546        let mut out = [Value::None];
547        node.eval(&[Value::U64(42)], &mut out);
548        assert_eq!(out[0].as_str(), "00000042");
549    }
550
551    #[test]
552    fn zero_pad_no_truncation() {
553        let node = ZeroPadU64::new(3);
554        let mut out = [Value::None];
555        node.eval(&[Value::U64(12345)], &mut out);
556        assert_eq!(out[0].as_str(), "12345");
557    }
558
559    // ---- Narrower type widening adapter tests ----
560
561    #[test]
562    fn u32_to_u64_zero_extends() {
563        let node = U32ToU64::new();
564        let mut out = [Value::None];
565        node.eval(&[Value::U64(42)], &mut out);
566        assert_eq!(out[0].as_u64(), 42);
567        // High bits are masked off
568        node.eval(&[Value::U64(0xFFFF_FFFF_0000_0001)], &mut out);
569        assert_eq!(out[0].as_u64(), 1);
570    }
571
572    #[test]
573    fn i32_to_i64_sign_extends() {
574        let node = I32ToI64::new();
575        let mut out = [Value::None];
576        // Positive value in the bit-stuffed input form, which the
577        // lenient Wire<i32> extract accepts.
578        node.eval(&[Value::U64(42)], &mut out);
579        assert_eq!(out[0], Value::I64(42));
580        // Negative i32, bit-stuffed (-1 as u32 = 0xFFFFFFFF):
581        // sign-extension must survive the lenient extract.
582        node.eval(&[Value::U64(0xFFFF_FFFF)], &mut out);
583        assert_eq!(out[0], Value::I64(-1));
584        // Honest signed carrier input round-trips unchanged.
585        node.eval(&[Value::I64(-1)], &mut out);
586        assert_eq!(out[0], Value::I64(-1));
587    }
588
589    #[test]
590    fn f32_to_f64_widens() {
591        let node = F32ToF64::new();
592        let mut out = [Value::None];
593        let f32_bits = 3.14f32.to_bits() as u64;
594        node.eval(&[Value::U64(f32_bits)], &mut out);
595        // f32 3.14 widened to f64 should be close to 3.14
596        let result = out[0].as_f64();
597        assert!((result - 3.14).abs() < 0.001, "got {result}");
598    }
599
600    #[test]
601    fn i32_to_f64_converts() {
602        let node = I32ToF64::new();
603        let mut out = [Value::None];
604        node.eval(&[Value::U64(42)], &mut out);
605        assert_eq!(out[0].as_f64(), 42.0);
606        // Negative: -10 as u32
607        node.eval(&[Value::U64((-10i32) as u32 as u64)], &mut out);
608        assert_eq!(out[0].as_f64(), -10.0);
609    }
610
611    #[test]
612    fn u32_to_f64_converts() {
613        let node = U32ToF64::new();
614        let mut out = [Value::None];
615        node.eval(&[Value::U64(1000)], &mut out);
616        assert_eq!(out[0].as_f64(), 1000.0);
617    }
618
619    #[test]
620    fn i64_to_f64_converts() {
621        let node = I64ToF64::new();
622        let mut out = [Value::None];
623        node.eval(&[Value::U64(42)], &mut out);
624        assert_eq!(out[0].as_f64(), 42.0);
625        // Negative: -1i64 as u64
626        node.eval(&[Value::U64((-1i64) as u64)], &mut out);
627        assert_eq!(out[0].as_f64(), -1.0);
628    }
629
630    // ---- Narrower to-string adapter tests ----
631
632    #[test]
633    fn i32_to_string_formats_signed() {
634        let node = I32ToString::new();
635        let mut out = [Value::None];
636        node.eval(&[Value::U64(42)], &mut out);
637        assert_eq!(out[0].as_str(), "42");
638        node.eval(&[Value::U64((-7i32) as u32 as u64)], &mut out);
639        assert_eq!(out[0].as_str(), "-7");
640    }
641
642    #[test]
643    fn i64_to_string_formats_signed() {
644        let node = I64ToString::new();
645        let mut out = [Value::None];
646        node.eval(&[Value::U64(100)], &mut out);
647        assert_eq!(out[0].as_str(), "100");
648        node.eval(&[Value::U64((-42i64) as u64)], &mut out);
649        assert_eq!(out[0].as_str(), "-42");
650    }
651
652    #[test]
653    fn f32_to_string_formats() {
654        let node = F32ToString::new();
655        let mut out = [Value::None];
656        let bits = 2.5f32.to_bits() as u64;
657        node.eval(&[Value::U64(bits)], &mut out);
658        assert_eq!(out[0].as_str(), "2.5");
659    }
660
661    #[test]
662    fn u32_to_string_formats() {
663        let node = U32ToString::new();
664        let mut out = [Value::None];
665        node.eval(&[Value::U64(12345)], &mut out);
666        assert_eq!(out[0].as_str(), "12345");
667    }
668
669    // -----------------------------------------------------------
670    // Str→X parse adapters (type_system.md §4)
671    // -----------------------------------------------------------
672
673    #[test]
674    fn str_to_bool_canonical_forms() {
675        let node = StrToBool::new();
676        let mut out = [Value::None];
677        for (input, expected) in [
678            ("true", true),
679            ("false", false),
680            ("True", true),
681            ("False", false),
682            ("TRUE", true),
683            ("FALSE", false),
684            ("1", true),
685            ("0", false),
686        ] {
687            node.eval(&[Value::Str(input.into())], &mut out);
688            assert_eq!(out[0].as_bool(), expected, "input={input:?}");
689        }
690    }
691
692    #[test]
693    fn str_to_bool_trims_whitespace() {
694        let node = StrToBool::new();
695        let mut out = [Value::None];
696        node.eval(&[Value::Str("  true  ".into())], &mut out);
697        assert!(out[0].as_bool());
698        node.eval(&[Value::Str("\tfalse\n".into())], &mut out);
699        assert!(!out[0].as_bool());
700    }
701
702    #[test]
703    #[should_panic(expected = "__str_to_bool")]
704    fn str_to_bool_panics_on_unparseable() {
705        let node = StrToBool::new();
706        let mut out = [Value::None];
707        node.eval(&[Value::Str("yes".into())], &mut out);
708    }
709
710    #[test]
711    fn str_to_u64_basic() {
712        let node = StrToU64::new();
713        let mut out = [Value::None];
714        node.eval(&[Value::Str("0".into())], &mut out);
715        assert_eq!(out[0].as_u64(), 0);
716        node.eval(&[Value::Str("42".into())], &mut out);
717        assert_eq!(out[0].as_u64(), 42);
718        node.eval(&[Value::Str("18446744073709551615".into())], &mut out);
719        assert_eq!(out[0].as_u64(), u64::MAX);
720    }
721
722    #[test]
723    fn str_to_u64_trims_whitespace() {
724        let node = StrToU64::new();
725        let mut out = [Value::None];
726        node.eval(&[Value::Str("  42  ".into())], &mut out);
727        assert_eq!(out[0].as_u64(), 42);
728    }
729
730    #[test]
731    #[should_panic(expected = "__str_to_u64")]
732    fn str_to_u64_panics_on_negative() {
733        let node = StrToU64::new();
734        let mut out = [Value::None];
735        node.eval(&[Value::Str("-1".into())], &mut out);
736    }
737
738    #[test]
739    #[should_panic(expected = "__str_to_u64")]
740    fn str_to_u64_panics_on_garbage() {
741        let node = StrToU64::new();
742        let mut out = [Value::None];
743        node.eval(&[Value::Str("abc".into())], &mut out);
744    }
745
746    #[test]
747    fn str_to_f64_basic() {
748        let node = StrToF64::new();
749        let mut out = [Value::None];
750        node.eval(&[Value::Str("0.0".into())], &mut out);
751        assert_eq!(out[0].as_f64(), 0.0);
752        node.eval(&[Value::Str("3.14".into())], &mut out);
753        assert!((out[0].as_f64() - 3.14).abs() < 1e-12);
754        node.eval(&[Value::Str("-2.5e3".into())], &mut out);
755        assert_eq!(out[0].as_f64(), -2500.0);
756        node.eval(&[Value::Str("inf".into())], &mut out);
757        assert!(out[0].as_f64().is_infinite());
758    }
759
760    #[test]
761    fn str_to_f64_trims_whitespace() {
762        let node = StrToF64::new();
763        let mut out = [Value::None];
764        node.eval(&[Value::Str("  1.5  ".into())], &mut out);
765        assert_eq!(out[0].as_f64(), 1.5);
766    }
767
768    #[test]
769    #[should_panic(expected = "__str_to_f64")]
770    fn str_to_f64_panics_on_garbage() {
771        let node = StrToF64::new();
772        let mut out = [Value::None];
773        node.eval(&[Value::Str("not-a-number".into())], &mut out);
774    }
775
776    /// The explicit narrowing casts saturate instead of panicking:
777    /// NaN / negatives → 0, above-range → u64::MAX, and the two
778    /// differ only in truncation vs rounding.
779    #[test]
780    fn narrowing_casts_saturate() {
781        let t = TruncU64::new();
782        let r = RoundU64::new();
783        let mut out = [Value::None];
784        t.eval(&[Value::F64(900.9)], &mut out);
785        assert_eq!(out[0].as_u64(), 900, "trunc drops the fraction");
786        r.eval(&[Value::F64(900.9)], &mut out);
787        assert_eq!(out[0].as_u64(), 901, "round goes to nearest");
788        t.eval(&[Value::F64(-5.0)], &mut out);
789        assert_eq!(out[0].as_u64(), 0, "negative saturates to 0");
790        r.eval(&[Value::F64(f64::NAN)], &mut out);
791        assert_eq!(out[0].as_u64(), 0, "NaN saturates to 0");
792        t.eval(&[Value::F64(f64::INFINITY)], &mut out);
793        assert_eq!(out[0].as_u64(), u64::MAX, "overflow saturates to MAX");
794    }
795}