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;
10
11use serde::{Deserialize, Serialize};
12
13use crate::abi::{EntityId, TypeCode};
14
15#[derive(Debug, Default, Clone, Serialize, Deserialize)]
16pub(crate) struct ResourceLedger {
17    entity_bytes: BTreeMap<EntityId, u64>,
18    type_counts: BTreeMap<TypeCode, u32>,
19    total_bytes: u64,
20    total_entities: u32,
21}
22
23impl ResourceLedger {
24    pub(crate) fn new() -> Self {
25        Self::default()
26    }
27
28    /// Total component bytes attributed across all entities. Consumed by
29    /// `runtime::kernel::step()` for `memory_budget_bytes` enforcement.
30    #[inline]
31    pub(crate) fn total_bytes(&self) -> u64 {
32        self.total_bytes
33    }
34
35    // Test-only observability accessors below. Production introspection
36    // wiring lands with the future IntrospectHandle interface (deferred).
37
38    #[cfg_attr(not(test), allow(dead_code))]
39    #[inline]
40    pub(crate) fn total_entities(&self) -> u32 {
41        self.total_entities
42    }
43
44    #[cfg_attr(not(test), allow(dead_code))]
45    pub(crate) fn entity_bytes(&self, id: EntityId) -> u64 {
46        *self.entity_bytes.get(&id).unwrap_or(&0)
47    }
48
49    #[cfg_attr(not(test), allow(dead_code))]
50    pub(crate) fn type_count(&self, tc: TypeCode) -> u32 {
51        *self.type_counts.get(&tc).unwrap_or(&0)
52    }
53
54    /// Register a new entity with zero attached bytes. Returns true on
55    /// fresh insertion, false if the entity was already present (idempotent).
56    pub(crate) fn add_entity(&mut self, id: EntityId) -> bool {
57        if self.entity_bytes.insert(id, 0).is_none() {
58            self.total_entities = self.total_entities.saturating_add(1);
59            true
60        } else {
61            false
62        }
63    }
64
65    /// Remove an entity; returns the bytes that were attributed to it.
66    /// Caller is responsible for emitting per-component `remove_component`
67    /// calls in canonical apply order *before* this one if it wants
68    /// `type_counts` decrements; this method is the entity-row removal
69    /// (saturating-subtract from totals).
70    pub(crate) fn remove_entity(&mut self, id: EntityId) -> u64 {
71        if let Some(bytes) = self.entity_bytes.remove(&id) {
72            self.total_bytes = self.total_bytes.saturating_sub(bytes);
73            self.total_entities = self.total_entities.saturating_sub(1);
74            bytes
75        } else {
76            0
77        }
78    }
79
80    /// Attach a component of `(tc, size)` to `entity`. Returns false if the
81    /// entity is unknown — apply ordering is the caller's responsibility.
82    pub(crate) fn add_component(&mut self, entity: EntityId, tc: TypeCode, size: u64) -> bool {
83        let Some(bytes) = self.entity_bytes.get_mut(&entity) else {
84            return false;
85        };
86        *bytes = bytes.saturating_add(size);
87        self.total_bytes = self.total_bytes.saturating_add(size);
88        *self.type_counts.entry(tc).or_insert(0) += 1;
89        true
90    }
91
92    /// Detach a component. Returns false if the entity is unknown.
93    /// `type_counts` entry is removed when its count reaches zero.
94    pub(crate) fn remove_component(&mut self, entity: EntityId, tc: TypeCode, size: u64) -> bool {
95        let Some(bytes) = self.entity_bytes.get_mut(&entity) else {
96            return false;
97        };
98        *bytes = bytes.saturating_sub(size);
99        self.total_bytes = self.total_bytes.saturating_sub(size);
100        if let Some(c) = self.type_counts.get_mut(&tc) {
101            *c = c.saturating_sub(1);
102            if *c == 0 {
103                self.type_counts.remove(&tc);
104            }
105        }
106        true
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn e(n: u64) -> EntityId {
115        EntityId::new(n).unwrap()
116    }
117    fn t(n: u32) -> TypeCode {
118        TypeCode(n)
119    }
120
121    #[test]
122    fn empty_ledger_zeroes() {
123        let l = ResourceLedger::new();
124        assert_eq!(l.total_entities(), 0);
125        assert_eq!(l.total_bytes(), 0);
126        assert_eq!(l.entity_bytes(e(1)), 0);
127        assert_eq!(l.type_count(t(1)), 0);
128    }
129
130    #[test]
131    fn add_entity_increments_total() {
132        let mut l = ResourceLedger::new();
133        assert!(l.add_entity(e(1)));
134        assert!(l.add_entity(e(2)));
135        assert_eq!(l.total_entities(), 2);
136    }
137
138    #[test]
139    fn add_entity_idempotent() {
140        let mut l = ResourceLedger::new();
141        assert!(l.add_entity(e(1)));
142        assert!(!l.add_entity(e(1)));
143        assert_eq!(l.total_entities(), 1);
144    }
145
146    #[test]
147    fn add_component_updates_bytes_and_count() {
148        let mut l = ResourceLedger::new();
149        l.add_entity(e(1));
150        assert!(l.add_component(e(1), t(10), 100));
151        assert_eq!(l.entity_bytes(e(1)), 100);
152        assert_eq!(l.total_bytes(), 100);
153        assert_eq!(l.type_count(t(10)), 1);
154    }
155
156    #[test]
157    fn add_component_to_unknown_entity_is_noop() {
158        let mut l = ResourceLedger::new();
159        assert!(!l.add_component(e(1), t(10), 100));
160        assert_eq!(l.total_bytes(), 0);
161        assert_eq!(l.type_count(t(10)), 0);
162    }
163
164    #[test]
165    fn remove_component_balances_add() {
166        let mut l = ResourceLedger::new();
167        l.add_entity(e(1));
168        l.add_component(e(1), t(10), 100);
169        l.add_component(e(1), t(20), 50);
170        assert_eq!(l.total_bytes(), 150);
171        l.remove_component(e(1), t(10), 100);
172        assert_eq!(l.total_bytes(), 50);
173        assert_eq!(l.entity_bytes(e(1)), 50);
174        assert_eq!(l.type_count(t(10)), 0);
175        assert_eq!(l.type_count(t(20)), 1);
176    }
177
178    #[test]
179    fn remove_entity_returns_bytes_and_drops_total() {
180        let mut l = ResourceLedger::new();
181        l.add_entity(e(1));
182        l.add_component(e(1), t(10), 100);
183        l.add_component(e(1), t(20), 50);
184        let bytes = l.remove_entity(e(1));
185        assert_eq!(bytes, 150);
186        assert_eq!(l.total_entities(), 0);
187        assert_eq!(l.total_bytes(), 0);
188        assert_eq!(l.entity_bytes(e(1)), 0);
189    }
190
191    #[test]
192    fn remove_unknown_entity_is_noop() {
193        let mut l = ResourceLedger::new();
194        assert_eq!(l.remove_entity(e(999)), 0);
195    }
196
197    #[test]
198    fn add_remove_balanced_yields_empty() {
199        let mut l = ResourceLedger::new();
200        l.add_entity(e(1));
201        l.add_component(e(1), t(10), 100);
202        l.remove_component(e(1), t(10), 100);
203        l.remove_entity(e(1));
204        assert_eq!(l.total_bytes(), 0);
205        assert_eq!(l.total_entities(), 0);
206    }
207
208    #[test]
209    fn type_count_aggregates_across_entities() {
210        let mut l = ResourceLedger::new();
211        l.add_entity(e(1));
212        l.add_entity(e(2));
213        l.add_component(e(1), t(7), 10);
214        l.add_component(e(2), t(7), 20);
215        assert_eq!(l.type_count(t(7)), 2);
216        assert_eq!(l.total_bytes(), 30);
217    }
218
219    #[test]
220    fn type_count_underflow_is_saturating() {
221        // Defensive: extra remove without prior add does not panic.
222        let mut l = ResourceLedger::new();
223        l.add_entity(e(1));
224        l.remove_component(e(1), t(99), 50); // count was 0
225        assert_eq!(l.type_count(t(99)), 0);
226    }
227}