Skip to main content

ipfrs_tensorlogic/
flow_controller.rs

1//! Tensor flow controller — manages backpressure, rate limiting, and priority-based admission
2//! for tensor data flowing through a processing pipeline.
3
4/// Operational state of the flow controller.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum FlowState {
7    /// Normal operation; items are accepted and processed at full rate.
8    Running,
9    /// Rate limit applied; some items may be delayed.
10    Throttled,
11    /// All flow halted; no items are processed.
12    Paused,
13    /// No new items accepted; existing items continue to drain.
14    Draining,
15}
16
17/// Priority level assigned to a flow item.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
19pub enum FlowPriority {
20    Low = 0,
21    Normal = 1,
22    High = 2,
23    Critical = 3,
24}
25
26/// A single item flowing through the pipeline.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct FlowItem {
29    pub item_id: u64,
30    pub tensor_id: u64,
31    pub priority: FlowPriority,
32    pub size_bytes: u64,
33    pub enqueued_at_tick: u64,
34}
35
36/// Configuration for the flow controller.
37#[derive(Clone, Debug)]
38pub struct FlowControllerConfig {
39    /// Maximum number of items the queue may hold.
40    pub max_queue_size: usize,
41    /// Maximum bytes to process in a single tick.
42    pub max_bytes_per_tick: u64,
43    /// Queue fill ratio (0.0–1.0) at which the state transitions to `Throttled`.
44    pub backpressure_threshold: f64,
45}
46
47impl Default for FlowControllerConfig {
48    fn default() -> Self {
49        Self {
50            max_queue_size: 256,
51            max_bytes_per_tick: 1_048_576,
52            backpressure_threshold: 0.8,
53        }
54    }
55}
56
57/// Cumulative statistics for the flow controller.
58#[derive(Clone, Debug, Default)]
59pub struct FlowStats {
60    pub total_admitted: u64,
61    pub total_dropped: u64,
62    pub total_processed: u64,
63    pub total_bytes_processed: u64,
64}
65
66impl FlowStats {
67    /// Drop rate: `dropped / (admitted + dropped)`. Returns `0.0` when both are zero.
68    pub fn drop_rate(&self) -> f64 {
69        let total = self.total_admitted + self.total_dropped;
70        if total == 0 {
71            0.0
72        } else {
73            self.total_dropped as f64 / total as f64
74        }
75    }
76}
77
78/// Controls the flow of tensor data through a processing pipeline.
79///
80/// The internal queue is kept sorted by priority descending (Critical first),
81/// then by `enqueued_at_tick` ascending (FIFO within a priority level).
82pub struct TensorFlowController {
83    queue: Vec<FlowItem>,
84    state: FlowState,
85    config: FlowControllerConfig,
86    stats: FlowStats,
87}
88
89impl TensorFlowController {
90    /// Create a new controller with the given configuration, starting in `Running` state.
91    pub fn new(config: FlowControllerConfig) -> Self {
92        Self {
93            queue: Vec::new(),
94            state: FlowState::Running,
95            config,
96            stats: FlowStats::default(),
97        }
98    }
99
100    /// Attempt to admit a new item into the queue.
101    ///
102    /// Returns `true` if the item was admitted, `false` if it was dropped.
103    pub fn admit(&mut self, item: FlowItem) -> bool {
104        // Paused and Draining states reject all new items.
105        if self.state == FlowState::Paused || self.state == FlowState::Draining {
106            self.stats.total_dropped += 1;
107            return false;
108        }
109
110        // Reject when the queue is at capacity.
111        if self.queue.len() >= self.config.max_queue_size {
112            self.stats.total_dropped += 1;
113            return false;
114        }
115
116        // Insert maintaining sort order: priority desc, then enqueued_at_tick asc.
117        let pos = self.queue.partition_point(|existing| {
118            existing.priority > item.priority
119                || (existing.priority == item.priority
120                    && existing.enqueued_at_tick <= item.enqueued_at_tick)
121        });
122        self.queue.insert(pos, item);
123        self.stats.total_admitted += 1;
124
125        // Update state based on queue fill ratio.
126        self.state = self.compute_state_after_admit();
127        true
128    }
129
130    /// Process one tick: drain items from the front of the queue up to the byte budget.
131    ///
132    /// Returns the items that were processed during this tick.
133    pub fn process_tick(&mut self) -> Vec<FlowItem> {
134        if self.state == FlowState::Paused {
135            return Vec::new();
136        }
137
138        let mut processed = Vec::new();
139        let mut bytes_remaining = self.config.max_bytes_per_tick;
140
141        while let Some(front) = self.queue.first() {
142            if front.size_bytes > bytes_remaining {
143                break;
144            }
145            bytes_remaining -= front.size_bytes;
146            let item = self.queue.remove(0);
147            self.stats.total_processed += 1;
148            self.stats.total_bytes_processed += item.size_bytes;
149            processed.push(item);
150        }
151
152        // Update state after draining.
153        self.state = self.compute_state_after_tick();
154        processed
155    }
156
157    /// Halt all flow immediately.
158    pub fn pause(&mut self) {
159        self.state = FlowState::Paused;
160    }
161
162    /// Resume from a `Paused` state. The new state is determined by the current queue fill.
163    pub fn resume(&mut self) {
164        if self.state == FlowState::Paused {
165            self.state = self.fill_based_state();
166        }
167    }
168
169    /// Stop admitting new items; allow the queue to drain.
170    pub fn drain(&mut self) {
171        self.state = FlowState::Draining;
172    }
173
174    /// Current number of items waiting in the queue.
175    pub fn queue_len(&self) -> usize {
176        self.queue.len()
177    }
178
179    /// Reference to the current statistics.
180    pub fn stats(&self) -> &FlowStats {
181        &self.stats
182    }
183
184    // ── internal helpers ──────────────────────────────────────────────────────
185
186    /// Derive the correct state based solely on queue fill ratio (used after `admit`).
187    fn compute_state_after_admit(&self) -> FlowState {
188        let fill = self.queue.len() as f64 / self.config.max_queue_size as f64;
189        if fill >= self.config.backpressure_threshold {
190            FlowState::Throttled
191        } else {
192            FlowState::Running
193        }
194    }
195
196    /// Derive the correct state after a `process_tick` call.
197    fn compute_state_after_tick(&self) -> FlowState {
198        if self.state == FlowState::Draining && self.queue.is_empty() {
199            return FlowState::Paused;
200        }
201        self.fill_based_state()
202    }
203
204    /// Running vs Throttled based on queue fill — does not consider Paused / Draining.
205    fn fill_based_state(&self) -> FlowState {
206        let fill = self.queue.len() as f64 / self.config.max_queue_size as f64;
207        if fill >= self.config.backpressure_threshold {
208            FlowState::Throttled
209        } else {
210            FlowState::Running
211        }
212    }
213}
214
215// ── tests ─────────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn default_controller() -> TensorFlowController {
222        TensorFlowController::new(FlowControllerConfig::default())
223    }
224
225    fn make_item(item_id: u64, priority: FlowPriority, size_bytes: u64, tick: u64) -> FlowItem {
226        FlowItem {
227            item_id,
228            tensor_id: item_id * 10,
229            priority,
230            size_bytes,
231            enqueued_at_tick: tick,
232        }
233    }
234
235    // 1. new() starts Running with empty queue
236    #[test]
237    fn test_new_starts_running() {
238        let ctrl = default_controller();
239        assert_eq!(ctrl.state, FlowState::Running);
240        assert_eq!(ctrl.queue_len(), 0);
241    }
242
243    // 2. admit in Running state succeeds
244    #[test]
245    fn test_admit_running_succeeds() {
246        let mut ctrl = default_controller();
247        let admitted = ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
248        assert!(admitted);
249        assert_eq!(ctrl.queue_len(), 1);
250    }
251
252    // 3. admit when Paused drops item
253    #[test]
254    fn test_admit_paused_drops() {
255        let mut ctrl = default_controller();
256        ctrl.pause();
257        let admitted = ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
258        assert!(!admitted);
259        assert_eq!(ctrl.stats().total_dropped, 1);
260        assert_eq!(ctrl.queue_len(), 0);
261    }
262
263    // 4. admit when Draining drops item
264    #[test]
265    fn test_admit_draining_drops() {
266        let mut ctrl = default_controller();
267        ctrl.drain();
268        let admitted = ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
269        assert!(!admitted);
270        assert_eq!(ctrl.stats().total_dropped, 1);
271        assert_eq!(ctrl.queue_len(), 0);
272    }
273
274    // 5. admit at capacity drops item
275    #[test]
276    fn test_admit_at_capacity_drops() {
277        let config = FlowControllerConfig {
278            max_queue_size: 2,
279            max_bytes_per_tick: 1_048_576,
280            backpressure_threshold: 0.99,
281        };
282        let mut ctrl = TensorFlowController::new(config);
283        assert!(ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0)));
284        assert!(ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1)));
285        let admitted = ctrl.admit(make_item(3, FlowPriority::Normal, 100, 2));
286        assert!(!admitted);
287        assert_eq!(ctrl.stats().total_dropped, 1);
288    }
289
290    // 6. backpressure_threshold triggers Throttled
291    #[test]
292    fn test_backpressure_threshold_throttled() {
293        let config = FlowControllerConfig {
294            max_queue_size: 10,
295            max_bytes_per_tick: 1_048_576,
296            backpressure_threshold: 0.8,
297        };
298        let mut ctrl = TensorFlowController::new(config);
299        // Fill to 8 items (80% = threshold)
300        for i in 0..8 {
301            ctrl.admit(make_item(i, FlowPriority::Normal, 100, i));
302        }
303        assert_eq!(ctrl.state, FlowState::Throttled);
304    }
305
306    // 7. below threshold stays Running
307    #[test]
308    fn test_below_threshold_stays_running() {
309        let config = FlowControllerConfig {
310            max_queue_size: 10,
311            max_bytes_per_tick: 1_048_576,
312            backpressure_threshold: 0.8,
313        };
314        let mut ctrl = TensorFlowController::new(config);
315        // 7 items = 70% < 80%
316        for i in 0..7 {
317            ctrl.admit(make_item(i, FlowPriority::Normal, 100, i));
318        }
319        assert_eq!(ctrl.state, FlowState::Running);
320    }
321
322    // 8. process_tick returns items up to byte budget
323    #[test]
324    fn test_process_tick_respects_byte_budget() {
325        let config = FlowControllerConfig {
326            max_queue_size: 256,
327            max_bytes_per_tick: 300,
328            backpressure_threshold: 0.8,
329        };
330        let mut ctrl = TensorFlowController::new(config);
331        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
332        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
333        ctrl.admit(make_item(3, FlowPriority::Normal, 100, 2));
334        ctrl.admit(make_item(4, FlowPriority::Normal, 100, 3));
335        let processed = ctrl.process_tick();
336        // 3 × 100 = 300, 4th would exceed budget
337        assert_eq!(processed.len(), 3);
338        assert_eq!(ctrl.queue_len(), 1);
339    }
340
341    // 9. process_tick returns empty when Paused
342    #[test]
343    fn test_process_tick_paused_returns_empty() {
344        let mut ctrl = default_controller();
345        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
346        ctrl.pause();
347        let processed = ctrl.process_tick();
348        assert!(processed.is_empty());
349        assert_eq!(ctrl.queue_len(), 1);
350    }
351
352    // 10. process_tick respects priority order (Critical before Low)
353    #[test]
354    fn test_process_tick_priority_order() {
355        let config = FlowControllerConfig {
356            max_queue_size: 256,
357            max_bytes_per_tick: 200,
358            backpressure_threshold: 0.8,
359        };
360        let mut ctrl = TensorFlowController::new(config);
361        // Admit Low first, then Critical
362        ctrl.admit(make_item(1, FlowPriority::Low, 100, 0));
363        ctrl.admit(make_item(2, FlowPriority::Critical, 100, 1));
364        let processed = ctrl.process_tick();
365        assert_eq!(processed.len(), 2);
366        // Critical must come first
367        assert_eq!(processed[0].priority, FlowPriority::Critical);
368        assert_eq!(processed[1].priority, FlowPriority::Low);
369    }
370
371    // 11. process_tick respects FIFO within same priority
372    #[test]
373    fn test_process_tick_fifo_within_priority() {
374        let config = FlowControllerConfig {
375            max_queue_size: 256,
376            max_bytes_per_tick: 300,
377            backpressure_threshold: 0.8,
378        };
379        let mut ctrl = TensorFlowController::new(config);
380        ctrl.admit(make_item(10, FlowPriority::Normal, 100, 5));
381        ctrl.admit(make_item(20, FlowPriority::Normal, 100, 3));
382        ctrl.admit(make_item(30, FlowPriority::Normal, 100, 7));
383        let processed = ctrl.process_tick();
384        assert_eq!(processed.len(), 3);
385        assert_eq!(processed[0].enqueued_at_tick, 3);
386        assert_eq!(processed[1].enqueued_at_tick, 5);
387        assert_eq!(processed[2].enqueued_at_tick, 7);
388    }
389
390    // 12. process_tick updates total_processed
391    #[test]
392    fn test_process_tick_updates_total_processed() {
393        let mut ctrl = default_controller();
394        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
395        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
396        ctrl.process_tick();
397        assert_eq!(ctrl.stats().total_processed, 2);
398    }
399
400    // 13. process_tick updates total_bytes_processed
401    #[test]
402    fn test_process_tick_updates_total_bytes() {
403        let mut ctrl = default_controller();
404        ctrl.admit(make_item(1, FlowPriority::Normal, 400, 0));
405        ctrl.admit(make_item(2, FlowPriority::Normal, 600, 1));
406        ctrl.process_tick();
407        assert_eq!(ctrl.stats().total_bytes_processed, 1000);
408    }
409
410    // 14. Draining + empty queue → Paused after process_tick
411    #[test]
412    fn test_draining_empty_queue_becomes_paused() {
413        let mut ctrl = default_controller();
414        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
415        ctrl.drain();
416        assert_eq!(ctrl.state, FlowState::Draining);
417        ctrl.process_tick();
418        assert_eq!(ctrl.state, FlowState::Paused);
419    }
420
421    // 15. pause() sets Paused
422    #[test]
423    fn test_pause_sets_paused() {
424        let mut ctrl = default_controller();
425        ctrl.pause();
426        assert_eq!(ctrl.state, FlowState::Paused);
427    }
428
429    // 16. resume() from Paused sets Running (empty queue)
430    #[test]
431    fn test_resume_from_paused_sets_running() {
432        let mut ctrl = default_controller();
433        ctrl.pause();
434        ctrl.resume();
435        assert_eq!(ctrl.state, FlowState::Running);
436    }
437
438    // 17. resume() from Paused with heavy queue sets Throttled
439    #[test]
440    fn test_resume_from_paused_heavy_queue_sets_throttled() {
441        let config = FlowControllerConfig {
442            max_queue_size: 10,
443            max_bytes_per_tick: 1_048_576,
444            backpressure_threshold: 0.5,
445        };
446        let mut ctrl = TensorFlowController::new(config);
447        // Admit 5 items (50% = threshold) → Throttled
448        for i in 0..5 {
449            ctrl.admit(make_item(i, FlowPriority::Normal, 100, i));
450        }
451        ctrl.pause();
452        ctrl.resume();
453        assert_eq!(ctrl.state, FlowState::Throttled);
454    }
455
456    // 18. drain() sets Draining
457    #[test]
458    fn test_drain_sets_draining() {
459        let mut ctrl = default_controller();
460        ctrl.drain();
461        assert_eq!(ctrl.state, FlowState::Draining);
462    }
463
464    // 19. drop_rate() is 0.0 when nothing dropped
465    #[test]
466    fn test_drop_rate_zero_when_nothing_dropped() {
467        let ctrl = default_controller();
468        assert_eq!(ctrl.stats().drop_rate(), 0.0);
469    }
470
471    // 20. drop_rate() correct when items dropped
472    #[test]
473    fn test_drop_rate_correct() {
474        let config = FlowControllerConfig {
475            max_queue_size: 1,
476            max_bytes_per_tick: 1_048_576,
477            backpressure_threshold: 0.99,
478        };
479        let mut ctrl = TensorFlowController::new(config);
480        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0)); // admitted
481        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1)); // dropped (full)
482                                                                // admitted=1, dropped=1 → drop_rate = 0.5
483        let rate = ctrl.stats().drop_rate();
484        assert!((rate - 0.5).abs() < f64::EPSILON);
485    }
486
487    // 21. total_admitted increments
488    #[test]
489    fn test_total_admitted_increments() {
490        let mut ctrl = default_controller();
491        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
492        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
493        ctrl.admit(make_item(3, FlowPriority::Normal, 100, 2));
494        assert_eq!(ctrl.stats().total_admitted, 3);
495    }
496
497    // 22. total_dropped increments on drop
498    #[test]
499    fn test_total_dropped_increments() {
500        let mut ctrl = default_controller();
501        ctrl.pause();
502        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
503        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
504        assert_eq!(ctrl.stats().total_dropped, 2);
505    }
506
507    // 23. queue_len reflects actual size
508    #[test]
509    fn test_queue_len_reflects_size() {
510        let mut ctrl = default_controller();
511        assert_eq!(ctrl.queue_len(), 0);
512        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
513        assert_eq!(ctrl.queue_len(), 1);
514        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
515        assert_eq!(ctrl.queue_len(), 2);
516        ctrl.process_tick();
517        assert_eq!(ctrl.queue_len(), 0);
518    }
519
520    // 24. Mixed priority ordering is preserved across admit calls
521    #[test]
522    fn test_mixed_priority_ordering() {
523        let config = FlowControllerConfig {
524            max_queue_size: 256,
525            max_bytes_per_tick: 1_048_576,
526            backpressure_threshold: 0.8,
527        };
528        let mut ctrl = TensorFlowController::new(config);
529        ctrl.admit(make_item(1, FlowPriority::Low, 10, 0));
530        ctrl.admit(make_item(2, FlowPriority::High, 10, 1));
531        ctrl.admit(make_item(3, FlowPriority::Normal, 10, 2));
532        ctrl.admit(make_item(4, FlowPriority::Critical, 10, 3));
533        let processed = ctrl.process_tick();
534        assert_eq!(processed[0].priority, FlowPriority::Critical);
535        assert_eq!(processed[1].priority, FlowPriority::High);
536        assert_eq!(processed[2].priority, FlowPriority::Normal);
537        assert_eq!(processed[3].priority, FlowPriority::Low);
538    }
539
540    // 25. Draining state still processes existing items
541    #[test]
542    fn test_draining_processes_existing_items() {
543        let mut ctrl = default_controller();
544        ctrl.admit(make_item(1, FlowPriority::Normal, 100, 0));
545        ctrl.admit(make_item(2, FlowPriority::Normal, 100, 1));
546        ctrl.drain();
547        let processed = ctrl.process_tick();
548        assert_eq!(processed.len(), 2);
549        assert_eq!(ctrl.stats().total_processed, 2);
550    }
551}