Skip to main content

ipfrs_tensorlogic/
event_bus_v2.rs

1//! TensorEventBusV2 — typed in-process event bus for TensorLogic events.
2//!
3//! Supports multiple subscribers with priority-ordered delivery, per-subscriber
4//! event filtering, bounded queues with drop accounting, drain-on-demand, dead-letter
5//! queuing for unrouted events, and cumulative delivery statistics.
6//!
7//! # Design overview
8//!
9//! ```text
10//! Publisher  ──publish(event)──►  TensorEventBusV2
11//!                                       │
12//!               ┌────────── priority-sorted subscriptions ─────────────┐
13//!               │  sub(prio=10, filter=All)                             │
14//!               │  sub(prio=5,  filter=RuleEventsOnly)                  │
15//!               │  sub(prio=1,  filter=TensorEventsOnly)                │
16//!               └──────────────────────────────────────────────────────┘
17//!                                       │
18//!                          no subscriber accepted ──► dead_letters
19//! ```
20
21// ─── Types ───────────────────────────────────────────────────────────────────
22
23/// Events that can be published on the bus.
24#[derive(Clone, Debug, PartialEq)]
25pub enum TensorEvent {
26    /// A Datalog/TensorLogic rule was fired.
27    RuleFired {
28        /// Identifier of the rule.
29        rule_id: u64,
30        /// Number of variable bindings produced.
31        bindings_count: usize,
32    },
33    /// An inference session completed.
34    InferenceComplete {
35        /// Identifier of the session.
36        session_id: u64,
37        /// Wall-clock duration in milliseconds.
38        duration_ms: u64,
39    },
40    /// A tensor was updated to a new version.
41    TensorUpdated {
42        /// Identifier of the tensor.
43        tensor_id: u64,
44        /// New version number.
45        new_version: u64,
46    },
47    /// A checkpoint was persisted to disk.
48    CheckpointSaved {
49        /// Filesystem path of the checkpoint file.
50        path: String,
51        /// Size of the checkpoint in bytes.
52        size_bytes: u64,
53    },
54    /// A gradient step was applied.
55    GradientApplied {
56        /// Optimiser step counter.
57        step: u64,
58        /// Training loss at this step.
59        loss: f64,
60    },
61}
62
63// ─── Filter ──────────────────────────────────────────────────────────────────
64
65/// Subscription filter — determines which [`TensorEvent`] variants a subscriber receives.
66#[derive(Clone, Debug, PartialEq)]
67pub enum EventFilter {
68    /// Receive every event regardless of variant.
69    All,
70    /// Receive only [`TensorEvent::RuleFired`] events.
71    RuleEventsOnly,
72    /// Receive only [`TensorEvent::InferenceComplete`] events.
73    InferenceEventsOnly,
74    /// Receive only [`TensorEvent::TensorUpdated`] events.
75    TensorEventsOnly,
76}
77
78impl EventFilter {
79    /// Returns `true` when this filter accepts the given event.
80    pub fn matches(&self, event: &TensorEvent) -> bool {
81        match self {
82            EventFilter::All => true,
83            EventFilter::RuleEventsOnly => matches!(event, TensorEvent::RuleFired { .. }),
84            EventFilter::InferenceEventsOnly => {
85                matches!(event, TensorEvent::InferenceComplete { .. })
86            }
87            EventFilter::TensorEventsOnly => matches!(event, TensorEvent::TensorUpdated { .. }),
88        }
89    }
90}
91
92// ─── Subscription ─────────────────────────────────────────────────────────────
93
94/// Per-subscriber state maintained by the bus.
95#[derive(Debug)]
96pub struct Subscription {
97    /// Unique subscription identifier returned by [`TensorEventBusV2::subscribe`].
98    pub sub_id: u64,
99    /// Event filter for this subscription.
100    pub filter: EventFilter,
101    /// Delivery priority — higher values are served first.
102    pub priority: u32,
103    /// Maximum number of events buffered before drops occur.
104    pub max_queue_size: usize,
105    /// Pending (unread) events for this subscriber.
106    pub queue: Vec<TensorEvent>,
107    /// Cumulative count of events successfully enqueued.
108    pub delivered: u64,
109    /// Cumulative count of events dropped because the queue was full.
110    pub dropped: u64,
111}
112
113impl Subscription {
114    fn new(sub_id: u64, filter: EventFilter, priority: u32, max_queue_size: usize) -> Self {
115        Self {
116            sub_id,
117            filter,
118            priority,
119            max_queue_size,
120            queue: Vec::new(),
121            delivered: 0,
122            dropped: 0,
123        }
124    }
125}
126
127// ─── BusStats ─────────────────────────────────────────────────────────────────
128
129/// Aggregate delivery statistics for the entire bus.
130#[derive(Clone, Debug, Default, PartialEq)]
131pub struct BusStats {
132    /// Total number of events passed to [`TensorEventBusV2::publish`].
133    pub total_published: u64,
134    /// Total events successfully enqueued across all subscribers.
135    pub total_delivered: u64,
136    /// Total events dropped (queue full) across all subscribers.
137    pub total_dropped: u64,
138}
139
140impl BusStats {
141    /// Fraction of published events that were delivered to at least one subscriber queue.
142    ///
143    /// Returns `1.0` when no events have been published (vacuously perfect).
144    pub fn delivery_rate(&self) -> f64 {
145        if self.total_published == 0 {
146            return 1.0;
147        }
148        // The numerator is capped at total_published to keep the rate in [0, 1].
149        // An event published to N subscribers counts N times towards total_delivered,
150        // so we normalise by total_published rather than total_delivered.
151        // Simple ratio: events-delivered / events-published (per-event fan-out semantics).
152        // Because one publish can fan out to many subs the rate can exceed 1.0 when
153        // there are multiple subscribers; callers should interpret accordingly.
154        self.total_delivered as f64 / self.total_published as f64
155    }
156}
157
158// ─── TensorEventBusV2 ─────────────────────────────────────────────────────────
159
160/// Typed in-process event bus for TensorLogic events.
161///
162/// Events are published synchronously and fanned out to all matching subscriptions
163/// in descending priority order.  Each subscription maintains a bounded queue;
164/// events that overflow the queue are counted as dropped.  Events that match no
165/// subscription are collected in the dead-letter queue.
166#[derive(Debug)]
167pub struct TensorEventBusV2 {
168    /// Active subscriptions, kept sorted by `priority` descending.
169    pub subscriptions: Vec<Subscription>,
170    /// Events that were not delivered to any subscriber.
171    pub dead_letters: Vec<TensorEvent>,
172    /// Bus-level statistics.
173    pub stats: BusStats,
174    /// Monotonically increasing counter used to assign subscription IDs.
175    pub next_sub_id: u64,
176}
177
178impl TensorEventBusV2 {
179    /// Creates a new, empty event bus.
180    pub fn new() -> Self {
181        Self {
182            subscriptions: Vec::new(),
183            dead_letters: Vec::new(),
184            stats: BusStats::default(),
185            next_sub_id: 1,
186        }
187    }
188
189    /// Registers a new subscription and returns its unique identifier.
190    ///
191    /// The internal subscription list is re-sorted after each registration so that
192    /// [`publish`](Self::publish) can iterate in priority order without extra work.
193    pub fn subscribe(&mut self, filter: EventFilter, priority: u32, max_queue_size: usize) -> u64 {
194        let sub_id = self.next_sub_id;
195        self.next_sub_id += 1;
196
197        let effective_max = if max_queue_size == 0 {
198            100
199        } else {
200            max_queue_size
201        };
202
203        self.subscriptions
204            .push(Subscription::new(sub_id, filter, priority, effective_max));
205
206        // Keep sorted: highest priority first.
207        self.subscriptions
208            .sort_unstable_by_key(|s| std::cmp::Reverse(s.priority));
209
210        sub_id
211    }
212
213    /// Removes the subscription identified by `sub_id`.
214    ///
215    /// Returns `true` if the subscription existed and was removed.
216    pub fn unsubscribe(&mut self, sub_id: u64) -> bool {
217        let before = self.subscriptions.len();
218        self.subscriptions.retain(|s| s.sub_id != sub_id);
219        self.subscriptions.len() < before
220    }
221
222    /// Publishes an event to all matching subscriptions.
223    ///
224    /// Subscriptions are visited in descending priority order.  For each
225    /// subscription whose filter matches:
226    /// - If the queue is not full, the event is cloned and enqueued; `delivered` and
227    ///   `stats.total_delivered` are incremented.
228    /// - If the queue is full, the event is not enqueued; `dropped` and
229    ///   `stats.total_dropped` are incremented.
230    ///
231    /// If no subscription accepted the event it is moved to [`dead_letters`](Self::dead_letters).
232    pub fn publish(&mut self, event: TensorEvent) {
233        self.stats.total_published += 1;
234
235        let mut any_accepted = false;
236
237        for sub in self.subscriptions.iter_mut() {
238            if !sub.filter.matches(&event) {
239                continue;
240            }
241
242            if sub.queue.len() < sub.max_queue_size {
243                sub.queue.push(event.clone());
244                sub.delivered += 1;
245                self.stats.total_delivered += 1;
246                any_accepted = true;
247            } else {
248                sub.dropped += 1;
249                self.stats.total_dropped += 1;
250                // Still counts as "accepted by filter" for dead-letter purposes.
251                any_accepted = true;
252            }
253        }
254
255        if !any_accepted {
256            self.dead_letters.push(event);
257        }
258    }
259
260    /// Drains and returns all queued events for the subscription identified by `sub_id`.
261    ///
262    /// Returns an empty `Vec` if the subscription does not exist or has no pending events.
263    pub fn drain(&mut self, sub_id: u64) -> Vec<TensorEvent> {
264        match self.subscriptions.iter_mut().find(|s| s.sub_id == sub_id) {
265            Some(sub) => {
266                let mut out = Vec::with_capacity(sub.queue.len());
267                core::mem::swap(&mut sub.queue, &mut out);
268                out
269            }
270            None => Vec::new(),
271        }
272    }
273
274    /// Returns a reference to the current bus statistics.
275    pub fn stats(&self) -> &BusStats {
276        &self.stats
277    }
278}
279
280impl Default for TensorEventBusV2 {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286// ─── Tests ────────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    // Helper: build a RuleFired event.
293    fn rule_fired(rule_id: u64, bindings_count: usize) -> TensorEvent {
294        TensorEvent::RuleFired {
295            rule_id,
296            bindings_count,
297        }
298    }
299
300    // Helper: build an InferenceComplete event.
301    fn inference_complete(session_id: u64, duration_ms: u64) -> TensorEvent {
302        TensorEvent::InferenceComplete {
303            session_id,
304            duration_ms,
305        }
306    }
307
308    // Helper: build a TensorUpdated event.
309    fn tensor_updated(tensor_id: u64, new_version: u64) -> TensorEvent {
310        TensorEvent::TensorUpdated {
311            tensor_id,
312            new_version,
313        }
314    }
315
316    // Helper: build a CheckpointSaved event.
317    fn checkpoint_saved(path: &str, size_bytes: u64) -> TensorEvent {
318        TensorEvent::CheckpointSaved {
319            path: path.to_string(),
320            size_bytes,
321        }
322    }
323
324    // Helper: build a GradientApplied event.
325    fn gradient_applied(step: u64, loss: f64) -> TensorEvent {
326        TensorEvent::GradientApplied { step, loss }
327    }
328
329    // ── Test 1: subscribe returns a monotonically increasing sub_id ──────────
330
331    #[test]
332    fn subscribe_returns_unique_sub_ids() {
333        let mut bus = TensorEventBusV2::new();
334        let id1 = bus.subscribe(EventFilter::All, 10, 100);
335        let id2 = bus.subscribe(EventFilter::All, 10, 100);
336        let id3 = bus.subscribe(EventFilter::All, 10, 100);
337        assert_ne!(id1, id2);
338        assert_ne!(id2, id3);
339        assert_ne!(id1, id3);
340    }
341
342    // ── Test 2: first sub_id is nonzero ──────────────────────────────────────
343
344    #[test]
345    fn subscribe_first_id_nonzero() {
346        let mut bus = TensorEventBusV2::new();
347        let id = bus.subscribe(EventFilter::All, 5, 100);
348        assert!(id > 0, "sub_id must be nonzero");
349    }
350
351    // ── Test 3: publish routes to correct subscriber queue ───────────────────
352
353    #[test]
354    fn publish_routes_to_subscriber_queue() {
355        let mut bus = TensorEventBusV2::new();
356        let id = bus.subscribe(EventFilter::All, 10, 100);
357        let event = rule_fired(42, 3);
358        bus.publish(event.clone());
359        let drained = bus.drain(id);
360        assert_eq!(drained.len(), 1);
361        assert_eq!(drained[0], event);
362    }
363
364    // ── Test 4: filter All vs RuleEventsOnly ─────────────────────────────────
365
366    #[test]
367    fn filter_all_receives_every_variant() {
368        let mut bus = TensorEventBusV2::new();
369        let id = bus.subscribe(EventFilter::All, 5, 100);
370        bus.publish(rule_fired(1, 0));
371        bus.publish(inference_complete(2, 50));
372        bus.publish(tensor_updated(3, 7));
373        bus.publish(checkpoint_saved("/tmp/ckpt", 1024));
374        bus.publish(gradient_applied(10, 0.25));
375        let drained = bus.drain(id);
376        assert_eq!(drained.len(), 5);
377    }
378
379    // ── Test 5: RuleEventsOnly filter passes only RuleFired ──────────────────
380
381    #[test]
382    fn filter_rule_events_only() {
383        let mut bus = TensorEventBusV2::new();
384        let id = bus.subscribe(EventFilter::RuleEventsOnly, 5, 100);
385        bus.publish(rule_fired(1, 2));
386        bus.publish(inference_complete(2, 10));
387        bus.publish(rule_fired(3, 5));
388        let drained = bus.drain(id);
389        assert_eq!(drained.len(), 2);
390        for e in &drained {
391            assert!(matches!(e, TensorEvent::RuleFired { .. }));
392        }
393    }
394
395    // ── Test 6: InferenceEventsOnly filter ───────────────────────────────────
396
397    #[test]
398    fn filter_inference_events_only() {
399        let mut bus = TensorEventBusV2::new();
400        let id = bus.subscribe(EventFilter::InferenceEventsOnly, 5, 100);
401        bus.publish(rule_fired(1, 0));
402        bus.publish(inference_complete(7, 100));
403        bus.publish(tensor_updated(3, 2));
404        let drained = bus.drain(id);
405        assert_eq!(drained.len(), 1);
406        assert!(matches!(
407            drained[0],
408            TensorEvent::InferenceComplete { session_id: 7, .. }
409        ));
410    }
411
412    // ── Test 7: TensorEventsOnly filter ──────────────────────────────────────
413
414    #[test]
415    fn filter_tensor_events_only() {
416        let mut bus = TensorEventBusV2::new();
417        let id = bus.subscribe(EventFilter::TensorEventsOnly, 5, 100);
418        bus.publish(rule_fired(1, 0));
419        bus.publish(tensor_updated(10, 5));
420        bus.publish(gradient_applied(1, 0.1));
421        let drained = bus.drain(id);
422        assert_eq!(drained.len(), 1);
423        assert!(matches!(
424            drained[0],
425            TensorEvent::TensorUpdated { tensor_id: 10, .. }
426        ));
427    }
428
429    // ── Test 8: priority ordering ─────────────────────────────────────────────
430
431    #[test]
432    fn priority_ordering_high_to_low() {
433        // Use a custom event to detect ordering side-effects via delivery counters.
434        let mut bus = TensorEventBusV2::new();
435        // Subscribe in low-first order; bus must still deliver high-priority first.
436        let id_low = bus.subscribe(EventFilter::All, 1, 100);
437        let id_high = bus.subscribe(EventFilter::All, 99, 100);
438
439        bus.publish(rule_fired(1, 0));
440
441        // Both should receive the event.
442        assert_eq!(bus.drain(id_high).len(), 1);
443        assert_eq!(bus.drain(id_low).len(), 1);
444
445        // Verify internal ordering: highest priority subscription is first.
446        assert!(bus.subscriptions[0].priority >= bus.subscriptions[1].priority);
447    }
448
449    // ── Test 9: queue full causes drops ──────────────────────────────────────
450
451    #[test]
452    fn queue_full_drops_excess_events() {
453        let max = 3_usize;
454        let mut bus = TensorEventBusV2::new();
455        let id = bus.subscribe(EventFilter::All, 5, max);
456
457        for i in 0..5_u64 {
458            bus.publish(rule_fired(i, 0));
459        }
460
461        let sub = bus
462            .subscriptions
463            .iter()
464            .find(|s| s.sub_id == id)
465            .expect("test: should succeed");
466        assert_eq!(sub.queue.len(), max);
467        assert_eq!(sub.dropped, 2);
468        assert_eq!(sub.delivered, max as u64);
469    }
470
471    // ── Test 10: drain clears the queue ──────────────────────────────────────
472
473    #[test]
474    fn drain_clears_queue() {
475        let mut bus = TensorEventBusV2::new();
476        let id = bus.subscribe(EventFilter::All, 5, 100);
477        bus.publish(rule_fired(1, 0));
478        bus.publish(rule_fired(2, 0));
479        assert_eq!(bus.drain(id).len(), 2);
480        // Second drain must be empty.
481        assert!(bus.drain(id).is_empty());
482    }
483
484    // ── Test 11: unsubscribe removes subscription ─────────────────────────────
485
486    #[test]
487    fn unsubscribe_removes_subscription() {
488        let mut bus = TensorEventBusV2::new();
489        let id = bus.subscribe(EventFilter::All, 5, 100);
490        assert!(bus.unsubscribe(id));
491        // Removed, so second call must return false.
492        assert!(!bus.unsubscribe(id));
493        assert!(bus.subscriptions.is_empty());
494    }
495
496    // ── Test 12: unsubscribe non-existent id returns false ────────────────────
497
498    #[test]
499    fn unsubscribe_nonexistent_returns_false() {
500        let mut bus = TensorEventBusV2::new();
501        assert!(!bus.unsubscribe(999));
502    }
503
504    // ── Test 13: dead_letters when no subscribers ─────────────────────────────
505
506    #[test]
507    fn dead_letters_with_no_subscribers() {
508        let mut bus = TensorEventBusV2::new();
509        let event = rule_fired(1, 0);
510        bus.publish(event.clone());
511        assert_eq!(bus.dead_letters.len(), 1);
512        assert_eq!(bus.dead_letters[0], event);
513    }
514
515    // ── Test 14: dead_letters when no filter matches ──────────────────────────
516
517    #[test]
518    fn dead_letters_when_no_filter_matches() {
519        let mut bus = TensorEventBusV2::new();
520        // Subscribe only to rule events.
521        let _id = bus.subscribe(EventFilter::RuleEventsOnly, 5, 100);
522        // Publish an inference event — no subscriber wants it.
523        bus.publish(inference_complete(1, 50));
524        assert_eq!(bus.dead_letters.len(), 1);
525    }
526
527    // ── Test 15: delivery_rate perfect when all delivered ─────────────────────
528
529    #[test]
530    fn delivery_rate_perfect() {
531        let mut bus = TensorEventBusV2::new();
532        let _id = bus.subscribe(EventFilter::All, 5, 100);
533        bus.publish(rule_fired(1, 0));
534        bus.publish(inference_complete(2, 10));
535        let stats = bus.stats();
536        // 2 events, 2 deliveries — delivery_rate = delivered/published = 2/2 = 1.0
537        assert!((stats.delivery_rate() - 1.0_f64).abs() < f64::EPSILON);
538    }
539
540    // ── Test 16: delivery_rate when no events published ───────────────────────
541
542    #[test]
543    fn delivery_rate_empty_bus() {
544        let bus = TensorEventBusV2::new();
545        assert_eq!(bus.stats().delivery_rate(), 1.0);
546    }
547
548    // ── Test 17: stats total_dropped accumulates across subscriptions ─────────
549
550    #[test]
551    fn stats_total_dropped_accumulates() {
552        let mut bus = TensorEventBusV2::new();
553        // Two subscriptions each with queue size 1.
554        let _id1 = bus.subscribe(EventFilter::All, 5, 1);
555        let _id2 = bus.subscribe(EventFilter::All, 5, 1);
556
557        // Publish 3 events: first fills each queue (1 each), remaining 2 are dropped per sub.
558        bus.publish(rule_fired(1, 0));
559        bus.publish(rule_fired(2, 0));
560        bus.publish(rule_fired(3, 0));
561
562        // Each sub received 1, dropped 2 → total_dropped = 4.
563        assert_eq!(bus.stats().total_dropped, 4);
564    }
565
566    // ── Test 18: multiple subscribers all receive matching event ──────────────
567
568    #[test]
569    fn multiple_subscribers_all_receive() {
570        let mut bus = TensorEventBusV2::new();
571        let id1 = bus.subscribe(EventFilter::All, 10, 100);
572        let id2 = bus.subscribe(EventFilter::All, 5, 100);
573        let id3 = bus.subscribe(EventFilter::All, 1, 100);
574
575        bus.publish(tensor_updated(7, 3));
576
577        assert_eq!(bus.drain(id1).len(), 1);
578        assert_eq!(bus.drain(id2).len(), 1);
579        assert_eq!(bus.drain(id3).len(), 1);
580    }
581
582    // ── Test 19: partial subscriber match (some filter, some don't) ───────────
583
584    #[test]
585    fn partial_subscriber_match() {
586        let mut bus = TensorEventBusV2::new();
587        let id_rule = bus.subscribe(EventFilter::RuleEventsOnly, 10, 100);
588        let id_all = bus.subscribe(EventFilter::All, 5, 100);
589
590        // Publish one event that only id_all matches.
591        bus.publish(gradient_applied(1, 0.5));
592
593        assert!(bus.drain(id_rule).is_empty());
594        assert_eq!(bus.drain(id_all).len(), 1);
595        // Should NOT be in dead_letters because id_all accepted it.
596        assert!(bus.dead_letters.is_empty());
597    }
598
599    // ── Test 20: checkpoint saved event round-trips correctly ─────────────────
600
601    #[test]
602    fn checkpoint_saved_event_round_trip() {
603        let mut bus = TensorEventBusV2::new();
604        let id = bus.subscribe(EventFilter::All, 5, 100);
605        let event = checkpoint_saved("/tmp/model.safetensors", 4096);
606        bus.publish(event.clone());
607        let drained = bus.drain(id);
608        assert_eq!(drained.len(), 1);
609        assert_eq!(drained[0], event);
610    }
611
612    // ── Test 21: gradient applied event round-trips correctly ─────────────────
613
614    #[test]
615    fn gradient_applied_event_round_trip() {
616        let mut bus = TensorEventBusV2::new();
617        let id = bus.subscribe(EventFilter::All, 5, 100);
618        let event = gradient_applied(42, 0.001_f64);
619        bus.publish(event.clone());
620        let drained = bus.drain(id);
621        assert_eq!(drained.len(), 1);
622        if let TensorEvent::GradientApplied { step, loss } = &drained[0] {
623            assert_eq!(*step, 42);
624            assert!((*loss - 0.001_f64).abs() < 1e-12);
625        } else {
626            panic!("unexpected event variant");
627        }
628    }
629
630    // ── Test 22: stats total_published and total_delivered are consistent ──────
631
632    #[test]
633    fn stats_consistency() {
634        let mut bus = TensorEventBusV2::new();
635        let _id = bus.subscribe(EventFilter::All, 5, 100);
636        for i in 0..10_u64 {
637            bus.publish(rule_fired(i, 0));
638        }
639        let s = bus.stats();
640        assert_eq!(s.total_published, 10);
641        assert_eq!(s.total_delivered, 10);
642        assert_eq!(s.total_dropped, 0);
643    }
644
645    // ── Test 23: drain on unknown sub_id returns empty vec ────────────────────
646
647    #[test]
648    fn drain_unknown_sub_id_returns_empty() {
649        let mut bus = TensorEventBusV2::new();
650        assert!(bus.drain(9999).is_empty());
651    }
652
653    // ── Test 24: default queue size used when max_queue_size is zero ──────────
654
655    #[test]
656    fn default_queue_size_applied_when_zero() {
657        let mut bus = TensorEventBusV2::new();
658        let id = bus.subscribe(EventFilter::All, 5, 0);
659        let sub = bus
660            .subscriptions
661            .iter()
662            .find(|s| s.sub_id == id)
663            .expect("test: should succeed");
664        assert_eq!(sub.max_queue_size, 100);
665    }
666}