Skip to main content

num2words2_core/
lang_kk.rs

1//! Port of `lang_KK.py` (Kazakh).
2//!
3//! Shape: **self-contained**. `Num2Word_KK` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`, so Python never populates `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and delegates to a hand
7//! written `_int_to_word` recursion. `cards`/`maxval`/`merge` therefore stay
8//! at their trait defaults here and are never reached — there is no overflow
9//! check at all (see bug 3 below for what happens instead).
10//!
11//! `Num2Word_KK` overrides every in-scope entry point (`to_cardinal`,
12//! `to_ordinal`, `to_ordinal_num`, `to_year`), so nothing is inherited from
13//! `Num2Word_Base` on the four supported paths. `setup()` only assigns the
14//! word tables reproduced below; the base `__init__` work (`self.cards`,
15//! `self.MAXVAL`, `self.title`) is inert for this language.
16//!
17//! # Faithfully reproduced Python bugs
18//!
19//! This is a port, not a rewrite. All of the following look wrong and are
20//! nevertheless exactly what Python emits — every one is pinned by the frozen
21//! corpus:
22//!
23//! 1. **`to_cardinal(0)` == `"zero"`, in English.** `_int_to_word` opens with
24//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the
25//!    empty string (a placeholder so that `ones[n]` indexes by digit), which
26//!    is falsy, so the `else` branch always wins and the Kazakh converter
27//!    answers with the English word. The Kazakh "нөл" appears nowhere in the
28//!    module. Hence `to_ordinal(0)` == `"zero-інші"`.
29//! 2. **`negword` is `"minus "`, also English** (not "минус"), so
30//!    `to_cardinal(-1)` == `"minus бір"`. Likewise `pointword` is `"point"`.
31//! 3. **Numbers >= 10^9 are returned as bare digits.** `_int_to_word`'s final
32//!    `else` is `return str(number)` — commented "Fallback for very large
33//!    numbers" — so `to_cardinal(10**9)` == `"1000000000"` and
34//!    `to_ordinal(10**9)` == `"1000000000-інші"`. This is the de facto ceiling
35//!    of the language: no `OverflowError` is ever raised, the digits just leak
36//!    through. `billion`/`trillion` are never defined. The fallback prints the
37//!    *absolute* value, because `to_cardinal` strips the sign before calling
38//!    in, so `to_cardinal(-10**9)` == `"minus 1000000000"`.
39//! 4. **100 is `"бір жүз"`, not `"жүз"`.** The `number < 1000` branch is
40//!    unconditionally `self.ones[hundreds_val] + " " + self.hundred`, with no
41//!    `hundreds_val == 1` special case, so the leading "бір" ("one") is always
42//!    emitted. Natural Kazakh would say plain "жүз". Same for `1000` →
43//!    `"бір мың"` (via the `_int_to_word(1)` recursion on the thousands part).
44//!
45//! # No overflow, no errors (integers) — one ValueError (floats)
46//!
47//! Nothing on the four *integer* paths can raise: `_int_to_word` indexes
48//! `ones`/`tens` only with a digit it just computed by `% 10` or `// 100`, and
49//! every value too large for the tables hits the `str(number)` fallback. The
50//! frozen corpus agrees — no `err` row exists for kk on cardinal/ordinal/
51//! ordinal_num/year with integer input. All four functions return `Ok` for
52//! every `BigInt`.
53//!
54//! The **float/Decimal** path has exactly one raising surface: `to_cardinal`
55//! re-parses the sign-stripped `str(number)` with `int()`, so any point-less
56//! form that is not an integer literal — `repr(1e16)` == "1e+16",
57//! `str(Decimal("1E+2"))` == "1E+2", "inf"/"nan"/"Infinity" — raises
58//! `ValueError: invalid literal for int() with base 10: '...'`. Pointed forms
59//! never raise, whatever their size (the >= 10^9 integer part just leaks
60//! digits, bug 3). Pinned by the wholefloat corpus: ValueError rows for
61//! 1e+16/1e+20/1E+2/1E+20 across cardinal/ordinal/year, `ok` rows for every
62//! pointed form including "-0.0" -> "minus zero point zero". See
63//! [`route_by_str`].
64//!
65//! # Currency
66//!
67//! `Num2Word_KK` overrides `to_currency` outright — `Num2Word_Base`'s version,
68//! and with it `parse_currency_parts`, `pluralize`, `CURRENCY_PRECISION` and
69//! `CURRENCY_ADJECTIVES`, is unreachable — but inherits `to_cheque` unchanged.
70//! The two therefore disagree about unknown codes, which is the single most
71//! important thing about this surface: `to_currency` *falls back* to KZT and
72//! never raises, while `to_cheque` raises `NotImplementedError`. See the four
73//! further reproduced bugs (5-9) documented on `to_currency` itself.
74//!
75//! The method is character-for-character the same as `Num2Word_FO`'s,
76//! `Num2Word_BR`'s and several others' — a copy-paste family whose shared
77//! quirks (string-surgery cent split, the eager `.get(..., values()[0])`
78//! default, the ignored `adjective`) are ported identically across those
79//! files.
80
81use crate::base::{Lang, N2WError, Result};
82use crate::currency::{CurrencyForms, CurrencyValue};
83use crate::floatpath::FloatValue;
84use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
85use bigdecimal::BigDecimal;
86use num_bigint::BigInt;
87use num_integer::Integer;
88use num_traits::{One, Signed, ToPrimitive, Zero};
89use std::collections::HashMap;
90
91/// `self.negword`. English, verbatim from Python — see module bug 2.
92const NEGWORD: &str = "minus ";
93
94/// `self.ones`. Index 0 is the empty string; `_int_to_word` never reaches it
95/// via the digit paths (they are guarded by `if ones_val == 0` / `if
96/// remainder`), and the `number == 0` path turns it into "zero" (bug 1).
97const ONES: [&str; 10] = [
98    "", "бір", "екі", "үш", "төрт", "бес", "алты", "жеті", "сегіз", "тоғыз",
99];
100
101/// `self.tens`. Index 0 is the empty string and is unreachable: the `number <
102/// 100` branch is only entered when `number >= 10`, so `tens_val >= 1`.
103const TENS: [&str; 10] = [
104    "", "он", "жиырма", "отыз", "қырық", "елу", "алпыс", "жетпіс", "сексен", "тоқсан",
105];
106
107/// `self.hundred`.
108const HUNDRED: &str = "жүз";
109/// `self.thousand`.
110const THOUSAND: &str = "мың";
111/// `self.million`.
112const MILLION: &str = "миллион";
113
114/// `self.to_ordinal`'s suffix, appended to the cardinal with a literal hyphen.
115const ORDINAL_SUFFIX: &str = "-інші";
116
117/// `self.__class__.__name__`, for the `NotImplementedError` message that
118/// `Num2Word_Base.to_cheque` raises on an unknown code.
119const LANG_NAME: &str = "Num2Word_KK";
120
121/// `Num2Word_KK.to_currency`'s own default: `separator=" "`, a bare space —
122/// *not* `Num2Word_Base`'s `","`.
123const SEPARATOR_DEFAULT: &str = " ";
124
125/// The separator the pyo3 binding hands us when the Python caller omitted one.
126///
127/// `Num2Word_KK.to_currency` declares `separator=" "` in its own signature, but
128/// the `Lang` trait carries no per-language parameter defaults, and both
129/// `__init__.py`'s currency fast path and `bench/diff_test.py` substitute
130/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not KK's —
131/// before the value crosses the boundary. By then "caller omitted separator"
132/// and "caller explicitly passed a comma" are the same `&str`, and the
133/// information needed to tell them apart no longer exists on this side.
134///
135/// So `,` is read back as the unset sentinel and KK's own default restored.
136/// This is the only reading the oracle supports: every float row of the `kk`
137/// currency corpus was generated by `num2words(v, lang="kk", to="currency",
138/// currency=c)` with no `separator=`, which reaches `Num2Word_KK.to_currency`
139/// with its *own* default, and every one of them expects a bare space
140/// ("он екі euros отыз төрт cents", not "он екі euros,отыз төрт cents").
141///
142/// The cost is narrow and known: a caller who *explicitly* passes
143/// `separator=","` gets " " where Python would give ",". Expressing that case
144/// needs `Option<&str>` in the trait signature, which lives in `base.rs` —
145/// outside this port's remit. Flagged in the port report. `lang_fo.rs`,
146/// `lang_br.rs`, `lang_bs.rs` and `lang_ba.rs` resolve the identical conflict
147/// the same way; `lang_FO.py`'s `to_currency` is character-for-character the
148/// same method as this one.
149const SEPARATOR_UNSET: &str = ",";
150
151/// `to_currency`'s fallback code — see currency bug 5.
152///
153/// Python spells it `list(self.CURRENCY_FORMS.values())[0]`, i.e. "whichever
154/// entry was inserted first". `CURRENCY_FORMS` is a dict literal ordered
155/// `KZT, USD, EUR` and dicts preserve insertion order, so that is KZT. Pinning
156/// the *key* rather than reproducing a positional lookup keeps the intent
157/// legible; a `HashMap` has no insertion order to index into anyway.
158///
159/// Verified against the live interpreter rather than read off the source:
160/// `list(CONVERTER_CLASSES["kk"].CURRENCY_FORMS.keys())` == `['KZT', 'USD',
161/// 'EUR']`. Nothing mutates this dict — `Num2Word_KK` declares its own
162/// `CURRENCY_FORMS` and inherits from `Num2Word_Base`, not `Num2Word_EUR`, so
163/// the `Num2Word_EN.__init__` shared-dict rewrite documented in
164/// PORTING_CURRENCY.md does not reach it (`c.CURRENCY_FORMS is
165/// CONVERTER_CLASSES['en'].CURRENCY_FORMS` is `False`).
166const FALLBACK_CURRENCY: &str = "KZT";
167
168/// The hard-coded 2 in `parts[1][:2].ljust(2, "0")` — see currency bug 6.
169const CENT_DIVISOR: i64 = 100;
170
171/// Python's `int(s)` failure, message verbatim from CPython. `s` is the
172/// point-less `str(number)` slice `to_cardinal` feeds it — sign already
173/// stripped, because the leading "-" is peeled off the string before `int()`
174/// ever runs.
175fn int_value_error(s: &str) -> N2WError {
176    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
177}
178
179/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
180/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
181///
182/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
183/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
184/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
185/// Only the exponent dressing needs fixing up. (Same helper as `lang_ky.rs`
186/// and `lang_fo.rs`; `lang_KK.py`'s `to_cardinal` is character-for-character
187/// the same method.)
188fn py_float_sci_repr(a: f64) -> String {
189    let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
190    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
191    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
192    format!(
193        "{}e{}{:02}",
194        mant,
195        if exp < 0 { '-' } else { '+' },
196        exp.abs()
197    )
198}
199
200/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
201enum StrRoute {
202    /// `"." in n` — the decimal grammar spells every fractional character.
203    Point,
204    /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
205    WholeDigits(BigInt),
206}
207
208/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
209/// `ValueError` for the point-less forms that are not integer literals.
210///
211/// * **Float** — repr is fixed-notation (always with a ".") for every finite
212///   value outside the scientific range, so the only no-point floats are the
213///   scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
214///   which make `int()` raise. `FloatValue::has_visible_point` is *not* used
215///   here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
216///   has no point and must raise.
217/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
218///   a point routes to the decimal grammar, plain digits (canonical integral
219///   Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
220///   `int()` ValueError.
221fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
222    match v {
223        FloatValue::Float { value, .. } => {
224            let a = value.abs();
225            if !a.is_finite() {
226                // str(float("inf")) == "inf", str(float("nan")) == "nan" —
227                // no ".", so int() raises. The "-" of "-inf" is stripped by
228                // the string-sign peel before int() ever sees it.
229                return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
230            }
231            if a >= 1e16 || (a > 0.0 && a < 1e-4) {
232                // repr picked exponent form: no "." in n -> int(n) raises.
233                return Err(int_value_error(&py_float_sci_repr(a)));
234            }
235            // Every other finite float reprs with a point ("5.0", "0.0001").
236            Ok(StrRoute::Point)
237        }
238        FloatValue::Decimal { value, .. } => {
239            // abs() first: Python strips the string sign before this test.
240            let s = python_decimal_str(&value.abs());
241            if s.contains('.') {
242                Ok(StrRoute::Point)
243            } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
244                Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
245            } else {
246                // "1E+2", "1E+20", ... — int() raises.
247                Err(int_value_error(&s))
248            }
249        }
250    }
251}
252
253/// Python's `setup()` only assigns constant word tables, which are module-level
254/// `const`s here. The one piece of real state is `CURRENCY_FORMS`.
255pub struct LangKk {
256    /// `Num2Word_KK.CURRENCY_FORMS`.
257    ///
258    /// Built once in [`LangKk::new`] and stored, never per call: the binding
259    /// holds the converter in a `OnceLock`, so this table is constructed
260    /// exactly once per process.
261    ///
262    /// Each entry is a 2-tuple of 2-tuples, `((unit_sg, unit_pl), (sub_sg,
263    /// sub_pl))`. The arity is load-bearing even where the two forms are
264    /// spelled identically (KZT's "теңге"/"теңге"): `to_currency` indexes `[1]`
265    /// for the plural and `Num2Word_Base.to_cheque` takes `cr1[-1]`, so both
266    /// slots must exist.
267    currency_forms: HashMap<&'static str, CurrencyForms>,
268}
269
270impl Default for LangKk {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276impl LangKk {
277    pub fn new() -> Self {
278        // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY
279        // captures the one place Python's ordering was observable.
280        let currency_forms: HashMap<&'static str, CurrencyForms> = [
281            ("KZT", &["теңге", "теңге"][..], &["тиын", "тиын"][..]),
282            ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
283            ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
284        ]
285        .into_iter()
286        .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
287        .collect();
288        LangKk { currency_forms }
289    }
290
291    /// The `(left, right)` split at the head of `Num2Word_KK.to_currency`, for
292    /// a value already made non-negative.
293    ///
294    /// Python works on the *string*:
295    ///
296    /// ```python
297    /// parts = str(val).split(".")
298    /// left = int(parts[0]) if parts[0] else 0
299    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
300    /// ```
301    ///
302    /// Taking the first two fractional digits and right-padding to two is
303    /// exactly `trunc(frac * 100)` for any plain decimal string — `"5"` →
304    /// `"50"` → 50 is `trunc(0.5 * 100)`, `"345"` → `"34"` is
305    /// `trunc(0.345 * 100)` — so this computes the split arithmetically rather
306    /// than round-tripping the value back through `BigDecimal`'s `Display`,
307    /// which switches to exponential form on its own thresholds and is not a
308    /// formatting impl this port controls. (`lang_fo.rs` and `lang_eu.rs` port
309    /// the same idiom the same way.)
310    ///
311    /// `with_scale(0)` truncates (it divides the underlying `BigInt`), which is
312    /// what `int()` does, and both operands are non-negative here, so floor and
313    /// truncation agree.
314    ///
315    /// A true `int` has no "." in `str(val)`, so `len(parts) > 1` is false and
316    /// `right` is 0 — which is also what `frac == 0` gives here. KK therefore
317    /// needs no `isinstance(val, int)` branch: currency bug 7 means the
318    /// int/float distinction is invisible in this language's output.
319    fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
320        match val {
321            CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
322            CurrencyValue::Decimal { value: d, .. } => {
323                let abs = d.abs();
324                // int(parts[0]) — truncation toward zero; abs is non-negative.
325                let left = abs.with_scale(0).as_bigint_and_exponent().0;
326                let frac = &abs - BigDecimal::from(left.clone());
327                let right = (frac * BigDecimal::from(CENT_DIVISOR))
328                    .with_scale(0)
329                    .as_bigint_and_exponent()
330                    .0;
331                (left, right)
332            }
333        }
334    }
335
336    /// Python's `_int_to_word`.
337    ///
338    /// Kept as a direct transcription, including the unreachable arms, so the
339    /// correspondence with the source stays checkable. Cannot fail.
340    fn int_to_word(&self, number: &BigInt) -> String {
341        // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
342        // ONES[0] is "" → falsy → always the English "zero". See bug 1.
343        if number.is_zero() {
344            return if ONES[0].is_empty() {
345                "zero".to_string()
346            } else {
347                ONES[0].to_string()
348            };
349        }
350
351        // `if number < 0: return self.negword + self._int_to_word(abs(number))`
352        // Dead code in practice: `to_cardinal` strips the sign from the string
353        // before parsing, so a negative never reaches here. Ported anyway.
354        if number.is_negative() {
355            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
356        }
357
358        let ten = BigInt::from(10);
359        let hundred = BigInt::from(100);
360        let thousand = BigInt::from(1_000);
361        let million = BigInt::from(1_000_000);
362        let billion = BigInt::from(1_000_000_000);
363
364        if number < &ten {
365            // 1..=9, so the cast and the index are both in range.
366            return ONES[number.to_usize().unwrap()].to_string();
367        }
368
369        if number < &hundred {
370            // Python: `number // 10`, `number % 10`. `number` is positive here,
371            // so floor-division and truncating division agree; div_rem is safe.
372            let (tens_val, ones_val) = number.div_rem(&ten);
373            let t = TENS[tens_val.to_usize().unwrap()]; // 1..=9
374            let o = ones_val.to_usize().unwrap(); // 0..=9
375            return if o == 0 {
376                t.to_string()
377            } else {
378                format!("{} {}", t, ONES[o])
379            };
380        }
381
382        if number < &thousand {
383            let (hundreds_val, remainder) = number.div_rem(&hundred);
384            // No `hundreds_val == 1` special case in Python → "бір жүз". Bug 4.
385            let mut result = format!("{} {}", ONES[hundreds_val.to_usize().unwrap()], HUNDRED);
386            if !remainder.is_zero() {
387                result.push(' ');
388                result.push_str(&self.int_to_word(&remainder));
389            }
390            return result;
391        }
392
393        if number < &million {
394            let (thousands_val, remainder) = number.div_rem(&thousand);
395            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
396            if !remainder.is_zero() {
397                result.push(' ');
398                result.push_str(&self.int_to_word(&remainder));
399            }
400            return result;
401        }
402
403        if number < &billion {
404            let (millions_val, remainder) = number.div_rem(&million);
405            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
406            if !remainder.is_zero() {
407                result.push(' ');
408                result.push_str(&self.int_to_word(&remainder));
409            }
410            return result;
411        }
412
413        // `return str(number)  # Fallback for very large numbers`. See bug 3.
414        number.to_string()
415    }
416}
417
418impl Lang for LangKk {
419    /// This language's own `to_currency(currency=...)` default,
420    /// read from the live Python signature. Only 44 of 156 use EUR.
421    fn default_currency(&self) -> &str {
422        "KZT"
423    }
424
425    /// This language's own `to_currency(separator=...)` default,
426    /// read from the live Python signature. Base's is ",", but only
427    /// 36 of 149 languages actually use it — most default to " " or a
428    /// conjunction, so inheriting Base's comma silently corrupts them.
429    fn default_separator(&self) -> &str {
430        " "
431    }
432
433    fn negword(&self) -> &str {
434        NEGWORD
435    }
436
437    fn pointword(&self) -> &str {
438        "point"
439    }
440
441    /// Python's `to_cardinal`.
442    ///
443    /// The original round-trips through the decimal string (`n =
444    /// str(number).strip()`) to split off the sign and a possible fractional
445    /// part. For integral input the `"." in n` branch is unreachable, so this
446    /// reproduces only the `else` arm — sign strip, `int(n)`, `_int_to_word`,
447    /// then a final `.strip()`.
448    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
449        let n = value.to_string(); // already stripped: no stray whitespace
450        let (ret, digits) = match n.strip_prefix('-') {
451            Some(rest) => (NEGWORD, rest),
452            None => ("", n.as_str()),
453        };
454
455        // `int(n)` on the sign-stripped digits — the magnitude. Cannot fail:
456        // `BigInt::to_string` always yields a parseable decimal literal, and
457        // it is never the bare "-" that trips other languages up.
458        let parsed: BigInt = digits.parse().unwrap_or_else(|_| BigInt::zero());
459
460        // `(ret + self._int_to_word(int(n))).strip()`
461        Ok(format!("{}{}", ret, self.int_to_word(&parsed))
462            .trim()
463            .to_string())
464    }
465
466    /// Python: `return self.to_cardinal(number) + "-інші"`.
467    ///
468    /// A flat suffix with no agreement, no stem change, and no zero/negative
469    /// guard — hence `"zero-інші"` and `"minus бір-інші"`, and, above 10^9,
470    /// `"1000000000-інші"`.
471    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
472        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
473    }
474
475    /// Python: `return str(number) + "."` — the sign survives ("-1.").
476    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
477        Ok(format!("{}.", value))
478    }
479
480    /// Python: `def to_year(self, val, longval=True): return
481    /// self.to_cardinal(val)`. `longval` is accepted and ignored, and there is
482    /// no century/"hundred" pairing — 1905 is "бір мың тоғыз жүз бес", i.e.
483    /// plain 1905, not "nineteen oh five".
484    fn to_year(&self, value: &BigInt) -> Result<String> {
485        self.to_cardinal(value)
486    }
487
488    /// Port of the float/Decimal path of `Num2Word_KK.to_cardinal`.
489    ///
490    /// KK does **not** inherit `Num2Word_Base.to_cardinal_float`/`float2tuple`.
491    /// It overrides `to_cardinal(number)`, which operates on `str(number)`:
492    ///
493    /// ```python
494    /// n = str(number).strip()
495    /// if n.startswith("-"):
496    ///     n = n[1:]; ret = self.negword
497    /// else:
498    ///     ret = ""
499    /// if "." in n:
500    ///     left, right = n.split(".", 1)
501    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
502    ///     for digit in right:
503    ///         ret += self._int_to_word(int(digit)) + " "
504    ///     return ret.strip()
505    /// ```
506    ///
507    /// The fractional part is read **digit by digit off the decimal string**,
508    /// so the `float2tuple` f64-artefact heuristic never applies here: `2.675`
509    /// becomes `str` `"2.675"` → `"алты жеті бес"` directly, with no
510    /// `674.999…` to rescue. Reproduce that by rebuilding the repr string, not
511    /// by arithmetic on the raw double (which *would* reintroduce the artefact).
512    ///
513    /// * **Float** — Rust's `{}` drops the `.0` on whole floats (`1.0` → "1"),
514    ///   so the repr is reconstructed with `{:.precision$}` on the absolute
515    ///   value. `precision` is the repr-derived fractional length (== Python's
516    ///   `abs(Decimal(str(v)).as_tuple().exponent)`), and formatting the double
517    ///   back to that many places recovers exactly the shortest-round-trip
518    ///   digits (verified: `2.675` → "2.675", `1.005` → "1.005", `1.0` →
519    ///   "1.0").
520    /// * **Decimal** — reconstructed arithmetically (exact): `int(left)` is the
521    ///   truncated integer part, and the fractional digits are
522    ///   `(abs - left) * 10**precision` left-padded to `precision`. This keeps
523    ///   trailing zeros the way `str(Decimal("1.10"))` does ("1.10" → "10") and
524    ///   stays exact at trillion scale (`98746251323029.99`, issue #603), where
525    ///   `int(left)` overflows KK's `_int_to_word` word tables and leaks through
526    ///   as bare digits (module bug 3).
527    ///
528    /// The integer part `int(left)` runs back through KK's `int_to_word`, so
529    /// module bugs 1/3/4 all reach here: `0.x` → "zero point …" (English
530    /// "zero", bug 1), `100.5` → "бір жүз point …" (bug 4), and integer parts
531    /// ≥ 10^9 render as raw digits (bug 3).
532    ///
533    /// `precision_override` (the `precision=` kwarg, issue #580) is **inert**
534    /// for KK: `to_cardinal` takes no such parameter, so the kwarg is dropped
535    /// before it can matter. Verified live:
536    /// `num2words(0.5, lang="kk", precision=3)` == "zero point бес".
537    fn to_cardinal_float(
538        &self,
539        value: &FloatValue,
540        _precision_override: Option<u32>,
541    ) -> Result<String> {
542        // Rebuild `n` (already sign-stripped) as "<left>.<right>" with exactly
543        // `precision` fractional digits — or no "." when precision is 0, which
544        // mirrors Python's `if "." in n` guard falling through to the integer
545        // branch. `neg` mirrors `str(number).startswith("-")`.
546        let (neg, left, right_str) = match value {
547            FloatValue::Float { value, precision } => {
548                // Sign *bit*, not `< 0.0`: repr(-0.0) is "-0.0", so Python's
549                // startswith("-") is true and KK answers "minus zero point
550                // zero". A `< 0.0` test would drop the minus.
551                let neg = value.is_sign_negative();
552                let prec = *precision as usize;
553                let s = format!("{:.p$}", value.abs(), p = prec);
554                match s.split_once('.') {
555                    Some((l, r)) => (
556                        neg,
557                        l.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()),
558                        r.to_string(),
559                    ),
560                    None => (
561                        neg,
562                        s.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()),
563                        String::new(),
564                    ),
565                }
566            }
567            FloatValue::Decimal { value, precision } => {
568                let neg = value.is_negative();
569                let abs = value.abs();
570                // int(left) — truncate the absolute value toward zero.
571                let left = abs.with_scale(0).as_bigint_and_exponent().0;
572                if *precision == 0 {
573                    (neg, left, String::new())
574                } else {
575                    let frac = &abs - BigDecimal::from(left.clone());
576                    let scale = BigInt::from(10).pow(*precision);
577                    let right = (frac * BigDecimal::from(scale))
578                        .with_scale(0)
579                        .as_bigint_and_exponent()
580                        .0;
581                    // Left-pad to `precision` digits (right is non-negative and
582                    // < 10**precision, so this never truncates). Manual pad
583                    // rather than a numeric fill so BigInt's Display is used
584                    // verbatim.
585                    let rs = right.to_string();
586                    let pad = (*precision as usize).saturating_sub(rs.len());
587                    (neg, left, format!("{}{}", "0".repeat(pad), rs))
588                }
589            }
590        };
591
592        // ret += _int_to_word(int(left)) [+ " point " + per-digit words];
593        // strip. `pointword` ("point") is used raw — KK's `to_cardinal` applies
594        // no title() here (and is_title is false anyway).
595        let mut tokens: Vec<String> = vec![self.int_to_word(&left)];
596        if !right_str.is_empty() {
597            tokens.push(self.pointword().to_string());
598            for ch in right_str.chars() {
599                // Each char is one decimal digit; int(digit) ∈ 0..=9.
600                let d = ch.to_digit(10).unwrap_or(0);
601                tokens.push(self.int_to_word(&BigInt::from(d)));
602            }
603        }
604        let joined = tokens.join(" ");
605        // negword ("minus ") is prepended raw, then the whole thing stripped —
606        // exactly Python's `ret = self.negword + ...; return ret.strip()`.
607        let result = if neg {
608            format!("{}{}", NEGWORD, joined)
609        } else {
610            joined
611        };
612        Ok(result.trim().to_string())
613    }
614
615    /// `to_cardinal(float/Decimal)` — the full entry, routing on
616    /// `"." in str(number)` rather than on whole-ness (Base's
617    /// `int(value) == value` test never runs; KK overrides `to_cardinal`
618    /// outright).
619    ///
620    /// A whole float therefore keeps its ".0" tail (`5.0` -> "бес point
621    /// zero", `-0.0` -> "minus zero point zero"), a point-less integral
622    /// Decimal takes the integer grammar (`Decimal("100")` -> "бір жүз"),
623    /// and a point-less non-integer form is Python's `int()` ValueError
624    /// (`1e+16`, `Decimal("1E+2")`).
625    ///
626    /// `precision_override` is threaded through untouched; KK's grammar never
627    /// reads a precision, so it is inert either way (see `to_cardinal_float`).
628    fn cardinal_float_entry(
629        &self,
630        value: &FloatValue,
631        precision_override: Option<u32>,
632    ) -> Result<String> {
633        match route_by_str(value)? {
634            StrRoute::Point => self.to_cardinal_float(value, precision_override),
635            StrRoute::WholeDigits(abs_int) => {
636                // `(ret + self._int_to_word(int(n))).strip()`, with the sign
637                // already peeled off the string into `ret = self.negword`.
638                let body = self.int_to_word(&abs_int);
639                if value.is_negative() {
640                    Ok(format!("{}{}", NEGWORD, body).trim().to_string())
641                } else {
642                    Ok(body)
643                }
644            }
645        }
646    }
647
648    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-інші"` —
649    /// the same blind suffix as the integer path, so `5.0` == "бес point
650    /// zero-інші" and the ValueError of `1e+16` propagates unchanged.
651    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
652        Ok(format!(
653            "{}{}",
654            self.cardinal_float_entry(value, None)?,
655            ORDINAL_SUFFIX
656        ))
657    }
658
659    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. Purely textual —
660    /// nothing is parsed, so even the scientific forms succeed: `1e+16` ==
661    /// "1e+16." and `Decimal("1E+2")` == "1E+2.".
662    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
663        Ok(format!("{}.", repr_str))
664    }
665
666    // `year_float_entry` is deliberately NOT overridden: KK's `to_year` is
667    // `return self.to_cardinal(val)`, and the trait default routes through
668    // the overridden `cardinal_float_entry` above — so `to_year(5.0)` ==
669    // "бес point zero" and `to_year(1e+16)` raises ValueError, as the
670    // corpus pins.
671
672    /// `converter.str_to_number` — Base's `Decimal(value)`, which KK does not
673    /// override. Inf/NaN parse fine here; the per-mode behaviour is served
674    /// natively by [`Lang::inf_result`] / [`Lang::nan_result`] below.
675    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
676        python_decimal_parse(s)
677    }
678
679    /// `Decimal('Infinity')` / `-Infinity` reached KK. `to_cardinal` strips the
680    /// sign textually then does `int("Infinity")` → `ValueError`; `to_ordinal`
681    /// (`to_cardinal(...) + "-інші"`) and `to_year` (`to_cardinal`) raise the
682    /// same `ValueError` before their suffix/wrap. `to_ordinal_num` is a purely
683    /// textual `str(number) + "."`, so it echoes the token (sign preserved).
684    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
685        match to {
686            "ordinal_num" => {
687                let token = if negative { "-Infinity" } else { "Infinity" };
688                Ok(format!("{}.", token))
689            }
690            // cardinal/ordinal/year: int() sees the sign-stripped token.
691            _ => Err(int_value_error("Infinity")),
692        }
693    }
694
695    /// `Decimal('NaN')` reached KK. `int("NaN")` → `ValueError` on the
696    /// cardinal/ordinal/year paths; `to_ordinal_num` echoes "NaN.".
697    fn nan_result(&self, to: &str) -> Result<String> {
698        match to {
699            "ordinal_num" => Ok("NaN.".to_string()),
700            _ => Err(int_value_error("NaN")),
701        }
702    }
703
704    // ---- currency ------------------------------------------------------
705    //
706    // `Num2Word_KK` overrides `to_currency` outright and inherits `to_cheque`
707    // from `Num2Word_Base`. Those two entry points reach the class's state by
708    // completely separate routes, which is why only `currency_forms` and
709    // `lang_name` are overridden alongside `to_currency`:
710    //
711    // * `to_currency` never raises — it *falls back* to the first
712    //   `CURRENCY_FORMS` entry for an unknown code (bug 5).
713    // * `to_cheque` does `self.CURRENCY_FORMS[currency]` and turns the
714    //   `KeyError` into `NotImplementedError`.
715    //
716    // So the fallback must live in `to_currency` and must NOT leak into
717    // `currency_forms`, or `cheque:GBP` would return "... ТЕҢГЕ" instead of
718    // raising. The corpus pins both halves:
719    //   currency:GBP 12.34 -> "он екі теңге отыз төрт тиын"  (fallback)
720    //   cheque:GBP  1234.56 -> NotImplementedError            (no fallback)
721    //
722    // Not overridden, because `Num2Word_KK` does not override them either:
723    //
724    // * `currency_precision` — `Num2Word_KK` defines no `CURRENCY_PRECISION`
725    //   and inherits `Num2Word_Base`'s empty `{}`, so `.get(code, 100)` is 100
726    //   for every code. The trait default is already 100. KK inherits from
727    //   `Num2Word_Base`, not from `Num2Word_EN`, so it never sees EN's
728    //   KWD/BHD-style overrides: `cheque:KWD` would be /100, not /1000 — and
729    //   in fact never gets that far, since KWD is absent from the table and
730    //   raises first. Confirmed live: `CONVERTER_CLASSES["kk"].
731    //   CURRENCY_PRECISION == {}`.
732    // * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}` (inherited,
733    //   empty), and `to_currency` ignores `adjective` anyway (bug 8).
734    // * `pluralize` — abstract in `Num2Word_Base` and left abstract by
735    //   `Num2Word_KK`. Unreachable: `to_currency` selects the plural inline
736    //   with `left != 1`, and `to_cheque` takes `cr1[-1]` unconditionally. The
737    //   trait default (raise NotImplemented) is the faithful mirror.
738    // * `money_verbose` / `cents_verbose` / `cents_terse` — `Num2Word_Base`'s
739    //   `_money_verbose` is `return self.to_cardinal(number)`, which is the
740    //   trait default. `to_cheque` calls it; the other two are unreachable
741    //   because KK's `to_currency` never delegates to the base.
742    // * `cardinal_from_decimal` — the fractional-cents branch lives in
743    //   `Num2Word_Base.to_currency`, which KK never enters. Left at its
744    //   default.
745
746    /// `self.__class__.__name__`, read by `Num2Word_Base.to_cheque`'s
747    /// `NotImplementedError` message.
748    fn lang_name(&self) -> &str {
749        LANG_NAME
750    }
751
752    /// `self.CURRENCY_FORMS[code]` — a *strict* lookup, as `to_cheque` does it.
753    ///
754    /// Returns `None` for an unknown code so the inherited `to_cheque` raises
755    /// `NotImplementedError`. `to_currency`'s forgiving
756    /// `.get(code, <first entry>)` is a different lookup and is spelled out
757    /// there; the two must not be merged.
758    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
759        self.currency_forms.get(code)
760    }
761
762    /// Port of `Num2Word_KK.to_currency`.
763    ///
764    /// ```python
765    /// def to_currency(
766    ///     self, val, currency="KZT", cents=True, separator=" ", adjective=False
767    /// ):
768    ///     """Convert to currency in Kazakh."""
769    ///     is_negative = False
770    ///     if val < 0:
771    ///         is_negative = True
772    ///         val = abs(val)
773    ///
774    ///     parts = str(val).split(".")
775    ///     left = int(parts[0]) if parts[0] else 0
776    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
777    ///
778    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
779    ///         currency, list(self.CURRENCY_FORMS.values())[0]
780    ///     )
781    ///
782    ///     left_str = self._int_to_word(left)
783    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
784    ///
785    ///     if cents and right:
786    ///         cents_str = self._int_to_word(right)
787    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
788    ///
789    ///     if is_negative:
790    ///         result = self.negword + result
791    ///
792    ///     return result.strip()
793    /// ```
794    ///
795    /// A complete override: `Num2Word_Base.to_currency` is never called, so
796    /// none of `parse_currency_parts`, `pluralize`, `_cents_verbose`,
797    /// `_cents_terse`, `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES` is
798    /// reachable from here. Infallible — there is no `NotImplementedError`
799    /// path (bug 5) and no table index that can escape its range.
800    ///
801    /// # Faithfully reproduced Python bugs (currency)
802    ///
803    /// 5. **An unknown currency code silently becomes KZT.** Python's
804    ///    `.get(currency, list(self.CURRENCY_FORMS.values())[0])` falls back to
805    ///    the first-inserted entry instead of raising, so `currency:GBP`,
806    ///    `:JPY`, `:KWD`, `:BHD`, `:INR`, `:CNY` and `:CHF` all quietly render
807    ///    in теңге/тиын. All 84 such rows are in the corpus. The amounts are
808    ///    *not* converted — 12.34 GBP prints as 12 теңге 34 тиын.
809    /// 6. **Cents are always /100, whatever the currency.** `parts[1][:2]`
810    ///    hard-codes two fractional digits, so a 3-decimal currency would lose
811    ///    its third digit and a 0-decimal one would gain a subunit. Moot in
812    ///    practice given bug 5 — JPY and KWD never reach their own forms.
813    /// 7. **`int` and `float` are indistinguishable.** `Num2Word_Base`
814    ///    branches on `isinstance(val, int)` to skip cents; KK instead tests
815    ///    `if cents and right:`, a truthiness test on the *cent count*. So
816    ///    `1.0` and `1` both give "бір euro" — the float does not render a
817    ///    zero-cents segment the way the base class would. The corpus pins
818    ///    `1.0` -> "бір euro".
819    /// 8. **`adjective` is accepted and never read.** The parameter is in the
820    ///    signature and appears nowhere in the body, so `adjective=True` is a
821    ///    no-op. (`CURRENCY_ADJECTIVES` is empty anyway, so even the base class
822    ///    would have been a no-op here.)
823    /// 9. **The English "zero" leaks into every currency string.** `left == 0`
824    ///    routes through `_int_to_word`'s module bug 1, hence
825    ///    "zero euros бір cent" for 0.01.
826    fn to_currency(
827        &self,
828        val: &CurrencyValue,
829        currency: &str,
830        cents: bool,
831        separator: Option<&str>,
832        _adjective: bool, // declared by Python, never read — bug 8.
833    ) -> Result<String> {
834        // Trait now hands us None when the caller omitted separator=;
835        // resolve it to this language's own default before the ported body.
836        let separator = separator.unwrap_or(self.default_separator());
837        let separator = if separator == SEPARATOR_UNSET {
838            SEPARATOR_DEFAULT
839        } else {
840            separator
841        };
842
843        // Python: `if val < 0: is_negative = True; val = abs(val)`. The abs is
844        // folded into split_currency, which every later step reads through.
845        let is_negative = val.is_negative();
846        let (left, right) = Self::split_currency(val);
847
848        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — bug 5. Python
849        // evaluates the default eagerly on every call, but it cannot fail:
850        // KZT is always present.
851        let forms = match self.currency_forms.get(currency) {
852            Some(f) => f,
853            None => &self.currency_forms[FALLBACK_CURRENCY],
854        };
855
856        let one = BigInt::one();
857        let left_str = self.int_to_word(&left);
858        let mut result = format!(
859            "{} {}",
860            left_str,
861            if left != one { &forms.unit[1] } else { &forms.unit[0] }
862        );
863
864        // `if cents and right:` — a truthiness test on the cent count, not on
865        // the type of `val`. See bug 7.
866        if cents && !right.is_zero() {
867            let cents_str = self.int_to_word(&right);
868            result.push_str(separator);
869            result.push_str(&cents_str);
870            result.push(' ');
871            result.push_str(if right != one {
872                &forms.subunit[1]
873            } else {
874                &forms.subunit[0]
875            });
876        }
877
878        if is_negative {
879            // negword carries a trailing space ("minus "), so this is the seam
880            // the final strip() does *not* touch.
881            result = format!("{}{}", NEGWORD, result);
882        }
883
884        Ok(result.trim().to_string())
885    }
886}