Skip to main content

num2words2_core/
floatpath.rs

1//! Port of `base.py`'s float/Decimal path: `float2tuple` and
2//! `to_cardinal_float`.
3//!
4//! # Why the f64 arrives as an f64
5//!
6//! The float branch of `float2tuple` is *binary* arithmetic:
7//!
8//! ```python
9//! post = abs(value - pre) * 10**precision
10//! if abs(round(post) - post) < 0.01:
11//!     post = int(round(post))
12//! else:
13//!     post = int(math.floor(post))
14//! ```
15//!
16//! Those artefacts are load-bearing. `2.675` really does produce
17//! `674.9999999999998`, and the `< 0.01` heuristic is what rescues it back to
18//! `675`. Re-deriving that from a decimal string would compute the *right*
19//! answer and therefore the *wrong* one. Python floats and Rust `f64` are both
20//! IEEE-754 doubles, so passing the raw double across reproduces it exactly.
21//!
22//! `precision` still comes from Python: it is `abs(Decimal(str(value))
23//! .as_tuple().exponent)`, which depends on `repr(float)` — shortest
24//! round-trip formatting that we deliberately do not reimplement.
25//!
26//! # The one true rounding trap
27//!
28//! Python's `round()` is round-half-to-**even** (`round(2.5) == 2`), while
29//! Rust's `f64::round()` is half-away-from-zero (`2.5_f64.round() == 3.0`).
30//! Every rounding here uses `round_ties_even()`.
31
32use crate::base::{Lang, N2WError, Result};
33use bigdecimal::BigDecimal;
34use num_bigint::BigInt;
35use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero};
36
37/// The value handed to the float path, mirroring what Python's dispatcher saw.
38///
39/// `base.float2tuple` branches on `isinstance(value, Decimal)`: the Decimal
40/// arm is exact arbitrary precision, the float arm is lossy f64. They are not
41/// interchangeable — issue #603 exists precisely because a `float()` cast
42/// silently rounded `98_746_251_323_029.99` at trillion scale.
43#[derive(Debug, Clone)]
44pub enum FloatValue {
45    /// Python passed a `float`. `precision` is derived from `repr` on the
46    /// Python side.
47    Float { value: f64, precision: u32 },
48    /// Python passed a `Decimal`. `precision` is `abs(exponent)`.
49    Decimal { value: BigDecimal, precision: u32 },
50}
51
52impl FloatValue {
53    pub fn precision(&self) -> u32 {
54        match self {
55            FloatValue::Float { precision, .. } => *precision,
56            FloatValue::Decimal { precision, .. } => *precision,
57        }
58    }
59
60    pub fn is_negative(&self) -> bool {
61        match self {
62            // Sign-bit aware: the converters see the *string* form, and
63            // str(-0.0) is "-0.0", so Python renders the negword for
64            // negative zero. `< 0.0` would miss it.
65            FloatValue::Float { value, .. } => value.is_sign_negative(),
66            FloatValue::Decimal { value, .. } => value.is_negative(),
67        }
68    }
69
70    /// Whether Python's string form of the value shows a decimal point —
71    /// the `"." in str(value)` test many self-contained converters route on.
72    /// `repr(float)` has a point unless repr picked exponent form (1e+16);
73    /// `str(Decimal)` has one exactly when the scale is positive.
74    pub fn has_visible_point(&self) -> bool {
75        match self {
76            // Rust's Display renders 21.0 as "21", so it cannot stand in for
77            // Python's repr. The rule that matters: repr picks exponent form
78            // (no dot) at |v| >= 1e16; every other finite float prints with
79            // a point ("21.0", "0.0"). Fractional values below 1e-4 also go
80            // e-form, but they never reach a whole-vs-float routing test.
81            FloatValue::Float { value, .. } => {
82                value.is_finite() && (value.fract() != 0.0 || value.abs() < 1e16)
83            }
84            FloatValue::Decimal { value, .. } => {
85                crate::strnum::python_decimal_str(value).contains('.')
86            }
87        }
88    }
89
90    /// Python's `int(value) == value` routing test: `Some(int)` when the
91    /// value is whole, `None` when fractional. NaN/±inf return `None` — the
92    /// dispatcher keeps those on the Python side (base's `assert` raises
93    /// OverflowError/ValueError there, outside the whole-value route).
94    pub fn as_whole_int(&self) -> Option<BigInt> {
95        match self {
96            FloatValue::Float { value, .. } => {
97                if !value.is_finite() || value.fract() != 0.0 {
98                    return None;
99                }
100                // Exact: every whole f64 is exactly representable.
101                BigInt::from_f64(*value)
102            }
103            FloatValue::Decimal { value, .. } => {
104                if value.is_integer() {
105                    Some(value.with_scale(0).as_bigint_and_exponent().0)
106                } else {
107                    None
108                }
109            }
110        }
111    }
112
113    /// PR savoirfairelinux/num2words#666: the dispatcher's
114    /// `isinstance(number, float) and number == int(number)` test — `Some(int)`
115    /// only for a genuine `float` (not `Decimal`) whose value is integral.
116    /// Routes a whole float through the integer `to_ordinal_num` so the result
117    /// formats without the decimal point ("1st", not "1.0st"). Decimals keep
118    /// their scale ("5.00th"), matching `isinstance(number, float) == False`.
119    pub fn whole_float_int(&self) -> Option<BigInt> {
120        match self {
121            FloatValue::Float { value, .. } if value.is_finite() && value.fract() == 0.0 => {
122                BigInt::from_f64(*value)
123            }
124            _ => None,
125        }
126    }
127}
128
129/// Python's `Num2Word_Base.float2tuple`. Returns `(pre, post)`.
130///
131/// `precision` is taken from the value rather than recomputed; Python assigns
132/// `self.precision` as a side effect here, and callers read it afterwards.
133pub fn float2tuple(v: &FloatValue) -> (BigInt, BigInt) {
134    match v {
135        FloatValue::Decimal { value, precision } => {
136            // pre = int(value) — truncates toward zero.
137            let pre = value.with_scale(0).as_bigint_and_exponent().0;
138            // post = abs(value - Decimal(pre)) * 10**precision
139            let post = (value - BigDecimal::from(pre.clone())).abs()
140                * BigDecimal::from(BigInt::from(10).pow(*precision));
141            (pre, post.with_scale(0).as_bigint_and_exponent().0)
142        }
143        FloatValue::Float { value, precision } => {
144            let pre = value.trunc();
145            // Python: abs(value - pre) * 10**precision, all in f64.
146            let post = (value - pre).abs() * 10f64.powi(*precision as i32);
147
148            // The heuristic: values like 1.239999999 are float noise for
149            // 1.24, so round rather than floor when we are within 0.01 of an
150            // integer. round() is banker's in Python — round_ties_even here,
151            // NOT f64::round(), which would break every exact .5 tie.
152            let rounded = post.round_ties_even();
153            let out = if (rounded - post).abs() < 0.01 {
154                rounded
155            } else {
156                post.floor()
157            };
158            (
159                BigInt::from(pre as i128),
160                BigInt::from(out as i128),
161            )
162        }
163    }
164}
165
166/// Python's `Num2Word_Base.to_cardinal_float`.
167///
168/// `precision_override` is the `precision=` kwarg (issue #580). Python saves
169/// and restores `self.precision` around the call; the Rust core is stateless,
170/// so the effective precision is threaded through instead.
171pub fn default_to_cardinal_float<L: Lang + ?Sized>(
172    lang: &L,
173    v: &FloatValue,
174    precision_override: Option<u32>,
175) -> Result<String> {
176    let precision = precision_override.unwrap_or_else(|| v.precision());
177    let v = match (v, precision_override) {
178        // A caller-supplied precision replaces the repr-derived one.
179        (FloatValue::Float { value, .. }, Some(p)) => FloatValue::Float {
180            value: *value,
181            precision: p,
182        },
183        (FloatValue::Decimal { value, .. }, Some(p)) => FloatValue::Decimal {
184            value: value.clone(),
185            precision: p,
186        },
187        (other, None) => other.clone(),
188    };
189
190    let (pre, post) = float2tuple(&v);
191
192    // post = str(post); post = "0" * (precision - len(post)) + post
193    let post_str = post.to_string();
194    let post_str = format!(
195        "{}{}",
196        "0".repeat((precision as usize).saturating_sub(post_str.len())),
197        post_str
198    );
199
200    let mut out = vec![lang.to_cardinal(&pre)?];
201    // Python: `if value < 0 and pre == 0: out = [negword] + out` — the sign is
202    // otherwise lost, because int(-0.5) == 0 carries no minus.
203    if v.is_negative() && pre.is_zero() {
204        out.insert(0, lang.negword().trim().to_string());
205    }
206
207    if precision > 0 {
208        out.push(lang.title(lang.pointword()));
209    }
210
211    for ch in post_str.chars().take(precision as usize) {
212        let d = ch.to_digit(10).ok_or_else(|| {
213            N2WError::Value(format!("non-digit {:?} in fractional part", ch))
214        })?;
215        out.push(lang.to_cardinal(&BigInt::from(d))?);
216    }
217
218    Ok(out.join(" "))
219}
220
221/// "Condition C" float routing — `"." in str(value)` decides. Most
222/// self-contained converters route on the value's *string* form: visible
223/// decimals take the language's float grammar even when the value is whole
224/// (5.0 -> "pět čárka nula"), plain ints don't (Decimal("5") -> "pět").
225/// Languages with this shape point their `cardinal_float_entry` here.
226pub fn point_routed_float_entry<L: Lang + ?Sized>(
227    lang: &L,
228    v: &FloatValue,
229    precision_override: Option<u32>,
230) -> Result<String> {
231    if !v.has_visible_point() {
232        if let Some(i) = v.as_whole_int() {
233            return lang.to_cardinal(&i);
234        }
235    }
236    lang.to_cardinal_float(v, precision_override)
237}
238
239/// Render a `BigDecimal` through the float path — the fractional-cents entry
240/// point. `right` is e.g. 65.3 cents, so its own scale is the precision.
241pub fn cardinal_from_bigdecimal<L: Lang + ?Sized>(
242    lang: &L,
243    value: &BigDecimal,
244) -> Result<String> {
245    // Python reaches here via `self.to_cardinal(float(right))`, so the value
246    // goes through a float cast first — reproduce that rather than staying in
247    // arbitrary precision, or the digits can differ.
248    let f = value.to_f64().ok_or_else(|| {
249        N2WError::Value(format!("cannot represent {} as f64", value))
250    })?;
251    let precision = float_repr_precision(f);
252    // Through the TRAIT, not the free function: languages that override
253    // to_cardinal_float (ru's "целых", hu's "egész") must see their own
254    // float grammar here too — Python reaches this as self.to_cardinal(float),
255    // which dispatches virtually.
256    lang.to_cardinal_float(&FloatValue::Float { value: f, precision }, None)
257}
258
259/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64.
260///
261/// Rust's `{}` for f64 is shortest-round-trip, the same contract as Python's
262/// `repr`, so counting the digits after the point matches. Exponent form
263/// (`1e21`) has no fractional digits, matching Python's exponent-0 tuple.
264fn float_repr_precision(f: f64) -> u32 {
265    let s = format!("{}", f);
266    match s.split_once('.') {
267        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
268        _ => 0,
269    }
270}