Skip to main content

ipfrs_tensorlogic/
budget_manager.rs

1//! Tensor Budget Manager — computational budget tracking and enforcement per inference session.
2//!
3//! Manages FLOP, memory, and time budgets for tensor operations.  Each
4//! inference session gets an independent [`SessionBudget`]; the
5//! [`TensorBudgetManager`] orchestrates multiple concurrent sessions and
6//! exposes aggregated statistics.
7
8use std::collections::HashMap;
9
10// ─── Resource type ────────────────────────────────────────────────────────────
11
12/// The kind of computational resource being budgeted.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum ResourceType {
15    /// Floating-point operations (FLOPs).
16    Flops,
17    /// Memory consumption in bytes.
18    MemoryBytes,
19    /// Wall-clock time in milliseconds.
20    TimeMs,
21}
22
23// ─── Budget ───────────────────────────────────────────────────────────────────
24
25/// A single resource budget: limit, utilisation tracking.
26#[derive(Clone, Debug)]
27pub struct Budget {
28    /// Which resource this budget governs.
29    pub resource: ResourceType,
30    /// Maximum allowed units.
31    pub limit: u64,
32    /// Units consumed so far.
33    pub used: u64,
34}
35
36impl Budget {
37    /// Create a new budget with a given limit (and zero usage).
38    pub fn new(resource: ResourceType, limit: u64) -> Self {
39        Self {
40            resource,
41            limit,
42            used: 0,
43        }
44    }
45
46    /// How many units remain before the limit is hit.
47    pub fn remaining(&self) -> u64 {
48        self.limit.saturating_sub(self.used)
49    }
50
51    /// Fraction of the budget that has been consumed (`used / limit`).
52    ///
53    /// Returns `0.0` when `limit == 0` to avoid division by zero.
54    pub fn utilization(&self) -> f64 {
55        self.used as f64 / self.limit.max(1) as f64
56    }
57
58    /// Whether the budget is fully consumed (`used >= limit`).
59    pub fn is_exhausted(&self) -> bool {
60        self.used >= self.limit
61    }
62}
63
64// ─── Session budget ───────────────────────────────────────────────────────────
65
66/// Per-session budget container.  Holds one [`Budget`] per [`ResourceType`].
67#[derive(Clone, Debug)]
68pub struct SessionBudget {
69    /// Unique identifier for the owning session.
70    pub session_id: u64,
71    /// Per-resource budgets.
72    pub budgets: HashMap<ResourceType, Budget>,
73}
74
75impl SessionBudget {
76    /// Create a new session budget with pre-defined limits.
77    ///
78    /// Resources not listed in `limits` will be auto-created on first use
79    /// with a limit of [`u64::MAX`].
80    pub fn new(session_id: u64, limits: Vec<(ResourceType, u64)>) -> Self {
81        let mut budgets = HashMap::new();
82        for (resource, limit) in limits {
83            budgets.insert(resource, Budget::new(resource, limit));
84        }
85        Self {
86            session_id,
87            budgets,
88        }
89    }
90
91    /// Consume `amount` units of `resource`.
92    ///
93    /// # Errors
94    ///
95    /// Returns `Err("budget exceeded")` if the consumption would push `used`
96    /// past `limit`.  The budget is **not** modified in that case.
97    ///
98    /// If no budget exists for `resource`, one is auto-created with
99    /// `limit = u64::MAX` before the consumption is applied.
100    pub fn consume(&mut self, resource: ResourceType, amount: u64) -> Result<(), String> {
101        let budget = self
102            .budgets
103            .entry(resource)
104            .or_insert_with(|| Budget::new(resource, u64::MAX));
105
106        // Saturating check: would adding `amount` exceed the limit?
107        if budget.used.saturating_add(amount) > budget.limit {
108            return Err("budget exceeded".to_string());
109        }
110        budget.used = budget.used.saturating_add(amount);
111        Ok(())
112    }
113
114    /// Remaining units for `resource`.
115    ///
116    /// Returns [`u64::MAX`] when the resource has not been registered (no
117    /// limit has been set — it is effectively unlimited).
118    pub fn remaining(&self, resource: ResourceType) -> u64 {
119        self.budgets
120            .get(&resource)
121            .map(|b| b.remaining())
122            .unwrap_or(u64::MAX)
123    }
124
125    /// Whether **any** registered resource is exhausted.
126    pub fn is_any_exhausted(&self) -> bool {
127        self.budgets.values().any(|b| b.is_exhausted())
128    }
129}
130
131// ─── Budget manager statistics ────────────────────────────────────────────────
132
133/// Aggregate statistics across all sessions managed by a [`TensorBudgetManager`].
134#[derive(Clone, Debug)]
135pub struct BudgetManagerStats {
136    /// Total number of sessions ever created (including closed ones).
137    pub total_sessions: usize,
138    /// Number of sessions that have at least one exhausted resource.
139    pub exhausted_sessions: usize,
140}
141
142impl BudgetManagerStats {
143    /// Fraction of sessions that have at least one exhausted resource.
144    ///
145    /// Returns `0.0` when no sessions exist.
146    pub fn exhaustion_rate(&self) -> f64 {
147        if self.total_sessions == 0 {
148            return 0.0;
149        }
150        self.exhausted_sessions as f64 / self.total_sessions as f64
151    }
152}
153
154// ─── Tensor budget manager ────────────────────────────────────────────────────
155
156/// Orchestrates computational budgets across multiple concurrent inference
157/// sessions.
158///
159/// # Example
160///
161/// ```
162/// use ipfrs_tensorlogic::budget_manager::{TensorBudgetManager, ResourceType};
163///
164/// let mut mgr = TensorBudgetManager::new();
165/// let sid = mgr.open_session(vec![
166///     (ResourceType::Flops,       1_000_000),
167///     (ResourceType::MemoryBytes, 256 * 1024 * 1024),
168/// ]);
169///
170/// mgr.consume(sid, ResourceType::Flops, 500_000).expect("example: should succeed in docs");
171/// assert!(mgr.close_session(sid));
172/// ```
173#[derive(Debug, Default)]
174pub struct TensorBudgetManager {
175    /// Active sessions keyed by session id.
176    pub sessions: HashMap<u64, SessionBudget>,
177    /// Monotonically-increasing session id counter.
178    pub next_session_id: u64,
179}
180
181impl TensorBudgetManager {
182    /// Create a new, empty budget manager.
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Open a new session with the supplied resource limits.
188    ///
189    /// Returns the unique session id assigned to the new session.
190    pub fn open_session(&mut self, limits: Vec<(ResourceType, u64)>) -> u64 {
191        let id = self.next_session_id;
192        self.next_session_id = self.next_session_id.saturating_add(1);
193        self.sessions.insert(id, SessionBudget::new(id, limits));
194        id
195    }
196
197    /// Consume `amount` units of `resource` for the given session.
198    ///
199    /// # Errors
200    ///
201    /// - `Err("session not found")` — unknown `session_id`.
202    /// - `Err("budget exceeded")` — the resource budget would be exceeded.
203    pub fn consume(
204        &mut self,
205        session_id: u64,
206        resource: ResourceType,
207        amount: u64,
208    ) -> Result<(), String> {
209        self.sessions
210            .get_mut(&session_id)
211            .ok_or_else(|| "session not found".to_string())?
212            .consume(resource, amount)
213    }
214
215    /// Close (remove) a session.
216    ///
217    /// Returns `true` if the session existed and was removed, `false`
218    /// otherwise.
219    pub fn close_session(&mut self, session_id: u64) -> bool {
220        self.sessions.remove(&session_id).is_some()
221    }
222
223    /// Compute aggregate statistics across **all currently open** sessions.
224    pub fn stats(&self) -> BudgetManagerStats {
225        let total_sessions = self.sessions.len();
226        let exhausted_sessions = self
227            .sessions
228            .values()
229            .filter(|s| s.is_any_exhausted())
230            .count();
231        BudgetManagerStats {
232            total_sessions,
233            exhausted_sessions,
234        }
235    }
236
237    /// Return per-resource utilisation for the given session.
238    ///
239    /// Returns `None` if the session does not exist.
240    pub fn session_utilization(&self, session_id: u64) -> Option<Vec<(ResourceType, f64)>> {
241        let session = self.sessions.get(&session_id)?;
242        let utilizations = session
243            .budgets
244            .iter()
245            .map(|(&resource, budget)| (resource, budget.utilization()))
246            .collect();
247        Some(utilizations)
248    }
249}
250
251// ─── Tests ────────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // Helper: open a session with common limits.
258    fn make_manager_with_session() -> (TensorBudgetManager, u64) {
259        let mut mgr = TensorBudgetManager::new();
260        let sid = mgr.open_session(vec![
261            (ResourceType::Flops, 1_000),
262            (ResourceType::MemoryBytes, 4_096),
263            (ResourceType::TimeMs, 500),
264        ]);
265        (mgr, sid)
266    }
267
268    // ── 1. open_session returns a valid id and creates budgets ─────────────
269    #[test]
270    fn test_open_session_creates_budgets() {
271        let (mgr, sid) = make_manager_with_session();
272        assert!(mgr.sessions.contains_key(&sid));
273        let session = &mgr.sessions[&sid];
274        assert_eq!(session.session_id, sid);
275        assert_eq!(session.budgets[&ResourceType::Flops].limit, 1_000);
276        assert_eq!(session.budgets[&ResourceType::MemoryBytes].limit, 4_096);
277        assert_eq!(session.budgets[&ResourceType::TimeMs].limit, 500);
278    }
279
280    // ── 2. open_session assigns distinct ids ──────────────────────────────
281    #[test]
282    fn test_open_session_distinct_ids() {
283        let mut mgr = TensorBudgetManager::new();
284        let a = mgr.open_session(vec![]);
285        let b = mgr.open_session(vec![]);
286        let c = mgr.open_session(vec![]);
287        assert_ne!(a, b);
288        assert_ne!(b, c);
289        assert_ne!(a, c);
290    }
291
292    // ── 3. consume within budget succeeds ─────────────────────────────────
293    #[test]
294    fn test_consume_within_budget_ok() {
295        let (mut mgr, sid) = make_manager_with_session();
296        assert!(mgr.consume(sid, ResourceType::Flops, 500).is_ok());
297        assert_eq!(mgr.sessions[&sid].budgets[&ResourceType::Flops].used, 500);
298    }
299
300    // ── 4. consume exactly at the limit is allowed ────────────────────────
301    #[test]
302    fn test_consume_at_limit_ok() {
303        let (mut mgr, sid) = make_manager_with_session();
304        assert!(mgr.consume(sid, ResourceType::Flops, 1_000).is_ok());
305        assert!(mgr.sessions[&sid].budgets[&ResourceType::Flops].is_exhausted());
306    }
307
308    // ── 5. consume exceeding limit returns Err ────────────────────────────
309    #[test]
310    fn test_consume_exceeds_budget_err() {
311        let (mut mgr, sid) = make_manager_with_session();
312        let result = mgr.consume(sid, ResourceType::Flops, 1_001);
313        assert!(result.is_err());
314        assert_eq!(result.unwrap_err(), "budget exceeded");
315    }
316
317    // ── 6. budget not modified on Err ─────────────────────────────────────
318    #[test]
319    fn test_consume_budget_unchanged_on_err() {
320        let (mut mgr, sid) = make_manager_with_session();
321        let _ = mgr.consume(sid, ResourceType::Flops, 999);
322        let _ = mgr.consume(sid, ResourceType::Flops, 999); // would exceed
323        assert_eq!(mgr.sessions[&sid].budgets[&ResourceType::Flops].used, 999);
324    }
325
326    // ── 7. auto-create unlimited budget for unknown resource ──────────────
327    #[test]
328    fn test_auto_create_unlimited_budget() {
329        let mut mgr = TensorBudgetManager::new();
330        // open session with NO limits for Flops
331        let sid = mgr.open_session(vec![(ResourceType::MemoryBytes, 1_024)]);
332        // consuming Flops should succeed (auto-created with u64::MAX limit)
333        assert!(mgr.consume(sid, ResourceType::Flops, u64::MAX / 2).is_ok());
334        assert_eq!(
335            mgr.sessions[&sid].budgets[&ResourceType::Flops].limit,
336            u64::MAX
337        );
338    }
339
340    // ── 8. remaining after consume ────────────────────────────────────────
341    #[test]
342    fn test_remaining_after_consume() {
343        let (mut mgr, sid) = make_manager_with_session();
344        mgr.consume(sid, ResourceType::TimeMs, 200)
345            .expect("test: should succeed");
346        let session = &mgr.sessions[&sid];
347        assert_eq!(session.remaining(ResourceType::TimeMs), 300);
348    }
349
350    // ── 9. remaining for unregistered resource is u64::MAX ────────────────
351    #[test]
352    fn test_remaining_unregistered_resource() {
353        let mut mgr = TensorBudgetManager::new();
354        let sid = mgr.open_session(vec![]); // no resources registered
355        let session = &mgr.sessions[&sid];
356        assert_eq!(session.remaining(ResourceType::Flops), u64::MAX);
357    }
358
359    // ── 10. is_exhausted ─────────────────────────────────────────────────
360    #[test]
361    fn test_budget_is_exhausted() {
362        let mut b = Budget::new(ResourceType::Flops, 100);
363        assert!(!b.is_exhausted());
364        b.used = 100;
365        assert!(b.is_exhausted());
366        b.used = 101;
367        assert!(b.is_exhausted());
368    }
369
370    // ── 11. utilization calculation ───────────────────────────────────────
371    #[test]
372    fn test_budget_utilization() {
373        let mut b = Budget::new(ResourceType::MemoryBytes, 200);
374        assert!((b.utilization() - 0.0).abs() < f64::EPSILON);
375        b.used = 100;
376        assert!((b.utilization() - 0.5).abs() < f64::EPSILON);
377        b.used = 200;
378        assert!((b.utilization() - 1.0).abs() < f64::EPSILON);
379    }
380
381    // ── 12. utilization when limit == 0 ───────────────────────────────────
382    #[test]
383    fn test_budget_utilization_zero_limit() {
384        let b = Budget::new(ResourceType::Flops, 0);
385        // limit.max(1) == 1, so utilization = 0/1 = 0.0
386        assert!((b.utilization() - 0.0).abs() < f64::EPSILON);
387    }
388
389    // ── 13. is_any_exhausted ─────────────────────────────────────────────
390    #[test]
391    fn test_session_is_any_exhausted() {
392        let mut mgr = TensorBudgetManager::new();
393        let sid = mgr.open_session(vec![
394            (ResourceType::Flops, 10),
395            (ResourceType::MemoryBytes, 1_000),
396        ]);
397        assert!(!mgr.sessions[&sid].is_any_exhausted());
398        mgr.consume(sid, ResourceType::Flops, 10)
399            .expect("test: should succeed");
400        assert!(mgr.sessions[&sid].is_any_exhausted());
401    }
402
403    // ── 14. close_session removes session and returns true ────────────────
404    #[test]
405    fn test_close_session_removes() {
406        let (mut mgr, sid) = make_manager_with_session();
407        assert!(mgr.close_session(sid));
408        assert!(!mgr.sessions.contains_key(&sid));
409    }
410
411    // ── 15. close_session on unknown id returns false ─────────────────────
412    #[test]
413    fn test_close_session_unknown_returns_false() {
414        let mut mgr = TensorBudgetManager::new();
415        assert!(!mgr.close_session(9999));
416    }
417
418    // ── 16. stats: exhausted_sessions count ──────────────────────────────
419    #[test]
420    fn test_stats_exhausted_sessions() {
421        let mut mgr = TensorBudgetManager::new();
422        let s1 = mgr.open_session(vec![(ResourceType::Flops, 10)]);
423        let s2 = mgr.open_session(vec![(ResourceType::Flops, 10)]);
424        let _s3 = mgr.open_session(vec![(ResourceType::Flops, 10)]);
425
426        // exhaust s1 and s2
427        mgr.consume(s1, ResourceType::Flops, 10)
428            .expect("test: should succeed");
429        mgr.consume(s2, ResourceType::Flops, 10)
430            .expect("test: should succeed");
431
432        let stats = mgr.stats();
433        assert_eq!(stats.total_sessions, 3);
434        assert_eq!(stats.exhausted_sessions, 2);
435    }
436
437    // ── 17. exhaustion_rate ───────────────────────────────────────────────
438    #[test]
439    fn test_exhaustion_rate() {
440        let mut mgr = TensorBudgetManager::new();
441        let s1 = mgr.open_session(vec![(ResourceType::Flops, 10)]);
442        let _s2 = mgr.open_session(vec![(ResourceType::Flops, 10)]);
443        mgr.consume(s1, ResourceType::Flops, 10)
444            .expect("test: should succeed");
445
446        let stats = mgr.stats();
447        assert!((stats.exhaustion_rate() - 0.5).abs() < f64::EPSILON);
448    }
449
450    // ── 18. exhaustion_rate with no sessions ─────────────────────────────
451    #[test]
452    fn test_exhaustion_rate_no_sessions() {
453        let mgr = TensorBudgetManager::new();
454        let stats = mgr.stats();
455        assert_eq!(stats.total_sessions, 0);
456        assert!((stats.exhaustion_rate() - 0.0).abs() < f64::EPSILON);
457    }
458
459    // ── 19. session_utilization returns correct values ────────────────────
460    #[test]
461    fn test_session_utilization() {
462        let mut mgr = TensorBudgetManager::new();
463        let sid = mgr.open_session(vec![
464            (ResourceType::Flops, 1_000),
465            (ResourceType::MemoryBytes, 2_000),
466        ]);
467        mgr.consume(sid, ResourceType::Flops, 250)
468            .expect("test: should succeed");
469        mgr.consume(sid, ResourceType::MemoryBytes, 1_000)
470            .expect("test: should succeed");
471
472        let utils = mgr.session_utilization(sid).expect("session must exist");
473        let mut map: HashMap<ResourceType, f64> = utils.into_iter().collect();
474        let flops_util = map.remove(&ResourceType::Flops).expect("Flops missing");
475        let mem_util = map
476            .remove(&ResourceType::MemoryBytes)
477            .expect("MemoryBytes missing");
478
479        assert!((flops_util - 0.25).abs() < 1e-9);
480        assert!((mem_util - 0.5).abs() < 1e-9);
481    }
482
483    // ── 20. session_utilization returns None for unknown session ──────────
484    #[test]
485    fn test_session_utilization_unknown() {
486        let mgr = TensorBudgetManager::new();
487        assert!(mgr.session_utilization(42).is_none());
488    }
489
490    // ── 21. consume on unknown session returns Err ────────────────────────
491    #[test]
492    fn test_consume_unknown_session_err() {
493        let mut mgr = TensorBudgetManager::new();
494        let result = mgr.consume(9999, ResourceType::Flops, 1);
495        assert!(result.is_err());
496        assert_eq!(result.unwrap_err(), "session not found");
497    }
498
499    // ── 22. multiple resources tracked independently ──────────────────────
500    #[test]
501    fn test_multiple_resources_independent() {
502        let (mut mgr, sid) = make_manager_with_session();
503        mgr.consume(sid, ResourceType::Flops, 900)
504            .expect("test: should succeed");
505        mgr.consume(sid, ResourceType::MemoryBytes, 100)
506            .expect("test: should succeed");
507
508        let session = &mgr.sessions[&sid];
509        assert_eq!(session.budgets[&ResourceType::Flops].used, 900);
510        assert_eq!(session.budgets[&ResourceType::MemoryBytes].used, 100);
511        assert_eq!(session.budgets[&ResourceType::TimeMs].used, 0);
512    }
513}