Skip to main content

lean_ctx/core/
ocla_bus.rs

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