Skip to main content

triblespace_core/value/schemas/
r256.rs

1use crate::id::ExclusiveId;
2use crate::id::Id;
3use crate::id_hex;
4use crate::macros::entity;
5use crate::metadata;
6use crate::metadata::{ConstDescribe, ConstId};
7use crate::repo::BlobStore;
8use crate::trible::Fragment;
9use crate::value::schemas::hash::Blake3;
10use crate::value::ToValue;
11use crate::value::TryFromValue;
12use crate::value::Value;
13use crate::value::ValueSchema;
14use std::convert::Infallible;
15
16use std::convert::TryInto;
17
18use num_rational::Ratio;
19
20/// A 256-bit ratio value.
21/// It is stored as two 128-bit signed integers, the numerator and the denominator.
22/// The ratio is always reduced to its canonical form, which mean that the numerator and the denominator
23/// are coprime and the denominator is positive.
24/// Both the numerator and the denominator are stored in little-endian byte order,
25/// with the numerator in the first 16 bytes and the denominator in the last 16 bytes.
26///
27/// For a big-endian version, see [R256BE].
28pub struct R256LE;
29
30impl ConstId for R256LE {
31    const ID: Id = id_hex!("0A9B43C5C2ECD45B257CDEFC16544358");
32}
33
34/// A 256-bit ratio value.
35/// It is stored as two 128-bit signed integers, the numerator and the denominator.
36/// The ratio is always reduced to its canonical form, which mean that the numerator and the denominator
37/// are coprime and the denominator is positive.
38/// Both the numerator and the denominator are stored in big-endian byte order,
39/// with the numerator in the first 16 bytes and the denominator in the last 16 bytes.
40///
41/// For a little-endian version, see [R256LE].
42pub struct R256BE;
43
44impl ConstId for R256BE {
45    const ID: Id = id_hex!("CA5EAF567171772C1FFD776E9C7C02D1");
46}
47
48/// A type alias for the default (little-endian) variant of the 256-bit ratio schema.
49pub type R256 = R256LE;
50
51impl ConstDescribe for R256LE {
52    fn describe<B>(blobs: &mut B) -> Result<Fragment, B::PutError>
53    where
54        B: BlobStore<Blake3>,
55    {
56        let id = Self::ID;
57        let description = blobs.put(
58            "Exact ratio stored as two i128 values (numerator/denominator) in little-endian, normalized with a positive denominator. This keeps fractions canonical and comparable.\n\nUse for exact rates, proportions, or unit conversions where rounding is unacceptable. Prefer F64 or F256 when approximate floats are fine or when interfacing with floating-point APIs.\n\nDenominator zero is invalid; the schema expects canonicalized fractions. If you need intervals or ranges instead of ratios, use the range schemas.",
59        )?;
60        let tribles = entity! {
61            ExclusiveId::force_ref(&id) @
62                metadata::name: blobs.put("r256le")?,
63                metadata::description: description,
64                metadata::tag: metadata::KIND_VALUE_SCHEMA,
65        };
66
67        #[cfg(feature = "wasm")]
68        let tribles = {
69            let mut tribles = tribles;
70            tribles += entity! { ExclusiveId::force_ref(&id) @
71                metadata::value_formatter: blobs.put(wasm_formatter::R256_LE_WASM)?,
72            };
73            tribles
74        };
75        Ok(tribles)
76    }
77}
78impl ValueSchema for R256LE {
79    type ValidationError = Infallible;
80}
81impl ConstDescribe for R256BE {
82    fn describe<B>(blobs: &mut B) -> Result<Fragment, B::PutError>
83    where
84        B: BlobStore<Blake3>,
85    {
86        let id = Self::ID;
87        let description = blobs.put(
88            "Exact ratio stored as two i128 values (numerator/denominator) in big-endian, normalized with a positive denominator. This is useful when bytewise ordering or protocol encoding matters.\n\nUse for exact fractions in ordered or interoperable formats. Prefer F64 or F256 when approximate floats are acceptable.\n\nAs with the little-endian variant, values are expected to be canonical and denominator must be non-zero.",
89        )?;
90        let tribles = entity! {
91            ExclusiveId::force_ref(&id) @
92                metadata::name: blobs.put("r256be")?,
93                metadata::description: description,
94                metadata::tag: metadata::KIND_VALUE_SCHEMA,
95        };
96
97        #[cfg(feature = "wasm")]
98        let tribles = {
99            let mut tribles = tribles;
100            tribles += entity! { ExclusiveId::force_ref(&id) @
101                metadata::value_formatter: blobs.put(wasm_formatter::R256_BE_WASM)?,
102            };
103            tribles
104        };
105        Ok(tribles)
106    }
107}
108
109#[cfg(feature = "wasm")]
110mod wasm_formatter {
111    use core::fmt::Write;
112
113    use triblespace_core_macros::value_formatter;
114
115    #[value_formatter]
116    pub(crate) fn r256_le(raw: &[u8; 32], out: &mut impl Write) -> Result<(), u32> {
117        let mut buf = [0u8; 16];
118        buf.copy_from_slice(&raw[..16]);
119        let numer = i128::from_le_bytes(buf);
120        buf.copy_from_slice(&raw[16..]);
121        let denom = i128::from_le_bytes(buf);
122
123        if denom == 0 {
124            return Err(2);
125        }
126
127        if denom == 1 {
128            write!(out, "{numer}").map_err(|_| 1u32)?;
129        } else {
130            write!(out, "{numer}/{denom}").map_err(|_| 1u32)?;
131        }
132        Ok(())
133    }
134
135    #[value_formatter]
136    pub(crate) fn r256_be(raw: &[u8; 32], out: &mut impl Write) -> Result<(), u32> {
137        let mut buf = [0u8; 16];
138        buf.copy_from_slice(&raw[..16]);
139        let numer = i128::from_be_bytes(buf);
140        buf.copy_from_slice(&raw[16..]);
141        let denom = i128::from_be_bytes(buf);
142
143        if denom == 0 {
144            return Err(2);
145        }
146
147        if denom == 1 {
148            write!(out, "{numer}").map_err(|_| 1u32)?;
149        } else {
150            write!(out, "{numer}/{denom}").map_err(|_| 1u32)?;
151        }
152        Ok(())
153    }
154}
155impl ValueSchema for R256BE {
156    type ValidationError = Infallible;
157}
158
159/// An error that can occur when converting a ratio value.
160///
161/// The error can be caused by a non-canonical ratio, where the numerator and the denominator are not coprime,
162/// or by a zero denominator.
163#[derive(Debug)]
164pub enum RatioError {
165    /// The stored numerator/denominator pair is not in reduced (coprime) form.
166    NonCanonical(i128, i128),
167    /// The denominator is zero, which is invalid for a ratio.
168    ZeroDenominator,
169}
170
171impl TryFromValue<'_, R256BE> for Ratio<i128> {
172    type Error = RatioError;
173
174    fn try_from_value(v: &Value<R256BE>) -> Result<Self, Self::Error> {
175        let n = i128::from_be_bytes(v.raw[0..16].try_into().unwrap());
176        let d = i128::from_be_bytes(v.raw[16..32].try_into().unwrap());
177
178        if d == 0 {
179            return Err(RatioError::ZeroDenominator);
180        }
181
182        let ratio = Ratio::new_raw(n, d);
183        let ratio = ratio.reduced();
184        let (reduced_n, reduced_d) = ratio.into_raw();
185
186        if reduced_n != n || reduced_d != d {
187            Err(RatioError::NonCanonical(n, d))
188        } else {
189            Ok(ratio)
190        }
191    }
192}
193
194
195impl ToValue<R256BE> for Ratio<i128> {
196    fn to_value(self) -> Value<R256BE> {
197        let ratio = self.reduced();
198
199        let mut bytes = [0; 32];
200        bytes[0..16].copy_from_slice(&ratio.numer().to_be_bytes());
201        bytes[16..32].copy_from_slice(&ratio.denom().to_be_bytes());
202
203        Value::new(bytes)
204    }
205}
206
207impl ToValue<R256BE> for i128 {
208    fn to_value(self) -> Value<R256BE> {
209        let mut bytes = [0; 32];
210        bytes[0..16].copy_from_slice(&self.to_be_bytes());
211        bytes[16..32].copy_from_slice(&1i128.to_be_bytes());
212
213        Value::new(bytes)
214    }
215}
216
217impl TryFromValue<'_, R256LE> for Ratio<i128> {
218    type Error = RatioError;
219
220    fn try_from_value(v: &Value<R256LE>) -> Result<Self, Self::Error> {
221        let n = i128::from_le_bytes(v.raw[0..16].try_into().unwrap());
222        let d = i128::from_le_bytes(v.raw[16..32].try_into().unwrap());
223
224        if d == 0 {
225            return Err(RatioError::ZeroDenominator);
226        }
227
228        let ratio = Ratio::new_raw(n, d);
229        let ratio = ratio.reduced();
230        let (reduced_n, reduced_d) = ratio.into_raw();
231
232        if reduced_n != n || reduced_d != d {
233            Err(RatioError::NonCanonical(n, d))
234        } else {
235            Ok(ratio)
236        }
237    }
238}
239
240
241impl ToValue<R256LE> for Ratio<i128> {
242    fn to_value(self) -> Value<R256LE> {
243        let mut bytes = [0; 32];
244        bytes[0..16].copy_from_slice(&self.numer().to_le_bytes());
245        bytes[16..32].copy_from_slice(&self.denom().to_le_bytes());
246
247        Value::new(bytes)
248    }
249}
250
251impl ToValue<R256LE> for i128 {
252    fn to_value(self) -> Value<R256LE> {
253        let mut bytes = [0; 32];
254        bytes[0..16].copy_from_slice(&self.to_le_bytes());
255        bytes[16..32].copy_from_slice(&1i128.to_le_bytes());
256
257        Value::new(bytes)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::value::{ToValue, TryFromValue};
265    use num_rational::Ratio;
266    use proptest::prelude::*;
267
268    fn arb_ratio() -> impl Strategy<Value = Ratio<i128>> {
269        (any::<i128>(), any::<i128>().prop_filter("non-zero", |d| *d != 0))
270            .prop_map(|(n, d)| Ratio::new(n, d))
271    }
272
273    proptest! {
274        // --- R256BE property tests ---
275
276        #[test]
277        fn r256be_ratio_roundtrip(input in arb_ratio()) {
278            let value: Value<R256BE> = input.to_value();
279            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
280            prop_assert_eq!(input, output);
281        }
282
283        #[test]
284        fn r256be_canonicalization(n: i128, d in any::<i128>().prop_filter("non-zero", |d| *d != 0)) {
285            let ratio = Ratio::new(n, d);
286            let value: Value<R256BE> = ratio.to_value();
287            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
288            // Output must be in reduced form
289            prop_assert_eq!(output, output.reduced());
290        }
291
292        #[test]
293        fn r256be_i128_roundtrip(input: i128) {
294            let value: Value<R256BE> = input.to_value();
295            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
296            prop_assert_eq!(*output.numer(), input);
297            prop_assert_eq!(*output.denom(), 1i128);
298        }
299
300        #[test]
301        fn r256be_validates(input in arb_ratio()) {
302            let value: Value<R256BE> = input.to_value();
303            prop_assert!(R256BE::validate(value).is_ok());
304        }
305
306        // --- R256LE property tests ---
307
308        #[test]
309        fn r256le_ratio_roundtrip(input in arb_ratio()) {
310            let value: Value<R256LE> = input.to_value();
311            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
312            prop_assert_eq!(input, output);
313        }
314
315        #[test]
316        fn r256le_canonicalization(n: i128, d in any::<i128>().prop_filter("non-zero", |d| *d != 0)) {
317            let ratio = Ratio::new(n, d);
318            let value: Value<R256LE> = ratio.to_value();
319            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
320            prop_assert_eq!(output, output.reduced());
321        }
322
323        #[test]
324        fn r256le_i128_roundtrip(input: i128) {
325            let value: Value<R256LE> = input.to_value();
326            let output = Ratio::<i128>::try_from_value(&value).expect("valid ratio");
327            prop_assert_eq!(*output.numer(), input);
328            prop_assert_eq!(*output.denom(), 1i128);
329        }
330
331        #[test]
332        fn r256le_validates(input in arb_ratio()) {
333            let value: Value<R256LE> = input.to_value();
334            prop_assert!(R256LE::validate(value).is_ok());
335        }
336
337        #[test]
338        fn r256_le_and_be_differ(input in arb_ratio().prop_filter("non-trivial", |r| *r.numer() != 0)) {
339            let le_val: Value<R256LE> = input.to_value();
340            let be_val: Value<R256BE> = input.to_value();
341            prop_assert_ne!(le_val.raw, be_val.raw);
342        }
343    }
344
345    // --- Error-case unit tests ---
346
347    #[test]
348    fn r256be_non_canonical_error() {
349        let mut bytes = [0u8; 32];
350        bytes[0..16].copy_from_slice(&2i128.to_be_bytes());
351        bytes[16..32].copy_from_slice(&4i128.to_be_bytes());
352        let value = Value::<R256BE>::new(bytes);
353        assert!(Ratio::<i128>::try_from_value(&value).is_err());
354    }
355
356    #[test]
357    fn r256be_zero_denominator_error() {
358        let mut bytes = [0u8; 32];
359        bytes[0..16].copy_from_slice(&1i128.to_be_bytes());
360        bytes[16..32].copy_from_slice(&0i128.to_be_bytes());
361        let value = Value::<R256BE>::new(bytes);
362        assert!(matches!(
363            Ratio::<i128>::try_from_value(&value),
364            Err(RatioError::ZeroDenominator)
365        ));
366    }
367
368    #[test]
369    fn r256le_non_canonical_error() {
370        let mut bytes = [0u8; 32];
371        bytes[0..16].copy_from_slice(&6i128.to_le_bytes());
372        bytes[16..32].copy_from_slice(&4i128.to_le_bytes());
373        let value = Value::<R256LE>::new(bytes);
374        assert!(Ratio::<i128>::try_from_value(&value).is_err());
375    }
376
377    #[test]
378    fn r256le_zero_denominator_error() {
379        let mut bytes = [0u8; 32];
380        bytes[0..16].copy_from_slice(&1i128.to_le_bytes());
381        bytes[16..32].copy_from_slice(&0i128.to_le_bytes());
382        let value = Value::<R256LE>::new(bytes);
383        assert!(matches!(
384            Ratio::<i128>::try_from_value(&value),
385            Err(RatioError::ZeroDenominator)
386        ));
387    }
388}