Skip to main content

sectorsync_core/
replication.rs

1//! Replication planning helpers.
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, HashMap};
5use std::hash::Hash;
6
7use crate::entity::EntityRecord;
8use crate::ids::{ClientId, EntityHandle, Tick};
9#[cfg(not(feature = "simd"))]
10use crate::interest::RangeOnlyVisibility;
11use crate::interest::{ViewerQuery, VisibilityFilter};
12use crate::policy::{CompiledSyncPolicy, PolicyTable};
13use crate::spatial::{Bounds, CellCoord3};
14use crate::spatial_index::{CellIndex, CellQueryScratch, CellQueryStats, CellQueryStrategy};
15use crate::station::Station;
16
17const HASHED_REPLICATION_TRACKER_MIN_ENTRIES: usize = 2_048;
18
19#[derive(Clone, Debug)]
20enum AdaptiveTrackMap<K, V> {
21    Ordered(BTreeMap<K, V>),
22    Hashed(HashMap<K, V>),
23}
24
25impl<K: Copy + Eq + Hash + Ord, V> AdaptiveTrackMap<K, V> {
26    fn new() -> Self {
27        Self::Ordered(BTreeMap::new())
28    }
29
30    fn len(&self) -> usize {
31        match self {
32            Self::Ordered(entries) => entries.len(),
33            Self::Hashed(entries) => entries.len(),
34        }
35    }
36
37    fn is_empty(&self) -> bool {
38        match self {
39            Self::Ordered(entries) => entries.is_empty(),
40            Self::Hashed(entries) => entries.is_empty(),
41        }
42    }
43
44    fn contains_key(&self, key: &K) -> bool {
45        match self {
46            Self::Ordered(entries) => entries.contains_key(key),
47            Self::Hashed(entries) => entries.contains_key(key),
48        }
49    }
50
51    fn get(&self, key: &K) -> Option<&V> {
52        match self {
53            Self::Ordered(entries) => entries.get(key),
54            Self::Hashed(entries) => entries.get(key),
55        }
56    }
57
58    fn get_mut(&mut self, key: &K) -> Option<&mut V> {
59        match self {
60            Self::Ordered(entries) => entries.get_mut(key),
61            Self::Hashed(entries) => entries.get_mut(key),
62        }
63    }
64
65    fn insert(&mut self, key: K, value: V) -> Option<V> {
66        let promote = match self {
67            Self::Ordered(entries) => {
68                entries.len() >= HASHED_REPLICATION_TRACKER_MIN_ENTRIES.saturating_sub(1)
69                    && !entries.contains_key(&key)
70            }
71            Self::Hashed(_) => false,
72        };
73        if promote {
74            let Self::Ordered(ordered) = std::mem::replace(self, Self::Hashed(HashMap::new()))
75            else {
76                unreachable!("promotion starts from ordered tracker storage");
77            };
78            let mut hashed = HashMap::with_capacity(ordered.len().saturating_add(1));
79            hashed.extend(ordered);
80            *self = Self::Hashed(hashed);
81        }
82        match self {
83            Self::Ordered(entries) => entries.insert(key, value),
84            Self::Hashed(entries) => entries.insert(key, value),
85        }
86    }
87
88    fn retain<F>(&mut self, mut keep: F)
89    where
90        F: FnMut(&K, &mut V) -> bool,
91    {
92        match self {
93            Self::Ordered(entries) => entries.retain(|key, value| keep(key, value)),
94            Self::Hashed(entries) => entries.retain(|key, value| keep(key, value)),
95        }
96    }
97
98    #[cfg(test)]
99    fn is_hashed(&self) -> bool {
100        matches!(self, Self::Hashed(_))
101    }
102}
103
104/// Per-client replication budget.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub struct ReplicationBudget {
107    /// Maximum entities to include in a frame.
108    pub max_entities: usize,
109    /// Estimated byte budget for a frame.
110    pub max_bytes: usize,
111    /// Estimated bytes charged per selected entity by simple planners.
112    pub estimated_entity_bytes: usize,
113}
114
115impl Default for ReplicationBudget {
116    fn default() -> Self {
117        Self {
118            max_entities: 300,
119            max_bytes: 16 * 1024,
120            estimated_entity_bytes: 32,
121        }
122    }
123}
124
125/// Replication planner result.
126#[derive(Clone, Debug, Default, PartialEq, Eq)]
127pub struct ReplicationPlan {
128    /// Selected entity handles.
129    pub entities: Vec<EntityHandle>,
130    /// Planner statistics.
131    pub stats: ReplicationStats,
132}
133
134/// Aggregated work and retained-capacity signals from one viewer batch.
135#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
136pub struct ReplicationBatchStats {
137    /// Viewer queries planned in input order.
138    pub viewers: usize,
139    /// Spatial candidates returned across all viewers.
140    pub candidates: usize,
141    /// Candidate records considered after stale-handle filtering.
142    pub considered: usize,
143    /// Entities selected across all plans.
144    pub selected: usize,
145    /// Spatial candidates left unexamined after a first-fit budget filled.
146    pub unexamined_after_budget: usize,
147    /// Estimated payload bytes across all plans.
148    pub estimated_bytes: usize,
149    /// Queries that probed the regular cell grid.
150    pub grid_queries: usize,
151    /// Queries that scanned occupied cells.
152    pub occupied_queries: usize,
153    /// Grid cells probed across the batch.
154    pub grid_cells_probed: usize,
155    /// Occupied cells scanned across the batch.
156    pub occupied_cells_scanned: usize,
157    /// Cells intersecting viewer query bounds across the batch.
158    pub matched_cells: usize,
159    /// Largest retained candidate-handle capacity.
160    pub candidate_capacity_max: usize,
161    /// Largest retained candidate-deduplication capacity.
162    pub dedup_capacity_max: usize,
163    /// Largest retained matched-cell capacity.
164    pub matching_cell_capacity_max: usize,
165    /// Largest retained priority candidate capacity.
166    pub priority_capacity_max: usize,
167    /// Distinct quantized AOI ranges observed in the batch.
168    pub unique_query_ranges: usize,
169    /// Viewer queries served from a within-call candidate cache.
170    pub reused_query_ranges: usize,
171    /// Candidate handles copied into reusable repeated-range cache entries.
172    pub cached_candidate_handles: usize,
173    /// Query-range cache entries retained for later batch calls.
174    pub query_cache_capacity_max: usize,
175    /// Candidate-handle capacity retained across query cache entries.
176    pub query_cache_candidate_capacity_max: usize,
177}
178
179impl ReplicationBatchStats {
180    fn record(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
181        self.record_plan(plan, scratch);
182        self.viewers = self.viewers.saturating_add(1);
183        let query = scratch.query_stats();
184        match query.strategy {
185            CellQueryStrategy::Grid => self.grid_queries = self.grid_queries.saturating_add(1),
186            CellQueryStrategy::OccupiedCells => {
187                self.occupied_queries = self.occupied_queries.saturating_add(1);
188            }
189        }
190        self.grid_cells_probed = self
191            .grid_cells_probed
192            .saturating_add(query.grid_cells_probed);
193        self.occupied_cells_scanned = self
194            .occupied_cells_scanned
195            .saturating_add(query.occupied_cells_scanned);
196        self.matched_cells = self.matched_cells.saturating_add(query.matched_cells);
197    }
198
199    fn record_reused_query(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
200        self.record_plan(plan, scratch);
201        self.viewers = self.viewers.saturating_add(1);
202        self.reused_query_ranges = self.reused_query_ranges.saturating_add(1);
203    }
204
205    fn record_plan(&mut self, plan: &ReplicationPlan, scratch: &ReplicationScratch) {
206        self.candidates = self.candidates.saturating_add(plan.stats.candidates);
207        self.considered = self.considered.saturating_add(plan.stats.considered);
208        self.selected = self.selected.saturating_add(plan.stats.selected);
209        self.unexamined_after_budget = self
210            .unexamined_after_budget
211            .saturating_add(plan.stats.unexamined_after_budget);
212        self.estimated_bytes = self
213            .estimated_bytes
214            .saturating_add(plan.stats.estimated_bytes);
215        self.candidate_capacity_max = self
216            .candidate_capacity_max
217            .max(scratch.candidate_capacity());
218        self.dedup_capacity_max = self
219            .dedup_capacity_max
220            .max(scratch.candidate_dedup_capacity());
221        self.matching_cell_capacity_max = self
222            .matching_cell_capacity_max
223            .max(scratch.matching_cell_capacity());
224        self.priority_capacity_max = self
225            .priority_capacity_max
226            .max(scratch.prioritized_capacity());
227    }
228
229    /// Merges another deterministic batch partition into this report.
230    pub fn merge(&mut self, other: Self) {
231        self.viewers = self.viewers.saturating_add(other.viewers);
232        self.candidates = self.candidates.saturating_add(other.candidates);
233        self.considered = self.considered.saturating_add(other.considered);
234        self.selected = self.selected.saturating_add(other.selected);
235        self.estimated_bytes = self.estimated_bytes.saturating_add(other.estimated_bytes);
236        self.grid_queries = self.grid_queries.saturating_add(other.grid_queries);
237        self.occupied_queries = self.occupied_queries.saturating_add(other.occupied_queries);
238        self.grid_cells_probed = self
239            .grid_cells_probed
240            .saturating_add(other.grid_cells_probed);
241        self.occupied_cells_scanned = self
242            .occupied_cells_scanned
243            .saturating_add(other.occupied_cells_scanned);
244        self.matched_cells = self.matched_cells.saturating_add(other.matched_cells);
245        self.candidate_capacity_max = self
246            .candidate_capacity_max
247            .max(other.candidate_capacity_max);
248        self.dedup_capacity_max = self.dedup_capacity_max.max(other.dedup_capacity_max);
249        self.matching_cell_capacity_max = self
250            .matching_cell_capacity_max
251            .max(other.matching_cell_capacity_max);
252        self.priority_capacity_max = self.priority_capacity_max.max(other.priority_capacity_max);
253        self.unique_query_ranges = self
254            .unique_query_ranges
255            .saturating_add(other.unique_query_ranges);
256        self.reused_query_ranges = self
257            .reused_query_ranges
258            .saturating_add(other.reused_query_ranges);
259        self.cached_candidate_handles = self
260            .cached_candidate_handles
261            .saturating_add(other.cached_candidate_handles);
262        self.query_cache_capacity_max = self
263            .query_cache_capacity_max
264            .max(other.query_cache_capacity_max);
265        self.query_cache_candidate_capacity_max = self
266            .query_cache_candidate_capacity_max
267            .max(other.query_cache_candidate_capacity_max);
268    }
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
272struct BatchQueryKey {
273    min: CellCoord3,
274    max: CellCoord3,
275}
276
277#[derive(Clone, Debug)]
278struct CachedBatchQuery {
279    handles: Vec<EntityHandle>,
280}
281
282/// Ordered plans and aggregate statistics produced for a viewer batch.
283#[derive(Clone, Debug, Default, PartialEq, Eq)]
284pub struct ReplicationBatchResult {
285    /// One plan per input viewer, retaining input order.
286    pub plans: Vec<ReplicationPlan>,
287    /// Aggregate work signals for the batch.
288    pub stats: ReplicationBatchStats,
289}
290
291/// Borrowed ordered plans produced from reusable batch storage.
292#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293pub struct ReplicationBatchView<'a> {
294    /// One plan per input viewer, retaining input order.
295    pub plans: &'a [ReplicationPlan],
296    /// Aggregate work signals for the active plans.
297    pub stats: ReplicationBatchStats,
298}
299
300/// Caller-owned reusable output storage for viewer batch planning.
301///
302/// Plan slots and their entity buffers grow to the largest observed batch and
303/// are retained for later calls. This storage contains no cross-client send or
304/// acknowledgement state.
305#[derive(Clone, Debug, Default)]
306pub struct ReplicationBatchScratch {
307    plans: Vec<ReplicationPlan>,
308    active_plans: usize,
309    stats: ReplicationBatchStats,
310    query_counts: HashMap<BatchQueryKey, usize>,
311    query_cache_index: HashMap<BatchQueryKey, usize>,
312    query_cache: Vec<CachedBatchQuery>,
313    active_query_cache: usize,
314}
315
316impl ReplicationBatchScratch {
317    /// Creates empty batch output storage.
318    pub fn new() -> Self {
319        Self {
320            plans: Vec::new(),
321            active_plans: 0,
322            stats: ReplicationBatchStats::default(),
323            query_counts: HashMap::new(),
324            query_cache_index: HashMap::new(),
325            query_cache: Vec::new(),
326            active_query_cache: 0,
327        }
328    }
329
330    /// Number of plan slots retained for reuse.
331    pub fn retained_plan_slots(&self) -> usize {
332        self.plans.len()
333    }
334
335    /// Total selected-entity capacity retained across all plan slots.
336    pub fn retained_entity_capacity(&self) -> usize {
337        self.plans.iter().map(|plan| plan.entities.capacity()).sum()
338    }
339
340    /// Query-range cache entries retained for later configured batch calls.
341    pub fn retained_query_cache_slots(&self) -> usize {
342        self.query_cache.len()
343    }
344
345    /// Candidate-handle capacity retained across query-range cache entries.
346    pub fn retained_query_cache_candidate_capacity(&self) -> usize {
347        self.query_cache
348            .iter()
349            .map(|entry| entry.handles.capacity())
350            .sum()
351    }
352
353    /// Returns the active result from the most recent planning call.
354    pub fn view(&self) -> ReplicationBatchView<'_> {
355        ReplicationBatchView {
356            plans: &self.plans[..self.active_plans],
357            stats: self.stats,
358        }
359    }
360
361    fn prepare(&mut self, plans: usize) {
362        if self.plans.len() < plans {
363            self.plans.resize_with(plans, ReplicationPlan::default);
364        }
365        self.active_plans = plans;
366        self.stats = ReplicationBatchStats::default();
367        self.query_counts.clear();
368        self.query_cache_index.clear();
369        self.active_query_cache = 0;
370    }
371
372    fn prepare_query_ranges(&mut self, index: &CellIndex, viewers: &[ViewerQuery]) {
373        for viewer in viewers {
374            let key = batch_query_key(index, viewer);
375            let count = self.query_counts.entry(key).or_insert(0);
376            *count = count.saturating_add(1);
377        }
378        self.stats.unique_query_ranges = self.query_counts.len();
379    }
380
381    fn query_repeats(&self, key: BatchQueryKey) -> bool {
382        self.query_counts.get(&key).is_some_and(|count| *count > 1)
383    }
384
385    fn cached_query(&self, key: BatchQueryKey) -> Option<usize> {
386        self.query_cache_index.get(&key).copied()
387    }
388
389    fn cache_query(
390        &mut self,
391        key: BatchQueryKey,
392        handles: &[EntityHandle],
393        max_cached_query_ranges: usize,
394    ) {
395        if self.active_query_cache >= max_cached_query_ranges {
396            return;
397        }
398        let cache_index = self.active_query_cache;
399        if cache_index == self.query_cache.len() {
400            self.query_cache.push(CachedBatchQuery {
401                handles: Vec::new(),
402            });
403        }
404        let entry = &mut self.query_cache[cache_index];
405        entry.handles.clear();
406        entry.handles.extend_from_slice(handles);
407        self.query_cache_index.insert(key, cache_index);
408        self.active_query_cache = self.active_query_cache.saturating_add(1);
409        self.stats.cached_candidate_handles = self
410            .stats
411            .cached_candidate_handles
412            .saturating_add(handles.len());
413    }
414
415    fn record_query_cache_capacity(&mut self) {
416        self.stats.query_cache_capacity_max = self.query_cache.len();
417        self.stats.query_cache_candidate_capacity_max = self
418            .query_cache
419            .iter()
420            .map(|entry| entry.handles.capacity())
421            .sum();
422    }
423}
424
425fn batch_query_key(index: &CellIndex, viewer: &ViewerQuery) -> BatchQueryKey {
426    let aabb = Bounds::Sphere {
427        radius: viewer.radius,
428    }
429    .to_aabb(viewer.position);
430    BatchQueryKey {
431        min: index.grid().cell_at(aabb.min),
432        max: index.grid().cell_at(aabb.max),
433    }
434}
435
436/// Bounded per-client replication tracking configuration.
437#[derive(Clone, Copy, Debug, PartialEq, Eq)]
438pub struct ReplicationTrackerConfig {
439    /// Maximum tracked client/entity entries.
440    pub max_entries: usize,
441}
442
443impl Default for ReplicationTrackerConfig {
444    fn default() -> Self {
445        Self {
446            max_entries: 65_536,
447        }
448    }
449}
450
451/// Per-client/entity replication tracking key.
452#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
453pub struct ReplicationTrackKey {
454    /// Client that received the entity update.
455    pub client_id: ClientId,
456    /// Station-local entity handle selected by the planner.
457    pub entity: EntityHandle,
458}
459
460/// Per-client/entity replication tracking record.
461#[derive(Clone, Copy, Debug, PartialEq, Eq)]
462pub struct ReplicationTrackRecord {
463    /// Client that received the entity update.
464    pub client_id: ClientId,
465    /// Station-local entity handle selected by the planner.
466    pub entity: EntityHandle,
467    /// Last tick where this entity was sent to the client.
468    pub last_sent: Tick,
469    /// Last tick where the caller confirmed delivery.
470    pub last_acked: Option<Tick>,
471}
472
473/// Replication tracker statistics.
474#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
475pub struct ReplicationTrackerStats {
476    /// Currently tracked entries.
477    pub entries: usize,
478    /// Total record insertions or updates.
479    pub sent_records: usize,
480    /// Total ACK updates applied.
481    pub acked_records: usize,
482    /// Records pruned by explicit cleanup.
483    pub pruned_records: usize,
484}
485
486/// Replication tracking error.
487#[derive(Clone, Copy, Debug, PartialEq, Eq)]
488pub enum ReplicationTrackerError {
489    /// Recording would exceed the configured entry capacity.
490    CapacityExceeded {
491        /// Entries currently tracked.
492        current: usize,
493        /// New entries needed for this operation.
494        needed: usize,
495        /// Maximum tracked entries.
496        max: usize,
497    },
498}
499
500impl core::fmt::Display for ReplicationTrackerError {
501    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
502        match self {
503            Self::CapacityExceeded {
504                current,
505                needed,
506                max,
507            } => write!(
508                f,
509                "replication tracker capacity exceeded: current {current}, needed {needed}, max {max}"
510            ),
511        }
512    }
513}
514
515impl std::error::Error for ReplicationTrackerError {}
516
517/// Bounded per-client replication send/ACK tracker.
518#[derive(Clone, Debug)]
519pub struct ReplicationTracker {
520    config: ReplicationTrackerConfig,
521    records: AdaptiveTrackMap<ReplicationTrackKey, ReplicationTrackRecord>,
522    stats: ReplicationTrackerStats,
523}
524
525impl Default for ReplicationTracker {
526    fn default() -> Self {
527        Self::new(ReplicationTrackerConfig::default())
528    }
529}
530
531impl ReplicationTracker {
532    /// Creates an empty tracker.
533    pub fn new(config: ReplicationTrackerConfig) -> Self {
534        Self {
535            config,
536            records: AdaptiveTrackMap::new(),
537            stats: ReplicationTrackerStats::default(),
538        }
539    }
540
541    /// Returns tracker configuration.
542    pub const fn config(&self) -> ReplicationTrackerConfig {
543        self.config
544    }
545
546    /// Returns tracker statistics.
547    pub const fn stats(&self) -> ReplicationTrackerStats {
548        self.stats
549    }
550
551    /// Returns tracked entry count.
552    pub fn len(&self) -> usize {
553        self.records.len()
554    }
555
556    /// Returns whether no entries are tracked.
557    pub fn is_empty(&self) -> bool {
558        self.records.is_empty()
559    }
560
561    /// Returns the last sent tick for a client/entity pair.
562    pub fn last_sent(&self, client_id: ClientId, entity: EntityHandle) -> Option<Tick> {
563        self.records
564            .get(&ReplicationTrackKey { client_id, entity })
565            .map(|record| record.last_sent)
566    }
567
568    /// Returns a tracked record for a client/entity pair.
569    pub fn get(&self, client_id: ClientId, entity: EntityHandle) -> Option<ReplicationTrackRecord> {
570        self.records
571            .get(&ReplicationTrackKey { client_id, entity })
572            .copied()
573    }
574
575    /// Records that a planned set of entities was sent to a client.
576    pub fn record_plan_sent(
577        &mut self,
578        client_id: ClientId,
579        plan: &ReplicationPlan,
580        sent_at: Tick,
581    ) -> Result<usize, ReplicationTrackerError> {
582        self.ensure_capacity_for(client_id, &plan.entities)?;
583        let mut recorded = 0;
584        for entity in &plan.entities {
585            let key = ReplicationTrackKey {
586                client_id,
587                entity: *entity,
588            };
589            self.records.insert(
590                key,
591                ReplicationTrackRecord {
592                    client_id,
593                    entity: *entity,
594                    last_sent: sent_at,
595                    last_acked: None,
596                },
597            );
598            recorded += 1;
599        }
600        self.refresh_entry_count();
601        self.stats.sent_records = self.stats.sent_records.saturating_add(recorded);
602        Ok(recorded)
603    }
604
605    /// Records delivery acknowledgement for one client/entity pair.
606    pub fn acknowledge(
607        &mut self,
608        client_id: ClientId,
609        entity: EntityHandle,
610        acked_at: Tick,
611    ) -> bool {
612        let Some(record) = self
613            .records
614            .get_mut(&ReplicationTrackKey { client_id, entity })
615        else {
616            return false;
617        };
618        record.last_acked = Some(acked_at);
619        self.stats.acked_records = self.stats.acked_records.saturating_add(1);
620        true
621    }
622
623    /// Records delivery acknowledgement for every entity in a plan.
624    pub fn acknowledge_plan(
625        &mut self,
626        client_id: ClientId,
627        plan: &ReplicationPlan,
628        acked_at: Tick,
629    ) -> usize {
630        plan.entities
631            .iter()
632            .filter(|entity| self.acknowledge(client_id, **entity, acked_at))
633            .count()
634    }
635
636    /// Removes all entries for one client.
637    pub fn clear_client(&mut self, client_id: ClientId) -> usize {
638        let before = self.records.len();
639        self.records.retain(|key, _| key.client_id != client_id);
640        let pruned = before.saturating_sub(self.records.len());
641        self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
642        self.refresh_entry_count();
643        pruned
644    }
645
646    /// Removes entries last sent before `older_than`.
647    pub fn prune_sent_before(&mut self, older_than: Tick) -> usize {
648        let before = self.records.len();
649        self.records
650            .retain(|_, record| record.last_sent.get() >= older_than.get());
651        let pruned = before.saturating_sub(self.records.len());
652        self.stats.pruned_records = self.stats.pruned_records.saturating_add(pruned);
653        self.refresh_entry_count();
654        pruned
655    }
656
657    fn ensure_capacity_for(
658        &self,
659        client_id: ClientId,
660        entities: &[EntityHandle],
661    ) -> Result<(), ReplicationTrackerError> {
662        if self.records.len().saturating_add(entities.len()) <= self.config.max_entries {
663            return Ok(());
664        }
665        let mut needed = 0_usize;
666        for entity in entities {
667            if !self.records.contains_key(&ReplicationTrackKey {
668                client_id,
669                entity: *entity,
670            }) {
671                needed = needed.saturating_add(1);
672            }
673        }
674        if self.records.len().saturating_add(needed) > self.config.max_entries {
675            return Err(ReplicationTrackerError::CapacityExceeded {
676                current: self.records.len(),
677                needed,
678                max: self.config.max_entries,
679            });
680        }
681        Ok(())
682    }
683
684    fn refresh_entry_count(&mut self) {
685        self.stats.entries = self.records.len();
686    }
687}
688
689/// Replication planner statistics.
690#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
691pub struct ReplicationStats {
692    /// Candidate handles returned from the spatial index.
693    pub candidates: usize,
694    /// Candidate records considered after stale handle filtering.
695    pub considered: usize,
696    /// Selected entities.
697    pub selected: usize,
698    /// Entities skipped because the budget was exhausted.
699    pub skipped_by_budget: usize,
700    /// Spatial candidates not examined after a work-bounded budget filled.
701    pub unexamined_after_budget: usize,
702    /// Entities skipped because their cadence interval has not elapsed.
703    pub skipped_by_cadence: usize,
704    /// Estimated frame bytes.
705    pub estimated_bytes: usize,
706}
707
708/// Stateless distance-based replication cadence helper.
709#[derive(Clone, Copy, Debug, Default)]
710pub struct ReplicationCadence;
711
712impl ReplicationCadence {
713    /// Returns the target update frequency for a policy at a squared distance.
714    pub fn target_hz(policy: &CompiledSyncPolicy, distance_squared: f32) -> u16 {
715        let min_hz = policy.min_hz.max(1);
716        let max_hz = policy.max_hz.max(min_hz);
717        let radius_squared = policy.interest_radius * policy.interest_radius;
718        let closeness =
719            if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
720                1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0)
721            } else {
722                1.0
723            };
724        let span = f32::from(max_hz - min_hz);
725        let target = f32::from(min_hz) + span * closeness;
726        rounded_frequency_to_u16(target, min_hz, max_hz)
727    }
728
729    /// Returns the tick interval for a policy at a squared distance.
730    pub fn interval_ticks(
731        policy: &CompiledSyncPolicy,
732        station_tick_rate_hz: u16,
733        distance_squared: f32,
734    ) -> u64 {
735        let tick_rate = u64::from(station_tick_rate_hz.max(1));
736        let target_hz = u64::from(Self::target_hz(policy, distance_squared).max(1));
737        tick_rate.div_ceil(target_hz).max(1)
738    }
739
740    /// Returns whether a replication update should be sent at `now`.
741    pub fn should_send(
742        policy: &CompiledSyncPolicy,
743        station_tick_rate_hz: u16,
744        distance_squared: f32,
745        now: Tick,
746        last_sent: Option<Tick>,
747    ) -> bool {
748        let Some(last_sent) = last_sent else {
749            return true;
750        };
751        let interval = Self::interval_ticks(policy, station_tick_rate_hz, distance_squared);
752        now.get().saturating_sub(last_sent.get()) >= interval
753    }
754}
755
756/// Stateless replication priority scoring helper.
757#[derive(Clone, Copy, Debug, Default)]
758pub struct ReplicationPriority;
759
760#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
761fn rounded_frequency_to_u16(target: f32, min_hz: u16, max_hz: u16) -> u16 {
762    let bounded = target.round().clamp(f32::from(min_hz), f32::from(max_hz));
763    bounded as u16
764}
765
766#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
767fn normalized_score_to_u64(closeness: f32) -> u64 {
768    debug_assert!(closeness.is_finite() && (0.0..=1.0).contains(&closeness));
769    (closeness * 1_000_000.0).round() as u64
770}
771
772impl ReplicationPriority {
773    /// Returns a deterministic priority score for budgeted selection.
774    pub fn score(policy: &CompiledSyncPolicy, distance_squared: f32) -> u64 {
775        let weight = u64::from(policy.priority_weight.max(1));
776        let radius_squared = policy.interest_radius * policy.interest_radius;
777        let distance_score =
778            if radius_squared.is_finite() && radius_squared > 0.0 && distance_squared.is_finite() {
779                let closeness = 1.0 - (distance_squared / radius_squared).clamp(0.0, 1.0);
780                normalized_score_to_u64(closeness)
781            } else {
782                1_000_000
783            };
784        weight
785            .saturating_mul(1_000_000)
786            .saturating_add(distance_score)
787    }
788}
789
790#[derive(Clone, Copy, Debug, PartialEq)]
791struct PrioritizedReplicationCandidate {
792    handle: EntityHandle,
793    score: u64,
794    distance_squared: f32,
795}
796
797/// Reusable scratch storage for allocation-aware replication planning.
798#[derive(Clone, Debug, Default)]
799pub struct ReplicationScratch {
800    cell_query: CellQueryScratch,
801    prioritized: Vec<PrioritizedReplicationCandidate>,
802}
803
804impl ReplicationScratch {
805    /// Clears retained planning results while keeping allocated capacity.
806    pub fn clear(&mut self) {
807        self.cell_query.clear();
808        self.prioritized.clear();
809    }
810
811    /// Number of spatial candidates retained from the last query.
812    pub fn candidate_count(&self) -> usize {
813        self.cell_query.len()
814    }
815
816    /// Capacity retained for priority candidate sorting.
817    pub fn prioritized_capacity(&self) -> usize {
818        self.prioritized.capacity()
819    }
820
821    /// Work counters from the last spatial candidate query.
822    pub const fn query_stats(&self) -> CellQueryStats {
823        self.cell_query.stats()
824    }
825
826    /// Capacity retained for spatial candidate handles.
827    pub fn candidate_capacity(&self) -> usize {
828        self.cell_query.handle_capacity()
829    }
830
831    /// Capacity retained by spatial candidate deduplication.
832    pub fn candidate_dedup_capacity(&self) -> usize {
833        self.cell_query.dedup_capacity()
834    }
835
836    /// Capacity retained for cells matched by sparse spatial queries.
837    pub fn matching_cell_capacity(&self) -> usize {
838        self.cell_query.matching_cell_capacity()
839    }
840}
841
842/// Simple range/visibility-based replication planner.
843#[derive(Clone, Copy, Debug, Default)]
844pub struct ReplicationPlanner;
845
846/// Selection semantics for the canonical configured replication kernel.
847#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
848pub enum ReplicationSelectionMode {
849    /// Deterministic first-fit selection that stops once the budget is full.
850    #[default]
851    Throughput,
852    /// Deterministic global priority selection over every eligible candidate.
853    Prioritized,
854}
855
856impl ReplicationPlanner {
857    /// Plans one viewer through the canonical allocation-light configured kernel.
858    ///
859    /// `eligible` applies caller-owned dirty or delivery state before budget
860    /// consumption. `last_sent` supplies optional caller-owned cadence state;
861    /// returning `None` admits the candidate without prior-send delay.
862    #[allow(clippy::too_many_arguments)]
863    pub fn plan_for_viewer_configured_into<F, E, L>(
864        station: &Station,
865        index: &CellIndex,
866        policies: &PolicyTable,
867        viewer: &ViewerQuery,
868        filter: &F,
869        budget: ReplicationBudget,
870        mode: ReplicationSelectionMode,
871        eligible: E,
872        last_sent: L,
873        scratch: &mut ReplicationScratch,
874        plan: &mut ReplicationPlan,
875    ) where
876        F: VisibilityFilter,
877        E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
878        L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
879    {
880        index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
881        let candidates = scratch.cell_query.handles();
882        Self::plan_for_candidates_configured_into(
883            station,
884            candidates,
885            policies,
886            viewer,
887            filter,
888            budget,
889            mode,
890            eligible,
891            last_sent,
892            &mut scratch.prioritized,
893            plan,
894        );
895    }
896
897    /// Plans viewers in input order through the configured reusable kernel.
898    #[allow(clippy::too_many_arguments)]
899    pub fn plan_for_viewers_configured_into<'a, F, E, L>(
900        station: &Station,
901        index: &CellIndex,
902        policies: &PolicyTable,
903        viewers: &[ViewerQuery],
904        filter: &F,
905        budget: ReplicationBudget,
906        mode: ReplicationSelectionMode,
907        max_cached_query_ranges: usize,
908        eligible: E,
909        last_sent: L,
910        scratch: &mut ReplicationScratch,
911        batch: &'a mut ReplicationBatchScratch,
912    ) -> ReplicationBatchView<'a>
913    where
914        F: VisibilityFilter,
915        E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
916        L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
917    {
918        batch.prepare(viewers.len());
919        batch.prepare_query_ranges(index, viewers);
920        for (viewer_index, viewer) in viewers.iter().enumerate() {
921            let key = batch_query_key(index, viewer);
922            if let Some(cache_index) = batch.cached_query(key) {
923                {
924                    let candidates = &batch.query_cache[cache_index].handles;
925                    let plan = &mut batch.plans[viewer_index];
926                    Self::plan_for_candidates_configured_into(
927                        station,
928                        candidates,
929                        policies,
930                        viewer,
931                        filter,
932                        budget,
933                        mode,
934                        &eligible,
935                        &last_sent,
936                        &mut scratch.prioritized,
937                        plan,
938                    );
939                }
940                batch
941                    .stats
942                    .record_reused_query(&batch.plans[viewer_index], scratch);
943                continue;
944            }
945
946            index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
947            if batch.query_repeats(key) {
948                batch.cache_query(key, scratch.cell_query.handles(), max_cached_query_ranges);
949            }
950            {
951                let candidates = scratch.cell_query.handles();
952                let plan = &mut batch.plans[viewer_index];
953                Self::plan_for_candidates_configured_into(
954                    station,
955                    candidates,
956                    policies,
957                    viewer,
958                    filter,
959                    budget,
960                    mode,
961                    &eligible,
962                    &last_sent,
963                    &mut scratch.prioritized,
964                    plan,
965                );
966            }
967            batch.stats.record(&batch.plans[viewer_index], scratch);
968        }
969        batch.record_query_cache_capacity();
970        batch.view()
971    }
972
973    #[allow(clippy::too_many_arguments)]
974    fn plan_for_candidates_configured_into<F, E, L>(
975        station: &Station,
976        candidates: &[EntityHandle],
977        policies: &PolicyTable,
978        viewer: &ViewerQuery,
979        filter: &F,
980        budget: ReplicationBudget,
981        mode: ReplicationSelectionMode,
982        eligible: E,
983        last_sent: L,
984        prioritized: &mut Vec<PrioritizedReplicationCandidate>,
985        plan: &mut ReplicationPlan,
986    ) where
987        F: VisibilityFilter,
988        E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
989        L: Fn(&ViewerQuery, EntityHandle) -> Option<Tick>,
990    {
991        let tick_rate_hz = station.config().tick_rate_hz;
992        let now = station.tick();
993        match mode {
994            ReplicationSelectionMode::Throughput => Self::plan_for_candidates_inner_into(
995                station,
996                candidates,
997                policies,
998                viewer,
999                filter,
1000                budget,
1001                |handle, policy, distance_squared| {
1002                    ReplicationCadence::should_send(
1003                        policy,
1004                        tick_rate_hz,
1005                        distance_squared,
1006                        now,
1007                        last_sent(viewer, handle),
1008                    )
1009                },
1010                eligible,
1011                true,
1012                plan,
1013            ),
1014            ReplicationSelectionMode::Prioritized => {
1015                Self::plan_for_candidates_prioritized_inner_into(
1016                    station,
1017                    candidates,
1018                    policies,
1019                    viewer,
1020                    filter,
1021                    budget,
1022                    prioritized,
1023                    |handle, policy, distance_squared| {
1024                        ReplicationCadence::should_send(
1025                            policy,
1026                            tick_rate_hz,
1027                            distance_squared,
1028                            now,
1029                            last_sent(viewer, handle),
1030                        )
1031                    },
1032                    eligible,
1033                    plan,
1034                );
1035            }
1036        }
1037    }
1038
1039    /// Plans one viewer into caller-owned scratch and output storage.
1040    #[allow(clippy::too_many_arguments)]
1041    pub fn plan_for_viewer_into<F: VisibilityFilter>(
1042        station: &Station,
1043        index: &CellIndex,
1044        policies: &PolicyTable,
1045        viewer: &ViewerQuery,
1046        filter: &F,
1047        budget: ReplicationBudget,
1048        scratch: &mut ReplicationScratch,
1049        plan: &mut ReplicationPlan,
1050    ) {
1051        Self::plan_for_viewer_configured_into(
1052            station,
1053            index,
1054            policies,
1055            viewer,
1056            filter,
1057            budget,
1058            ReplicationSelectionMode::Throughput,
1059            |_, _, _| true,
1060            |_, _| None,
1061            scratch,
1062            plan,
1063        );
1064    }
1065
1066    /// Plans viewers into caller-owned scratch and ordered output storage.
1067    #[allow(clippy::too_many_arguments)]
1068    pub fn plan_for_viewers_into<'a, F: VisibilityFilter>(
1069        station: &Station,
1070        index: &CellIndex,
1071        policies: &PolicyTable,
1072        viewers: &[ViewerQuery],
1073        filter: &F,
1074        budget: ReplicationBudget,
1075        scratch: &mut ReplicationScratch,
1076        batch: &'a mut ReplicationBatchScratch,
1077    ) -> ReplicationBatchView<'a> {
1078        Self::plan_for_viewers_configured_into(
1079            station,
1080            index,
1081            policies,
1082            viewers,
1083            filter,
1084            budget,
1085            ReplicationSelectionMode::Throughput,
1086            0,
1087            |_, _, _| true,
1088            |_, _| None,
1089            scratch,
1090            batch,
1091        )
1092    }
1093
1094    /// Plans one range-only viewer into reusable output with optional SIMD filtering.
1095    #[allow(clippy::too_many_arguments)]
1096    pub fn plan_for_viewer_range_into(
1097        station: &Station,
1098        index: &CellIndex,
1099        policies: &PolicyTable,
1100        viewer: &ViewerQuery,
1101        budget: ReplicationBudget,
1102        scratch: &mut ReplicationScratch,
1103        plan: &mut ReplicationPlan,
1104    ) {
1105        let candidates =
1106            index.query_sphere_into(viewer.position, viewer.radius, &mut scratch.cell_query);
1107        Self::plan_for_range_candidates_into(station, candidates, policies, viewer, budget, plan);
1108    }
1109
1110    /// Plans a range-only viewer batch into caller-owned reusable output slots.
1111    #[allow(clippy::too_many_arguments)]
1112    pub fn plan_for_viewers_range_into<'a>(
1113        station: &Station,
1114        index: &CellIndex,
1115        policies: &PolicyTable,
1116        viewers: &[ViewerQuery],
1117        budget: ReplicationBudget,
1118        scratch: &mut ReplicationScratch,
1119        batch: &'a mut ReplicationBatchScratch,
1120    ) -> ReplicationBatchView<'a> {
1121        batch.prepare(viewers.len());
1122        for (plan, viewer) in batch.plans[..viewers.len()].iter_mut().zip(viewers) {
1123            Self::plan_for_viewer_range_into(
1124                station, index, policies, viewer, budget, scratch, plan,
1125            );
1126            batch.stats.record(plan, scratch);
1127        }
1128        batch.view()
1129    }
1130
1131    #[cfg(test)]
1132    fn plan_for_candidates_inner<F, C>(
1133        station: &Station,
1134        candidates: &[EntityHandle],
1135        policies: &PolicyTable,
1136        viewer: &ViewerQuery,
1137        filter: &F,
1138        budget: ReplicationBudget,
1139        cadence_allows: C,
1140    ) -> ReplicationPlan
1141    where
1142        F: VisibilityFilter,
1143        C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1144    {
1145        let mut plan = ReplicationPlan::default();
1146        Self::plan_for_candidates_inner_into(
1147            station,
1148            candidates,
1149            policies,
1150            viewer,
1151            filter,
1152            budget,
1153            cadence_allows,
1154            |_, _, _| true,
1155            false,
1156            &mut plan,
1157        );
1158        plan
1159    }
1160
1161    #[allow(clippy::too_many_arguments)]
1162    fn plan_for_candidates_inner_into<F, C, E>(
1163        station: &Station,
1164        candidates: &[EntityHandle],
1165        policies: &PolicyTable,
1166        viewer: &ViewerQuery,
1167        filter: &F,
1168        budget: ReplicationBudget,
1169        cadence_allows: C,
1170        eligible: E,
1171        stop_when_budget_full: bool,
1172        plan: &mut ReplicationPlan,
1173    ) where
1174        F: VisibilityFilter,
1175        C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1176        E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
1177    {
1178        let max_entities = viewer.max_entities.min(budget.max_entities);
1179        let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1180        let hard_limit = max_entities.min(max_by_bytes);
1181        plan.entities.clear();
1182        plan.entities.reserve(hard_limit.min(candidates.len()));
1183        plan.stats = ReplicationStats {
1184            candidates: candidates.len(),
1185            ..ReplicationStats::default()
1186        };
1187
1188        if stop_when_budget_full && hard_limit == 0 {
1189            plan.stats.unexamined_after_budget = candidates.len();
1190            return;
1191        }
1192
1193        for (candidate_index, handle) in candidates.iter().enumerate() {
1194            let Some(entity) = station.get(*handle) else {
1195                continue;
1196            };
1197            plan.stats.considered += 1;
1198
1199            let Some(policy) = policies.get(entity.policy_id) else {
1200                continue;
1201            };
1202            let distance_squared = entity.position.distance_squared(viewer.position);
1203            let policy_radius_sq = policy.interest_radius * policy.interest_radius;
1204            if distance_squared > policy_radius_sq {
1205                continue;
1206            }
1207            if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
1208                continue;
1209            }
1210            if !cadence_allows(*handle, policy, distance_squared) {
1211                plan.stats.skipped_by_cadence += 1;
1212                continue;
1213            }
1214            if !eligible(viewer, *handle, entity) {
1215                continue;
1216            }
1217
1218            if plan.entities.len() >= hard_limit {
1219                plan.stats.skipped_by_budget += 1;
1220                continue;
1221            }
1222
1223            plan.entities.push(*handle);
1224            if stop_when_budget_full && plan.entities.len() == hard_limit {
1225                plan.stats.unexamined_after_budget =
1226                    candidates.len().saturating_sub(candidate_index + 1);
1227                break;
1228            }
1229        }
1230
1231        plan.stats.selected = plan.entities.len();
1232        plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1233    }
1234
1235    #[cfg(all(not(feature = "simd"), test))]
1236    fn plan_for_range_candidates(
1237        station: &Station,
1238        candidates: &[EntityHandle],
1239        policies: &PolicyTable,
1240        viewer: &ViewerQuery,
1241        budget: ReplicationBudget,
1242    ) -> ReplicationPlan {
1243        let mut plan = ReplicationPlan::default();
1244        Self::plan_for_range_candidates_into(
1245            station, candidates, policies, viewer, budget, &mut plan,
1246        );
1247        plan
1248    }
1249
1250    #[cfg(not(feature = "simd"))]
1251    fn plan_for_range_candidates_into(
1252        station: &Station,
1253        candidates: &[EntityHandle],
1254        policies: &PolicyTable,
1255        viewer: &ViewerQuery,
1256        budget: ReplicationBudget,
1257        plan: &mut ReplicationPlan,
1258    ) {
1259        Self::plan_for_candidates_inner_into(
1260            station,
1261            candidates,
1262            policies,
1263            viewer,
1264            &RangeOnlyVisibility,
1265            budget,
1266            |_, _, _| true,
1267            |_, _, _| true,
1268            false,
1269            plan,
1270        );
1271    }
1272
1273    #[cfg(all(feature = "simd", test))]
1274    fn plan_for_range_candidates(
1275        station: &Station,
1276        candidates: &[EntityHandle],
1277        policies: &PolicyTable,
1278        viewer: &ViewerQuery,
1279        budget: ReplicationBudget,
1280    ) -> ReplicationPlan {
1281        let mut plan = ReplicationPlan::default();
1282        Self::plan_for_range_candidates_into(
1283            station, candidates, policies, viewer, budget, &mut plan,
1284        );
1285        plan
1286    }
1287
1288    #[cfg(feature = "simd")]
1289    fn plan_for_range_candidates_into(
1290        station: &Station,
1291        candidates: &[EntityHandle],
1292        policies: &PolicyTable,
1293        viewer: &ViewerQuery,
1294        budget: ReplicationBudget,
1295        plan: &mut ReplicationPlan,
1296    ) {
1297        use wide::{CmpLe, f32x8};
1298
1299        const LANES: usize = 8;
1300        let max_entities = viewer.max_entities.min(budget.max_entities);
1301        let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1302        let hard_limit = max_entities.min(max_by_bytes);
1303        plan.entities.clear();
1304        plan.entities.reserve(hard_limit.min(candidates.len()));
1305        plan.stats = ReplicationStats {
1306            candidates: candidates.len(),
1307            ..ReplicationStats::default()
1308        };
1309        let viewer_radius_squared = viewer.radius_squared();
1310
1311        for handles in candidates.chunks(LANES) {
1312            let mut distance_squared = [f32::NAN; LANES];
1313            let mut policy_radius_squared = [f32::NAN; LANES];
1314            let mut valid_lanes = 0_u8;
1315
1316            for (lane, handle) in handles.iter().copied().enumerate() {
1317                let Some(entity) = station.get(handle) else {
1318                    continue;
1319                };
1320                plan.stats.considered = plan.stats.considered.saturating_add(1);
1321                let Some(policy) = policies.get(entity.policy_id) else {
1322                    continue;
1323                };
1324                distance_squared[lane] = entity.position.distance_squared(viewer.position);
1325                policy_radius_squared[lane] = policy.interest_radius * policy.interest_radius;
1326                valid_lanes |= 1 << lane;
1327            }
1328
1329            let visible_lanes = u8::try_from(
1330                (f32x8::new(distance_squared).cmp_le(f32x8::new(policy_radius_squared))
1331                    & f32x8::new(distance_squared).cmp_le(f32x8::splat(viewer_radius_squared)))
1332                .move_mask(),
1333            )
1334            .expect("eight-lane SIMD mask fits u8")
1335                & valid_lanes;
1336
1337            for (lane, handle) in handles.iter().copied().enumerate() {
1338                if visible_lanes & (1 << lane) == 0 {
1339                    continue;
1340                }
1341                if plan.entities.len() >= hard_limit {
1342                    plan.stats.skipped_by_budget = plan.stats.skipped_by_budget.saturating_add(1);
1343                } else {
1344                    plan.entities.push(handle);
1345                }
1346            }
1347        }
1348
1349        plan.stats.selected = plan.entities.len();
1350        plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1351    }
1352
1353    #[allow(clippy::too_many_arguments)]
1354    #[allow(clippy::too_many_arguments)]
1355    fn plan_for_candidates_prioritized_inner_into<F, C, E>(
1356        station: &Station,
1357        candidates: &[EntityHandle],
1358        policies: &PolicyTable,
1359        viewer: &ViewerQuery,
1360        filter: &F,
1361        budget: ReplicationBudget,
1362        eligible: &mut Vec<PrioritizedReplicationCandidate>,
1363        cadence_allows: C,
1364        candidate_eligible: E,
1365        plan: &mut ReplicationPlan,
1366    ) where
1367        F: VisibilityFilter,
1368        C: Fn(EntityHandle, &CompiledSyncPolicy, f32) -> bool,
1369        E: Fn(&ViewerQuery, EntityHandle, &EntityRecord) -> bool,
1370    {
1371        let max_entities = viewer.max_entities.min(budget.max_entities);
1372        let max_by_bytes = budget.max_bytes / budget.estimated_entity_bytes.max(1);
1373        let hard_limit = max_entities.min(max_by_bytes);
1374        plan.entities.clear();
1375        plan.entities.reserve(hard_limit.min(candidates.len()));
1376        plan.stats = ReplicationStats {
1377            candidates: candidates.len(),
1378            ..ReplicationStats::default()
1379        };
1380        eligible.clear();
1381
1382        for handle in candidates {
1383            let Some(entity) = station.get(*handle) else {
1384                continue;
1385            };
1386            plan.stats.considered += 1;
1387
1388            let Some(policy) = policies.get(entity.policy_id) else {
1389                continue;
1390            };
1391            let distance_squared = entity.position.distance_squared(viewer.position);
1392            let policy_radius_sq = policy.interest_radius * policy.interest_radius;
1393            if distance_squared > policy_radius_sq {
1394                continue;
1395            }
1396            if !filter.is_visible_with_distance(viewer, entity, distance_squared) {
1397                continue;
1398            }
1399            if !cadence_allows(*handle, policy, distance_squared) {
1400                plan.stats.skipped_by_cadence += 1;
1401                continue;
1402            }
1403            if !candidate_eligible(viewer, *handle, entity) {
1404                continue;
1405            }
1406
1407            eligible.push(PrioritizedReplicationCandidate {
1408                handle: *handle,
1409                score: ReplicationPriority::score(policy, distance_squared),
1410                distance_squared,
1411            });
1412        }
1413
1414        let selected = prioritize_candidates(eligible, hard_limit);
1415
1416        plan.stats.skipped_by_budget = eligible.len().saturating_sub(selected);
1417        plan.entities.extend(
1418            eligible
1419                .iter()
1420                .take(selected)
1421                .map(|candidate| candidate.handle),
1422        );
1423        plan.stats.selected = plan.entities.len();
1424        plan.stats.estimated_bytes = plan.stats.selected * budget.estimated_entity_bytes;
1425    }
1426}
1427
1428fn compare_prioritized_candidates(
1429    left: &PrioritizedReplicationCandidate,
1430    right: &PrioritizedReplicationCandidate,
1431) -> Ordering {
1432    right
1433        .score
1434        .cmp(&left.score)
1435        .then_with(|| left.distance_squared.total_cmp(&right.distance_squared))
1436        .then_with(|| left.handle.cmp(&right.handle))
1437}
1438
1439fn prioritize_candidates(eligible: &mut [PrioritizedReplicationCandidate], limit: usize) -> usize {
1440    let selected = eligible.len().min(limit);
1441    if selected == 0 {
1442        return 0;
1443    }
1444    if selected.saturating_mul(2) < eligible.len() {
1445        eligible.select_nth_unstable_by(selected, compare_prioritized_candidates);
1446        eligible[..selected].sort_by(compare_prioritized_candidates);
1447    } else {
1448        eligible.sort_by(compare_prioritized_candidates);
1449    }
1450    selected
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use crate::entity::EntityTags;
1457    use crate::ids::{ClientId, EntityId, InstanceId, NodeId, PolicyId, StationId};
1458    use crate::interest::{AndVisibility, FrustumVisibility, RangeOnlyVisibility, TagVisibility};
1459    use crate::policy::CompiledSyncPolicy;
1460    use crate::spatial::{Aabb3, Bounds, Frustum3, GridSpec, Position3};
1461    use crate::station::{Station, StationConfig};
1462
1463    fn plan_owned<F: VisibilityFilter>(
1464        station: &Station,
1465        index: &CellIndex,
1466        policies: &PolicyTable,
1467        viewer: &ViewerQuery,
1468        filter: &F,
1469        budget: ReplicationBudget,
1470    ) -> ReplicationPlan {
1471        let mut scratch = ReplicationScratch::default();
1472        let mut plan = ReplicationPlan::default();
1473        ReplicationPlanner::plan_for_viewer_into(
1474            station,
1475            index,
1476            policies,
1477            viewer,
1478            filter,
1479            budget,
1480            &mut scratch,
1481            &mut plan,
1482        );
1483        plan
1484    }
1485
1486    #[test]
1487    fn planner_applies_composed_frustum_visibility_filter() {
1488        let mut station = Station::new(StationConfig {
1489            station_id: StationId::new(1),
1490            node_id: NodeId::new(1),
1491            instance_id: InstanceId::new(1),
1492            tick_rate_hz: 20,
1493        });
1494        let grid = GridSpec::new(16.0).expect("grid is valid");
1495        let mut index = CellIndex::new(grid);
1496        let mut policies = PolicyTable::default();
1497        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1498
1499        let visible = station
1500            .spawn_owned(
1501                EntityId::new(1),
1502                Position3::new(10.0, 0.0, 0.0),
1503                Bounds::Point,
1504                PolicyId::new(1),
1505            )
1506            .expect("spawn visible");
1507        let outside_frustum = station
1508            .spawn_owned(
1509                EntityId::new(2),
1510                Position3::new(-10.0, 0.0, 0.0),
1511                Bounds::Point,
1512                PolicyId::new(1),
1513            )
1514            .expect("spawn outside frustum");
1515        index.upsert(visible, Position3::new(10.0, 0.0, 0.0), Bounds::Point);
1516        index.upsert(
1517            outside_frustum,
1518            Position3::new(-10.0, 0.0, 0.0),
1519            Bounds::Point,
1520        );
1521
1522        let viewer = ViewerQuery {
1523            client_id: ClientId::new(7),
1524            position: Position3::new(0.0, 0.0, 0.0),
1525            radius: 128.0,
1526            max_entities: 8,
1527        };
1528        let frustum = Frustum3::from_aabb(Aabb3::new(
1529            Position3::new(0.0, -20.0, -20.0),
1530            Position3::new(80.0, 20.0, 20.0),
1531        ));
1532        let filter = AndVisibility::new(RangeOnlyVisibility, FrustumVisibility::new(frustum));
1533
1534        let plan = plan_owned(
1535            &station,
1536            &index,
1537            &policies,
1538            &viewer,
1539            &filter,
1540            ReplicationBudget::default(),
1541        );
1542
1543        assert_eq!(plan.entities, vec![visible]);
1544        assert_eq!(plan.stats.selected, 1);
1545        assert_eq!(plan.stats.considered, 2);
1546    }
1547
1548    #[test]
1549    fn planner_applies_tag_visibility_filter() {
1550        let mut station = Station::new(StationConfig {
1551            station_id: StationId::new(1),
1552            node_id: NodeId::new(1),
1553            instance_id: InstanceId::new(1),
1554            tick_rate_hz: 20,
1555        });
1556        let grid = GridSpec::new(16.0).expect("grid is valid");
1557        let mut index = CellIndex::new(grid);
1558        let mut policies = PolicyTable::default();
1559        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1560
1561        let static_visible = station
1562            .spawn_owned(
1563                EntityId::new(1),
1564                Position3::new(10.0, 0.0, 0.0),
1565                Bounds::Point,
1566                PolicyId::new(1),
1567            )
1568            .expect("spawn static");
1569        let fast_mover = station
1570            .spawn_owned(
1571                EntityId::new(2),
1572                Position3::new(12.0, 0.0, 0.0),
1573                Bounds::Point,
1574                PolicyId::new(1),
1575            )
1576            .expect("spawn mover");
1577        station
1578            .set_tags(static_visible, EntityTags::from_bits(0b001))
1579            .expect("tag static");
1580        station
1581            .set_tags(fast_mover, EntityTags::from_bits(0b010))
1582            .expect("tag mover");
1583        index.upsert(
1584            static_visible,
1585            Position3::new(10.0, 0.0, 0.0),
1586            Bounds::Point,
1587        );
1588        index.upsert(fast_mover, Position3::new(12.0, 0.0, 0.0), Bounds::Point);
1589
1590        let viewer = ViewerQuery {
1591            client_id: ClientId::new(7),
1592            position: Position3::new(0.0, 0.0, 0.0),
1593            radius: 128.0,
1594            max_entities: 8,
1595        };
1596        let filter = AndVisibility::new(
1597            RangeOnlyVisibility,
1598            TagVisibility::new(EntityTags::from_bits(0b001), EntityTags::from_bits(0b010)),
1599        );
1600
1601        let plan = plan_owned(
1602            &station,
1603            &index,
1604            &policies,
1605            &viewer,
1606            &filter,
1607            ReplicationBudget::default(),
1608        );
1609
1610        assert_eq!(plan.entities, vec![static_visible]);
1611        assert_eq!(plan.stats.selected, 1);
1612        assert_eq!(plan.stats.considered, 2);
1613    }
1614
1615    #[test]
1616    fn caller_eligibility_filters_before_replication_budget() {
1617        let mut station = Station::new(StationConfig {
1618            station_id: StationId::new(1),
1619            node_id: NodeId::new(1),
1620            instance_id: InstanceId::new(1),
1621            tick_rate_hz: 20,
1622        });
1623        let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid is valid"));
1624        let mut policies = PolicyTable::default();
1625        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1626        let handles = (1_u16..=3)
1627            .map(|id| {
1628                let position = Position3::new(f32::from(id), 0.0, 0.0);
1629                let handle = station
1630                    .spawn_owned(
1631                        EntityId::new(u64::from(id)),
1632                        position,
1633                        Bounds::Point,
1634                        PolicyId::new(1),
1635                    )
1636                    .expect("entity id is unique");
1637                index.upsert(handle, position, Bounds::Point);
1638                handle
1639            })
1640            .collect::<Vec<_>>();
1641        let viewer = ViewerQuery {
1642            client_id: ClientId::new(1),
1643            position: Position3::new(0.0, 0.0, 0.0),
1644            radius: 128.0,
1645            max_entities: 1,
1646        };
1647        let mut scratch = ReplicationScratch::default();
1648        let mut plan = ReplicationPlan::default();
1649
1650        ReplicationPlanner::plan_for_viewer_configured_into(
1651            &station,
1652            &index,
1653            &policies,
1654            &viewer,
1655            &RangeOnlyVisibility,
1656            ReplicationBudget {
1657                max_entities: 1,
1658                ..ReplicationBudget::default()
1659            },
1660            ReplicationSelectionMode::Throughput,
1661            |_, handle, _| handle == handles[2],
1662            |_, _| None,
1663            &mut scratch,
1664            &mut plan,
1665        );
1666
1667        assert_eq!(plan.entities, vec![handles[2]]);
1668        assert_eq!(plan.stats.selected, 1);
1669        assert_eq!(plan.stats.skipped_by_budget, 0);
1670    }
1671
1672    #[test]
1673    fn work_bounded_planner_stops_after_first_fit_budget() {
1674        let mut station = Station::new(StationConfig {
1675            station_id: StationId::new(1),
1676            node_id: NodeId::new(1),
1677            instance_id: InstanceId::new(1),
1678            tick_rate_hz: 20,
1679        });
1680        let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid is valid"));
1681        let mut policies = PolicyTable::default();
1682        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0));
1683        for id in 1_u16..=8 {
1684            let position = Position3::new(f32::from(id), 0.0, 0.0);
1685            let handle = station
1686                .spawn_owned(
1687                    EntityId::new(u64::from(id)),
1688                    position,
1689                    Bounds::Point,
1690                    PolicyId::new(1),
1691                )
1692                .expect("entity id is unique");
1693            index.upsert(handle, position, Bounds::Point);
1694        }
1695        let viewer = ViewerQuery {
1696            client_id: ClientId::new(1),
1697            position: Position3::new(0.0, 0.0, 0.0),
1698            radius: 128.0,
1699            max_entities: 2,
1700        };
1701        let mut scratch = ReplicationScratch::default();
1702        let mut plan = ReplicationPlan::default();
1703
1704        ReplicationPlanner::plan_for_viewer_configured_into(
1705            &station,
1706            &index,
1707            &policies,
1708            &viewer,
1709            &RangeOnlyVisibility,
1710            ReplicationBudget {
1711                max_entities: 2,
1712                ..ReplicationBudget::default()
1713            },
1714            ReplicationSelectionMode::Throughput,
1715            |_, _, _| true,
1716            |_, _| None,
1717            &mut scratch,
1718            &mut plan,
1719        );
1720
1721        assert_eq!(plan.stats.selected, 2);
1722        assert_eq!(plan.stats.considered, 2);
1723        assert_eq!(plan.stats.skipped_by_budget, 0);
1724        assert_eq!(plan.stats.unexamined_after_budget, 6);
1725    }
1726
1727    #[test]
1728    #[allow(clippy::too_many_lines)]
1729    fn range_batch_matches_ordered_scalar_plans() {
1730        let mut station = Station::new(StationConfig {
1731            station_id: StationId::new(1),
1732            node_id: NodeId::new(1),
1733            instance_id: InstanceId::new(1),
1734            tick_rate_hz: 128,
1735        });
1736        let grid = GridSpec::new(16.0).expect("grid is valid");
1737        let mut index = CellIndex::new(grid);
1738        let mut policies = PolicyTable::default();
1739        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1740        for entity_index in 0_u16..24 {
1741            let position = Position3::new(f32::from(entity_index) * 8.0 - 64.0, 0.0, 0.0);
1742            let handle = station
1743                .spawn_owned(
1744                    EntityId::new(u64::from(entity_index)),
1745                    position,
1746                    Bounds::Point,
1747                    PolicyId::new(1),
1748                )
1749                .expect("entity id is unique");
1750            index.upsert(handle, position, Bounds::Point);
1751        }
1752        let viewers = [
1753            ViewerQuery {
1754                client_id: ClientId::new(1),
1755                position: Position3::new(0.0, 0.0, 0.0),
1756                radius: 80.0,
1757                max_entities: 32,
1758            },
1759            ViewerQuery {
1760                client_id: ClientId::new(2),
1761                position: Position3::new(48.0, 0.0, 0.0),
1762                radius: 48.0,
1763                max_entities: 8,
1764            },
1765        ];
1766        let mut scalar_scratch = ReplicationScratch::default();
1767        let expected = viewers
1768            .iter()
1769            .map(|viewer| {
1770                let mut plan = ReplicationPlan::default();
1771                ReplicationPlanner::plan_for_viewer_range_into(
1772                    &station,
1773                    &index,
1774                    &policies,
1775                    viewer,
1776                    ReplicationBudget::default(),
1777                    &mut scalar_scratch,
1778                    &mut plan,
1779                );
1780                plan
1781            })
1782            .collect::<Vec<_>>();
1783
1784        let mut batch_scratch = ReplicationScratch::default();
1785        let mut batch_output = ReplicationBatchScratch::default();
1786        let batch_view = ReplicationPlanner::plan_for_viewers_range_into(
1787            &station,
1788            &index,
1789            &policies,
1790            &viewers,
1791            ReplicationBudget::default(),
1792            &mut batch_scratch,
1793            &mut batch_output,
1794        );
1795        let batch = ReplicationBatchResult {
1796            plans: batch_view.plans.to_vec(),
1797            stats: batch_view.stats,
1798        };
1799
1800        assert_eq!(batch.plans, expected);
1801        assert_eq!(batch.stats.viewers, viewers.len());
1802        assert_eq!(
1803            batch.stats.selected,
1804            expected.iter().map(|plan| plan.stats.selected).sum()
1805        );
1806        assert_eq!(
1807            batch.stats.grid_queries + batch.stats.occupied_queries,
1808            viewers.len()
1809        );
1810
1811        let mut reusable_planning = ReplicationScratch::default();
1812        let mut reusable_output = ReplicationBatchScratch::new();
1813        {
1814            let reused = ReplicationPlanner::plan_for_viewers_range_into(
1815                &station,
1816                &index,
1817                &policies,
1818                &viewers,
1819                ReplicationBudget::default(),
1820                &mut reusable_planning,
1821                &mut reusable_output,
1822            );
1823            assert_eq!(reused.plans, expected);
1824            assert_eq!(reused.stats, batch.stats);
1825        }
1826        let retained_capacity = reusable_output.retained_entity_capacity();
1827        assert_eq!(reusable_output.retained_plan_slots(), viewers.len());
1828
1829        let reused = ReplicationPlanner::plan_for_viewers_range_into(
1830            &station,
1831            &index,
1832            &policies,
1833            &viewers[..1],
1834            ReplicationBudget::default(),
1835            &mut reusable_planning,
1836            &mut reusable_output,
1837        );
1838        assert_eq!(reused.plans, &expected[..1]);
1839        assert_eq!(reusable_output.retained_plan_slots(), viewers.len());
1840        assert_eq!(
1841            reusable_output.retained_entity_capacity(),
1842            retained_capacity
1843        );
1844    }
1845
1846    #[test]
1847    fn range_batch_preserves_scalar_nan_radius_semantics() {
1848        let mut station = Station::new(StationConfig {
1849            station_id: StationId::new(1),
1850            node_id: NodeId::new(1),
1851            instance_id: InstanceId::new(1),
1852            tick_rate_hz: 128,
1853        });
1854        let mut policies = PolicyTable::default();
1855        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 1, 128, 96.0));
1856        let handle = station
1857            .spawn_owned(
1858                EntityId::new(1),
1859                Position3::new(1.0, 2.0, 3.0),
1860                Bounds::Point,
1861                PolicyId::new(1),
1862            )
1863            .expect("spawn entity");
1864        let viewer = ViewerQuery {
1865            client_id: ClientId::new(1),
1866            position: Position3::new(0.0, 0.0, 0.0),
1867            radius: f32::NAN,
1868            max_entities: 8,
1869        };
1870        let candidates = [handle];
1871        let scalar = ReplicationPlanner::plan_for_candidates_inner(
1872            &station,
1873            &candidates,
1874            &policies,
1875            &viewer,
1876            &RangeOnlyVisibility,
1877            ReplicationBudget::default(),
1878            |_, _, _| true,
1879        );
1880        let range = ReplicationPlanner::plan_for_range_candidates(
1881            &station,
1882            &candidates,
1883            &policies,
1884            &viewer,
1885            ReplicationBudget::default(),
1886        );
1887
1888        assert!(scalar.entities.is_empty());
1889        assert_eq!(range, scalar);
1890    }
1891
1892    #[test]
1893    fn cadence_scales_interval_by_squared_distance() {
1894        let policy = CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 100.0);
1895
1896        assert_eq!(ReplicationCadence::target_hz(&policy, 0.0), 20);
1897        assert_eq!(ReplicationCadence::interval_ticks(&policy, 20, 0.0), 1);
1898        assert_eq!(ReplicationCadence::target_hz(&policy, 100.0_f32 * 100.0), 2);
1899        assert_eq!(
1900            ReplicationCadence::interval_ticks(&policy, 20, 100.0_f32 * 100.0),
1901            10
1902        );
1903    }
1904
1905    #[test]
1906    fn priority_score_prefers_weight_then_distance() {
1907        let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 100.0);
1908        low.priority_weight = 1;
1909        let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 100.0);
1910        high.priority_weight = 10;
1911
1912        assert!(
1913            ReplicationPriority::score(&high, 90.0 * 90.0) > ReplicationPriority::score(&low, 0.0)
1914        );
1915        assert!(
1916            ReplicationPriority::score(&low, 0.0) > ReplicationPriority::score(&low, 90.0 * 90.0)
1917        );
1918    }
1919
1920    #[test]
1921    fn top_k_priority_selection_matches_full_sort_for_all_budget_edges() {
1922        let candidates = (0_u32..257)
1923            .map(|index| PrioritizedReplicationCandidate {
1924                handle: EntityHandle::new(index, index % 3),
1925                score: u64::from(index.wrapping_mul(37) % 23),
1926                distance_squared: f32::from(
1927                    u16::try_from(index.wrapping_mul(19) % 41).expect("distance fits u16"),
1928                ),
1929            })
1930            .collect::<Vec<_>>();
1931
1932        for limit in [0, 1, 7, 64, 256, 257, 300] {
1933            let mut expected = candidates.clone();
1934            expected.sort_by(compare_prioritized_candidates);
1935            expected.truncate(limit.min(expected.len()));
1936            let mut actual = candidates.clone();
1937            let selected = prioritize_candidates(&mut actual, limit);
1938
1939            assert_eq!(selected, expected.len());
1940            assert_eq!(&actual[..selected], expected.as_slice());
1941        }
1942    }
1943
1944    #[test]
1945    fn configured_kernel_combines_mode_eligibility_and_per_viewer_cadence() {
1946        let mut station = Station::new(StationConfig {
1947            station_id: StationId::new(1),
1948            node_id: NodeId::new(1),
1949            instance_id: InstanceId::new(1),
1950            tick_rate_hz: 20,
1951        });
1952        station.advance_tick();
1953        let mut index = CellIndex::new(GridSpec::new(16.0).expect("grid"));
1954        let mut policies = PolicyTable::default();
1955        let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 20, 20, 128.0);
1956        low.priority_weight = 1;
1957        let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 20, 20, 128.0);
1958        high.priority_weight = 100;
1959        policies.set(low);
1960        policies.set(high);
1961        let low_handle = station
1962            .spawn_owned(
1963                EntityId::new(1),
1964                Position3::new(1.0, 0.0, 0.0),
1965                Bounds::Point,
1966                PolicyId::new(1),
1967            )
1968            .expect("low");
1969        let high_handle = station
1970            .spawn_owned(
1971                EntityId::new(2),
1972                Position3::new(2.0, 0.0, 0.0),
1973                Bounds::Point,
1974                PolicyId::new(2),
1975            )
1976            .expect("high");
1977        index.upsert(low_handle, Position3::new(1.0, 0.0, 0.0), Bounds::Point);
1978        index.upsert(high_handle, Position3::new(2.0, 0.0, 0.0), Bounds::Point);
1979        let viewer = ViewerQuery {
1980            client_id: ClientId::new(7),
1981            position: Position3::new(0.0, 0.0, 0.0),
1982            radius: 128.0,
1983            max_entities: 1,
1984        };
1985        let budget = ReplicationBudget {
1986            max_entities: 1,
1987            max_bytes: 64,
1988            estimated_entity_bytes: 32,
1989        };
1990        let mut scratch = ReplicationScratch::default();
1991        let mut plan = ReplicationPlan::default();
1992
1993        ReplicationPlanner::plan_for_viewer_configured_into(
1994            &station,
1995            &index,
1996            &policies,
1997            &viewer,
1998            &RangeOnlyVisibility,
1999            budget,
2000            ReplicationSelectionMode::Throughput,
2001            |_, handle, _| handle == low_handle,
2002            |_, _| None,
2003            &mut scratch,
2004            &mut plan,
2005        );
2006        assert_eq!(plan.entities, vec![low_handle]);
2007
2008        ReplicationPlanner::plan_for_viewer_configured_into(
2009            &station,
2010            &index,
2011            &policies,
2012            &viewer,
2013            &RangeOnlyVisibility,
2014            budget,
2015            ReplicationSelectionMode::Prioritized,
2016            |_, _, _| true,
2017            |_, handle| (handle == high_handle).then_some(station.tick()),
2018            &mut scratch,
2019            &mut plan,
2020        );
2021        assert_eq!(plan.entities, vec![low_handle]);
2022        assert_eq!(plan.stats.skipped_by_cadence, 1);
2023    }
2024
2025    #[test]
2026    #[allow(clippy::too_many_lines)]
2027    fn configured_batch_reuses_only_repeated_quantized_aoi_ranges() {
2028        let mut station = Station::with_capacity(
2029            StationConfig {
2030                station_id: StationId::new(1),
2031                node_id: NodeId::new(1),
2032                instance_id: InstanceId::new(1),
2033                tick_rate_hz: 20,
2034            },
2035            64,
2036        );
2037        let mut index = CellIndex::with_capacity(GridSpec::new(16.0).expect("grid"), 64, 64);
2038        let mut policies = PolicyTable::default();
2039        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 20, 20, 128.0));
2040        for entity in 0..64_u64 {
2041            let lane = u16::try_from(entity % 8).expect("entity lane fits u16");
2042            let position = Position3::new(f32::from(lane) * 8.0, 0.0, 0.0);
2043            let handle = station
2044                .spawn_owned(
2045                    EntityId::new(entity + 1),
2046                    position,
2047                    Bounds::Point,
2048                    PolicyId::new(1),
2049                )
2050                .expect("spawn");
2051            index.upsert(handle, position, Bounds::Point);
2052        }
2053        let viewers = [
2054            ViewerQuery {
2055                client_id: ClientId::new(1),
2056                position: Position3::new(0.0, 0.0, 0.0),
2057                radius: 32.0,
2058                max_entities: 64,
2059            },
2060            ViewerQuery {
2061                client_id: ClientId::new(2),
2062                position: Position3::new(1.0, 0.0, 0.0),
2063                radius: 32.0,
2064                max_entities: 64,
2065            },
2066            ViewerQuery {
2067                client_id: ClientId::new(3),
2068                position: Position3::new(24.0, 0.0, 0.0),
2069                radius: 32.0,
2070                max_entities: 64,
2071            },
2072            ViewerQuery {
2073                client_id: ClientId::new(4),
2074                position: Position3::new(80.0, 0.0, 0.0),
2075                radius: 32.0,
2076                max_entities: 64,
2077            },
2078        ];
2079        let budget = ReplicationBudget {
2080            max_entities: 64,
2081            max_bytes: 64 * 32,
2082            estimated_entity_bytes: 32,
2083        };
2084        let mut cached_scratch = ReplicationScratch::default();
2085        let mut cached_output = ReplicationBatchScratch::default();
2086        let cached = ReplicationPlanner::plan_for_viewers_configured_into(
2087            &station,
2088            &index,
2089            &policies,
2090            &viewers,
2091            &RangeOnlyVisibility,
2092            budget,
2093            ReplicationSelectionMode::Throughput,
2094            4,
2095            |_, _, _| true,
2096            |_, _| None,
2097            &mut cached_scratch,
2098            &mut cached_output,
2099        );
2100        let cached_plans = cached.plans.to_vec();
2101        let cached_stats = cached.stats;
2102
2103        let mut uncached_scratch = ReplicationScratch::default();
2104        let mut uncached_output = ReplicationBatchScratch::default();
2105        let uncached = ReplicationPlanner::plan_for_viewers_configured_into(
2106            &station,
2107            &index,
2108            &policies,
2109            &viewers,
2110            &RangeOnlyVisibility,
2111            budget,
2112            ReplicationSelectionMode::Throughput,
2113            0,
2114            |_, _, _| true,
2115            |_, _| None,
2116            &mut uncached_scratch,
2117            &mut uncached_output,
2118        );
2119
2120        assert_eq!(cached_plans, uncached.plans);
2121        assert_eq!(cached_stats.unique_query_ranges, 3);
2122        assert_eq!(cached_stats.reused_query_ranges, 1);
2123        assert_eq!(uncached.stats.reused_query_ranges, 0);
2124        assert_eq!(
2125            cached_stats.grid_queries + cached_stats.occupied_queries,
2126            viewers.len() - 1
2127        );
2128        assert_eq!(
2129            uncached.stats.grid_queries + uncached.stats.occupied_queries,
2130            viewers.len()
2131        );
2132        assert_eq!(cached_output.retained_query_cache_slots(), 1);
2133        assert!(cached_output.retained_query_cache_candidate_capacity() >= 1);
2134    }
2135
2136    #[test]
2137    fn planner_with_cadence_skips_recent_far_entities() {
2138        let mut station = Station::new(StationConfig {
2139            station_id: StationId::new(1),
2140            node_id: NodeId::new(1),
2141            instance_id: InstanceId::new(1),
2142            tick_rate_hz: 20,
2143        });
2144        for _ in 0..10 {
2145            station.advance_tick();
2146        }
2147        let grid = GridSpec::new(16.0).expect("grid is valid");
2148        let mut index = CellIndex::new(grid);
2149        let mut policies = PolicyTable::default();
2150        policies.set(CompiledSyncPolicy::new(PolicyId::new(1), 2, 20, 128.0));
2151
2152        let near = station
2153            .spawn_owned(
2154                EntityId::new(1),
2155                Position3::new(0.0, 0.0, 0.0),
2156                Bounds::Point,
2157                PolicyId::new(1),
2158            )
2159            .expect("spawn near");
2160        let far = station
2161            .spawn_owned(
2162                EntityId::new(2),
2163                Position3::new(120.0, 0.0, 0.0),
2164                Bounds::Point,
2165                PolicyId::new(1),
2166            )
2167            .expect("spawn far");
2168        index.upsert(near, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
2169        index.upsert(far, Position3::new(120.0, 0.0, 0.0), Bounds::Point);
2170
2171        let viewer = ViewerQuery {
2172            client_id: ClientId::new(7),
2173            position: Position3::new(0.0, 0.0, 0.0),
2174            radius: 128.0,
2175            max_entities: 8,
2176        };
2177        let mut scratch = ReplicationScratch::default();
2178        let mut plan = ReplicationPlan::default();
2179        ReplicationPlanner::plan_for_viewer_configured_into(
2180            &station,
2181            &index,
2182            &policies,
2183            &viewer,
2184            &RangeOnlyVisibility,
2185            ReplicationBudget::default(),
2186            ReplicationSelectionMode::Throughput,
2187            |_, _, _| true,
2188            |_, _| Some(Tick::new(9)),
2189            &mut scratch,
2190            &mut plan,
2191        );
2192
2193        assert_eq!(plan.entities, vec![near]);
2194        assert_eq!(plan.stats.selected, 1);
2195        assert_eq!(plan.stats.skipped_by_cadence, 1);
2196
2197        let mut reusable = ReplicationPlan::default();
2198        ReplicationPlanner::plan_for_viewer_configured_into(
2199            &station,
2200            &index,
2201            &policies,
2202            &viewer,
2203            &RangeOnlyVisibility,
2204            ReplicationBudget::default(),
2205            ReplicationSelectionMode::Throughput,
2206            |_, _, _| true,
2207            |_, _| Some(Tick::new(9)),
2208            &mut scratch,
2209            &mut reusable,
2210        );
2211        assert_eq!(reusable, plan);
2212    }
2213
2214    #[test]
2215    fn prioritized_planner_uses_policy_weight_under_budget() {
2216        let mut station = Station::new(StationConfig {
2217            station_id: StationId::new(1),
2218            node_id: NodeId::new(1),
2219            instance_id: InstanceId::new(1),
2220            tick_rate_hz: 20,
2221        });
2222        let grid = GridSpec::new(16.0).expect("grid is valid");
2223        let mut index = CellIndex::new(grid);
2224        let mut policies = PolicyTable::default();
2225        let mut low = CompiledSyncPolicy::new(PolicyId::new(1), 1, 20, 128.0);
2226        low.priority_weight = 1;
2227        let mut high = CompiledSyncPolicy::new(PolicyId::new(2), 1, 20, 128.0);
2228        high.priority_weight = 10;
2229        policies.set(low);
2230        policies.set(high);
2231
2232        let near_low = station
2233            .spawn_owned(
2234                EntityId::new(1),
2235                Position3::new(0.0, 0.0, 0.0),
2236                Bounds::Point,
2237                PolicyId::new(1),
2238            )
2239            .expect("spawn near low priority");
2240        let far_high = station
2241            .spawn_owned(
2242                EntityId::new(2),
2243                Position3::new(96.0, 0.0, 0.0),
2244                Bounds::Point,
2245                PolicyId::new(2),
2246            )
2247            .expect("spawn far high priority");
2248        index.upsert(near_low, Position3::new(0.0, 0.0, 0.0), Bounds::Point);
2249        index.upsert(far_high, Position3::new(96.0, 0.0, 0.0), Bounds::Point);
2250
2251        let viewer = ViewerQuery {
2252            client_id: ClientId::new(7),
2253            position: Position3::new(0.0, 0.0, 0.0),
2254            radius: 128.0,
2255            max_entities: 1,
2256        };
2257        let budget = ReplicationBudget {
2258            max_entities: 1,
2259            max_bytes: 32,
2260            estimated_entity_bytes: 32,
2261        };
2262        let mut scratch = ReplicationScratch::default();
2263        let mut plan = ReplicationPlan::default();
2264        ReplicationPlanner::plan_for_viewer_configured_into(
2265            &station,
2266            &index,
2267            &policies,
2268            &viewer,
2269            &RangeOnlyVisibility,
2270            budget,
2271            ReplicationSelectionMode::Prioritized,
2272            |_, _, _| true,
2273            |_, _| None,
2274            &mut scratch,
2275            &mut plan,
2276        );
2277
2278        assert_eq!(plan.entities, vec![far_high]);
2279        assert_eq!(plan.stats.selected, 1);
2280        assert_eq!(plan.stats.skipped_by_budget, 1);
2281
2282        let mut scratch_plan = ReplicationPlan::default();
2283        ReplicationPlanner::plan_for_viewer_configured_into(
2284            &station,
2285            &index,
2286            &policies,
2287            &viewer,
2288            &RangeOnlyVisibility,
2289            budget,
2290            ReplicationSelectionMode::Prioritized,
2291            |_, _, _| true,
2292            |_, _| None,
2293            &mut scratch,
2294            &mut scratch_plan,
2295        );
2296        assert_eq!(scratch_plan.entities, plan.entities);
2297        assert_eq!(scratch_plan.stats, plan.stats);
2298        assert_eq!(scratch.candidate_count(), 2);
2299        assert!(scratch.prioritized_capacity() >= 2);
2300        assert_eq!(scratch.query_stats().candidate_handles, 2);
2301        assert!(scratch.candidate_capacity() >= 2);
2302        assert!(scratch.candidate_dedup_capacity() >= 2);
2303
2304        assert_prioritized_output_reuse(
2305            &station,
2306            &index,
2307            &policies,
2308            &viewer,
2309            budget,
2310            &plan,
2311            &mut scratch,
2312        );
2313    }
2314
2315    fn assert_prioritized_output_reuse(
2316        station: &Station,
2317        index: &CellIndex,
2318        policies: &PolicyTable,
2319        viewer: &ViewerQuery,
2320        budget: ReplicationBudget,
2321        expected: &ReplicationPlan,
2322        scratch: &mut ReplicationScratch,
2323    ) {
2324        let mut reusable = ReplicationPlan::default();
2325        ReplicationPlanner::plan_for_viewer_configured_into(
2326            station,
2327            index,
2328            policies,
2329            viewer,
2330            &RangeOnlyVisibility,
2331            budget,
2332            ReplicationSelectionMode::Prioritized,
2333            |_, _, _| true,
2334            |_, _| None,
2335            scratch,
2336            &mut reusable,
2337        );
2338        let retained_entities = reusable.entities.as_ptr();
2339        assert_eq!(&reusable, expected);
2340        ReplicationPlanner::plan_for_viewer_configured_into(
2341            station,
2342            index,
2343            policies,
2344            viewer,
2345            &RangeOnlyVisibility,
2346            budget,
2347            ReplicationSelectionMode::Prioritized,
2348            |_, _, _| true,
2349            |_, _| None,
2350            scratch,
2351            &mut reusable,
2352        );
2353        assert_eq!(reusable.entities.as_ptr(), retained_entities);
2354    }
2355
2356    #[test]
2357    fn replication_tracker_records_sent_ack_and_prune() {
2358        let client_id = ClientId::new(7);
2359        let first = EntityHandle::new(1, 0);
2360        let second = EntityHandle::new(2, 0);
2361        let plan = ReplicationPlan {
2362            entities: vec![first, second],
2363            stats: ReplicationStats::default(),
2364        };
2365        let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 4 });
2366
2367        let recorded = tracker
2368            .record_plan_sent(client_id, &plan, Tick::new(10))
2369            .expect("recording should fit");
2370        assert_eq!(recorded, 2);
2371        assert_eq!(tracker.last_sent(client_id, first), Some(Tick::new(10)));
2372        assert_eq!(tracker.stats().entries, 2);
2373        assert_eq!(tracker.stats().sent_records, 2);
2374
2375        assert!(tracker.acknowledge(client_id, first, Tick::new(11)));
2376        assert_eq!(
2377            tracker
2378                .get(client_id, first)
2379                .expect("tracked record")
2380                .last_acked,
2381            Some(Tick::new(11))
2382        );
2383        assert_eq!(tracker.stats().acked_records, 1);
2384
2385        assert_eq!(tracker.prune_sent_before(Tick::new(11)), 2);
2386        assert!(tracker.is_empty());
2387        assert_eq!(tracker.stats().pruned_records, 2);
2388    }
2389
2390    #[test]
2391    fn replication_tracker_rejects_capacity_without_partial_insert() {
2392        let client_id = ClientId::new(7);
2393        let plan = ReplicationPlan {
2394            entities: vec![EntityHandle::new(1, 0), EntityHandle::new(2, 0)],
2395            stats: ReplicationStats::default(),
2396        };
2397        let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 1 });
2398
2399        let error = tracker
2400            .record_plan_sent(client_id, &plan, Tick::new(10))
2401            .expect_err("recording should exceed capacity");
2402
2403        assert_eq!(
2404            error,
2405            ReplicationTrackerError::CapacityExceeded {
2406                current: 0,
2407                needed: 2,
2408                max: 1,
2409            }
2410        );
2411        assert!(tracker.is_empty());
2412        assert_eq!(tracker.stats().sent_records, 0);
2413    }
2414
2415    #[test]
2416    fn replication_tracker_uses_exact_capacity_check_near_limit() {
2417        let client_id = ClientId::new(7);
2418        let first = EntityHandle::new(1, 0);
2419        let second = EntityHandle::new(2, 0);
2420        let third = EntityHandle::new(3, 0);
2421        let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig { max_entries: 2 });
2422        tracker
2423            .record_plan_sent(
2424                client_id,
2425                &ReplicationPlan {
2426                    entities: vec![first],
2427                    stats: ReplicationStats::default(),
2428                },
2429                Tick::new(1),
2430            )
2431            .expect("initial record should fit");
2432        tracker
2433            .record_plan_sent(
2434                client_id,
2435                &ReplicationPlan {
2436                    entities: vec![first, second],
2437                    stats: ReplicationStats::default(),
2438                },
2439                Tick::new(2),
2440            )
2441            .expect("one existing and one new record should fit exactly");
2442
2443        let error = tracker
2444            .record_plan_sent(
2445                client_id,
2446                &ReplicationPlan {
2447                    entities: vec![first, second, third],
2448                    stats: ReplicationStats::default(),
2449                },
2450                Tick::new(3),
2451            )
2452            .expect_err("new record should exceed exact capacity");
2453        assert_eq!(
2454            error,
2455            ReplicationTrackerError::CapacityExceeded {
2456                current: 2,
2457                needed: 1,
2458                max: 2,
2459            }
2460        );
2461        assert_eq!(tracker.last_sent(client_id, first), Some(Tick::new(2)));
2462        assert_eq!(tracker.last_sent(client_id, second), Some(Tick::new(2)));
2463        assert_eq!(tracker.get(client_id, third), None);
2464    }
2465
2466    #[test]
2467    fn replication_tracker_promotes_without_losing_ack_or_prune_state() {
2468        let first_client = ClientId::new(1);
2469        let second_client = ClientId::new(2);
2470        let mut tracker = ReplicationTracker::new(ReplicationTrackerConfig {
2471            max_entries: HASHED_REPLICATION_TRACKER_MIN_ENTRIES + 1,
2472        });
2473        let initial_entities: Vec<_> = (0..HASHED_REPLICATION_TRACKER_MIN_ENTRIES - 2)
2474            .map(|index| {
2475                EntityHandle::new(u32::try_from(index).expect("test entity index fits u32"), 1)
2476            })
2477            .collect();
2478        tracker
2479            .record_plan_sent(
2480                first_client,
2481                &ReplicationPlan {
2482                    entities: initial_entities.clone(),
2483                    stats: ReplicationStats::default(),
2484                },
2485                Tick::new(1),
2486            )
2487            .expect("initial records should fit");
2488        let second_entity = EntityHandle::new(u32::MAX, 1);
2489        tracker
2490            .record_plan_sent(
2491                second_client,
2492                &ReplicationPlan {
2493                    entities: vec![second_entity],
2494                    stats: ReplicationStats::default(),
2495                },
2496                Tick::new(1),
2497            )
2498            .expect("second client record should fit");
2499        assert!(!tracker.records.is_hashed());
2500
2501        let first_entity = initial_entities[0];
2502        tracker
2503            .record_plan_sent(
2504                first_client,
2505                &ReplicationPlan {
2506                    entities: vec![first_entity],
2507                    stats: ReplicationStats::default(),
2508                },
2509                Tick::new(2),
2510            )
2511            .expect("existing record should update without promotion");
2512        assert!(!tracker.records.is_hashed());
2513
2514        let final_entity = EntityHandle::new(
2515            u32::try_from(HASHED_REPLICATION_TRACKER_MIN_ENTRIES)
2516                .expect("test entity index fits u32"),
2517            1,
2518        );
2519        tracker
2520            .record_plan_sent(
2521                first_client,
2522                &ReplicationPlan {
2523                    entities: vec![final_entity],
2524                    stats: ReplicationStats::default(),
2525                },
2526                Tick::new(2),
2527            )
2528            .expect("threshold record should promote");
2529        assert!(tracker.records.is_hashed());
2530        assert!(tracker.acknowledge(first_client, final_entity, Tick::new(3)));
2531        assert_eq!(tracker.clear_client(second_client), 1);
2532        assert!(tracker.records.is_hashed());
2533        assert_eq!(
2534            tracker.prune_sent_before(Tick::new(2)),
2535            initial_entities.len() - 1
2536        );
2537        assert!(tracker.records.is_hashed());
2538        assert_eq!(tracker.len(), 2);
2539        assert_eq!(tracker.stats().entries, 2);
2540        assert_eq!(tracker.stats().acked_records, 1);
2541        assert_eq!(
2542            tracker
2543                .get(first_client, final_entity)
2544                .expect("acked record should survive")
2545                .last_acked,
2546            Some(Tick::new(3))
2547        );
2548    }
2549}