sui_intern/memo.rs
1//! `ContentMemo` — a byte-neutral, content-keyed memo of a PURE function.
2//!
3//! # The load-bearing invariant (why this is safe on a byte-parity path)
4//!
5//! The memoized value is a **pure function of its content-key**, so a cache
6//! hit is **byte-identical** to a recompute. That is exactly what makes
7//! memoization safe on sui's byte-parity-critical eval path: a memoized nix
8//! evaluation must produce the identical `drvPath` whether the value was
9//! freshly computed or served from the memo. The key IS the content address;
10//! the value is fully determined by it.
11//!
12//! **RISK — do not violate:** never memoize a value that depends on anything
13//! other than its key — wall-clock (`currentTime`), environment (`getEnv`),
14//! mutable filesystem reads, or eval-order-sensitive identity. Those are not
15//! pure functions of the key, so a hit would NOT equal a recompute and could
16//! change a `drvPath`. If the value can vary for a fixed key, it is not a
17//! `ContentMemo` candidate.
18//!
19//! # Why it's a primitive
20//!
21//! This exact shape — a thread-local `Map<ContentKey, Rc<Value>>` of a pure
22//! function, cleared at a natural boundary — was hand-rolled three times
23//! before this module (the sui-compat NAR-hash memo, the sui-eval
24//! referenced-idents memo, the sui-eval overlay-flatten cache). This is the
25//! extracted, packaged shape: declare one with [`thread_local_content_memo!`]
26//! and get a memoized accessor + a clear fn for free.
27//!
28//! Single-threaded by construction (`Rc` + `RefCell`), matching sui's
29//! single-threaded evaluator.
30
31use rustc_hash::FxHashMap;
32use std::cell::RefCell;
33use std::hash::{Hash, Hasher};
34use std::marker::PhantomData;
35use std::rc::Rc;
36
37/// A content address derived **by construction** from a value of type `T`.
38///
39/// # The type-level seal (M3 — the stale-key/decoupling axis)
40///
41/// The general [`ContentMemo::get_or_compute`] lets the `key` and the
42/// `compute` closure **disagree**: a caller can pass a key that is *not* a
43/// function of what `compute` actually reads (the stale-key footgun — the
44/// `Sharing::PerSite`/libxcrypt divergence class). `ContentKey<T>` removes
45/// that footgun structurally: its **sole constructor** is [`ContentKey::of`],
46/// which hashes `T`'s structural read-set. So "the key IS the content of the
47/// input" is not a caller obligation — it holds **by construction**, and a
48/// key decoupled from its input **has no way to be built**.
49///
50/// Paired with [`ContentMemo::get_or_compute_keyed`], which derives the key
51/// from the same `&T` it hands to `compute`, the key↔content decoupling axis
52/// is **parse-time-rejected** (there is no expressible program that memoizes
53/// under a key not derived from the computed input).
54///
55/// # Honest ceiling — what this does NOT seal
56///
57/// This seals the KEY↔CONTENT structural axis only. It does **not** seal the
58/// **purity** of `T → V`: `compute` could still read wall-clock, `getEnv`, or a
59/// mutable filesystem — those are opaque to the type system. There is no
60/// `PureFn` in safe Rust and there cannot be, so the purity axis stays
61/// **only-mitigated (C1) forever** (the module invariant + the CI byte gate are
62/// the correct terminal enforcement). Do not read `ContentKey<T>` as a purity
63/// proof — it is a decoupling proof.
64///
65/// The digest is a 32-byte BLAKE3 over `T`'s `Hash` serialization, so the key
66/// is a stable, collision-resistant content address of the value's structural
67/// fields.
68///
69/// The trait impls below are hand-written (not `#[derive]`d) so they hold for
70/// **any** `T` — the standard derive would demand `T: Clone + Eq + Hash + …`
71/// even though the only real field is the 32-byte digest (`T` lives only in a
72/// zero-size `PhantomData`).
73pub struct ContentKey<T> {
74 digest: [u8; 32],
75 _marker: PhantomData<fn() -> T>,
76}
77
78impl<T> Clone for ContentKey<T> {
79 fn clone(&self) -> Self {
80 *self
81 }
82}
83impl<T> Copy for ContentKey<T> {}
84impl<T> PartialEq for ContentKey<T> {
85 fn eq(&self, other: &Self) -> bool {
86 self.digest == other.digest
87 }
88}
89impl<T> Eq for ContentKey<T> {}
90impl<T> Hash for ContentKey<T> {
91 fn hash<H: Hasher>(&self, state: &mut H) {
92 self.digest.hash(state);
93 }
94}
95impl<T> std::fmt::Debug for ContentKey<T> {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 // Short hex prefix of the digest — enough to distinguish keys in logs.
98 write!(f, "ContentKey(")?;
99 for b in &self.digest[..4] {
100 write!(f, "{b:02x}")?;
101 }
102 write!(f, "…)")
103 }
104}
105
106/// A `std::hash::Hasher` that streams into a BLAKE3 hasher, so any `T: Hash`
107/// can be content-addressed generically (its structural read-set = exactly the
108/// bytes its `Hash` impl writes).
109struct Blake3Hasher(blake3::Hasher);
110
111impl Hasher for Blake3Hasher {
112 fn finish(&self) -> u64 {
113 // Not the content address — `ContentKey::of` uses the full 32-byte
114 // digest via `finalize()`. This exists only to satisfy the trait for
115 // the streaming `write` path; a fold of the first 8 digest bytes.
116 let bytes = self.0.finalize();
117 let mut buf = [0u8; 8];
118 buf.copy_from_slice(&bytes.as_bytes()[..8]);
119 u64::from_le_bytes(buf)
120 }
121
122 fn write(&mut self, bytes: &[u8]) {
123 self.0.update(bytes);
124 }
125}
126
127impl<T: Hash> ContentKey<T> {
128 /// Derive the content key from `input` — the **sole** constructor.
129 ///
130 /// Hashes `input`'s structural read-set (everything its `Hash` impl writes)
131 /// through BLAKE3, so the returned key is a pure, deterministic function of
132 /// `input`'s content. Two structurally-equal `T`s produce the identical key;
133 /// there is no way to construct a `ContentKey<T>` that is *not* the content
134 /// address of some `&T`.
135 #[must_use]
136 pub fn of(input: &T) -> Self {
137 let mut hasher = Blake3Hasher(blake3::Hasher::new());
138 input.hash(&mut hasher);
139 Self {
140 digest: *hasher.0.finalize().as_bytes(),
141 _marker: PhantomData,
142 }
143 }
144
145 /// The raw 32-byte BLAKE3 digest (for debugging / cross-checking).
146 #[must_use]
147 pub fn digest(&self) -> &[u8; 32] {
148 &self.digest
149 }
150}
151
152/// A single-threaded content-keyed memo. `K` is the content address; the
153/// value `Rc<V>` is a **pure function of `K`** (see the module invariant).
154/// A hit is a cheap `Rc::clone`.
155#[derive(Debug)]
156pub struct ContentMemo<K: Eq + Hash, V> {
157 map: RefCell<FxHashMap<K, Rc<V>>>,
158}
159
160impl<K: Eq + Hash, V> Default for ContentMemo<K, V> {
161 fn default() -> Self {
162 Self {
163 map: RefCell::new(FxHashMap::default()),
164 }
165 }
166}
167
168impl<K: Eq + Hash + Clone, V> ContentMemo<K, V> {
169 /// A fresh empty memo.
170 #[must_use]
171 pub fn new() -> Self {
172 Self::default()
173 }
174
175 /// Return the memoized value for `key`, computing + storing it via
176 /// `compute` on a miss. A hit is a cheap `Rc::clone` and is byte-identical
177 /// to a recompute **by the module's purity invariant** (the caller's
178 /// responsibility — `compute` must be a pure function of `key`).
179 pub fn get_or_compute(&self, key: K, compute: impl FnOnce() -> V) -> Rc<V> {
180 if let Some(hit) = self.map.borrow().get(&key).cloned() {
181 return hit;
182 }
183 // Compute OUTSIDE the borrow: `compute` may itself touch other memos.
184 let rc = Rc::new(compute());
185 self.map.borrow_mut().insert(key, rc.clone());
186 rc
187 }
188
189 /// Drop all entries. Call at a natural boundary (e.g. per top-level eval)
190 /// so stale keys from a prior computation cannot persist and collide.
191 pub fn clear(&self) {
192 self.map.borrow_mut().clear();
193 }
194
195 /// Number of memoized entries.
196 #[must_use]
197 pub fn len(&self) -> usize {
198 self.map.borrow().len()
199 }
200
201 /// Whether the memo is empty.
202 #[must_use]
203 pub fn is_empty(&self) -> bool {
204 self.map.borrow().is_empty()
205 }
206}
207
208impl<T: Hash, V> ContentMemo<ContentKey<T>, V> {
209 /// The **keyed** memo API — the M3 seal for the key↔content decoupling axis.
210 ///
211 /// Derives the [`ContentKey`] from `input` internally (so the key is
212 /// *provably* the content address of `input`, not some ambient value the
213 /// caller passed alongside it) and hands the **same `&input`** to
214 /// `compute`. A caller therefore **cannot** memoize under a key decoupled
215 /// from the computed input — the decoupling has no constructor.
216 ///
217 /// A hit is a cheap `Rc::clone` and is byte-identical to a recompute **by
218 /// the module's purity invariant** — `compute` must still be a pure
219 /// function of its `&T` argument (the C1-ceiling purity axis this seal does
220 /// NOT close; see [`ContentKey`]).
221 pub fn get_or_compute_keyed(&self, input: &T, compute: impl FnOnce(&T) -> V) -> Rc<V> {
222 let key = ContentKey::of(input);
223 if let Some(hit) = self.map.borrow().get(&key).cloned() {
224 return hit;
225 }
226 // Compute OUTSIDE the borrow: `compute` may itself touch other memos.
227 let rc = Rc::new(compute(input));
228 self.map.borrow_mut().insert(key, rc.clone());
229 rc
230 }
231}
232
233/// Declare a thread-local [`ContentMemo`] plus a memoized accessor and a clear
234/// fn — the boilerplate that was hand-rolled at every memo site. Real-easy
235/// front: one declaration replaces the `thread_local!` + `get_or_compute`
236/// wrapper + `clear` wrapper trio.
237///
238/// ```ignore
239/// use sui_intern::thread_local_content_memo;
240/// use std::collections::HashSet;
241///
242/// thread_local_content_memo! {
243/// /// referenced idents per (source-id, text-range)
244/// REFERENCED_IDENTS: (u32, rnix::TextRange) => HashSet<smol_str::SmolStr>;
245/// fn referenced_idents_memo; // accessor: (key, || compute) -> Rc<V>
246/// fn clear_referenced_idents; // clear the memo (per top-level eval)
247/// }
248///
249/// // hit-or-compute (byte-neutral: value is a pure fn of the key):
250/// let set = referenced_idents_memo(key, || walk_and_collect(expr));
251/// // reset at the eval boundary:
252/// clear_referenced_idents();
253/// ```
254#[macro_export]
255macro_rules! thread_local_content_memo {
256 (
257 $(#[$meta:meta])*
258 $STATIC:ident : $K:ty => $V:ty ;
259 fn $get:ident ;
260 fn $clear:ident ;
261 ) => {
262 thread_local! {
263 $(#[$meta])*
264 static $STATIC: $crate::memo::ContentMemo<$K, $V> =
265 $crate::memo::ContentMemo::new();
266 }
267
268 /// Memoized accessor — returns the cached `Rc` on a hit, else computes,
269 /// stores, and returns. `compute` MUST be a pure function of `key`
270 /// (the memo's byte-neutrality invariant).
271 #[allow(dead_code)]
272 fn $get(key: $K, compute: impl FnOnce() -> $V) -> ::std::rc::Rc<$V> {
273 $STATIC.with(|m| m.get_or_compute(key, compute))
274 }
275
276 /// Clear the thread-local memo (call at a natural boundary).
277 #[allow(dead_code)]
278 fn $clear() {
279 $STATIC.with(|m| m.clear());
280 }
281 };
282}
283
284/// `ContentKey<T>` has no public constructor other than `of(&T)`, and its
285/// fields are private — so you cannot build one from a value of the wrong type
286/// (or from raw bytes). This doctest proves the type-level seal: passing a `&B`
287/// where a `&A` is expected is a type error, not a runtime footgun.
288///
289/// ```compile_fail
290/// use sui_intern::memo::ContentKey;
291/// struct A(u32);
292/// struct B(u32);
293/// let b = B(1);
294/// // A key over A cannot be constructed from a &B — the decoupling has no path.
295/// let _k: ContentKey<A> = ContentKey::<A>::of(&b);
296/// ```
297///
298/// And the private fields cannot be hand-forged from an arbitrary digest:
299///
300/// ```compile_fail
301/// use sui_intern::memo::ContentKey;
302/// struct A(u32);
303/// // No public constructor from raw bytes; fields are private.
304/// let _k: ContentKey<A> = ContentKey { digest: [0u8; 32], _marker: Default::default() };
305/// ```
306#[allow(dead_code)]
307fn _content_key_type_seal_doc() {}
308
309#[cfg(test)]
310mod tests {
311 use super::{ContentKey, ContentMemo};
312
313 #[test]
314 fn hit_returns_same_rc_and_does_not_recompute() {
315 let memo: ContentMemo<u32, String> = ContentMemo::new();
316 let mut calls = 0;
317 let a = memo.get_or_compute(7, || {
318 calls += 1;
319 "seven".to_string()
320 });
321 let b = memo.get_or_compute(7, || {
322 calls += 1;
323 "SHOULD-NOT-RUN".to_string()
324 });
325 assert_eq!(calls, 1, "second get for the same key must not recompute");
326 assert!(std::rc::Rc::ptr_eq(&a, &b), "same key returns the same Rc");
327 assert_eq!(&*a, "seven");
328 }
329
330 #[test]
331 fn distinct_keys_are_independent() {
332 let memo: ContentMemo<u32, u32> = ContentMemo::new();
333 let a = memo.get_or_compute(1, || 10);
334 let b = memo.get_or_compute(2, || 20);
335 assert_eq!((*a, *b), (10, 20));
336 assert_eq!(memo.len(), 2);
337 assert!(!std::rc::Rc::ptr_eq(&a, &b));
338 }
339
340 #[test]
341 fn clear_drops_entries_so_recompute_happens() {
342 let memo: ContentMemo<u32, u32> = ContentMemo::new();
343 let mut calls = 0;
344 let _ = memo.get_or_compute(1, || {
345 calls += 1;
346 1
347 });
348 assert_eq!(memo.len(), 1);
349 memo.clear();
350 assert!(memo.is_empty());
351 let _ = memo.get_or_compute(1, || {
352 calls += 1;
353 1
354 });
355 assert_eq!(calls, 2, "after clear, the key recomputes");
356 }
357
358 // The macro declares fn items at module scope.
359 crate::thread_local_content_memo! {
360 /// test memo
361 TEST_MEMO: u32 => String;
362 fn tl_get;
363 fn tl_clear;
364 }
365
366 #[test]
367 fn macro_accessor_memoizes_and_clears() {
368 tl_clear();
369 let a = tl_get(3, || "three".to_string());
370 let b = tl_get(3, || "nope".to_string());
371 assert!(std::rc::Rc::ptr_eq(&a, &b));
372 assert_eq!(&*a, "three");
373 tl_clear();
374 let c = tl_get(3, || "again".to_string());
375 assert_eq!(&*c, "again", "after clear the key recomputes");
376 }
377
378 // --- M3: ContentKey<T> (the key↔content decoupling seal) ---
379
380 #[derive(Hash)]
381 struct Input {
382 name: String,
383 n: u32,
384 }
385
386 #[test]
387 fn content_key_of_is_deterministic() {
388 let a = Input {
389 name: "x".into(),
390 n: 5,
391 };
392 let b = Input {
393 name: "x".into(),
394 n: 5,
395 };
396 // Same structural content → identical key (the "key IS the content"
397 // invariant, holding by construction).
398 assert_eq!(ContentKey::of(&a), ContentKey::of(&b));
399 assert_eq!(ContentKey::of(&a).digest(), ContentKey::of(&b).digest());
400 // Re-deriving the same value is stable across calls.
401 assert_eq!(ContentKey::of(&a), ContentKey::of(&a));
402 }
403
404 #[test]
405 fn content_key_differs_on_different_content() {
406 let a = Input {
407 name: "x".into(),
408 n: 5,
409 };
410 let differ_n = Input {
411 name: "x".into(),
412 n: 6,
413 };
414 let differ_name = Input {
415 name: "y".into(),
416 n: 5,
417 };
418 assert_ne!(ContentKey::of(&a), ContentKey::of(&differ_n));
419 assert_ne!(ContentKey::of(&a), ContentKey::of(&differ_name));
420 }
421
422 #[test]
423 fn keyed_memo_round_trips_and_does_not_recompute_on_hit() {
424 let memo: ContentMemo<ContentKey<Input>, String> = ContentMemo::new();
425 let mut calls = 0;
426 let input = Input {
427 name: "hello".into(),
428 n: 2,
429 };
430
431 // Miss: compute runs, receives the SAME &input the key was derived from.
432 let a = memo.get_or_compute_keyed(&input, |i| {
433 calls += 1;
434 assert_eq!(i.name, "hello");
435 assert_eq!(i.n, 2);
436 format!("{}-{}", i.name, i.n)
437 });
438 assert_eq!(&*a, "hello-2");
439
440 // Hit on a structurally-equal but DISTINCT value: same content key, no
441 // recompute — the key is the content, not the object identity.
442 let same_content = Input {
443 name: "hello".into(),
444 n: 2,
445 };
446 let b = memo.get_or_compute_keyed(&same_content, |_| {
447 calls += 1;
448 "SHOULD-NOT-RUN".to_string()
449 });
450 assert_eq!(calls, 1, "structurally-equal input must hit, not recompute");
451 assert!(std::rc::Rc::ptr_eq(&a, &b), "hit returns the same Rc");
452
453 // Different content → miss → recompute.
454 let other = Input {
455 name: "world".into(),
456 n: 9,
457 };
458 let c = memo.get_or_compute_keyed(&other, |i| {
459 calls += 1;
460 format!("{}-{}", i.name, i.n)
461 });
462 assert_eq!(calls, 2);
463 assert_eq!(&*c, "world-9");
464 assert_eq!(memo.len(), 2);
465 }
466}