Skip to main content

cpop_protocol/rfc/
fixed_point.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Fixed-point integer types for RFC-compliant CBOR encoding.
4//!
5//! Implements fixed-point integer representations per
6//! draft-condrey-rats-pop-schema-01 Section 3 (Numeric Representation).
7//!
8//! Fixed-point replaces IEEE 754 for security-critical values because:
9//! 1. Cross-platform reproducibility — integer arithmetic is fully specified
10//!    by CBOR (RFC 8949) with no implementation latitude.
11//! 2. Constant-time comparison — eliminates timing side-channels.
12//! 3. Deterministic encoding — single canonical CBOR form ensures
13//!    identical hash inputs across implementations.
14//!
15//! | Type       | Scale Factor | Range       | Example              |
16//! |------------|--------------|-------------|----------------------|
17//! | Millibits  | x1000        | 0–1000      | 0.95 → 950           |
18//! | Centibits  | x10000       | 0–10000     | 0.0005 → 5           |
19//! | Decibits   | x10          | 0–640       | 3.2 bits → 32        |
20//! | DeciWpm    | x10          | 0–5000      | 45.5 WPM → 455       |
21
22use serde::{Deserialize, Serialize};
23use std::ops::{Add, Sub};
24
25macro_rules! fixed_point {
26    (
27        $(#[$meta:meta])*
28        $name:ident($inner:ty), scale=$scale:expr, min=$min:expr, max=$max:expr
29    ) => {
30        $(#[$meta])*
31        #[derive(
32            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default,
33            Serialize, Deserialize,
34        )]
35        #[serde(transparent)]
36        pub struct $name(pub $inner);
37
38        impl $name {
39            pub const MAX: $name = $name($max as $inner);
40            pub const MIN: $name = $name($min as $inner);
41
42            #[inline]
43            pub const fn new(value: $inner) -> Self {
44                $name(value)
45            }
46
47            pub fn from_float(value: f64) -> Self {
48                let scaled = (value * ($scale as f64)).round() as i32;
49                let clamped = scaled.clamp($min, $max) as $inner;
50                $name(clamped)
51            }
52
53            #[inline]
54            pub const fn raw(&self) -> $inner {
55                self.0
56            }
57
58            #[inline]
59            pub fn to_float(&self) -> f64 {
60                self.0 as f64 / $scale as f64
61            }
62        }
63
64        impl From<f64> for $name {
65            fn from(value: f64) -> Self {
66                $name::from_float(value)
67            }
68        }
69
70        impl From<$name> for f64 {
71            fn from(value: $name) -> Self {
72                value.to_float()
73            }
74        }
75    };
76}
77
78fixed_point! {
79    /// Ratio scaled x1000: [0, 1000] maps to [0.0, 1.0].
80    /// Used for: confidence, coverage, activity ratios.
81    Millibits(u16), scale=1000, min=0, max=1000
82}
83
84fixed_point! {
85    /// Signed ratio scaled x1000: [-1000, 1000] maps to [-1.0, 1.0].
86    /// Used for: Spearman rho correlation coefficients.
87    RhoMillibits(i16), scale=1000, min=-1000, max=1000
88}
89
90fixed_point! {
91    /// Fine ratio scaled x10000: [0, 10000] maps to [0.0, 1.0].
92    /// Used for: differential privacy epsilon, p-values.
93    Centibits(u16), scale=10000, min=0, max=10000
94}
95
96fixed_point! {
97    /// Entropy scaled x10: [0, 640] maps to [0.0, 64.0] bits.
98    /// Used for: Shannon entropy measurements.
99    Decibits(u16), scale=10, min=0, max=640
100}
101
102fixed_point! {
103    /// Slope scaled x10: [-100, 100] maps to [-10.0, +10.0].
104    /// Used for: pink noise slope (typically around -1.0).
105    SlopeDecibits(i8), scale=10, min=-100, max=100
106}
107
108fixed_point! {
109    /// WPM scaled x10: [0, 5000] maps to [0.0, 500.0].
110    /// Used for: effective typing rate measurements.
111    DeciWpm(u16), scale=10, min=0, max=5000
112}
113
114/// Economic cost in microdollars (USD x 1,000,000).
115/// Used for: forgery cost bounds, economic attack analysis.
116#[derive(
117    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
118)]
119#[serde(transparent)]
120pub struct Microdollars(pub u64);
121
122impl Microdollars {
123    /// Create from a raw microdollar value.
124    #[inline]
125    pub const fn new(value: u64) -> Self {
126        Microdollars(value)
127    }
128
129    /// Convert from dollars to microdollars (1 USD = 1,000,000).
130    pub fn from_dollars(value: f64) -> Self {
131        let scaled = (value * 1_000_000.0).round() as i64;
132        Microdollars(scaled.max(0) as u64)
133    }
134
135    /// Return the raw microdollar value.
136    #[inline]
137    pub const fn raw(&self) -> u64 {
138        self.0
139    }
140
141    /// Convert to dollars as `f64`.
142    #[inline]
143    pub fn to_dollars(&self) -> f64 {
144        self.0 as f64 / 1_000_000.0
145    }
146}
147
148impl Add for Millibits {
149    type Output = Self;
150    fn add(self, other: Self) -> Self {
151        Millibits((self.0 + other.0).min(1000))
152    }
153}
154
155impl Sub for Millibits {
156    type Output = Self;
157    fn sub(self, other: Self) -> Self {
158        Millibits(self.0.saturating_sub(other.0))
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_millibits_roundtrip() {
168        let values = [0.0, 0.5, 0.95, 1.0, 0.001, 0.999];
169        for v in values {
170            let mb = Millibits::from_float(v);
171            let back: f64 = mb.into();
172            assert!(
173                (back - v).abs() < 0.001,
174                "value {} roundtripped to {}",
175                v,
176                back
177            );
178        }
179    }
180
181    #[test]
182    fn test_millibits_clamping() {
183        assert_eq!(Millibits::from_float(-0.5).raw(), 0);
184        assert_eq!(Millibits::from_float(1.5).raw(), 1000);
185    }
186
187    #[test]
188    fn test_rho_millibits_signed() {
189        let rho = RhoMillibits::from_float(-0.75);
190        assert_eq!(rho.raw(), -750);
191        assert!((rho.to_float() - (-0.75)).abs() < 0.001);
192    }
193
194    #[test]
195    fn test_centibits_precision() {
196        let epsilon = Centibits::from_float(0.0005);
197        assert_eq!(epsilon.raw(), 5);
198        assert!((epsilon.to_float() - 0.0005).abs() < 0.0001);
199    }
200
201    #[test]
202    fn test_decibits_entropy() {
203        let entropy = Decibits::from_float(3.2);
204        assert_eq!(entropy.raw(), 32);
205        assert!((entropy.to_float() - 3.2).abs() < 0.1);
206    }
207
208    #[test]
209    fn test_slope_decibits_negative() {
210        let slope = SlopeDecibits::from_float(-1.2);
211        assert_eq!(slope.raw(), -12);
212        assert!((slope.to_float() - (-1.2)).abs() < 0.1);
213    }
214
215    #[test]
216    fn test_deci_wpm() {
217        let wpm = DeciWpm::from_float(45.5);
218        assert_eq!(wpm.raw(), 455);
219        assert!((wpm.to_float() - 45.5).abs() < 0.1);
220    }
221
222    #[test]
223    fn test_microdollars() {
224        let cost = Microdollars::from_dollars(0.05);
225        assert_eq!(cost.raw(), 50000);
226        assert!((cost.to_dollars() - 0.05).abs() < 0.000001);
227    }
228
229    #[test]
230    fn test_millibits_serde() {
231        let mb = Millibits::from_float(0.75);
232        let json = serde_json::to_string(&mb).unwrap();
233        assert_eq!(json, "750");
234        let decoded: Millibits = serde_json::from_str(&json).unwrap();
235        assert_eq!(decoded, mb);
236    }
237
238    #[test]
239    fn test_millibits_arithmetic() {
240        let a = Millibits::new(300);
241        let b = Millibits::new(400);
242        assert_eq!((a + b).raw(), 700);
243        assert_eq!((b - a).raw(), 100);
244        assert_eq!((a - b).raw(), 0); // saturating
245    }
246}