Skip to main content

num2words2_core/
lang_dv.rs

1//! Port of `lang_DV.py` (Dhivehi / Maldivian).
2//!
3//! Shape: **self-contained**. `Num2Word_DV` inherits from nothing — it is a
4//! bare `class Num2Word_DV:` with no base class at all, so `base.py`'s engine
5//! (`splitnum`/`clean`/`merge`/`set_high_numwords` on `Num2Word_Base`) is
6//! never involved. Every trait method here is an override; `cards()`,
7//! `maxval()` and `merge()` stay at their `Lang` defaults and are unused.
8//!
9//! Note that `Num2Word_DV` defines its *own* `cards`, `MAXVAL` and
10//! `set_high_numwords` that merely share names with `base.py`'s — they are
11//! unrelated tables driving the module's own `convert_int`. They are modelled
12//! as private fields/consts below rather than through the `Lang` trait, to
13//! avoid the engine accidentally picking them up.
14//!
15//! # The numbering system
16//!
17//! Dhivehi groups digits Indian-style: units take 3 digits, then thousands
18//! take 2 (`ހާސް`), then lakh takes 1 (`ލައްކަ`), and everything above that
19//! reverts to Western 3-digit groups (million, billion, ... nonillion). That
20//! is exactly what `self.grouping` encodes:
21//!
22//! ```text
23//! [-3, -5, -6, -9, -12, -15, -18, -21, -24, -27, -30, -33, -36]
24//! ```
25//!
26//! `convert_int` pairs `[len(digits)] + grouping` pairwise into `(end, start)`
27//! and slices `digits_str[start:end]`, yielding
28//! `parts[0] = last 3 digits`, `parts[1] = digits[-5:-3]` (thousands),
29//! `parts[2] = digits[-6:-5]` (lakh), `parts[3] = digits[-9:-6]` (million), …
30//! `parts[1..]` is then `zip`ped against `reversed(self.cards.items())`, i.e.
31//! the multiplier words in ascending order. `zip` stops at the shorter side,
32//! so `parts[12]` (`digits[-36:-33]`) is never consumed — unreachable anyway,
33//! since MAXVAL caps input at 33 digits.
34//!
35//! # Stem vs nominal
36//!
37//! Every numeral has two forms: a *stem* (bound/attributive, `base_stem`) and
38//! a *nominal* (free-standing, `base_nominal`). Cardinals end in the nominal
39//! form, ordinals in the stem form — that is the whole of the `nominal` flag
40//! threaded through `convert_int`. So `to_cardinal(3)` is `ތިނެއް` while
41//! `to_ordinal(3)` is `ތިން ވަނަ`.
42//!
43//! # Faithfully reproduced Python quirks
44//!
45//! This is a port, not a rewrite. All of the following are verified against
46//! the interpreter and against `bench/corpus.jsonl`:
47//!
48//! 1. **Negative cardinals emit a double space.** When `part_words` is empty
49//!    (any |value| < 1000 with a non-zero last group) `convert_int` returns
50//!    `"".join([]) + " " + word`, i.e. a string with a *leading* space. The
51//!    non-negative path launders this via `result.strip()`, but the `sign == 1`
52//!    path does `" ".join([negword, result])` with **no strip**, so
53//!    `to_cardinal(-1)` == `"މައިނަސް  އެކެއް"` — two spaces. `to_cardinal(-1000)`
54//!    has only one, because `parts[0] == "000"` takes the early `int(parts[0])
55//!    == 0` return, which has no leading space. Reproduced in
56//!    [`LangDv::to_cardinal_float`].
57//!
58//! 2. **`convert_three2nominal` mixes stem and nominal for hundreds.** The
59//!    `x00` branch uses `convert_two2nominal(digits_str[0])` while the `xyz`
60//!    branch uses `convert_two2stem(digits_str[0])`. So cardinal 300 is
61//!    `ތިނެއްސަތޭކަ` (*nominal* three + hundred) but cardinal 555 is
62//!    `ފަސްސަތޭކަ ފަންސާސްފަހެއް` (*stem* five + hundred). Almost certainly a
63//!    typo for `convert_two2stem`, but it is what Python emits.
64//!
65//! 3. **Hundreds spacing is asymmetric.** 100s and 200s concatenate with no
66//!    separator (`ސަތޭކަ` + tail), while 300–900 insert a space before the tail
67//!    (`convert_two2stem(d0) + base_stem[100] + " " + tail`). Hence 101 →
68//!    `ސަތޭކައެކެއް` (no space) but 555 → `ފަސްސަތޭކަ ފަންސާސްފަހެއް` (space).
69//!
70//! 4. **The overflow bound is not `MAXVAL`.** `MAXVAL` is 10^33, but the guard
71//!    reads
72//!
73//!    ```python
74//!    if Decimal(self.MAXVAL).compare(abs(decimal_value).to_integral(ROUND_FLOOR)) < 1:
75//!    ```
76//!
77//!    and `Decimal.__abs__` is a **context operation**: it rounds to the
78//!    default context precision of 28 significant digits with ROUND_HALF_EVEN.
79//!    (`__init__` has `getcontext().prec = 34` sitting commented out, so this
80//!    is a latent bug the author half-noticed.) A 33-digit input is therefore
81//!    rounded *up* to 10^33 before the comparison, and `compare(...) < 1` means
82//!    `MAXVAL <= rounded`, so it raises. The true threshold is
83//!    `abs(value) >= 10^33 - 50000`: `to_cardinal(10**33 - 50001)` succeeds but
84//!    `to_cardinal(10**33 - 50000)` raises `OverflowError` (the tie rounds to
85//!    even, i.e. up). `ROUND_FLOOR` on `to_integral` is a red herring — `abs`
86//!    has already made the value integral, so `to_integral` is a no-op.
87//!    Modelled by [`round_half_even_28`]. Crucially the *conversion* still uses
88//!    the exact digits — only the guard sees the rounded value.
89//!
90//! 5. `to_ordinal`/`to_ordinal_num` raise `TypeError` (not ValueError) for any
91//!    negative input, via `verify_ordinal`. `to_cardinal` is unaffected.
92//!
93//! # Hazard: the overflow bound depends on *global* interpreter state
94//!
95//! [`CTX_PREC`] is pinned to 28 — the pristine `decimal` default, and the
96//! value the frozen corpus was generated under. But `getcontext()` is global
97//! (thread-local) mutable state, and `lang_AR.py:408` mutates it and never
98//! restores it:
99//!
100//! ```python
101//! except decimal.InvalidOperation:
102//!     decimal.getcontext().prec = len(temp_number_dec.as_tuple().digits)
103//! ```
104//!
105//! That handler fires for any input with more digits than the current
106//! precision, so a single `Num2Word_AR().to_cardinal(10**33 - 1)` earlier in
107//! the *same process* raises the global precision to 33 — after which
108//! `Num2Word_DV().to_cardinal(10**33 - 1)` stops raising `OverflowError` and
109//! happily returns a string. Same object, same input, different answer,
110//! depending on whether an unrelated language ran first.
111//!
112//! This port is deliberately order-independent and reproduces the pristine
113//! (prec == 28) behaviour. If a differential harness ever runs AR-then-DV in
114//! one interpreter, Python — not this file — is what changed.
115//!
116//! # Currency
117//!
118//! `to_currency` shares nothing with `base.py`'s. The signature is
119//!
120//! ```python
121//! def to_currency(self, value, currency="ރުފިޔާ", cents="ލާރި"):
122//! ```
123//!
124//! — `currency` and `cents` are **words**, not an ISO code and a verbosity
125//! flag. `Num2Word_DV` has no `CURRENCY_FORMS`, no `CURRENCY_PRECISION`, no
126//! `pluralize`, and no `separator`/`adjective` kwargs, so those trait hooks all
127//! stay at their defaults and are never consulted.
128//!
129//! The dispatcher passes `currency=<ISO code>` straight through, and DV
130//! interpolates whatever it is given **verbatim** as the unit word: there is no
131//! lookup, so `to_currency(1, currency="EUR")` is `"އެއް EUR"` and no code can
132//! ever raise `NotImplementedError`. Every code is "supported", which is
133//! exactly what the corpus records for all nine it tries. Omitting `currency=`
134//! instead yields the class's own default word — `to_currency(1)` is
135//! `"އެއް ރުފިޔާ"` (rufiyaa), the real Maldivian unit — which is what the
136//! generated [`Lang::default_currency`] override carries; the binding resolves
137//! it before `to_currency` runs, so the `&str` arriving here is already right.
138//!
139//! Continuing the quirk list above, all verified against the interpreter:
140//!
141//! 6. **Cents use ROUND_HALF_EVEN and go negative.** `int_part` is
142//!    `value.to_integral_value()` — round to *nearest*, ties to even — not
143//!    floor, and `frac_part` is `(value - int_part) * 100` after the same
144//!    rounding. So 99.99 rounds **up** to 100 and leaves `frac_part == -1`,
145//!    printing `"ސަތޭކަ EUR މައިނަސް  އެއް ލާރި"`: "one hundred EUR **minus** one
146//!    laari", carrying quirk 1's double space along with it (`to_cardinal_float`
147//!    is what renders that -1). 1234.56 likewise becomes 1235 EUR minus 44
148//!    laari. And 0.5 ties to even, so `int_part` is 0 and it prints "fifty
149//!    laari" with no unit word at all.
150//!
151//! 7. **Either segment vanishes when its part is zero.** Both are guarded by a
152//!    bare truthiness test on a `Decimal`, so 0.01 is `"އެއް ލާރި"` (no unit
153//!    word) and 1.0 is `"އެއް EUR"` (no cents). When both parts round to zero
154//!    but the value itself is non-zero — 0.001, say — `" ".join([])` yields the
155//!    **empty string**.
156//!
157//! 8. **A positive Decimal exponent survives `to_integral_value`, so large
158//!    floats collapse to their coefficient.** `to_integral_value` returns a
159//!    value whose exponent is `>= 0` *unchanged*, and `to_cardinal_float` then
160//!    reads `as_tuple().digits` — the coefficient alone. `str(1e21)` is
161//!    `'1e+21'`, i.e. `Decimal('1E+21')` with digits `(1,)` and exponent 21, so
162//!    `to_currency(1e21)` renders **`"އެއް EUR"`** — "one EUR". The exponent is
163//!    silently dropped. Only the float path can reach this: `Decimal(int)`
164//!    always has exponent 0, which is why the verified integer modes are
165//!    immune. Modelled by keeping the currency path on `BigDecimal` and reading
166//!    its `(coefficient, exponent)` rather than flattening to a `BigInt` — see
167//!    [`LangDv::to_cardinal_float_dec`].
168//!
169//! `to_cheque` does not exist on the class at all, so it surfaces as
170//! `AttributeError` from the dispatcher's `getattr`, not as the
171//! `NotImplementedError` that `default_to_cheque` would produce.
172//!
173//! # Float/Decimal routing
174//!
175//! DV has no whole-value fast path: `to_cardinal(5.0)` goes through
176//! `Decimal(str(5.0))` == `Decimal('5.0')`, whose `as_tuple()` has exponent
177//! -1, so the pointword branch runs and the result is "five point zero"
178//! (`ފަހެއް ޕޮއިންޓް ސުމެއް`). [`Lang::cardinal_float_entry`] is therefore
179//! overridden to bypass the base's whole -> int routing entirely, and the
180//! ordinal/year entries follow the class's own methods. More reproduced
181//! quirks, all corpus-verified:
182//!
183//! 9.  **`str(float)`'s scientific regime collapses to the coefficient.**
184//!     `str(1e16)` is `'1e+16'`, so `Decimal(str(1e16))` has digits `(1,)`
185//!     and exponent 16 — and `convert_int` only ever sees the digit tuple, so
186//!     `to_cardinal(1e16)` is `އެކެއް` ("one"). Same for `Decimal('1E+2')` ->
187//!     "one". Reconstructed in [`py_float_dec_tuple`] from Rust's
188//!     shortest-round-trip digits (`{:e}`), which match `repr(float)`'s
189//!     digits by uniqueness of the shortest representation; the regime
190//!     boundary (scientific iff adjusted exponent >= 16 or < -4) is
191//!     CPython's `float_repr_style` cutoff.
192//! 10. **Whole floats keep their `.0`.** A fixed-point repr of an integral
193//!     float appends ".0", giving the Decimal a trailing zero digit and
194//!     exponent -1 — hence the trailing `ސުމެއް`/`ސުން` after the pointword
195//!     in every `N.0` row.
196//! 11. **`cardinal(0.0)`, `cardinal(-0.0)` and `cardinal(".5")` raise
197//!     IndexError**: `digits[:exponent]` is empty and `convert_int` dies on
198//!     `parts[0]` — before the sign is even looked at, which is why `-0.0`
199//!     raises rather than printing a negword.
200//! 12. **`verify_ordinal` raises TypeError for non-integral floats first**,
201//!     then for negatives (also TypeError); `-0.0` passes both checks
202//!     (`-0.0 == int(-0.0)`, `abs(-0.0) == -0.0`) and dies later with
203//!     cardinal's IndexError — except in `to_ordinal_num`, which happily
204//!     formats it as `-0.0 ވަނަ`.
205//! 13. **Ordinal floats mix nominal and stem.** `to_cardinal_float(value,
206//!     nominal=False)` hardcodes the *integer* part nominal — Python calls
207//!     `convert_int(digits[:exponent])` with the default — while the
208//!     fractional digits follow the flag as stems: `to_ordinal(1.0)` is
209//!     `އެކެއް ޕޮއިންޓް ސުން ވަނަ` (nominal one, stem zero).
210//! 14. **`to_year` in [1100, 2000) splits float years absurdly**:
211//!     `to_year(1234.0)` computes `high, low = value // 100, value % 100` ==
212//!     `12.0, 34.0` (both still floats!) and renders "twelve point zero
213//!     hundred thirty-four point zero". Negative years go through `abs()`
214//!     *before* the cardinal, so no negword and no double space:
215//!     `to_year(-1.5)` == `އެކެއް ޕޮއިންޓް ފަހެއް ބީ.ސީ`.
216//!
217//! # Strings
218//!
219//! `str_to_number` is `to_decimal`, which turns `InvalidOperation` into
220//! `TypeError("{} is not a valid value.")` — that is the whole override.
221//! One deliberate shortcut: Python parses `"NaN"` into `Decimal('NaN')`
222//! successfully and only raises later, when `to_cardinal_float`'s overflow
223//! guard evaluates `Decimal(MAXVAL).compare(NaN) < 1` (ordered comparison
224//! with a NaN Decimal -> `decimal.InvalidOperation`). The pyo3 shim
225//! hardcodes `ParsedNumber::NaN -> ValueError` before any DV hook could
226//! run, so this port raises the InvalidOperation from `str_to_number`
227//! itself — the same observable exception for every corpus row ("NaN" only
228//! appears under `to='cardinal'`). The one place the shortcut is visibly
229//! unfaithful is `to_ordinal("NaN")`, where genuine Python raises
230//! ValueError from `int(Decimal('NaN'))` — a row no corpus exercises.
231//!
232//! # Fraction
233//!
234//! `Num2Word_DV` has no `to_fraction` — like `to_cheque`, the dispatcher's
235//! attribute lookup fails, so every fraction-corpus row and every "n/d"
236//! string (whatever `to=` says) is `AttributeError`, including `"1/0"`,
237//! which dies on lookup before any division could raise ZeroDivisionError.
238//! Known gap: `to='fraction'` with a *plain* numeric string ("5", "1.5")
239//! cannot be reproduced from this file — the pyo3 shim's `dec_mode`
240//! hardcodes the base-class TypeError (`to_fraction() missing 1 required
241//! positional argument`) for that combination and never consults the
242//! language, while Python raises AttributeError on the `getattr`.
243//!
244//! # Grammatical kwargs
245//!
246//! `to_cardinal(value, nominal=True)` is the only extra cardinal kwarg;
247//! Python tests it with bare truthiness (`if not nominal`), so False, 0,
248//! None and "" all select the stem form. `to_year`'s `suffix=""` rides
249//! `to_year_kw`; a truthy suffix overrides the implicit `bcword` on
250//! negative years. Everything else falls back to Python via the
251//! `kw.only` guard.
252//!
253//! `cardinal_from_decimal` stays at its default: DV's `to_currency` never
254//! produces fractional cents, since `frac_part` is always run through
255//! `to_integral_value`.
256
257use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
258use crate::currency::CurrencyValue;
259use crate::floatpath::FloatValue;
260use crate::strnum::{python_decimal_parse, ParsedNumber};
261use bigdecimal::BigDecimal;
262use num_bigint::BigInt;
263use num_integer::Integer;
264use num_traits::{Signed, Zero};
265
266const NEGWORD: &str = "މައިނަސް";
267const ORDWORD: &str = "ވަނަ";
268const BCWORD: &str = "ބީ.ސީ";
269
270/// `to_currency`'s `cents=` default: the *word* ލާރި (laari, 1/100 rufiyaa),
271/// not a verbosity flag. See the `to_currency` impl for why the trait's bool
272/// cannot carry it.
273const CENTSWORD: &str = "ލާރި";
274
275/// `self.high_numwords`, in source order. `set_high_numwords` zips this with
276/// `range(3 + 3*len(high), 3, -3)` == `30, 27, …, 6`, so index 0 is 10^30.
277const HIGH_NUMWORDS: [&str; 9] = [
278    "ނޮނިލި",
279    "އޮކްޓިލި",
280    "ސެޕްޓިލި",
281    "ސެކްސްޓިލި",
282    "ކުއިންޓިލި",
283    "ކުއަޑްރި",
284    "ޓްރި",
285    "ބިލި",
286    "މިލި",
287];
288
289/// The suffix `set_high_numwords` appends to every high numword.
290const HIGH_SUFFIX: &str = "ޔަން";
291
292/// `self.grouping`, i.e. `[-3, -5] + [(x * 3 + 6) * -1 for x in range(len(self.cards))]`
293/// with `len(self.cards) == 11`.
294const GROUPING: [isize; 13] = [
295    -3, -5, -6, -9, -12, -15, -18, -21, -24, -27, -30, -33, -36,
296];
297
298/// Number of significant digits `Decimal.__abs__` rounds to (the default
299/// context precision, which `__init__` leaves untouched).
300const CTX_PREC: usize = 28;
301
302/// `self.MAXVAL` == `list(self.cards.keys())[0] * 1000` == 10^30 * 1000.
303const MAXVAL_EXP: usize = 33;
304
305pub struct LangDv {
306    /// `reversed(self.cards.items())` — multiplier words in ascending order of
307    /// their key, which is the order `convert_int` zips `parts[1:]` against.
308    /// Only the word is ever read (the comprehension binds `base` and drops
309    /// it), so the keys are not carried.
310    mult_words_asc: Vec<String>,
311    maxval: BigInt,
312}
313
314pub fn new() -> LangDv {
315    LangDv::new()
316}
317
318impl Default for LangDv {
319    fn default() -> Self {
320        LangDv::new()
321    }
322}
323
324impl LangDv {
325    pub fn new() -> LangDv {
326        // Mirror of `set_high_numwords` + the two explicit inserts. Python
327        // builds `cards` descending (10^30 … 10^6, then 100000, then 1000);
328        // `convert_int` only ever consumes it reversed, so store it ascending.
329        let mut cards_desc: Vec<String> = HIGH_NUMWORDS
330            .iter()
331            .map(|w| format!("{}{}", w, HIGH_SUFFIX))
332            .collect();
333        cards_desc.push("ލައްކަ".to_string()); // self.cards[100000]
334        cards_desc.push("ހާސް".to_string()); // self.cards[1000]
335
336        let mut mult_words_asc = cards_desc;
337        mult_words_asc.reverse();
338
339        LangDv {
340            mult_words_asc,
341            maxval: pow10(MAXVAL_EXP),
342        }
343    }
344
345    /// `self.base_stem` — bound/attributive forms. Missing key => `KeyError`.
346    fn base_stem(&self, k: i64) -> Result<&'static str> {
347        Ok(match k {
348            0 => "ސުން",
349            1 => "އެއް",
350            2 => "ދެ",
351            3 => "ތިން",
352            4 => "ހަތަރު",
353            5 => "ފަސް",
354            6 => "ހަ",
355            7 => "ހަތް",
356            8 => "އައް",
357            9 => "ނުވަ",
358            10 => "ދިހަ",
359            100 => "ސަތޭކަ",
360            200 => "ދުއިސައްތަ",
361            _ => return Err(N2WError::Key(k.to_string())),
362        })
363    }
364
365    /// `self.base_nominal` — free-standing forms. Missing key => `KeyError`.
366    fn base_nominal(&self, k: i64) -> Result<&'static str> {
367        Ok(match k {
368            0 => "ސުމެއް",
369            1 => "އެކެއް",
370            2 => "ދޭއް",
371            3 => "ތިނެއް",
372            4 => "ހަތަރެއް",
373            5 => "ފަހެއް",
374            6 => "ހައެއް",
375            7 => "ހަތެއް",
376            8 => "އަށެއް",
377            9 => "ނުވައެއް",
378            10 => "ދިހައެއް",
379            11 => "އެގާރަ",
380            12 => "ބާރަ",
381            13 => "ތޭރަ",
382            14 => "ސައުދަ",
383            15 => "ފަނަރަ",
384            16 => "ސޯޅަ",
385            17 => "ސަތާރަ",
386            18 => "އަށާރަ",
387            19 => "ނަވާރަ",
388            20 => "ވިހި",
389            21 => "އެކާވީސް",
390            22 => "ބާވީސް",
391            23 => "ތޭވީސް",
392            24 => "ސައުވީސް",
393            25 => "ފަންސަވީސް",
394            26 => "ސައްބީސް",
395            27 => "ހަތާވީސް",
396            28 => "އަށާވީސް",
397            29 => "ނަވާވީސް",
398            30 => "ތިރީސް",
399            40 => "ސާޅީސް",
400            50 => "ފަންސާސް",
401            60 => "ފަސްދޮޅަސް",
402            70 => "ހަތްދިހަ",
403            80 => "އައްޑިހަ",
404            90 => "ނުވަދިހަ",
405            _ => return Err(N2WError::Key(k.to_string())),
406        })
407    }
408
409    /// `convert_decade2nominal`: `self.base_nominal[int(decade) * 10]`.
410    fn convert_decade2nominal(&self, decade: &str) -> Result<&'static str> {
411        self.base_nominal(py_int(decade)? * 10)
412    }
413
414    /// `convert_decade2stem`: 1 => `base_stem[10]`, else the nominal decade.
415    fn convert_decade2stem(&self, decade: &str) -> Result<&'static str> {
416        if py_int(decade)? == 1 {
417            return self.base_stem(10);
418        }
419        self.convert_decade2nominal(decade)
420    }
421
422    /// `convert_two2nominal`.
423    ///
424    /// Unlike its `2stem` sibling this does **not** do `str(int(...))` first,
425    /// so it indexes the caller's string as-is. Callers only ever pass an
426    /// already-trimmed 1-char string or an exactly-2-char `digits_str[1:3]`
427    /// slice, so the `< 30` guard always covers the 1-char case and the
428    /// `digits_str[1]` access below is never out of range.
429    fn convert_two2nominal(&self, digits_str: &str) -> Result<String> {
430        let n = py_int(digits_str)?;
431        if n < 30 {
432            return Ok(self.base_nominal(n)?.to_string());
433        }
434
435        let d1 = char_at(digits_str, 1)?;
436        let d0 = char_at(digits_str, 0)?;
437        if py_int(&d1)? == 0 {
438            return Ok(self.convert_decade2stem(&d0)?.to_string());
439        }
440
441        Ok(format!(
442            "{}{}",
443            self.convert_decade2stem(&d0)?,
444            self.base_nominal(py_int(&d1)?)?
445        ))
446    }
447
448    /// `convert_two2stem`.
449    fn convert_two2stem(&self, digits_str: &str) -> Result<String> {
450        let n = py_int(digits_str)?;
451        let ds = n.to_string(); // `str(int(digits_str))` — trim leading zeros
452
453        if n < 11 {
454            return Ok(self.base_stem(n)?.to_string());
455        }
456        if n < 30 {
457            return Ok(self.base_nominal(n)?.to_string());
458        }
459
460        let d1 = char_at(&ds, 1)?;
461        let d0 = char_at(&ds, 0)?;
462        if py_int(&d1)? == 0 {
463            return Ok(self.convert_decade2stem(&d0)?.to_string());
464        }
465
466        Ok(format!(
467            "{}{}",
468            self.convert_decade2stem(&d0)?,
469            self.base_stem(py_int(&d1)?)?
470        ))
471    }
472
473    /// `convert_three2stem`.
474    ///
475    /// 100/200 have dedicated stems and glue straight onto the tail; 300–900
476    /// are built as `<digit stem> + ސަތޭކަ + " " + <tail>` — note the space,
477    /// which the 100/200 branches do not emit. Quirk 3 in the module docs.
478    fn convert_three2stem(&self, digits_str: &str) -> Result<String> {
479        let ds = py_int(digits_str)?.to_string(); // trim leading zeros
480
481        if ds.len() < 3 {
482            return self.convert_two2stem(&ds);
483        }
484
485        let d0 = char_at(&ds, 0)?;
486        let tail = py_slice(&ds, 1, 3); // digits_str[1:3]
487        let tail_n = py_int(&tail)?;
488
489        if py_int(&d0)? == 1 {
490            if tail_n == 0 {
491                return Ok(self.base_stem(100)?.to_string());
492            }
493            return Ok(format!(
494                "{}{}",
495                self.base_stem(100)?,
496                self.convert_two2stem(&tail)?
497            ));
498        }
499
500        if py_int(&d0)? == 2 {
501            if tail_n == 0 {
502                return Ok(self.base_stem(200)?.to_string());
503            }
504            return Ok(format!(
505                "{}{}",
506                self.base_stem(200)?,
507                self.convert_two2stem(&tail)?
508            ));
509        }
510
511        if tail_n == 0 {
512            return Ok(format!(
513                "{}{}",
514                self.convert_two2stem(&d0)?,
515                self.base_stem(100)?
516            ));
517        }
518
519        Ok(format!(
520            "{}{} {}",
521            self.convert_two2stem(&d0)?,
522            self.base_stem(100)?,
523            self.convert_two2stem(&tail)?
524        ))
525    }
526
527    /// `convert_three2nominal`.
528    ///
529    /// Mirrors `convert_three2stem` except the tails are nominal — **and** the
530    /// `x00` branch uses `convert_two2nominal(digits_str[0])` where the `xyz`
531    /// branch uses `convert_two2stem(digits_str[0])`. That inconsistency is
532    /// quirk 2 in the module docs: cardinal 300 == `ތިނެއްސަތޭކަ` (nominal three)
533    /// but cardinal 555 == `ފަސްސަތޭކަ ފަންސާސްފަހެއް` (stem five). Preserved verbatim.
534    fn convert_three2nominal(&self, digits_str: &str) -> Result<String> {
535        let ds = py_int(digits_str)?.to_string(); // trim leading zeros
536
537        if ds.len() < 3 {
538            return self.convert_two2nominal(&ds);
539        }
540
541        let d0 = char_at(&ds, 0)?;
542        let tail = py_slice(&ds, 1, 3);
543        let tail_n = py_int(&tail)?;
544
545        if py_int(&d0)? == 1 {
546            if tail_n == 0 {
547                return Ok(self.base_stem(100)?.to_string());
548            }
549            return Ok(format!(
550                "{}{}",
551                self.base_stem(100)?,
552                self.convert_two2nominal(&tail)?
553            ));
554        }
555
556        if py_int(&d0)? == 2 {
557            if tail_n == 0 {
558                return Ok(self.base_stem(200)?.to_string());
559            }
560            return Ok(format!(
561                "{}{}",
562                self.base_stem(200)?,
563                self.convert_two2nominal(&tail)?
564            ));
565        }
566
567        if tail_n == 0 {
568            // NOTE: nominal here, stem in the branch below. Python bug, kept.
569            return Ok(format!(
570                "{}{}",
571                self.convert_two2nominal(&d0)?,
572                self.base_stem(100)?
573            ));
574        }
575
576        Ok(format!(
577            "{}{} {}",
578            self.convert_two2stem(&d0)?,
579            self.base_stem(100)?,
580            self.convert_two2nominal(&tail)?
581        ))
582    }
583
584    /// `convert_int`. `digits_str` is the decimal magnitude, i.e. Python's
585    /// `"".join(str(d) for d in digits)` where `digits` came from
586    /// `Decimal.as_tuple()` (sign-free, and exponent 0 for integer input).
587    fn convert_int(&self, digits_str: &str, nominal: bool) -> Result<String> {
588        if digits_str == "0" {
589            return Ok(if nominal {
590                self.base_nominal(0)?.to_string()
591            } else {
592                self.base_stem(0)?.to_string()
593            });
594        }
595
596        // parts = filter(truthy, digits_str[start:end] for end, start in
597        //                pairwise([len(digits_str)] + self.grouping))
598        //
599        // Emptiness is monotone here: `s[-a:-b]` is non-empty iff `len(s) > b`,
600        // and the `b`s increase along `grouping`, so the filter only ever trims
601        // a suffix of the sequence and never renumbers a surviving element.
602        let mut bounds: Vec<isize> = Vec::with_capacity(1 + GROUPING.len());
603        bounds.push(digits_str.chars().count() as isize);
604        bounds.extend_from_slice(&GROUPING);
605
606        let mut parts: Vec<String> = Vec::new();
607        for w in bounds.windows(2) {
608            let (end, start) = (w[0], w[1]);
609            let p = py_slice(digits_str, start, end);
610            if !p.is_empty() {
611                parts.push(p);
612            }
613        }
614
615        // `parts[0]` — IndexError if the filter left nothing. Unreachable for
616        // integer input (the magnitude always has >= 1 digit), but this is the
617        // site where float input dies, so it is modelled rather than assumed.
618        let head = match parts.first() {
619            Some(h) => h.clone(),
620            None => return Err(N2WError::Index("list index out of range".into())),
621        };
622
623        // zip(parts[1:], reversed(self.cards.items())) — stops at the shorter.
624        let mut part_words: Vec<String> = Vec::new();
625        for (num, word) in parts.iter().skip(1).zip(self.mult_words_asc.iter()) {
626            if py_int(num)? != 0 {
627                part_words.push(format!("{}{}", self.convert_three2stem(num)?, word));
628            }
629        }
630        part_words.reverse();
631
632        if py_int(&head)? == 0 {
633            return Ok(part_words.join(" "));
634        }
635
636        // NOTE: when `part_words` is empty this yields a leading space. The
637        // caller strips it only on the non-negative path — see quirk 1.
638        let tail = if nominal {
639            self.convert_three2nominal(&head)?
640        } else {
641            self.convert_three2stem(&head)?
642        };
643        Ok(format!("{} {}", part_words.join(" "), tail))
644    }
645
646    /// `to_cardinal_float`, restricted to integer input.
647    fn to_cardinal_float(&self, value: &BigInt, nominal: bool) -> Result<String> {
648        // if Decimal(self.MAXVAL).compare(abs(v).to_integral(ROUND_FLOOR)) < 1
649        //
650        // `compare(x) < 1` means `MAXVAL <= x`. `abs()` rounds to 28
651        // significant digits first (quirk 4), so this is *not* `v >= 10^33`.
652        if round_half_even_28(&value.abs()) >= self.maxval {
653            // Python formats the signed value, not its absolute value.
654            return Err(N2WError::Overflow(format!(
655                "abs({}) must be less than {}.",
656                value, self.maxval
657            )));
658        }
659
660        let result = self.convert_int(&value.abs().to_string(), nominal)?;
661
662        if value.is_negative() {
663            // " ".join([negword, result]) — no strip, hence the double space.
664            return Ok(format!("{} {}", NEGWORD, result));
665        }
666        Ok(result.trim().to_string())
667    }
668
669    /// `to_cardinal_float`, for the currency path — where the argument is an
670    /// already-integral `Decimal` rather than an `int`.
671    ///
672    /// Deliberately *not* folded into [`LangDv::to_cardinal_float`]. That one
673    /// takes a `BigInt`, which flattens a `Decimal` to its full digit
674    /// expansion; the whole point here is that Python does **not** expand —
675    /// `as_tuple()` hands `convert_int` the bare coefficient and the exponent
676    /// is dropped on the floor (quirk 8). `Decimal(int)` always has exponent 0,
677    /// so the two agree on every value the verified integer modes can produce
678    /// and diverge only where Python itself does.
679    fn to_cardinal_float_dec(&self, value: &BigDecimal, nominal: bool) -> Result<String> {
680        // sign, digits, exponent = decimal_value.as_tuple()
681        let (coefficient, scale) = value.as_bigint_and_exponent();
682        let exponent = -scale;
683        let digits = coefficient.abs().to_string();
684
685        if self.overflows(&coefficient, exponent) {
686            // Python formats the *Decimal*, so a positive exponent prints in
687            // scientific notation: "abs(1.5E+33) must be less than ...".
688            return Err(N2WError::Overflow(format!(
689                "abs({}) must be less than {}.",
690                py_decimal_str(value),
691                self.maxval
692            )));
693        }
694
695        // The `exponent < 0` arm of `to_cardinal_float` (the pointword/
696        // convert_discrete branch) is unreachable from here: every caller feeds
697        // this a `to_integral_value()` result, which never has a negative
698        // exponent. So only the `convert_int` arm can run.
699        let result = self.convert_int(&digits, nominal)?;
700
701        if coefficient.is_negative() {
702            // " ".join([negword, result]) — no strip, hence the double space
703            // that quirk 1 describes. This is the path 99.99's frac_part of -1
704            // takes, which is why the corpus shows "މައިނަސް  އެއް ލާރި".
705            return Ok(format!("{} {}", NEGWORD, result));
706        }
707        Ok(result.trim().to_string())
708    }
709
710    /// `to_cardinal_float`'s overflow guard for a `Decimal` argument:
711    /// `Decimal(MAXVAL).compare(abs(v).to_integral(ROUND_FLOOR)) < 1`, i.e.
712    /// `MAXVAL <= round_to_28_significant_digits(|v|)` — see quirk 4.
713    ///
714    /// `exponent` is Python's, so `>= 0` for every caller.
715    fn overflows(&self, coefficient: &BigInt, exponent: i64) -> bool {
716        if coefficient.is_zero() {
717            return false;
718        }
719        // |v| >= 10^adjusted, where `adjusted` is the power of ten of the
720        // leading digit. So adjusted >= 33 settles it as MAXVAL <= |v| without
721        // materialising 10^exponent — which a Decimal("1E+999999999") would
722        // otherwise demand, turning Python's instant OverflowError into an OOM.
723        // Exact, not an approximation: rounding to nearest cannot pull a value
724        // that is already >= 10^33 below 10^33, since 10^33 is itself
725        // representable in 28 significant digits.
726        let ndigits = coefficient.abs().to_string().len() as i64;
727        if exponent + ndigits - 1 >= MAXVAL_EXP as i64 {
728            return true;
729        }
730        // |v| < 10^33 now, so the exact integer is cheap. Rounding to 28
731        // significant digits can still carry it up to exactly 10^33 — that is
732        // the whole of quirk 4 — so the full check still has to run.
733        let exact = coefficient.abs() * pow10(exponent.max(0) as usize);
734        round_half_even_28(&exact) >= self.maxval
735    }
736
737    /// `verify_ordinal`. The `value == int(value)` float check cannot fail for
738    /// integer input; only the negative check is reachable, and it raises
739    /// `TypeError` rather than the `ValueError` one might expect.
740    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
741        if value.is_negative() {
742            return Err(N2WError::Type(format!(
743                "Cannot treat negative num {} as ordinal.",
744                value
745            )));
746        }
747        Ok(())
748    }
749
750    /// `convert_discrete`: render each individual digit as a *free* word. The
751    /// fractional tail of the float path is spelled digit by digit, so this
752    /// never sees anything but single 0-9 characters and neither table can
753    /// miss. `nominal` picks `base_nominal` (cardinals) over `base_stem`; the
754    /// non-nominal branch is only reachable via ordinal-float input, which
755    /// `verify_ordinal` rejects before it can arrive here.
756    fn convert_discrete(&self, digits_str: &str, nominal: bool) -> Result<String> {
757        let mut out: Vec<String> = Vec::new();
758        for ch in digits_str.chars() {
759            let d = py_int(&ch.to_string())?;
760            out.push(if nominal {
761                self.base_nominal(d)?.to_string()
762            } else {
763                self.base_stem(d)?.to_string()
764            });
765        }
766        Ok(out.join(" "))
767    }
768
769    /// The *real* `to_cardinal_float(value, nominal)` body — the one Python
770    /// reaches with genuine float/Decimal input. Not to be confused with
771    /// [`LangDv::to_cardinal_float`] above, which shares the Python name but is
772    /// only ever fed an already-integral argument by the integer/currency
773    /// modes. Both the Float and Decimal arms of the trait entry point reduce
774    /// to `decimal_value.as_tuple()` — `(sign_negative, digit_str, exponent)` —
775    /// and converge here.
776    ///
777    /// `digit_str` is the coefficient with no leading zeros (bar a lone `"0"`);
778    /// `exponent` is Python's `as_tuple` exponent. The overflow guard has
779    /// already fired in the caller.
780    fn cardinal_float_body(
781        &self,
782        sign_negative: bool,
783        digit_str: &str,
784        exponent: i64,
785        nominal: bool,
786    ) -> Result<String> {
787        let result = if exponent < 0 {
788            let n = digit_str.chars().count() as i64;
789            // Python slices the coefficient tuple: `digits[:exponent]` is the
790            // integer part, `digits[exponent:len(digits)]` the fractional one.
791            let cut = n + exponent; // == len(digits) - |exponent|
792
793            // `digits[:exponent]` — empty when |exponent| >= len(digits), which
794            // makes convert_int die with IndexError. That is evaluated first in
795            // Python's join list, so 0.5 / 0.01 / 0.001 / 1.0-with-no-int-part
796            // raise before the fractional digits are ever touched.
797            let int_digits: String = if cut > 0 {
798                digit_str.chars().take(cut as usize).collect()
799            } else {
800                String::new()
801            };
802            // Python hardcodes nominal=True for the integer part here (the
803            // default arg), independent of the flag threaded to the fraction.
804            let int_part = self.convert_int(&int_digits, true)?;
805
806            // `digits[exponent:len(digits)]` — the fractional digits, clamped
807            // to the whole coefficient when |exponent| exceeds its length
808            // (unreachable in practice: int_part has already raised by then).
809            let frac_digits: String = if cut > 0 {
810                digit_str.chars().skip(cut as usize).collect()
811            } else {
812                digit_str.chars().collect()
813            };
814            let frac_part = self.convert_discrete(&frac_digits, nominal)?;
815
816            // " ".join([convert_int(...), pointword, convert_discrete(...)]).
817            // int_part still carries convert_int's leading space; the strip on
818            // the non-negative path below launders it (quirk 1).
819            format!("{} {} {}", int_part, self.pointword(), frac_part)
820        } else {
821            self.convert_int(digit_str, nominal)?
822        };
823
824        if sign_negative {
825            // " ".join([negword, result]) — no strip, so convert_int's leading
826            // space survives as the quirk-1 double space.
827            Ok(format!("{} {}", NEGWORD, result))
828        } else {
829            Ok(result.trim().to_string())
830        }
831    }
832
833    /// `to_cardinal_float`'s overflow guard for genuine float/Decimal input:
834    /// `Decimal(MAXVAL).compare(abs(v).to_integral(ROUND_FLOOR)) < 1`, i.e.
835    /// `MAXVAL <= floor(round_to_28_significant_digits(|v|))` (quirk 4). Unlike
836    /// [`LangDv::overflows`], this floors the fractional part, matching
837    /// `to_integral(ROUND_FLOOR)` — the value here can carry a real fraction.
838    ///
839    /// The 28-digit rounding is a no-op for any float (<= 17 significant digits)
840    /// and for every corpus Decimal, but is reproduced for faithfulness.
841    fn float_path_overflows(&self, digit_str: &str, exponent: i64) -> bool {
842        let d = match BigInt::parse_bytes(digit_str.as_bytes(), 10) {
843            Some(d) if !d.is_zero() => d,
844            _ => return false,
845        };
846        // abs(v) context-rounds |coefficient| to 28 significant digits; scaling
847        // by 10**exponent does not change which digits are significant, so
848        // rounding the coefficient is rounding the value.
849        let d = round_half_even_28(&d);
850        // to_integral(ROUND_FLOOR): floor(d * 10**exponent), with d >= 0.
851        let floored = if exponent >= 0 {
852            d * pow10(exponent as usize)
853        } else {
854            d / pow10((-exponent) as usize) // truncation == floor for d >= 0
855        };
856        floored >= self.maxval
857    }
858
859    /// `to_cardinal_float(value, nominal)` for a value already reduced to its
860    /// `as_tuple()` view: overflow guard, then [`LangDv::cardinal_float_body`].
861    fn cardinal_tuple(
862        &self,
863        sign_negative: bool,
864        digit_str: &str,
865        exponent: i64,
866        nominal: bool,
867    ) -> Result<String> {
868        if self.float_path_overflows(digit_str, exponent) {
869            // Python formats the *Decimal* (`errmsg_toobig.format(decimal_value,
870            // self.MAXVAL)`), so a positive exponent prints scientifically.
871            return Err(N2WError::Overflow(format!(
872                "abs({}) must be less than {}.",
873                py_decimal_str_parts(sign_negative, digit_str, exponent),
874                self.maxval
875            )));
876        }
877        self.cardinal_float_body(sign_negative, digit_str, exponent, nominal)
878    }
879
880    /// The full `to_cardinal_float(value, nominal)` for genuine float/Decimal
881    /// input — `to_decimal` (a no-op tuple reduction here), guard, body.
882    fn cardinal_float_full(&self, value: &FloatValue, nominal: bool) -> Result<String> {
883        let (sign_negative, digit_str, exponent) = float_value_tuple(value);
884        self.cardinal_tuple(sign_negative, &digit_str, exponent, nominal)
885    }
886
887    /// `to_cardinal(f)` for an f64 the *port itself* produced (`to_year`'s
888    /// `high`/`low`), routed through `Decimal(str(f))` like any float input.
889    fn cardinal_from_f64(&self, f: f64, nominal: bool) -> Result<String> {
890        let (sign_negative, digit_str, exponent) = py_float_dec_tuple(f);
891        self.cardinal_tuple(sign_negative, &digit_str, exponent, nominal)
892    }
893
894    /// `verify_ordinal` for float/Decimal input.
895    ///
896    /// Order matters: the `value == int(value)` check runs first, so `-1.5`
897    /// raises the *floatord* TypeError, not the negord one. `-0.0` passes
898    /// both (`-0.0 == 0` and `abs(-0.0) == -0.0`) — quirk 12.
899    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<()> {
900        let integral = match value {
901            FloatValue::Float { value, .. } => *value == value.trunc(),
902            FloatValue::Decimal { value, .. } => {
903                let (coeff, scale) = value.as_bigint_and_exponent();
904                scale <= 0 || (&coeff % pow10(scale as usize)).is_zero()
905            }
906        };
907        if !integral {
908            return Err(N2WError::Type(format!(
909                "Cannot treat float {} as ordinal.",
910                float_value_str(value)
911            )));
912        }
913        let negative = match value {
914            // Numeric `<`, not the sign bit: abs(-0.0) == -0.0 in Python.
915            FloatValue::Float { value, .. } => *value < 0.0,
916            // BigDecimal cannot carry a negative zero, so a negative
917            // coefficient is a genuinely negative value.
918            FloatValue::Decimal { value, .. } => {
919                value.as_bigint_and_exponent().0.is_negative()
920            }
921        };
922        if negative {
923            return Err(N2WError::Type(format!(
924                "Cannot treat negative num {} as ordinal.",
925                float_value_str(value)
926            )));
927        }
928        Ok(())
929    }
930
931    /// `to_year(value, suffix)` for integer input — the shared body behind
932    /// the plain trait hook (`suffix=""`) and `to_year_kw`.
933    fn year_int(&self, value: &BigInt, suffix: &str) -> Result<String> {
934        let mut v = value.clone();
935        let mut suffix = suffix.to_string();
936        if v.is_negative() {
937            v = v.abs();
938            // suffix = self.bcword if not suffix else suffix
939            if suffix.is_empty() {
940                suffix = BCWORD.to_string();
941            }
942        }
943
944        // `v` is non-negative by here, so Rust's truncating `/` and `%` agree
945        // with Python's floor semantics.
946        let hundred = BigInt::from(100);
947        let high = &v / &hundred;
948        let low = &v % &hundred;
949
950        let mut result: Vec<String> = Vec::new();
951        if v < BigInt::from(2000) && v >= BigInt::from(1100) {
952            result.push(self.to_cardinal(&high)?);
953            result.push("ސަތޭކަ".to_string()); // literal in Python, == base_stem[100]
954            result.push(self.to_cardinal(&low)?);
955        } else {
956            result.push(self.to_cardinal(&v)?);
957        }
958
959        if !suffix.is_empty() {
960            result.push(suffix);
961        }
962
963        Ok(result.join(" "))
964    }
965}
966
967impl Lang for LangDv {
968
969    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
970        // Python class attribute MAXVAL (self-contained converter).
971        Some(num_bigint::BigInt::from(10u32).pow(33))
972    }
973    /// `self.pointword`, read from the live Python instance.
974    /// Unused by the four integer modes, so phase 1 never needed it —
975    /// the float path is the first caller.
976    fn pointword(&self) -> &str {
977        "ޕޮއިންޓް"
978    }
979
980    fn lang_name(&self) -> &str {
981        "Num2Word_DV"
982    }
983
984    /// This language's own `to_currency(currency=...)` default,
985    /// read from the live Python signature. Only 44 of 156 use EUR.
986    fn default_currency(&self) -> &str {
987        "ރުފިޔާ"
988    }
989
990    fn negword(&self) -> &str {
991        NEGWORD
992    }
993
994    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
995        self.to_cardinal_float(value, true)
996    }
997
998    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
999        self.verify_ordinal(value)?;
1000        Ok(format!(
1001            "{} {}",
1002            self.to_cardinal_float(value, false)?,
1003            ORDWORD
1004        ))
1005    }
1006
1007    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1008        self.verify_ordinal(value)?;
1009        Ok(format!("{} {}", value, ORDWORD))
1010    }
1011
1012    /// `to_year(value, suffix="")`. This plain hook carries no `suffix`;
1013    /// callers passing one go through [`Lang::to_year_kw`]. The default empty
1014    /// string only matters for negative years, where Python substitutes
1015    /// `bcword`.
1016    fn to_year(&self, value: &BigInt) -> Result<String> {
1017        self.year_int(value, "")
1018    }
1019
1020    /// `to_year(value, suffix=...)` — the only extra kwarg the Python
1021    /// signature accepts. `suffix=None` is falsy in `if not suffix`, so it
1022    /// behaves like the default; a non-str suffix would only crash later in
1023    /// `" ".join`, so it is punted back to Python instead of guessed at.
1024    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1025        if !kw.only(&["suffix"]) {
1026            return Err(N2WError::Fallback("kwargs".into()));
1027        }
1028        let suffix = match kw.get("suffix") {
1029            Option::None | Some(KwVal::None) => String::new(),
1030            Some(KwVal::Str(s)) => s.clone(),
1031            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1032        };
1033        self.year_int(value, &suffix)
1034    }
1035
1036    /// `to_cardinal(value, nominal=True)` — the extra cardinal kwarg. Python
1037    /// only ever tests it with bare truthiness, so any falsy value selects
1038    /// the stem form.
1039    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1040        if !kw.only(&["nominal"]) {
1041            return Err(N2WError::Fallback("kwargs".into()));
1042        }
1043        self.to_cardinal_float(value, kw_nominal(kw))
1044    }
1045
1046    /// The genuine float/Decimal cardinal path. `Num2Word_DV.to_cardinal(value)`
1047    /// forwards to `to_cardinal_float(value, nominal=True)`, which converts the
1048    /// value to a `Decimal` (a float via `Decimal(str(value))`, so `2.675`
1049    /// yields the exact string digits `2,6,7,5` — DV never touches `float2tuple`
1050    /// and its binary-artefact heuristic) and renders it through
1051    /// `decimal_value.as_tuple()`.
1052    ///
1053    /// `precision_override` is ignored: DV's `to_cardinal_float` signature is
1054    /// `(value, nominal=True)` — no `precision` kwarg — and DV carries no
1055    /// `self.precision` attribute for the dispatcher's `precision=` path to
1056    /// latch onto, so Python could never thread one in here.
1057    fn to_cardinal_float(
1058        &self,
1059        value: &FloatValue,
1060        _precision_override: Option<u32>,
1061    ) -> Result<String> {
1062        self.cardinal_float_full(value, true)
1063    }
1064
1065    /// `to_cardinal(float/Decimal)` — the full entry, whole values included.
1066    /// DV has no whole-value routing: `Decimal(str(5.0))` keeps its `.0`, so
1067    /// even integral floats run the pointword grammar (quirk 10), and a
1068    /// scientific repr collapses to its coefficient (quirk 9).
1069    fn cardinal_float_entry(
1070        &self,
1071        value: &FloatValue,
1072        _precision_override: Option<u32>,
1073    ) -> Result<String> {
1074        self.cardinal_float_full(value, true)
1075    }
1076
1077    /// `to_ordinal(float/Decimal)`: `verify_ordinal(value)` (TypeError on
1078    /// non-integral, then on negative — quirk 12), then the float grammar
1079    /// with `nominal=False` (quirk 13) plus the ordword.
1080    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1081        self.verify_ordinal_float(value)?;
1082        Ok(format!(
1083            "{} {}",
1084            self.cardinal_float_full(value, false)?,
1085            ORDWORD
1086        ))
1087    }
1088
1089    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal`, then
1090    /// `"{} {}".format(value, ordword)` — `repr_str` is Python's
1091    /// `str(value)`, so `-0.0` (which passes verify) prints as `-0.0 ވަނަ`.
1092    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1093        self.verify_ordinal_float(value)?;
1094        Ok(format!("{} {}", repr_str, ORDWORD))
1095    }
1096
1097    /// `to_year(float/Decimal)` — quirk 14. `value < 0` takes `abs()` first,
1098    /// so negative years carry no negword; [1100, 2000) splits into
1099    /// still-float `high`/`low` halves rendered independently.
1100    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1101        match value {
1102            FloatValue::Float { value: f, .. } => {
1103                let mut v = *f;
1104                let mut suffix = "";
1105                // Numeric `<`: -0.0 is not negative and keeps its sign into
1106                // the cardinal, which raises IndexError there regardless.
1107                if v < 0.0 {
1108                    v = -v;
1109                    suffix = BCWORD;
1110                }
1111
1112                let mut result: Vec<String> = Vec::new();
1113                if v < 2000.0 && v >= 1100.0 {
1114                    // Python float `%` is fmod (positive operands here), and
1115                    // `//` is the floor of the corrected quotient.
1116                    let low = v % 100.0;
1117                    let high = ((v - low) / 100.0).floor();
1118                    result.push(self.cardinal_from_f64(high, true)?);
1119                    result.push("ސަތޭކަ".to_string());
1120                    result.push(self.cardinal_from_f64(low, true)?);
1121                } else {
1122                    result.push(self.cardinal_from_f64(v, true)?);
1123                }
1124
1125                if !suffix.is_empty() {
1126                    result.push(suffix.to_string());
1127                }
1128                Ok(result.join(" "))
1129            }
1130            FloatValue::Decimal { value: d, .. } => {
1131                let mut v = d.clone();
1132                let mut suffix = "";
1133                if v < BigDecimal::from(0) {
1134                    v = v.abs();
1135                    suffix = BCWORD;
1136                }
1137
1138                let mut result: Vec<String> = Vec::new();
1139                if v < BigDecimal::from(2000) && v >= BigDecimal::from(1100) {
1140                    // Decimal divide-integer (`//`) yields exponent 0; the
1141                    // remainder keeps the ideal exponent min(exp(v), 0).
1142                    let (coeff, scale) = v.as_bigint_and_exponent();
1143                    let hundred = BigInt::from(100);
1144                    let (q, low_coeff, low_exp) = if scale >= 0 {
1145                        let div = &hundred * pow10(scale as usize);
1146                        let q = &coeff / &div; // v >= 1100 > 0: trunc == floor
1147                        let low = &coeff - &q * &div;
1148                        (q, low, -scale)
1149                    } else {
1150                        let full = &coeff * pow10((-scale) as usize);
1151                        let q = &full / &hundred;
1152                        let low = &full - &q * &hundred;
1153                        (q, low, 0)
1154                    };
1155                    result.push(self.cardinal_tuple(false, &q.to_string(), 0, true)?);
1156                    result.push("ސަތޭކަ".to_string());
1157                    result.push(self.cardinal_tuple(false, &low_coeff.to_string(), low_exp, true)?);
1158                } else {
1159                    let (coeff, scale) = v.as_bigint_and_exponent();
1160                    result.push(self.cardinal_tuple(
1161                        coeff.is_negative(),
1162                        &coeff.abs().to_string(),
1163                        -scale,
1164                        true,
1165                    )?);
1166                }
1167
1168                if !suffix.is_empty() {
1169                    result.push(suffix.to_string());
1170                }
1171                Ok(result.join(" "))
1172            }
1173        }
1174    }
1175
1176    /// `to_cardinal(float/Decimal, nominal=...)` — the kwarg rides the float
1177    /// entry too; the integer part stays hardcoded-nominal either way
1178    /// (quirk 13), only the fractional digits and the no-point branch follow
1179    /// the flag.
1180    fn to_cardinal_float_kw(
1181        &self,
1182        value: &FloatValue,
1183        _precision_override: Option<u32>,
1184        kw: &Kwargs,
1185    ) -> Result<String> {
1186        if !kw.only(&["nominal"]) {
1187            return Err(N2WError::Fallback("kwargs".into()));
1188        }
1189        self.cardinal_float_full(value, kw_nominal(kw))
1190    }
1191
1192    /// `str_to_number` == `to_decimal`: `Decimal(value)` with
1193    /// `InvalidOperation` rewritten to `TypeError(errmsg_nonnum)`. The NaN
1194    /// arm short-circuits the InvalidOperation Python would raise a step
1195    /// later (`Decimal(MAXVAL).compare(NaN) < 1` in `to_cardinal_float`) —
1196    /// see the "Strings" section of the module docs for why it lives here.
1197    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1198        match python_decimal_parse(s) {
1199            Ok(ParsedNumber::NaN) => Err(N2WError::Custom {
1200                module: "decimal",
1201                class: "InvalidOperation",
1202                msg: "comparison involving NaN".into(),
1203            }),
1204            // A successful parse is served natively. The `to="fraction"`
1205            // AttributeError (Num2Word_DV has no to_fraction) is now handled
1206            // by the binding's fraction arm, which probes `to_fraction(1,1)`
1207            // and surfaces the language's AttributeError — so the earlier
1208            // blanket defer-to-Python is no longer needed.
1209            Ok(other) => Ok(other),
1210            Err(N2WError::Custom {
1211                module: "decimal",
1212                class: "InvalidOperation",
1213                ..
1214            }) => Err(N2WError::Type(format!("{} is not a valid value.", s))),
1215            Err(e) => Err(e),
1216        }
1217    }
1218
1219    /// Like `to_cheque`, `Num2Word_DV` defines no `to_fraction` and inherits
1220    /// none, so the dispatcher's attribute lookup raises `AttributeError`
1221    /// before the arguments are even parsed — `to_fraction("1", "0")` never
1222    /// reaches a division, so no ZeroDivisionError either.
1223    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
1224        Err(N2WError::Attribute(
1225            "'Num2Word_DV' object has no attribute 'to_fraction'".into(),
1226        ))
1227    }
1228
1229    /// `to_currency(self, value, currency="ރުފިޔާ", cents="ލާރި")`.
1230    ///
1231    /// Shares no machinery with `base.to_currency`: no forms table, no
1232    /// precision, no pluralize, no separator, no adjective. `separator` and
1233    /// `adjective` are therefore ignored rather than defaulted — the Python
1234    /// method has no such parameters, so nothing could consume them.
1235    ///
1236    /// `has_decimal` is ignored for the same reason. `base.to_currency` needs
1237    /// it to decide whether the cents segment appears; DV decides that with a
1238    /// plain truthiness test on `frac_part` instead (quirk 7), and its
1239    /// `to_decimal` treats an `int` and a `float` identically.
1240    fn to_currency(
1241        &self,
1242        val: &CurrencyValue,
1243        currency: &str,
1244        cents: bool,
1245        _separator: Option<&str>,
1246        _adjective: bool,
1247    ) -> Result<String> {
1248        // decimal_value = self.to_decimal(value). Decimal(int) and
1249        // Decimal(str(float)) — which is what the shim already handed us.
1250        let decimal_value = match val {
1251            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
1252            CurrencyValue::Decimal { value, .. } => value.clone(),
1253        };
1254
1255        if decimal_value.is_zero() {
1256            return Ok(format!("{} {}", self.base_stem(0)?, currency));
1257        }
1258
1259        // ROUND_HALF_EVEN, not floor — quirk 6. 99.99 rounds *up* to 100 and
1260        // frac_part comes out negative.
1261        let int_part = to_integral_value(&decimal_value);
1262        let frac_part =
1263            to_integral_value(&((&decimal_value - &int_part) * BigDecimal::from(100)));
1264
1265        let mut result: Vec<String> = Vec::new();
1266
1267        if !int_part.is_zero() {
1268            result.push(self.to_cardinal_float_dec(&int_part, false)?);
1269            result.push(currency.to_string());
1270        }
1271
1272        if !frac_part.is_zero() {
1273            result.push(self.to_cardinal_float_dec(&frac_part, false)?);
1274            // Python appends `cents` — a *word*, defaulting to ލާރި. The trait
1275            // hands us a bool, because `base.to_currency`'s `cents=` is a
1276            // verbosity flag. The shim sends `kwargs.get("cents", True)`, so
1277            // `true` is the only value an ordinary call produces and the
1278            // default word is what Python would have used.
1279            //
1280            // `false` can only mean the caller explicitly passed `cents=False`,
1281            // and Python then puts the bool itself into the list and dies in
1282            // `" ".join(...)`. Reproduced rather than papered over — but note
1283            // it fires only once the cents segment exists at all, which is why
1284            // `to_currency(1.0, cents=False)` still returns "އެއް EUR", and why
1285            // this sits after frac_part has been rendered and pushed: an
1286            // OverflowError from that render happens first in Python too.
1287            if !cents {
1288                return Err(N2WError::Type(format!(
1289                    "sequence item {}: expected str instance, bool found",
1290                    result.len()
1291                )));
1292            }
1293            result.push(CENTSWORD.to_string());
1294        }
1295
1296        // Both parts zero but the value itself non-zero (0.001) => "" — quirk 7.
1297        Ok(result.join(" "))
1298    }
1299
1300    /// `Num2Word_DV` defines no `to_cheque`, and inherits none — it has no base
1301    /// class. The dispatcher's `getattr(converter, "to_cheque")` is what fails,
1302    /// before any conversion runs, so this is an `AttributeError` and *not* the
1303    /// `NotImplementedError` that `default_to_cheque` would raise on the empty
1304    /// forms table. The corpus records `AttributeError` for all nine codes.
1305    fn to_cheque(&self, _val: &BigDecimal, _currency: &str) -> Result<String> {
1306        Err(N2WError::Attribute(
1307            "'Num2Word_DV' object has no attribute 'to_cheque'".into(),
1308        ))
1309    }
1310}
1311
1312/// 10^n.
1313fn pow10(n: usize) -> BigInt {
1314    let mut p = BigInt::from(1);
1315    for _ in 0..n {
1316        p *= 10;
1317    }
1318    p
1319}
1320
1321/// The shortest round-trip decimal digits of `f` and its adjusted (power of
1322/// the leading digit) exponent, via Rust's `{:e}` — whose default precision
1323/// is "the smallest number of digits necessary to represent the value
1324/// uniquely", i.e. the same digits CPython's `repr(float)` prints, since the
1325/// shortest representation of a double is unique.
1326fn shortest_digits(f: f64) -> (String, i64) {
1327    let s = format!("{:e}", f); // e.g. "1.234e3", "-5e-1", "0e0"
1328    let (mant, exp) = s.split_once('e').expect("LowerExp always contains 'e'");
1329    let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
1330    let adj: i64 = exp.parse().expect("LowerExp exponent is an integer");
1331    (digits, adj)
1332}
1333
1334/// Reproduce `Decimal(str(f)).as_tuple()` as `(sign_negative, digit_str,
1335/// exponent)`, matching Python's `to_decimal(float)`.
1336///
1337/// `repr(float)` prints the shortest round-trip digits, fixed-point while the
1338/// adjusted exponent is in [-4, 16) and scientific outside. The regimes give
1339/// the Decimal *different* tuples:
1340///
1341///   * scientific: the coefficient is the bare digits (`'1e+16'` -> `(1,)`,
1342///     exponent 16) — quirk 9's collapse-to-coefficient;
1343///   * fixed-point, integral value: repr appends ".0", so the coefficient
1344///     gains the padding zeros plus one more and the exponent is -1
1345///     (`'1000.0'` -> `(1,0,0,0,0)`, exponent -1) — quirk 10;
1346///   * fixed-point, fractional value: digits as-is with the natural exponent
1347///     (`'12.34'` -> `(1,2,3,4)`, exponent -2). The tiny-scientific regime
1348///     (`'1e-05'`) lands on the same tuple as its fixed expansion would,
1349///     because `Decimal` strips leading coefficient zeros.
1350fn py_float_dec_tuple(f: f64) -> (bool, String, i64) {
1351    let sign_negative = f.is_sign_negative();
1352    let (m, adj) = shortest_digits(f);
1353    let ndig = m.chars().count() as i64;
1354    let e_nat = adj - (ndig - 1);
1355    if adj >= 16 || adj < -4 {
1356        // repr is scientific either way; both directions keep the bare digits.
1357        return (sign_negative, m, e_nat);
1358    }
1359    if e_nat >= 0 {
1360        // Integral fixed-point repr: '.0' appended.
1361        let mut d = m;
1362        for _ in 0..=e_nat {
1363            d.push('0');
1364        }
1365        // 0.0's coefficient collapses back to a single 0.
1366        let trimmed = d.trim_start_matches('0');
1367        let d = if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() };
1368        (sign_negative, d, -1)
1369    } else {
1370        (sign_negative, m, e_nat)
1371    }
1372}
1373
1374/// Python's `str(float)` — repr with the same regime split as
1375/// [`py_float_dec_tuple`]. Only used for exception messages
1376/// (`errmsg_floatord` / `errmsg_negord` format the original value); the
1377/// corpora compare exception types, not text.
1378fn py_float_str(f: f64) -> String {
1379    let sign = if f.is_sign_negative() { "-" } else { "" };
1380    let (m, adj) = shortest_digits(f);
1381    if (-4..16).contains(&adj) {
1382        let intlen = adj + 1;
1383        if intlen <= 0 {
1384            format!("{}0.{}{}", sign, "0".repeat((-intlen) as usize), m)
1385        } else if intlen as usize >= m.len() {
1386            format!("{}{}{}.0", sign, m, "0".repeat(intlen as usize - m.len()))
1387        } else {
1388            let (a, b) = m.split_at(intlen as usize);
1389            format!("{}{}.{}", sign, a, b)
1390        }
1391    } else {
1392        let mut chars = m.chars();
1393        let lead = chars.next().unwrap_or('0');
1394        let rest: String = chars.collect();
1395        let coeff = if rest.is_empty() {
1396            lead.to_string()
1397        } else {
1398            format!("{}.{}", lead, rest)
1399        };
1400        // Python zero-pads the exponent to two digits: 1e-05, 1e+16.
1401        format!(
1402            "{}{}e{}{:02}",
1403            sign,
1404            coeff,
1405            if adj < 0 { '-' } else { '+' },
1406            adj.abs()
1407        )
1408    }
1409}
1410
1411/// `self.to_decimal(value).as_tuple()` — the `(sign, digits, exponent)` view
1412/// every DV float path renders from.
1413fn float_value_tuple(value: &FloatValue) -> (bool, String, i64) {
1414    match value {
1415        // float -> Decimal(str(value)), reconstructed from the f64 repr.
1416        FloatValue::Float { value, .. } => py_float_dec_tuple(*value),
1417        // Decimal -> the Decimal unchanged. as_bigint_and_exponent() is
1418        // (coefficient, scale); Python's exponent is -scale. bigdecimal
1419        // preserves trailing zeros from the source string, so Decimal("1.10")
1420        // keeps coefficient 110 / exponent -2 — the trailing zero the corpus
1421        // depends on.
1422        FloatValue::Decimal { value, .. } => {
1423            let (coeff, scale) = value.as_bigint_and_exponent();
1424            (coeff.is_negative(), coeff.abs().to_string(), -scale)
1425        }
1426    }
1427}
1428
1429/// `"{}".format(value)` for verify_ordinal's error messages: `str(float)`
1430/// for a float, `str(Decimal)` for a Decimal.
1431fn float_value_str(value: &FloatValue) -> String {
1432    match value {
1433        FloatValue::Float { value, .. } => py_float_str(*value),
1434        FloatValue::Decimal { .. } => {
1435            let (sign, digits, exponent) = float_value_tuple(value);
1436            py_decimal_str_parts(sign, &digits, exponent)
1437        }
1438    }
1439}
1440
1441/// The `nominal=` kwarg under Python truthiness — `if not nominal` is the
1442/// only test the class ever applies, so False/0/None/""/[] all mean stem.
1443fn kw_nominal(kw: &Kwargs) -> bool {
1444    match kw.get("nominal") {
1445        Option::None => true, // not passed: the default
1446        Some(KwVal::Bool(b)) => *b,
1447        Some(KwVal::Int(i)) => *i != 0,
1448        Some(KwVal::None) => false,
1449        Some(KwVal::Str(s)) => !s.is_empty(),
1450        Some(KwVal::List(l)) => !l.is_empty(),
1451    }
1452}
1453
1454/// `str(Decimal)` (the spec's to-scientific-string) from a
1455/// `(sign, digit_str, exponent)` tuple. Only reached to build an OverflowError
1456/// *message*; the differential harness compares exception *type*, not text, so
1457/// this is never on the parity path — implemented in full for fidelity anyway.
1458fn py_decimal_str_parts(sign_negative: bool, digit_str: &str, exponent: i64) -> String {
1459    let sign = if sign_negative { "-" } else { "" };
1460    let chars: Vec<char> = digit_str.chars().collect();
1461    let ndigits = chars.len() as i64;
1462    let adjusted = exponent + ndigits - 1;
1463
1464    if exponent <= 0 && adjusted >= -6 {
1465        // Fixed-point form.
1466        if exponent == 0 {
1467            return format!("{}{}", sign, digit_str);
1468        }
1469        let leftdigits = exponent + ndigits; // where the point falls
1470        let (intpart, fracpart) = if leftdigits <= 0 {
1471            (
1472                "0".to_string(),
1473                format!(".{}{}", "0".repeat((-leftdigits) as usize), digit_str),
1474            )
1475        } else {
1476            let l = leftdigits as usize;
1477            (
1478                chars[..l].iter().collect::<String>(),
1479                format!(".{}", chars[l..].iter().collect::<String>()),
1480            )
1481        };
1482        return format!("{}{}{}", sign, intpart, fracpart);
1483    }
1484
1485    // Scientific form.
1486    let coeff = if ndigits == 1 {
1487        chars[0].to_string()
1488    } else {
1489        format!("{}.{}", chars[0], chars[1..].iter().collect::<String>())
1490    };
1491    let esign = if adjusted < 0 { "-" } else { "+" };
1492    format!("{}{}E{}{}", sign, coeff, esign, adjusted.abs())
1493}
1494
1495/// Round a non-negative integer to `CTX_PREC` significant decimal digits using
1496/// ROUND_HALF_EVEN — what `Decimal.__abs__` does under the default context.
1497///
1498/// Verified against CPython at the boundary: `10**33 - 50001` rounds to
1499/// `9.999999999999999999999999999E+32` (below MAXVAL, converts fine) while
1500/// `10**33 - 50000` is a tie and rounds to even, giving `1E+33` == MAXVAL and
1501/// tripping the overflow guard.
1502fn round_half_even_28(a: &BigInt) -> BigInt {
1503    let ndigits = a.to_string().len(); // `a` is non-negative: no sign char
1504    if ndigits <= CTX_PREC {
1505        return a.clone();
1506    }
1507
1508    let divisor = pow10(ndigits - CTX_PREC);
1509    let (mut q, r) = a.div_rem(&divisor);
1510    let twice = &r * 2;
1511
1512    if twice > divisor {
1513        q += 1;
1514    } else if twice == divisor {
1515        // Exact tie: round half to even.
1516        if !q.is_even() {
1517            q += 1;
1518        }
1519    }
1520    q * divisor
1521}
1522
1523/// Python's `Decimal.to_integral_value()` — round to an integer using the
1524/// context's rounding, which `__init__` leaves at the default ROUND_HALF_EVEN.
1525/// (The commented-out `getcontext().rounding = ROUND_FLOOR` never took effect;
1526/// were it live, 99.99 would floor to 99 and quirk 6's negative cents could not
1527/// arise.)
1528///
1529/// A value whose exponent is already `>= 0` comes back **untouched**, keeping
1530/// its coefficient and exponent — so `Decimal("1E+21")` stays `1E+21` rather
1531/// than expanding to 22 digits. That is what quirk 8 rides on.
1532fn to_integral_value(d: &BigDecimal) -> BigDecimal {
1533    let (coefficient, scale) = d.as_bigint_and_exponent();
1534    // scale <= 0 is Python's exponent >= 0.
1535    if scale <= 0 {
1536        return d.clone();
1537    }
1538
1539    let divisor = pow10(scale as usize);
1540    let negative = coefficient.is_negative();
1541    let (mut q, r) = coefficient.abs().div_rem(&divisor);
1542    // ROUND_HALF_EVEN on the magnitude; the mode is symmetric about zero.
1543    let twice = &r * 2;
1544    if twice > divisor || (twice == divisor && !q.is_even()) {
1545        q += 1;
1546    }
1547    if negative {
1548        q = -q;
1549    }
1550    // Rounding always lands on exponent 0, matching Python.
1551    BigDecimal::new(q, 0)
1552}
1553
1554/// Python's `str(Decimal)` — the spec's *to-scientific-string* — restricted to
1555/// the integral values this file can reach it with.
1556///
1557/// Only two of the spec's three branches can fire. Exponent 0 prints the
1558/// coefficient plainly; a positive exponent switches to scientific notation
1559/// keyed on the adjusted exponent, so `Decimal('1.5E+33')` is `"1.5E+33"` and
1560/// not `"1500000000000000000000000000000000"`. A negative exponent would need
1561/// the third branch, which `to_integral_value` rules out.
1562fn py_decimal_str(d: &BigDecimal) -> String {
1563    let (coefficient, scale) = d.as_bigint_and_exponent();
1564    let exponent = -scale;
1565    let digits = coefficient.abs().to_string();
1566    let sign = if coefficient.is_negative() { "-" } else { "" };
1567
1568    if exponent <= 0 {
1569        return format!("{}{}", sign, digits);
1570    }
1571
1572    // adjusted exponent = exponent + len(digits) - 1
1573    let adjusted = exponent + digits.chars().count() as i64 - 1;
1574    let mut chars = digits.chars();
1575    let lead = chars.next().unwrap_or('0');
1576    let rest: String = chars.collect();
1577    let coeff = if rest.is_empty() {
1578        lead.to_string()
1579    } else {
1580        format!("{}.{}", lead, rest)
1581    };
1582    // Python prints the exponent's sign explicitly and never zero-pads it.
1583    let esign = if adjusted < 0 { "-" } else { "+" };
1584    format!("{}{}E{}{}", sign, coeff, esign, adjusted.abs())
1585}
1586
1587/// Python's `int(s)` over an ASCII digit string. Every call site feeds it a
1588/// slice of at most 3 digits, so `i64` cannot overflow.
1589fn py_int(s: &str) -> Result<i64> {
1590    s.parse::<i64>().map_err(|_| {
1591        N2WError::Value(format!(
1592            "invalid literal for int() with base 10: '{}'",
1593            s
1594        ))
1595    })
1596}
1597
1598/// Python's `s[i]`, raising `IndexError` past the end.
1599fn char_at(s: &str, i: usize) -> Result<String> {
1600    s.chars()
1601        .nth(i)
1602        .map(|c| c.to_string())
1603        .ok_or_else(|| N2WError::Index("string index out of range".into()))
1604}
1605
1606/// Python's `s[start:end]` with negative-index and clamping semantics.
1607/// Operates on chars; every caller passes an ASCII digit string.
1608fn py_slice(s: &str, start: isize, end: isize) -> String {
1609    let chars: Vec<char> = s.chars().collect();
1610    let len = chars.len() as isize;
1611
1612    let norm = |i: isize| -> usize {
1613        if i < 0 {
1614            let j = len + i;
1615            if j < 0 {
1616                0
1617            } else {
1618                j as usize
1619            }
1620        } else if i > len {
1621            len as usize
1622        } else {
1623            i as usize
1624        }
1625    };
1626
1627    let (a, b) = (norm(start), norm(end));
1628    if a >= b {
1629        return String::new();
1630    }
1631    chars[a..b].iter().collect()
1632}
1633
1634#[cfg(test)]
1635mod float_tests {
1636    use super::*;
1637    use std::str::FromStr;
1638
1639    fn f(value: f64, precision: u32) -> Result<String> {
1640        // Fully-qualified: the inherent to_cardinal_float(&BigInt, bool) would
1641        // otherwise shadow the trait method under method-call syntax.
1642        Lang::to_cardinal_float(
1643            &LangDv::new(),
1644            &FloatValue::Float { value, precision },
1645            None,
1646        )
1647    }
1648
1649    fn d(s: &str) -> Result<String> {
1650        Lang::to_cardinal_float(
1651            &LangDv::new(),
1652            &FloatValue::Decimal {
1653                value: BigDecimal::from_str(s).unwrap(),
1654                // DV ignores precision; a plausible value all the same.
1655                precision: s.split_once('.').map_or(0, |(_, fr)| fr.len() as u32),
1656            },
1657            None,
1658        )
1659    }
1660
1661    fn is_index(r: &Result<String>) -> bool {
1662        matches!(r, Err(N2WError::Index(_)))
1663    }
1664
1665    #[test]
1666    fn float_cardinal_matches_corpus_and_interpreter() {
1667        assert!(is_index(&f(0.5, 1)));
1668        assert_eq!(f(1.5, 1).unwrap(), "އެކެއް ޕޮއިންޓް ފަހެއް");
1669        assert_eq!(f(3.14, 2).unwrap(), "ތިނެއް ޕޮއިންޓް އެކެއް ހަތަރެއް");
1670        assert_eq!(f(12.34, 2).unwrap(), "ބާރަ ޕޮއިންޓް ތިނެއް ހަތަރެއް");
1671        assert_eq!(f(-12.34, 2).unwrap(), "މައިނަސް  ބާރަ ޕޮއިންޓް ތިނެއް ހަތަރެއް");
1672        assert_eq!(f(1.005, 3).unwrap(), "އެކެއް ޕޮއިންޓް ސުމެއް ސުމެއް ފަހެއް");
1673        assert_eq!(f(2.675, 3).unwrap(), "ދޭއް ޕޮއިންޓް ހައެއް ހަތެއް ފަހެއް");
1674        assert!(is_index(&f(0.01, 2)));
1675        assert_eq!(f(99.99, 2).unwrap(), "ނުވަދިހަނުވައެއް ޕޮއިންޓް ނުވައެއް ނުވައެއް");
1676        assert_eq!(f(1.0, 1).unwrap(), "އެކެއް ޕޮއިންޓް ސުމެއް");
1677        assert!(is_index(&f(0.0, 1)));
1678        assert!(is_index(&f(-0.5, 1)));
1679        assert_eq!(f(100.5, 1).unwrap(), "ސަތޭކަ ޕޮއިންޓް ފަހެއް");
1680        assert_eq!(
1681            f(1234.56, 2).unwrap(),
1682            "އެއްހާސް ދުއިސައްތަތިރީސްހަތަރެއް ޕޮއިންޓް ފަހެއް ހައެއް"
1683        );
1684        assert_eq!(f(-1.5, 1).unwrap(), "މައިނަސް  އެކެއް ޕޮއިންޓް ފަހެއް");
1685        assert!(is_index(&f(0.1, 1)));
1686        assert!(is_index(&f(0.99, 2)));
1687        assert_eq!(f(1.01, 2).unwrap(), "އެކެއް ޕޮއިންޓް ސުމެއް އެކެއް");
1688        assert_eq!(f(2.25, 2).unwrap(), "ދޭއް ޕޮއިންޓް ދޭއް ފަހެއް");
1689    }
1690
1691    #[test]
1692    fn decimal_cardinal_matches_corpus() {
1693        assert!(is_index(&d("0.01")));
1694        assert_eq!(d("1.10").unwrap(), "އެކެއް ޕޮއިންޓް އެކެއް ސުމެއް");
1695        assert_eq!(d("12.345").unwrap(), "ބާރަ ޕޮއިންޓް ތިނެއް ހަތަރެއް ފަހެއް");
1696        assert_eq!(
1697            d("98746251323029.99").unwrap(),
1698            "ނުވަދިހައައްޓްރިޔަން ހަތްސަތޭކަ ސާޅީސްހަބިލިޔަން ދުއިސައްތަފަންސާސްއެއްމިލިޔަން ތިންލައްކަ ތޭވީސްހާސް ނަވާވީސް ޕޮއިންޓް ނުވައެއް ނުވައެއް"
1699        );
1700        assert!(is_index(&d("0.001")));
1701        assert_eq!(d("1.005").unwrap(), "އެކެއް ޕޮއިންޓް ސުމެއް ސުމެއް ފަހެއް");
1702        assert_eq!(d("2.675").unwrap(), "ދޭއް ޕޮއިންޓް ހައެއް ހަތެއް ފަހެއް");
1703        assert!(is_index(&d("0.50")));
1704    }
1705
1706    #[test]
1707    fn whole_float_entries_match_corpus() {
1708        let l = LangDv::new();
1709        let fv = |v: f64| FloatValue::Float { value: v, precision: 1 };
1710
1711        // cardinal: no whole-value fast path (quirk 10) + scientific collapse
1712        // (quirk 9) + IndexError on empty integer part (quirk 11).
1713        assert_eq!(
1714            l.cardinal_float_entry(&fv(5.0), None).unwrap(),
1715            "ފަހެއް ޕޮއިންޓް ސުމެއް"
1716        );
1717        assert_eq!(
1718            l.cardinal_float_entry(&fv(-2.0), None).unwrap(),
1719            "މައިނަސް  ދޭއް ޕޮއިންޓް ސުމެއް"
1720        );
1721        assert_eq!(
1722            l.cardinal_float_entry(&fv(-1000.0), None).unwrap(),
1723            "މައިނަސް އެއްހާސް ޕޮއިންޓް ސުމެއް"
1724        );
1725        assert_eq!(l.cardinal_float_entry(&fv(1e16), None).unwrap(), "އެކެއް");
1726        assert_eq!(l.cardinal_float_entry(&fv(1e20), None).unwrap(), "އެކެއް");
1727        assert!(matches!(
1728            l.cardinal_float_entry(&fv(0.0), None),
1729            Err(N2WError::Index(_))
1730        ));
1731        assert!(matches!(
1732            l.cardinal_float_entry(&fv(-0.0), None),
1733            Err(N2WError::Index(_))
1734        ));
1735
1736        // ordinal: verify_ordinal order (quirk 12) + nominal/stem mix (13).
1737        assert_eq!(
1738            l.ordinal_float_entry(&fv(1.0)).unwrap(),
1739            "އެކެއް ޕޮއިންޓް ސުން ވަނަ"
1740        );
1741        assert_eq!(l.ordinal_float_entry(&fv(1e16)).unwrap(), "އެއް ވަނަ");
1742        assert!(matches!(l.ordinal_float_entry(&fv(2.5)), Err(N2WError::Type(_))));
1743        assert!(matches!(l.ordinal_float_entry(&fv(-1.5)), Err(N2WError::Type(_))));
1744        assert!(matches!(l.ordinal_float_entry(&fv(-1.0)), Err(N2WError::Type(_))));
1745        assert!(matches!(l.ordinal_float_entry(&fv(-0.0)), Err(N2WError::Index(_))));
1746
1747        // ordinal_num: -0.0 passes verify and echoes the repr.
1748        assert_eq!(
1749            l.ordinal_num_float_entry(&fv(-0.0), "-0.0").unwrap(),
1750            "-0.0 ވަނަ"
1751        );
1752        assert!(matches!(
1753            l.ordinal_num_float_entry(&fv(-1.0), "-1.0"),
1754            Err(N2WError::Type(_))
1755        ));
1756
1757        // year: abs-before-cardinal + the [1100, 2000) float split (quirk 14).
1758        assert_eq!(
1759            l.year_float_entry(&fv(-1.5)).unwrap(),
1760            "އެކެއް ޕޮއިންޓް ފަހެއް ބީ.ސީ"
1761        );
1762        assert_eq!(
1763            l.year_float_entry(&fv(1234.0)).unwrap(),
1764            "ބާރަ ޕޮއިންޓް ސުމެއް ސަތޭކަ ތިރީސްހަތަރެއް ޕޮއިންޓް ސުމެއް"
1765        );
1766        assert_eq!(l.year_float_entry(&fv(1e16)).unwrap(), "އެކެއް");
1767        assert!(matches!(l.year_float_entry(&fv(-0.0)), Err(N2WError::Index(_))));
1768    }
1769
1770    #[test]
1771    fn decimal_entries_match_corpus() {
1772        let l = LangDv::new();
1773        let dv = |s: &str| FloatValue::Decimal {
1774            value: BigDecimal::from_str(s).unwrap(),
1775            precision: 0,
1776        };
1777
1778        assert_eq!(l.cardinal_float_entry(&dv("1E+2"), None).unwrap(), "އެކެއް");
1779        assert_eq!(l.cardinal_float_entry(&dv("1E+20"), None).unwrap(), "އެކެއް");
1780        assert_eq!(l.ordinal_float_entry(&dv("0")).unwrap(), "ސުން ވަނަ");
1781        assert_eq!(l.ordinal_float_entry(&dv("5")).unwrap(), "ފަސް ވަނަ");
1782        assert_eq!(l.ordinal_float_entry(&dv("100")).unwrap(), "ސަތޭކަ ވަނަ");
1783        assert_eq!(
1784            l.ordinal_float_entry(&dv("5.00")).unwrap(),
1785            "ފަހެއް ޕޮއިންޓް ސުން ސުން ވަނަ"
1786        );
1787        assert_eq!(l.ordinal_float_entry(&dv("1E+2")).unwrap(), "އެއް ވަނަ");
1788        assert!(matches!(l.ordinal_float_entry(&dv("-3.0")), Err(N2WError::Type(_))));
1789        assert_eq!(
1790            l.year_float_entry(&dv("-3.0")).unwrap(),
1791            "ތިނެއް ޕޮއިންޓް ސުމެއް ބީ.ސީ"
1792        );
1793        assert_eq!(l.year_float_entry(&dv("1E+2")).unwrap(), "އެކެއް");
1794        assert_eq!(
1795            l.year_float_entry(&dv("12345.000")).unwrap(),
1796            "ބާރަހާސް ތިންސަތޭކަ ސާޅީސްފަހެއް ޕޮއިންޓް ސުމެއް ސުމެއް ސުމެއް"
1797        );
1798    }
1799
1800    #[test]
1801    fn strings_fraction_and_kwargs_match_corpus() {
1802        use crate::base::{KwVal, Kwargs};
1803        let l = LangDv::new();
1804
1805        // str_to_number: TypeError instead of InvalidOperation; NaN
1806        // short-circuits the compare's InvalidOperation.
1807        assert!(matches!(l.str_to_number("abc"), Err(N2WError::Type(_))));
1808        assert!(matches!(l.str_to_number(""), Err(N2WError::Type(_))));
1809        assert!(matches!(
1810            l.str_to_number("NaN"),
1811            Err(N2WError::Custom { class: "InvalidOperation", .. })
1812        ));
1813        assert!(matches!(l.str_to_number("1e3"), Ok(ParsedNumber::Dec(_))));
1814
1815        // to_fraction: AttributeError on lookup, even for 1/0.
1816        assert!(matches!(
1817            l.to_fraction(&BigInt::from(1), &BigInt::from(0)),
1818            Err(N2WError::Attribute(_))
1819        ));
1820
1821        // nominal= kwarg (the kwargs corpus rows).
1822        let kw = |v: KwVal| Kwargs(vec![("nominal".to_string(), v)]);
1823        assert_eq!(
1824            l.to_cardinal_kw(&BigInt::from(1234), &kw(KwVal::Bool(false))).unwrap(),
1825            "އެއްހާސް ދުއިސައްތަތިރީސްހަތަރު"
1826        );
1827        assert_eq!(
1828            l.to_cardinal_kw(&BigInt::from(-5), &kw(KwVal::Bool(false))).unwrap(),
1829            "މައިނަސް  ފަސް"
1830        );
1831        assert_eq!(
1832            l.to_cardinal_kw(&BigInt::from(0), &kw(KwVal::Bool(false))).unwrap(),
1833            "ސުން"
1834        );
1835        assert_eq!(
1836            l.to_cardinal_kw(&BigInt::from(0), &kw(KwVal::Bool(true))).unwrap(),
1837            "ސުމެއް"
1838        );
1839        assert!(matches!(
1840            l.to_cardinal_kw(
1841                &BigInt::from(1),
1842                &Kwargs(vec![("case".to_string(), KwVal::Str("dative".into()))])
1843            ),
1844            Err(N2WError::Fallback(_))
1845        ));
1846
1847        // suffix= kwarg on to_year.
1848        let skw = Kwargs(vec![("suffix".to_string(), KwVal::Str("AD".into()))]);
1849        assert_eq!(l.to_year_kw(&BigInt::from(5), &skw).unwrap(), "ފަހެއް AD");
1850        assert_eq!(l.to_year_kw(&BigInt::from(-17), &skw).unwrap(), "ސަތާރަ AD");
1851        assert_eq!(
1852            l.to_year_kw(
1853                &BigInt::from(-17),
1854                &Kwargs(vec![("suffix".to_string(), KwVal::None)])
1855            )
1856            .unwrap(),
1857            "ސަތާރަ ބީ.ސީ"
1858        );
1859    }
1860
1861    #[test]
1862    fn overflow_boundary_matches_python() {
1863        // Python raises OverflowError when floor(round28(|v|)) >= 10**33 in the
1864        // float/Decimal path (quirk 4: abs() rounds to 28 significant digits
1865        // half-to-even first). Boundary values verified against CPython.
1866        let overflow = |s: &str| matches!(d(s), Err(N2WError::Overflow(_)));
1867
1868        assert!(overflow(&"9".repeat(33))); // 10**33 - 1 rounds up to 10**33
1869        assert!(overflow(&format!("1{}", "0".repeat(33)))); // 10**33
1870        assert!(overflow(&(10u128.pow(33) - 50000).to_string())); // exact tie, up
1871        assert!(d(&(10u128.pow(33) - 50001).to_string()).is_ok()); // just under
1872        assert!(d(&"9".repeat(28)).is_ok()); // 28 digits, no rounding
1873        assert!(d(&format!("1{}", "0".repeat(32))).is_ok()); // 10**32
1874        // A fractional value floors below the bound and converts fine.
1875        assert!(d(&format!("{}.5", "9".repeat(32))).is_ok());
1876    }
1877}