game_player/
transposition_table.rs

1//! Transposition Table Module
2//!
3//! This module implements a transposition table, which is a map of game state values referenced by the states' fingerprints.
4
5/// A map of game state values referenced by the states' fingerprints.
6///
7/// A game state can be the result of different sequences of the same (or a different) set of moves. This technique is used to
8/// cache the value of a game state regardless of the moves used to reach it, thus the name "transposition" table. The purpose of
9/// the "transposition" table has been extended to become simply a cache of game state values, so it is more aptly named "game
10/// state value cache" -- but the old name persists.
11///
12/// As a speed and memory optimization in this implementation, slots in the table are not unique to the state being stored, and a
13/// value may be overwritten when a new value is added. A value is overwritten only when its "quality" is less than or equal to the
14/// "quality" of the value being added.
15///
16/// # Note
17/// The fingerprint is assumed to be a random and uniformly distributed 64-bit value. It is assumed to never be u64::MAX.
18///
19/// # Examples
20///
21/// ```rust
22/// # use game_player::transposition_table::TranspositionTable;
23/// let mut table = TranspositionTable::new(1000, 100);
24///
25/// // Store a value
26/// table.update(12345, 0.75, 5);
27///
28/// // Retrieve the value
29/// if let Some((value, quality)) = table.check(12345, 0) {
30///     assert_eq!(value, 0.75);
31///     assert_eq!(quality, 5);
32/// }
33/// ```
34pub struct TranspositionTable {
35    /// The table of entries
36    table: Vec<Entry>,
37    /// The maximum age of entries allowed in the table
38    max_age: i16,
39}
40
41// Table entry
42//
43// A note about age and quality: There are expected to be collisions in the table, so the quality is used to determine if a new
44// entry should replace an existing one. Now, an entry that has not been referenced for a while will probably never be
45// referenced again, so it should eventually be allowed to be replaced by a newer entry, regardless of the quality of the new
46// entry.
47#[derive(Clone)]
48#[repr(C, packed)] // 16 bytes
49struct Entry {
50    fingerprint: u64, // The state's fingerprint
51    value: f32,       // The state's value
52    q: i16,           // The quality of the value
53    age: i16,         // The number of turns since the entry has been referenced
54}
55
56impl Entry {
57    const UNUSED: u64 = u64::MAX;
58
59    fn clear(&mut self) {
60        self.fingerprint = Self::UNUSED;
61    }
62}
63
64impl Default for Entry {
65    fn default() -> Self {
66        Self {
67            fingerprint: Self::UNUSED,
68            value: 0.0,
69            q: 0,
70            age: 0,
71        }
72    }
73}
74
75// Check that the size of Entry is 16 bytes. The size is not required to be 16 bytes, but 16 bytes is an optimal size.
76static_assertions::assert_eq_size!(f32, [u8; 4]); // float should be 32 bits
77static_assertions::assert_eq_size!(Entry, [u8; 16]); // Entry should be 16 bytes
78
79impl TranspositionTable {
80    /// Creates a new TranspositionTable
81    ///
82    /// # Arguments
83    /// * `size` - Number of entries in the table
84    /// * `max_age` - Maximum age of entries allowed in the table
85    ///
86    /// # Examples
87    ///
88    /// ```rust
89    /// # use game_player::transposition_table::TranspositionTable;
90    /// let table = TranspositionTable::new(1000, 50);
91    /// // Table is ready to use with 1000 entries and max age of 50
92    /// ```
93    ///
94    /// # Panics
95    ///
96    /// Panics if `size` is 0 or `max_age` is 0 or negative.
97    ///
98    /// ```should_panic
99    /// # use game_player::transposition_table::TranspositionTable;
100    /// let table = TranspositionTable::new(0, 50); // This will panic
101    /// ```
102    pub fn new(size: usize, max_age: i16) -> Self {
103        assert!(size > 0);
104        assert!(max_age > 0);
105        Self {
106            table: vec![Entry::default(); size],
107            max_age,
108        }
109    }
110
111    /// Returns the value and quality of a state if they are stored in the table and its quality is above the specified minimum (if
112    /// specified). Otherwise, None is returned.
113    ///
114    /// # Arguments
115    /// * `fingerprint` - Fingerprint of state to be checked for
116    /// * `min_q` - Minimum quality. If less than 0, it is not used.
117    ///
118    /// # Returns
119    /// optional result as (value, quality)
120    ///
121    /// # Panics
122    /// Panics if `fingerprint` is `u64::MAX`.
123    ///
124    /// # Side Effects
125    /// * Resets the age of the entry to 0 if found.
126    ///
127    /// # Examples
128    /// ```rust
129    /// # use game_player::transposition_table::TranspositionTable;
130    /// let mut table = TranspositionTable::new(100, 10);
131    ///
132    /// // Store a value with quality 5
133    /// table.update(12345, 1.5, 5);
134    ///
135    /// // Check with no minimum quality
136    /// assert_eq!(table.check(12345, -1), Some((1.5, 5)));
137    ///
138    /// // Check with minimum quality of 3 (should succeed)
139    /// assert_eq!(table.check(12345, 3), Some((1.5, 5)));
140    ///
141    /// // Check with minimum quality of 10 (should fail)
142    /// assert_eq!(table.check(12345, 10), None);
143    ///
144    /// // Check non-existent entry
145    /// assert_eq!(table.check(99999, -1), None);
146    /// ```
147    pub fn check(&mut self, fingerprint: u64, min_q: i16) -> Option<(f32, i16)> {
148        assert_ne!(fingerprint, Entry::UNUSED, "fingerprint != u64::MAX");
149
150        // Find the entry
151        let entry = self.find(fingerprint);
152        if entry.fingerprint != fingerprint {
153            return None; // Not found
154        }
155
156        // The entry was accessed so reset its age
157        entry.age = 0;
158
159        // Check the quality if min_q >= 0
160        if min_q >= 0 && entry.q < min_q {
161            return None; // Insufficient quality
162        }
163
164        Some((entry.value, entry.q))
165    }
166
167    /// Updates (or adds) an entry in the table if its quality is greater than or equal to the existing entry's quality
168    ///
169    /// # Arguments
170    /// * `fingerprint` - Fingerprint of state to be stored
171    /// * `value` - Value to be stored
172    /// * `quality` - Quality of the value
173    ///
174    /// # Panics
175    /// Panics if `fingerprint` is `u64::MAX`.
176    /// Panics if `quality` is negative.
177    ///
178    /// # Examples
179    /// ```rust
180    /// # use game_player::transposition_table::TranspositionTable;
181    /// let mut table = TranspositionTable::new(100, 10);
182    ///
183    /// // Add a new entry
184    /// table.update(12345, 1.0, 5);
185    /// assert_eq!(table.check(12345, -1), Some((1.0, 5)));
186    ///
187    /// // Try to update with lower quality (should not replace)
188    /// table.update(12345, 2.0, 3);
189    /// assert_eq!(table.check(12345, -1), Some((1.0, 5))); // Original value
190    ///
191    /// // Update with higher quality (should replace)
192    /// table.update(12345, 2.0, 7);
193    /// assert_eq!(table.check(12345, -1), Some((2.0, 7))); // New value
194    /// ```
195    pub fn update(&mut self, fingerprint: u64, value: f32, quality: i16) {
196        assert_ne!(fingerprint, Entry::UNUSED, "fingerprint != u64::MAX");
197        assert!(quality >= 0);
198
199        // Find the entry for the fingerprint
200        let entry = self.find(fingerprint);
201        let is_unused = entry.fingerprint == Entry::UNUSED;
202
203        // If the entry is unused or if the new quality >= the stored quality, then store the new value. Note: It is assumed to be
204        // better to replace values of equal quality in order to dispose of old entries that are less likely to be relevant.
205
206        if is_unused || quality >= entry.q {
207            *entry = Entry {
208                fingerprint,
209                value,
210                q: quality,
211                age: 0,
212            };
213        }
214    }
215
216    /// Sets an entry in the table.
217    ///
218    /// This method adds or updates an entry in the table, regardless of its quality.
219    ///
220    /// # Arguments
221    /// * `fingerprint` - Fingerprint of state to be stored
222    /// * `value` - Value to be stored
223    /// * `quality` - Quality of the value
224    ///
225    /// # Panics
226    /// Panics if `fingerprint` is `u64::MAX`.
227    /// Panics if `quality` is negative.
228    ///
229    /// # Examples
230    /// ```rust
231    /// # use game_player::transposition_table::TranspositionTable;
232    /// let mut table = TranspositionTable::new(100, 10);
233    ///
234    /// // Set an entry
235    /// table.set(12345, 1.5, 5);
236    /// assert_eq!(table.check(12345, -1), Some((1.5, 5)));
237    ///
238    /// // Set again with lower quality (should still replace)
239    /// table.set(12345, 2.5, 3);
240    /// assert_eq!(table.check(12345, -1), Some((2.5, 3)));
241    /// ```
242    pub fn set(&mut self, fingerprint: u64, value: f32, quality: i16) {
243        assert_ne!(fingerprint, Entry::UNUSED, "fingerprint != u64::MAX");
244        assert!(quality >= 0);
245
246        // Find the entry for the fingerprint
247        let entry = self.find(fingerprint);
248
249        // Store the state, value and quality
250        *entry = Entry {
251            fingerprint,
252            value,
253            q: quality,
254            age: 0,
255        };
256    }
257
258    /// The T-table is persistent. So in order to gradually dispose of entries that are no longer relevant, entries that have not
259    /// been referenced for a while are removed.
260    ///
261    /// This method increments the age of all entries and removes entries that exceed the maximum age.
262    ///
263    /// # Examples
264    ///
265    /// ```rust
266    /// # use game_player::transposition_table::TranspositionTable;
267    /// let mut table = TranspositionTable::new(100, 2); // max_age = 2
268    ///
269    /// // Add an entry
270    /// table.set(12345, 1.0, 5);
271    /// assert_eq!(table.check(12345, -1), Some((1.0, 5)));
272    ///
273    /// // Age the table once - entry should still be there
274    /// table.age();
275    /// assert_eq!(table.check(12345, -1), Some((1.0, 5))); // Age reset by check
276    ///
277    /// // Age twice more without accessing - entry should be removed
278    /// table.age();
279    /// table.age();
280    /// table.age();
281    /// assert_eq!(table.check(12345, -1), None); // Entry aged out
282    /// ```
283    pub fn age(&mut self) {
284        self.table
285            .iter_mut()
286            .filter(|entry| entry.fingerprint != Entry::UNUSED)
287            .for_each(|entry| {
288                entry.age += 1;
289                if entry.age > self.max_age {
290                    entry.clear();
291                }
292            });
293    }
294
295    // Find the entry slot for a fingerprint using simple modulo hashing
296    fn find(&mut self, hash: u64) -> &mut Entry {
297        let i = (hash as usize) % self.table.len();
298        &mut self.table[i]
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_new_valid_parameters() {
308        let table = TranspositionTable::new(100, 10);
309        assert_eq!(table.table.len(), 100);
310        assert_eq!(table.max_age, 10);
311    }
312
313    #[test]
314    #[should_panic(expected = "assertion failed: size > 0")]
315    fn test_new_zero_size_panics() {
316        TranspositionTable::new(0, 10);
317    }
318
319    #[test]
320    #[should_panic(expected = "assertion failed: max_age > 0")]
321    fn test_new_zero_max_age_panics() {
322        TranspositionTable::new(100, 0);
323    }
324
325    #[test]
326    #[should_panic(expected = "assertion failed: max_age > 0")]
327    fn test_new_negative_max_age_panics() {
328        TranspositionTable::new(100, -1);
329    }
330
331    #[test]
332    fn test_check_nonexistent_entry() {
333        let mut table = TranspositionTable::new(100, 10);
334        assert_eq!(table.check(12345, -1), None);
335        assert_eq!(table.check(12345, 0), None);
336        assert_eq!(table.check(12345, 5), None);
337    }
338
339    #[test]
340    fn test_update_and_check_basic() {
341        let mut table = TranspositionTable::new(100, 10);
342
343        // Add an entry
344        table.update(12345, 1.5, 5);
345
346        // Check it's there
347        assert_eq!(table.check(12345, -1), Some((1.5, 5)));
348        assert_eq!(table.check(12345, 0), Some((1.5, 5)));
349        assert_eq!(table.check(12345, 5), Some((1.5, 5)));
350    }
351
352    #[test]
353    fn test_check_minimum_quality() {
354        let mut table = TranspositionTable::new(100, 10);
355
356        table.update(12345, 2.0, 5);
357
358        // Check with various minimum qualities
359        assert_eq!(table.check(12345, -1), Some((2.0, 5))); // No minimum
360        assert_eq!(table.check(12345, 0), Some((2.0, 5))); // Below stored quality
361        assert_eq!(table.check(12345, 3), Some((2.0, 5))); // Below stored quality
362        assert_eq!(table.check(12345, 5), Some((2.0, 5))); // Equal to stored quality
363        assert_eq!(table.check(12345, 6), None); // Above stored quality
364        assert_eq!(table.check(12345, 10), None); // Well above stored quality
365    }
366
367    #[test]
368    fn test_update_quality_replacement_rules() {
369        let mut table = TranspositionTable::new(100, 10);
370
371        // Add initial entry
372        table.update(12345, 1.0, 5);
373        assert_eq!(table.check(12345, -1), Some((1.0, 5)));
374
375        // Try to update with lower quality (should not replace)
376        table.update(12345, 2.0, 3);
377        assert_eq!(table.check(12345, -1), Some((1.0, 5))); // Original value
378
379        // Update with equal quality (should replace)
380        table.update(12345, 3.0, 5);
381        assert_eq!(table.check(12345, -1), Some((3.0, 5))); // New value
382
383        // Update with higher quality (should replace)
384        table.update(12345, 4.0, 7);
385        assert_eq!(table.check(12345, -1), Some((4.0, 7))); // New value
386    }
387
388    #[test]
389    fn test_set_always_replaces() {
390        let mut table = TranspositionTable::new(100, 10);
391
392        // Add initial entry
393        table.set(12345, 1.0, 5);
394        assert_eq!(table.check(12345, -1), Some((1.0, 5)));
395
396        // Set with lower quality (should still replace)
397        table.set(12345, 2.0, 3);
398        assert_eq!(table.check(12345, -1), Some((2.0, 3)));
399
400        // Set with higher quality (should replace)
401        table.set(12345, 3.0, 7);
402        assert_eq!(table.check(12345, -1), Some((3.0, 7)));
403    }
404
405    #[test]
406    fn test_age_resets_on_check() {
407        let mut table = TranspositionTable::new(100, 2);
408
409        // Add an entry
410        table.set(12345, 1.0, 5);
411
412        // Age once
413        table.age();
414
415        // Check (should reset age)
416        assert_eq!(table.check(12345, -1), Some((1.0, 5)));
417    }
418
419    #[test]
420    fn test_age_removes_old_entries() {
421        let mut table = TranspositionTable::new(100, 2);
422
423        // Add an entry
424        table.set(12345, 1.0, 5);
425        assert_eq!(table.check(12345, -1), Some((1.0, 5)));
426
427        // Age beyond max_age without accessing
428        table.age(); // age = 1
429        table.age(); // age = 2
430        table.age(); // age = 3, should be removed (> max_age = 2)
431
432        // Entry should be gone
433        assert_eq!(table.check(12345, -1), None);
434    }
435
436    #[test]
437    fn test_multiple_entries() {
438        let mut table = TranspositionTable::new(100, 10);
439
440        // Add multiple entries
441        table.update(1, 1.0, 1);
442        table.update(2, 2.0, 2);
443        table.update(3, 3.0, 3);
444
445        // Check all entries exist
446        assert_eq!(table.check(1, -1), Some((1.0, 1)));
447        assert_eq!(table.check(2, -1), Some((2.0, 2)));
448        assert_eq!(table.check(3, -1), Some((3.0, 3)));
449
450        // Check non-existent entry
451        assert_eq!(table.check(4, -1), None);
452    }
453
454    #[test]
455    fn test_hash_collision_handling() {
456        // Create a small table to force collisions
457        let mut table = TranspositionTable::new(1, 10); // Only 1 slot
458
459        // Add first entry
460        table.update(1, 1.0, 5);
461        assert_eq!(table.check(1, -1), Some((1.0, 5)));
462
463        // Add second entry that will hash to same slot
464        // Since we have quality-based replacement, this should replace
465        table.update(2, 2.0, 5);
466        assert_eq!(table.check(2, -1), Some((2.0, 5)));
467        assert_eq!(table.check(1, -1), None); // First entry should be gone
468    }
469
470    #[test]
471    fn test_floating_point_values() {
472        let mut table = TranspositionTable::new(100, 10);
473
474        // Test various floating point values
475        let test_values = [
476            0.0,
477            -0.0,
478            1.0,
479            -1.0,
480            std::f32::consts::PI,
481            -std::f32::consts::E,
482            f32::MIN,
483            f32::MAX,
484            f32::EPSILON,
485            -f32::EPSILON,
486        ];
487
488        for (i, &value) in test_values.iter().enumerate() {
489            let fingerprint = (i + 1) as u64;
490            table.update(fingerprint, value, 1);
491            assert_eq!(table.check(fingerprint, -1), Some((value, 1)));
492        }
493    }
494
495    #[test]
496    fn test_edge_case_qualities() {
497        let mut table = TranspositionTable::new(100, 10);
498
499        // Test with quality 0
500        table.update(1, 1.0, 0);
501        assert_eq!(table.check(1, -1), Some((1.0, 0)));
502        assert_eq!(table.check(1, 0), Some((1.0, 0)));
503        assert_eq!(table.check(1, 1), None);
504
505        // Test with high quality
506        table.update(2, 2.0, i16::MAX);
507        assert_eq!(table.check(2, -1), Some((2.0, i16::MAX)));
508        assert_eq!(table.check(2, i16::MAX), Some((2.0, i16::MAX)));
509    }
510
511    #[test]
512    #[should_panic(expected = "assertion `left != right` failed: fingerprint != u64::MAX")]
513    fn test_check_with_invalid_fingerprint() {
514        let mut table = TranspositionTable::new(100, 10);
515        table.check(u64::MAX, -1);
516    }
517
518    #[test]
519    #[should_panic(expected = "assertion `left != right` failed: fingerprint != u64::MAX")]
520    fn test_update_with_invalid_fingerprint() {
521        let mut table = TranspositionTable::new(100, 10);
522        table.update(u64::MAX, 1.0, 5);
523    }
524
525    #[test]
526    #[should_panic(expected = "assertion failed: quality >= 0")]
527    fn test_update_with_negative_quality() {
528        let mut table = TranspositionTable::new(100, 10);
529        table.update(12345, 1.0, -1);
530    }
531
532    #[test]
533    #[should_panic(expected = "assertion `left != right` failed: fingerprint != u64::MAX")]
534    fn test_set_with_invalid_fingerprint() {
535        let mut table = TranspositionTable::new(100, 10);
536        table.set(u64::MAX, 1.0, 5);
537    }
538
539    #[test]
540    #[should_panic(expected = "assertion failed: quality >= 0")]
541    fn test_set_with_negative_quality() {
542        let mut table = TranspositionTable::new(100, 10);
543        table.set(12345, 1.0, -1);
544    }
545
546    #[test]
547    fn test_large_table() {
548        let mut table = TranspositionTable::new(10000, 100);
549
550        // Add many entries
551        for i in 1..=1000 {
552            table.update(i, i as f32 * 0.1, (i % 20) as i16);
553        }
554
555        // Check some entries exist
556        assert_eq!(table.check(1, -1), Some((0.1, 1)));
557        assert_eq!(table.check(500, -1), Some((50.0, 0)));
558        assert_eq!(table.check(1000, -1), Some((100.0, 0)));
559    }
560
561    #[test]
562    fn test_aging_multiple_entries() {
563        let mut table = TranspositionTable::new(100, 3);
564
565        // Add multiple entries
566        table.set(1, 1.0, 1);
567        table.set(2, 2.0, 2);
568        table.set(3, 3.0, 3);
569
570        // Age twice
571        table.age();
572        table.age();
573
574        // Access one entry to reset its age
575        assert_eq!(table.check(2, -1), Some((2.0, 2)));
576
577        // Age beyond max_age
578        table.age();
579        table.age();
580
581        // Only the accessed entry should remain
582        assert_eq!(table.check(1, -1), None);
583        assert_eq!(table.check(2, -1), Some((2.0, 2)));
584        assert_eq!(table.check(3, -1), None);
585    }
586}