Skip to main content

edge_conservation/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2
3#[cfg(not(feature = "std"))]
4extern crate alloc;
5
6#[cfg(feature = "std")]
7use std::string::String;
8#[cfg(feature = "std")]
9use std::string::ToString;
10#[cfg(feature = "std")]
11use std::vec::Vec;
12#[cfg(feature = "std")]
13use std::format;
14
15#[cfg(not(feature = "std"))]
16use alloc::format;
17#[cfg(not(feature = "std"))]
18use alloc::string::String;
19#[cfg(not(feature = "std"))]
20use alloc::string::ToString;
21#[cfg(not(feature = "std"))]
22use alloc::vec::Vec;
23
24/// Default tolerance for floating-point comparisons.
25pub const DEFAULT_TOLERANCE: f64 = 1e-9;
26
27/// Natural log of 2, used for base-2 entropy.
28const LN_2: f64 = 0.6931471805599453;
29
30// ---------------------------------------------------------------------------
31// Structs
32// ---------------------------------------------------------------------------
33
34/// Result of a single conservation-law check.
35#[derive(Debug, Clone)]
36pub struct ConservationReport {
37    /// Observed sum minus expected total.
38    pub delta: f64,
39    /// Tolerance used for the check.
40    pub tolerance: f64,
41    /// Whether the check passed.
42    pub passed: bool,
43    /// Monotonic timestamp (milliseconds since some epoch; caller-defined).
44    pub timestamp_ms: u64,
45    /// Optional label for the check.
46    pub label: Option<String>,
47}
48
49impl ConservationReport {
50    /// Create a new report from parts and a total.
51    pub fn new(parts: &[f64], total: f64, tolerance: f64, timestamp_ms: u64) -> Self {
52        let sum: f64 = parts.iter().copied().sum();
53        let delta = sum - total;
54        let passed = delta.abs() <= tolerance;
55        Self {
56            delta,
57            tolerance,
58            passed,
59            timestamp_ms,
60            label: None,
61        }
62    }
63
64    /// Attach a label.
65    pub fn with_label(mut self, label: &str) -> Self {
66        self.label = Some(label.to_string());
67        self
68    }
69
70    /// Serialize to compact JSON (hand-rolled, no serde).
71    pub fn to_json(&self) -> String {
72        let label_json = match &self.label {
73            Some(l) => format!(",\"label\":\"{}\"", l),
74            None => String::new(),
75        };
76        format!(
77            "{{\"delta\":{},{},\"tolerance\":{},{},\"ts\":{}}}",
78            fmt_f64(self.delta),
79            if self.passed { "\"passed\":true" } else { "\"passed\":false" },
80            fmt_f64(self.tolerance),
81            label_json,
82            self.timestamp_ms,
83        )
84    }
85}
86
87/// Summary report from [`EdgeVerifier`].
88#[derive(Debug, Clone)]
89pub struct SummaryReport {
90    pub checks: Vec<ConservationReport>,
91    pub total: usize,
92    pub passed: usize,
93    pub failed: usize,
94    pub timestamp_ms: u64,
95}
96
97impl SummaryReport {
98    pub fn to_json(&self) -> String {
99        let items: Vec<String> = self.checks.iter().map(|c| c.to_json()).collect();
100        format!(
101            "{{\"total\":{},\"passed\":{},\"failed\":{},\"ts\":{},\"checks\":[{}]}}",
102            self.total,
103            self.passed,
104            self.failed,
105            self.timestamp_ms,
106            items.join(","),
107        )
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Free functions
113// ---------------------------------------------------------------------------
114
115/// Verify that the sum of `parts` equals `total` within `tolerance`.
116pub fn verify_conservation(parts: &[f64], total: f64) -> ConservationReport {
117    verify_conservation_with_tolerance(parts, total, DEFAULT_TOLERANCE)
118}
119
120/// Same as [`verify_conservation`] but with an explicit tolerance.
121pub fn verify_conservation_with_tolerance(
122    parts: &[f64],
123    total: f64,
124    tolerance: f64,
125) -> ConservationReport {
126    let ts = raw_timestamp_ms();
127    ConservationReport::new(parts, total, tolerance, ts)
128}
129
130/// Compute Shannon entropy in base-2 for a probability distribution.
131///
132/// H(p) = -Σ p_i * log2(p_i)
133///
134/// Elements that are zero are skipped (0 * log(0) → 0).
135pub fn shannon_entropy(probs: &[f64]) -> f64 {
136    let mut h = 0.0f64;
137    for &p in probs {
138        if p > 0.0 {
139            h -= p * (ln_f64(p) / LN_2);
140        }
141    }
142    h
143}
144
145/// Compute KL divergence D(p || q) in base-2.
146///
147/// D(p||q) = Σ p_i * log2(p_i / q_i)
148///
149/// Panics if `p` and `q` have different lengths or if any `q_i == 0` where `p_i > 0`.
150pub fn kl_divergence(p: &[f64], q: &[f64]) -> f64 {
151    assert_eq!(p.len(), q.len(), "p and q must have equal length");
152    let mut d = 0.0f64;
153    for (&pi, &qi) in p.iter().zip(q.iter()) {
154        if pi > 0.0 {
155            assert!(qi > 0.0, "q_i must be > 0 where p_i > 0");
156            d += pi * (ln_f64(pi / qi) / LN_2);
157        }
158    }
159    d
160}
161
162/// Verify the determinant of a 2×2 matrix against an expected value.
163///
164/// `m` is row-major: `[a, b, c, d]` → `|a b; c d| = ad - bc`.
165pub fn verify_determinant(m: &[f64; 4], expected_det: f64) -> bool {
166    let det = m[0] * m[3] - m[1] * m[2];
167    (det - expected_det).abs() <= DEFAULT_TOLERANCE
168}
169
170// ---------------------------------------------------------------------------
171// EdgeVerifier — accumulator
172// ---------------------------------------------------------------------------
173
174/// Accumulates multiple conservation checks and produces a summary report.
175pub struct EdgeVerifier {
176    checks: Vec<ConservationReport>,
177    now_fn: fn() -> u64,
178}
179
180impl EdgeVerifier {
181    /// Create a new verifier using the default monotonic timestamp source.
182    pub fn new() -> Self {
183        Self {
184            checks: Vec::new(),
185            now_fn: raw_timestamp_ms,
186        }
187    }
188
189    /// Create a verifier with a custom clock (useful for testing).
190    pub fn with_clock(now_fn: fn() -> u64) -> Self {
191        Self {
192            checks: Vec::new(),
193            now_fn,
194        }
195    }
196
197    /// Verify sum(parts) == total within tolerance and record the result.
198    pub fn verify(&mut self, parts: &[f64], total: f64, tolerance: f64, label: &str) -> bool {
199        let ts = (self.now_fn)();
200        let report = ConservationReport::new(parts, total, tolerance, ts).with_label(label);
201        let passed = report.passed;
202        self.checks.push(report);
203        passed
204    }
205
206    /// Verify a 2×2 determinant and record the result.
207    pub fn verify_det(&mut self, m: &[f64; 4], expected: f64, label: &str) -> bool {
208        let ts = (self.now_fn)();
209        let det = m[0] * m[3] - m[1] * m[2];
210        let passed = (det - expected).abs() <= DEFAULT_TOLERANCE;
211        let delta = det - expected;
212        self.checks.push(ConservationReport {
213            delta,
214            tolerance: DEFAULT_TOLERANCE,
215            passed,
216            timestamp_ms: ts,
217            label: Some(label.to_string()),
218        });
219        passed
220    }
221
222    /// Consume the verifier and produce a summary.
223    pub fn summary(self) -> SummaryReport {
224        let ts = (self.now_fn)();
225        let total = self.checks.len();
226        let passed = self.checks.iter().filter(|c| c.passed).count();
227        SummaryReport {
228            checks: self.checks,
229            total,
230            passed,
231            failed: total - passed,
232            timestamp_ms: ts,
233        }
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Internal helpers
239// ---------------------------------------------------------------------------
240
241/// Monotonic counter-based timestamp.
242fn raw_timestamp_ms() -> u64 {
243    #[cfg(feature = "std")]
244    {
245        use std::sync::atomic::{AtomicU64, Ordering};
246        static COUNTER: AtomicU64 = AtomicU64::new(0);
247        COUNTER.fetch_add(1, Ordering::Relaxed)
248    }
249    #[cfg(not(feature = "std"))]
250    {
251        static mut COUNTER: u64 = 0;
252        // In no_std embedded, supply your own clock via with_clock().
253        unsafe { COUNTER += 1; COUNTER }
254    }
255}
256
257/// Wrapper for f64 natural log that works in both std and no_std.
258#[cfg(feature = "std")]
259fn ln_f64(x: f64) -> f64 {
260    x.ln()
261}
262
263#[cfg(not(feature = "std"))]
264fn ln_f64(x: f64) -> f64 {
265    libm::log(x)
266}
267
268/// Format an f64 as a compact JSON number.
269pub(crate) fn fmt_f64(v: f64) -> String {
270    if v.is_nan() {
271        return "null".to_string();
272    }
273    if v.is_infinite() {
274        return if v.is_sign_positive() { "1e308".to_string() } else { "-1e308".to_string() };
275    }
276    let s = format!("{}", v);
277    if s.contains('.') {
278        let trimmed = s.trim_end_matches('0');
279        if trimmed.ends_with('.') {
280            format!("{}0", trimmed)
281        } else {
282            trimmed.to_string()
283        }
284    } else {
285        s
286    }
287}
288
289#[cfg(test)]
290#[path = "tests.rs"]
291mod tests;