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}