Skip to main content

kache_store/
eviction.rs

1//! Eviction *selection*, decoupled from the removal *mechanism*
2//! (kunobi-ninja/kache#595).
3//!
4//! `Store` historically had three eviction entry points — size-pressure,
5//! age-based, and content-duplicate — each with its own hardcoded `SELECT`,
6//! all funnelling into the same `remove_entry_guarded`. Mechanism was already
7//! unified; selection was not.
8//!
9//! This module makes selection a pure function of observable per-entry
10//! features. Two consequences matter:
11//!
12//! - **Shadow evaluation.** A candidate policy can rank the same candidate set
13//!   and log what it *would* evict, while the live policy decides what actually
14//!   goes. That comparison is what #594 needs, and it is impractical while the
15//!   ranking is an `ORDER BY` clause inside the removal loop.
16//! - **Signals beyond SQL columns.** Selection written as SQL can only see
17//!   columns of `entries`; `compile_time_ms` lives in each entry's `meta.json`
18//!   and is therefore invisible to eviction today (#594). With features
19//!   assembled in Rust, adding a signal is a struct field.
20//!
21//! Policies never remove anything. The pin/grace check, blob refcount
22//! decrement, and refuse-on-corrupt-meta guard stay in
23//! `Store::remove_entry_guarded` — a policy that could bypass them would
24//! reintroduce the mid-restore eviction race (#326, #182).
25
26use std::cmp::Ordering;
27use std::collections::HashMap;
28
29/// Percentage of `max_size` a size-pressure sweep evicts *down to*.
30pub const EVICT_TARGET_PERCENT: u64 = 90;
31
32/// The low edge of GC's hysteresis band: the size a sweep evicts down to.
33///
34/// Compute through `u128` so arbitrary byte-sized limits get an exact floor
35/// without overflowing near `u64::MAX`.
36pub fn eviction_target(max_size: u64) -> u64 {
37    ((max_size as u128 * EVICT_TARGET_PERCENT as u128) / 100) as u64
38}
39
40/// The high edge of GC's hysteresis band: the size a sweep *fires* at.
41///
42/// Trigger and target were both [`eviction_target`], so "evict to 90% — avoids
43/// boundary thrashing" described a band that did not exist: a store parked on
44/// the 90% line re-triggered on essentially every pass, each one evicting just
45/// enough to dip under before the next `put` pushed it back over. Firing at the
46/// configured cap and stopping at 90% gives the sweep the 10% of headroom the
47/// comment always claimed.
48pub fn over_eviction_trigger(physical_size: u64, max_size: u64) -> bool {
49    physical_size > max_size
50}
51
52/// The observable state of one cache entry, as seen by an eviction policy.
53///
54/// Deliberately small: it is materialized for every entry in the store on each
55/// sweep, so new fields should earn their bytes.
56#[derive(Debug, Clone)]
57pub struct EntryFeatures {
58    pub key: String,
59    pub size: i64,
60    pub hit_count: i64,
61    /// Hours since `last_accessed`. May be negative under clock skew; policies
62    /// are responsible for clamping (the previous SQL clamped at 0.01).
63    pub idle_hours: f64,
64    /// Content address of the entry's artifact set, when known.
65    pub content_hash: Option<String>,
66    pub committed: bool,
67    /// What a miss on this entry would cost to rebuild, in milliseconds
68    /// (kunobi-ninja/kache#594). `0` means unknown — either a pre-#594 entry
69    /// not yet backfilled, or a compile too fast to register. No policy ships
70    /// today that reads this; it is here so a value-aware policy can be
71    /// written and shadow-evaluated against the current one.
72    pub compile_time_ms: i64,
73    /// Physical bytes evicting this entry would actually free right now:
74    /// the sizes of blobs it holds every remaining reference to
75    /// (kunobi-ninja/kache#608). Zero for an entry whose blobs are all
76    /// shared — evicting it destroys rebuild value and reclaims nothing.
77    /// `None` means unknown (entry not yet backfilled into `entry_blobs`);
78    /// policies fall back to the logical `size`.
79    pub reclaimable_bytes: Option<i64>,
80    /// Recent at candidate selection time. GC skips their metadata read and
81    /// transaction setup; removal checks the grace again for other entries.
82    pub recently_accessed: bool,
83    /// Delivered by the remote within [`crate::store::IMPORT_PIN`], judged for
84    /// an automatic sweep; always false for a sweep the user asked for.
85    /// Eviction keeps it (kunobi-ninja/kache#1008).
86    pub recently_imported: bool,
87}
88
89/// Ranks or filters eviction candidates. Pure: no I/O, no store mutation.
90pub trait EvictionPolicy {
91    /// Stable identifier, for logging and shadow-mode comparison.
92    fn name(&self) -> &'static str;
93
94    /// Candidates to evict, worst-first. The caller stops early when a size
95    /// budget is satisfied, so a policy may return more keys than are needed.
96    fn select(&self, candidates: &[EntryFeatures]) -> Vec<String>;
97}
98
99/// Priority score for size-pressure eviction; **lower is evicted first**.
100///
101/// `(hit_count + 1) / (idle_hours * reclaimable_mb)` — prefers evicting stale,
102/// rarely-hit entries whose removal actually frees bytes, degrading to LRU
103/// with a size tiebreaker when ages are similar. The size term is the entry's
104/// *marginal reclaimable* bytes (blobs it holds the last references to), not
105/// its logical size: a 500 MB entry whose blobs are all shared frees nothing,
106/// while a 200 MB entry with unique blobs frees 200 MB, and a size-pressure
107/// sweep exists to free bytes (kunobi-ninja/kache#608). Entries not yet
108/// backfilled into `entry_blobs` (`None`) rank on logical size as before.
109/// The clamps reproduce the `MAX(..., 0.01)` / `MAX(..., 0.001)` of the SQL
110/// this replaced, which exist to keep a just-accessed or free-nothing entry
111/// from dividing by zero — a fully-shared entry thus ranks as if it were
112/// tiny, i.e. last among equally stale candidates.
113///
114/// Note this score has no notion of what an entry costs to *rebuild*; see #594.
115pub fn size_pressure_score(e: &EntryFeatures) -> f64 {
116    let idle = e.idle_hours.max(0.01);
117    let bytes = e.reclaimable_bytes.unwrap_or(e.size);
118    let size_mb = (bytes as f64 / 1_048_576.0).max(0.001);
119    (e.hit_count as f64 + 1.0) / (idle * size_mb)
120}
121
122/// Size-pressure eviction: rank every entry by [`size_pressure_score`].
123pub struct SizePressurePolicy;
124
125impl EvictionPolicy for SizePressurePolicy {
126    fn name(&self) -> &'static str {
127        "size-pressure"
128    }
129
130    fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
131        let mut ranked: Vec<&EntryFeatures> = candidates.iter().collect();
132        // Total order: NaN cannot arise from the clamped score, but sort_by
133        // demands a total comparator, so fall back to Equal rather than panic.
134        ranked.sort_by(|a, b| {
135            size_pressure_score(a)
136                .partial_cmp(&size_pressure_score(b))
137                .unwrap_or(Ordering::Equal)
138        });
139        ranked.into_iter().map(|e| e.key.clone()).collect()
140    }
141}
142
143/// Retention value per reclaimable byte; **lower is evicted first**
144/// (kunobi-ninja/kache#594, shadow candidate — never the live policy yet).
145///
146/// `compile_time_ms / reclaimable_mb`: what a re-miss on this entry costs to
147/// rebuild, per byte its eviction would actually free. This is deliberately
148/// the PURE density score from the #594 analysis, with no recency or
149/// hit-count terms bolted on: reuse probability belongs as a multiplier
150/// estimated from the tombstone demand stream, not as an arbitrary divisor —
151/// and the demand stream this shadow feeds is exactly what will estimate it.
152///
153/// Two knowingly rough edges, acceptable because this only ever shadows:
154/// entries whose `compile_time_ms` is still 0 (not yet backfilled, or a
155/// sub-millisecond compile) rank as worthless and become the shadow's first
156/// victims, and the serve-from-cache cost is treated as negligible rather
157/// than subtracted from the rebuild cost.
158pub fn value_density_score(e: &EntryFeatures) -> f64 {
159    let bytes = e.reclaimable_bytes.unwrap_or(e.size);
160    let size_mb = (bytes as f64 / 1_048_576.0).max(0.001);
161    e.compile_time_ms as f64 / size_mb
162}
163
164/// Pure rebuild-cost-density ranking — the #594 shadow candidate.
165pub struct ValueDensityPolicy;
166
167impl EvictionPolicy for ValueDensityPolicy {
168    fn name(&self) -> &'static str {
169        "value-density"
170    }
171
172    fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
173        let mut ranked: Vec<&EntryFeatures> = candidates.iter().collect();
174        ranked.sort_by(|a, b| {
175            value_density_score(a)
176                .partial_cmp(&value_density_score(b))
177                .unwrap_or(Ordering::Equal)
178        });
179        ranked.into_iter().map(|e| e.key.clone()).collect()
180    }
181}
182
183/// The set of keys a policy's ranking would evict to free `bytes_needed` —
184/// the shadow half of a #594 comparison. Walks `order` accumulating each
185/// entry's *expected* reclaim (its marginal reclaimable bytes, logical size
186/// when unknown) until the budget is met.
187///
188/// An approximation over the pre-sweep snapshot, not a simulation: the live
189/// walk budgets on bytes each removal ACTUALLY freed, where a shared blob's
190/// bytes materialize only when its last surviving twin goes, while this walk
191/// sees every twin's marginal bytes as the snapshot reported them (zero for
192/// all of them) and never updates as it goes. Unknown reclaim falls back to
193/// logical size, which can overestimate progress and shorten the prefix, and
194/// entries the live sweep skips (recency-pinned, lost removal races) still
195/// consume this budget. Good enough for a per-entry agreement verdict;
196/// a faithful counterfactual sweep would need dynamically updated blob
197/// refcounts and the live eligibility rules (#594 follow-up territory).
198pub fn would_evict_for_budget(
199    candidates: &[EntryFeatures],
200    order: &[String],
201    bytes_needed: u64,
202) -> std::collections::HashSet<String> {
203    let by_key: HashMap<&str, &EntryFeatures> =
204        candidates.iter().map(|e| (e.key.as_str(), e)).collect();
205    let mut victims = std::collections::HashSet::new();
206    let mut freed: u64 = 0;
207    for key in order {
208        if freed >= bytes_needed {
209            break;
210        }
211        let Some(e) = by_key.get(key.as_str()) else {
212            continue;
213        };
214        freed += e.reclaimable_bytes.unwrap_or(e.size).max(0) as u64;
215        victims.insert(key.clone());
216    }
217    victims
218}
219
220/// Age-based eviction: every entry untouched for more than `hours`.
221///
222/// Replaces `WHERE last_accessed < datetime('now', '-N hours')`. One deliberate
223/// difference: `datetime()` compares at whole-second granularity, whereas
224/// `idle_hours` comes from `julianday()` and is sub-second. An entry sitting
225/// within the same second as the cutoff is therefore now selected where the
226/// old query kept it. Immaterial for a retention sweep measured in hours, and
227/// the finer comparison is the more truthful one, but it is a difference and
228/// not a pure refactor.
229pub struct OlderThanPolicy {
230    pub hours: u64,
231}
232
233impl EvictionPolicy for OlderThanPolicy {
234    fn name(&self) -> &'static str {
235        "older-than"
236    }
237
238    fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
239        let cutoff = self.hours as f64;
240        candidates
241            .iter()
242            .filter(|e| e.idle_hours > cutoff)
243            .map(|e| e.key.clone())
244            .collect()
245    }
246}
247
248/// Content-duplicate eviction: when several committed entries share one
249/// `content_hash`, keep the most recently accessed and evict the rest —
250/// but only where removal actually frees bytes.
251///
252/// Ties at the group maximum are all kept, reproducing the strict `<` of the
253/// previous `WHERE e.last_accessed < dups.newest_access`.
254///
255/// Blobs are refcounted and shared, so deleting a key whose blobs are all held
256/// by another entry destroys hit history without reclaiming space. Candidates
257/// are eligible only when backfill proves a positive marginal reclaim. Unknown
258/// legacy rows fail closed until a later bounded backfill maps them.
259pub struct DuplicatePolicy;
260
261impl EvictionPolicy for DuplicatePolicy {
262    fn name(&self) -> &'static str {
263        "duplicate"
264    }
265
266    fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
267        // Group committed, content-addressed entries by content hash. Idle time
268        // is the inverse of recency, so the group's *minimum* idle is its most
269        // recently accessed member.
270        let mut newest: HashMap<&str, f64> = HashMap::new();
271        let mut counts: HashMap<&str, usize> = HashMap::new();
272        for e in candidates {
273            if !e.committed {
274                continue;
275            }
276            let Some(hash) = e.content_hash.as_deref() else {
277                continue;
278            };
279            *counts.entry(hash).or_insert(0) += 1;
280            newest
281                .entry(hash)
282                .and_modify(|m| *m = m.min(e.idle_hours))
283                .or_insert(e.idle_hours);
284        }
285
286        let mut eligible: Vec<&EntryFeatures> = candidates
287            .iter()
288            .filter(|e| {
289                if !e.committed {
290                    return false;
291                }
292                let Some(hash) = e.content_hash.as_deref() else {
293                    return false;
294                };
295                // Removing this key must actually free bytes; a fully shared
296                // entry costs its hit history and reclaims nothing.
297                if !matches!(e.reclaimable_bytes, Some(bytes) if bytes > 0) {
298                    return false;
299                }
300                // Only groups with a genuine duplicate, and only members
301                // strictly older than the group's newest access.
302                counts.get(hash).copied().unwrap_or(0) > 1
303                    && newest
304                        .get(hash)
305                        .is_some_and(|newest_idle| e.idle_hours > *newest_idle)
306            })
307            .collect();
308
309        // The removal walk may stop once its byte target is met, so return
310        // victims worst-first: stalest duplicate first, then key order for a
311        // stable choice among equally old entries.
312        eligible.sort_by(|a, b| {
313            b.idle_hours
314                .total_cmp(&a.idle_hours)
315                .then_with(|| a.key.cmp(&b.key))
316        });
317        eligible.into_iter().map(|e| e.key.clone()).collect()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn eviction_target_is_exact_for_arbitrary_limits_without_overflow() {
327        assert_eq!(eviction_target(1_010), 909);
328        assert_eq!(
329            eviction_target(u64::MAX),
330            ((u64::MAX as u128 * EVICT_TARGET_PERCENT as u128) / 100) as u64
331        );
332    }
333
334    #[test]
335    fn eviction_trigger_is_strictly_above_the_configured_cap() {
336        assert!(!over_eviction_trigger(999, 1_000));
337        assert!(!over_eviction_trigger(1_000, 1_000));
338        assert!(over_eviction_trigger(1_001, 1_000));
339    }
340
341    fn feat(key: &str, size: i64, hits: i64, idle: f64) -> EntryFeatures {
342        EntryFeatures {
343            key: key.into(),
344            size,
345            hit_count: hits,
346            idle_hours: idle,
347            content_hash: None,
348            committed: true,
349            compile_time_ms: 0,
350            reclaimable_bytes: None,
351            recently_accessed: false,
352            recently_imported: false,
353        }
354    }
355
356    /// The score must rank a large, stale, unused entry below a small, fresh,
357    /// frequently-hit one — the ordering the size-pressure sweep depends on.
358    #[test]
359    fn size_pressure_ranks_large_stale_unused_first() {
360        let big_stale = feat("big", 600 * 1024 * 1024, 0, 15.0);
361        let small_hot = feat("small", 14 * 1024, 9, 0.1);
362        assert!(size_pressure_score(&big_stale) < size_pressure_score(&small_hot));
363
364        let order = SizePressurePolicy.select(&[small_hot, big_stale]);
365        assert_eq!(order, vec!["big", "small"]);
366    }
367
368    /// A just-accessed entry (idle ~0) and a zero-size entry must not produce a
369    /// division blow-up — the reason the SQL carried MAX() clamps.
370    #[test]
371    fn size_pressure_score_is_finite_at_the_clamps() {
372        assert!(size_pressure_score(&feat("fresh", 1024, 0, 0.0)).is_finite());
373        assert!(size_pressure_score(&feat("empty", 0, 0, 5.0)).is_finite());
374        // Clock skew must not invert the ordering into +inf.
375        assert!(size_pressure_score(&feat("skewed", 1024, 0, -3.0)).is_finite());
376        // A fully-shared entry (reclaims nothing) hits the same clamp.
377        let mut shared = feat("shared", 1024, 0, 5.0);
378        shared.reclaimable_bytes = Some(0);
379        assert!(size_pressure_score(&shared).is_finite());
380    }
381
382    /// The size term is *marginal reclaimable* bytes, not logical size
383    /// (kunobi-ninja/kache#608): an entry with unique blobs must be evicted
384    /// before an equally stale, larger entry whose blobs are all shared —
385    /// evicting the latter frees nothing.
386    #[test]
387    fn size_pressure_prefers_entries_that_actually_free_bytes() {
388        let mut shared_big = feat("shared_big", 500 * 1024 * 1024, 0, 10.0);
389        shared_big.reclaimable_bytes = Some(0);
390        let mut unique_small = feat("unique_small", 200 * 1024 * 1024, 0, 10.0);
391        unique_small.reclaimable_bytes = Some(200 * 1024 * 1024);
392
393        assert!(size_pressure_score(&unique_small) < size_pressure_score(&shared_big));
394        let order = SizePressurePolicy.select(&[shared_big, unique_small]);
395        assert_eq!(order, vec!["unique_small", "shared_big"]);
396    }
397
398    /// An entry not yet backfilled into `entry_blobs` (None) ranks on its
399    /// logical size — the pre-#608 behavior — rather than as free-nothing.
400    #[test]
401    fn size_pressure_falls_back_to_logical_size_when_reclaimable_unknown() {
402        let unknown = feat("unknown", 600 * 1024 * 1024, 0, 15.0);
403        let small_hot = feat("small", 14 * 1024, 9, 0.1);
404        assert!(size_pressure_score(&unknown) < size_pressure_score(&small_hot));
405    }
406
407    /// kunobi-ninja/kache#594: the shadow candidate ranks by rebuild cost per
408    /// reclaimable byte — a big cheap entry goes before a small expensive
409    /// one, the opposite of what size pressure prefers.
410    #[test]
411    fn value_density_evicts_cheap_per_byte_first() {
412        let mut big_cheap = feat("big_cheap", 600 * 1024 * 1024, 0, 15.0);
413        big_cheap.compile_time_ms = 300; // 0.3s for 600 MB
414        let mut small_expensive = feat("small_expensive", 3 * 1024 * 1024, 0, 15.0);
415        small_expensive.compile_time_ms = 6_000; // 6s for 3 MB
416
417        assert!(value_density_score(&big_cheap) < value_density_score(&small_expensive));
418        let order = ValueDensityPolicy.select(&[small_expensive, big_cheap]);
419        assert_eq!(order, vec!["big_cheap", "small_expensive"]);
420    }
421
422    /// Density divides by marginal reclaimable bytes when known: an entry
423    /// whose blobs are all shared frees nothing, so its retention costs
424    /// nothing and it ranks LAST (infinite-ish density from the clamp).
425    #[test]
426    fn value_density_uses_reclaimable_bytes_and_ranks_unknown_cost_first() {
427        let mut shared = feat("shared", 500 * 1024 * 1024, 0, 10.0);
428        shared.reclaimable_bytes = Some(0);
429        shared.compile_time_ms = 1_000;
430        let mut unique = feat("unique", 10 * 1024 * 1024, 0, 10.0);
431        unique.reclaimable_bytes = Some(10 * 1024 * 1024);
432        unique.compile_time_ms = 1_000;
433        assert!(value_density_score(&unique) < value_density_score(&shared));
434
435        // Zero compile time (not yet backfilled) ranks as worthless — a
436        // documented shadow-only rough edge.
437        let unbackfilled = feat("unbackfilled", 1024, 0, 10.0);
438        assert_eq!(value_density_score(&unbackfilled), 0.0);
439    }
440
441    /// Below the 0.001 MB clamp the size term saturates, so sub-clamp
442    /// entries order purely by rebuild cost. Pins the clamp's placement on
443    /// the MB-converted value: mis-scaling the conversion (which is
444    /// order-preserving everywhere else) un-saturates these and flips them.
445    #[test]
446    fn value_density_orders_sub_clamp_entries_by_cost_alone() {
447        let mut a = feat("a", 500, 0, 1.0);
448        a.compile_time_ms = 1;
449        let mut b = feat("b", 1000, 0, 1.0);
450        b.compile_time_ms = 2;
451        // Both clamp to 0.001 MB: scores are 1000 vs 2000, cheaper first.
452        assert!(value_density_score(&a) < value_density_score(&b));
453        let order = ValueDensityPolicy.select(&[b, a]);
454        assert_eq!(order, vec!["a", "b"]);
455    }
456
457    /// The budget walk takes the ranking's prefix whose expected reclaim
458    /// covers `bytes_needed`, and no more.
459    #[test]
460    fn would_evict_for_budget_takes_the_covering_prefix() {
461        let mut a = feat("a", 100, 0, 1.0);
462        a.reclaimable_bytes = Some(100);
463        let mut b = feat("b", 100, 0, 1.0);
464        b.reclaimable_bytes = Some(0); // fully shared: frees nothing
465        let mut c = feat("c", 100, 0, 1.0);
466        c.reclaimable_bytes = Some(100);
467        let d = feat("d", 100, 0, 1.0); // unknown: expected reclaim = logical 100
468
469        let candidates = vec![a, b, c, d];
470        let order: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
471
472        let victims = would_evict_for_budget(&candidates, &order, 150);
473        // a frees 100 (< 150), b frees 0, c reaches 200 — d is spared.
474        assert!(victims.contains("a") && victims.contains("b") && victims.contains("c"));
475        assert!(!victims.contains("d"));
476
477        assert!(would_evict_for_budget(&candidates, &order, 0).is_empty());
478    }
479
480    #[test]
481    fn older_than_selects_strictly_older_entries() {
482        let c = vec![
483            feat("old", 100, 0, 48.5),
484            feat("exactly", 100, 0, 24.0),
485            feat("fresh", 100, 0, 1.0),
486        ];
487        let picked = OlderThanPolicy { hours: 24 }.select(&c);
488        assert_eq!(
489            picked,
490            vec!["old"],
491            "boundary entry must be kept (strict >)"
492        );
493    }
494
495    /// Keep the most recently accessed member of each content group, evict the
496    /// rest, and never touch a group with only one member.
497    #[test]
498    fn duplicate_keeps_newest_and_ignores_singletons() {
499        let mut a = feat("dup_new", 100, 0, 1.0);
500        let mut b = feat("dup_old", 100, 0, 9.0);
501        let mut c = feat("dup_older", 100, 0, 20.0);
502        let mut lone = feat("lone", 100, 0, 99.0);
503        a.content_hash = Some("h1".into());
504        b.content_hash = Some("h1".into());
505        c.content_hash = Some("h1".into());
506        lone.content_hash = Some("h2".into());
507        b.reclaimable_bytes = Some(100);
508        c.reclaimable_bytes = Some(100);
509
510        let picked = DuplicatePolicy.select(&[a, b, c, lone]);
511        assert_eq!(picked, vec!["dup_older", "dup_old"]);
512    }
513
514    /// Uncommitted entries and entries without a content hash are invisible to
515    /// duplicate eviction — they may be mid-write.
516    #[test]
517    fn duplicate_ignores_uncommitted_and_hashless() {
518        let mut committed = feat("committed", 100, 0, 1.0);
519        let mut uncommitted = feat("uncommitted", 100, 0, 9.0);
520        committed.content_hash = Some("h".into());
521        uncommitted.content_hash = Some("h".into());
522        uncommitted.committed = false;
523
524        assert!(DuplicatePolicy.select(&[committed, uncommitted]).is_empty());
525    }
526
527    /// Ties at the group's newest access are all kept: the previous SQL used a
528    /// strict `<` against the group max, so two equally-fresh duplicates both
529    /// survive rather than one being arbitrarily dropped.
530    #[test]
531    fn duplicate_keeps_all_entries_tied_at_newest() {
532        let mut a = feat("tie_a", 100, 0, 5.0);
533        let mut b = feat("tie_b", 100, 0, 5.0);
534        a.content_hash = Some("h".into());
535        b.content_hash = Some("h".into());
536        a.reclaimable_bytes = Some(100);
537        b.reclaimable_bytes = Some(100);
538        assert!(DuplicatePolicy.select(&[a, b]).is_empty());
539    }
540
541    /// kunobi-ninja/kache#709: a duplicate whose blob is still referenced
542    /// must be skipped because removing it frees no physical bytes.
543    #[test]
544    fn duplicate_skips_victim_whose_blob_is_still_referenced() {
545        let mut newest = feat("newest", 100, 0, 1.0);
546        let mut oldest = feat("oldest", 100, 0, 9.0);
547        newest.content_hash = Some("h".into());
548        oldest.content_hash = Some("h".into());
549        oldest.reclaimable_bytes = Some(0);
550
551        assert!(
552            DuplicatePolicy.select(&[newest, oldest]).is_empty(),
553            "a zero-marginal-byte duplicate must never be selected"
554        );
555    }
556
557    #[test]
558    fn duplicate_fails_closed_when_marginal_bytes_are_unknown() {
559        let mut newest = feat("newest", 100, 0, 1.0);
560        let mut legacy = feat("legacy", 100, 0, 9.0);
561        newest.content_hash = Some("h".into());
562        legacy.content_hash = Some("h".into());
563        assert_eq!(legacy.reclaimable_bytes, None);
564
565        assert!(DuplicatePolicy.select(&[newest, legacy]).is_empty());
566    }
567
568    #[test]
569    fn duplicate_still_evicts_victim_with_positive_marginal_bytes() {
570        let mut newest = feat("newest", 100, 0, 1.0);
571        let mut oldest = feat("oldest", 100, 0, 9.0);
572        newest.content_hash = Some("h".into());
573        oldest.content_hash = Some("h".into());
574        oldest.reclaimable_bytes = Some(100);
575
576        assert_eq!(DuplicatePolicy.select(&[newest, oldest]), vec!["oldest"]);
577    }
578
579    mod properties {
580        use super::*;
581        use proptest::prelude::*;
582
583        /// Candidates with unique keys. Idle times and sizes come from small
584        /// sets so scores tie often; reclaim covers unknown, zero and full.
585        fn candidates() -> impl Strategy<Value = Vec<EntryFeatures>> {
586            let entry = (
587                proptest::sample::select(&[0_i64, 512, 4096, 3 << 20][..]),
588                0_i64..4,
589                proptest::sample::select(&[-1.0, 0.0, 0.5, 2.0, 30.0][..]),
590                proptest::option::of(proptest::sample::select(&["h0", "h1"][..])),
591                any::<bool>(),
592                0_u8..3,
593            );
594            proptest::collection::vec(entry, 0..12).prop_map(|entries| {
595                entries
596                    .into_iter()
597                    .enumerate()
598                    .map(
599                        |(i, (size, hits, idle, hash, committed, reclaim))| EntryFeatures {
600                            key: format!("k{i:02}"),
601                            size,
602                            hit_count: hits,
603                            idle_hours: idle,
604                            content_hash: hash.map(str::to_string),
605                            committed,
606                            compile_time_ms: 0,
607                            reclaimable_bytes: [None, Some(0), Some(size)][reclaim as usize],
608                            recently_accessed: false,
609                            recently_imported: false,
610                        },
611                    )
612                    .collect()
613            })
614        }
615
616        proptest! {
617            #![proptest_config(ProptestConfig {
618                cases: 64,
619                max_shrink_iters: 256,
620                ..ProptestConfig::default()
621            })]
622
623            /// Selection is a pure function of the candidate list. Size
624            /// pressure ranks every key once by ascending score; reordering
625            /// the list can only reorder keys whose scores tie, since ties
626            /// keep list order. Duplicate selection does not depend on list
627            /// order at all, and keeps each group's most recent member.
628            #[test]
629            fn eviction_selection_is_deterministic(
630                (list, shuffled) in candidates().prop_flat_map(|c| (Just(c.clone()), Just(c).prop_shuffle())),
631            ) {
632                let score = |key: &String| {
633                    size_pressure_score(list.iter().find(|e| &e.key == key).unwrap())
634                };
635                let ranked = SizePressurePolicy.select(&list);
636                prop_assert_eq!(&ranked, &SizePressurePolicy.select(&list));
637                let mut keys = ranked.clone();
638                keys.sort();
639                prop_assert_eq!(keys, list.iter().map(|e| e.key.clone()).collect::<Vec<_>>());
640                let scores: Vec<f64> = ranked.iter().map(score).collect();
641                prop_assert!(scores.is_sorted(), "{scores:?}");
642                let reordered: Vec<f64> = SizePressurePolicy.select(&shuffled).iter().map(score).collect();
643                prop_assert_eq!(scores, reordered);
644
645                let duplicates = DuplicatePolicy.select(&list);
646                prop_assert_eq!(&duplicates, &DuplicatePolicy.select(&shuffled));
647                for key in &duplicates {
648                    let e = list.iter().find(|e| &e.key == key).unwrap();
649                    let newest = list
650                        .iter()
651                        .filter(|o| o.committed && o.content_hash.is_some() && o.content_hash == e.content_hash)
652                        .map(|o| o.idle_hours)
653                        .fold(f64::INFINITY, f64::min);
654                    prop_assert!(e.committed && e.idle_hours > newest, "{e:?}");
655                    prop_assert!(e.reclaimable_bytes.is_some_and(|b| b > 0), "{e:?}");
656                }
657            }
658        }
659    }
660}