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            return InactivityDiff::AllZeros(target.len() as u32);
103        }
104    }
105
106    let common_len = base.len().min(target.len());
107    let mut indices = Vec::with_capacity(100);
108    let mut new_values = Vec::with_capacity(100);
109
110    for (i, (&v1, &v2)) in base.iter().zip(target.iter()).take(common_len).enumerate() {
111        if v1 != v2 {
112            indices.push(i as u32);
113            new_values.push(v2);
114        }
115    }
116
117    let extensions = target[common_len..].to_vec();
118
119    InactivityDiff::Sparse {
120        indices,
121        new_values,
122        extensions,
123    }
124}
125
126/// Applies an inactivity-score delta to a vector in place.
127///
128/// After successful execution, `base` contains the inactivity-score vector
129/// represented by `delta`.
130///
131/// [`ArchivedInactivityDiff::AllZeros`] clears the existing vector and
132/// recreates it with the specified number of zero-valued scores.
133///
134/// [`ArchivedInactivityDiff::Sparse`] updates the recorded indices and then
135/// appends any newly added validator scores.
136///
137/// # Errors
138///
139/// Returns [`Error::InvalidDelta`] if:
140///
141/// - an all-zero vector length cannot be represented as a `usize`;
142/// - the sparse index and value arrays have different lengths; or
143/// - a sparse index is outside the current `base` vector.
144///
145/// A sparse delta must therefore be applied to a compatible base state.
146///
147/// # Complexity
148///
149/// - [`ArchivedInactivityDiff::AllZeros`]: O(n), where *n* is the target
150///   vector length.
151/// - [`ArchivedInactivityDiff::Sparse`]: O(m + k), where *m* is the number of
152///   recorded updates and *k* is the number of appended scores.
153///
154/// # Example
155///
156/// ```
157/// use eth_state_diff::inactivity_scores::{apply_inactivity, diff_inactivity};
158/// use eth_state_diff::types::ArchivedInactivityDiff;
159///
160/// let mut base = vec![10, 20, 30];
161/// let target = vec![10, 25, 30];
162///
163/// let delta = diff_inactivity(&base, &target);
164///
165/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&delta)
166///     .expect("serializing a locally constructed delta should succeed");
167/// let archived = unsafe {
168///     rkyv::access_unchecked::<ArchivedInactivityDiff>(&bytes)
169/// };
170///
171/// apply_inactivity(&mut base, archived)
172///     .expect("delta generated from the same base should apply successfully");
173///
174/// assert_eq!(base, target);
175/// ```
176pub fn apply_inactivity(base: &mut Vec<u64>, delta: &ArchivedInactivityDiff) -> Result<(), Error> {
177    match delta {
178        ArchivedInactivityDiff::AllZeros(len) => {
179            let len = usize::try_from(len.to_native()).map_err(|_| {
180                Error::InvalidDelta("inactivity-score vector length does not fit in usize".into())
181            })?;
182
183            base.clear();
184            base.resize(len, 0);
185        }
186
187        ArchivedInactivityDiff::Sparse {
188            indices,
189            new_values,
190            extensions,
191        } => {
192            if indices.len() != new_values.len() {
193                return Err(Error::InvalidDelta(format!(
194                    "inactivity-score delta contains {} indices but {} replacement values",
195                    indices.len(),
196                    new_values.len()
197                )));
198            }
199
200            for (idx, val) in indices.iter().zip(new_values.iter()) {
201                let index = usize::try_from(idx.to_native()).map_err(|_| {
202                    Error::InvalidDelta("inactivity-score index does not fit in usize".into())
203                })?;
204
205                let Some(value) = base.get_mut(index) else {
206                    return Err(Error::InvalidDelta(format!(
207                        "inactivity-score index {index} is outside base vector of length {}",
208                        base.len()
209                    )));
210                };
211
212                *value = val.to_native();
213            }
214
215            base.reserve(extensions.len());
216            base.extend(extensions.iter().map(|value| value.to_native()));
217        }
218    }
219
220    Ok(())
221}