Skip to main content

num2words2_core/
lang_rm_puter.rs

1//! Port of `lang_RM_PUTER.py` (Romansh, Puter idiom).
2//!
3//! Shape: **self-contained**. `Num2Word_RM_PUTER` subclasses **nothing** — it
4//! is a bare `class Num2Word_RM_PUTER:` with `def __init__(self): pass`. There
5//! is no `Num2Word_Base` in its ancestry at all, which drives three facts the
6//! rest of this file depends on:
7//!
8//!   * `self.cards` / `self.MAXVAL` / `merge` never exist, so `cards`,
9//!     `maxval` and `merge` stay at their trait defaults and are never
10//!     reached. There is **no `OverflowError` path** in this language.
11//!   * The only ceiling is `big_number_to_cardinal`'s explicit
12//!     `raise NotImplementedError("The given number is too large.")` at
13//!     >= 66 digits — see "Ceiling" below.
14//!   * **`to_ordinal_num` and `to_year` do not exist.** Not inherited, not
15//!     defined. Calling either raises `AttributeError`. See "The
16//!     AttributeError modes" below — this is the single most important thing
17//!     to know about this language.
18//!
19//! The module header says "Based on lang_IT template from Filippo Costa", and
20//! the structure (phonetic contraction + `adapt_*` surface rules +
21//! `EXPONENT_PREFIXES`) mirrors `lang_IT.py` closely. It does **not** share
22//! IT's class hierarchy, though, so none of IT's inherited methods come along.
23//!
24//! # The AttributeError modes
25//!
26//! `to_ordinal_num` and `to_year` are **not implemented anywhere in the MRO**.
27//! Python:
28//!
29//! ```text
30//! >>> Num2Word_RM_PUTER().to_year(5)
31//! AttributeError: 'Num2Word_RM_PUTER' object has no attribute 'to_year'
32//! >>> Num2Word_RM_PUTER().to_ordinal_num(5)
33//! AttributeError: 'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'
34//! ```
35//!
36//! The frozen corpus agrees: **all 90 `ordinal_num` rows and all 35 `year`
37//! rows are `{"ok": false, "err": "AttributeError"}`**, for every argument
38//! including 0 and 1.
39//!
40//! The trait defaults in `base.rs` would *succeed* here (`to_ordinal_num` →
41//! `str(value)`, `to_year` → `to_cardinal`), which is exactly wrong, so both
42//! are overridden below to fail. `base.rs` has no `N2WError::Attribute`
43//! variant, so — following the convention already set by `lang_it.rs` — they
44//! return `N2WError::Type` carrying a message that names `AttributeError`
45//! explicitly. See [`attribute_error`] and the porting report's `concerns`:
46//! the integration layer must either add an `Attribute` variant or keep
47//! `rm_puter` out of the Rust fast path for these two modes.
48//!
49//! # Faithfully reproduced Python quirks
50//!
51//! This is a port, not a rewrite. All of the following are verified against
52//! the interpreter and are preserved verbatim:
53//!
54//! 1. **`adapt_thousand` handles "eot" where `adapt_hundred` handles "eoch".**
55//!    `adapt_hundred` rewrites `"eoch"` → `"edoch"`, but `adapt_thousand`
56//!    rewrites `"eot"` → `"edot"` — and **no word in this language starts with
57//!    "ot"** ("eight" is `"och"`). The rule is inherited dead from the Italian
58//!    template ("otto"), so the thousands path never gets its `ed` liaison:
59//!      * `to_cardinal(108)`  == `"tschientedoch"`  (hundreds: liaison fires)
60//!      * `to_cardinal(1008)` == `"millieoch"`      (thousands: liaison does
61//!        **not** fire — this looks like a typo for "eoch" but is what ships)
62//!      * `to_cardinal(2008)` == `"duamillieoch"`, `to_cardinal(10008)` ==
63//!        `"deschmillieoch"`, all likewise un-liaised.
64//!    `adapt_milliard` covers `" e och"` → `" ed och"` correctly, so
65//!    `to_cardinal(1000008)` == `"ün milliun ed och"`. Only the thousands rung
66//!    is affected. Not fixed here — see [`adapt_thousand`].
67//!
68//! 2. **`adapt_thousand`'s collective-plural rules are substring rules, not
69//!    multiplier rules.** `"traismilli"` → `"trajamilli"` fires wherever that
70//!    substring appears, including when "trais" is the *units digit of the
71//!    thousands multiplier* rather than the multiplier itself:
72//!    `to_cardinal(123456)` == `"tschientvainchatrajamilli..."` — the "trais"
73//!    of "tschientvainchatrais" (123) is rewritten to "traja". This is
74//!    reproduced exactly (and the corpus confirms it).
75//!
76//! 3. **`tens_to_cardinal`'s `else` branch is unreachable.** It is only called
77//!    for `20 <= number < 100`, so `tens` is always in 2..=9 and always a
78//!    `STR_TENS` key; `CARDINAL_WORDS[tens][:-1] + "aunta"` never runs. Ported
79//!    verbatim anyway.
80//!
81//! 4. **`STR_TENS[2]` is `"vaincha"`, not `"vainch"`.** The trailing "a" is a
82//!    scaffold: `phonetic_contraction` restores the surface form via the
83//!    `"vaincha_"` → `"vainch"` rule, where `_` is the empty-units marker.
84//!    Rule *order* is load-bearing — `"aün"` → `"ün"` runs first, so
85//!    `"vainchaün"` (21) contracts to `"vainchün"` and the `"vaincha_"` rule
86//!    never sees it. See [`phonetic_contraction`].
87//!
88//! 5. **The "e" liaison distribution in `hundreds_to_cardinal` is irregular**:
89//!    `tens in 1..=13` or `tens in {15, 16, 20, 30}`. 14 is excluded, 17/18/19
90//!    are excluded. The Python comment says "distribution may seem unusual but
91//!    it was reviewed by a native speaker", so it is deliberate, not a bug:
92//!    `to_cardinal(116)` == `"tschientesaidesch"` but `to_cardinal(118)` ==
93//!    `"tschientdischdoch"`. Ported as-is.
94//!
95//! # Ceiling
96//!
97//! `big_number_to_cardinal` raises `NotImplementedError` at `len(str(n)) >= 66`
98//! — i.e. the largest representable value is 65 digits. That bound is exactly
99//! what keeps `EXPONENT_PREFIXES` (11 entries, 0..=10) in range: 65 digits →
100//! `predigits = 2` → `exponent_length = 63` → `63 // 6 == 10`, the last index.
101//! So the `IndexError` modelled in [`exponent_length_to_string`] is
102//! unreachable. `to_cardinal(10**64)` == `"ün decilliard"`;
103//! `to_cardinal(10**65)` raises `NotImplementedError`. Values are `BigInt`
104//! throughout — 65 digits does not fit a `u128`.
105//!
106//! # Python semantics that matter here
107//!
108//! Strings genuinely contain "ü", so **all indexing and slicing is
109//! character-based, never byte-based**: `to_ordinal` inspects `cardinal[-1]`
110//! and may drop it with `cardinal[:-1]`, and a byte-wise `[..len-1]` would
111//! split "ü" in half. See [`drop_last_char`] and the `to_ordinal` impl.
112//!
113//! # The float / Decimal cardinal path
114//!
115//! `to_cardinal` dispatches non-integer input inline. A **float** hits the
116//! `isinstance(number, float)` branch → `float_to_words`, spelled out in
117//! [`LangRmPuter::float_to_words`]: `to_cardinal(int(v))`, then `" comma "`,
118//! then each fractional digit of `str(v)` spelled separately. A **Decimal** is
119//! not a float, so it skips that branch and falls through the integer dispatch,
120//! where it crashes (`TypeError`/`ValueError`) or, for a whole number
121//! `>= 1e6`, succeeds — see [`LangRmPuter::decimal_to_cardinal`]. Both are
122//! reached through the overridden `to_cardinal_float` at the bottom of the file
123//! (the generic `base.rs` float path would double the spaces around " comma ").
124//!
125//! # Still out of scope
126//!
127//! The `number % 1 != 0` branch of `to_ordinal` (ordinal of a non-integer) is
128//! not modelled — this phase is cardinal-only, and no `ordinal` float corpus
129//! rows exist for this language. Currency/cheque/fraction do not exist on this
130//! class at all and would likewise raise `AttributeError`.
131
132use crate::base::{Lang, N2WError, Result};
133use crate::floatpath::FloatValue;
134use bigdecimal::BigDecimal;
135use num_bigint::BigInt;
136use num_integer::Integer;
137use num_traits::{Signed, ToPrimitive};
138
139// Globals — mirroring the Python module-level tables.
140
141const ZERO: &str = "nolla";
142
143const MINUS_PREFIX_WORD: &str = "minus ";
144
145/// `FLOAT_INFIX_WORD` — the separator `float_to_words` puts between the
146/// integer part and the spelled-out fractional digits. Note the surrounding
147/// spaces are part of the constant: the result is `prefix + " comma " +
148/// postfix`, so exactly one space falls on each side. (The generic
149/// `pointword()` route in `base.rs` would title-case this and *also* join it
150/// with spaces, yielding `nolla  comma  tschinch` with doubled spaces — which
151/// is why this class needs its own `to_cardinal_float`.)
152const FLOAT_INFIX_WORD: &str = " comma ";
153
154/// `CARDINAL_WORDS`, indices 0..=19.
155const CARDINAL_WORDS: [&str; 20] = [
156    ZERO,
157    "ün",
158    "duos",
159    "trais",
160    "quatter",
161    "tschinch",
162    "ses",
163    "set",
164    "och",
165    "nouv",
166    "desch",
167    "ündesch",
168    "dudesch",
169    "tredesch",
170    "quattordesch",
171    "quindesch",
172    "saidesch",
173    "dischset",
174    "dischdoch",
175    "dischnouv",
176];
177
178/// `ORDINAL_WORDS`, indices 0..=20 (note it runs one further than
179/// `CARDINAL_WORDS`, which is why `to_ordinal` guards with `<= 20`).
180const ORDINAL_WORDS: [&str; 21] = [
181    ZERO,
182    "prüm",
183    "seguond",
184    "terz",
185    "quart",
186    "tschinchevel",
187    "sesevel",
188    "settevel",
189    "ochevel",
190    "nouvevel",
191    "deschevel",
192    "ündeschevel",
193    "dudeschevel",
194    "tredeschevel",
195    "quattordeschevel",
196    "quindeschevel",
197    "saideschevel",
198    "dischsettevel",
199    "dischdochevel",
200    "dischnouvevel",
201    "vainchevel",
202];
203
204/// `EXPONENT_PREFIXES`, indices 0..=10. Index 0 is `ZERO` and unreachable
205/// (`exponent_length // 6 == 0` would need `exponent_length < 6`, but
206/// `big_number_to_cardinal` only runs for >= 10**6, giving >= 6).
207const EXPONENT_PREFIXES: [&str; 11] = [
208    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "och", "nov", "dec",
209];
210
211/// `STR_TENS`, a dict with keys 2..=9. Returns `None` for a missing key, which
212/// is how the (unreachable) `else` branch of `tens_to_cardinal` is selected —
213/// Python tests `if tens in STR_TENS`, so a miss is not a `KeyError`.
214fn str_tens(tens: usize) -> Option<&'static str> {
215    match tens {
216        2 => Some("vaincha"), // surface form restored by phonetic_contraction
217        3 => Some("trenta"),
218        4 => Some("quaraunta"),
219        5 => Some("tschinquaunta"),
220        6 => Some("sesaunta"),
221        7 => Some("settaunta"),
222        8 => Some("ochaunta"),
223        9 => Some("nonaunta"),
224        _ => None,
225    }
226}
227
228// --- Python exception encoding -------------------------------------------
229
230/// Python raised `AttributeError`, which `base.rs` cannot express: there is no
231/// `N2WError::Attribute` variant. Following `lang_it.rs`, emit `N2WError::Type`
232/// with a message naming the real exception type so the integration layer can
233/// remap it. See the module docs' "The AttributeError modes".
234fn attribute_error(msg: &str) -> N2WError {
235    N2WError::Attribute(msg.to_string())
236}
237
238/// Python raised `IndexError` (a list index out of range). Every call site
239/// below is provably unreachable given `to_cardinal`'s range dispatch; these
240/// exist so an out-of-range access reproduces Python's exception type instead
241/// of panicking.
242fn index_error(msg: &str) -> N2WError {
243    N2WError::Index(msg.to_string())
244}
245
246/// Python raised `ValueError` — `int("")`. Unreachable: `big_number_to_cardinal`
247/// only runs for >= 10**6 (>= 7 digits), so `exponent` is never empty.
248fn value_error(s: &str) -> N2WError {
249    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
250}
251
252/// Python's `int(s)` for the all-digit strings this module builds. Leading
253/// zeros are fine (`int("000001") == 1`); only `""` can fail here.
254fn parse_int(s: &str) -> Result<BigInt> {
255    BigInt::parse_bytes(s.as_bytes(), 10).ok_or_else(|| value_error(s))
256}
257
258/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
259///
260/// repr picks exponent form (no point) for finite non-zero magnitudes below
261/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
262/// float prints with a point ("0.0", "21.0"). `float_to_words` does
263/// `str(float_number).split('.')[1]`, so a pointless repr is Python's
264/// `IndexError: list index out of range` — corpus-confirmed for `1e+16` and
265/// `1e+20`. This must be an explicit repr test: the binding's `precision`
266/// for `1e+16` is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the
267/// `{:.16}` reconstruction *would* carry a point Python's repr does not have.
268fn float_repr_has_point(f: f64) -> bool {
269    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
270}
271
272/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
273/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
274/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
275/// corpus but both are modelled.
276fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
277    if f.is_nan() {
278        return Err(N2WError::Value(
279            "cannot convert float NaN to integer".to_string(),
280        ));
281    }
282    num_traits::FromPrimitive::from_f64(f.trunc()).ok_or_else(|| {
283        N2WError::Overflow("cannot convert float infinity to integer".to_string())
284    })
285}
286
287// --- Character-safe slicing ----------------------------------------------
288
289/// Python's `s[:-1]` — drop the last **character**. Byte slicing would split
290/// the "ü" in e.g. "vainchün".
291fn drop_last_char(s: &str) -> String {
292    let chars: Vec<char> = s.chars().collect();
293    if chars.is_empty() {
294        return String::new();
295    }
296    chars[..chars.len() - 1].iter().collect()
297}
298
299/// `CARDINAL_WORDS[i]` with Python's `IndexError` on a miss.
300///
301/// Note: a *negative* Python index would wrap rather than raise, but no
302/// negative ever reaches here — `to_cardinal` strips the sign before
303/// dispatching on magnitude.
304fn cardinal_word(i: usize) -> Result<&'static str> {
305    CARDINAL_WORDS
306        .get(i)
307        .copied()
308        .ok_or_else(|| index_error("list index out of range"))
309}
310
311/// `ORDINAL_WORDS[i]` with Python's `IndexError` on a miss.
312fn ordinal_word(i: usize) -> Result<&'static str> {
313    ORDINAL_WORDS
314        .get(i)
315        .copied()
316        .ok_or_else(|| index_error("list index out of range"))
317}
318
319/// A `BigInt` used as a list index. Callers have already bounded the value
320/// (`< 20`, `< 100`, `< 1000`), so `None` is unreachable; it maps to the
321/// `IndexError` Python would raise.
322fn as_index(n: &BigInt) -> Result<usize> {
323    n.to_usize()
324        .ok_or_else(|| index_error("list index out of range"))
325}
326
327// --- Utils (module-level functions in Python) -----------------------------
328
329/// Port of `phonetic_contraction`. `_` marks "no following unit".
330///
331/// The four rules run **in this order** and the order is load-bearing:
332/// `"aün"` → `"ün"` fires before `"vaincha_"` → `"vainch"`, so 21
333/// ("vainchaün") contracts via the first rule and never reaches the third.
334///
335/// * 31: "trentaün"    → "trentün"
336/// * 88: "ochauntaoch" → "ochauntoch"
337/// * 20: "vaincha_"    → "vainch"
338/// * 30: "trenta_"     → "trenta"
339fn phonetic_contraction(s: &str) -> String {
340    s.replace("aün", "ün")
341        .replace("aoch", "och")
342        .replace("vaincha_", "vainch")
343        .replace("_", "")
344}
345
346/// Port of `adapt_hundred`: collective plural + e/ed phonotactic adaptation.
347fn adapt_hundred(s: &str) -> String {
348    s.replace("duostschient", "duatschient")
349        .replace("traistschient", "trajatschient")
350        .replace("eün", "edün")
351        .replace("eoch", "edoch")
352}
353
354/// Port of `adapt_thousand`: collective plural + e/ed phonotactic adaptation.
355///
356/// The last rule is `"eot"` → `"edot"`, **not** `"eoch"` → `"edoch"`. No word
357/// here begins with "ot", so it is dead — inherited from the Italian template
358/// ("otto"). Consequence: `to_cardinal(1008)` == `"millieoch"` while
359/// `to_cardinal(108)` == `"tschientedoch"`. See module quirk 1; not fixed.
360fn adapt_thousand(s: &str) -> String {
361    s.replace("duosmilli", "duamilli")
362        .replace("traismilli", "trajamilli")
363        .replace("eün", "edün")
364        .replace("eot", "edot")
365}
366
367/// Port of `adapt_milliard`: article gender agreement + e/ed adaptation.
368///
369/// Python pads with a space on each side so the `" e ün"` / `" e och"` rules
370/// can match at a word boundary; the caller then `.strip()`s the padding back
371/// off. Both are reproduced (see `big_number_to_cardinal`'s trailing `trim`).
372fn adapt_milliard(s: &str) -> String {
373    let padded = format!(" {} ", s);
374    padded.replace(" e ün", " ed ün").replace(" e och", " ed och")
375}
376
377/// Port of `exponent_length_to_string`.
378///
379/// `exponent_length` is always a multiple of 3 (guaranteed by
380/// `big_number_to_cardinal`'s `predigits` split). `// 6 == 0` and `% 6 == 0`
381/// therefore pick "illiun" for 6/12/18... and "illiard" for 9/15/21...
382///
383/// The `IndexError` is unreachable — see the module docs' "Ceiling".
384fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
385    let prefix = EXPONENT_PREFIXES
386        .get(exponent_length / 6)
387        .copied()
388        .ok_or_else(|| index_error("list index out of range"))?;
389    if exponent_length % 6 == 0 {
390        Ok(format!("{}illiun", prefix))
391    } else {
392        Ok(format!("{}illiard", prefix))
393    }
394}
395
396/// Port of `omitt_if_zero` (sic — one "t", two "t"s; Python's spelling).
397fn omitt_if_zero(number_to_string: &str) -> String {
398    if number_to_string == ZERO {
399        String::new()
400    } else {
401        number_to_string.to_string()
402    }
403}
404
405/// Port of `empty_if_zero`: the `_` marker consumed by `phonetic_contraction`.
406fn empty_if_zero(number_to_string: &str) -> String {
407    if number_to_string == ZERO {
408        "_".to_string()
409    } else {
410        number_to_string.to_string()
411    }
412}
413
414// --- Main class -----------------------------------------------------------
415
416pub struct LangRmPuter;
417
418impl Default for LangRmPuter {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424impl LangRmPuter {
425    pub fn new() -> Self {
426        LangRmPuter
427    }
428
429    /// Python's `self.to_cardinal`, as an inherent method so the internal
430    /// recursion (`hundreds_to_cardinal` → `to_cardinal` → ...) resolves
431    /// exactly as Python's does. No subclass exists, so dynamic dispatch and
432    /// this direct call are equivalent.
433    fn card(&self, number: &BigInt) -> Result<String> {
434        if number.is_negative() {
435            // Python: MINUS_PREFIX_WORD + self.to_cardinal(-number)
436            return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.card(&-number)?));
437        }
438        // `card` is the integer path: Python's `isinstance(number, float)`
439        // branch is handled up in `to_cardinal_float` → `float_to_words`, which
440        // then calls back here with the integer part.
441        if number < &BigInt::from(20) {
442            Ok(cardinal_word(as_index(number)?)?.to_string())
443        } else if number < &BigInt::from(100) {
444            self.tens_to_cardinal(number)
445        } else if number < &BigInt::from(1000) {
446            self.hundreds_to_cardinal(number)
447        } else if number < &BigInt::from(1_000_000) {
448            self.thousands_to_cardinal(number)
449        } else {
450            self.big_number_to_cardinal(number)
451        }
452    }
453
454    /// Port of `tens_to_cardinal`. Reached only for `20 <= number < 100`.
455    fn tens_to_cardinal(&self, number: &BigInt) -> Result<String> {
456        let tens = number.div_floor(&BigInt::from(10));
457        let units = number.mod_floor(&BigInt::from(10));
458        let tens_i = as_index(&tens)?;
459        let units_i = as_index(&units)?;
460
461        let prefix = match str_tens(tens_i) {
462            Some(p) => p.to_string(),
463            // Unreachable: tens is always 2..=9 here. See module quirk 3.
464            None => format!("{}aunta", drop_last_char(cardinal_word(tens_i)?)),
465        };
466        // 0 is tracked with '_' and removed in phonetic_contraction.
467        let postfix = empty_if_zero(cardinal_word(units_i)?);
468        Ok(phonetic_contraction(&format!("{}{}", prefix, postfix)))
469    }
470
471    /// Port of `hundreds_to_cardinal`. Reached only for `100 <= number < 1000`.
472    fn hundreds_to_cardinal(&self, number: &BigInt) -> Result<String> {
473        let hundreds = number.div_floor(&BigInt::from(100));
474        let tens = number.mod_floor(&BigInt::from(100));
475
476        let mut prefix = "tschient".to_string();
477        if hundreds != BigInt::from(1) {
478            prefix = format!("{}tschient", cardinal_word(as_index(&hundreds)?)?);
479        }
480        let postfix = omitt_if_zero(&self.card(&tens)?);
481
482        // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30.
483        // Irregular on purpose — see module quirk 5.
484        let mut infix = "";
485        let t = as_index(&tens)?;
486        if (t > 0 && t <= 13) || matches!(t, 15 | 16 | 20 | 30) {
487            infix = "e";
488        }
489        Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
490    }
491
492    /// Port of `thousands_to_cardinal`. Reached only for
493    /// `1000 <= number < 1_000_000`.
494    fn thousands_to_cardinal(&self, number: &BigInt) -> Result<String> {
495        let thousands = number.div_floor(&BigInt::from(1000));
496        let hundreds = number.mod_floor(&BigInt::from(1000));
497
498        let mut prefix = "milli".to_string();
499        if thousands != BigInt::from(1) {
500            prefix = format!("{}milli", self.card(&thousands)?);
501        }
502        let postfix = omitt_if_zero(&self.card(&hundreds)?);
503
504        // Python comment says "e/ed is inserted if tens <= 100"; the code
505        // actually tests the whole `hundreds` remainder, which is what runs.
506        let mut infix = "";
507        if hundreds <= BigInt::from(100) && !postfix.is_empty() {
508            infix = "e";
509        }
510        Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
511    }
512
513    /// Port of `big_number_to_cardinal`. Reached only for `number >= 10**6`,
514    /// so `digits` is at least 7 long and never carries a sign.
515    fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
516        let digits: Vec<char> = number.to_string().chars().collect();
517        let length = digits.len();
518        if length >= 66 {
519            return Err(N2WError::NotImplemented(
520                "The given number is too large.".to_string(),
521            ));
522        }
523
524        // How many digits come before the "illion" term:
525        //   tschient milliards => 3 / desch milliuns => 2 / ün milliard => 1
526        // Python: `length % 3 or 3` — 0 is falsy, so a multiple of 3 gives 3.
527        let predigits = if length % 3 == 0 { 3 } else { length % 3 };
528        let multiplier: Vec<char> = digits[..predigits].to_vec();
529        let exponent: Vec<char> = digits[predigits..].to_vec();
530
531        let mut infix = exponent_length_to_string(exponent.len())?;
532        let prefix = if multiplier == vec!['1'] {
533            "ün ".to_string()
534        } else {
535            let m: String = multiplier.iter().collect();
536            let p = self.card(&parse_int(&m)?)?;
537            // Plural form
538            infix = format!(" {}s", infix);
539            p
540        };
541
542        // Python: `if set(exponent) != set("0")` — i.e. "is the exponent
543        // non-zero?". An *empty* exponent would also take this branch (set()
544        // != {"0"}) and die in int(""); it can't happen, but the shape is
545        // preserved rather than simplified to `any(!= '0')`.
546        let exponent_is_zero = !exponent.is_empty() && exponent.iter().all(|&c| c == '0');
547        let postfix = if !exponent_is_zero {
548            let exponent_str: String = exponent.iter().collect();
549            let p = self.card(&parse_int(&exponent_str)?)?;
550            // "e" is introduced if there is a 3-digit gap before the next value
551            if exponent_str.starts_with("000") {
552                infix.push_str(" e ");
553            } else {
554                infix.push(' ');
555            }
556            p
557        } else {
558            String::new()
559        };
560
561        // adapt_milliard pads with spaces; Python strips them back off here.
562        Ok(adapt_milliard(&format!("{}{}{}", prefix, infix, postfix))
563            .trim()
564            .to_string())
565    }
566
567    /// Port of `float_to_words` for the **cardinal** case (`ordinal=False`).
568    ///
569    /// ```python
570    /// prefix = self.to_cardinal(int(float_number))
571    /// float_part = str(float_number).split('.')[1]
572    /// postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
573    /// return prefix + " comma " + postfix
574    /// ```
575    ///
576    /// Two Python facts drive the shape:
577    ///
578    ///   * The sign never reaches `float_to_words`. `to_cardinal` peels it off
579    ///     first (`if number < 0: MINUS_PREFIX_WORD + self.to_cardinal(-number)`),
580    ///     so `float_to_words` only ever sees a non-negative float. We mirror
581    ///     that by prefixing `"minus "` around the absolute-value body rather
582    ///     than by signing the parts. `int(-0.5)` would carry no minus anyway,
583    ///     so this is the only way the sign survives — exactly as in Python.
584    ///
585    ///   * `str(float_number).split('.')[1]` is the shortest-round-trip repr's
586    ///     fractional digits. `precision` (computed Python-side as
587    ///     `abs(Decimal(str(v)).as_tuple().exponent)`) is *by definition* the
588    ///     length of that fraction, so `format!("{:.precision$}", a)` — which
589    ///     rounds `a` to exactly its own repr length, i.e. does no real
590    ///     rounding — reproduces those digits byte for byte. We deliberately do
591    ///     **not** reimplement `repr(float)`; we reuse the precision it derived.
592    ///
593    /// Out of range: if `str(v)` were in exponent form (`abs(v) >= 1e16` or a
594    /// very small magnitude), Python's `split('.')[1]` raises `IndexError`.
595    /// That is unreachable for every value the corpus/harness feeds here
596    /// (max `1234.56`); see the porting report's `concerns`.
597    fn float_to_words(&self, value: f64, precision: u32) -> Result<String> {
598        let negative = value < 0.0;
599        let a = value.abs();
600
601        // prefix = self.to_cardinal(int(a)) — int() truncates toward zero; a >= 0.
602        // Computed *before* the split, exactly as Python does — for a huge
603        // float the 66-digit NotImplementedError beats the IndexError below.
604        let pre = f64_trunc_to_bigint(a)?;
605        let prefix = self.card(&pre)?;
606
607        // float_part = str(a).split('.')[1]. A repr in exponent form ("1e+16")
608        // has no '.' — Python's IndexError, corpus-confirmed.
609        if !float_repr_has_point(a) {
610            return Err(index_error("list index out of range"));
611        }
612
613        // float_part = str(a).split('.')[1], rebuilt at the repr precision.
614        let formatted = format!("{:.*}", precision as usize, a);
615        let float_part = formatted.split_once('.').map(|(_, f)| f).unwrap_or("");
616
617        // postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
618        let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
619        for ch in float_part.chars() {
620            // Every char here is a decimal digit; int(c) never fails. Mapping a
621            // miss to Python's ValueError keeps parity if that ever changed.
622            let d = ch
623                .to_digit(10)
624                .ok_or_else(|| value_error(&ch.to_string()))?;
625            parts.push(self.card(&BigInt::from(d))?);
626        }
627        let postfix = parts.join(" ");
628
629        let body = format!("{}{}{}", prefix, FLOAT_INFIX_WORD, postfix);
630        if negative {
631            Ok(format!("{}{}", MINUS_PREFIX_WORD, body))
632        } else {
633            Ok(body)
634        }
635    }
636
637    /// Reproduce `to_cardinal(Decimal)`. A `Decimal` is **not** a `float`, so it
638    /// never takes the `isinstance(number, float)` branch; it flows through the
639    /// integer-magnitude dispatch and crashes — the corpus records every
640    /// `cardinal_dec` row for this language as an error.
641    ///
642    /// Behaviour, verified against the live interpreter (sign is peeled off
643    /// first, so everything below is in terms of `a = |value|`):
644    ///
645    ///   * `a < 1_000_000` → **`TypeError`**. Every branch below a million
646    ///     eventually evaluates `CARDINAL_WORDS[<Decimal>]` (directly, or after
647    ///     recursing down to a value `< 20`), and a `Decimal` is not a valid
648    ///     list index. True even for whole-valued Decimals like `Decimal("100")`.
649    ///
650    ///   * `a >= 1_000_000` → `big_number_to_cardinal(str(a))`:
651    ///       - `len(str(a)) >= 66` → **`NotImplementedError`** (checked first).
652    ///       - otherwise, if `a` has a fractional part (`precision > 0`), `str(a)`
653    ///         contains a `.`; since `a >= 1e6` the integer part is at least 7
654    ///         digits and `predigits <= 3`, so the `.` always lands in the
655    ///         `exponent` slice and `int(exponent)` is the first `int()` to fail
656    ///         → **`ValueError`** naming that exact substring.
657    ///       - a whole number (`precision == 0`) has an all-digit `str(a)` and
658    ///         succeeds, producing the same spelling as the integer path.
659    ///
660    /// The exotic exponent-notation Decimal (`Decimal("1E+7")`, positive
661    /// `as_tuple().exponent`) is not reachable through the harness and is not
662    /// modelled exactly — see the porting report's `concerns`.
663    fn decimal_to_cardinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
664        let negative = value.is_negative();
665        let a = value.abs();
666
667        if a < BigDecimal::from(1_000_000i64) {
668            // CARDINAL_WORDS[<decimal.Decimal>] — list index that is not an int.
669            return Err(N2WError::Type(
670                "list indices must be integers or slices, not decimal.Decimal".to_string(),
671            ));
672        }
673
674        if precision == 0 {
675            // str(a) is all digits: big_number_to_cardinal succeeds and matches
676            // the integer path. card() also owns the `>= 66 digits` ceiling.
677            let int_part = a.with_scale(0).as_bigint_and_exponent().0;
678            let body = self.card(&int_part)?;
679            return Ok(if negative {
680                format!("{}{}", MINUS_PREFIX_WORD, body)
681            } else {
682                body
683            });
684        }
685
686        // precision > 0: rebuild str(a) = "<int_part>.<frac>" so the failing
687        // substring in the ValueError matches Python's byte for byte.
688        let (coeff, _exp) = a.with_scale(precision as i64).as_bigint_and_exponent();
689        let modulus = BigInt::from(10).pow(precision);
690        let int_str = (&coeff / &modulus).to_string();
691        let frac_raw = (&coeff % &modulus).to_string();
692        let frac_str = format!(
693            "{}{}",
694            "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
695            frac_raw
696        );
697        let s = format!("{}.{}", int_str, frac_str);
698
699        // big_number_to_cardinal: the `length >= 66` guard runs before any int().
700        let length = s.chars().count();
701        if length >= 66 {
702            return Err(N2WError::NotImplemented(
703                "The given number is too large.".to_string(),
704            ));
705        }
706        // predigits = length % 3 or 3; exponent = digits[predigits:]. int() of
707        // that slice is the crash site (it holds the '.').
708        let predigits = if length % 3 == 0 { 3 } else { length % 3 };
709        let exponent: String = s.chars().skip(predigits).collect();
710        Err(value_error(&exponent))
711    }
712
713    /// `Num2Word_RM_PUTER.to_ordinal` for a `float` argument:
714    ///
715    /// ```python
716    /// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
717    /// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
718    /// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
719    /// else: cardinal = self.to_cardinal(number)  # float branch, "... comma nolla"
720    /// ```
721    ///
722    /// Corpus-confirmed quirks, all reproduced:
723    ///   * a *fractional* float works: `2.5` -> "seguond comma tschinch"
724    ///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
725    ///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
726    ///     `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
727    ///   * a whole float `> 20` renders its float *cardinal* ("vainchün comma
728    ///     nolla") and then takes the ordinal suffix — ends in 'a', so the 'a'
729    ///     is dropped and "evel" appended: "vainchün comma nollevel";
730    ///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
731    ///     die on the pointless repr -> IndexError.
732    fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
733        if f < 0.0 {
734            return Ok(format!(
735                "{}{}",
736                MINUS_PREFIX_WORD,
737                self.float_ordinal(-f, precision)?
738            ));
739        }
740        if f.fract() != 0.0 {
741            // float_to_words(number, ordinal=True): the prefix is the ordinal
742            // of int(number); the digit words stay *cardinal*.
743            let prefix = self.to_ordinal(&f64_trunc_to_bigint(f)?)?;
744            if !float_repr_has_point(f) {
745                return Err(index_error("list index out of range"));
746            }
747            let formatted = format!("{:.*}", precision as usize, f);
748            let float_part = formatted.split_once('.').map(|(_, x)| x).unwrap_or("");
749            let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
750            for ch in float_part.chars() {
751                let d = ch
752                    .to_digit(10)
753                    .ok_or_else(|| value_error(&ch.to_string()))?;
754                parts.push(self.card(&BigInt::from(d))?);
755            }
756            return Ok(format!(
757                "{}{}{}",
758                prefix,
759                FLOAT_INFIX_WORD,
760                parts.join(" ")
761            ));
762        }
763        if f <= 20.0 {
764            // ORDINAL_WORDS[<float>] — a float is not a valid list index.
765            return Err(N2WError::Type(
766                "list indices must be integers or slices, not float".to_string(),
767            ));
768        }
769        // cardinal = self.to_cardinal(number) — the float branch again. Ends in
770        // "... comma nolla" here, so the 'a' rule (drop 'a', add "evel") always
771        // fires; the other arms are kept for shape parity.
772        let cardinal = self.float_to_words(f, precision)?;
773        let last = cardinal
774            .chars()
775            .next_back()
776            .ok_or_else(|| index_error("string index out of range"))?;
777        if last == 'a' {
778            Ok(format!("{}evel", drop_last_char(&cardinal)))
779        } else if cardinal.ends_with("set") {
780            Ok(format!("{}tevel", cardinal))
781        } else {
782            Ok(format!("{}evel", cardinal))
783        }
784    }
785
786    /// `Num2Word_RM_PUTER.to_ordinal` for a `Decimal` argument. `Decimal % 1`
787    /// works, so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
788    /// branch and renders (reading `str(Decimal)`); a whole-valued one falls
789    /// into the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
790    /// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
791    /// `10**6`, str-splitting above (ValueError for a fractional or
792    /// scientific-repr Decimal). `Decimal("-0.0") < 0` is False, so it is *not*
793    /// minus-prefixed — it crashes in the table branch like `0.0` does.
794    fn decimal_ordinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
795        if value.is_negative() {
796            return Ok(format!(
797                "{}{}",
798                MINUS_PREFIX_WORD,
799                self.decimal_ordinal(&value.abs(), precision)?
800            ));
801        }
802        if !value.is_integer() {
803            // float_to_words(number, ordinal=True) over str(Decimal).
804            let pre = value.with_scale(0).as_bigint_and_exponent().0;
805            let prefix = self.to_ordinal(&pre)?;
806            let s = crate::strnum::python_decimal_str(value);
807            let float_part = match s.split_once('.') {
808                Some((_, frac)) => frac.to_string(),
809                // Scientific repr with no '.' (e.g. Decimal("5E-7")).
810                None => return Err(index_error("list index out of range")),
811            };
812            let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
813            for c in float_part.chars() {
814                // int(c) — an 'E'/'+' from a scientific repr is ValueError.
815                let d = c.to_digit(10).ok_or_else(|| value_error(&c.to_string()))?;
816                parts.push(self.card(&BigInt::from(d))?);
817            }
818            return Ok(format!(
819                "{}{}{}",
820                prefix,
821                FLOAT_INFIX_WORD,
822                parts.join(" ")
823            ));
824        }
825        if value <= &BigDecimal::from(20) {
826            return Err(N2WError::Type(
827                "list indices must be integers or slices, not decimal.Decimal".to_string(),
828            ));
829        }
830        let cardinal = self.decimal_to_cardinal(value, precision)?;
831        let last = cardinal
832            .chars()
833            .next_back()
834            .ok_or_else(|| index_error("string index out of range"))?;
835        if last == 'a' {
836            Ok(format!("{}evel", drop_last_char(&cardinal)))
837        } else if cardinal.ends_with("set") {
838            Ok(format!("{}tevel", cardinal))
839        } else {
840            Ok(format!("{}evel", cardinal))
841        }
842    }
843}
844
845impl Lang for LangRmPuter {
846    // Num2Word_RM and its variants define no to_currency / to_cheque
847    // at all, so Python raises AttributeError on attribute lookup —
848    // not the NotImplementedError the trait default would give.
849    fn to_currency(
850        &self,
851        _val: &crate::currency::CurrencyValue,
852        _currency: &str,
853        _cents: bool,
854        _separator: Option<&str>,
855        _adjective: bool,
856    ) -> Result<String> {
857        Err(N2WError::Attribute(format!(
858            "'{}' object has no attribute 'to_currency'",
859            "Num2Word_RM_PUTER",
860        )))
861    }
862
863    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
864        Err(N2WError::Attribute(format!(
865            "'{}' object has no attribute 'to_cheque'",
866            "Num2Word_RM_PUTER",
867        )))
868    }
869
870    /// `MINUS_PREFIX_WORD` is a class attribute used directly by
871    /// `to_cardinal`/`to_ordinal`; `self.negword` never exists on this class.
872    /// Exposed here only for completeness — nothing in this file reads it.
873    fn negword(&self) -> &str {
874        MINUS_PREFIX_WORD
875    }
876
877    /// `FLOAT_INFIX_WORD`. This class overrides `to_cardinal_float` (below) and
878    /// never routes through `base.rs`'s generic pointword path, so nothing in
879    /// this file actually reads `pointword`; it is kept only so the trait's
880    /// default float path — were it ever reached — would use the right word.
881    fn pointword(&self) -> &str {
882        FLOAT_INFIX_WORD
883    }
884
885    /// Port of the float/Decimal cardinal path.
886    ///
887    /// This class handles non-integers *inside* `to_cardinal` (the
888    /// `isinstance(number, float)` branch → `float_to_words`), so the generic
889    /// `base.py` `to_cardinal_float` never runs for it and the trait default
890    /// (which doubles the spaces around " comma ") is wrong here. We dispatch
891    /// the two `FloatValue` arms to the faithful reproductions instead.
892    ///
893    /// `precision_override` is ignored on purpose: `num2words`'s `precision=`
894    /// kwarg only takes effect when `hasattr(converter, "precision")`, and this
895    /// bare class has no `precision` attribute — nor does `float_to_words` read
896    /// one. It slices `str(float_number)` directly.
897    fn to_cardinal_float(
898        &self,
899        value: &FloatValue,
900        _precision_override: Option<u32>,
901    ) -> Result<String> {
902        match value {
903            FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
904            FloatValue::Decimal { value, precision } => {
905                self.decimal_to_cardinal(value, *precision)
906            }
907        }
908    }
909
910    /// Port of `Num2Word_RM_PUTER.to_cardinal`, integer path only.
911    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
912        self.card(value)
913    }
914
915    /// Port of `Num2Word_RM_PUTER.to_ordinal`.
916    ///
917    /// `number % 1 != 0` is always false for an `int`, so the float branch is
918    /// not modelled. Negatives recurse behind "minus " and are *not* rejected
919    /// (there is no `verify_ordinal` on this class): `to_ordinal(-1)` ==
920    /// "minus prüm".
921    ///
922    /// The suffix rules read the **last character** of the cardinal:
923    ///   * ends in "a" → drop it, add "evel"   (30 "trenta"  → "trentevel")
924    ///   * ends in "set" → add "tevel"         (77 "settauntaset" →
925    ///                                          "settauntasettevel")
926    ///   * otherwise    → add "evel"           (21 "vainchün" → "vainchünevel")
927    ///
928    /// Note the "a" test runs first, so a cardinal ending in "a" never reaches
929    /// the "set" test (they are mutually exclusive anyway).
930    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
931        if value.is_negative() {
932            return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.to_ordinal(&-value)?));
933        }
934        if value <= &BigInt::from(20) {
935            return Ok(ordinal_word(as_index(value)?)?.to_string());
936        }
937
938        let cardinal = self.card(value)?;
939        // Python's `cardinal[-1]`: IndexError on an empty string. Unreachable
940        // — to_cardinal never returns "" for value > 20.
941        let last = cardinal
942            .chars()
943            .next_back()
944            .ok_or_else(|| index_error("string index out of range"))?;
945
946        if last == 'a' {
947            Ok(format!("{}evel", drop_last_char(&cardinal)))
948        } else if cardinal.ends_with("set") {
949            Ok(format!("{}tevel", cardinal))
950        } else {
951            Ok(format!("{}evel", cardinal))
952        }
953    }
954
955    /// **Not implemented in Python** — `Num2Word_RM_PUTER` defines no
956    /// `to_ordinal_num` and inherits from nothing, so Python raises
957    /// `AttributeError`. The corpus records this for all 90 `ordinal_num`
958    /// rows. Overridden to fail, because the `base.rs` default would wrongly
959    /// succeed. See the module docs' "The AttributeError modes".
960    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
961        Err(attribute_error(
962            "'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'",
963        ))
964    }
965
966    /// **Not implemented in Python** — same story as `to_ordinal_num`; the
967    /// corpus records `AttributeError` for all 35 `year` rows. The `base.rs`
968    /// default would delegate to `to_cardinal` and wrongly succeed.
969    fn to_year(&self, _value: &BigInt) -> Result<String> {
970        Err(attribute_error(
971            "'Num2Word_RM_PUTER' object has no attribute 'to_year'",
972        ))
973    }
974
975    /// Full `to_cardinal(float/Decimal)` routing. The gate is
976    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
977    /// whole-valued float still renders through `float_to_words`
978    /// (`1.0` -> "ün comma nolla") and a whole-valued Decimal still crashes
979    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
980    /// default's whole-value -> int-path shortcut is exactly wrong here.
981    fn cardinal_float_entry(
982        &self,
983        value: &FloatValue,
984        _precision_override: Option<u32>,
985    ) -> Result<String> {
986        match value {
987            FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
988            FloatValue::Decimal { value, precision } => {
989                self.decimal_to_cardinal(value, *precision)
990            }
991        }
992    }
993
994    /// `to_ordinal(float/Decimal)` — see [`LangRmPuter::float_ordinal`] /
995    /// [`LangRmPuter::decimal_ordinal`] for the branch-by-branch mapping.
996    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
997        match value {
998            FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
999            FloatValue::Decimal { value, precision } => self.decimal_ordinal(value, *precision),
1000        }
1001    }
1002
1003    /// **Does not exist on `Num2Word_RM_PUTER`** — same AttributeError as the
1004    /// integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
1005    /// would otherwise echo the repr.
1006    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
1007        Err(attribute_error(
1008            "'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'",
1009        ))
1010    }
1011
1012    /// **Does not exist on `Num2Word_RM_PUTER`** — same AttributeError as the
1013    /// integer [`Lang::to_year`] override.
1014    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
1015        Err(attribute_error(
1016            "'Num2Word_RM_PUTER' object has no attribute 'to_year'",
1017        ))
1018    }
1019
1020
1021    /// `Decimal('-0.0')` per mode. `BigDecimal` cannot carry the sign, so the
1022    /// binding would otherwise demote it to a signed-zero `Float` and render
1023    /// "nolla comma nolla" — wrong. A real `Decimal('-0.0')` is not a `float`,
1024    /// so `to_cardinal`/`to_ordinal` skip the `isinstance(number, float)`
1025    /// branch, fall into the integer-magnitude dispatch (`-0.0 < 0` and
1026    /// `-0.0 % 1 != 0` are both False, `-0.0 < 20` / `<= 20` True) and index a
1027    /// list with a `Decimal` → **`TypeError`**. `ordinal_num`/`year` do not
1028    /// exist on this class at all, so they return `None` and let the demoted
1029    /// `Float{-0.0}` path reach the `AttributeError` overrides below.
1030    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1031        match to {
1032            // CARDINAL_WORDS[<Decimal>] / ORDINAL_WORDS[<Decimal>].
1033            "cardinal" | "ordinal" => Some(Err(N2WError::Type(
1034                "list indices must be integers or slices, not decimal.Decimal".to_string(),
1035            ))),
1036            // ordinal_num / year: AttributeError, served by the demoted-float
1037            // path through the overrides below.
1038            _ => None,
1039        }
1040    }
1041
1042    /// **Does not exist on `Num2Word_RM_PUTER`.** The dispatcher does
1043    /// `converter.str_to_number(value)` for every string input, and this
1044    /// bare class has no such attribute — so *every* `num2words("...")`
1045    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
1046    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
1047    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
1048        Err(attribute_error(
1049            "'Num2Word_RM_PUTER' object has no attribute 'str_to_number'",
1050        ))
1051    }
1052
1053    /// **Does not exist on `Num2Word_RM_PUTER`.** `to_fraction` is a
1054    /// `Num2Word_Base` method (issue #584) and this class has no base, so
1055    /// the attribute lookup fails for every n/d — including `1/0`, where
1056    /// Python never reaches the ZeroDivision check. Corpus: all 25
1057    /// fraction2 rows are AttributeError.
1058    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
1059        Err(attribute_error(
1060            "'Num2Word_RM_PUTER' object has no attribute 'to_fraction'",
1061        ))
1062    }
1063}