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/// JIT level: P1 (String output; no compiled_u64 path).
21// SRD-80 PR B.14 — edge adapter family migrated to
22// `#[polydat_node]`. 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/// JIT level: P1 (Str input; no compiled_u64 path).
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: P2 (compiled_u64 via f64::from_bits truncation).
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: P2 (compiled_u64 via f64::from_bits + ceil).
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#[crate::polydat_node(category = Conversions)]
318fn discretize(
319    input: f64,
320    #[poly_default(100.0f64)] range: crate::derive_support::Const<f64>,
321    #[poly_default(10u64)] buckets: crate::derive_support::Const<u64>,
322) -> u64 {
323    let r = *range;
324    let b = *buckets;
325    let v = input.clamp(0.0, r - f64::EPSILON);
326    let bucket = (v / r * b as f64) as u64;
327    bucket.min(b.saturating_sub(1))
328}
329
330/// Format a u64 as a string with a specific radix (2, 8, 10, 16).
331///
332/// Signature: `format_u64(input: u64, radix: u32) -> (String)`
333///
334/// Explicit formatting node for producing human-readable or
335/// protocol-specific numeric strings. Includes standard prefixes:
336/// `0x` for hex, `0b` for binary, `0o` for octal; no prefix for
337/// decimal. Use `FormatU64::hex()` for addresses, `::binary()` for
338/// bitmask display, or `::decimal()` for plain numeric strings.
339///
340/// JIT level: P1 (String output; no compiled_u64 path).
341#[crate::polydat_node(category = Conversions)]
342fn format_u64(
343    input: u64,
344    #[poly_default(10u64)] radix: crate::derive_support::Const<u64>,
345) -> String {
346    match *radix {
347        2 => format!("0b{input:b}"),
348        8 => format!("0o{input:o}"),
349        16 => format!("0x{input:x}"),
350        _ => input.to_string(),
351    }
352}
353
354impl FormatU64 {
355    /// Base 10.
356    pub fn decimal() -> Self {
357        Self::new(10)
358    }
359    /// Base 16, with a `0x` prefix.
360    pub fn hex() -> Self {
361        Self::new(16)
362    }
363    /// Base 8, with a `0o` prefix.
364    pub fn octal() -> Self {
365        Self::new(8)
366    }
367    /// Base 2, with a `0b` prefix.
368    pub fn binary() -> Self {
369        Self::new(2)
370    }
371    /// The given radix; anything but 2, 8, or 16 formats as base 10.
372    pub fn with_radix(radix: u32) -> Self {
373        Self::new(radix as u64)
374    }
375}
376
377// SRD-80 PR B.5 — `format_f64` and `zero_pad_u64` migrated to
378// the `#[polydat_node]` derive with `Const<u64>` const args.
379// Tests below construct via `FormatF64::new(2)` and
380// `ZeroPadU64::new(8)` — both work since the macro generates
381// `new(precision: u64)` / `new(width: u64)` and integer
382// literals coerce to u64. The historic `usize` parameter type
383// is now `u64` end-to-end (operator-visible API change in the
384// struct's `new()` signature, but the only call sites are this
385// module's own tests).
386
387/// Format an f64 with controlled decimal precision.
388///
389/// Signature: `format_f64(input: f64, precision: u64) -> (String)`
390#[crate::polydat_node(category = Conversions)]
391fn format_f64(
392    input: f64,
393    #[poly_default(2)] precision: crate::derive_support::Const<u64>,
394) -> String {
395    format!("{:.prec$}", input, prec = *precision as usize)
396}
397
398/// Zero-pad a u64 to a fixed width string.
399///
400/// Signature: `zero_pad_u64(input: u64, width: u64) -> (String)`
401#[crate::polydat_node(category = Conversions)]
402fn zero_pad_u64(
403    input: u64,
404    #[poly_default(10)] width: crate::derive_support::Const<u64>,
405) -> String {
406    format!("{:0>width$}", input, width = *width as usize)
407}
408
409// ---------------------------------------------------------------------------
410// Signature declarations for the DSL registry
411// ---------------------------------------------------------------------------
412
413use crate::dsl::registry::{FuncCategory, FuncSig};
414
415/// Signatures for type conversion nodes.
416pub fn signatures() -> &'static [FuncSig] {
417    #[allow(unused_imports)]
418    use FuncCategory as C;
419    &[
420        // `unit_interval` / `clamp_f64` migrated to `#[polydat_node]`
421        // per SRD-80b Phase E (library/sampling/icd.rs). The macro
422        // emits both FuncSig (via inventory) and the matching
423        // builder, so this signatures() list no longer carries them
424        // and the corresponding `build_node` arms are gone.
425        // `to_f64` / `f64_to_u64` / `round_to_u64` / `floor_to_u64` /
426        // `ceil_to_u64` / `discretize` / `format_u64` migrated to
427        // `#[polydat_node]` per SRD-80 PR B.14.
428        // `format_f64` / `zero_pad_u64` migrated to `#[polydat_node]`
429        // per SRD-80 PR B.5 — FuncSigs registered via macro-emitted
430        // NodeRegistration.
431    ]
432}
433
434/// Convert u64 integer value to f64. SRD-80 PR B.14 migration.
435#[crate::polydat_node(category = Conversions)]
436fn to_f64(input: u64) -> f64 {
437    input as f64
438}
439
440/// Try to build a conversion node from a function name and const args.
441///
442/// Returns `None` if the name is not handled by this module.
443pub(crate) fn build_node(
444    _name: &str,
445    _wires: &[crate::compile::assembly::WireRef],
446    _wire_types: &[crate::ast::PortType],
447    _consts: &[crate::dsl::factory::ConstArg],
448) -> Option<Result<Box<dyn crate::ast::PolydatNode>, String>> {
449    // `unit_interval` / `clamp_f64` route via macro-emitted
450    // NodeRegistration per SRD-80b Phase E (sampling/icd.rs).
451    // `to_f64` / `f64_to_u64` / `round_to_u64` / `floor_to_u64` /
452    // `ceil_to_u64` / `discretize` / `format_u64` route via
453    // proc-macro NodeRegistration per SRD-80 PR B.14.
454    // `format_f64` / `zero_pad_u64` route through proc-macro-emitted
455    // NodeRegistration per SRD-80 PR B.5.
456    None
457}
458
459/// Assembly-time constant validation. See SRD 15 §"Const Constraint Metadata".
460///
461/// `format_u64.radix` (`AllowedU64{2,8,10,16}`) and
462/// `discretize.range` / `.buckets` (`NonZeroU64`) ride on
463/// `ParamSpec.constraint`; Pass 1 enforces them and there's
464/// nothing relational left for this validator to do.
465pub(crate) fn validate_node(
466    _name: &str,
467    _consts: &[crate::dsl::factory::ConstArg],
468) -> Result<(), String> {
469    Ok(())
470}
471
472crate::register_nodes!(signatures, build_node, validate_node);
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::ast::{PolydatNode, Value};
477
478    #[test]
479    fn f64_to_u64_truncates() {
480        let node = F64ToU64::new();
481        let mut out = [Value::None];
482        node.eval(&[Value::F64(3.7)], &mut out);
483        assert_eq!(out[0].as_u64(), 3);
484        node.eval(&[Value::F64(3.2)], &mut out);
485        assert_eq!(out[0].as_u64(), 3);
486    }
487
488    #[test]
489    fn round_to_u64_rounds() {
490        let node = RoundToU64::new();
491        let mut out = [Value::None];
492        node.eval(&[Value::F64(3.7)], &mut out);
493        assert_eq!(out[0].as_u64(), 4);
494        node.eval(&[Value::F64(3.2)], &mut out);
495        assert_eq!(out[0].as_u64(), 3);
496    }
497
498    #[test]
499    fn floor_to_u64_floors() {
500        let node = FloorToU64::new();
501        let mut out = [Value::None];
502        node.eval(&[Value::F64(3.9)], &mut out);
503        assert_eq!(out[0].as_u64(), 3);
504    }
505
506    #[test]
507    fn ceil_to_u64_ceils() {
508        let node = CeilToU64::new();
509        let mut out = [Value::None];
510        node.eval(&[Value::F64(3.1)], &mut out);
511        assert_eq!(out[0].as_u64(), 4);
512    }
513
514    #[test]
515    fn discretize_basic() {
516        let node = Discretize::new(100.0, 10);
517        let mut out = [Value::None];
518        node.eval(&[Value::F64(0.0)], &mut out);
519        assert_eq!(out[0].as_u64(), 0);
520        node.eval(&[Value::F64(55.0)], &mut out);
521        assert_eq!(out[0].as_u64(), 5);
522        node.eval(&[Value::F64(99.0)], &mut out);
523        assert_eq!(out[0].as_u64(), 9);
524    }
525
526    #[test]
527    fn discretize_clamps() {
528        let node = Discretize::new(100.0, 10);
529        let mut out = [Value::None];
530        node.eval(&[Value::F64(-5.0)], &mut out);
531        assert_eq!(out[0].as_u64(), 0);
532        node.eval(&[Value::F64(200.0)], &mut out);
533        assert_eq!(out[0].as_u64(), 9);
534    }
535
536    #[test]
537    fn format_u64_hex() {
538        let node = FormatU64::hex();
539        let mut out = [Value::None];
540        node.eval(&[Value::U64(255)], &mut out);
541        assert_eq!(out[0].as_str(), "0xff");
542    }
543
544    #[test]
545    fn format_u64_binary() {
546        let node = FormatU64::binary();
547        let mut out = [Value::None];
548        node.eval(&[Value::U64(42)], &mut out);
549        assert_eq!(out[0].as_str(), "0b101010");
550    }
551
552    #[test]
553    fn format_u64_decimal() {
554        let node = FormatU64::decimal();
555        let mut out = [Value::None];
556        node.eval(&[Value::U64(12345)], &mut out);
557        assert_eq!(out[0].as_str(), "12345");
558    }
559
560    #[test]
561    fn format_f64_precision() {
562        let node = FormatF64::new(2);
563        let mut out = [Value::None];
564        node.eval(&[Value::F64(3.14159)], &mut out);
565        assert_eq!(out[0].as_str(), "3.14");
566    }
567
568    #[test]
569    fn format_f64_zero_precision() {
570        let node = FormatF64::new(0);
571        let mut out = [Value::None];
572        node.eval(&[Value::F64(3.7)], &mut out);
573        assert_eq!(out[0].as_str(), "4");
574    }
575
576    #[test]
577    fn zero_pad() {
578        let node = ZeroPadU64::new(8);
579        let mut out = [Value::None];
580        node.eval(&[Value::U64(42)], &mut out);
581        assert_eq!(out[0].as_str(), "00000042");
582    }
583
584    #[test]
585    fn zero_pad_no_truncation() {
586        let node = ZeroPadU64::new(3);
587        let mut out = [Value::None];
588        node.eval(&[Value::U64(12345)], &mut out);
589        assert_eq!(out[0].as_str(), "12345");
590    }
591
592    // ---- Narrower type widening adapter tests ----
593
594    #[test]
595    fn u32_to_u64_zero_extends() {
596        let node = U32ToU64::new();
597        let mut out = [Value::None];
598        node.eval(&[Value::U64(42)], &mut out);
599        assert_eq!(out[0].as_u64(), 42);
600        // High bits are masked off
601        node.eval(&[Value::U64(0xFFFF_FFFF_0000_0001)], &mut out);
602        assert_eq!(out[0].as_u64(), 1);
603    }
604
605    #[test]
606    fn i32_to_i64_sign_extends() {
607        let node = I32ToI64::new();
608        let mut out = [Value::None];
609        // Positive value (legacy bit-stuffed input form — the
610        // lenient Wire<i32> extract must keep accepting it during
611        // the honest-I64 migration).
612        node.eval(&[Value::U64(42)], &mut out);
613        assert_eq!(out[0], Value::I64(42));
614        // Negative i32, legacy stuffed (-1 as u32 = 0xFFFFFFFF):
615        // sign-extension must survive the lenient extract.
616        node.eval(&[Value::U64(0xFFFF_FFFF)], &mut out);
617        assert_eq!(out[0], Value::I64(-1));
618        // Honest signed carrier input round-trips unchanged.
619        node.eval(&[Value::I64(-1)], &mut out);
620        assert_eq!(out[0], Value::I64(-1));
621    }
622
623    #[test]
624    fn f32_to_f64_widens() {
625        let node = F32ToF64::new();
626        let mut out = [Value::None];
627        let f32_bits = 3.14f32.to_bits() as u64;
628        node.eval(&[Value::U64(f32_bits)], &mut out);
629        // f32 3.14 widened to f64 should be close to 3.14
630        let result = out[0].as_f64();
631        assert!((result - 3.14).abs() < 0.001, "got {result}");
632    }
633
634    #[test]
635    fn i32_to_f64_converts() {
636        let node = I32ToF64::new();
637        let mut out = [Value::None];
638        node.eval(&[Value::U64(42)], &mut out);
639        assert_eq!(out[0].as_f64(), 42.0);
640        // Negative: -10 as u32
641        node.eval(&[Value::U64((-10i32) as u32 as u64)], &mut out);
642        assert_eq!(out[0].as_f64(), -10.0);
643    }
644
645    #[test]
646    fn u32_to_f64_converts() {
647        let node = U32ToF64::new();
648        let mut out = [Value::None];
649        node.eval(&[Value::U64(1000)], &mut out);
650        assert_eq!(out[0].as_f64(), 1000.0);
651    }
652
653    #[test]
654    fn i64_to_f64_converts() {
655        let node = I64ToF64::new();
656        let mut out = [Value::None];
657        node.eval(&[Value::U64(42)], &mut out);
658        assert_eq!(out[0].as_f64(), 42.0);
659        // Negative: -1i64 as u64
660        node.eval(&[Value::U64((-1i64) as u64)], &mut out);
661        assert_eq!(out[0].as_f64(), -1.0);
662    }
663
664    // ---- Narrower to-string adapter tests ----
665
666    #[test]
667    fn i32_to_string_formats_signed() {
668        let node = I32ToString::new();
669        let mut out = [Value::None];
670        node.eval(&[Value::U64(42)], &mut out);
671        assert_eq!(out[0].as_str(), "42");
672        node.eval(&[Value::U64((-7i32) as u32 as u64)], &mut out);
673        assert_eq!(out[0].as_str(), "-7");
674    }
675
676    #[test]
677    fn i64_to_string_formats_signed() {
678        let node = I64ToString::new();
679        let mut out = [Value::None];
680        node.eval(&[Value::U64(100)], &mut out);
681        assert_eq!(out[0].as_str(), "100");
682        node.eval(&[Value::U64((-42i64) as u64)], &mut out);
683        assert_eq!(out[0].as_str(), "-42");
684    }
685
686    #[test]
687    fn f32_to_string_formats() {
688        let node = F32ToString::new();
689        let mut out = [Value::None];
690        let bits = 2.5f32.to_bits() as u64;
691        node.eval(&[Value::U64(bits)], &mut out);
692        assert_eq!(out[0].as_str(), "2.5");
693    }
694
695    #[test]
696    fn u32_to_string_formats() {
697        let node = U32ToString::new();
698        let mut out = [Value::None];
699        node.eval(&[Value::U64(12345)], &mut out);
700        assert_eq!(out[0].as_str(), "12345");
701    }
702
703    // -----------------------------------------------------------
704    // Str→X parse adapters (type_system.md §4)
705    // -----------------------------------------------------------
706
707    #[test]
708    fn str_to_bool_canonical_forms() {
709        let node = StrToBool::new();
710        let mut out = [Value::None];
711        for (input, expected) in [
712            ("true", true),
713            ("false", false),
714            ("True", true),
715            ("False", false),
716            ("TRUE", true),
717            ("FALSE", false),
718            ("1", true),
719            ("0", false),
720        ] {
721            node.eval(&[Value::Str(input.into())], &mut out);
722            assert_eq!(out[0].as_bool(), expected, "input={input:?}");
723        }
724    }
725
726    #[test]
727    fn str_to_bool_trims_whitespace() {
728        let node = StrToBool::new();
729        let mut out = [Value::None];
730        node.eval(&[Value::Str("  true  ".into())], &mut out);
731        assert!(out[0].as_bool());
732        node.eval(&[Value::Str("\tfalse\n".into())], &mut out);
733        assert!(!out[0].as_bool());
734    }
735
736    #[test]
737    #[should_panic(expected = "__str_to_bool")]
738    fn str_to_bool_panics_on_unparseable() {
739        let node = StrToBool::new();
740        let mut out = [Value::None];
741        node.eval(&[Value::Str("yes".into())], &mut out);
742    }
743
744    #[test]
745    fn str_to_u64_basic() {
746        let node = StrToU64::new();
747        let mut out = [Value::None];
748        node.eval(&[Value::Str("0".into())], &mut out);
749        assert_eq!(out[0].as_u64(), 0);
750        node.eval(&[Value::Str("42".into())], &mut out);
751        assert_eq!(out[0].as_u64(), 42);
752        node.eval(&[Value::Str("18446744073709551615".into())], &mut out);
753        assert_eq!(out[0].as_u64(), u64::MAX);
754    }
755
756    #[test]
757    fn str_to_u64_trims_whitespace() {
758        let node = StrToU64::new();
759        let mut out = [Value::None];
760        node.eval(&[Value::Str("  42  ".into())], &mut out);
761        assert_eq!(out[0].as_u64(), 42);
762    }
763
764    #[test]
765    #[should_panic(expected = "__str_to_u64")]
766    fn str_to_u64_panics_on_negative() {
767        let node = StrToU64::new();
768        let mut out = [Value::None];
769        node.eval(&[Value::Str("-1".into())], &mut out);
770    }
771
772    #[test]
773    #[should_panic(expected = "__str_to_u64")]
774    fn str_to_u64_panics_on_garbage() {
775        let node = StrToU64::new();
776        let mut out = [Value::None];
777        node.eval(&[Value::Str("abc".into())], &mut out);
778    }
779
780    #[test]
781    fn str_to_f64_basic() {
782        let node = StrToF64::new();
783        let mut out = [Value::None];
784        node.eval(&[Value::Str("0.0".into())], &mut out);
785        assert_eq!(out[0].as_f64(), 0.0);
786        node.eval(&[Value::Str("3.14".into())], &mut out);
787        assert!((out[0].as_f64() - 3.14).abs() < 1e-12);
788        node.eval(&[Value::Str("-2.5e3".into())], &mut out);
789        assert_eq!(out[0].as_f64(), -2500.0);
790        node.eval(&[Value::Str("inf".into())], &mut out);
791        assert!(out[0].as_f64().is_infinite());
792    }
793
794    #[test]
795    fn str_to_f64_trims_whitespace() {
796        let node = StrToF64::new();
797        let mut out = [Value::None];
798        node.eval(&[Value::Str("  1.5  ".into())], &mut out);
799        assert_eq!(out[0].as_f64(), 1.5);
800    }
801
802    #[test]
803    #[should_panic(expected = "__str_to_f64")]
804    fn str_to_f64_panics_on_garbage() {
805        let node = StrToF64::new();
806        let mut out = [Value::None];
807        node.eval(&[Value::Str("not-a-number".into())], &mut out);
808    }
809
810    /// The explicit narrowing casts saturate instead of panicking:
811    /// NaN / negatives → 0, above-range → u64::MAX, and the two
812    /// differ only in truncation vs rounding.
813    #[test]
814    fn narrowing_casts_saturate() {
815        let t = TruncU64::new();
816        let r = RoundU64::new();
817        let mut out = [Value::None];
818        t.eval(&[Value::F64(900.9)], &mut out);
819        assert_eq!(out[0].as_u64(), 900, "trunc drops the fraction");
820        r.eval(&[Value::F64(900.9)], &mut out);
821        assert_eq!(out[0].as_u64(), 901, "round goes to nearest");
822        t.eval(&[Value::F64(-5.0)], &mut out);
823        assert_eq!(out[0].as_u64(), 0, "negative saturates to 0");
824        r.eval(&[Value::F64(f64::NAN)], &mut out);
825        assert_eq!(out[0].as_u64(), 0, "NaN saturates to 0");
826        t.eval(&[Value::F64(f64::INFINITY)], &mut out);
827        assert_eq!(out[0].as_u64(), u64::MAX, "overflow saturates to MAX");
828    }
829}