Skip to main content

renew_frame/
digest.rs

1//! The state fingerprint: FNV-1a-64 by explicit ordered absorption.
2//!
3//! Not a hash-map hasher and not a security hash. It answers one question
4//! — did two runs produce the same state — and nothing more. that rule does not
5//! apply: there is no untrusted input, no adversary, and no
6//! collision-resistance requirement.
7//!
8//! Hand-rolled rather than borrowed, for three reasons no dependency would
9//! fix. `RandomState` is seeded per process and can never back a cross-run
10//! claim. `SipHasher13` carries no cross-version stability guarantee, so a
11//! frozen digest would break on a toolchain bump for reasons unrelated to
12//! the engine. And `#[derive(Hash)]` absorbs fields in declaration order
13//! *implicitly*, so reordering two fields would silently change every
14//! digest in the tree — explicit absorption turns that into a visible
15//! diff.
16
17use crate::schedule::FramePlan;
18
19const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
20const PRIME: u64 = 0x0000_0100_0000_01b3;
21
22/// An in-progress fingerprint. Absorption is by value and returns the new
23/// state, so the order of a digest is written out as an expression and can
24/// be read off the page.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct StateHash(u64);
27
28impl StateHash {
29    #[must_use]
30    pub const fn new() -> Self {
31        Self(OFFSET_BASIS)
32    }
33
34    /// Absorb raw bytes, in the order given.
35    #[must_use]
36    pub const fn absorb_bytes(mut self, bytes: &[u8]) -> Self {
37        let mut index = 0;
38        while index < bytes.len() {
39            // `u64::from` is not callable in a `const fn` (const trait
40            // impls are unstable), so the widening is written as a cast.
41            #[allow(clippy::cast_lossless)]
42            let byte = bytes[index] as u64;
43            self.0 = (self.0 ^ byte).wrapping_mul(PRIME);
44            index += 1;
45        }
46        self
47    }
48
49    /// Absorb a 64-bit value, little-endian. The byte order is fixed here
50    /// rather than left to the host so a digest means the same thing on
51    /// every target.
52    #[must_use]
53    pub const fn absorb_u64(self, value: u64) -> Self {
54        self.absorb_bytes(&value.to_le_bytes())
55    }
56
57    /// Absorb a 32-bit value, little-endian.
58    #[must_use]
59    pub const fn absorb_u32(self, value: u32) -> Self {
60        self.absorb_bytes(&value.to_le_bytes())
61    }
62
63    /// Absorb a float by its bit pattern — never by its value, which has
64    /// two zeros and no equality for NaN.
65    #[must_use]
66    pub const fn absorb_f32_bits(self, value: f32) -> Self {
67        self.absorb_u32(value.to_bits())
68    }
69
70    /// The canonical per-frame absorption order, written once so no
71    /// consumer invents a second one.
72    ///
73    /// `alpha` is excluded deliberately: it is a pure function of the
74    /// remainder and the timestep, both of which are absorbed here, so
75    /// hashing it would add no information and would make the oracle
76    /// float-dependent. An unstated exclusion is how a determinism oracle
77    /// goes quietly vacuous, so it is stated.
78    ///
79    /// **The timestep was not absorbed until it was checked.** The
80    /// sentence above was written when this folded four fields, none of
81    /// them the timestep, so the exclusion it justified rested on a
82    /// premise that was false: two plans with equal first tick, step
83    /// count, dropped count and remainder, cut against different
84    /// timesteps, digested identically and had different alphas. Nothing
85    /// in the tree could produce that pair, since a loop's timestep is
86    /// fixed at construction — which is exactly why it survived. A digest
87    /// justified by a claim that happens to hold is a digest waiting for
88    /// the day it stops.
89    #[must_use]
90    pub const fn absorb_plan(self, plan: &FramePlan) -> Self {
91        self.absorb_u64(plan.first_tick())
92            .absorb_u32(plan.step_count())
93            .absorb_u64(plan.dropped())
94            .absorb_u64(plan.remainder().get())
95            .absorb_u64(plan.dt().nanos().get())
96    }
97
98    #[must_use]
99    pub const fn finish(self) -> u64 {
100        self.0
101    }
102}
103
104impl Default for StateHash {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::StateHash;
113    use crate::schedule::FrameLoop;
114    use crate::time::{StepBudget, Timestamp, Timestep};
115    use core::num::NonZeroU64;
116
117    /// The published FNV-1a-64 vector for "a", pinning the constants and
118    /// the byte order against a source outside this repository.
119    #[test]
120    fn the_published_reference_vector_matches() {
121        assert_eq!(StateHash::new().finish(), 0xcbf2_9ce4_8422_2325);
122        assert_eq!(
123            StateHash::new().absorb_bytes(b"a").finish(),
124            0xaf63_dc4c_8601_ec8c
125        );
126        assert_eq!(
127            StateHash::new().absorb_bytes(b"foobar").finish(),
128            0x8594_4171_f739_67e8
129        );
130    }
131
132    #[test]
133    fn the_default_is_the_offset_basis() {
134        assert_eq!(StateHash::default(), StateHash::new());
135    }
136
137    #[test]
138    fn absorbing_nothing_changes_nothing() {
139        assert_eq!(StateHash::new().absorb_bytes(&[]), StateHash::new());
140    }
141
142    #[test]
143    fn absorption_order_is_part_of_the_digest() {
144        let forward = StateHash::new().absorb_u64(1).absorb_u64(2).finish();
145        let reversed = StateHash::new().absorb_u64(2).absorb_u64(1).finish();
146        assert_ne!(forward, reversed);
147    }
148
149    /// A 32-bit absorption is four bytes and a 64-bit one is eight, so the
150    /// same numeric value through the two widths must differ — otherwise
151    /// a width change in a consumer's state would be invisible.
152    #[test]
153    fn width_is_part_of_the_digest() {
154        assert_ne!(
155            StateHash::new().absorb_u32(7).finish(),
156            StateHash::new().absorb_u64(7).finish()
157        );
158        assert_eq!(
159            StateHash::new().absorb_u32(7).finish(),
160            StateHash::new().absorb_bytes(&7u32.to_le_bytes()).finish()
161        );
162    }
163
164    #[test]
165    fn a_float_is_absorbed_by_its_bit_pattern() {
166        assert_eq!(
167            StateHash::new().absorb_f32_bits(1.5).finish(),
168            StateHash::new().absorb_u32(1.5f32.to_bits()).finish()
169        );
170        // The two zeros are distinguishable, which is the point of
171        // hashing bits rather than values.
172        assert_ne!(
173            StateHash::new().absorb_f32_bits(0.0).finish(),
174            StateHash::new().absorb_f32_bits(-0.0).finish()
175        );
176    }
177
178    #[test]
179    fn a_plan_is_absorbed_field_by_field_in_the_documented_order() {
180        let mut frame = FrameLoop::new(
181            Timestep::HZ_60,
182            StepBudget::DEFAULT,
183            Timestamp::from_nanos(0),
184        );
185        let plan = frame.begin_frame(Timestamp::from_nanos(200_000_000));
186        let by_hand = StateHash::new()
187            .absorb_u64(plan.first_tick())
188            .absorb_u32(plan.step_count())
189            .absorb_u64(plan.dropped())
190            .absorb_u64(plan.remainder().get())
191            .absorb_u64(plan.dt().nanos().get());
192        assert_eq!(StateHash::new().absorb_plan(&plan), by_hand);
193    }
194
195    /// The exclusion the determinism oracle depends on, asserted from the
196    /// other side: two plans that differ only in their alpha cannot exist,
197    /// because alpha is derived from absorbed fields. What *can* be
198    /// asserted is that every absorbed field moves the digest.
199    #[test]
200    fn every_absorbed_field_moves_the_digest() {
201        let mut frame = FrameLoop::new(
202            Timestep::HZ_60,
203            StepBudget::DEFAULT,
204            Timestamp::from_nanos(0),
205        );
206        let base = frame.begin_frame(Timestamp::from_nanos(200_000_000));
207        let next = frame.begin_frame(Timestamp::from_nanos(400_000_000));
208        assert_ne!(base.first_tick(), next.first_tick());
209        assert_ne!(
210            StateHash::new().absorb_plan(&base),
211            StateHash::new().absorb_plan(&next)
212        );
213    }
214
215    /// The timestep is absorbed, asserted the only way that means
216    /// anything: **two plans agreeing in every other absorbed field and
217    /// differing only in timestep.** Elapsed time is chosen per loop so
218    /// both cut three steps and leave the same remainder, which is what
219    /// makes the timestep the sole difference — and their alphas differ,
220    /// since alpha is that shared remainder over two different divisors.
221    /// That pair is exactly what digested identically before the field
222    /// was folded in.
223    ///
224    /// The first version of this test built its two plans from one
225    /// elapsed time and two rates, so they differed in step count and
226    /// remainder as well. It passed with the absorption deleted —
227    /// measured, not assumed — which is the whole reason it is written
228    /// this way now.
229    #[test]
230    fn two_timesteps_agreeing_in_every_other_field_do_not_share_a_digest() {
231        // Three steps and a one-millisecond remainder, at two rates.
232        let plan_at = |step_nanos: u64| {
233            let dt = Timestep::from_nanos(NonZeroU64::new(step_nanos).expect("positive"));
234            let mut frame = FrameLoop::new(dt, StepBudget::DEFAULT, Timestamp::from_nanos(0));
235            frame.begin_frame(Timestamp::from_nanos(3 * step_nanos + 1_000_000))
236        };
237        let slow = plan_at(20_000_000);
238        let fast = plan_at(10_000_000);
239
240        // The premise, asserted rather than assumed: everything the
241        // digest absorbed before the timestep joined it agrees.
242        assert_eq!(slow.first_tick(), fast.first_tick());
243        assert_eq!(slow.step_count(), fast.step_count());
244        assert_eq!(slow.dropped(), fast.dropped());
245        assert_eq!(slow.remainder().get(), fast.remainder().get());
246        assert_ne!(slow.dt().nanos(), fast.dt().nanos());
247        // And the consequence that made the gap matter: the same
248        // remainder over two different divisors is two different
249        // interpolation factors, so a renderer would have drawn these two
250        // plans differently while the digest called them the same.
251        assert_ne!(
252            slow.remainder().get() * fast.dt().nanos().get(),
253            fast.remainder().get() * slow.dt().nanos().get(),
254            "cross-multiplied, the two ratios must differ"
255        );
256
257        assert_ne!(
258            StateHash::new().absorb_plan(&slow),
259            StateHash::new().absorb_plan(&fast),
260            "two plans differing only in timestep digested the same, so the oracle cannot              see the loop's rate and alpha carries an input it does not cover"
261        );
262    }
263
264    /// `absorb_plan` is usable at compile time, which is what lets a
265    /// consumer state an expected digest as a `const`.
266    #[test]
267    fn the_digest_is_computable_at_compile_time() {
268        const DIGEST: u64 = StateHash::new().absorb_u64(1).absorb_u32(2).finish();
269        assert_eq!(
270            DIGEST,
271            StateHash::new().absorb_u64(1).absorb_u32(2).finish()
272        );
273    }
274}