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