Skip to main content

concinnity_core/render/
streaming.rs

1//! Asset-streaming policy core.
2//!
3//! Pure decision logic: given each streamable item's priority score (camera
4//! distance), a per-frame load budget, and a cap on how many items may be
5//! resident at once, this decides *which* items to load and *which* to evict.
6//! It performs no I/O, spawns no threads, and touches no backend.
7//!
8//! The `std`-side half -- the background fetch thread, the channels, and the
9//! GPU upload -- lives in concinnity-engine's `app::texture_stream`. Keep that
10//! boundary: no thread, file, or clock belongs in this file.
11
12use crate::memory::{Arena, MemTag};
13use alloc::vec;
14use alloc::vec::Vec;
15
16/// Residency state of a single streamable item.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum StreamState {
19    /// Not on the GPU; eligible to be loaded.
20    Unloaded,
21    /// A background load has been dispatched but has not completed.
22    Pending,
23    /// On the GPU and ready to sample.
24    Resident,
25}
26
27#[derive(Clone, Copy, Debug)]
28struct Item {
29    state: StreamState,
30    // Priority score; lower = more urgent. The driver feeds squared camera
31    // distance, so "closer to the camera" sorts first and no `sqrt` (which
32    // lives in `std`, not `core`) is needed here.
33    score: f32,
34    // Frame this item was last referenced; the LRU tiebreak when two resident
35    // items have an equal score during eviction.
36    last_touch: u64,
37    // Resident GPU footprint in bytes, reported by the driver on load
38    // completion. Zero until the item first becomes Resident; retained as the
39    // re-load estimate after an eviction. Only counted while Resident, so a
40    // stale weight on an Unloaded item never inflates `resident_bytes`.
41    bytes: u64,
42    // A blocked item is never loaded and is evicted if resident, regardless of
43    // score. Set by scene residency for items whose owning scene is unpinned.
44    blocked: bool,
45}
46
47/// The load / evict decisions produced by one [`StreamPlanner::plan`] call.
48#[derive(Debug, Default, PartialEq, Eq)]
49pub struct StreamPlan {
50    /// Item ids whose background load should be dispatched this frame.
51    pub to_load: Vec<usize>,
52    /// Item ids that should be evicted from the GPU this frame.
53    pub to_evict: Vec<usize>,
54}
55
56/// Decides what to stream in and out of a fixed-size residency pool.
57///
58/// The planner owns only residency *bookkeeping*: it never reads or writes a
59/// GPU resource. Each frame the driver updates scores, calls [`plan`], and
60/// reports completed loads back via [`mark_resident`].
61///
62/// [`plan`]: StreamPlanner::plan
63/// [`mark_resident`]: StreamPlanner::mark_resident
64pub struct StreamPlanner {
65    items: Vec<Item>,
66    // Max number of loads `plan` will dispatch in a single call.
67    load_budget: usize,
68    // Max number of items allowed Resident-or-Pending simultaneously. Once the
69    // pool is full a load can only proceed by evicting a lower-priority item.
70    resident_cap: usize,
71    // Optional cap on total resident bytes. When `Some(b)`, `plan` treats the
72    // pool as full whenever loading a candidate would push resident bytes past
73    // `b`, evicting farther residents until it fits. `None` (the default)
74    // disables byte accounting entirely, leaving the count-only policy.
75    byte_budget: Option<u64>,
76    // Working memory for `plan`'s two id lists, reserved once. `plan` runs
77    // every frame for every pool, and both lists are bounded by the item count,
78    // so they come out of an arena rather than the heap.
79    scratch: Arena,
80}
81
82// Room for the two id lists `plan` builds, each at most one entry per item.
83fn scratch_bytes(count: usize) -> usize {
84    (2 * count * size_of::<usize>()).max(size_of::<usize>())
85}
86
87impl StreamPlanner {
88    /// Create a planner tracking `count` items, all initially `Unloaded`.
89    ///
90    /// `load_budget` and `resident_cap` are both clamped to at least 1 so a
91    /// zero from a misconfigured asset cannot wedge streaming permanently.
92    pub fn new(count: usize, load_budget: usize, resident_cap: usize) -> Self {
93        Self {
94            items: vec![
95                Item {
96                    state: StreamState::Unloaded,
97                    score: 0.0,
98                    last_touch: 0,
99                    bytes: 0,
100                    blocked: false,
101                };
102                count
103            ],
104            load_budget: load_budget.max(1),
105            resident_cap: resident_cap.max(1),
106            byte_budget: None,
107            scratch: Arena::tagged(scratch_bytes(count), MemTag::Scratch),
108        }
109    }
110
111    /// Set (or clear with `None`) the total resident-byte budget. `None` keeps
112    /// the count-only policy; `Some(b)` additionally evicts to hold resident
113    /// bytes at or under `b`. Off by default so worlds that never set it behave
114    /// exactly as the count-only planner.
115    pub fn set_byte_budget(&mut self, budget: Option<u64>) {
116        self.byte_budget = budget;
117    }
118
119    /// The active resident-byte budget, or `None` when byte accounting is off
120    /// (the count-only policy). For diagnostics.
121    pub fn byte_budget(&self) -> Option<u64> {
122        self.byte_budget
123    }
124
125    /// Number of tracked items.
126    pub fn len(&self) -> usize {
127        self.items.len()
128    }
129
130    /// Whether the planner tracks no items.
131    pub fn is_empty(&self) -> bool {
132        self.items.is_empty()
133    }
134
135    /// Residency state of item `id`, or `None` if `id` is out of range.
136    pub fn state(&self, id: usize) -> Option<StreamState> {
137        self.items.get(id).map(|i| i.state)
138    }
139
140    /// Set item `id`'s priority score (lower = loaded sooner / evicted later).
141    /// Out-of-range ids are ignored.
142    pub fn set_score(&mut self, id: usize, score: f32) {
143        if let Some(item) = self.items.get_mut(id) {
144            item.score = score;
145        }
146    }
147
148    /// Record that item `id` was referenced on `frame`. Refreshes the LRU
149    /// tiebreak used when evicting equally-scored resident items.
150    pub fn touch(&mut self, id: usize, frame: u64) {
151        if let Some(item) = self.items.get_mut(id) {
152            item.last_touch = frame;
153        }
154    }
155
156    /// Report that a dispatched load for item `id` has completed and the
157    /// resource is now on the GPU, occupying `bytes` of GPU memory. The driver
158    /// knows the exact resident size at completion (decoded pixel bytes, or
159    /// vertex + index buffer bytes); `bytes` may be 0 when the size is unknown
160    /// or nothing was actually uploaded (e.g. a failed fetch left a placeholder).
161    pub fn mark_resident(&mut self, id: usize, frame: u64, bytes: u64) {
162        if let Some(item) = self.items.get_mut(id) {
163            item.state = StreamState::Resident;
164            item.last_touch = frame;
165            item.bytes = bytes;
166        }
167    }
168
169    /// Block or unblock item `id`. A blocked item is never scheduled to load;
170    /// if resident it is evicted by the next [`plan`](Self::plan) call.
171    /// Out-of-range ids are ignored.
172    pub fn set_blocked(&mut self, id: usize, blocked: bool) {
173        if let Some(item) = self.items.get_mut(id) {
174            item.blocked = blocked;
175        }
176    }
177
178    /// Force item `id` back to `Unloaded` (e.g. after a failed load that
179    /// should be retried). Out-of-range ids are ignored. The item's last-known
180    /// byte weight is retained as the estimate for a future re-load; it no
181    /// longer counts toward `resident_bytes` while Unloaded.
182    pub fn mark_unloaded(&mut self, id: usize) {
183        if let Some(item) = self.items.get_mut(id) {
184            item.state = StreamState::Unloaded;
185        }
186    }
187
188    /// Total bytes of all currently Resident items, for diagnostics and the
189    /// byte-budget policy. Pending and Unloaded items are excluded.
190    pub fn resident_bytes(&self) -> u64 {
191        self.items
192            .iter()
193            .filter(|it| it.state == StreamState::Resident)
194            .map(|it| it.bytes)
195            .sum()
196    }
197
198    /// `(resident, pending, unloaded)` item counts, for diagnostics.
199    pub fn counts(&self) -> (usize, usize, usize) {
200        let mut resident = 0;
201        let mut pending = 0;
202        let mut unloaded = 0;
203        for item in &self.items {
204            match item.state {
205                StreamState::Resident => resident += 1,
206                StreamState::Pending => pending += 1,
207                StreamState::Unloaded => unloaded += 1,
208            }
209        }
210        (resident, pending, unloaded)
211    }
212
213    /// Decide which items to load and evict this frame.
214    ///
215    /// `Unloaded` items are considered best-score-first. While the pool has
216    /// spare capacity -- under both the count cap and (when set) the byte budget
217    /// -- they are simply scheduled to load. Once the pool is full a candidate
218    /// can still load by evicting worst-scored residents, but only ones strictly
219    /// farther than the candidate, so equal-priority items never churn. A large
220    /// candidate may evict several small residents to fit under the byte budget.
221    /// At most `load_budget` loads are scheduled per call.
222    ///
223    /// With no byte budget set this reduces exactly to the count-only policy.
224    ///
225    /// This method mutates planner state: scheduled items become `Pending` and
226    /// evicted items become `Unloaded`, so a later `plan` call in the same
227    /// frame (or the next frame) will not re-pick them.
228    pub fn plan(&mut self) -> StreamPlan {
229        let mut plan = StreamPlan::default();
230
231        // Blocked residents are evicted unconditionally: their owning scene is
232        // unpinned, so no score keeps them on the GPU. (A blocked Pending item
233        // completes its in-flight load first and is evicted here next call.)
234        for (id, item) in self.items.iter_mut().enumerate() {
235            if item.blocked && item.state == StreamState::Resident {
236                item.state = StreamState::Unloaded;
237                plan.to_evict.push(id);
238            }
239        }
240
241        // Both id lists below come out of the planner's arena, so planning a
242        // frame allocates nothing. Resetting takes `&mut`, which is the proof
243        // that last frame's lists are gone.
244        self.scratch.reset();
245
246        // Candidate loads: every unblocked Unloaded item, best score first.
247        let mut candidates = self
248            .scratch
249            .vec::<usize>(self.items.len())
250            .expect("scratch is sized for every item");
251        candidates.extend(
252            self.items
253                .iter()
254                .enumerate()
255                .filter(|(_, it)| it.state == StreamState::Unloaded && !it.blocked)
256                .map(|(id, _)| id),
257        );
258        if candidates.is_empty() {
259            return plan;
260        }
261        // Unstable sort (the stable one heap-allocates scratch); the id
262        // tiebreak reproduces the id-ascending tie order stability gave.
263        candidates.sort_unstable_by(|&a, &b| {
264            self.items[a]
265                .score
266                .partial_cmp(&self.items[b].score)
267                .unwrap_or(core::cmp::Ordering::Equal)
268                .then(a.cmp(&b))
269        });
270
271        // Residents in eviction order (worst first): highest score, then
272        // least-recently-touched, then lowest id -- the exact order the old
273        // per-victim `worst_resident` scan produced, precomputed once so
274        // eviction is a forward cursor walk rather than an O(resident) rescan
275        // per victim. Committed evictions are always a prefix of this list
276        // (candidates are best-first and evict worst-first, so each candidate
277        // extends the evicted prefix), which a single `evicted` cursor tracks.
278        let mut residents = self
279            .scratch
280            .vec::<usize>(self.items.len())
281            .expect("scratch is sized for every item");
282        residents.extend(
283            self.items
284                .iter()
285                .enumerate()
286                .filter(|(_, it)| it.state == StreamState::Resident)
287                .map(|(id, _)| id),
288        );
289        residents.sort_unstable_by(|&a, &b| {
290            let (ia, ib) = (&self.items[a], &self.items[b]);
291            ib.score
292                .partial_cmp(&ia.score)
293                .unwrap_or(core::cmp::Ordering::Equal)
294                .then(ia.last_touch.cmp(&ib.last_touch))
295                .then(a.cmp(&b))
296        });
297
298        // Running occupancy / resident-byte totals, seeded once and updated only
299        // when a load actually commits -- matching the old per-candidate
300        // recompute, which reflected only committed state (a candidate that did
301        // not fit left the pool untouched).
302        let mut occ = self.occupied();
303        let mut resident_bytes = self.resident_bytes();
304        // Front of `residents` already evicted (committed) this call.
305        let mut evicted = 0usize;
306
307        for &id in candidates.iter() {
308            if plan.to_load.len() >= self.load_budget {
309                break;
310            }
311            let cand_score = self.items[id].score;
312            let cand_bytes = self.items[id].bytes;
313
314            // Tentatively shed the next worst residents past the committed
315            // prefix -- only ones strictly farther than the candidate -- until
316            // it would fit under both the count cap and the byte budget. The
317            // tentative totals start from the committed ones; commit only if the
318            // candidate actually fits.
319            let mut tent_occ = occ;
320            let mut tent_bytes = resident_bytes;
321            let mut cursor = evicted;
322            let fits = loop {
323                let count_ok = tent_occ < self.resident_cap;
324                let byte_ok = self
325                    .byte_budget
326                    .is_none_or(|b| tent_bytes + cand_bytes <= b);
327                if count_ok && byte_ok {
328                    break true;
329                }
330                match residents.get(cursor) {
331                    Some(&victim) if self.items[victim].score > cand_score => {
332                        tent_occ -= 1;
333                        tent_bytes -= self.items[victim].bytes;
334                        cursor += 1;
335                    }
336                    // No farther resident left to shed: this candidate cannot
337                    // be placed.
338                    _ => break false,
339                }
340            };
341
342            if fits {
343                for &victim in &residents[evicted..cursor] {
344                    self.items[victim].state = StreamState::Unloaded;
345                    plan.to_evict.push(victim);
346                }
347                evicted = cursor;
348                self.items[id].state = StreamState::Pending;
349                plan.to_load.push(id);
350                // Commit: the evictions are now real, and the new load occupies
351                // a slot (Pending; its bytes are not counted until it becomes
352                // Resident, matching `resident_bytes()`).
353                occ = tent_occ + 1;
354                resident_bytes = tent_bytes;
355            } else if self.byte_budget.is_none() {
356                // Count-only: candidates are score-sorted, so if the best
357                // remaining one cannot displace the worst resident, none can.
358                break;
359            }
360            // Byte budget set: a later, smaller candidate may still fit, so
361            // keep scanning rather than stopping here.
362        }
363
364        plan
365    }
366
367    // Items occupying (or about to occupy) a pool slot: everything not Unloaded.
368    fn occupied(&self) -> usize {
369        self.items
370            .iter()
371            .filter(|it| it.state != StreamState::Unloaded)
372            .count()
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn new_planner_has_all_items_unloaded() {
382        let p = StreamPlanner::new(3, 4, 8);
383        assert_eq!(p.len(), 3);
384        for id in 0..3 {
385            assert_eq!(p.state(id), Some(StreamState::Unloaded));
386        }
387        assert_eq!(p.state(3), None);
388        assert_eq!(p.counts(), (0, 0, 3));
389    }
390
391    // The reservation must cover the worst frame -- every item a load
392    // candidate, then every item resident -- because `plan` takes it as given.
393    // Reserving less would surface as a panic mid-frame.
394    #[test]
395    fn the_scratch_reservation_covers_the_worst_frame() {
396        const COUNT: usize = 32;
397        let mut p = StreamPlanner::new(COUNT, COUNT, COUNT);
398        let plan = p.plan();
399        assert_eq!(plan.to_load.len(), COUNT, "every item is a candidate");
400        for id in 0..COUNT {
401            p.mark_resident(id, 0, 1);
402        }
403        let _ = p.plan();
404
405        assert!(p.scratch.peak() <= p.scratch.capacity());
406        assert_eq!(p.scratch.capacity(), scratch_bytes(COUNT));
407    }
408
409    // Planning reuses that one reservation frame after frame: the arena is the
410    // point, so a plan must never reach the heap for its working lists.
411    #[test]
412    fn repeated_planning_reuses_the_same_reservation() {
413        let mut p = StreamPlanner::new(16, 4, 8);
414        let capacity = p.scratch.capacity();
415        for frame in 0..16u64 {
416            for id in 0..16 {
417                p.set_score(id, ((id as u64 + frame) % 16) as f32);
418            }
419            let _ = p.plan();
420        }
421        assert_eq!(p.scratch.capacity(), capacity);
422        assert!(p.scratch.peak() <= capacity);
423    }
424
425    #[test]
426    fn zero_budget_and_cap_are_clamped_to_one() {
427        let mut p = StreamPlanner::new(2, 0, 0);
428        let plan = p.plan();
429        // A budget/cap of 0 would wedge streaming; clamped to 1 it still moves.
430        assert_eq!(plan.to_load.len(), 1);
431    }
432
433    #[test]
434    fn plan_loads_nearest_items_first_within_budget() {
435        let mut p = StreamPlanner::new(4, 2, 8);
436        p.set_score(0, 30.0);
437        p.set_score(1, 10.0);
438        p.set_score(2, 20.0);
439        p.set_score(3, 40.0);
440        let plan = p.plan();
441        // Budget is 2; the two lowest scores (ids 1 then 2) are picked in order.
442        assert_eq!(plan.to_load, vec![1, 2]);
443        assert!(plan.to_evict.is_empty());
444        assert_eq!(p.state(1), Some(StreamState::Pending));
445        assert_eq!(p.state(2), Some(StreamState::Pending));
446        assert_eq!(p.state(0), Some(StreamState::Unloaded));
447    }
448
449    #[test]
450    fn pending_items_are_not_re_dispatched() {
451        let mut p = StreamPlanner::new(3, 1, 8);
452        let first = p.plan();
453        assert_eq!(first.to_load.len(), 1);
454        let dispatched = first.to_load[0];
455        let second = p.plan();
456        assert!(!second.to_load.contains(&dispatched));
457    }
458
459    #[test]
460    fn resident_cap_blocks_loading_when_no_eviction_is_worthwhile() {
461        let mut p = StreamPlanner::new(3, 4, 2);
462        // Two near items become resident.
463        p.set_score(0, 1.0);
464        p.set_score(1, 2.0);
465        p.set_score(2, 99.0);
466        let plan = p.plan();
467        assert_eq!(plan.to_load, vec![0, 1]);
468        p.mark_resident(0, 1, 0);
469        p.mark_resident(1, 1, 0);
470        // The far item cannot displace either resident; they are both closer.
471        let plan = p.plan();
472        assert!(plan.to_load.is_empty());
473        assert!(plan.to_evict.is_empty());
474    }
475
476    #[test]
477    fn closer_candidate_evicts_a_farther_resident() {
478        let mut p = StreamPlanner::new(3, 4, 2);
479        p.set_score(0, 50.0);
480        p.set_score(1, 60.0);
481        p.set_score(2, 99.0);
482        let plan = p.plan();
483        assert_eq!(plan.to_load, vec![0, 1]);
484        p.mark_resident(0, 1, 0);
485        p.mark_resident(1, 1, 0);
486        // Item 2 walks closer than resident item 1.
487        p.set_score(2, 10.0);
488        let plan = p.plan();
489        assert_eq!(plan.to_load, vec![2]);
490        assert_eq!(plan.to_evict, vec![1]);
491        assert_eq!(p.state(1), Some(StreamState::Unloaded));
492        assert_eq!(p.state(2), Some(StreamState::Pending));
493    }
494
495    #[test]
496    fn eviction_breaks_score_ties_toward_least_recently_touched() {
497        let mut p = StreamPlanner::new(3, 4, 2);
498        p.set_score(0, 5.0);
499        p.set_score(1, 5.0);
500        p.set_score(2, 99.0); // far away initially, so 0 and 1 become resident
501        let plan = p.plan();
502        assert_eq!(plan.to_load, vec![0, 1]);
503        p.mark_resident(0, 1, 0);
504        p.mark_resident(1, 1, 0);
505        // Item 0 is referenced more recently than item 1.
506        p.touch(0, 100);
507        p.touch(1, 50);
508        // A closer candidate forces one eviction; the staler resident loses.
509        p.set_score(2, 1.0);
510        let plan = p.plan();
511        assert_eq!(plan.to_evict, vec![1]);
512    }
513
514    #[test]
515    fn counts_track_state_transitions() {
516        let mut p = StreamPlanner::new(3, 1, 8);
517        assert_eq!(p.counts(), (0, 0, 3));
518        let plan = p.plan();
519        let id = plan.to_load[0];
520        assert_eq!(p.counts(), (0, 1, 2));
521        p.mark_resident(id, 1, 0);
522        assert_eq!(p.counts(), (1, 0, 2));
523        p.mark_unloaded(id);
524        assert_eq!(p.counts(), (0, 0, 3));
525    }
526
527    #[test]
528    fn empty_planner_plans_nothing() {
529        let mut p = StreamPlanner::new(0, 4, 8);
530        assert_eq!(p.len(), 0);
531        assert_eq!(p.plan(), StreamPlan::default());
532    }
533
534    #[test]
535    fn blocked_item_is_never_scheduled_to_load() {
536        let mut p = StreamPlanner::new(2, 4, 8);
537        p.set_score(0, 1.0);
538        p.set_score(1, 2.0);
539        p.set_blocked(0, true);
540        let plan = p.plan();
541        assert_eq!(plan.to_load, vec![1]);
542        assert_eq!(p.state(0), Some(StreamState::Unloaded));
543    }
544
545    #[test]
546    fn blocked_resident_is_evicted_unconditionally() {
547        let mut p = StreamPlanner::new(2, 4, 8);
548        p.mark_resident(0, 1, 100);
549        p.mark_resident(1, 1, 100);
550        p.set_blocked(0, true);
551        let plan = p.plan();
552        assert_eq!(plan.to_evict, vec![0]);
553        assert!(plan.to_load.is_empty(), "blocked item must not reload");
554        assert_eq!(p.state(0), Some(StreamState::Unloaded));
555        assert_eq!(p.state(1), Some(StreamState::Resident));
556        assert_eq!(p.resident_bytes(), 100);
557    }
558
559    #[test]
560    fn unblocking_makes_an_item_loadable_again() {
561        let mut p = StreamPlanner::new(1, 4, 8);
562        p.set_blocked(0, true);
563        assert!(p.plan().to_load.is_empty());
564        p.set_blocked(0, false);
565        assert_eq!(p.plan().to_load, vec![0]);
566    }
567
568    #[test]
569    fn blocked_pending_item_is_evicted_after_its_load_completes() {
570        let mut p = StreamPlanner::new(1, 4, 8);
571        let plan = p.plan();
572        assert_eq!(plan.to_load, vec![0]);
573        // Blocked while the load is in flight: nothing to do yet.
574        p.set_blocked(0, true);
575        assert_eq!(p.plan(), StreamPlan::default());
576        // The load completes; the next plan evicts it.
577        p.mark_resident(0, 2, 64);
578        assert_eq!(p.plan().to_evict, vec![0]);
579    }
580
581    // Give an Unloaded item a known byte weight, as if it had been resident and
582    // then evicted: `mark_resident` records the size, `mark_unloaded` frees the
583    // slot but keeps the weight as the re-load estimate.
584    fn seed_bytes(p: &mut StreamPlanner, id: usize, bytes: u64) {
585        p.mark_resident(id, 0, bytes);
586        p.mark_unloaded(id);
587    }
588
589    #[test]
590    fn byte_budget_accessor_reflects_set_and_clear() {
591        let mut p = StreamPlanner::new(1, 4, 8);
592        assert_eq!(p.byte_budget(), None);
593        p.set_byte_budget(Some(4096));
594        assert_eq!(p.byte_budget(), Some(4096));
595        p.set_byte_budget(None);
596        assert_eq!(p.byte_budget(), None);
597    }
598
599    #[test]
600    fn resident_bytes_sums_resident_items_only() {
601        let mut p = StreamPlanner::new(3, 4, 8);
602        assert_eq!(p.resident_bytes(), 0);
603        p.mark_resident(0, 1, 100);
604        p.mark_resident(1, 1, 250);
605        assert_eq!(p.resident_bytes(), 350);
606        // An unloaded item stops counting even though it keeps its weight.
607        p.mark_unloaded(0);
608        assert_eq!(p.resident_bytes(), 250);
609    }
610
611    #[test]
612    fn no_byte_budget_ignores_item_bytes() {
613        // Generous count cap, no byte budget: even huge items never evict.
614        let mut p = StreamPlanner::new(3, 4, 8);
615        p.set_score(0, 5.0);
616        p.set_score(1, 6.0);
617        p.mark_resident(0, 1, 10_000);
618        p.mark_resident(1, 1, 10_000);
619        seed_bytes(&mut p, 2, 10_000);
620        p.set_score(2, 7.0);
621        let plan = p.plan();
622        // A count slot is free, so it simply loads with no eviction.
623        assert_eq!(plan.to_load, vec![2]);
624        assert!(plan.to_evict.is_empty());
625    }
626
627    #[test]
628    fn byte_budget_evicts_worst_scored_resident_to_fit() {
629        // Count cap is generous; the byte budget is the only pressure.
630        let mut p = StreamPlanner::new(3, 4, 100);
631        p.set_byte_budget(Some(100));
632        // Two residents fill the 100-byte budget: a near one and a far one.
633        p.set_score(0, 5.0);
634        p.set_score(1, 80.0);
635        p.mark_resident(0, 1, 50);
636        p.mark_resident(1, 1, 50);
637        assert_eq!(p.resident_bytes(), 100);
638        // A mid-distance 50-byte candidate wants in.
639        seed_bytes(&mut p, 2, 50);
640        p.set_score(2, 10.0);
641        let plan = p.plan();
642        // It must evict the worst-scored resident (far id 1), not near id 0.
643        assert_eq!(plan.to_evict, vec![1]);
644        assert_eq!(plan.to_load, vec![2]);
645    }
646
647    #[test]
648    fn count_cap_binds_first_for_small_items() {
649        // Tight count cap, roomy byte budget: the count cap drives eviction.
650        let mut p = StreamPlanner::new(3, 4, 2);
651        p.set_byte_budget(Some(10_000));
652        p.set_score(0, 10.0);
653        p.set_score(1, 20.0);
654        p.set_score(2, 99.0);
655        let plan = p.plan();
656        assert_eq!(plan.to_load, vec![0, 1]);
657        p.mark_resident(0, 1, 8);
658        p.mark_resident(1, 1, 8);
659        // id 2 walks in closer than id 1. Resident bytes (16) are nowhere near
660        // the 10_000 budget, so the full count cap is what forces the swap.
661        seed_bytes(&mut p, 2, 8);
662        p.set_score(2, 15.0);
663        let plan = p.plan();
664        assert_eq!(plan.to_evict, vec![1]);
665        assert_eq!(plan.to_load, vec![2]);
666        assert_eq!(p.resident_bytes(), 8); // only id 0 remains resident
667    }
668
669    #[test]
670    fn byte_budget_binds_first_for_large_items() {
671        // Roomy count cap, tight byte budget: bytes drive eviction.
672        let mut p = StreamPlanner::new(3, 4, 100);
673        p.set_byte_budget(Some(100));
674        p.set_score(0, 10.0);
675        p.set_score(1, 20.0);
676        p.mark_resident(0, 1, 50);
677        p.mark_resident(1, 1, 50); // 100 bytes: budget full, 2/100 slots used
678        seed_bytes(&mut p, 2, 50);
679        p.set_score(2, 15.0);
680        let plan = p.plan();
681        // 98 count slots are free, so only the byte budget can force this.
682        assert_eq!(plan.to_evict, vec![1]);
683        assert_eq!(plan.to_load, vec![2]);
684        assert!(p.resident_bytes() + 50 <= 100);
685    }
686
687    #[test]
688    fn large_candidate_evicts_multiple_farther_residents_but_not_a_closer_one() {
689        let mut p = StreamPlanner::new(5, 4, 100);
690        p.set_byte_budget(Some(100));
691        // Four 20-byte residents: one near (score 5), three far (30/40/50).
692        p.set_score(0, 5.0);
693        p.set_score(1, 30.0);
694        p.set_score(2, 40.0);
695        p.set_score(3, 50.0);
696        p.mark_resident(0, 1, 20);
697        p.mark_resident(1, 1, 20);
698        p.mark_resident(2, 1, 20);
699        p.mark_resident(3, 1, 20);
700        assert_eq!(p.resident_bytes(), 80);
701        // A big 60-byte candidate (score 10) needs 40 bytes freed: it evicts
702        // the two worst-scored residents (far id 3 then id 2), never the nearer
703        // id 0 or id 1.
704        seed_bytes(&mut p, 4, 60);
705        p.set_score(4, 10.0);
706        let plan = p.plan();
707        assert_eq!(plan.to_load, vec![4]);
708        assert_eq!(plan.to_evict, vec![3, 2]);
709        assert_eq!(p.state(0), Some(StreamState::Resident));
710        assert_eq!(p.state(1), Some(StreamState::Resident));
711        assert!(p.resident_bytes() + 60 <= 100);
712    }
713
714    #[test]
715    fn byte_budget_does_not_evict_a_resident_closer_than_the_candidate() {
716        let mut p = StreamPlanner::new(2, 4, 100);
717        p.set_byte_budget(Some(50));
718        // A single near resident already fills the budget.
719        p.set_score(0, 5.0);
720        p.mark_resident(0, 1, 50);
721        // A farther, large candidate cannot displace the closer resident.
722        seed_bytes(&mut p, 1, 50);
723        p.set_score(1, 99.0);
724        let plan = p.plan();
725        assert!(plan.to_load.is_empty());
726        assert!(plan.to_evict.is_empty());
727        assert_eq!(p.state(0), Some(StreamState::Resident));
728    }
729
730    // The worst (highest-score, then least-recently-touched, then lowest-id)
731    // resident not already excluded -- the original per-victim scan, kept here
732    // as the reference the optimized `plan` is checked against.
733    fn worst_resident_ref(
734        items: &[Item],
735        excluded: &[usize],
736        tentative: &[usize],
737    ) -> Option<usize> {
738        let mut worst: Option<usize> = None;
739        for (id, item) in items.iter().enumerate() {
740            if item.state != StreamState::Resident
741                || excluded.contains(&id)
742                || tentative.contains(&id)
743            {
744                continue;
745            }
746            match worst {
747                None => worst = Some(id),
748                Some(w) => {
749                    let better = item.score > items[w].score
750                        || (item.score == items[w].score && item.last_touch < items[w].last_touch);
751                    if better {
752                        worst = Some(id);
753                    }
754                }
755            }
756        }
757        worst
758    }
759
760    // The pre-optimization `plan` algorithm (per-candidate O(resident) rescans),
761    // used only to verify the optimized version is behavior-identical.
762    fn plan_reference(
763        items: &mut [Item],
764        load_budget: usize,
765        resident_cap: usize,
766        byte_budget: Option<u64>,
767    ) -> StreamPlan {
768        let mut plan = StreamPlan::default();
769        let mut candidates: Vec<usize> = items
770            .iter()
771            .enumerate()
772            .filter(|(_, it)| it.state == StreamState::Unloaded)
773            .map(|(id, _)| id)
774            .collect();
775        candidates.sort_by(|&a, &b| {
776            items[a]
777                .score
778                .partial_cmp(&items[b].score)
779                .unwrap_or(core::cmp::Ordering::Equal)
780        });
781        for &id in &candidates {
782            if plan.to_load.len() >= load_budget {
783                break;
784            }
785            let cand_score = items[id].score;
786            let cand_bytes = items[id].bytes;
787            let mut occ = items
788                .iter()
789                .filter(|it| it.state != StreamState::Unloaded)
790                .count();
791            let mut resident_bytes: u64 = items
792                .iter()
793                .filter(|it| it.state == StreamState::Resident)
794                .map(|it| it.bytes)
795                .sum();
796            let mut victims: Vec<usize> = Vec::new();
797            let fits = loop {
798                let count_ok = occ < resident_cap;
799                let byte_ok = byte_budget.is_none_or(|b| resident_bytes + cand_bytes <= b);
800                if count_ok && byte_ok {
801                    break true;
802                }
803                match worst_resident_ref(items, &plan.to_evict, &victims) {
804                    Some(victim) if items[victim].score > cand_score => {
805                        occ -= 1;
806                        resident_bytes -= items[victim].bytes;
807                        victims.push(victim);
808                    }
809                    _ => break false,
810                }
811            };
812            if fits {
813                for &victim in &victims {
814                    items[victim].state = StreamState::Unloaded;
815                    plan.to_evict.push(victim);
816                }
817                items[id].state = StreamState::Pending;
818                plan.to_load.push(id);
819            } else if byte_budget.is_none() {
820                break;
821            }
822        }
823        plan
824    }
825
826    // The optimized `plan` must produce the exact same load / evict decisions
827    // and resulting item states as the reference across many random scenarios
828    // with heavy score and LRU ties, byte budget on and off, at and over cap.
829    #[test]
830    fn plan_matches_reference_on_random_scenarios() {
831        // Deterministic LCG so the test is reproducible and needs no rng dep.
832        let mut seed: u64 = 0x1234_5678_9abc_def0;
833        let mut next = || {
834            seed = seed
835                .wrapping_mul(6364136223846793005)
836                .wrapping_add(1442695040888963407);
837            (seed >> 33) as u32
838        };
839
840        for _ in 0..4000 {
841            let count = (next() % 12) as usize;
842            let items: Vec<Item> = (0..count)
843                .map(|_| {
844                    let state = match next() % 3 {
845                        0 => StreamState::Unloaded,
846                        1 => StreamState::Pending,
847                        _ => StreamState::Resident,
848                    };
849                    Item {
850                        state,
851                        // Small ranges so scores and last_touch tie often.
852                        score: (next() % 6) as f32,
853                        last_touch: (next() % 4) as u64,
854                        bytes: (next() % 20) as u64,
855                        blocked: false,
856                    }
857                })
858                .collect();
859            let load_budget = ((next() % 5) + 1) as usize;
860            let resident_cap = ((next() % 8) + 1) as usize;
861            let byte_budget = if next() % 2 == 0 {
862                None
863            } else {
864                Some((next() % 60) as u64)
865            };
866
867            let mut ref_items = items.clone();
868            let ref_plan = plan_reference(&mut ref_items, load_budget, resident_cap, byte_budget);
869
870            let mut p = StreamPlanner {
871                items: items.clone(),
872                load_budget,
873                resident_cap,
874                byte_budget,
875                scratch: Arena::with_capacity(scratch_bytes(items.len())),
876            };
877            let got = p.plan();
878
879            assert_eq!(got, ref_plan, "plan differs (count={count})");
880            for (i, (a, b)) in p.items.iter().zip(ref_items.iter()).enumerate() {
881                assert_eq!(a.state, b.state, "state[{i}] differs (count={count})");
882            }
883        }
884    }
885}