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    /// Currently-attributed declared size of the `(entity, type_code)`
52    /// component, or `None` if no such component is accounted. This is the
53    /// amount a `RemoveComponent` will actually free at apply time, so the
54    /// budget projection in `runtime::kernel::step()` credits removals by
55    /// this stored value — never by the caller-declared `size`, which is
56    /// untrusted and could otherwise poison the projection.
57    pub(crate) fn component_size(&self, entity: EntityId, tc: TypeCode) -> Option<u64> {
58        self.component_sizes.get(&(entity, tc)).copied()
59    }
60
61    #[cfg_attr(not(test), allow(dead_code))]
62    pub(crate) fn entity_bytes(&self, id: EntityId) -> u64 {
63        self.component_sizes
64            .range((id, TypeCode(0))..=(id, TypeCode(u32::MAX)))
65            .fold(0u64, |acc, (_, size)| acc.saturating_add(*size))
66    }
67
68    #[cfg_attr(not(test), allow(dead_code))]
69    pub(crate) fn type_count(&self, tc: TypeCode) -> u32 {
70        let n = self
71            .component_sizes
72            .keys()
73            .filter(|(_, t)| *t == tc)
74            .count();
75        u32::try_from(n).unwrap_or(u32::MAX)
76    }
77
78    /// Register a new entity with zero attached bytes. Returns true on
79    /// fresh insertion, false if the entity was already present (idempotent).
80    pub(crate) fn add_entity(&mut self, id: EntityId) -> bool {
81        self.entities.insert(id)
82    }
83
84    /// Remove an entity and cascade-drop every component it still held,
85    /// so a despawn cannot leave orphaned byte/type accounting behind.
86    /// Returns the total bytes that were attributed to the entity.
87    pub(crate) fn remove_entity(&mut self, id: EntityId) -> u64 {
88        if !self.entities.remove(&id) {
89            return 0;
90        }
91        let keys: Vec<(EntityId, TypeCode)> = self
92            .component_sizes
93            .range((id, TypeCode(0))..=(id, TypeCode(u32::MAX)))
94            .map(|(k, _)| *k)
95            .collect();
96        let mut freed = 0u64;
97        for k in keys {
98            if let Some(size) = self.component_sizes.remove(&k) {
99                freed = freed.saturating_add(size);
100                self.total_bytes = self.total_bytes.saturating_sub(size);
101            }
102        }
103        freed
104    }
105
106    /// Attach (or replace) a component of `(tc, size)` on `entity`. On a
107    /// replace, `total_bytes` is adjusted by the size *delta* — the prior
108    /// component's size is never double-counted and the type is not
109    /// re-counted. Returns false if the entity is unknown.
110    pub(crate) fn add_component(&mut self, entity: EntityId, tc: TypeCode, size: u64) -> bool {
111        if !self.entities.contains(&entity) {
112            return false;
113        }
114        match self.component_sizes.insert((entity, tc), size) {
115            Some(old) if size >= old => {
116                self.total_bytes = self.total_bytes.saturating_add(size - old);
117            }
118            Some(old) => {
119                self.total_bytes = self.total_bytes.saturating_sub(old - size);
120            }
121            None => {
122                self.total_bytes = self.total_bytes.saturating_add(size);
123            }
124        }
125        true
126    }
127
128    /// Detach a component. The size subtracted is the component's *stored*
129    /// declared size — the caller-supplied `_size` is ignored, so the
130    /// ledger is self-correcting and cannot be skewed by a mismatched
131    /// remove size. Returns false if the entity is unknown.
132    pub(crate) fn remove_component(&mut self, entity: EntityId, tc: TypeCode, _size: u64) -> bool {
133        if !self.entities.contains(&entity) {
134            return false;
135        }
136        if let Some(old) = self.component_sizes.remove(&(entity, tc)) {
137            self.total_bytes = self.total_bytes.saturating_sub(old);
138        }
139        true
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn e(n: u64) -> EntityId {
148        EntityId::new(n).unwrap()
149    }
150    fn t(n: u32) -> TypeCode {
151        TypeCode(n)
152    }
153
154    #[test]
155    fn empty_ledger_zeroes() {
156        let l = ResourceLedger::new();
157        assert_eq!(l.total_entities(), 0);
158        assert_eq!(l.total_bytes(), 0);
159        assert_eq!(l.entity_bytes(e(1)), 0);
160        assert_eq!(l.type_count(t(1)), 0);
161    }
162
163    #[test]
164    fn add_entity_increments_total() {
165        let mut l = ResourceLedger::new();
166        assert!(l.add_entity(e(1)));
167        assert!(l.add_entity(e(2)));
168        assert_eq!(l.total_entities(), 2);
169    }
170
171    #[test]
172    fn add_entity_idempotent() {
173        let mut l = ResourceLedger::new();
174        assert!(l.add_entity(e(1)));
175        assert!(!l.add_entity(e(1)));
176        assert_eq!(l.total_entities(), 1);
177    }
178
179    #[test]
180    fn add_component_updates_bytes_and_count() {
181        let mut l = ResourceLedger::new();
182        l.add_entity(e(1));
183        assert!(l.add_component(e(1), t(10), 100));
184        assert_eq!(l.entity_bytes(e(1)), 100);
185        assert_eq!(l.total_bytes(), 100);
186        assert_eq!(l.type_count(t(10)), 1);
187    }
188
189    #[test]
190    fn add_component_to_unknown_entity_is_noop() {
191        let mut l = ResourceLedger::new();
192        assert!(!l.add_component(e(1), t(10), 100));
193        assert_eq!(l.total_bytes(), 0);
194        assert_eq!(l.type_count(t(10)), 0);
195    }
196
197    #[test]
198    fn remove_component_balances_add() {
199        let mut l = ResourceLedger::new();
200        l.add_entity(e(1));
201        l.add_component(e(1), t(10), 100);
202        l.add_component(e(1), t(20), 50);
203        assert_eq!(l.total_bytes(), 150);
204        l.remove_component(e(1), t(10), 100);
205        assert_eq!(l.total_bytes(), 50);
206        assert_eq!(l.entity_bytes(e(1)), 50);
207        assert_eq!(l.type_count(t(10)), 0);
208        assert_eq!(l.type_count(t(20)), 1);
209    }
210
211    #[test]
212    fn remove_entity_returns_bytes_and_drops_total() {
213        let mut l = ResourceLedger::new();
214        l.add_entity(e(1));
215        l.add_component(e(1), t(10), 100);
216        l.add_component(e(1), t(20), 50);
217        let bytes = l.remove_entity(e(1));
218        assert_eq!(bytes, 150);
219        assert_eq!(l.total_entities(), 0);
220        assert_eq!(l.total_bytes(), 0);
221        assert_eq!(l.entity_bytes(e(1)), 0);
222    }
223
224    #[test]
225    fn remove_unknown_entity_is_noop() {
226        let mut l = ResourceLedger::new();
227        assert_eq!(l.remove_entity(e(999)), 0);
228    }
229
230    #[test]
231    fn add_remove_balanced_yields_empty() {
232        let mut l = ResourceLedger::new();
233        l.add_entity(e(1));
234        l.add_component(e(1), t(10), 100);
235        l.remove_component(e(1), t(10), 100);
236        l.remove_entity(e(1));
237        assert_eq!(l.total_bytes(), 0);
238        assert_eq!(l.total_entities(), 0);
239    }
240
241    #[test]
242    fn type_count_aggregates_across_entities() {
243        let mut l = ResourceLedger::new();
244        l.add_entity(e(1));
245        l.add_entity(e(2));
246        l.add_component(e(1), t(7), 10);
247        l.add_component(e(2), t(7), 20);
248        assert_eq!(l.type_count(t(7)), 2);
249        assert_eq!(l.total_bytes(), 30);
250    }
251
252    #[test]
253    fn type_count_underflow_is_saturating() {
254        // Defensive: extra remove without prior add does not panic.
255        let mut l = ResourceLedger::new();
256        l.add_entity(e(1));
257        l.remove_component(e(1), t(99), 50); // count was 0
258        assert_eq!(l.type_count(t(99)), 0);
259    }
260
261    #[test]
262    fn set_component_replace_adjusts_by_delta_no_double_count() {
263        // Regression: re-setting the same (entity, type) must adjust by the
264        // size delta, not add the full size again, and must not re-count
265        // the type. Larger-then-smaller exercises both delta directions.
266        let mut l = ResourceLedger::new();
267        l.add_entity(e(1));
268        assert!(l.add_component(e(1), t(7), 100));
269        assert!(l.add_component(e(1), t(7), 400)); // replace, +300
270        assert_eq!(l.total_bytes(), 400);
271        assert_eq!(l.entity_bytes(e(1)), 400);
272        assert_eq!(l.type_count(t(7)), 1);
273        assert!(l.add_component(e(1), t(7), 50)); // replace, -350
274        assert_eq!(l.total_bytes(), 50);
275        assert_eq!(l.type_count(t(7)), 1);
276    }
277
278    #[test]
279    fn remove_entity_cascades_component_accounting() {
280        // Regression: despawn must drop every component's bytes AND type
281        // counts — no orphaned accounting after the entity is gone.
282        let mut l = ResourceLedger::new();
283        l.add_entity(e(1));
284        l.add_component(e(1), t(7), 100);
285        l.add_component(e(1), t(9), 50);
286        assert_eq!(l.total_bytes(), 150);
287        assert_eq!(l.type_count(t(7)), 1);
288        assert_eq!(l.type_count(t(9)), 1);
289        assert_eq!(l.remove_entity(e(1)), 150);
290        assert_eq!(l.total_bytes(), 0);
291        assert_eq!(l.total_entities(), 0);
292        assert_eq!(l.type_count(t(7)), 0);
293        assert_eq!(l.type_count(t(9)), 0);
294    }
295
296    #[test]
297    fn remove_component_uses_stored_size_not_caller_size() {
298        // Regression: a remove with a mismatched size must not skew the
299        // ledger — the stored size is authoritative.
300        let mut l = ResourceLedger::new();
301        l.add_entity(e(1));
302        l.add_component(e(1), t(7), 100);
303        assert!(l.remove_component(e(1), t(7), 999_999)); // bogus size ignored
304        assert_eq!(l.total_bytes(), 0);
305        assert_eq!(l.entity_bytes(e(1)), 0);
306    }
307}