sectorsync-core 0.1.0

Core spatial indexing, authority, AOI, and replication planning primitives for SectorSync
Documentation
//! Hotspot metrics and split planning primitives.

use crate::ids::StationId;
use crate::spatial::CellCoord3;

/// Load sample for one spatial cell.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CellLoadSample {
    /// Cell coordinate.
    pub cell: CellCoord3,
    /// Owned entity count observed in this cell.
    pub owned_entities: usize,
    /// Ghost entity count observed in this cell.
    pub ghost_entities: usize,
    /// Estimated subscribers interested in this cell.
    pub subscribers: usize,
    /// Estimated updates generated by this cell for the current window.
    pub estimated_updates: usize,
    /// Estimated payload bytes generated by this cell for the current window.
    pub estimated_bytes: usize,
    /// Runtime-defined tick cost units for this cell.
    pub tick_cost_units: u64,
    /// Runtime-defined event pressure units for this cell.
    pub event_pressure: usize,
}

impl CellLoadSample {
    /// Returns a deterministic weighted pressure score for ordering cells.
    pub fn pressure_score(self) -> u64 {
        let entities = (self.owned_entities + self.ghost_entities) as u64;
        let subscribers = self.subscribers as u64;
        let updates = self.estimated_updates as u64;
        let bytes = (self.estimated_bytes / 256) as u64;
        let events = self.event_pressure as u64;
        entities
            .saturating_mul(8)
            .saturating_add(subscribers.saturating_mul(4))
            .saturating_add(updates.saturating_mul(2))
            .saturating_add(bytes)
            .saturating_add(events.saturating_mul(16))
            .saturating_add(self.tick_cost_units)
    }
}

/// Load sample for one station over a measurement window.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StationLoadSample {
    /// Station being measured.
    pub station_id: StationId,
    /// Number of authoritative entities.
    pub owned_entities: usize,
    /// Number of read-only ghost entities.
    pub ghost_entities: usize,
    /// Estimated subscribers routed to this station.
    pub subscribers: usize,
    /// Total queued cross-station events.
    pub queued_events: usize,
    /// Estimated frame bytes generated by this station.
    pub estimated_bytes: usize,
    /// Runtime-defined station tick cost units.
    pub tick_cost_units: u64,
    /// Per-cell load samples.
    pub cells: Vec<CellLoadSample>,
}

impl StationLoadSample {
    /// Returns total entity count.
    pub const fn total_entities(&self) -> usize {
        self.owned_entities + self.ghost_entities
    }

    /// Returns the highest per-cell pressure score.
    pub fn max_cell_pressure(&self) -> u64 {
        self.cells
            .iter()
            .map(|cell| cell.pressure_score())
            .max()
            .unwrap_or(0)
    }
}

/// Thresholds used to classify hotspot pressure.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HotspotThresholds {
    /// Maximum total entities before a station is considered hot.
    pub max_station_entities: usize,
    /// Maximum subscribers before a station is considered hot.
    pub max_station_subscribers: usize,
    /// Maximum queued events before a station is considered hot.
    pub max_queued_events: usize,
    /// Maximum estimated bytes before a station is considered hot.
    pub max_estimated_bytes: usize,
    /// Maximum tick cost units before a station is considered hot.
    pub max_tick_cost_units: u64,
    /// Maximum pressure score for a single cell before split is suggested.
    pub max_cell_pressure: u64,
}

impl Default for HotspotThresholds {
    fn default() -> Self {
        Self {
            max_station_entities: 50_000,
            max_station_subscribers: 2_000,
            max_queued_events: 10_000,
            max_estimated_bytes: 64 * 1024 * 1024,
            max_tick_cost_units: 16_000,
            max_cell_pressure: 10_000,
        }
    }
}

/// Hotspot classification.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HotspotSeverity {
    /// Load is within configured limits.
    Normal,
    /// Load is near or slightly over limits.
    Warm,
    /// Load is clearly over limits and should trigger mitigation.
    Hot,
}

/// Hotspot evaluation result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HotspotDecision {
    /// Station evaluated by this decision.
    pub station_id: StationId,
    /// Classification severity.
    pub severity: HotspotSeverity,
    /// Weighted pressure score.
    pub score: u64,
    /// Human-readable reason codes.
    pub reasons: Vec<&'static str>,
}

/// Cell split proposal for external schedulers.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SplitProposal {
    /// Source station that should give up some cell ownership.
    pub source_station: StationId,
    /// Cells recommended for movement to a new or less loaded station.
    pub cells_to_move: Vec<CellCoord3>,
    /// Total pressure score covered by `cells_to_move`.
    pub moved_pressure_score: u64,
}

/// Hotspot evaluator and split planner.
#[derive(Clone, Copy, Debug, Default)]
pub struct HotspotPlanner;

impl HotspotPlanner {
    /// Evaluates a station load sample against thresholds.
    pub fn evaluate(sample: &StationLoadSample, thresholds: HotspotThresholds) -> HotspotDecision {
        let mut score = 0_u64;
        let mut reasons = Vec::new();

        if sample.total_entities() > thresholds.max_station_entities {
            score = score.saturating_add(1);
            reasons.push("station_entities");
        }
        if sample.subscribers > thresholds.max_station_subscribers {
            score = score.saturating_add(1);
            reasons.push("station_subscribers");
        }
        if sample.queued_events > thresholds.max_queued_events {
            score = score.saturating_add(1);
            reasons.push("queued_events");
        }
        if sample.estimated_bytes > thresholds.max_estimated_bytes {
            score = score.saturating_add(1);
            reasons.push("estimated_bytes");
        }
        if sample.tick_cost_units > thresholds.max_tick_cost_units {
            score = score.saturating_add(1);
            reasons.push("tick_cost");
        }
        if sample.max_cell_pressure() > thresholds.max_cell_pressure {
            score = score.saturating_add(1);
            reasons.push("cell_pressure");
        }

        let severity = match score {
            0 => HotspotSeverity::Normal,
            1 => HotspotSeverity::Warm,
            _ => HotspotSeverity::Hot,
        };

        HotspotDecision {
            station_id: sample.station_id,
            severity,
            score,
            reasons,
        }
    }

    /// Proposes cells to move by selecting the highest-pressure cells first.
    pub fn propose_cell_split(
        sample: &StationLoadSample,
        max_cells_to_move: usize,
    ) -> SplitProposal {
        let mut cells = sample.cells.clone();
        cells.sort_by(|left, right| {
            right
                .pressure_score()
                .cmp(&left.pressure_score())
                .then_with(|| left.cell.cmp(&right.cell))
        });

        let mut proposal = SplitProposal {
            source_station: sample.station_id,
            cells_to_move: Vec::with_capacity(max_cells_to_move.min(cells.len())),
            moved_pressure_score: 0,
        };

        for cell in cells.into_iter().take(max_cells_to_move) {
            proposal.moved_pressure_score = proposal
                .moved_pressure_score
                .saturating_add(cell.pressure_score());
            proposal.cells_to_move.push(cell.cell);
        }

        proposal
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn planner_detects_hot_station_and_suggests_hottest_cells() {
        let sample = StationLoadSample {
            station_id: StationId::new(3),
            owned_entities: 100,
            subscribers: 20,
            cells: vec![
                CellLoadSample {
                    cell: CellCoord3::new(0, 0, 0),
                    owned_entities: 1,
                    ..CellLoadSample::default()
                },
                CellLoadSample {
                    cell: CellCoord3::new(1, 0, 0),
                    owned_entities: 100,
                    event_pressure: 50,
                    ..CellLoadSample::default()
                },
            ],
            ..StationLoadSample::default()
        };
        let thresholds = HotspotThresholds {
            max_station_entities: 50,
            max_cell_pressure: 10,
            ..HotspotThresholds::default()
        };

        let decision = HotspotPlanner::evaluate(&sample, thresholds);
        assert_eq!(decision.severity, HotspotSeverity::Hot);

        let proposal = HotspotPlanner::propose_cell_split(&sample, 1);
        assert_eq!(proposal.cells_to_move, vec![CellCoord3::new(1, 0, 0)]);
    }
}