Skip to content

Value does not preserve f32 across a round-trip (serializer prints f32-shortest decimal, parser re-reads as f64) #613

Description

@enomado

Summary

Serializing a Value that holds an f32 and parsing it back does not preserve
the number: the type flips f32 → f64, and for some values the numeric value
drifts too. It affects essentially every fractional f32 value under the
default (compact) config.

This is Value-only — typed f32/f64 fields are unaffected (the type is
known, so parsing is exact). It also does not occur for f32 values whose
shortest decimal is itself exactly f32-representable (e.g. 0.5, small
integers).

Repro

use ron::value::{Number, Value};

// Type is lost: F32 -> F64
let v = Value::Number(Number::F32(0.1_f32.into()));
let s = ron::to_string(&v).unwrap();               // "0.1"
let back: Value = ron::from_str(&s).unwrap();
assert_eq!(v, back); // FAILS: F32(0.1) != F64(0.1)

// Value drifts, not just the type:
let v = ron::from_str::<Value>("924444480.0").unwrap(); // F32(924444480)
let s = ron::to_string(&v).unwrap();                    // "924444500.0"
let back: Value = ron::from_str(&s).unwrap();           // F64(924444500.0)
assert_eq!(v, back); // FAILS: value drifted 924444480 -> 924444500

Root cause

An asymmetry between the two sides:

  • Serializer (src/ser/mod.rs serialize_f32): write!(output, "{}", v) emits f32's shortest decimal — the shortest string that round-trips when re-parsed as f32.
  • Parser (src/parse.rs, the ParsedFloat discriminator): a suffix-less float literal is parsed as f64, then narrowed with if value.total_cmp(&f64::from(value as f32)).is_eq()F32 else F64.

So the serializer's shortest-f32 decimal is fed to a parser that reads it as
f64 first. 0.1f32 prints as "0.1", which as f64 is 0.1f64 and does not
round-trip through f32, so it lands as F64. 924444480f32 prints as
"924444500.0" (a different decimal that maps to the same f32), which as
f64 is 924444500F64(924444500).

Mitigation / possible fixes

number_suffixes already disambiguates this — to_string_pretty(.., PrettyConfig::default().number_suffixes(true))
emits 0.1f32, which round-trips exactly. Two things make it not quite a full
answer today:

  1. it is off by default, so Value round-trips are lossy out of the box;
  2. compact to_string ignores it entirely (number_suffixes() only reads a
    pretty config), so a lossless compact f32 Value round-trip isn't
    reachable via the public API.

Not sure what the intended contract is here — filing in case Value round-trip
fidelity for f32 is meant to hold. Reproducing tests (both the drift and the
number_suffixes mitigation) are in #612 (tests/zz_finding_float_precision.rs),
part of a broader external robustness-test PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions