Skip to main content

arkhe_kernel/state/
ledger.rs

1//! `ResourceLedger` — per-instance resource accounting — a StepStage bucket.
2//!
3//! Tracks entity count, per-entity total bytes (sum of attached components'
4//! `approx_size`), per-component-type counts (observability), and global
5//! totals. Production mutations flow only through `runtime::apply::apply_stage`;
6//! the API is `pub(crate)` so unit tests and the apply pipeline both
7//! reach it directly.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::abi::{EntityId, TypeCode};
14
15#[derive(Debug, Default, Clone, Serialize, Deserialize)]
16pub(crate) struct ResourceLedger {
17    /// Known entities (an entity may exist with zero attached components).
18    entities: BTreeSet<EntityId>,
19    /// Per-component declared `size` — the single accounting authority
20    /// (A21). Keyed by `(entity, type_code)`; `total_bytes`, per-entity
21    /// byte sums, and per-type counts all derive from this map, so a
22    /// `SetComponent` replace adjusts by the byte delta (never double-
23    /// counts) and a remove / despawn subtracts the exact stored size.
24    component_sizes: BTreeMap<(EntityId, TypeCode), u64>,
25    /// O(1) cache of `sum(component_sizes.values())` — hot-path read for
26    /// `memory_budget_bytes` enforcement in `runtime::kernel::step()`.
27    total_bytes: u64,
28}
29
30impl ResourceLedger {
31    pub(crate) fn new() -> Self {
32        Self::default()
33    }
34
35    /// Total component bytes attributed across all entities. Consumed by
36    /// `runtime::kernel::step()` for `memory_budget_bytes` enforcement.
37    #[inline]
38    pub(crate) fn total_bytes(&self) -> u64 {
39        self.total_bytes
40    }
41
42    // Test-only observability accessors below. Production introspection
43    // wiring lands with the future IntrospectHandle interface (deferred).
44
45    #[cfg_attr(not(test), allow(dead_code))]
46    #[inline]
47    pub(crate) fn total_entities(&self) -> u32 {
48        u32::try_from(self.entities.len()).unwrap_or(u32::MAX)
49    }
50
51    #[cfg_attr(not(test), allow(dead_code))]
52    pub(crate) fn entity_bytes(&self, id: EntityId) -> u64 {
53        self.component_sizes
54            .range((id, TypeCode(0))..=(id, TypeCode(u32::MAX)))
55            .fold(0u64, |acc, (_, size)| acc.saturating_add(*size))
56    }
57
58    #[cfg_attr(not(test), allow(dead_code))]
59    pub(crate) fn type_count(&self, tc: TypeCode) -> u32 {
60        let n = self
61            .component_sizes
62            .keys()
63            .filter(|(_, t)| *t == tc)
64            .count();
65        u32::try_from(n).unwrap_or(u32::MAX)
66    }
67
68    /// Register a new entity with zero attached bytes. Returns true on
69    /// fresh insertion, false if the entity was already present (idempotent).
70    pub(crate) fn add_entity(&mut self, id: EntityId) -> bool {
71        self.entities.insert(id)
72    }
73
74    /// Remove an entity and cascade-drop every component it still held,
75    /// so a despawn cannot leave orphaned byte/type accounting behind.
76    /// Returns the total bytes that were attributed to the entity.
77    pub(crate) fn remove_entity(&mut self, id: EntityId) -> u64 {
78        if !self.entities.remove(&id) {
79            return 0;
80        }
81        let keys: Vec<(EntityId, TypeCode)> = self
82            .component_sizes
83            .range((id, TypeCode(0))..=(id, TypeCode(u32::MAX)))
84            .map(|(k, _)| *k)
85            .collect();
86        let mut freed = 0u64;
87        for k in keys {
88            if let Some(size) = self.component_sizes.remove(&k) {
89                freed = freed.saturating_add(size);
90                self.total_bytes = self.total_bytes.saturating_sub(size);
91            }
92        }
93        freed
94    }
95
96    /// Attach (or replace) a component of `(tc, size)` on `entity`. On a
97    /// replace, `total_bytes` is adjusted by the size *delta* — the prior
98    /// component's size is never double-counted and the type is not
99    /// re-counted. Returns false if the entity is unknown.
100    pub(crate) fn add_component(&mut self, entity: EntityId, tc: TypeCode, size: u64) -> bool {
101        if !self.entities.contains(&entity) {
102            return false;
103        }
104        match self.component_sizes.insert((entity, tc), size) {
105            Some(old) if size >= old => {
106                self.total_bytes = self.total_bytes.saturating_add(size - old);
107            }
108            Some(old) => {
109                self.total_bytes = self.total_bytes.saturating_sub(old - size);
110            }
111            None => {
112                self.total_bytes = self.total_bytes.saturating_add(size);
113            }
114        }
115        true
116    }
117
118    /// Detach a component. The size subtracted is the component's *stored*
119    /// declared size — the caller-supplied `_size` is ignored, so the
120    /// ledger is self-correcting and cannot be skewed by a mismatched
121    /// remove size. Returns false if the entity is unknown.
122    pub(crate) fn remove_component(&mut self, entity: EntityId, tc: TypeCode, _size: u64) -> bool {
123        if !self.entities.contains(&entity) {
124            return false;
125        }
126        if let Some(old) = self.component_sizes.remove(&(entity, tc)) {
127            self.total_bytes = self.total_bytes.saturating_sub(old);
128        }
129        true
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn e(n: u64) -> EntityId {
138        EntityId::new(n).unwrap()
139    }
140    fn t(n: u32) -> TypeCode {
141        TypeCode(n)
142    }
143
144    #[test]
145    fn empty_ledger_zeroes() {
146        let l = ResourceLedger::new();
147        assert_eq!(l.total_entities(), 0);
148        assert_eq!(l.total_bytes(), 0);
149        assert_eq!(l.entity_bytes(e(1)), 0);
150        assert_eq!(l.type_count(t(1)), 0);
151    }
152
153    #[test]
154    fn add_entity_increments_total() {
155        let mut l = ResourceLedger::new();
156        assert!(l.add_entity(e(1)));
157        assert!(l.add_entity(e(2)));
158        assert_eq!(l.total_entities(), 2);
159    }
160
161    #[test]
162    fn add_entity_idempotent() {
163        let mut l = ResourceLedger::new();
164        assert!(l.add_entity(e(1)));
165        assert!(!l.add_entity(e(1)));
166        assert_eq!(l.total_entities(), 1);
167    }
168
169    #[test]
170    fn add_component_updates_bytes_and_count() {
171        let mut l = ResourceLedger::new();
172        l.add_entity(e(1));
173        assert!(l.add_component(e(1), t(10), 100));
174        assert_eq!(l.entity_bytes(e(1)), 100);
175        assert_eq!(l.total_bytes(), 100);
176        assert_eq!(l.type_count(t(10)), 1);
177    }
178
179    #[test]
180    fn add_component_to_unknown_entity_is_noop() {
181        let mut l = ResourceLedger::new();
182        assert!(!l.add_component(e(1), t(10), 100));
183        assert_eq!(l.total_bytes(), 0);
184        assert_eq!(l.type_count(t(10)), 0);
185    }
186
187    #[test]
188    fn remove_component_balances_add() {
189        let mut l = ResourceLedger::new();
190        l.add_entity(e(1));
191        l.add_component(e(1), t(10), 100);
192        l.add_component(e(1), t(20), 50);
193        assert_eq!(l.total_bytes(), 150);
194        l.remove_component(e(1), t(10), 100);
195        assert_eq!(l.total_bytes(), 50);
196        assert_eq!(l.entity_bytes(e(1)), 50);
197        assert_eq!(l.type_count(t(10)), 0);
198        assert_eq!(l.type_count(t(20)), 1);
199    }
200
201    #[test]
202    fn remove_entity_returns_bytes_and_drops_total() {
203        let mut l = ResourceLedger::new();
204        l.add_entity(e(1));
205        l.add_component(e(1), t(10), 100);
206        l.add_component(e(1), t(20), 50);
207        let bytes = l.remove_entity(e(1));
208        assert_eq!(bytes, 150);
209        assert_eq!(l.total_entities(), 0);
210        assert_eq!(l.total_bytes(), 0);
211        assert_eq!(l.entity_bytes(e(1)), 0);
212    }
213
214    #[test]
215    fn remove_unknown_entity_is_noop() {
216        let mut l = ResourceLedger::new();
217        assert_eq!(l.remove_entity(e(999)), 0);
218    }
219
220    #[test]
221    fn add_remove_balanced_yields_empty() {
222        let mut l = ResourceLedger::new();
223        l.add_entity(e(1));
224        l.add_component(e(1), t(10), 100);
225        l.remove_component(e(1), t(10), 100);
226        l.remove_entity(e(1));
227        assert_eq!(l.total_bytes(), 0);
228        assert_eq!(l.total_entities(), 0);
229    }
230
231    #[test]
232    fn type_count_aggregates_across_entities() {
233        let mut l = ResourceLedger::new();
234        l.add_entity(e(1));
235        l.add_entity(e(2));
236        l.add_component(e(1), t(7), 10);
237        l.add_component(e(2), t(7), 20);
238        assert_eq!(l.type_count(t(7)), 2);
239        assert_eq!(l.total_bytes(), 30);
240    }
241
242    #[test]
243    fn type_count_underflow_is_saturating() {
244        // Defensive: extra remove without prior add does not panic.
245        let mut l = ResourceLedger::new();
246        l.add_entity(e(1));
247        l.remove_component(e(1), t(99), 50); // count was 0
248        assert_eq!(l.type_count(t(99)), 0);
249    }
250
251    #[test]
252    fn set_component_replace_adjusts_by_delta_no_double_count() {
253        // Regression: re-setting the same (entity, type) must adjust by the
254        // size delta, not add the full size again, and must not re-count
255        // the type. Larger-then-smaller exercises both delta directions.
256        let mut l = ResourceLedger::new();
257        l.add_entity(e(1));
258        assert!(l.add_component(e(1), t(7), 100));
259        assert!(l.add_component(e(1), t(7), 400)); // replace, +300
260        assert_eq!(l.total_bytes(), 400);
261        assert_eq!(l.entity_bytes(e(1)), 400);
262        assert_eq!(l.type_count(t(7)), 1);
263        assert!(l.add_component(e(1), t(7), 50)); // replace, -350
264        assert_eq!(l.total_bytes(), 50);
265        assert_eq!(l.type_count(t(7)), 1);
266    }
267
268    #[test]
269    fn remove_entity_cascades_component_accounting() {
270        // Regression: despawn must drop every component's bytes AND type
271        // counts — no orphaned accounting after the entity is gone.
272        let mut l = ResourceLedger::new();
273        l.add_entity(e(1));
274        l.add_component(e(1), t(7), 100);
275        l.add_component(e(1), t(9), 50);
276        assert_eq!(l.total_bytes(), 150);
277        assert_eq!(l.type_count(t(7)), 1);
278        assert_eq!(l.type_count(t(9)), 1);
279        assert_eq!(l.remove_entity(e(1)), 150);
280        assert_eq!(l.total_bytes(), 0);
281        assert_eq!(l.total_entities(), 0);
282        assert_eq!(l.type_count(t(7)), 0);
283        assert_eq!(l.type_count(t(9)), 0);
284    }
285
286    #[test]
287    fn remove_component_uses_stored_size_not_caller_size() {
288        // Regression: a remove with a mismatched size must not skew the
289        // ledger — the stored size is authoritative.
290        let mut l = ResourceLedger::new();
291        l.add_entity(e(1));
292        l.add_component(e(1), t(7), 100);
293        assert!(l.remove_component(e(1), t(7), 999_999)); // bogus size ignored
294        assert_eq!(l.total_bytes(), 0);
295        assert_eq!(l.entity_bytes(e(1)), 0);
296    }
297}