Skip to main content

seyd_fec/
lib.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Reed-Solomon erasure coding over GF(256) with a Cauchy generator matrix.
4//!
5//! Rationale, measurements and history live in PLAN.md ยง1.7 and in the legacy
6//! Python reference `packages/agent/fec.py`. What matters for this crate:
7//!
8//! * `k` parity chunks over `n` equal-length data chunks recover **any** `k`
9//!   erasures. Cauchy rather than Vandermonde because every square submatrix
10//!   of a Cauchy matrix is invertible, which is exactly that guarantee.
11//! * The construction โ€” poly `0x11d`, `A[i][j] = 1 / (i XOR (k + j))` โ€” is
12//!   part of the wire contract. Changing it breaks interop silently, which is
13//!   why `tools/fec-vectors.py | cargo run -p seyd-fec --example check` exists.
14//! * The coder is payload-agnostic: it never parses what it protects.
15
16pub mod gf;
17
18use std::collections::HashMap;
19use std::sync::{Mutex, OnceLock};
20
21/// Hard limit of the construction: `n + k <= 256`.
22pub const MAX_SYMBOLS: usize = 256;
23
24/// `k x n` Cauchy matrix, cached per `(n, k)`.
25///
26/// `x_i = i` for `i in 0..k`, `y_j = k + j` for `j in 0..n`; the sets are
27/// disjoint so nothing is inverted at zero, and distinct within each set,
28/// which is what makes every square submatrix invertible.
29pub type Matrix = std::sync::Arc<Vec<Vec<u8>>>;
30
31pub fn cauchy_matrix(n: usize, k: usize) -> Matrix {
32    static CACHE: OnceLock<Mutex<HashMap<(usize, usize), Matrix>>> = OnceLock::new();
33    assert!(n + k <= MAX_SYMBOLS, "n+k must be <= 256, got n={n} k={k}");
34    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
35    let mut guard = cache.lock().unwrap();
36    guard
37        .entry((n, k))
38        .or_insert_with(|| {
39            let m = (0..k)
40                .map(|i| (0..n).map(|j| gf::inv((i ^ (k + j)) as u8)).collect())
41                .collect();
42            std::sync::Arc::new(m)
43        })
44        .clone()
45}
46
47/// Parity chunks for an `n`-chunk block at `pct` overhead.
48///
49/// Always at least 1 when `pct > 0`: a tiny block still deserves protection,
50/// and `n = 1, k = 1` degenerates to a plain duplicate, which is right.
51pub fn parity_count(n: usize, pct: u32, cap: usize) -> usize {
52    if pct == 0 || n == 0 {
53        return 0;
54    }
55    // Python's round() is banker's rounding; ties here are only possible at
56    // .5 exactly, which the vector suite covers. Match it precisely.
57    let scaled = (n as f64) * (pct as f64) / 100.0;
58    let rounded = round_half_even(scaled);
59    rounded.max(1).min(cap).min(n)
60}
61
62fn round_half_even(x: f64) -> usize {
63    let floor = x.floor();
64    let diff = x - floor;
65    let round_up = diff > 0.5 || (diff == 0.5 && (floor as u64) % 2 == 1);
66    (if round_up { floor + 1.0 } else { floor }) as usize
67}
68
69/// Compute `k` parity chunks from `n` equal-length data chunks.
70///
71/// Every chunk must be exactly the same length โ€” the caller zero-pads the
72/// short final one and carries its true length in the wire header.
73pub fn encode_parity(chunks: &[&[u8]], k: usize) -> Vec<Vec<u8>> {
74    if k == 0 || chunks.is_empty() {
75        return Vec::new();
76    }
77    let size = chunks[0].len();
78    debug_assert!(
79        chunks.iter().all(|c| c.len() == size),
80        "unequal chunk sizes"
81    );
82    let matrix = cauchy_matrix(chunks.len(), k);
83    matrix
84        .iter()
85        .map(|row| {
86            let mut acc = vec![0u8; size];
87            for (&coeff, chunk) in row.iter().zip(chunks) {
88                gf::madd_into(&mut acc, chunk, coeff);
89            }
90            acc
91        })
92        .collect()
93}
94
95/// Reconstruct missing data chunks in place. Returns `false` if unrecoverable.
96///
97/// Only the erased data positions are solved for, so the system is `d x d`
98/// where `d` is the number of erasures rather than `n x n`.
99pub fn decode(data: &mut [Option<Vec<u8>>], parity: &[Option<Vec<u8>>]) -> bool {
100    let n = data.len();
101    let k = parity.len();
102    let lost: Vec<usize> = (0..n).filter(|&i| data[i].is_none()).collect();
103    if lost.is_empty() {
104        return true;
105    }
106    let available: Vec<usize> = (0..k).filter(|&p| parity[p].is_some()).collect();
107    if available.len() < lost.len() {
108        return false;
109    }
110    let use_rows = &available[..lost.len()];
111    let size = match data
112        .iter()
113        .chain(parity.iter())
114        .find_map(|c| c.as_ref().map(|v| v.len()))
115    {
116        Some(s) => s,
117        None => return false,
118    };
119    let matrix = cauchy_matrix(n, k);
120
121    // Syndromes: each chosen parity chunk minus the contribution of the data
122    // we still have; what remains is that row's combination of the lost chunks.
123    let syndromes: Vec<Vec<u8>> = use_rows
124        .iter()
125        .map(|&p| {
126            let mut acc = parity[p].as_ref().unwrap().clone();
127            for j in 0..n {
128                if let Some(chunk) = &data[j] {
129                    gf::madd_into(&mut acc, chunk, matrix[p][j]);
130                }
131            }
132            acc
133        })
134        .collect();
135
136    let sub: Vec<Vec<u8>> = use_rows
137        .iter()
138        .map(|&p| lost.iter().map(|&j| matrix[p][j]).collect())
139        .collect();
140    let inverse = match invert(&sub) {
141        Some(m) => m,
142        None => return false,
143    };
144
145    for (r, &idx) in lost.iter().enumerate() {
146        let mut acc = vec![0u8; size];
147        for (c, syndrome) in syndromes.iter().enumerate() {
148            gf::madd_into(&mut acc, syndrome, inverse[r][c]);
149        }
150        data[idx] = Some(acc);
151    }
152    true
153}
154
155/// Gauss-Jordan inverse of a square GF(256) matrix.
156fn invert(matrix: &[Vec<u8>]) -> Option<Vec<Vec<u8>>> {
157    let m = matrix.len();
158    let mut aug: Vec<Vec<u8>> = matrix
159        .iter()
160        .enumerate()
161        .map(|(i, row)| {
162            let mut r = row.clone();
163            r.extend((0..m).map(|j| u8::from(i == j)));
164            r
165        })
166        .collect();
167    for col in 0..m {
168        let pivot = (col..m).find(|&r| aug[r][col] != 0)?;
169        aug.swap(col, pivot);
170        let scale = gf::inv(aug[col][col]);
171        for v in aug[col].iter_mut() {
172            *v = gf::mul(*v, scale);
173        }
174        let pivot_row = aug[col].clone();
175        for (r, row) in aug.iter_mut().enumerate() {
176            if r != col && row[col] != 0 {
177                let f = row[col];
178                for (v, p) in row.iter_mut().zip(&pivot_row) {
179                    *v ^= gf::mul(f, *p);
180                }
181            }
182        }
183    }
184    Some(aug.into_iter().map(|row| row[m..].to_vec()).collect())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    struct Rng(u64);
192    impl Rng {
193        fn next(&mut self) -> u64 {
194            self.0 ^= self.0 << 13;
195            self.0 ^= self.0 >> 7;
196            self.0 ^= self.0 << 17;
197            self.0
198        }
199        fn below(&mut self, n: usize) -> usize {
200            (self.next() % n as u64) as usize
201        }
202    }
203
204    fn random_chunks(rng: &mut Rng, n: usize, size: usize) -> Vec<Vec<u8>> {
205        (0..n)
206            .map(|_| (0..size).map(|_| rng.next() as u8).collect())
207            .collect()
208    }
209
210    #[test]
211    fn any_k_erasures_recover() {
212        let mut rng = Rng(0x9e3779b97f4a7c15);
213        for &(n, k) in &[(1, 1), (6, 2), (12, 4), (18, 9), (50, 16), (200, 56)] {
214            let chunks = random_chunks(&mut rng, n, 64);
215            let refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
216            let parity = encode_parity(&refs, k);
217            for _trial in 0..20 {
218                let mut data: Vec<Option<Vec<u8>>> = chunks.iter().cloned().map(Some).collect();
219                let mut par: Vec<Option<Vec<u8>>> = parity.iter().cloned().map(Some).collect();
220                let mut erased = 0;
221                while erased < k {
222                    let i = rng.below(n + k);
223                    let slot = if i < n { &mut data[i] } else { &mut par[i - n] };
224                    if slot.is_some() {
225                        *slot = None;
226                        erased += 1;
227                    }
228                }
229                assert!(decode(&mut data, &par), "n={n} k={k}");
230                for (a, b) in data.iter().zip(&chunks) {
231                    assert_eq!(a.as_ref().unwrap(), b);
232                }
233            }
234        }
235    }
236
237    #[test]
238    fn too_many_erasures_fail() {
239        let chunks = [vec![1u8; 8], vec![2u8; 8], vec![3u8; 8]];
240        let refs: Vec<&[u8]> = chunks.iter().map(|c| c.as_slice()).collect();
241        let parity = encode_parity(&refs, 1);
242        let mut data = vec![None, None, Some(chunks[2].clone())];
243        let par: Vec<Option<Vec<u8>>> = parity.into_iter().map(Some).collect();
244        assert!(!decode(&mut data, &par));
245    }
246
247    #[test]
248    fn parity_count_matches_python() {
249        assert_eq!(parity_count(6, 25, 16), 2);
250        assert_eq!(parity_count(6, 8, 16), 1);
251        assert_eq!(parity_count(18, 50, 16), 9);
252        assert_eq!(parity_count(61, 30, 16), 16);
253        assert_eq!(parity_count(1, 50, 16), 1);
254        assert_eq!(parity_count(5, 0, 16), 0);
255        // banker's rounding: 2.5 -> 2, 3.5 -> 4
256        assert_eq!(parity_count(10, 25, 16), 2);
257        assert_eq!(parity_count(14, 25, 16), 4);
258    }
259
260    #[test]
261    fn cauchy_matches_reference() {
262        let m = cauchy_matrix(3, 2);
263        assert_eq!(m[0][0], gf::inv(2));
264        assert_eq!(m[1][2], gf::inv(1 ^ 4));
265    }
266}