Skip to main content

triblespace_core/inline/encodings/
r256.rs

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