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}