Skip to main content

eth_state_diff/
inactivity_scores.rs

1//! Delta encoding for validator inactivity scores.
2//!
3//! Inactivity scores are represented as a vector indexed by validator index.
4//! This module encodes changes between two score vectors without storing the
5//! complete target vector.
6//!
7//! Two representations are supported:
8//!
9//! - A sparse representation containing only changed indices and their target
10//!   values.
11//! - An all-zero representation for transitions to a completely zero-valued
12//!   target vector.
13//!
14//! Newly added validator scores are stored separately as an extension.
15//!
16//! Deltas produced by [`diff_inactivity`] can be applied in place using
17//! [`apply_inactivity`].
18
19use crate::{
20    types::{ArchivedInactivityDiff, InactivityDiff},
21    Error,
22};
23
24/// Computes a compact delta between two validator inactivity-score vectors.
25///
26/// The returned delta contains sufficient information to reconstruct `target`
27/// from `base`.
28///
29/// If the target contains only zero-valued scores and the base contains at
30/// least one non-zero score, [`InactivityDiff::AllZeros`] is emitted. This
31/// avoids storing individual updates when the entire target vector has been
32/// cleared.
33///
34/// Otherwise, [`InactivityDiff::Sparse`] stores only the indices whose values
35/// changed and their corresponding target values. Scores belonging to newly
36/// appended validators are stored in the `extensions` field.
37///
38/// If `base` and `target` have different lengths, only their common prefix is
39/// compared. Any remaining target scores are treated as newly appended
40/// entries.
41///
42/// # Arguments
43///
44/// * `base` - Inactivity scores from the source state.
45/// * `target` - Inactivity scores from the target state.
46///
47/// # Returns
48///
49/// A compact [`InactivityDiff`] representing the transition from `base` to
50/// `target`.
51///
52/// # Complexity
53///
54/// O(n) time, where *n* is the length of the larger input vector.
55///
56/// Additional space is proportional to the number of changed scores plus the
57/// number of newly appended scores.
58///
59/// # Example
60///
61/// ```
62/// use eth_state_diff::inactivity_scores::diff_inactivity;
63/// use eth_state_diff::types::InactivityDiff;
64///
65/// let base = vec![10, 20, 30, 40];
66/// let target = vec![10, 25, 30, 50];
67///
68/// let delta = diff_inactivity(&base, &target);
69///
70/// assert_eq!(
71///     delta,
72///     InactivityDiff::Sparse {
73///         indices: vec![1, 3],
74///         new_values: vec![25, 50],
75///         extensions: vec![],
76///     }
77/// );
78/// ```
79///
80/// A completely cleared vector can be represented without storing individual
81/// zero values:
82///
83/// ```
84/// use eth_state_diff::inactivity_scores::diff_inactivity;
85/// use eth_state_diff::types::InactivityDiff;
86///
87/// let base = vec![10, 20, 30];
88/// let target = vec![0, 0, 0];
89///
90/// assert_eq!(
91///     diff_inactivity(&base, &target),
92///     InactivityDiff::AllZeros(3)
93/// );
94/// ```
95pub fn diff_inactivity(base: &[u64], target: &[u64]) -> InactivityDiff {
96    let target_is_zero = target.iter().all(|&v| v == 0);
97
98    if target_is_zero {
99        let base_has_non_zero = base.iter().any(|&v| v != 0);
100
101        if base_has_non_zero {
102            let len =
103                u32::try_from(target.len()).expect("inactivity-score vector length exceeds u32");
104            return InactivityDiff::AllZeros(len);
105        }
106    }
107
108    let common_len = base.len().min(target.len());
109    let mut indices = Vec::with_capacity(100);
110    let mut new_values = Vec::with_capacity(100);
111
112    for (i, (&v1, &v2)) in base.iter().zip(target.iter()).take(common_len).enumerate() {
113        if v1 != v2 {
114            let idx = u32::try_from(i).expect("inactivity-score index exceeds u32");
115            indices.push(idx);
116            new_values.push(v2);
117        }
118    }
119
120    let extensions = target
121        .get(common_len..)
122        .expect("common_len is bounded by target length")
123        .to_vec();
124
125    InactivityDiff::Sparse {
126        indices,
127        new_values,
128        extensions,
129    }
130}
131
132/// Applies an inactivity-score delta to a vector in place.
133///
134/// After successful execution, `base` contains the inactivity-score vector
135/// represented by `delta`.
136///
137/// [`ArchivedInactivityDiff::AllZeros`] clears the existing vector and
138/// recreates it with the specified number of zero-valued scores.
139///
140/// [`ArchivedInactivityDiff::Sparse`] updates the recorded indices and then
141/// appends any newly added validator scores.
142///
143/// # Errors
144///
145/// Returns [`Error::InvalidDelta`] if:
146///
147/// - an all-zero vector length cannot be represented as a `usize`;
148/// - the sparse index and value arrays have different lengths; or
149/// - a sparse index is outside the current `base` vector.
150///
151/// A sparse delta must therefore be applied to a compatible base state.
152///
153/// # Complexity
154///
155/// - [`ArchivedInactivityDiff::AllZeros`]: O(n), where *n* is the target
156///   vector length.
157/// - [`ArchivedInactivityDiff::Sparse`]: O(m + k), where *m* is the number of
158///   recorded updates and *k* is the number of appended scores.
159///
160/// # Example
161///
162/// ```
163/// use eth_state_diff::inactivity_scores::{apply_inactivity, diff_inactivity};
164/// use eth_state_diff::types::ArchivedInactivityDiff;
165///
166/// let mut base = vec![10, 20, 30];
167/// let target = vec![10, 25, 30];
168///
169/// let delta = diff_inactivity(&base, &target);
170///
171/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta)
172///     .expect("serializing a locally constructed delta should succeed");
173/// let archived = rkyv::access::<ArchivedInactivityDiff, rkyv::rancor::Error>(&bytes)
174///     .expect("test setup: failed to access archived delta");
175///
176/// apply_inactivity(&mut base, archived)
177///     .expect("delta generated from the same base should apply successfully");
178///
179/// assert_eq!(base, target);
180/// ```
181pub fn apply_inactivity(base: &mut Vec<u64>, delta: &ArchivedInactivityDiff) -> Result<(), Error> {
182    match delta {
183        ArchivedInactivityDiff::AllZeros(len) => {
184            let len = usize::try_from(len.to_native()).map_err(|_| {
185                Error::InvalidDelta("inactivity-score vector length does not fit in usize".into())
186            })?;
187
188            base.clear();
189            base.resize(len, 0);
190        }
191
192        ArchivedInactivityDiff::Sparse {
193            indices,
194            new_values,
195            extensions,
196        } => {
197            if indices.len() != new_values.len() {
198                return Err(Error::InvalidDelta(format!(
199                    "inactivity-score delta contains {} indices but {} replacement values",
200                    indices.len(),
201                    new_values.len()
202                )));
203            }
204
205            for (idx, val) in indices.iter().zip(new_values.iter()) {
206                let index = usize::try_from(idx.to_native()).map_err(|_| {
207                    Error::InvalidDelta("inactivity-score index does not fit in usize".into())
208                })?;
209
210                let Some(value) = base.get_mut(index) else {
211                    return Err(Error::InvalidDelta(format!(
212                        "inactivity-score index {index} is outside base vector of length {}",
213                        base.len()
214                    )));
215                };
216
217                *value = val.to_native();
218            }
219
220            base.reserve(extensions.len());
221            base.extend(extensions.iter().map(|value| value.to_native()));
222        }
223    }
224
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::types::ArchivedInactivityDiff;
232
233    #[test]
234    fn test_diff_all_zeros_fast_path() {
235        let base = vec![10, 20, 30];
236        let target = vec![0, 0, 0];
237
238        let delta = diff_inactivity(&base, &target);
239
240        assert!(matches!(delta, InactivityDiff::AllZeros(3)));
241    }
242
243    #[test]
244    fn test_diff_sparse_identical() {
245        let base = vec![10, 20, 30];
246        let target = vec![10, 20, 30];
247
248        let delta = diff_inactivity(&base, &target);
249
250        match delta {
251            InactivityDiff::Sparse {
252                indices,
253                new_values,
254                extensions,
255            } => {
256                assert!(indices.is_empty());
257                assert!(new_values.is_empty());
258                assert!(extensions.is_empty());
259            }
260            _ => panic!("test setup: expected Sparse"),
261        }
262    }
263
264    #[test]
265    fn test_sparse_with_changes_and_extensions() {
266        let base = vec![10, 20, 30, 40];
267        let target = vec![10, 25, 30, 50, 60];
268        // Changes at index 1 (20 -> 25) and index 3 (40 -> 50)
269
270        let delta = diff_inactivity(&base, &target);
271
272        match delta {
273            InactivityDiff::Sparse {
274                indices,
275                new_values,
276                extensions,
277            } => {
278                assert_eq!(*indices, [1u32, 3u32]);
279                assert_eq!(*new_values, [25u64, 50u64]);
280                assert_eq!(*extensions, [60u64]); // Fixed!
281            }
282            _ => panic!("test setup: expected Sparse"),
283        }
284    }
285
286    #[test]
287    fn test_apply_all_zeros() {
288        let mut base = vec![10, 20, 30];
289        let delta = InactivityDiff::AllZeros(5);
290
291        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
292        let archived = rkyv::access::<ArchivedInactivityDiff, rkyv::rancor::Error>(&bytes)
293            .expect("test setup: failed to access archived delta");
294
295        apply_inactivity(&mut base, archived).expect("test setup: apply");
296
297        assert_eq!(base, vec![0; 5]);
298    }
299
300    #[test]
301    fn test_apply_sparse_roundtrip() {
302        let base = vec![10, 20, 30, 40];
303        let target = vec![10, 25, 30, 50, 60];
304
305        let delta = diff_inactivity(&base, &target);
306
307        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
308        let archived = rkyv::access::<ArchivedInactivityDiff, rkyv::rancor::Error>(&bytes)
309            .expect("test setup: failed to access archived delta");
310
311        let mut reconstructed = base;
312        apply_inactivity(&mut reconstructed, archived).expect("test setup: apply");
313
314        assert_eq!(reconstructed, target);
315    }
316
317    #[test]
318    fn test_apply_sparse_mismatched_lengths() {
319        let delta = InactivityDiff::Sparse {
320            indices: vec![0u32],
321            new_values: vec![],
322            extensions: vec![],
323        };
324
325        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
326        let archived = rkyv::access::<ArchivedInactivityDiff, rkyv::rancor::Error>(&bytes)
327            .expect("test setup: failed to access archived delta");
328
329        let mut base = vec![0u64; 5];
330        let result = apply_inactivity(&mut base, archived);
331
332        assert!(result.is_err());
333        let err_str = format!("{}", result.expect_err("test setup"));
334        assert!(err_str.contains("indices but 0 replacement values"));
335    }
336
337    #[test]
338    fn test_apply_sparse_index_out_of_bounds() {
339        let delta = InactivityDiff::Sparse {
340            indices: vec![10u32],
341            new_values: vec![99u64],
342            extensions: vec![],
343        };
344
345        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta).expect("test setup: serialize");
346        let archived = rkyv::access::<ArchivedInactivityDiff, rkyv::rancor::Error>(&bytes)
347            .expect("test setup: failed to access archived delta");
348
349        let mut base = vec![0u64; 5];
350        let result = apply_inactivity(&mut base, archived);
351
352        assert!(result.is_err());
353        let err_str = format!("{}", result.expect_err("test setup"));
354        assert!(err_str.contains("index 10 is outside base vector of length 5"));
355    }
356}