Skip to main content

num2words2_core/
lang_hy.rs

1//! Port of `lang_HY.py` (Armenian).
2//!
3//! Shape: **engine, with a self-contained wrapper**. `Num2Word_HY` subclasses
4//! `Num2Word_Base` and *does* define `high_numwords`/`mid_numwords`/
5//! `low_numwords` in `setup`, so Python builds `self.cards` and sets
6//! `MAXVAL = 1000 * cards.keys()[0]`. But it also overrides `to_cardinal` with
7//! a pre-filter that short-circuits 0, 1000, the millions range and the
8//! billions range, delegating everything else to `super().to_cardinal()` (the
9//! `splitnum`/`clean`/`merge` engine) and then post-processing the result.
10//!
11//! So this file supplies `cards` + `maxval` + `merge` (engine duties) *and*
12//! overrides `to_cardinal` to reproduce the wrapper. `default_to_cardinal` is
13//! the `super()` call.
14//!
15//! Inherited from `Num2Word_Base` and reused unchanged:
16//!   * `verify_ordinal` → `TypeError` on negatives (see [`LangHy::verify_ordinal`]).
17//!   * `title`          → `is_title` is False for HY, so it is a no-op. HY's
18//!     `exclude_title` (`["և", "ամբողջ", "մինուս"]`) is therefore dead but is
19//!     carried here for fidelity.
20//!   * `negword` = "մինուս " (set in `setup`, after `__init__`'s "(-) " default).
21//!
22//! # Faithfully reproduced Python bugs
23//!
24//! This is a port, not a rewrite. All of the following are wrong-looking but
25//! are exactly what CPython emits, verified against the interpreter and the
26//! frozen corpus:
27//!
28//! 1. **`set_high_numwords` stores tuples as card words.** `setup` builds
29//!    `high_numwords = [(10**12, "տրիլիոն"), (10**9, "միլիարդ"),
30//!    (10**6, "միլիոն")]` — a list of *pairs* — but `set_high_numwords`
31//!    treats each element as a bare word:
32//!    ```python
33//!    max = 3 + 10 * len(high)          # 33
34//!    for word, n in zip(high, range(max, 3, -10)):   # n = 33, 23, 13
35//!        self.cards[10**n] = word      # word is the TUPLE, not the string
36//!    ```
37//!    So `cards[10**33] = (10**12, "տրիլիոն")`, `cards[10**23] =
38//!    (10**9, "միլիարդ")`, `cards[10**13] = (10**6, "միլիոն")`. Three separate
39//!    errors compound here: the step is -10 instead of -3, the exponents are
40//!    off by ten orders of magnitude (10**13 for "million"), and the value is
41//!    a tuple. `merge` then interpolates the tuple with `"%s %s"`, so its
42//!    `str()` leaks into the output:
43//!    `to_cardinal(10**15)` == "հարյուր (1000000, 'միլիոն')".
44//!    Modelled by storing the tuple's Python `str()` as the card word — see
45//!    [`TUPLE_CARD_E33`] and friends. That substitution is exact for every
46//!    reachable input; the reasoning is spelled out on those constants.
47//!    Consequence: there is **no** card for 10**6, 10**9 or 10**12, so the
48//!    engine's largest usable card below 10**13 is 1000 ("հազար").
49//! 2. **`merge` adds where it should multiply.** Every arm returns
50//!    `cnum + nnum`, so 200 is tracked as 2+100=102 and 10**6 as 2000. The
51//!    numbers are only used for `merge`'s own comparisons, so the *text* still
52//!    comes out right for small values — but it is why 10**12 renders as four
53//!    "հազար" in a row (see bug 3) and why `nnum < cnum` tests behave oddly.
54//! 3. **The "հազար հազար" → "միլիոն" patch.** Because there is no million
55//!    card, the engine renders 10**6 as "հազար հազար" and 10**12 as
56//!    "հազար հազար հազար հազար". `to_cardinal` papers over this with a string
57//!    `.replace()`, which is non-overlapping and left-to-right, so 10**12
58//!    becomes "միլիոն միլիոն" rather than anything sensible. Reproduced
59//!    verbatim; Rust's `str::replace` has identical semantics.
60//! 4. **`to_ordinal` raises `KeyError` on every negative.** `value < 20` and
61//!    `value < 10` are both true for negatives, so it reaches
62//!    `ORDINAL_ONES[value]` with a key that is not in the dict:
63//!    `to_ordinal(-1)` … `to_ordinal(-10**6)` all raise `KeyError`, never the
64//!    `TypeError` that `verify_ordinal` would have produced (it is never
65//!    called). `to_ordinal_num` *does* call `verify_ordinal`, so it raises
66//!    `TypeError` for the same inputs — the two modes disagree. See
67//!    [`ordinal_ones`].
68//! 5. **`to_ordinal` just glues "երորդ" onto the cardinal above 100**, with no
69//!    stem adjustment, so `to_ordinal(110)` == "հարյուր տասըերորդ" and
70//!    `to_ordinal(999)` == "ինը հարյուր իննսուն ինըերորդ".
71//! 6. **`to_year`'s "մեկ հազար" strip is dead code.** `merge` already returns
72//!    `next` unchanged when `cnum == 1 and nnum == 1000`, so a cardinal can
73//!    never begin with "մեկ հազար" and the `year_str[4:]` branch never fires.
74//!    Ported anyway (as a character slice, not a byte slice).
75//! 7. **`merge`'s `ntext[:-1] + "ի"` genitive branch is unreachable.** It needs
76//!    `nnum < cnum`, `100 <= cnum < 1000` and `nnum % 100 == 0`; `splitnum`
77//!    only ever hands a sub-100 remainder to a hundreds-range `cnum`, and a
78//!    zero remainder is never appended. Ported anyway.
79//!
80//! # The currency surface
81//!
82//! `to_currency` is overridden **completely** and shares nothing with
83//! `Num2Word_Base.to_currency` — see [`LangHy::to_currency`] for the three
84//! consequences (no `CURRENCY_PRECISION`, no `separator`/`adjective`, and an
85//! unknown code returning a bare cardinal instead of raising). `to_cheque` is
86//! *not* overridden, so it uses the base implementation and does raise for the
87//! codes `to_currency` silently accepts.
88//!
89//! Because `to_currency` hands floats to `to_cardinal`, this file must also
90//! carry the inherited float-cardinal path (`float2tuple` /
91//! `to_cardinal_float` / a float twin of `splitnum`), which the corpus
92//! exercises through the unknown-code branch (`to_currency(12.34, "KWD")` ==
93//! "տասներկու ամբողջ երեք չորս"). That arithmetic is done in `f64` because
94//! Python does it in `f64`; see the "CPython float semantics" note below for
95//! why an exact-decimal model is measurably wrong.
96//!
97//! # `Decimal` is context-bound, not exact
98//!
99//! `to_currency` computes `(Decimal(str(val)) * 100) % 1` under the **default**
100//! decimal context, whose precision is 28. `Decimal.__mod__` raises
101//! `InvalidOperation(DivisionImpossible)` once the integer quotient needs more
102//! than 28 digits, so every `abs(val) >= 1e26` raises — int and float alike,
103//! and *before* the CURRENCY_FORMS lookup, so even an unknown code raises.
104//! Modelled with `N2WError::Custom { module: "decimal", class:
105//! "InvalidOperation" }`; see [`LangHy::to_currency`]. Below that bound the
106//! context never rounds (a value under 1e26 carries at most 26 integer digits,
107//! and `* 100` keeps it inside 28 significant digits), so exact `BigDecimal`
108//! arithmetic is faithful everywhere the raise does not fire.
109//!
110//! # Error variants
111//!
112//! * `to_ordinal(n)` for `n < 0` → `N2WError::Key` (bug 4).
113//! * `to_ordinal_num(n)` for `n < 0` → `N2WError::Type` (`verify_ordinal`).
114//! * `to_cardinal(n)` for `abs(n) >= 10**36` → `N2WError::Overflow`, from the
115//!   inherited MAXVAL check in `default_to_cardinal`.
116//! * `to_cheque(v, cur)` for a code outside `CURRENCY_FORMS` →
117//!   `N2WError::NotImplemented`. `to_currency` never raises for that case.
118
119use crate::base::{
120    clean, default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Node,
121    Result,
122};
123use crate::currency::{CurrencyForms, CurrencyValue};
124use crate::floatpath::{default_to_cardinal_float, FloatValue};
125use crate::strnum::{python_decimal_parse, ParsedNumber};
126use bigdecimal::BigDecimal;
127use num_bigint::BigInt;
128use num_integer::Integer;
129use num_traits::{One, Signed, ToPrimitive, Zero};
130use std::collections::HashMap;
131use std::sync::OnceLock;
132
133// ---------------------------------------------------------------------------
134// The tuple card words (bug 1).
135//
136// `cards[10**33]`, `cards[10**23]` and `cards[10**13]` hold Python *tuples*,
137// not strings. Storing each tuple's `str()` here is exact rather than
138// approximate, because a tuple card can only ever reach `merge` through the
139// arms that format it with `"%s"` — which is precisely `str()`:
140//
141//   * A tuple card is always the *right* operand. `splitnum` emits it as
142//     `out[1]` = `(self.cards[elem], elem)`; `out[0]` is either `(cards[1], 1)`
143//     or a recursive `splitnum(div)`. For a tuple to arrive on the *left*, some
144//     `div` would have to be >= 10**13, which needs `value >= 10**13 * 10**23`
145//     = 10**36 = MAXVAL — rejected by the overflow check first.
146//   * The two arms that would raise `TypeError` on a real tuple
147//     (`ctext + " " + ntext` and `ctext + ntext`) both require `nnum < 100`,
148//     and a tuple card's `nnum` is >= 10**13. Unreachable.
149//   * `ctext == "իննսուն"` against a tuple is simply False in Python, and is
150//     equally False against these strings.
151//
152// So every reachable use is `"%s %s" % (ctext, ntext)` → `str(tuple)`.
153// Verified against the corpus: to_cardinal(10**15) == "հարյուր (1000000, 'միլիոն')".
154const TUPLE_CARD_E33: &str = "(1000000000000, 'տրիլիոն')";
155const TUPLE_CARD_E23: &str = "(1000000000, 'միլիարդ')";
156const TUPLE_CARD_E13: &str = "(1000000, 'միլիոն')";
157
158// `merge`'s one word-sensitive comparison: only "իննսուն" (90) takes a space
159// before its unit ("իննսուն ինը" = 99), while every other ten glues
160// ("քսանմեկ" = 21).
161const NINETY: &str = "իննսուն";
162
163/// Python's `ORDINAL_ONES` (keys 1..=9).
164///
165/// A miss is a `KeyError`, which is how `to_ordinal` fails on negatives
166/// (bug 4) — `value < 10` is true for every negative, so it lands here. The
167/// `_` arm covers both out-of-range keys and values too large for `i64`.
168fn ordinal_ones(n: &BigInt) -> Result<&'static str> {
169    match n.to_i64() {
170        Some(1) => Ok("առաջին"),
171        Some(2) => Ok("երկրորդ"),
172        Some(3) => Ok("երրորդ"),
173        Some(4) => Ok("չորրորդ"),
174        Some(5) => Ok("հինգերորդ"),
175        Some(6) => Ok("վեցերորդ"),
176        Some(7) => Ok("յոթերորդ"),
177        Some(8) => Ok("ութերորդ"),
178        Some(9) => Ok("իններորդ"),
179        _ => Err(N2WError::Key(n.to_string())),
180    }
181}
182
183/// Python's `ORDINAL_TEENS` (keys 10..=19).
184fn ordinal_teens(n: &BigInt) -> Result<&'static str> {
185    match n.to_i64() {
186        Some(10) => Ok("տասներորդ"),
187        Some(11) => Ok("տասնմեկերորդ"),
188        Some(12) => Ok("տասներկուերորդ"),
189        Some(13) => Ok("տասներեքերորդ"),
190        Some(14) => Ok("տասնչորսերորդ"),
191        Some(15) => Ok("տասնհինգերորդ"),
192        Some(16) => Ok("տասնվեցերորդ"),
193        Some(17) => Ok("տասնյոթերորդ"),
194        Some(18) => Ok("տասնութերորդ"),
195        Some(19) => Ok("տասնիներորդ"),
196        _ => Err(N2WError::Key(n.to_string())),
197    }
198}
199
200/// Python's `ORDINAL_TENS` (keys 2..=9, i.e. the tens digit).
201fn ordinal_tens(n: &BigInt) -> Result<&'static str> {
202    match n.to_i64() {
203        Some(2) => Ok("քսաներորդ"),
204        Some(3) => Ok("երեսուներորդ"),
205        Some(4) => Ok("քառասուներորդ"),
206        Some(5) => Ok("հիսուներորդ"),
207        Some(6) => Ok("վաթսուներորդ"),
208        Some(7) => Ok("յոթանասուներորդ"),
209        Some(8) => Ok("ութսուներորդ"),
210        Some(9) => Ok("իննսուներորդ"),
211        _ => Err(N2WError::Key(n.to_string())),
212    }
213}
214
215/// Python's module-level `TENS` (keys 2..=9, i.e. the tens digit).
216///
217/// Distinct from the `mid_numwords` tens: this table is only used by
218/// `to_ordinal` for the 21..99 "<ten> <ordinal unit>" form.
219fn tens_word(n: &BigInt) -> Result<&'static str> {
220    match n.to_i64() {
221        Some(2) => Ok("քսան"),
222        Some(3) => Ok("երեսուն"),
223        Some(4) => Ok("քառասուն"),
224        Some(5) => Ok("հիսուն"),
225        Some(6) => Ok("վաթսուն"),
226        Some(7) => Ok("յոթանասուն"),
227        Some(8) => Ok("ութսուն"),
228        Some(9) => Ok("իննսուն"),
229        _ => Err(N2WError::Key(n.to_string())),
230    }
231}
232
233/// Numeric `value == 0` over either [`FloatValue`] arm — Python's `if value
234/// == 0` in `to_ordinal`, which -0.0 and `Decimal("0.00")` both satisfy.
235fn fv_eq_zero(v: &FloatValue) -> bool {
236    match v {
237        FloatValue::Float { value, .. } => *value == 0.0,
238        FloatValue::Decimal { value, .. } => value.is_zero(),
239    }
240}
241
242/// Numeric `value < n` over either [`FloatValue`] arm.
243fn fv_lt(v: &FloatValue, n: i64) -> bool {
244    match v {
245        FloatValue::Float { value, .. } => *value < n as f64,
246        FloatValue::Decimal { value, .. } => *value < BigDecimal::from(n),
247    }
248}
249
250/// Numeric `value >= n` over either [`FloatValue`] arm.
251fn fv_ge(v: &FloatValue, n: i64) -> bool {
252    match v {
253        FloatValue::Float { value, .. } => *value >= n as f64,
254        FloatValue::Decimal { value, .. } => *value >= BigDecimal::from(n),
255    }
256}
257
258/// The dict key as Python's KeyError would carry it — `repr(key)`-ish. Only
259/// the exception *type* is corpus-pinned; this keeps the message plausible.
260fn fv_key_repr(v: &FloatValue) -> String {
261    match v {
262        FloatValue::Float { value, .. } => py_repr_f64(*value),
263        FloatValue::Decimal { value, .. } => {
264            format!("Decimal('{}')", crate::strnum::python_decimal_str(value))
265        }
266    }
267}
268
269// ---------------------------------------------------------------------------
270// CPython float semantics.
271//
272// `Num2Word_HY.to_currency` hands *floats* to `to_cardinal`, and
273// `Num2Word_Base.float2tuple` then does its arithmetic in binary floating
274// point. That arithmetic is observable: it is exactly why `float2tuple`
275// carries a 0.01 fudge factor to undo its own rounding noise.
276//
277// Reproducing it with exact `BigDecimal` arithmetic is tempting and **wrong**.
278// Checked against CPython over 200k random doubles, an exact-decimal model of
279// `float2tuple` disagrees with the real thing on ~24% of full-precision inputs
280// (e.g. `0.4336456836623859` -> exact says post=4336456836623859, CPython says
281// 4336456836623858), and an exact model of `int(round(val * 100))` disagrees
282// above ~4.5e13 (e.g. `995691641656199.0` -> CPython 99569164165619904, exact
283// 99569164165619900). So the float ops are mirrored in `f64`, which is the same
284// IEEE-754 double arithmetic CPython performs.
285// ---------------------------------------------------------------------------
286
287/// The `f64` CPython was holding.
288///
289/// The `BigDecimal` was parsed from Python's `str(value)`, and `repr` of a
290/// float round-trips, so re-parsing those same digits recovers the identical
291/// double. Rust's float parser is correctly rounded, as is CPython's, so the
292/// two agree bit for bit.
293fn bd_to_f64(d: &BigDecimal) -> f64 {
294    d.to_string().parse::<f64>().unwrap_or(f64::NAN)
295}
296
297/// `int(x)` for a float: truncate toward zero, exactly and without an `i64`
298/// bottleneck. `BigDecimal::try_from(f64)` yields the double's *exact* binary
299/// value, so `int(1e300)` reproduces all 301 digits as CPython does.
300fn f64_trunc_to_bigint(v: f64) -> Result<BigInt> {
301    BigDecimal::try_from(v)
302        .map(|b| b.with_scale(0).as_bigint_and_exponent().0)
303        // int(inf) / int(nan) -> OverflowError / ValueError in CPython; only
304        // the overflow is reachable here (values come from finite input).
305        .map_err(|_| N2WError::Overflow(format!("cannot convert float {} to integer", v)))
306}
307
308/// `10 ** p` as CPython converts it for `float * int`.
309///
310/// Python builds the exact integer, then `float.__mul__` converts it with
311/// `PyLong_AsDouble` (correctly rounded), raising OverflowError past 1e308.
312/// Parsing `"1e{p}"` is likewise correctly rounded, so it matches; `powi`
313/// would not, as it accumulates error past 1e22.
314fn pow10_f64(p: i64) -> Result<f64> {
315    if p >= 309 {
316        return Err(N2WError::Overflow(
317            "int too large to convert to float".to_string(),
318        ));
319    }
320    Ok(format!("1e{}", p).parse::<f64>().unwrap_or(f64::INFINITY))
321}
322
323/// `abs(Decimal(repr(v)).as_tuple().exponent)` — the `precision` that
324/// `float2tuple` derives from `str(value)`.
325///
326/// CPython's `repr` prints the shortest round-tripping digits, switching to
327/// exponent form when the decimal point position `decpt` satisfies
328/// `decpt <= -4 || decpt > 16`. Rust's `{:e}` gives the same shortest digits,
329/// so `decpt` and the digit count are recoverable from it.
330///
331/// For `value = 0.digits x 10**decpt` the resulting `Decimal` exponent is
332/// `decpt - len(digits)` in every case *except* a positional integral value,
333/// where CPython appends ".0" and the exponent becomes -1 (`repr(50.0)` is
334/// "50.0", exponent -1, **not** "50" with exponent 0).
335fn python_repr_scale(v: f64) -> i64 {
336    if v == 0.0 {
337        return 1; // repr(0.0) == "0.0" -> exponent -1
338    }
339    let s = format!("{:e}", v.abs()); // e.g. "3.45e1"
340    let (mant, exp) = match s.split_once('e') {
341        Some(pair) => pair,
342        None => return 1,
343    };
344    let exp: i64 = exp.parse().unwrap_or(0);
345    let ndigits = mant.chars().filter(|c| c.is_ascii_digit()).count() as i64;
346    let decpt = exp + 1;
347    if decpt > -4 && decpt <= 16 && decpt >= ndigits {
348        // Positional and integral: CPython writes the trailing ".0".
349        return 1;
350    }
351    (decpt - ndigits).abs()
352}
353
354/// `repr(v)` for a float, as CPython prints it.
355///
356/// Shortest round-tripping digits, switching to exponent form when
357/// `decpt <= -4 || decpt > 16`, with a trailing ".0" forced on positional
358/// integral values and a 2-digit minimum exponent. Only the `errmsg_toobig`
359/// interpolation needs this; see [`LangHy::super_to_cardinal_f64`].
360///
361/// # Known divergence (last digit, exact ties only)
362///
363/// Rust's `{:e}` and CPython's `repr` are both shortest-round-trip, but they
364/// break an exact tie differently. `844923945304372.2` is the double
365/// 844923945304372.25, sitting exactly between two 16-digit candidates that
366/// both round-trip; CPython rounds the final digit to even ("...372.2"), Rust
367/// rounds away ("...372.3"). Measured at 1 in 4021 random doubles.
368///
369/// This is confined to this function, and this function only ever feeds an
370/// OverflowError *message*. It cannot affect any conversion output:
371/// [`python_repr_scale`] depends on the digit *count*, which ties do not
372/// change, and it matched CPython on 4021/4021 of the same sample. The message
373/// itself is unreachable through `to_currency` (which raises InvalidOperation
374/// at 1e26, far below MAXVAL 1e36).
375fn py_repr_f64(v: f64) -> String {
376    if v.is_nan() {
377        return "nan".to_string();
378    }
379    if v.is_infinite() {
380        return if v < 0.0 { "-inf" } else { "inf" }.to_string();
381    }
382    if v == 0.0 {
383        return if v.is_sign_negative() { "-0.0" } else { "0.0" }.to_string();
384    }
385    let neg = v < 0.0;
386    let s = format!("{:e}", v.abs()); // Rust's shortest round-trip, e.g. "3.45e1"
387    let (mant, exp) = match s.split_once('e') {
388        Some(p) => p,
389        None => return s,
390    };
391    let exp: i64 = exp.parse().unwrap_or(0);
392    let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
393    let nd = digits.len();
394    let decpt = exp + 1;
395    let body = if decpt <= -4 || decpt > 16 {
396        let mut m = String::new();
397        m.push_str(&digits[..1]);
398        if nd > 1 {
399            m.push('.');
400            m.push_str(&digits[1..]);
401        }
402        format!(
403            "{}e{}{:02}",
404            m,
405            if exp < 0 { "-" } else { "+" },
406            exp.abs()
407        )
408    } else if decpt <= 0 {
409        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
410    } else if decpt as usize >= nd {
411        format!("{}{}.0", digits, "0".repeat(decpt as usize - nd))
412    } else {
413        format!("{}.{}", &digits[..decpt as usize], &digits[decpt as usize..])
414    };
415    if neg {
416        format!("-{}", body)
417    } else {
418        body
419    }
420}
421
422/// `int > float` as Python evaluates it: **exactly**, with no float cast.
423///
424/// This matters next to `divmod`, which *does* cast: `splitnum` picks its card
425/// with an exact comparison and then divides in binary floating point.
426fn bigint_gt_f64(a: &BigInt, b: f64) -> bool {
427    match BigDecimal::try_from(b) {
428        Ok(bd) => BigDecimal::from(a.clone()) > bd,
429        // b is inf/nan: every finite int is < +inf and > -inf.
430        Err(_) => b < 0.0 || b.is_nan(),
431    }
432}
433
434/// `float(some_int)` as CPython's `PyLong_AsDouble` does it: correctly
435/// rounded. `BigInt`'s Display is exact and Rust's float parser is correctly
436/// rounded, so the round-trip reproduces it.
437fn bigint_to_f64(a: &BigInt) -> f64 {
438    a.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
439}
440
441/// CPython's `float.__mod__` (`float_rem`): C `fmod`, then take the divisor's
442/// sign. Rust's `%` on `f64` is `fmod`, which is exact.
443fn py_float_mod(x: f64, y: f64) -> f64 {
444    let m = x % y;
445    if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
446        m + y
447    } else {
448        m
449    }
450}
451
452/// CPython's `float.__floordiv__` (`float_floor_div`), including the
453/// `div - floordiv > 0.5` correction it applies after the exact subtraction.
454fn py_float_floordiv(x: f64, y: f64) -> f64 {
455    let m = x % y;
456    let mut div = (x - m) / y;
457    if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
458        // CPython also fixes up `mod` here; only `div` escapes to the caller.
459        div -= 1.0;
460    }
461    if div != 0.0 {
462        let fd = div.floor();
463        if div - fd > 0.5 {
464            fd + 1.0
465        } else {
466            fd
467        }
468    } else {
469        (0.0f64).copysign(x / y)
470    }
471}
472
473/// `10**28` — the point at which `(Decimal * 100) % 1` exceeds the default
474/// decimal context's 28-digit precision and raises. Built once; see the call
475/// site in [`LangHy::to_currency`].
476fn decimal_prec_limit() -> &'static BigDecimal {
477    static LIMIT: OnceLock<BigDecimal> = OnceLock::new();
478    LIMIT.get_or_init(|| BigDecimal::from(BigInt::from(10).pow(28)))
479}
480
481/// The rebound `cents` local in `Num2Word_HY.to_currency`.
482///
483/// The parameter arrives as a `bool` and is then overwritten with either an
484/// `int` (the whole-cents path) or a `Decimal` (the fractional-cents path).
485/// The body later branches on `isinstance(cents, Decimal)`, so the two types
486/// are load-bearing and cannot be unified into one numeric.
487enum Cents {
488    Int(BigInt),
489    Dec(BigDecimal),
490}
491
492impl Cents {
493    /// Python's `if cents:` — zero is falsy for both `int` and `Decimal`.
494    fn is_zero(&self) -> bool {
495        match self {
496            Cents::Int(i) => i.is_zero(),
497            Cents::Dec(d) => d.is_zero(),
498        }
499    }
500
501    /// Python's `cents == 50`. A `Decimal` with a fractional part never
502    /// compares equal, which is what keeps the 50/25/75/5 arms out of the
503    /// fractional path.
504    fn eq_i64(&self, n: i64) -> bool {
505        match self {
506            Cents::Int(i) => i == &BigInt::from(n),
507            Cents::Dec(d) => d == &BigDecimal::from(n),
508        }
509    }
510}
511
512/// Python's `CURRENCY_FORMS` for HY.
513///
514/// `Num2Word_HY` declares all eleven itself; `Num2Word_Base.CURRENCY_FORMS` is
515/// `{}` and nothing is merged in, so this table is the whole set. Every entry
516/// carries exactly two forms, matching Python's arity — `pluralize` indexes
517/// `forms[0]`/`forms[1]`, and both happen to be the same word in Armenian.
518///
519/// Built once in `new()` and stored: constructing it per call is what made an
520/// earlier revision of this port slower than the Python it replaces.
521fn currency_table() -> HashMap<&'static str, CurrencyForms> {
522    let mut m = HashMap::with_capacity(11);
523    m.insert("AMD", CurrencyForms::new(&["դրամ", "դրամ"], &["լումա", "լումա"]));
524    m.insert("EUR", CurrencyForms::new(&["եվրո", "եվրո"], &["ցենտ", "ցենտ"]));
525    m.insert(
526        "RUB",
527        CurrencyForms::new(&["ռուբլի", "ռուբլի"], &["կոպեկ", "կոպեկ"]),
528    );
529    m.insert("USD", CurrencyForms::new(&["դոլար", "դոլար"], &["ցենտ", "ցենտ"]));
530    m.insert("JPY", CurrencyForms::new(&["իեն", "իեն"], &["սեն", "սեն"]));
531    m.insert(
532        "GBP",
533        CurrencyForms::new(&["ֆունտ ստեռլինգ", "ֆունտ ստեռլինգ"], &["պենս", "պենս"]),
534    );
535    m.insert(
536        "CHF",
537        CurrencyForms::new(
538            &["շվեյցարական ֆրանկ", "շվեյցարական ֆրանկ"],
539            &["սանտիմ", "սանտիմ"],
540        ),
541    );
542    m.insert("CNY", CurrencyForms::new(&["յուան", "յուան"], &["ֆեն", "ֆեն"]));
543    m.insert(
544        "IRR",
545        CurrencyForms::new(&["իրանական ռիալ", "իրանական ռիալ"], &["դինար", "դինար"]),
546    );
547    m.insert(
548        "TRY",
549        CurrencyForms::new(&["թուրքական լիրա", "թուրքական լիրա"], &["ղուրուշ", "ղուրուշ"]),
550    );
551    m.insert(
552        "AED",
553        CurrencyForms::new(&["արաբական դիրհամ", "արաբական դիրհամ"], &["ֆիլս", "ֆիլս"]),
554    );
555    m
556}
557
558pub struct LangHy {
559    cards: Cards,
560    maxval: BigInt,
561    exclude_title: Vec<String>,
562    currency_forms: HashMap<&'static str, CurrencyForms>,
563    // Cached constants, so the hot paths avoid re-allocating BigInts.
564    hundred: BigInt,
565    thousand: BigInt,
566    million: BigInt,
567    billion: BigInt,
568    trillion: BigInt,
569    ten: BigInt,
570    twenty: BigInt,
571    two: BigInt,
572}
573
574impl Default for LangHy {
575    fn default() -> Self {
576        Self::new()
577    }
578}
579
580impl LangHy {
581    pub fn new() -> Self {
582        let mut cards = Cards::new();
583
584        // Python's `set_high_numwords(high_numwords)`, bug 1:
585        //   max = 3 + 10 * 3 = 33; zip(high, range(33, 3, -10)) → 33, 23, 13.
586        // The card *words* are the tuples themselves; we store their str().
587        let ten = BigInt::from(10u8);
588        cards.insert(ten.pow(33), TUPLE_CARD_E33);
589        cards.insert(ten.pow(23), TUPLE_CARD_E23);
590        cards.insert(ten.pow(13), TUPLE_CARD_E13);
591
592        // Python's `mid_numwords`. Note the gap: nothing between 1000 and
593        // 10**13, because bug 1 misplaced every high card.
594        set_mid_numwords(
595            &mut cards,
596            &[
597                (1000, "հազար"),
598                (100, "հարյուր"),
599                (90, "իննսուն"),
600                (80, "ութսուն"),
601                (70, "յոթանասուն"),
602                (60, "վաթսուն"),
603                (50, "հիսուն"),
604                (40, "քառասուն"),
605                (30, "երեսուն"),
606                (20, "քսան"),
607            ],
608        );
609
610        // Python's `low_numwords`: 20 entries mapping to 19 down to 0.
611        set_low_numwords(
612            &mut cards,
613            &[
614                "տասնինը",
615                "տասնութ",
616                "տասնյոթ",
617                "տասնվեց",
618                "տասնհինգ",
619                "տասնչորս",
620                "տասներեք",
621                "տասներկու",
622                "տասնմեկ",
623                "տասը",
624                "ինը",
625                "ութ",
626                "յոթ",
627                "վեց",
628                "հինգ",
629                "չորս",
630                "երեք",
631                "երկու",
632                "մեկ",
633                "զրո",
634            ],
635        );
636
637        // Python: MAXVAL = 1000 * list(self.cards.keys())[0]. Insertion order
638        // is descending, so keys()[0] is 10**33 → MAXVAL = 10**36.
639        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
640
641        LangHy {
642            cards,
643            maxval,
644            // Dead (is_title is False for HY) but present in Python's setup.
645            exclude_title: vec!["և".into(), "ամբողջ".into(), "մինուս".into()],
646            currency_forms: currency_table(),
647            hundred: BigInt::from(100),
648            thousand: BigInt::from(1000),
649            million: BigInt::from(1_000_000),
650            billion: BigInt::from(1_000_000_000u64),
651            trillion: BigInt::from(10u8).pow(12),
652            ten: BigInt::from(10),
653            twenty: BigInt::from(20),
654            two: BigInt::from(2),
655        }
656    }
657
658    /// Inherited `Num2Word_Base.verify_ordinal`. Integer input can never trip
659    /// the float check, so only the negative check is observable here.
660    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
661        if value.sign() == num_bigint::Sign::Minus {
662            return Err(N2WError::Type(format!(
663                "Cannot treat negative num {} as ordinal.",
664                value
665            )));
666        }
667        Ok(())
668    }
669
670    /// Inherited `Num2Word_Base.float2tuple`, **float branch**.
671    ///
672    /// `precision` is the instance attribute the Python sets here rather than
673    /// returns; callers need it too, so it is threaded through explicitly
674    /// instead. `to_cardinal_float` saves and restores `self.precision` around
675    /// the call, so no state escapes and passing it is equivalent.
676    ///
677    /// The 0.01 test is Python undoing its own float noise: `1.239999999`
678    /// would otherwise floor to 239999998.
679    fn float2tuple(&self, value: f64, precision: i64) -> Result<(BigInt, BigInt)> {
680        let pre = f64_trunc_to_bigint(value)?;
681        // `value - pre` is float-minus-int in Python; float(int(value)) is
682        // exactly value.trunc(), and the subtraction is exact either way.
683        let pow = pow10_f64(precision)?;
684        let post = (value - value.trunc()).abs() * pow;
685        let rounded = post.round_ties_even(); // Python 3 round() is half-even
686        let post = if (rounded - post).abs() < 0.01 {
687            rounded
688        } else {
689            post.floor()
690        };
691        Ok((pre, f64_trunc_to_bigint(post)?))
692    }
693
694    /// Inherited `Num2Word_Base.to_cardinal_float`.
695    ///
696    /// `precision` is derived from `str(value)` inside Python's `float2tuple`;
697    /// see [`python_repr_scale`].
698    fn to_cardinal_float(&self, value: f64) -> Result<String> {
699        let precision = python_repr_scale(value);
700        let (pre, post) = self.float2tuple(value, precision)?;
701
702        // post = "0" * (precision - len(post)) + str(post). Python's `*` on a
703        // negative count yields "", so a longer post is left untouched — and
704        // the loop below then reads only its first `precision` characters.
705        let mut post_s = post.to_string();
706        let plen = post_s.chars().count() as i64;
707        if precision > plen {
708            post_s = "0".repeat((precision - plen) as usize) + &post_s;
709        }
710
711        let mut out = vec![self.to_cardinal(&pre)?];
712        if value < 0.0 && pre.is_zero() {
713            out.insert(0, self.negword().trim().to_string());
714        }
715        if precision != 0 {
716            out.push(self.title(self.pointword()));
717        }
718        let chars: Vec<char> = post_s.chars().collect();
719        for i in 0..precision as usize {
720            let curr = chars[i].to_digit(10).unwrap_or(0);
721            out.push(self.to_cardinal(&BigInt::from(curr))?);
722        }
723        Ok(out.join(" "))
724    }
725
726    /// `Num2Word_Base.splitnum` for **float** input.
727    ///
728    /// Python has one method and lets duck typing carry a float through it, so
729    /// the decomposition is done in binary floating point. That is observable
730    /// and cannot be replaced by the exact integer engine: `divmod(value, elem)`
731    /// casts the card to a double, and cards above 2**53 are not exactly
732    /// representable. `10**23` becomes 99999999999999991611392.0, so
733    /// `splitnum(2e23)` divides by *that* and yields a clean `(2.0, 0.0)`,
734    /// whereas exact integer arithmetic on the same double yields a quotient of
735    /// 1 and a 23-digit remainder. Verified against CPython: `to_currency(2e23,
736    /// "KWD")` is "երկու (1000000000, 'միլիարդ')", not the long form.
737    ///
738    /// The leaves still carry the card keys as exact `BigInt`s, because that is
739    /// what Python puts in the tuples — only the division is float-tainted — so
740    /// `base::clean` and [`Lang::merge`] are reused unchanged.
741    fn splitnum_f64(&self, value: f64) -> Option<Vec<Node>> {
742        for (elem, word) in self.cards.iter() {
743            // Python's `if elem > value: continue` — exact, unlike the divmod.
744            if bigint_gt_f64(elem, value) {
745                continue;
746            }
747            let mut out: Vec<Node> = Vec::new();
748            let elem_f = bigint_to_f64(elem);
749            let (div, mod_) = if value == 0.0 {
750                (1.0, 0.0)
751            } else {
752                (
753                    py_float_floordiv(value, elem_f),
754                    py_float_mod(value, elem_f),
755                )
756            };
757
758            if div == 1.0 {
759                let one = BigInt::one();
760                let w = self.cards.get(&one).unwrap_or("").to_string();
761                out.push(Node::Leaf(w, one));
762            } else {
763                if div == value {
764                    // Tally systems; unreachable for HY (no card equals 1
765                    // besides `cards[1]`), ported for shape.
766                    let reps = div.to_string().parse::<usize>().unwrap_or(0);
767                    return Some(vec![Node::Leaf(
768                        word.repeat(reps),
769                        f64_trunc_to_bigint(div * elem_f).ok()?,
770                    )]);
771                }
772                out.push(Node::List(self.splitnum_f64(div)?));
773            }
774
775            out.push(Node::Leaf(word.clone(), elem.clone()));
776
777            if mod_ != 0.0 {
778                out.push(Node::List(self.splitnum_f64(mod_)?));
779            }
780            return Some(out);
781        }
782        None
783    }
784
785    /// `Num2Word_Base.to_cardinal` for a float that passes its
786    /// `assert int(value) == value` — i.e. the integral-float path.
787    ///
788    /// The MAXVAL comparison is exact (int vs float), while the message
789    /// interpolates the float with `%s`, hence [`py_repr_f64`]. Unreachable
790    /// through `to_currency`, which raises InvalidOperation at 1e26 — far below
791    /// MAXVAL (1e36) — but reachable via `cardinal_from_decimal`.
792    fn super_to_cardinal_f64(&self, value: f64) -> Result<String> {
793        let mut out = String::new();
794        let mut v = value;
795        if v < 0.0 {
796            v = v.abs();
797            out = format!("{} ", self.negword().trim());
798        }
799        // Python: `if value >= self.MAXVAL` — i.e. not (MAXVAL > value).
800        if !bigint_gt_f64(self.maxval(), v) {
801            return Err(N2WError::Overflow(format!(
802                "abs({}) must be less than {}.",
803                py_repr_f64(v),
804                self.maxval()
805            )));
806        }
807        let tree = self.splitnum_f64(v).ok_or_else(|| {
808            N2WError::Overflow(format!(
809                "abs({}) must be less than {}.",
810                py_repr_f64(v),
811                self.maxval()
812            ))
813        })?;
814        let words = match clean(self, tree) {
815            Node::Leaf(t, _) => t,
816            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
817        };
818        Ok(self.title(&format!("{}{}", out, words)))
819    }
820
821    /// `Num2Word_HY.to_cardinal` for **float** input — the twin of the
822    /// `BigInt` [`Lang::to_cardinal`] above.
823    ///
824    /// Python has one polymorphic method; Rust needs the two shapes split. The
825    /// integer twin is the verified one and is left untouched. The comparisons
826    /// here are float comparisons, exactly as Python performs them, which is
827    /// why `to_cardinal(1000.0)` short-circuits to "հազար" just like the int.
828    fn to_cardinal_f64(&self, value: f64) -> Result<String> {
829        if value == 0.0 {
830            return Ok("զրո".to_string());
831        }
832        if value == 1000.0 {
833            return Ok("հազար".to_string());
834        }
835
836        if value >= 1e6 && value < 1e9 {
837            let millions = py_float_floordiv(value, 1e6);
838            let rest = py_float_mod(value, 1e6);
839            let millions_part = if millions == 1.0 {
840                "մեկ միլիոն".to_string()
841            } else if millions == 2.0 {
842                "երկու միլիոն".to_string()
843            } else {
844                format!("{} միլիոն", self.to_cardinal_f64(millions)?)
845            };
846            if rest == 0.0 {
847                return Ok(millions_part);
848            }
849            return Ok(format!("{} {}", millions_part, self.to_cardinal_f64(rest)?));
850        }
851
852        if value == 1e9 {
853            return Ok("մեկ միլիարդ".to_string());
854        } else if py_float_mod(value, 1e9) == 0.0 && value < 1e12 {
855            let prefix = py_float_floordiv(value, 1e9);
856            if prefix == 2.0 {
857                return Ok("երկու միլիարդ".to_string());
858            }
859            return Ok(format!("{} միլիարդ", self.to_cardinal_f64(prefix)?));
860        }
861
862        // super().to_cardinal(value): `assert int(value) == value` diverts
863        // non-integral input to the float path. An integral float stays on the
864        // engine path — but as a *float*, so it goes through the float twin of
865        // splitnum rather than the exact integer engine.
866        let mut result = if value == value.trunc() {
867            self.super_to_cardinal_f64(value)?
868        } else {
869            self.to_cardinal_float(value)?
870        };
871
872        if result.contains("հազար հազար") {
873            result = result.replace("հազար հազար", "միլիոն");
874        }
875        Ok(result)
876    }
877
878    /// `Num2Word_HY.to_cardinal` dispatched on what Python actually held: an
879    /// `int` keeps the exact integer engine, a float takes the float twin.
880    fn to_cardinal_value(&self, val: &CurrencyValue) -> Result<String> {
881        match val {
882            CurrencyValue::Int(i) => self.to_cardinal(i),
883            CurrencyValue::Decimal { value: d, .. } => self.to_cardinal_f64(bd_to_f64(d)),
884        }
885    }
886
887    /// `Num2Word_HY.to_cardinal` for **Decimal** input — the twin of
888    /// [`LangHy::to_cardinal_f64`], but with exact `BigDecimal` arithmetic so
889    /// issue #603's `98746251323029.99` keeps every digit instead of rounding
890    /// through a double (a float cast lands on `…029.98`).
891    ///
892    /// `Num2Word_HY` never overrides `Num2Word_Base.to_cardinal_float`; a
893    /// Decimal reaches the float path exactly the way an int does — through the
894    /// `to_cardinal` wrapper, whose short-circuits (1000, the millions range,
895    /// the billions range) run on the Decimal first, in Decimal arithmetic.
896    /// That is observable: `Decimal("1000000.5")` → "մեկ միլիոն զրո ամբողջ
897    /// հինգ", because the millions arm splits off a `rest` of `Decimal("0.5")`
898    /// and renders it recursively (whole part 0 → "զրո"), which the base
899    /// `to_cardinal_float` alone would never emit.
900    ///
901    /// When no short-circuit fires, `super().to_cardinal(value)` decides:
902    /// integral Decimals take the integer engine (routed back through the
903    /// integer [`Lang::to_cardinal`], which is idempotent for a value that
904    /// already failed every short-circuit above), non-integral input takes
905    /// `Num2Word_Base.to_cardinal_float`'s exact Decimal arm
906    /// ([`default_to_cardinal_float`]). The wrapper's trailing
907    /// "հազար հազար" → "միլիոն" patch (bug 3) is applied last, as in Python.
908    fn to_cardinal_decimal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
909        if value.is_zero() {
910            return Ok("զրո".to_string());
911        }
912        let thousand = BigDecimal::from(1000);
913        if (value - &thousand).is_zero() {
914            return Ok("հազար".to_string());
915        }
916
917        let million = BigDecimal::from(1_000_000);
918        let billion = BigDecimal::from(1_000_000_000i64);
919        let trillion = BigDecimal::from(self.trillion.clone());
920        let million_i = BigInt::from(1_000_000);
921        let billion_i = BigInt::from(1_000_000_000i64);
922
923        // int(value): truncate toward zero, exactly as Python's int(Decimal).
924        let vint = value.with_scale(0).as_bigint_and_exponent().0;
925        // value == int(value)? `with_scale(0)` truncates, so this is the exact
926        // integrality test Python's `assert int(value) == value` performs.
927        let is_integral = (value - value.with_scale(0)).is_zero();
928
929        // value >= 1e6 and value < 1e9. Positive-only (a negative fails the
930        // lower bound), so the whole part floor-divides like Python's Decimal
931        // `//` and the fraction rides along inside `rest = value % 1e6`.
932        if value >= &million && value < &billion {
933            let millions = &vint / &million_i;
934            let rest = value - BigDecimal::from(&millions * &million_i);
935            let millions_part = if millions.is_one() {
936                "մեկ միլիոն".to_string()
937            } else if millions == self.two {
938                "երկու միլիոն".to_string()
939            } else {
940                format!("{} միլիոն", self.to_cardinal(&millions)?)
941            };
942            if rest.is_zero() {
943                return Ok(millions_part);
944            }
945            // Python recomputes precision from `rest`'s own exponent inside the
946            // recursive float2tuple; `abs(exponent)` is `rest`'s scale.
947            let rest_prec = rest.as_bigint_and_exponent().1.max(0) as u32;
948            return Ok(format!(
949                "{} {}",
950                millions_part,
951                self.to_cardinal_decimal(&rest, rest_prec)?
952            ));
953        }
954
955        // value == 1e9, else an exact multiple of 1e9 below 1e12.
956        if (value - &billion).is_zero() {
957            return Ok("մեկ միլիարդ".to_string());
958        } else if is_integral && vint.mod_floor(&billion_i).is_zero() && value < &trillion {
959            // Exact multiple, so `/` (toward zero) and Python's Decimal `//`
960            // agree; a negative multiple keeps its sign via `to_cardinal`.
961            let prefix = &vint / &billion_i;
962            if prefix == self.two {
963                return Ok("երկու միլիարդ".to_string());
964            }
965            return Ok(format!("{} միլիարդ", self.to_cardinal(&prefix)?));
966        }
967
968        // super().to_cardinal(value) + the "հազար հազար" patch.
969        let mut result = if is_integral {
970            self.to_cardinal(&vint)?
971        } else {
972            default_to_cardinal_float(
973                self,
974                &FloatValue::Decimal {
975                    value: value.clone(),
976                    precision,
977                },
978                None,
979            )?
980        };
981        if result.contains("հազար հազար") {
982            result = result.replace("հազար հազար", "միլիոն");
983        }
984        Ok(result)
985    }
986}
987
988impl Lang for LangHy {
989    /// This language's own `to_currency(currency=...)` default,
990    /// read from the live Python signature. Only 44 of 156 use EUR.
991    fn default_currency(&self) -> &str {
992        "AMD"
993    }
994
995    fn cards(&self) -> &Cards {
996        &self.cards
997    }
998    fn maxval(&self) -> &BigInt {
999        &self.maxval
1000    }
1001    fn negword(&self) -> &str {
1002        "մինուս "
1003    }
1004    fn pointword(&self) -> &str {
1005        "ամբողջ"
1006    }
1007    fn exclude_title(&self) -> &[String] {
1008        &self.exclude_title
1009    }
1010
1011    /// Port of `Num2Word_HY.merge`.
1012    ///
1013    /// Every arm returns `cnum + nnum` (bug 2) — the sums are wrong as
1014    /// arithmetic but are load-bearing for the comparisons below, so they are
1015    /// preserved exactly.
1016    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
1017        let (ltext, cnum) = l;
1018        let (rtext, nnum) = r;
1019        let mut ctext = ltext.to_string();
1020        let mut ntext = rtext.to_string();
1021
1022        if cnum.is_one() {
1023            // 1000 needs no "մեկ": "հազար", not "մեկ հազար". This is also why
1024            // to_year's strip is dead code (bug 6).
1025            if nnum == &self.thousand {
1026                return (ntext, nnum.clone());
1027            }
1028            if nnum < &self.thousand {
1029                return (ntext, nnum.clone());
1030            }
1031            ctext = "մեկ".to_string();
1032        }
1033
1034        if nnum < cnum && cnum >= &self.hundred && cnum < &self.thousand {
1035            if nnum.mod_floor(&self.hundred).is_zero() {
1036                // Unreachable in practice (bug 7). Python's `ntext[:-1]` drops
1037                // the last *character*; index by chars, never bytes.
1038                let mut cs: Vec<char> = ntext.chars().collect();
1039                cs.pop();
1040                ntext = cs.into_iter().collect::<String>() + "ի";
1041            }
1042            return (format!("{} {}", ctext, ntext), cnum + nnum);
1043        }
1044
1045        if nnum < &self.hundred {
1046            if cnum < &self.hundred {
1047                // Only 90 spaces its unit off; every other ten concatenates.
1048                if ctext == NINETY {
1049                    return (format!("{} {}", ctext, ntext), cnum + nnum);
1050                }
1051                return (format!("{}{}", ctext, ntext), cnum + nnum);
1052            }
1053            return (format!("{} {}", ctext, ntext), cnum + nnum);
1054        }
1055
1056        (format!("{} {}", ctext, ntext), cnum + nnum)
1057    }
1058
1059    /// Port of `Num2Word_HY.to_cardinal` — the wrapper around `super()`.
1060    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1061        if value.is_zero() {
1062            return Ok("զրո".to_string());
1063        }
1064
1065        // Simple cases
1066        if value == &self.thousand {
1067            return Ok("հազար".to_string());
1068        }
1069
1070        if value >= &self.million && value < &self.billion {
1071            // value is strictly positive here, so `/` and `%` agree with
1072            // Python's floor semantics.
1073            let millions = value / &self.million;
1074            let rest = value % &self.million;
1075            let millions_part = if millions.is_one() {
1076                "մեկ միլիոն".to_string()
1077            } else if millions == self.two {
1078                "երկու միլիոն".to_string()
1079            } else {
1080                format!("{} միլիոն", self.to_cardinal(&millions)?)
1081            };
1082            if rest.is_zero() {
1083                return Ok(millions_part);
1084            }
1085            return Ok(format!("{} {}", millions_part, self.to_cardinal(&rest)?));
1086        }
1087
1088        // For billions
1089        if value == &self.billion {
1090            return Ok("մեկ միլիարդ".to_string());
1091        } else if value.mod_floor(&self.billion).is_zero() && value < &self.trillion {
1092            // Python's `%` and `//` floor toward -inf, so negative multiples of
1093            // 10**9 reach here too: -10**9 → prefix -1 → "մինուս մեկ միլիարդ".
1094            // `mod_floor`/`div_floor` reproduce that; `%` and `/` would not.
1095            let prefix = value.div_floor(&self.billion);
1096            if prefix == self.two {
1097                return Ok("երկու միլիարդ".to_string());
1098            }
1099            return Ok(format!("{} միլիարդ", self.to_cardinal(&prefix)?));
1100        }
1101
1102        // For other cases use standard implementation (super()). This is where
1103        // the negword and the MAXVAL overflow check live.
1104        let mut result = default_to_cardinal(self, value)?;
1105
1106        // Fix for numbers like X000000 and X000000000 (bug 3). Python's
1107        // str.replace is non-overlapping and left-to-right, so
1108        // "հազար հազար հազար հազար" → "միլիոն միլիոն". Rust matches.
1109        if result.contains("հազար հազար") {
1110            result = result.replace("հազար հազար", "միլիոն");
1111        }
1112        Ok(result)
1113    }
1114
1115    /// Port of `Num2Word_HY.to_ordinal`.
1116    ///
1117    /// Never calls `verify_ordinal`; negatives fall through `value < 20` and
1118    /// `value < 10` into `ORDINAL_ONES[value]` and raise `KeyError` (bug 4).
1119    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1120        if value.is_zero() {
1121            return Ok("զրոերորդ".to_string());
1122        }
1123
1124        if value < &self.twenty {
1125            if value < &self.ten {
1126                return ordinal_ones(value).map(|s| s.to_string());
1127            } else {
1128                return ordinal_teens(value).map(|s| s.to_string());
1129            }
1130        }
1131
1132        if value < &self.hundred {
1133            let (tens, units) = value.div_mod_floor(&self.ten);
1134            if units.is_zero() {
1135                return ordinal_tens(&tens).map(|s| s.to_string());
1136            }
1137            return Ok(format!("{} {}", tens_word(&tens)?, ordinal_ones(&units)?));
1138        }
1139
1140        // For larger numbers use simple rule - add "երորդ" at the end (bug 5).
1141        let cardinal = self.to_cardinal(value)?;
1142        Ok(cardinal + "երորդ")
1143    }
1144
1145    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1146        self.verify_ordinal(value)?;
1147        Ok(format!("{}-րդ", value))
1148    }
1149
1150    fn lang_name(&self) -> &str {
1151        "Num2Word_HY"
1152    }
1153
1154    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1155        self.currency_forms.get(code)
1156    }
1157
1158    /// Port of `Num2Word_HY.pluralize`.
1159    ///
1160    /// Both HY forms are always the same word, so the branch is unobservable
1161    /// in output — but it is ported literally rather than collapsed, because
1162    /// `to_cheque` reads `cr1[-1]` and a dropped form would change its arity.
1163    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1164        if forms.is_empty() {
1165            return Ok(String::new());
1166        }
1167        if forms.len() >= 2 {
1168            let ten = BigInt::from(10);
1169            let hundred = BigInt::from(100);
1170            if n.is_one()
1171                || (n.mod_floor(&ten).is_one() && n.mod_floor(&hundred) != BigInt::from(11))
1172            {
1173                return Ok(forms[0].clone());
1174            }
1175            return Ok(forms[1].clone());
1176        }
1177        Ok(forms[0].clone())
1178    }
1179
1180    /// `Num2Word_HY.to_cardinal` reached with a non-integral value.
1181    ///
1182    /// Not used by this file's `to_currency` (which calls the float path
1183    /// directly), but wired up so the inherited `default_to_currency` shape and
1184    /// any later float-cardinal phase get HY's real behaviour rather than the
1185    /// NotImplemented default.
1186    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1187        self.to_cardinal_f64(bd_to_f64(value))
1188    }
1189
1190    /// The float/Decimal reach into `Num2Word_HY.to_cardinal`.
1191    ///
1192    /// `Num2Word_HY` does not override `Num2Word_Base.to_cardinal_float`, so it
1193    /// is *not* one of the 26 languages the trait default serves correctly: a
1194    /// float or Decimal enters through the `to_cardinal` wrapper, whose
1195    /// millions/billions short-circuits run in float (resp. Decimal) arithmetic
1196    /// *before* `super().to_cardinal`'s `assert int(value) == value` sends the
1197    /// non-integral remainder to the base `to_cardinal_float`. The default hook
1198    /// skips that wrapper and so drops the recursive "զրո" the millions arm
1199    /// emits (`1000000.5` → "մեկ միլիոն զրո ամբողջ հինգ", not
1200    /// "մեկ միլիոն ամբողջ հինգ"). This override re-expresses the wrapper for the
1201    /// two operand kinds: the `Float` arm reuses [`LangHy::to_cardinal_f64`],
1202    /// the `Decimal` arm [`LangHy::to_cardinal_decimal`] (exact, for #603).
1203    ///
1204    /// The dispatcher has already sent integral values to the integer
1205    /// [`Lang::to_cardinal`] (their `assert` passes), so only non-integral
1206    /// input normally arrives here; both twins still handle an integral value
1207    /// consistently if one does.
1208    ///
1209    /// `precision_override` (the `precision=` kwarg, issue #580) is ignored,
1210    /// matching the live interpreter: HY's `to_cardinal` takes no `precision`
1211    /// parameter and nothing in HY reads `self.precision`, so
1212    /// `num2words(12.345, lang="hy", precision=p)` is "տասներկու ամբողջ երեք
1213    /// չորս հինգ" for every `p`.
1214    fn to_cardinal_float(
1215        &self,
1216        value: &FloatValue,
1217        _precision_override: Option<u32>,
1218    ) -> Result<String> {
1219        match value {
1220            FloatValue::Float { value: x, .. } => self.to_cardinal_f64(*x),
1221            FloatValue::Decimal { value: d, precision } => {
1222                self.to_cardinal_decimal(d, *precision)
1223            }
1224        }
1225    }
1226
1227    /// Port of `Num2Word_HY.to_currency`.
1228    ///
1229    /// A **complete** override: it never calls `Num2Word_Base.to_currency`, so
1230    /// none of the base's currency machinery applies. Three consequences that
1231    /// look like mistakes and are not:
1232    ///
1233    /// 1. **`CURRENCY_PRECISION` is ignored.** The divisor is hard-coded 100,
1234    ///    so JPY — a zero-decimal currency everywhere else — renders subunits
1235    ///    here: `to_currency(0.5, "JPY")` == "հիսուն սեն".
1236    /// 2. **`separator` and `adjective` do not exist.** Python's signature is
1237    ///    `(self, val, currency="AMD", cents=True)`; the separator is the
1238    ///    hard-coded "," spliced in below, and passing either kwarg to HY is a
1239    ///    TypeError. Both trait parameters are therefore ignored.
1240    /// 3. **An unknown code does not raise.** The `else` returns
1241    ///    `to_cardinal(abs(val))`, so `to_currency(12.34, "KWD")` is
1242    ///    "տասներկու ամբողջ երեք չորս" rather than the NotImplementedError the
1243    ///    base would raise. `to_cheque` is *not* overridden, so it still raises
1244    ///    for those same codes — the two modes disagree.
1245    ///
1246    /// # Faithfully reproduced Python bugs
1247    ///
1248    /// * **The minus is dropped for 1.5 USD.** The `val == 1.5 and currency ==
1249    ///   "USD"` arm returns early, *before* the `is_negative` insert, and `val`
1250    ///   is already `abs()`-ed — so `to_currency(-1.5, "USD")` renders exactly
1251    ///   as `+1.5` does, with no "մինուս".
1252    /// * **The unknown-code branch drops the minus too**, for the same reason:
1253    ///   it returns `to_cardinal(abs(val))` before the insert.
1254    /// * **`cents == 5` loses the subunit noun.** `result.insert(-1, ...)`
1255    ///   splices "ամբողջ հինգ տասներորդ" *before the unit*, and no cents word is
1256    ///   ever appended: `to_currency(1.05, "EUR")` ==
1257    ///   "մեկ ամբողջ հինգ տասներորդ եվրո" — the "ցենտ" is simply gone.
1258    /// * **`cents` is a bool parameter that gets rebound to a number**, so the
1259    ///   name means two different things in one body. Modelled by [`Cents`].
1260    fn to_currency(
1261        &self,
1262        val: &CurrencyValue,
1263        currency: &str,
1264        cents: bool,
1265        _separator: Option<&str>,
1266        _adjective: bool,
1267    ) -> Result<String> {
1268        let mut result: Vec<String> = Vec::new();
1269        let is_negative = val.is_negative();
1270        // val = abs(val) — the int/float split survives the abs, and the
1271        // unknown-code branch below depends on it.
1272        let val: CurrencyValue = match val {
1273            CurrencyValue::Int(i) => CurrencyValue::Int(i.abs()),
1274            CurrencyValue::Decimal { value: d, is_float, .. } => CurrencyValue::Decimal { value: d.abs(), has_decimal: true, is_float: *is_float },
1275        };
1276
1277        // decimal_val = Decimal(str(val)); has_fractional_cents = (dv*100) % 1
1278        let decimal_val: BigDecimal = match &val {
1279            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
1280            CurrencyValue::Decimal { value: d, .. } => d.clone(),
1281        };
1282        let scaled = &decimal_val * BigDecimal::from(100);
1283
1284        // `(decimal_val * 100) % 1` runs under the *default* decimal context,
1285        // whose precision is 28 — it is not exact arithmetic. `Decimal.__mod__`
1286        // raises InvalidOperation(DivisionImpossible) once the integer quotient
1287        // needs more than `prec` digits, and here the quotient is the integer
1288        // part of `decimal_val * 100`. So every `abs(val) >= 1e26` raises,
1289        // int and float alike.
1290        //
1291        // This fires *before* the CURRENCY_FORMS lookup, so it beats even the
1292        // unknown-code branch: `to_currency(1e36, "KWD")` raises rather than
1293        // returning a cardinal. `trunc(x) >= 10**28` iff `x >= 10**28` for the
1294        // integral bound, so the truncation is skipped.
1295        if scaled >= *decimal_prec_limit() {
1296            return Err(N2WError::Custom {
1297                module: "decimal",
1298                class: "InvalidOperation",
1299                msg: "[<class 'decimal.DivisionImpossible'>]".to_string(),
1300            });
1301        }
1302
1303        let has_fractional_cents = (&scaled - scaled.with_scale(0)) != BigDecimal::zero();
1304
1305        let forms = match self.currency_forms.get(currency) {
1306            Some(f) => f,
1307            // else: return self.to_cardinal(val) — note `val` is the abs value,
1308            // so the sign is silently discarded.
1309            None => return self.to_cardinal_value(&val),
1310        };
1311
1312        let whole: BigInt;
1313        let cents_val: Cents;
1314        if cents {
1315            if has_fractional_cents {
1316                // Keep precision for fractional cents.
1317                let cents_decimal = scaled.clone();
1318                whole = decimal_val.with_scale(0).as_bigint_and_exponent().0;
1319                cents_val = Cents::Dec(cents_decimal - BigDecimal::from(&whole * 100));
1320            } else {
1321                // cents = int(round(val * 100)). An int multiplies exactly in
1322                // Python; a float goes through binary arithmetic, and the two
1323                // part company above ~4.5e13 (see the module notes).
1324                let total: BigInt = match &val {
1325                    CurrencyValue::Int(i) => i * 100,
1326                    CurrencyValue::Decimal { value: d, .. } => {
1327                        f64_trunc_to_bigint((bd_to_f64(d) * 100.0).round_ties_even())?
1328                    }
1329                };
1330                let hundred = BigInt::from(100);
1331                whole = total.div_floor(&hundred);
1332                cents_val = Cents::Int(total.mod_floor(&hundred));
1333            }
1334        } else {
1335            whole = match &val {
1336                CurrencyValue::Int(i) => i.clone(),
1337                CurrencyValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
1338            };
1339            cents_val = Cents::Int(BigInt::zero());
1340        }
1341
1342        if !whole.is_zero() {
1343            result.push(self.to_cardinal(&whole)?);
1344            result.push(self.pluralize(&whole, &forms.unit)?);
1345        }
1346
1347        if !cents_val.is_zero() {
1348            // Special case for 1.5 USD. Returns before the `is_negative`
1349            // insert, and `val` is already abs() — so -1.5 USD loses its minus.
1350            if matches!(&val, CurrencyValue::Decimal { value: d, .. } if bd_to_f64(d) == 1.5) && currency == "USD"
1351            {
1352                return Ok("մեկ դոլար ամբողջ հինգ տասներորդ ցենտ".to_string());
1353            }
1354            let has_whole = !whole.is_zero();
1355            if has_whole && cents_val.eq_i64(50) {
1356                result.push("հիսուն".to_string());
1357                result.push(self.pluralize(&BigInt::from(50), &forms.subunit)?);
1358            } else if has_whole && cents_val.eq_i64(25) {
1359                result.push("քսանհինգ".to_string());
1360                result.push(self.pluralize(&BigInt::from(25), &forms.subunit)?);
1361            } else if has_whole && cents_val.eq_i64(75) {
1362                result.push("յոթանասունհինգ".to_string());
1363                result.push(self.pluralize(&BigInt::from(75), &forms.subunit)?);
1364            } else if has_whole && cents_val.eq_i64(5) {
1365                // insert(-1): splices before the unit and never appends the
1366                // subunit noun, so the "ցենտ" is lost outright.
1367                result.insert(result.len() - 1, "ամբողջ հինգ տասներորդ".to_string());
1368            } else {
1369                if has_whole {
1370                    // The lone separator: the whole segment is collapsed into
1371                    // one element with a "," glued on.
1372                    result = vec![result.join(" ") + ","];
1373                }
1374                match &cents_val {
1375                    Cents::Dec(d) => {
1376                        // to_cardinal_float(float(cents)) — the Decimal is cast
1377                        // to a float first, so precision comes from repr() of
1378                        // the float, not from the Decimal's own scale:
1379                        // 12.345 -> cents 34.500 -> "երեսունչորս ամբողջ հինգ".
1380                        result.push(self.to_cardinal_float(bd_to_f64(d))?);
1381                        let n = d.with_scale(0).as_bigint_and_exponent().0;
1382                        result.push(self.pluralize(&n, &forms.subunit)?);
1383                    }
1384                    Cents::Int(n) => {
1385                        result.push(self.to_cardinal(n)?);
1386                        result.push(self.pluralize(n, &forms.subunit)?);
1387                    }
1388                }
1389            }
1390        }
1391
1392        if is_negative {
1393            result.insert(0, "մինուս".to_string());
1394        }
1395        Ok(result.join(" "))
1396    }
1397
1398    /// Port of `Num2Word_HY.to_year`. The `longval` kwarg is unused by the
1399    /// Python body, so it is dropped.
1400    fn to_year(&self, value: &BigInt) -> Result<String> {
1401        // Special case for year: for 1000-1999, remove "մեկ" before "հազար".
1402        if &self.thousand <= value && value < &BigInt::from(2000) {
1403            let mut year_str = self.to_cardinal(value)?;
1404            if year_str.starts_with("մեկ հազար") {
1405                // Dead code (bug 6): merge already suppresses the "մեկ".
1406                // Python's `[4:]` is a character slice — "մեկ " is 4 chars but
1407                // 7 bytes, so a byte slice would panic mid-codepoint.
1408                year_str = year_str.chars().skip(4).collect::<String>().trim().to_string();
1409            }
1410            return Ok(format!("{} թվական", year_str));
1411        }
1412
1413        Ok(format!("{} թվական", self.to_cardinal(value)?))
1414    }
1415
1416    // ---- float / Decimal entry routing --------------------------------
1417
1418    /// `to_ordinal(float/Decimal)` — Python's `Num2Word_HY.to_ordinal` has no
1419    /// type guard, and its dict lookups hash a *whole* float/Decimal exactly
1420    /// like the matching int (`ORDINAL_ONES[5.0]` hits key `5`), so:
1421    ///
1422    /// * `value == 0` (±0.0, `Decimal("0.00")`) -> "զրոերորդ";
1423    /// * whole values behave exactly like the int port — negatives fall into
1424    ///   `ORDINAL_ONES[value]` and raise KeyError (bug 4);
1425    /// * non-whole values below 100 always miss a dict (`ORDINAL_ONES[2.5]`,
1426    ///   or `ORDINAL_ONES[units]` for 20..100) -> KeyError;
1427    /// * everything else is `to_cardinal(value) + "երորդ"` — the float
1428    ///   grammar, tuple-leak bug included.
1429    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1430        if fv_eq_zero(value) {
1431            return Ok("զրոերորդ".to_string());
1432        }
1433        if let Some(i) = value.as_whole_int() {
1434            return self.to_ordinal(&i);
1435        }
1436        if fv_lt(value, 100) {
1437            return Err(N2WError::Key(fv_key_repr(value)));
1438        }
1439        Ok(format!("{}երորդ", self.cardinal_float_entry(value, None)?))
1440    }
1441
1442    /// `to_ordinal_num(float/Decimal)`: base `verify_ordinal(value)` — float
1443    /// check first, then sign (`abs(-0.0) == -0.0` passes, so "-0.0-րդ") —
1444    /// then `str(value) + "-րդ"`. `%s` interpolates `str(value)` == `repr_str`.
1445    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1446        match value.as_whole_int() {
1447            None => Err(N2WError::Type(format!(
1448                "Cannot treat float {} as ordinal.",
1449                repr_str
1450            ))),
1451            Some(i) => {
1452                if i.is_negative() {
1453                    Err(N2WError::Type(format!(
1454                        "Cannot treat negative num {} as ordinal.",
1455                        repr_str
1456                    )))
1457                } else {
1458                    Ok(format!("{}-րդ", repr_str))
1459                }
1460            }
1461        }
1462    }
1463
1464    /// `to_year(float/Decimal)`: the `1000 <= val < 2000` comparison is
1465    /// numeric, so it holds for floats/Decimals too; the "մեկ հազար" strip is
1466    /// the same dead-in-practice guard as the int port. Everything renders
1467    /// through HY's own cardinal (whole -> int path, fractional -> float
1468    /// grammar) plus " թվական".
1469    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1470        if fv_ge(value, 1000) && fv_lt(value, 2000) {
1471            let mut year_str = self.cardinal_float_entry(value, None)?;
1472            if year_str.starts_with("մեկ հազար") {
1473                // Python's `[4:]` is a character slice ("մեկ " is 4 chars).
1474                year_str = year_str
1475                    .chars()
1476                    .skip(4)
1477                    .collect::<String>()
1478                    .trim()
1479                    .to_string();
1480            }
1481            return Ok(format!("{} թվական", year_str));
1482        }
1483        Ok(format!("{} թվական", self.cardinal_float_entry(value, None)?))
1484    }
1485
1486    /// `converter.str_to_number` — Base's `Decimal(value)`, which HY does not
1487    /// override. Infinity/NaN parse fine as Decimals, but HY's `to_cardinal`
1488    /// then dies in *decimal* arithmetic, not in `int()`: `Infinity >= 10**6`
1489    /// passes and `Infinity % 10**9` raises `decimal.InvalidOperation`, while
1490    /// `NaN >= 10**6` (an ordered comparison against a NaN) raises
1491    /// `decimal.InvalidOperation` directly. The binding's shared sentinels
1492    /// would report OverflowError/ValueError instead, so the interception
1493    /// happens here, matching all three pinned cardinal rows.
1494    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1495        match python_decimal_parse(s)? {
1496            ParsedNumber::Inf { .. } | ParsedNumber::NaN => Err(N2WError::Custom {
1497                module: "decimal",
1498                class: "InvalidOperation",
1499                msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
1500            }),
1501            other => Ok(other),
1502        }
1503    }
1504}