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}