Skip to main content

lean_ctx/core/
ocla_bus.rs

1//! OCLA Event Bus (P2 / Track B — Event-Backbone).
2//!
3//! Zero-cost when disabled: a single `AtomicBool` check (< 5ns) gates all
4//! emission. When enabled, events flow into the existing `core::events` ring
5//! buffer and JSONL persistence.
6//!
7//! ## Design principles
8//!
9//! - **Wrap, don't replace**: The existing `events.rs` infrastructure (ring
10//!   buffer, JSONL rotation, persistence) is used as-is. `OclaBus` adds the
11//!   OCLA semantic layer on top.
12//! - **10 event types**: 5 existing (wired to current code) + 5 new (for
13//!   P8/P9/P11 traits, wired as those modules integrate).
14//! - **Test isolation**: `OclaBus::scoped(capacity)` creates a bus instance
15//!   that does NOT touch the global singleton, enabling parallel tests.
16//! - **Determinism**: Event IDs come from the existing sequence allocator.
17//!   Timestamps are the only non-deterministic field (acceptable for
18//!   observability; not included in contract assertions).
19
20use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
21use std::sync::{Mutex, OnceLock};
22
23use serde::{Deserialize, Serialize};
24
25use crate::core::context_kernel::bounded::BoundedQueue;
26
27// ─── OCLA Event Types ────────────────────────────────────────────────────────
28
29/// The 10 OCLA event types defined in the P2 spec.
30#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "type")]
32pub enum OclaEvent {
33    /// A proxy request completed (usage_meter.rs).
34    RequestCompleted {
35        model: String,
36        input_tokens: u64,
37        output_tokens: u64,
38        duration_ms: u64,
39        session_id: Option<String>,
40    },
41    /// User feedback recorded (feedback.rs).
42    FeedbackRecorded {
43        session_id: String,
44        outcome: FeedbackOutcome,
45        tool: Option<String>,
46    },
47    /// Compression threshold shifted (threshold_learning.rs).
48    ThresholdShift {
49        language: String,
50        old_value: f64,
51        new_value: f64,
52        metric: ThresholdMetric,
53    },
54    /// Compression applied to a request (compress.rs).
55    CompressionApplied {
56        path: Option<String>,
57        before_tokens: u64,
58        after_tokens: u64,
59        strategy: String,
60    },
61    /// Savings recorded to the ledger (savings_ledger/store.rs).
62    SavingsRecorded {
63        input_saved: u64,
64        output_saved: u64,
65        source: SavingsSource,
66        attribution_id: Option<String>,
67        evidence_class: Option<String>,
68        measurement_method: Option<String>,
69    },
70    /// Intent classified for a request (P8 — model_router.rs).
71    IntentClassified {
72        tier: String,
73        confidence: f64,
74        reasoning: String,
75    },
76    /// Outcome tracked for a response (P3 — future outcome_tracker.rs).
77    OutcomeRecorded {
78        session_id: String,
79        accepted: bool,
80        implicit: bool,
81    },
82    /// Response optimization applied (P9 — response_optimizer.rs).
83    ResponseOptimized {
84        cache_hit: bool,
85        is_duplicate: bool,
86        tokens_saved: u64,
87    },
88    /// Model routing decision made (P8 — model_router.rs).
89    ModelRouted {
90        requested_model: String,
91        routed_model: String,
92        tier: String,
93        model_changed: bool,
94    },
95    /// Agent chain event (P11 — future agent_gateway.rs).
96    AgentChainEvent {
97        agent_id: String,
98        action: String,
99        parent_agent: Option<String>,
100    },
101}
102
103/// Feedback outcome enum.
104#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum FeedbackOutcome {
107    Accept,
108    Reject,
109    Partial,
110}
111
112/// Threshold metric that shifted.
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum ThresholdMetric {
116    Entropy,
117    Jaccard,
118}
119
120/// Source of savings.
121#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum SavingsSource {
124    Compression,
125    Cache,
126    Routing,
127    Verbosity,
128    ResponseCache,
129}
130
131// ─── Bus Record ──────────────────────────────────────────────────────────────
132
133/// A timestamped OCLA event in the bus ring buffer.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135pub struct OclaBusRecord {
136    pub id: u64,
137    pub timestamp_ms: u64,
138    pub event: OclaEvent,
139}
140
141/// Policy applied when the OCLA bus reaches its configured capacity.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum OverflowPolicy {
145    /// Retain the new event and evict the oldest queued event.
146    DropOldest,
147    /// Retain queued events and reject the new event.
148    DropNewest,
149    /// Warn and reject the new event because this synchronous bus cannot block.
150    Backpressure,
151}
152
153impl OverflowPolicy {
154    fn from_env() -> Self {
155        match std::env::var("LEANCTX_BUS_OVERFLOW").as_deref() {
156            Ok("drop_newest") => Self::DropNewest,
157            Ok("backpressure") => Self::Backpressure,
158            Ok("drop_oldest") | Err(_) => Self::DropOldest,
159            Ok(value) => {
160                tracing::warn!(
161                    value,
162                    "invalid LEANCTX_BUS_OVERFLOW value; using drop_oldest"
163                );
164                Self::DropOldest
165            }
166        }
167    }
168}
169
170/// Details recorded whenever an event cannot be retained without overflow.
171#[derive(Debug, Clone)]
172pub struct OverflowEvent {
173    /// Policy active when the overflow occurred.
174    pub policy: OverflowPolicy,
175    /// Number of overflows observed by this bus since creation.
176    pub dropped_count: usize,
177    /// Maximum number of events retained by the queue.
178    pub queue_capacity: usize,
179}
180
181// ─── OclaBus ─────────────────────────────────────────────────────────────────
182
183/// The OCLA event bus. Zero-cost when disabled.
184///
185/// Global usage: `ocla_bus::emit(event)` — checks the global enable flag first.
186/// Test usage: `OclaBus::scoped(cap)` — isolated instance, no global state.
187pub struct OclaBus {
188    enabled: AtomicBool,
189    ring: Mutex<BoundedQueue<OclaBusRecord>>,
190    capacity: usize,
191    next_id: AtomicU64,
192    overflow_policy: OverflowPolicy,
193    overflow_count: AtomicUsize,
194}
195
196impl OclaBus {
197    /// Create a new bus with the given ring buffer capacity.
198    fn new(capacity: usize) -> Self {
199        Self::new_with_policy(capacity, OverflowPolicy::from_env())
200    }
201
202    fn new_with_policy(capacity: usize, overflow_policy: OverflowPolicy) -> Self {
203        Self {
204            enabled: AtomicBool::new(false),
205            ring: Mutex::new(BoundedQueue::new(capacity)),
206            capacity,
207            next_id: AtomicU64::new(1),
208            overflow_policy,
209            overflow_count: AtomicUsize::new(0),
210        }
211    }
212
213    /// Create a scoped (isolated) bus for testing. Does NOT affect the global bus.
214    pub fn scoped(capacity: usize) -> Self {
215        let bus = Self::new_with_policy(capacity, OverflowPolicy::DropOldest);
216        bus.enabled.store(true, Ordering::Relaxed);
217        bus
218    }
219
220    #[cfg(test)]
221    fn scoped_with_policy(capacity: usize, overflow_policy: OverflowPolicy) -> Self {
222        let bus = Self::new_with_policy(capacity, overflow_policy);
223        bus.enabled.store(true, Ordering::Relaxed);
224        bus
225    }
226
227    /// Enable the bus. Events will be recorded after this call.
228    pub fn enable(&self) {
229        self.enabled.store(true, Ordering::Release);
230    }
231
232    /// Disable the bus. Events will be discarded (< 5ns per call).
233    pub fn disable(&self) {
234        self.enabled.store(false, Ordering::Release);
235    }
236
237    /// Check if the bus is enabled.
238    #[inline]
239    pub fn is_enabled(&self) -> bool {
240        self.enabled.load(Ordering::Acquire)
241    }
242
243    /// Emit an event if the bus is enabled. Returns the event ID, or 0 if disabled.
244    #[inline]
245    pub fn emit_if_enabled(&self, event: OclaEvent) -> u64 {
246        if !self.is_enabled() {
247            return 0;
248        }
249        self.emit_unconditional(event)
250    }
251
252    /// Emit unconditionally (skips the enable check). Used internally and by
253    /// callers who have already verified the bus is enabled.
254    fn emit_unconditional(&self, event: OclaEvent) -> u64 {
255        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
256        let record = OclaBusRecord {
257            id,
258            timestamp_ms: current_timestamp_ms(),
259            event,
260        };
261
262        let mut ring = self
263            .ring
264            .lock()
265            .unwrap_or_else(std::sync::PoisonError::into_inner);
266        let _dropped = if ring.is_full() {
267            let dropped_count = self.overflow_count.fetch_add(1, Ordering::Relaxed) + 1;
268            let overflow = OverflowEvent {
269                policy: self.overflow_policy,
270                dropped_count,
271                queue_capacity: self.capacity,
272            };
273
274            match self.overflow_policy {
275                OverflowPolicy::DropOldest => ring.push(record),
276                OverflowPolicy::DropNewest => Some(record),
277                OverflowPolicy::Backpressure => {
278                    tracing::warn!(
279                        dropped_count = overflow.dropped_count,
280                        queue_capacity = overflow.queue_capacity,
281                        "OCLA bus backpressure requested; dropping newest event"
282                    );
283                    Some(record)
284                }
285            }
286        } else {
287            ring.push(record)
288        };
289
290        id
291    }
292
293    /// Drain all events from the ring (consumes them). Useful for test assertions.
294    pub fn drain(&self) -> Vec<OclaBusRecord> {
295        let mut ring = self
296            .ring
297            .lock()
298            .unwrap_or_else(std::sync::PoisonError::into_inner);
299        let count = ring.len();
300        ring.drain_oldest(count)
301    }
302
303    /// Read events since a given ID (non-consuming).
304    pub fn events_since(&self, after_id: u64) -> Vec<OclaBusRecord> {
305        let ring = self
306            .ring
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner);
309        ring.iter().filter(|r| r.id > after_id).cloned().collect()
310    }
311
312    /// Read the last N events.
313    pub fn latest(&self, n: usize) -> Vec<OclaBusRecord> {
314        let ring = self
315            .ring
316            .lock()
317            .unwrap_or_else(std::sync::PoisonError::into_inner);
318        let start = ring.len().saturating_sub(n);
319        ring.iter().skip(start).cloned().collect()
320    }
321
322    /// Current ring buffer occupancy.
323    pub fn len(&self) -> usize {
324        self.ring
325            .lock()
326            .unwrap_or_else(std::sync::PoisonError::into_inner)
327            .len()
328    }
329
330    /// Whether the ring is empty.
331    pub fn is_empty(&self) -> bool {
332        self.len() == 0
333    }
334
335    /// Total events emitted (including those evicted from the ring).
336    pub fn total_emitted(&self) -> u64 {
337        self.next_id.load(Ordering::Relaxed) - 1
338    }
339
340    /// Total queue overflows observed by this bus since creation.
341    pub fn overflow_count(&self) -> usize {
342        self.overflow_count.load(Ordering::Relaxed)
343    }
344
345    /// Overflow policy active for this bus.
346    pub fn overflow_policy(&self) -> OverflowPolicy {
347        self.overflow_policy
348    }
349}
350
351impl std::fmt::Debug for OclaBus {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        let ring_len = self.ring.lock().map_or(0, |r| r.len());
354        f.debug_struct("OclaBus")
355            .field("enabled", &self.is_enabled())
356            .field("ring", &format_args!("[{ring_len} events]"))
357            .field("capacity", &self.capacity)
358            .field("next_id", &self.next_id.load(Ordering::Relaxed))
359            .field("overflow_policy", &self.overflow_policy)
360            .field("overflow_count", &self.overflow_count())
361            .finish()
362    }
363}
364
365// ─── Global singleton ────────────────────────────────────────────────────────
366
367const DEFAULT_CAPACITY: usize = 1000;
368
369fn global_bus() -> &'static OclaBus {
370    static INSTANCE: OnceLock<OclaBus> = OnceLock::new();
371    INSTANCE.get_or_init(|| OclaBus::new(DEFAULT_CAPACITY))
372}
373
374/// Emit an OCLA event on the global bus. No-op (< 5ns) when disabled.
375#[inline]
376pub fn emit(event: OclaEvent) -> u64 {
377    global_bus().emit_if_enabled(event)
378}
379
380/// Enable the global OCLA bus.
381pub fn enable() {
382    global_bus().enable();
383}
384
385/// Disable the global OCLA bus.
386pub fn disable() {
387    global_bus().disable();
388}
389
390/// Check if the global OCLA bus is enabled.
391#[inline]
392pub fn is_enabled() -> bool {
393    global_bus().is_enabled()
394}
395
396/// Read events since a given ID from the global bus.
397pub fn events_since(after_id: u64) -> Vec<OclaBusRecord> {
398    global_bus().events_since(after_id)
399}
400
401/// Read the last N events from the global bus.
402pub fn latest(n: usize) -> Vec<OclaBusRecord> {
403    global_bus().latest(n)
404}
405
406/// Total events emitted on the global bus.
407pub fn total_emitted() -> u64 {
408    global_bus().total_emitted()
409}
410
411/// Total queue overflows observed by the global bus since startup.
412pub fn overflow_count() -> usize {
413    global_bus().overflow_count()
414}
415
416/// Overflow policy active for the global bus.
417pub fn overflow_policy() -> OverflowPolicy {
418    global_bus().overflow_policy()
419}
420
421// ─── Bridge to existing events.rs ────────────────────────────────────────────
422
423/// Bridge: emit an OCLA event AND forward it to the existing events.rs system.
424/// This ensures backward compatibility — the dashboard, CLI, and JSONL all
425/// continue to see events through the legacy path.
426pub fn emit_and_bridge(event: OclaEvent) -> u64 {
427    bridge_to_legacy(&event);
428    emit(event)
429}
430
431/// Convert an OCLA event to a legacy EventKind and emit it.
432fn bridge_to_legacy(event: &OclaEvent) {
433    use super::events::{EventKind, emit as legacy_emit};
434
435    let kind = match event {
436        OclaEvent::CompressionApplied {
437            path,
438            before_tokens,
439            after_tokens,
440            strategy,
441        } => EventKind::Compression {
442            path: path.clone().unwrap_or_default(),
443            before_lines: *before_tokens as u32,
444            after_lines: *after_tokens as u32,
445            strategy: strategy.clone(),
446            kept_line_count: *after_tokens as u32,
447            removed_line_count: before_tokens.saturating_sub(*after_tokens) as u32,
448        },
449        OclaEvent::ThresholdShift {
450            language,
451            old_value,
452            new_value,
453            metric,
454        } => EventKind::ThresholdShift {
455            language: language.clone(),
456            old_entropy: if *metric == ThresholdMetric::Entropy {
457                *old_value
458            } else {
459                0.0
460            },
461            new_entropy: if *metric == ThresholdMetric::Entropy {
462                *new_value
463            } else {
464                0.0
465            },
466            old_jaccard: if *metric == ThresholdMetric::Jaccard {
467                *old_value
468            } else {
469                0.0
470            },
471            new_jaccard: if *metric == ThresholdMetric::Jaccard {
472                *new_value
473            } else {
474                0.0
475            },
476        },
477        OclaEvent::RequestCompleted { .. }
478        | OclaEvent::FeedbackRecorded { .. }
479        | OclaEvent::SavingsRecorded { .. }
480        | OclaEvent::IntentClassified { .. }
481        | OclaEvent::OutcomeRecorded { .. }
482        | OclaEvent::ResponseOptimized { .. }
483        | OclaEvent::ModelRouted { .. }
484        | OclaEvent::AgentChainEvent { .. } => {
485            return;
486        }
487    };
488
489    legacy_emit(kind);
490}
491
492// ─── Helpers ─────────────────────────────────────────────────────────────────
493
494fn current_timestamp_ms() -> u64 {
495    use std::time::{SystemTime, UNIX_EPOCH};
496    SystemTime::now()
497        .duration_since(UNIX_EPOCH)
498        .unwrap_or_default()
499        .as_millis() as u64
500}
501
502// ─── Tests ───────────────────────────────────────────────────────────────────
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    fn savings_event(input_saved: u64) -> OclaEvent {
509        OclaEvent::SavingsRecorded {
510            input_saved,
511            output_saved: 0,
512            source: SavingsSource::Compression,
513            attribution_id: None,
514            evidence_class: None,
515            measurement_method: None,
516        }
517    }
518
519    #[test]
520    fn disabled_bus_returns_zero() {
521        let bus = OclaBus::new(16);
522        assert!(!bus.is_enabled());
523        let id = bus.emit_if_enabled(OclaEvent::RequestCompleted {
524            model: "gpt-4o".into(),
525            input_tokens: 100,
526            output_tokens: 50,
527            duration_ms: 200,
528            session_id: None,
529        });
530        assert_eq!(id, 0);
531        assert!(bus.is_empty());
532    }
533
534    #[test]
535    fn enabled_bus_records_events() {
536        let bus = OclaBus::scoped(16);
537        let id = bus.emit_if_enabled(OclaEvent::ModelRouted {
538            requested_model: "gpt-4o".into(),
539            routed_model: "gpt-4o-mini".into(),
540            tier: "fast".into(),
541            model_changed: true,
542        });
543        assert!(id > 0);
544        assert_eq!(bus.len(), 1);
545    }
546
547    #[test]
548    fn scoped_bus_is_isolated() {
549        let bus1 = OclaBus::scoped(8);
550        let bus2 = OclaBus::scoped(8);
551
552        bus1.emit_if_enabled(OclaEvent::ResponseOptimized {
553            cache_hit: true,
554            is_duplicate: false,
555            tokens_saved: 42,
556        });
557
558        assert_eq!(bus1.len(), 1);
559        assert_eq!(bus2.len(), 0, "scoped buses are isolated");
560    }
561
562    #[test]
563    fn test_bounded_queue_replaces_vecdeque() {
564        let bus = OclaBus::scoped(2);
565        bus.emit_if_enabled(savings_event(1));
566        bus.emit_if_enabled(savings_event(2));
567
568        assert_eq!(bus.len(), 2);
569        assert_eq!(bus.drain().len(), 2);
570        assert!(bus.is_empty());
571    }
572
573    #[test]
574    fn test_overflow_drop_oldest() {
575        let bus = OclaBus::scoped_with_policy(3, OverflowPolicy::DropOldest);
576        let id1 = bus.emit_if_enabled(OclaEvent::SavingsRecorded {
577            input_saved: 10,
578            output_saved: 5,
579            source: SavingsSource::Compression,
580            attribution_id: None,
581            evidence_class: None,
582            measurement_method: None,
583        });
584        bus.emit_if_enabled(OclaEvent::SavingsRecorded {
585            input_saved: 20,
586            output_saved: 10,
587            source: SavingsSource::Cache,
588            attribution_id: None,
589            evidence_class: None,
590            measurement_method: None,
591        });
592        bus.emit_if_enabled(OclaEvent::SavingsRecorded {
593            input_saved: 30,
594            output_saved: 15,
595            source: SavingsSource::Routing,
596            attribution_id: None,
597            evidence_class: None,
598            measurement_method: None,
599        });
600        // At capacity. Next emit evicts oldest.
601        bus.emit_if_enabled(OclaEvent::SavingsRecorded {
602            input_saved: 40,
603            output_saved: 20,
604            source: SavingsSource::Verbosity,
605            attribution_id: None,
606            evidence_class: None,
607            measurement_method: None,
608        });
609
610        assert_eq!(bus.len(), 3);
611        let events = bus.events_since(0);
612        assert!(events.iter().all(|r| r.id > id1), "oldest evicted");
613    }
614
615    #[test]
616    fn test_overflow_drop_newest() {
617        let bus = OclaBus::scoped_with_policy(2, OverflowPolicy::DropNewest);
618        bus.emit_if_enabled(savings_event(1));
619        bus.emit_if_enabled(savings_event(2));
620        let rejected_id = bus.emit_if_enabled(savings_event(3));
621
622        let retained_ids = bus
623            .events_since(0)
624            .into_iter()
625            .map(|record| record.id)
626            .collect::<Vec<_>>();
627        assert_eq!(retained_ids, vec![1, 2]);
628        assert_eq!(rejected_id, 3);
629    }
630
631    #[test]
632    fn test_overflow_metrics() {
633        let bus = OclaBus::scoped_with_policy(1, OverflowPolicy::DropNewest);
634        bus.emit_if_enabled(savings_event(1));
635        bus.emit_if_enabled(savings_event(2));
636        bus.emit_if_enabled(savings_event(3));
637
638        assert_eq!(bus.overflow_count(), 2);
639        assert_eq!(bus.overflow_policy(), OverflowPolicy::DropNewest);
640    }
641
642    #[test]
643    fn test_overflow_policy_from_env() {
644        let _env_lock = crate::core::data_dir::test_env_lock();
645        let previous = std::env::var_os("LEANCTX_BUS_OVERFLOW");
646        crate::test_env::set_var("LEANCTX_BUS_OVERFLOW", "backpressure");
647
648        let bus = OclaBus::new(1);
649        assert_eq!(bus.overflow_policy(), OverflowPolicy::Backpressure);
650
651        if let Some(value) = previous {
652            crate::test_env::set_var("LEANCTX_BUS_OVERFLOW", value);
653        } else {
654            crate::test_env::remove_var("LEANCTX_BUS_OVERFLOW");
655        }
656    }
657
658    #[test]
659    fn drain_consumes_all_events() {
660        let bus = OclaBus::scoped(16);
661        bus.emit_if_enabled(OclaEvent::IntentClassified {
662            tier: "fast".into(),
663            confidence: 0.9,
664            reasoning: "simple query".into(),
665        });
666        bus.emit_if_enabled(OclaEvent::IntentClassified {
667            tier: "premium".into(),
668            confidence: 0.8,
669            reasoning: "complex architecture".into(),
670        });
671
672        let drained = bus.drain();
673        assert_eq!(drained.len(), 2);
674        assert!(bus.is_empty(), "drain consumes events");
675    }
676
677    #[test]
678    fn events_since_filters_by_id() {
679        let bus = OclaBus::scoped(16);
680        let id1 = bus.emit_if_enabled(OclaEvent::FeedbackRecorded {
681            session_id: "s1".into(),
682            outcome: FeedbackOutcome::Accept,
683            tool: Some("ctx_read".into()),
684        });
685        let id2 = bus.emit_if_enabled(OclaEvent::FeedbackRecorded {
686            session_id: "s2".into(),
687            outcome: FeedbackOutcome::Reject,
688            tool: None,
689        });
690
691        let after = bus.events_since(id1);
692        assert_eq!(after.len(), 1);
693        assert_eq!(after[0].id, id2);
694    }
695
696    #[test]
697    fn latest_returns_tail() {
698        let bus = OclaBus::scoped(16);
699        for i in 0..5 {
700            bus.emit_if_enabled(OclaEvent::CompressionApplied {
701                path: Some(format!("file_{i}.rs")),
702                before_tokens: 100,
703                after_tokens: 50,
704                strategy: "treesitter".into(),
705            });
706        }
707
708        let tail = bus.latest(2);
709        assert_eq!(tail.len(), 2);
710        assert_eq!(tail[0].id, 4);
711        assert_eq!(tail[1].id, 5);
712    }
713
714    #[test]
715    fn enable_disable_toggle() {
716        let bus = OclaBus::new(16);
717        assert!(!bus.is_enabled());
718
719        bus.enable();
720        assert!(bus.is_enabled());
721        let id = bus.emit_if_enabled(OclaEvent::AgentChainEvent {
722            agent_id: "a1".into(),
723            action: "start".into(),
724            parent_agent: None,
725        });
726        assert!(id > 0);
727
728        bus.disable();
729        let id2 = bus.emit_if_enabled(OclaEvent::AgentChainEvent {
730            agent_id: "a2".into(),
731            action: "stop".into(),
732            parent_agent: Some("a1".into()),
733        });
734        assert_eq!(id2, 0);
735        assert_eq!(bus.len(), 1, "disabled emit is a no-op");
736    }
737
738    #[test]
739    fn total_emitted_counts_all() {
740        let bus = OclaBus::scoped(3);
741        bus.emit_if_enabled(OclaEvent::OutcomeRecorded {
742            session_id: "s1".into(),
743            accepted: true,
744            implicit: false,
745        });
746        bus.emit_if_enabled(OclaEvent::OutcomeRecorded {
747            session_id: "s2".into(),
748            accepted: false,
749            implicit: true,
750        });
751        bus.emit_if_enabled(OclaEvent::OutcomeRecorded {
752            session_id: "s3".into(),
753            accepted: true,
754            implicit: true,
755        });
756        // Emit one more (evicts first).
757        bus.emit_if_enabled(OclaEvent::OutcomeRecorded {
758            session_id: "s4".into(),
759            accepted: true,
760            implicit: false,
761        });
762
763        assert_eq!(bus.total_emitted(), 4, "counts all, even evicted");
764        assert_eq!(bus.len(), 3, "ring only holds capacity");
765    }
766
767    #[test]
768    fn event_serialization_roundtrip() {
769        let event = OclaEvent::ModelRouted {
770            requested_model: "claude-sonnet-4-20250514".into(),
771            routed_model: "claude-haiku-3".into(),
772            tier: "fast".into(),
773            model_changed: true,
774        };
775        let json = serde_json::to_string(&event).unwrap();
776        let deserialized: OclaEvent = serde_json::from_str(&json).unwrap();
777        assert_eq!(event, deserialized);
778    }
779
780    #[test]
781    fn all_10_event_types_serialize() {
782        let events = vec![
783            OclaEvent::RequestCompleted {
784                model: "m".into(),
785                input_tokens: 1,
786                output_tokens: 1,
787                duration_ms: 1,
788                session_id: None,
789            },
790            OclaEvent::FeedbackRecorded {
791                session_id: "s".into(),
792                outcome: FeedbackOutcome::Partial,
793                tool: None,
794            },
795            OclaEvent::ThresholdShift {
796                language: "rust".into(),
797                old_value: 0.5,
798                new_value: 0.6,
799                metric: ThresholdMetric::Entropy,
800            },
801            OclaEvent::CompressionApplied {
802                path: None,
803                before_tokens: 100,
804                after_tokens: 50,
805                strategy: "s".into(),
806            },
807            OclaEvent::SavingsRecorded {
808                input_saved: 10,
809                output_saved: 5,
810                source: SavingsSource::ResponseCache,
811                attribution_id: None,
812                evidence_class: None,
813                measurement_method: None,
814            },
815            OclaEvent::IntentClassified {
816                tier: "standard".into(),
817                confidence: 0.7,
818                reasoning: "r".into(),
819            },
820            OclaEvent::OutcomeRecorded {
821                session_id: "s".into(),
822                accepted: true,
823                implicit: false,
824            },
825            OclaEvent::ResponseOptimized {
826                cache_hit: false,
827                is_duplicate: true,
828                tokens_saved: 0,
829            },
830            OclaEvent::ModelRouted {
831                requested_model: "a".into(),
832                routed_model: "b".into(),
833                tier: "premium".into(),
834                model_changed: true,
835            },
836            OclaEvent::AgentChainEvent {
837                agent_id: "x".into(),
838                action: "spawn".into(),
839                parent_agent: Some("y".into()),
840            },
841        ];
842
843        for event in &events {
844            let json = serde_json::to_string(event).unwrap();
845            assert!(!json.is_empty());
846            let _: OclaEvent = serde_json::from_str(&json).unwrap();
847        }
848        assert_eq!(events.len(), 10, "exactly 10 event types");
849    }
850
851    #[test]
852    fn savings_recorded_p5_fields_roundtrip() {
853        let event = OclaEvent::SavingsRecorded {
854            input_saved: 100,
855            output_saved: 25,
856            source: SavingsSource::Routing,
857            attribution_id: Some("attr-42".into()),
858            evidence_class: Some("measured".into()),
859            measurement_method: Some("provider_reconciled".into()),
860        };
861        let json = serde_json::to_string(&event).unwrap();
862        assert!(json.contains("attribution_id"));
863        assert!(json.contains("evidence_class"));
864        assert!(json.contains("measurement_method"));
865        let deserialized: OclaEvent = serde_json::from_str(&json).unwrap();
866        assert_eq!(event, deserialized);
867    }
868}