Skip to main content

num2words2_core/
lang_kz.rs

1//! Port of `lang_KZ.py` (Kazakh).
2//!
3//! Shape: **self-contained**. `Num2Word_KZ` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives `_int2word` over 3-digit chunks. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check** — the only ceiling is the `THOUSANDS` table, which
9//! raises `KeyError` rather than `OverflowError` (see below).
10//!
11//! `setup()` sets `negword = "минус"` and `pointword = "бүтін"`.
12//!
13//! Inherited from `Num2Word_Base` (unchanged by KZ, so the trait defaults do
14//! the right thing):
15//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`
16//!     (verified: corpus `ordinal_num` of `-1` is `"-1"`, of `0` is `"0"`).
17//!   * `to_year(value)        -> self.to_cardinal(value)` → default delegates
18//!     through `&self`, picking up the `to_cardinal` override below. Kazakh
19//!     has no special year form: `to_year(1999)` == `to_cardinal(1999)` ==
20//!     "бір мың тоғыз жүз тоқсан тоғыз".
21//!
22//! The currency surface (`CURRENCY_FORMS`, `pluralize`, `_cents_verbose`) is
23//! ported below. The `"." in n` branch of `to_cardinal` — which only fires for
24//! float/Decimal input — is now ported as `to_cardinal_float` (see
25//! "Float / Decimal cardinal path" below). For integer input `str(number)`
26//! never contains `,` or `.`, so `to_cardinal` still reduces to
27//! `self._int2word(int(n))`.
28//!
29//! No cross-call mutable state: KZ defines no `str_to_number` override and
30//! stashes no flags between methods.
31//!
32//! # Float / Decimal cardinal path (`to_cardinal_float`)
33//!
34//! Python overrides **`to_cardinal`**, not `to_cardinal_float`, and handles
35//! non-integers inline: `n = str(number).replace(",", ".")`, and on `"." in n`
36//! it splits `left.right`, spells `int(left)` and — crucially — `int(right)` as
37//! a *single whole number* (not digit by digit), prefixing one "нөл" per
38//! leading zero of `right`, then joins `left бүтін <zeros> <int(right)>`. A
39//! leading "минус" is added for any negative. It never reads `self.precision`,
40//! so the `precision=` kwarg (issue #580 → `precision_override`) is **inert**
41//! — confirmed live: `num2words(1.5,'kz',precision=5)` and `precision=0` both
42//! give "бір бүтін бес". The Rust core splits int-cardinal (`to_cardinal`,
43//! `&BigInt`) from float-cardinal (`to_cardinal_float`, `&FloatValue`), so
44//! KZ's `"." in n` branch is ported into the `to_cardinal_float` override.
45//!
46//! `right` (Python's fractional digit string) is reconstructed from
47//! `float2tuple`'s `post`, zero-padded to `precision`. That is exact for KZ:
48//! `precision` is by construction the number of fractional digits in
49//! `str(number)` (`abs(Decimal(str(f)).as_tuple().exponent)` for floats, the
50//! Decimal scale otherwise), so `post` == `int(right)` and the padded string ==
51//! `right`. Using `float2tuple` keeps the load-bearing f64 arithmetic and its
52//! `< 0.01` rescue heuristic (e.g. `2.675` → `674.999…` → `675`) intact rather
53//! than recomputing from a decimal string.
54//!
55//! This differs from `Num2Word_Base.to_cardinal_float` (the trait default) in
56//! two ways that make the default wrong for KZ: (1) Base spells the fraction
57//! digit by digit ("отыз төрт" vs Base "үш төрт" for `.34`); (2) Base prepends
58//! the negword only when `pre == 0`, KZ always prepends it for negatives.
59//!
60//! ```text
61//! to_cardinal(1.23)  KZ: "бір бүтін жиырма үш"   Base: "бір бүтін екі үш"
62//! to_cardinal(0.25)  KZ: "нөл бүтін жиырма бес"  Base: "нөл бүтін екі бес"
63//! to_cardinal(1.1)   KZ: "бір бүтін бір"         Base: "бір бүтін бір"  (agree)
64//! ```
65//!
66//! # Fractional cents still diverge (currency gap, deferred to the currency phase)
67//!
68//! `base.to_currency` renders fractional cents (a subunit that is not a whole
69//! number, e.g. 65.3 cents) as `self.to_cardinal(float(right))` — a *virtual*
70//! call, so Python lands in **KZ's** float branch (whole-number spelling),
71//! which the `to_cardinal_float` override above now reproduces. The trait's
72//! **`cardinal_from_decimal`** default, however, still routes to `floatpath`
73//! (Base's digit-by-digit spelling), so it disagrees with KZ whenever the
74//! fractional cents carry two or more significant digits. It is **left at the
75//! default here**: this is the currency surface (a separate, later fixup phase),
76//! and no corpus row reaches it — every KZ `currency:USD` case has at most two
77//! decimals, so `right` is a whole number of cents and the branch never fires.
78//! Flagged rather than patched — see the report's `concerns`.
79//!
80//! # Faithfully reproduced Python bugs
81//!
82//! This is a port, not a rewrite. `to_ordinal` picks its suffix by testing the
83//! cardinal's final character against three hand-written character classes.
84//! Those classes are **incomplete**, and the resulting wrong forms are exactly
85//! what Python emits — every one below is confirmed against the frozen corpus:
86//!
87//! 1. `қ` (U+049B) is **missing** from the consonant class, and no vowel class
88//!    matches it either, so "қырық" (40) falls through to the `else` arm and
89//!    takes the front-vowel suffix: `to_ordinal(40)` == "қырықінші".
90//!    Idiomatic Kazakh is "қырқыншы".
91//! 2. The vowel arms append `ншы`/`нші` with no linking consonant, so
92//!    `to_ordinal(20)` == "жиырманшы" (idiomatic: "жиырмасыншы").
93//! 3. The back/front decision for consonant-final words inspects only the last
94//!    **two** characters, so "миллиард" (last two = "рд", no back vowel) is
95//!    misfiled as front: `to_ordinal(10**9)` == "бір миллиардінші"
96//!    (idiomatic: "бір миллиардыншы"). "триллион" and friends escape this only
97//!    because their last two characters are "он".
98//! 4. `у` (U+0443) appears in no class at all, so "елу" (50) also reaches the
99//!    `else` arm. Here the fallback happens to be right: "елуінші".
100//! 5. There is no "one thousand" elision: `to_cardinal(1000)` == "бір мың",
101//!    not "мың". Kept verbatim.
102//!
103//! # Error variants
104//!
105//! `THOUSANDS` has keys 1..=10 (up to "нониллион" == 1000^10 == 10^30), so the
106//! largest representable value is 10^33 - 1 (11 chunks, top chunk index 10).
107//! At 10^33 the top chunk index becomes 11 and Python raises `KeyError: 11` —
108//! a crash, not a deliberate raise, but the exception *type* is observable, so
109//! parity means reproducing it rather than tidying it into an `OverflowError`.
110//! Verified against the interpreter: `to_cardinal(10**33)` → `KeyError: 11`.
111//! The corpus tops out at 10^21 ("бір секстиллион"), so this bound is
112//! reproduced from the source and a live probe, not from corpus coverage.
113//!
114//! Unlike `lang_PL`, negatives are safe in every mode: `_int2word` strips the
115//! sign and recurses on `abs(n)` *before* `splitbyx`/`get_digits` ever see the
116//! string, so no `'-'` survives into `int()`.
117
118use crate::base::{Lang, N2WError, Result};
119use crate::currency::CurrencyForms;
120use crate::floatpath::{float2tuple, FloatValue};
121use crate::strnum::ParsedNumber;
122use num_bigint::BigInt;
123use num_traits::{Signed, Zero};
124use std::collections::HashMap;
125
126const ZERO: &str = "нөл";
127const NEGWORD: &str = "минус";
128const TEN: &str = "он";
129const HUNDRED: &str = "жүз";
130
131/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0` /
132/// `n3 > 1`), so the empty slot here is never read.
133const ONES: [&str; 10] = [
134    "", "бір", "екі", "үш", "төрт", "бес", "алты", "жеті", "сегіз", "тоғыз",
135];
136
137/// `TWENTIES`, keys 2..=9. Indices 0 and 1 are absent in Python; `n2 == 1` is
138/// handled separately by `TEN`, and `n2 == 0` appends nothing.
139const TWENTIES: [&str; 10] = [
140    "", "", "жиырма", "отыз", "қырық", "елу", "алпыс", "жетпіс", "сексен", "тоқсан",
141];
142
143/// `THOUSANDS`, keys 1..=10. Index 0 is absent in Python (guarded by `i > 0`).
144/// Index 11 and beyond do not exist — that is the `KeyError` ceiling.
145const THOUSANDS: [&str; 11] = [
146    "",
147    "мың",
148    "миллион",
149    "миллиард",
150    "триллион",
151    "квадриллион",
152    "квинтиллион",
153    "секстиллион",
154    "септиллион",
155    "октиллион",
156    "нониллион",
157];
158
159/// Vowels taking the bare `ншы` suffix: а о ұ ы е э.
160const BACK_VOWELS: &str = "аоұыеэ";
161
162/// Vowels taking the bare `нші` suffix: ә і ү ө.
163const FRONT_VOWELS: &str = "әіүө";
164
165/// The consonant class. Transcribed verbatim from Python — note that `қ`
166/// (U+049B) and `у` (U+0443) are **absent**; see module bugs 1 and 4.
167const CONSONANTS: &str = "бвгғджзйклмнңпрстфхһцчшщ";
168
169/// Back vowels probed against the last two characters to choose `ыншы` over
170/// `інші` after a consonant: а о ұ ы.
171const LAST2_BACK: &str = "аоұы";
172
173fn key_error(key: String) -> N2WError {
174    N2WError::Key(key)
175}
176
177/// The suffix-selection tail of `Num2Word_KZ.to_ordinal`, shared by the
178/// integer path and the float/Decimal entry (Python has one method; its
179/// `cardinal = self.to_cardinal(number)` call is virtual over the input type,
180/// so the same character inspection runs on "бес" and on "бес бүтін нөл нөл"
181/// alike).
182///
183/// Python indexes `cardinal[-1]` unguarded. `to_cardinal` of a non-zero value
184/// always yields at least one word, so the empty case is unreachable; an
185/// empty string would be an IndexError in Python, so mirror that rather than
186/// inventing a fallback.
187fn ordinal_suffix(cardinal: String) -> Result<String> {
188    let last = cardinal
189        .chars()
190        .next_back()
191        .ok_or_else(|| N2WError::Index("string index out of range".to_string()))?;
192
193    if BACK_VOWELS.contains(last) {
194        return Ok(format!("{}ншы", cardinal));
195    }
196    if FRONT_VOWELS.contains(last) {
197        return Ok(format!("{}нші", cardinal));
198    }
199    if CONSONANTS.contains(last) {
200        // Python: cardinal[-2:] — the last two *characters*, or the whole
201        // string if it is shorter than two.
202        let mut tail: Vec<char> = cardinal.chars().rev().take(2).collect();
203        tail.reverse();
204        let last2: String = tail.into_iter().collect();
205
206        // Python: any(v in cardinal[-2:] for v in "аоұы")
207        if LAST2_BACK.chars().any(|v| last2.contains(v)) {
208            return Ok(format!("{}ыншы", cardinal));
209        }
210        return Ok(format!("{}інші", cardinal));
211    }
212
213    // Default case: covers қ and у, neither of which is in any class.
214    Ok(format!("{}інші", cardinal))
215}
216
217/// `int(n)`'s ValueError for the no-`"."` branch of `to_cardinal`, reached
218/// when `str(number)` came out in exponent form (`1e+16`, `1E+2`), or as
219/// `inf`/`nan`. The message shape matches CPython's; only the exception type
220/// is corpus-checked.
221fn int_value_error(literal: &str) -> N2WError {
222    N2WError::Value(format!(
223        "invalid literal for int() with base 10: '{}'",
224        literal
225    ))
226}
227
228/// `str(number)` for a float that carries no visible point: exponent form for
229/// finite values (`1e+16`), `inf`/`nan` otherwise. Only used to build the
230/// `int()` ValueError message; the corpus checks exception *types*, so the
231/// digits just need to be recognisably Python-shaped, not a full repr port.
232fn float_no_point_str(f: f64) -> String {
233    if f.is_nan() {
234        return "nan".to_string();
235    }
236    if f.is_infinite() {
237        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
238    }
239    let s = format!("{:e}", f); // e.g. "1e16"
240    match s.split_once('e') {
241        Some((m, e)) if !e.starts_with('-') => format!("{}e+{:0>2}", m, e),
242        Some((m, e)) => format!("{}e-{:0>2}", m, &e[1..]),
243        None => s,
244    }
245}
246
247/// Port of `utils.splitbyx(n, x)` with `x == 3` and `format_int=True`.
248///
249/// `n` is the decimal string of a **non-negative** integer here (`_int2word`
250/// has already stripped any sign), so every chunk is 1..=3 digits and lies in
251/// `0..=999`. That bound is what licenses `u32` rather than `BigInt`: the
252/// chunk width is fixed by the slicing, not by the magnitude of the input.
253/// The number of chunks is unbounded, but it is a length, so `usize` is fine.
254fn splitbyx(n: &str) -> Vec<u32> {
255    // ASCII digits only, so byte indexing and char indexing coincide; go via
256    // chars anyway to keep the slicing honest.
257    let chars: Vec<char> = n.chars().collect();
258    let length = chars.len();
259    let x = 3usize;
260    let parse = |i: usize, j: usize| -> u32 {
261        chars[i..j.min(length)]
262            .iter()
263            .collect::<String>()
264            .parse::<u32>()
265            .expect("splitbyx operates on a non-negative decimal string")
266    };
267
268    let mut out: Vec<u32> = Vec::new();
269    if length > x {
270        let start = length % x;
271        if start > 0 {
272            out.push(parse(0, start));
273        }
274        let mut i = start;
275        while i < length {
276            out.push(parse(i, i + x));
277            i += x;
278        }
279    } else {
280        out.push(parse(0, length));
281    }
282    out
283}
284
285/// Port of `utils.get_digits(n)`:
286/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
287/// (units, tens, hundreds).
288///
289/// Callers only ever pass a `splitbyx` chunk (`0..=999`), for which `"%03d"`
290/// yields exactly three digits and the `[-3:]` slice is a no-op. The negative
291/// hazard that breaks `lang_PL.to_ordinal` cannot arise here.
292fn get_digits(n: u32) -> [usize; 3] {
293    let s = format!("{:03}", n);
294    let chars: Vec<char> = s.chars().collect();
295    let tail = &chars[chars.len() - 3..];
296    let mut a = [0usize; 3];
297    for (k, c) in tail.iter().rev().enumerate() {
298        a[k] = c.to_digit(10).expect("format!(\"{:03}\") emits digits") as usize;
299    }
300    a
301}
302
303/// `Num2Word_KZ.CURRENCY_FORMS`, verbatim — two codes, and that is the whole
304/// table.
305///
306/// KZ declares its own dict in the class body and subclasses `Num2Word_Base`
307/// directly, so it never sees the `lang_EUR` table that `Num2Word_EN.__init__`
308/// mutates in place, and none of EN's ~24 added codes leak in. Confirmed
309/// against the live interpreter *after* import (i.e. after every converter has
310/// been constructed and any in-place mutation has happened):
311/// `{"KZT": ("теңге", "тиын"), "USD": ("доллар", "цент")}`. That is precisely
312/// why the corpus expects `NotImplementedError` for EUR, GBP, JPY, KWD, BHD,
313/// INR, CNY and CHF — 96 of KZ's 108 currency rows.
314///
315/// # The one-element arity is the port, not a shortcut
316///
317/// KZ's entries are **flat `(str, str)` pairs**, not the `(unit_forms,
318/// subunit_forms)` pairs-of-tuples EN uses. `base.py` unpacks them as
319/// `cr1, cr2 = ("доллар", "цент")`, so `cr1` is the *string* "доллар" and `cr2`
320/// the *string* "цент" — there is no plural form to index. All three readers of
321/// that shape are guarded to treat a non-tuple as an opaque whole:
322///
323/// * `pluralize(n, cr1)` — KZ's returns `form` unchanged (see below).
324/// * `cr2[1] if isinstance(cr2, tuple) and len(cr2) > 1 else cr2` — a str, so
325///   the whole string.
326/// * `to_cheque`: `unit = cr1[-1] if isinstance(cr1, tuple) else cr1` — a str,
327///   so the whole string. Without that `isinstance` guard, `cr1[-1]` on a str
328///   would yield the last *character*, "р".
329///
330/// Modelling each side as a **one-element** `Vec` makes all three fall out of
331/// the shared code correctly: `unit.last()` and `subunit.get(1).or(first())`
332/// both return the single string. Adding a second form here would be a
333/// plausible-looking "fix" — Kazakh does have "доллардар" — and would silently
334/// change `to_cheque` and the fractional-cents branch to pick it up. Don't.
335fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
336    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
337    m.insert("USD", CurrencyForms::new(&["доллар"], &["цент"]));
338    m.insert("KZT", CurrencyForms::new(&["теңге"], &["тиын"]));
339    m
340}
341
342pub struct LangKz {
343    /// Built once in `new()`. `to_currency`/`to_cheque` only ever read this,
344    /// and rebuilding it per call is what made an earlier revision of this port
345    /// slower than the Python it replaces.
346    currency_forms: HashMap<&'static str, CurrencyForms>,
347}
348
349impl Default for LangKz {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355impl LangKz {
356    pub fn new() -> Self {
357        LangKz {
358            currency_forms: build_currency_forms(),
359        }
360    }
361
362    /// Port of `Num2Word_KZ._int2word`.
363    ///
364    /// The `feminine` parameter exists in Python but is never read by the
365    /// method body (only `_cents_verbose`, out of scope, ever passes it), so
366    /// it is omitted here.
367    fn int2word(&self, n: &BigInt) -> Result<String> {
368        if n.is_negative() {
369            // Python: " ".join([self.negword, self._int2word(abs(n))]).
370            // negword is "минус" with no padding, so this is a plain space
371            // join — note it does *not* go through `negword.strip()` the way
372            // `Num2Word_Base.to_cardinal` would.
373            return Ok(format!("{} {}", NEGWORD, self.int2word(&n.abs())?));
374        }
375
376        if n.is_zero() {
377            return Ok(ZERO.to_string());
378        }
379
380        let mut words: Vec<&str> = Vec::new();
381        let chunks = splitbyx(&n.to_string());
382        let mut i = chunks.len();
383
384        for x in chunks {
385            i -= 1;
386
387            if x == 0 {
388                continue;
389            }
390
391            let [n1, n2, n3] = get_digits(x);
392
393            if n3 > 0 {
394                if n3 > 1 {
395                    words.push(ONES[n3]);
396                }
397                words.push(HUNDRED);
398            }
399
400            if n2 == 1 {
401                words.push(TEN);
402            } else if n2 > 1 {
403                words.push(TWENTIES[n2]);
404            }
405
406            if n1 > 0 {
407                words.push(ONES[n1]);
408            }
409
410            if i > 0 {
411                // Python: THOUSANDS[i] — a bare dict lookup. Keys stop at 10,
412                // so i >= 11 (n >= 10^33) raises KeyError: 11.
413                let word = THOUSANDS
414                    .get(i)
415                    .copied()
416                    .ok_or_else(|| key_error(i.to_string()))?;
417                words.push(word);
418            }
419        }
420
421        Ok(words.join(" "))
422    }
423}
424
425impl Lang for LangKz {
426    /// This language's own `to_currency(currency=...)` default,
427    /// read from the live Python signature. Only 44 of 156 use EUR.
428    fn default_currency(&self) -> &str {
429        "EUR"
430    }
431
432    /// This language's own `to_currency(separator=...)` default,
433    /// read from the live Python signature. Base's is ",", but only
434    /// 36 of 149 languages actually use it — most default to " " or a
435    /// conjunction, so inheriting Base's comma silently corrupts them.
436    fn default_separator(&self) -> &str {
437        ","
438    }
439
440    fn negword(&self) -> &str {
441        NEGWORD
442    }
443
444    fn pointword(&self) -> &str {
445        "бүтін"
446    }
447
448    /// Port of `Num2Word_KZ.to_cardinal` for integer input.
449    ///
450    /// Python computes `n = str(number).replace(",", ".")` and branches on
451    /// `"." in n`. A Python `int` never stringifies with a `,` or `.`, so the
452    /// fractional branch is unreachable here and the whole method collapses to
453    /// `self._int2word(int(n))`.
454    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
455        self.int2word(value)
456    }
457
458    /// Port of `Num2Word_KZ.to_ordinal`.
459    ///
460    /// Suffixes are chosen by inspecting the *last character* of the cardinal
461    /// (and, for consonant endings, the last two). All indexing is by `char`:
462    /// every letter involved is multi-byte in UTF-8, so byte offsets would be
463    /// wrong. See the module docs for the four classification bugs this
464    /// faithfully reproduces.
465    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
466        if value.is_zero() {
467            return Ok(format!("{}інші", ZERO));
468        }
469
470        let cardinal = self.to_cardinal(value)?;
471        ordinal_suffix(cardinal)
472    }
473
474    /// Port of `Num2Word_KZ.to_cardinal`'s `"." in n` branch (float/Decimal
475    /// input). Python:
476    ///
477    /// ```python
478    /// n = str(number).replace(",", ".")            # has "." here
479    /// is_negative = n.startswith("-")
480    /// abs_n = n[1:] if is_negative else n
481    /// left, right = abs_n.split(".")
482    /// leading_zero_count = len(right) - len(right.lstrip("0"))
483    /// result = "%s %s %s" % (
484    ///     self._int2word(int(left)),
485    ///     self.pointword,
486    ///     (ZERO + " ") * leading_zero_count + self._int2word(int(right)),
487    /// )
488    /// if is_negative: result = self.negword + " " + result
489    /// ```
490    ///
491    /// `right` is Python's fractional digit string; `int(right)` is spelled as a
492    /// single whole number (KZ's divergence from Base's digit-by-digit form),
493    /// with one "нөл" per leading zero of `right`. Reconstructed from
494    /// `float2tuple`: `precision` is exactly the length of `str(number)`'s
495    /// fractional part, so `post` == `int(right)` and `post` zero-padded to
496    /// `precision` == `right`. The f64 arithmetic (and its `< 0.01` rescue, e.g.
497    /// `2.675` → `675`) stays inside `float2tuple`; nothing is recomputed from a
498    /// decimal string.
499    ///
500    /// `precision_override` is ignored: KZ's Python `to_cardinal` never reads
501    /// `self.precision`, so the `precision=` kwarg is inert (verified live).
502    fn to_cardinal_float(
503        &self,
504        value: &FloatValue,
505        _precision_override: Option<u32>,
506    ) -> Result<String> {
507        let precision = value.precision() as usize;
508
509        // (pre, post): pre = int(number) (signed, truncated toward zero),
510        // post = the fractional digits as a non-negative integer.
511        let (pre, post) = float2tuple(value);
512
513        // right = post zero-padded to `precision` digits (Python's fractional
514        // string). ASCII digits only, so byte length == char count.
515        let post_digits = post.to_string();
516        let right = format!(
517            "{}{}",
518            "0".repeat(precision.saturating_sub(post_digits.len())),
519            post_digits
520        );
521        // leading_zero_count = len(right) - len(right.lstrip("0")).
522        let leading_zero_count = right.len() - right.trim_start_matches('0').len();
523
524        // int(left): Python strips the sign from the string first, so `left` is
525        // the integer part of the *absolute* value; the sign is carried
526        // separately by `is_negative` below.
527        let left_words = self.int2word(&pre.abs())?;
528
529        // (ZERO + " ") * leading_zero_count + _int2word(int(right))
530        let fraction = format!(
531            "{}{}",
532            format!("{} ", ZERO).repeat(leading_zero_count),
533            self.int2word(&post)?
534        );
535
536        // "%s %s %s" % (left, pointword, fraction). pointword is used raw in
537        // Python (no title()); KZ is not a title language, so this matches.
538        let mut result = format!("{} {} {}", left_words, self.pointword(), fraction);
539
540        // if is_negative: result = negword + " " + result. Python's
541        // `is_negative = str(number).startswith("-")` follows the *string* sign,
542        // which for a float is the IEEE sign bit — so `-0.0` (str "-0.0") is
543        // negative even though `-0.0 < 0.0` is false. Match that on the raw f64
544        // via `is_sign_negative`; the Decimal arm keeps its own sign. KZ uses
545        // the raw negword ("минус") with no strip(), unlike Base's `negword.strip()`.
546        let is_negative = match value {
547            FloatValue::Float { value, .. } => value.is_sign_negative(),
548            FloatValue::Decimal { value, .. } => value.is_negative(),
549        };
550        if is_negative {
551            result = format!("{} {}", NEGWORD, result);
552        }
553        Ok(result)
554    }
555
556    /// Full `to_cardinal(float/Decimal)` routing — Python's gate is
557    /// `"." in str(number)`, NOT the base default's `int(value) == value`:
558    ///
559    /// * a **visible point** (any finite float below 1e16, or a Decimal with
560    ///   positive scale) takes the fractional branch even for whole values —
561    ///   `5.0` -> "бес бүтін нөл нөл", `Decimal("5.00")` -> "бес бүтін нөл нөл
562    ///   нөл" (one "нөл" per leading zero of the fractional string plus
563    ///   `_int2word(0)`).
564    /// * **no point** funnels the whole string into `int(n)`: plain digit
565    ///   Decimals ("5", "100") reach the integer path, while exponent forms
566    ///   (`str(1e16) == "1e+16"`, `str(Decimal("1E+2")) == "1E+2"`) and
567    ///   inf/nan raise **ValueError**, not the base default's OverflowError.
568    fn cardinal_float_entry(
569        &self,
570        value: &FloatValue,
571        precision_override: Option<u32>,
572    ) -> Result<String> {
573        if value.has_visible_point() {
574            return self.to_cardinal_float(value, precision_override);
575        }
576        match value {
577            // A finite float without a visible point stringifies in exponent
578            // form; inf/nan stringify as "inf"/"nan". int() rejects them all.
579            FloatValue::Float { value, .. } => Err(int_value_error(&float_no_point_str(*value))),
580            FloatValue::Decimal { value, .. } => {
581                let s = crate::strnum::python_decimal_str(value);
582                match crate::strnum::python_int_parse(&s) {
583                    Some(i) => self.to_cardinal(&i),
584                    None => Err(int_value_error(&s)),
585                }
586            }
587        }
588    }
589
590    /// `to_ordinal(float/Decimal)`. Python:
591    ///
592    /// ```python
593    /// if number == 0: return ZERO + "інші"          # 0.0, -0.0, Decimal("0")
594    /// cardinal = self.to_cardinal(number)           # str-routed, see above
595    /// ... suffix by cardinal's last character ...
596    /// ```
597    ///
598    /// The zero test is numeric, so `-0.0` and `Decimal("0.00")` short-circuit
599    /// to "нөлінші" without ever seeing the float grammar. Everything else
600    /// (whole floats included) gets the fractional cardinal plus the suffix:
601    /// `5.0` -> "бес бүтін нөл нөлінші". Exponent forms raise the cardinal's
602    /// ValueError before any suffix logic runs.
603    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
604        if value.as_whole_int().is_some_and(|i| i.is_zero()) {
605            return Ok(format!("{}інші", ZERO));
606        }
607        let cardinal = self.cardinal_float_entry(value, None)?;
608        ordinal_suffix(cardinal)
609    }
610
611    // year_float_entry is deliberately left at the trait default: Python's
612    // Num2Word_Base.to_year is `self.to_cardinal(value)` and KZ does not
613    // override it, so the default's delegation to cardinal_float_entry —
614    // which now carries KZ's own routing — is exactly the port.
615
616    /// `converter.str_to_number` is Base's `Decimal(value)` (KZ doesn't
617    /// override it), but `Decimal("Infinity")` then hits KZ's `to_cardinal`,
618    /// where `str(number)` has no "." and `int("Infinity")` raises
619    /// **ValueError** — not the OverflowError the binding's generic Inf arm
620    /// (which models `int(Decimal("Infinity"))` in *base's* integer path)
621    /// would produce. NaN already maps to ValueError there, matching
622    /// `int("NaN")`, so only Inf needs intercepting.
623    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
624        match crate::strnum::python_decimal_parse(s)? {
625            ParsedNumber::Inf { negative } => Err(int_value_error(if negative {
626                "-Infinity"
627            } else {
628                "Infinity"
629            })),
630            other => Ok(other),
631        }
632    }
633
634    // ---- currency -------------------------------------------------------
635    //
636    // KZ inherits `to_currency`, `to_cheque`, `_money_verbose` and
637    // `_cents_terse` from `Num2Word_Base` unchanged, so the trait defaults
638    // already are the port. It defines neither `CURRENCY_ADJECTIVES` nor
639    // `CURRENCY_PRECISION`, and nothing mutates Base's empty dicts in place —
640    // both read back as `{}` from the live interpreter after import. So:
641    //
642    //   * `currency_adjective` stays at the default `None`; the `adjective=True`
643    //     kwarg is inert for KZ (`currency in self.CURRENCY_ADJECTIVES` is
644    //     always False), and no `prefix_currency` call ever happens.
645    //   * `currency_precision` stays at the default 100, matching
646    //     `CURRENCY_PRECISION.get(code, 100)` over an empty dict. KZ has no
647    //     3-decimal and no 0-decimal currency, so the `divisor == 1000` and
648    //     `divisor == 1` branches of `default_to_currency` are unreachable here
649    //     — KWD/BHD/JPY raise NotImplementedError at the forms lookup instead,
650    //     long before precision matters.
651    //
652    // Only the forms table, the class name, `pluralize` and `_cents_verbose`
653    // are KZ's own.
654
655    /// Feeds `'Currency code "%s" not implemented for "%s"'`. Must stay exactly
656    /// the Python class name: 96 of KZ's 108 corpus currency rows are that
657    /// NotImplementedError.
658    fn lang_name(&self) -> &str {
659        "Num2Word_KZ"
660    }
661
662    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
663        self.currency_forms.get(code)
664    }
665
666    /// Port of `Num2Word_KZ.pluralize`: `def pluralize(self, n, form): return form`.
667    ///
668    /// Kazakh does not inflect the currency name for number, so this is the
669    /// identity on `form` and ignores `n` entirely — "бір доллар", "екі доллар",
670    /// "жүз доллар" all take the same word.
671    ///
672    /// Python's `form` is a *single string*, because KZ's `CURRENCY_FORMS`
673    /// entries are flat `(str, str)` pairs (see `build_currency_forms`). The
674    /// trait hands it over as the one-element slice that models it, so
675    /// `concat()` reconstitutes the string exactly. Unlike `Num2Word_EUR`'s
676    /// `forms[0 if n == 1 else 1]`, this cannot raise: the identity has no index
677    /// to run off the end of, so there is no IndexError to reproduce and the
678    /// `Result` is always `Ok`.
679    fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
680        Ok(forms.concat())
681    }
682
683    /// Port of `Num2Word_KZ._cents_verbose`:
684    /// `return self._int2word(number, currency == "KZT")`.
685    ///
686    /// The `feminine` flag KZ computes here (`currency == "KZT"`) is **dead**:
687    /// `_int2word` takes the parameter and never reads its body — so KZT and USD
688    /// cents spell identically, and only the trailing unit word differs
689    /// ("бір тиын" vs "бір цент"). Reproduced as-is rather than "simplified"
690    /// away, since the argument is part of the ported signature.
691    ///
692    /// Behaviourally this equals the inherited default
693    /// (`Num2Word_Base._cents_verbose` → `self.to_cardinal`), because KZ's
694    /// `to_cardinal` of an int is exactly `_int2word` after a str round-trip
695    /// that changes nothing. Overridden anyway because Python defines it: the
696    /// default only coincides by way of KZ's `to_cardinal` override, and
697    /// spelling it out keeps that coincidence from silently mattering.
698    fn cents_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
699        self.int2word(number)
700    }
701}