Skip to main content

cortexkit_model_batch/
number.rs

1//! Number text (`RULES.md` ยง12): a provider's number reaches the reply with
2//! the provider's own spelling.
3//!
4//! A runner that decodes a number into binary floating point and encodes
5//! it again can change its spelling (`0.10` becomes `0.1`, `1E+30` becomes
6//! `1e30`) and, past 17 significant digits, its value. Callers threshold on these numbers, so a role keeps
7//! the text: each known numeric member is a [`NumberText`], serde_json's
8//! [`RawValue`], which holds the exact bytes the provider sent.
9//!
10//! The serde_json features `arbitrary_precision` and `float_roundtrip`
11//! would also keep spellings, but they change number parsing for every
12//! crate in a workspace build. No role crate enables them.
13
14use serde_json::{value::RawValue, Number};
15
16/// A JSON value kept as the exact text it arrived as.
17pub type NumberText = Box<RawValue>;
18
19/// Whether `text` is one JSON number literal: an optional `-`, an integer
20/// part without leading zeros, an optional fraction and an optional
21/// exponent, with no surrounding whitespace.
22pub fn is_number_literal(text: &str) -> bool {
23    let bytes = text.as_bytes();
24    let mut at = 0;
25    let digits = |at: &mut usize| {
26        let start = *at;
27        while *at < bytes.len() && bytes[*at].is_ascii_digit() {
28            *at += 1;
29        }
30        *at - start
31    };
32    if bytes.get(at) == Some(&b'-') {
33        at += 1;
34    }
35    match bytes.get(at) {
36        Some(b'0') => at += 1,
37        Some(b'1'..=b'9') => {
38            digits(&mut at);
39        }
40        _ => return false,
41    }
42    if bytes.get(at) == Some(&b'.') {
43        at += 1;
44        if digits(&mut at) == 0 {
45            return false;
46        }
47    }
48    if matches!(bytes.get(at), Some(b'e' | b'E')) {
49        at += 1;
50        if matches!(bytes.get(at), Some(b'+' | b'-')) {
51            at += 1;
52        }
53        if digits(&mut at) == 0 {
54            return false;
55        }
56    }
57    at == bytes.len()
58}
59
60/// Whether `raw` holds one JSON number.
61pub fn is_number_text(raw: &RawValue) -> bool {
62    is_number_literal(raw.get())
63}
64
65/// `text` as number text, or why it is not one JSON number literal.
66pub fn number_text(text: &str) -> Result<NumberText, String> {
67    if !is_number_literal(text) {
68        return Err(format!("{text:?} is not a JSON number"));
69    }
70    RawValue::from_string(text.to_owned()).map_err(|e| e.to_string())
71}
72
73/// `number` as number text, in serde_json's spelling of it. For building a
74/// value a runner computed itself; a provider's number is kept as it came.
75pub fn number_text_of(number: &Number) -> NumberText {
76    RawValue::from_string(number.to_string()).expect("a Number's text is JSON")
77}
78
79/// Whether two optional raw values hold the same text.
80pub fn same_text(a: Option<&RawValue>, b: Option<&RawValue>) -> bool {
81    a.map(RawValue::get) == b.map(RawValue::get)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn number_literals() {
90        for good in ["0", "-0", "0.10", "1e-7", "1E+30", "12.500", "-3"] {
91            assert!(is_number_literal(good), "{good}");
92        }
93        for bad in [
94            "", "-", "01", "1.", ".5", "1e", "+1", " 1", "1 ", "NaN", "\"1\"", "null",
95        ] {
96            assert!(!is_number_literal(bad), "{bad}");
97        }
98    }
99
100    #[test]
101    fn number_text_keeps_the_spelling_through_a_decode() {
102        let decoded: Vec<NumberText> = serde_json::from_str("[0.10,1e-7,1E+30,12.500]").unwrap();
103        let texts: Vec<&str> = decoded.iter().map(|raw| raw.get()).collect();
104        assert_eq!(texts, ["0.10", "1e-7", "1E+30", "12.500"]);
105        assert_eq!(
106            serde_json::to_string(&decoded).unwrap(),
107            "[0.10,1e-7,1E+30,12.500]"
108        );
109        assert!(number_text("0.10").is_ok());
110        assert!(number_text("\"x\"").is_err());
111    }
112}