Skip to main content

ipfrs_tensorlogic/
audit_log.rs

1//! Inference Audit Log
2//!
3//! Provides an immutable, append-only audit trail for every distributed inference
4//! query: which peers were queried, which rules fired, what bindings were produced,
5//! and in what order. Designed for compliance and post-hoc debugging.
6//!
7//! # Overview
8//!
9//! [`InferenceAuditLog`] accumulates [`AuditEntry`] records in a bounded ring
10//! buffer (default cap 100 000 entries). Each entry carries a globally-monotonic
11//! `sequence` number, a `trace_id` that groups all events belonging to a single
12//! logical session, and an [`AuditEvent`] payload.
13//!
14//! [`AuditStats`] tracks high-level counters (total appended/trimmed, queries
15//! started/completed/failed) using atomic operations so that stat reads never
16//! block appenders.
17//!
18//! # Examples
19//!
20//! ```
21//! use ipfrs_tensorlogic::audit_log::{AuditEvent, InferenceAuditLog};
22//!
23//! let log = InferenceAuditLog::default();
24//!
25//! let seq = log.append(
26//!     "session-1",
27//!     AuditEvent::QueryStarted {
28//!         goal: "parent(X, alice)".to_string(),
29//!         session_id: "session-1".to_string(),
30//!         timestamp_ms: 1_000,
31//!     },
32//! ).expect("example: should succeed in docs");
33//!
34//! assert_eq!(seq, 0);
35//! assert_eq!(log.entry_count(), 1);
36//!
37//! let entries = log.entries_for_trace("session-1");
38//! assert_eq!(entries.len(), 1);
39//! ```
40
41use std::collections::{HashMap, HashSet};
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Mutex;
44
45use thiserror::Error;
46
47// ─── AuditEvent ──────────────────────────────────────────────────────────────
48
49/// A single observable event in the lifetime of a distributed inference query.
50#[derive(Debug, Clone)]
51pub enum AuditEvent {
52    /// The inference session was initiated for the given goal.
53    QueryStarted {
54        /// The goal string being resolved (e.g. `"parent(X, alice)"`).
55        goal: String,
56        /// Session identifier that scopes this query.
57        session_id: String,
58        /// Wall-clock time in milliseconds since UNIX epoch.
59        timestamp_ms: u64,
60    },
61
62    /// A remote peer was contacted to help resolve the goal.
63    PeerQueried {
64        /// Identifier of the remote peer (e.g. multiaddr or libp2p PeerId).
65        peer_id: String,
66        /// The specific sub-goal forwarded to that peer.
67        goal: String,
68        /// Wall-clock time in milliseconds since UNIX epoch.
69        timestamp_ms: u64,
70    },
71
72    /// A local or remote rule matched and produced bindings.
73    RuleFired {
74        /// Stable rule identifier (e.g. `"rule:parent/2#3"`).
75        rule_id: String,
76        /// Variable bindings produced by this rule application.
77        bindings: HashMap<String, String>,
78        /// Wall-clock time in milliseconds since UNIX epoch.
79        timestamp_ms: u64,
80    },
81
82    /// A batch of results was received from a remote peer.
83    ResultReceived {
84        /// Peer that returned this batch.
85        peer_id: String,
86        /// Number of variable bindings in this batch.
87        binding_count: usize,
88        /// Whether this is the peer's final response for the sub-goal.
89        is_final: bool,
90        /// Wall-clock time in milliseconds since UNIX epoch.
91        timestamp_ms: u64,
92    },
93
94    /// The inference session completed successfully.
95    QueryCompleted {
96        /// Session identifier.
97        session_id: String,
98        /// Total number of result bindings produced across all peers.
99        total_results: usize,
100        /// Elapsed time from `QueryStarted` to `QueryCompleted` in milliseconds.
101        duration_ms: u64,
102        /// Wall-clock time in milliseconds since UNIX epoch.
103        timestamp_ms: u64,
104    },
105
106    /// The inference session failed.
107    QueryFailed {
108        /// Session identifier.
109        session_id: String,
110        /// Human-readable failure reason.
111        reason: String,
112        /// Wall-clock time in milliseconds since UNIX epoch.
113        timestamp_ms: u64,
114    },
115}
116
117impl AuditEvent {
118    /// Returns a stable, lowercase string tag for this event variant.
119    ///
120    /// Useful for serialization, metrics labeling, and log filtering.
121    pub fn event_type(&self) -> &str {
122        match self {
123            AuditEvent::QueryStarted { .. } => "query_started",
124            AuditEvent::PeerQueried { .. } => "peer_queried",
125            AuditEvent::RuleFired { .. } => "rule_fired",
126            AuditEvent::ResultReceived { .. } => "result_received",
127            AuditEvent::QueryCompleted { .. } => "query_completed",
128            AuditEvent::QueryFailed { .. } => "query_failed",
129        }
130    }
131
132    /// Returns the wall-clock timestamp (ms since UNIX epoch) carried by this event.
133    pub fn timestamp_ms(&self) -> u64 {
134        match self {
135            AuditEvent::QueryStarted { timestamp_ms, .. } => *timestamp_ms,
136            AuditEvent::PeerQueried { timestamp_ms, .. } => *timestamp_ms,
137            AuditEvent::RuleFired { timestamp_ms, .. } => *timestamp_ms,
138            AuditEvent::ResultReceived { timestamp_ms, .. } => *timestamp_ms,
139            AuditEvent::QueryCompleted { timestamp_ms, .. } => *timestamp_ms,
140            AuditEvent::QueryFailed { timestamp_ms, .. } => *timestamp_ms,
141        }
142    }
143}
144
145// ─── AuditEntry ──────────────────────────────────────────────────────────────
146
147/// A single record in the audit log.
148#[derive(Debug, Clone)]
149pub struct AuditEntry {
150    /// Globally monotonic sequence counter — lower value means earlier insertion.
151    pub sequence: u64,
152    /// The event payload.
153    pub event: AuditEvent,
154    /// Groups all events belonging to one logical query (usually equals `session_id`).
155    pub trace_id: String,
156}
157
158// ─── AuditError ──────────────────────────────────────────────────────────────
159
160/// Errors returned by [`InferenceAuditLog`] operations.
161#[derive(Debug, Error)]
162pub enum AuditError {
163    /// The log has reached its configured capacity and will not accept more entries.
164    #[error("audit log capacity exceeded: current={current}, max={max}")]
165    CapacityExceeded {
166        /// Current number of entries in the log.
167        current: usize,
168        /// Configured maximum number of entries.
169        max: usize,
170    },
171}
172
173// ─── AuditStats ──────────────────────────────────────────────────────────────
174
175/// A point-in-time snapshot of [`AuditStats`] counters.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct AuditStatsSnapshot {
178    /// Cumulative number of entries ever appended (including trimmed ones).
179    pub total_appended: u64,
180    /// Cumulative number of entries removed by [`InferenceAuditLog::trim_before`].
181    pub total_trimmed: u64,
182    /// Number of [`AuditEvent::QueryStarted`] events appended.
183    pub total_queries_started: u64,
184    /// Number of [`AuditEvent::QueryCompleted`] events appended.
185    pub total_queries_completed: u64,
186    /// Number of [`AuditEvent::QueryFailed`] events appended.
187    pub total_queries_failed: u64,
188}
189
190/// Atomic counters tracking aggregate log activity.
191///
192/// All fields are [`AtomicU64`] so that `snapshot()` can be called without
193/// taking any lock.
194#[derive(Debug)]
195pub struct AuditStats {
196    /// Cumulative entries appended.
197    pub total_appended: AtomicU64,
198    /// Cumulative entries trimmed.
199    pub total_trimmed: AtomicU64,
200    /// Cumulative `QueryStarted` events.
201    pub total_queries_started: AtomicU64,
202    /// Cumulative `QueryCompleted` events.
203    pub total_queries_completed: AtomicU64,
204    /// Cumulative `QueryFailed` events.
205    pub total_queries_failed: AtomicU64,
206}
207
208impl Default for AuditStats {
209    fn default() -> Self {
210        Self {
211            total_appended: AtomicU64::new(0),
212            total_trimmed: AtomicU64::new(0),
213            total_queries_started: AtomicU64::new(0),
214            total_queries_completed: AtomicU64::new(0),
215            total_queries_failed: AtomicU64::new(0),
216        }
217    }
218}
219
220impl AuditStats {
221    /// Captures a consistent-enough snapshot of all counters.
222    ///
223    /// Because each counter is updated atomically but independently, there is
224    /// no cross-counter transaction guarantee; the snapshot is suitable for
225    /// monitoring and reporting but not for distributed coordination.
226    pub fn snapshot(&self) -> AuditStatsSnapshot {
227        AuditStatsSnapshot {
228            total_appended: self.total_appended.load(Ordering::Acquire),
229            total_trimmed: self.total_trimmed.load(Ordering::Acquire),
230            total_queries_started: self.total_queries_started.load(Ordering::Acquire),
231            total_queries_completed: self.total_queries_completed.load(Ordering::Acquire),
232            total_queries_failed: self.total_queries_failed.load(Ordering::Acquire),
233        }
234    }
235}
236
237// ─── InferenceAuditLog ───────────────────────────────────────────────────────
238
239/// Immutable, append-only audit trail for distributed inference queries.
240///
241/// # Thread Safety
242///
243/// All public methods are `&self` and safe to call from multiple threads
244/// concurrently. Sequence assignment and entry insertion are performed under
245/// a single [`Mutex`] to guarantee ordering; stat counters use relaxed/acquire
246/// atomics for low-overhead reads.
247///
248/// # Capacity
249///
250/// When `entry_count()` equals `max_entries`, [`append`] returns
251/// [`AuditError::CapacityExceeded`]. Call [`trim_before`] to free space.
252///
253/// [`append`]: InferenceAuditLog::append
254/// [`trim_before`]: InferenceAuditLog::trim_before
255pub struct InferenceAuditLog {
256    /// Ordered list of audit entries guarded by a mutex.
257    pub entries: Mutex<Vec<AuditEntry>>,
258    /// Monotonically increasing sequence counter.
259    pub next_sequence: AtomicU64,
260    /// Maximum number of entries the log will hold before rejecting appends.
261    pub max_entries: usize,
262    /// Aggregate statistics.
263    pub stats: AuditStats,
264}
265
266impl Default for InferenceAuditLog {
267    fn default() -> Self {
268        Self::new(100_000)
269    }
270}
271
272impl InferenceAuditLog {
273    /// Creates a new `InferenceAuditLog` with the given capacity.
274    pub fn new(max_entries: usize) -> Self {
275        Self {
276            entries: Mutex::new(Vec::new()),
277            next_sequence: AtomicU64::new(0),
278            max_entries,
279            stats: AuditStats::default(),
280        }
281    }
282
283    /// Appends a new audit event to the log and returns its sequence number.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`AuditError::CapacityExceeded`] if the log is full.
288    pub fn append(&self, trace_id: &str, event: AuditEvent) -> Result<u64, AuditError> {
289        // Update per-event-type stats before acquiring the lock so we don't
290        // hold the lock while doing atomic work.
291        match &event {
292            AuditEvent::QueryStarted { .. } => {
293                self.stats
294                    .total_queries_started
295                    .fetch_add(1, Ordering::Relaxed);
296            }
297            AuditEvent::QueryCompleted { .. } => {
298                self.stats
299                    .total_queries_completed
300                    .fetch_add(1, Ordering::Relaxed);
301            }
302            AuditEvent::QueryFailed { .. } => {
303                self.stats
304                    .total_queries_failed
305                    .fetch_add(1, Ordering::Relaxed);
306            }
307            _ => {}
308        }
309
310        let mut guard = self.entries.lock().expect("audit log mutex poisoned");
311
312        let current = guard.len();
313        if current >= self.max_entries {
314            return Err(AuditError::CapacityExceeded {
315                current,
316                max: self.max_entries,
317            });
318        }
319
320        let seq = self.next_sequence.fetch_add(1, Ordering::AcqRel);
321        guard.push(AuditEntry {
322            sequence: seq,
323            event,
324            trace_id: trace_id.to_string(),
325        });
326
327        self.stats.total_appended.fetch_add(1, Ordering::Relaxed);
328
329        Ok(seq)
330    }
331
332    /// Returns all audit entries whose `trace_id` matches the given string.
333    ///
334    /// Entries are returned in insertion (sequence) order.
335    pub fn entries_for_trace(&self, trace_id: &str) -> Vec<AuditEntry> {
336        let guard = self.entries.lock().expect("audit log mutex poisoned");
337        guard
338            .iter()
339            .filter(|e| e.trace_id == trace_id)
340            .cloned()
341            .collect()
342    }
343
344    /// Returns all entries with `sequence > given`.
345    ///
346    /// Useful for tailing the log from a known checkpoint.
347    pub fn events_since(&self, sequence: u64) -> Vec<AuditEntry> {
348        let guard = self.entries.lock().expect("audit log mutex poisoned");
349        guard
350            .iter()
351            .filter(|e| e.sequence > sequence)
352            .cloned()
353            .collect()
354    }
355
356    /// Removes all entries with `sequence < given` and returns how many were removed.
357    ///
358    /// This operation is O(n) in the number of current entries. It updates the
359    /// `total_trimmed` stat by the number of entries actually removed.
360    pub fn trim_before(&self, sequence: u64) -> usize {
361        let mut guard = self.entries.lock().expect("audit log mutex poisoned");
362        let before = guard.len();
363        guard.retain(|e| e.sequence >= sequence);
364        let removed = before - guard.len();
365        if removed > 0 {
366            self.stats
367                .total_trimmed
368                .fetch_add(removed as u64, Ordering::Relaxed);
369        }
370        removed
371    }
372
373    /// Returns the current number of entries in the log.
374    pub fn entry_count(&self) -> usize {
375        self.entries.lock().expect("audit log mutex poisoned").len()
376    }
377
378    /// Returns a sorted, deduplicated list of all trace IDs present in the log.
379    pub fn trace_ids(&self) -> Vec<String> {
380        let guard = self.entries.lock().expect("audit log mutex poisoned");
381        let set: HashSet<&str> = guard.iter().map(|e| e.trace_id.as_str()).collect();
382        let mut ids: Vec<String> = set.into_iter().map(str::to_string).collect();
383        ids.sort();
384        ids
385    }
386}
387
388// ─── Tests ───────────────────────────────────────────────────────────────────
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn make_started(session_id: &str, ts: u64) -> AuditEvent {
395        AuditEvent::QueryStarted {
396            goal: format!("parent(X, {})", session_id),
397            session_id: session_id.to_string(),
398            timestamp_ms: ts,
399        }
400    }
401
402    fn make_completed(session_id: &str, ts: u64) -> AuditEvent {
403        AuditEvent::QueryCompleted {
404            session_id: session_id.to_string(),
405            total_results: 3,
406            duration_ms: ts,
407            timestamp_ms: ts,
408        }
409    }
410
411    fn make_failed(session_id: &str, ts: u64) -> AuditEvent {
412        AuditEvent::QueryFailed {
413            session_id: session_id.to_string(),
414            reason: "timeout".to_string(),
415            timestamp_ms: ts,
416        }
417    }
418
419    fn make_peer_queried(peer_id: &str, ts: u64) -> AuditEvent {
420        AuditEvent::PeerQueried {
421            peer_id: peer_id.to_string(),
422            goal: "grandparent(X, Y)".to_string(),
423            timestamp_ms: ts,
424        }
425    }
426
427    fn make_rule_fired(rule_id: &str, ts: u64) -> AuditEvent {
428        AuditEvent::RuleFired {
429            rule_id: rule_id.to_string(),
430            bindings: {
431                let mut m = HashMap::new();
432                m.insert("X".to_string(), "alice".to_string());
433                m
434            },
435            timestamp_ms: ts,
436        }
437    }
438
439    fn make_result_received(peer_id: &str, ts: u64) -> AuditEvent {
440        AuditEvent::ResultReceived {
441            peer_id: peer_id.to_string(),
442            binding_count: 2,
443            is_final: true,
444            timestamp_ms: ts,
445        }
446    }
447
448    // ── 1. Append and retrieve by trace_id ──────────────────────────────────
449
450    #[test]
451    fn test_append_and_retrieve_by_trace_id() {
452        let log = InferenceAuditLog::default();
453        log.append("trace-a", make_started("trace-a", 1000))
454            .expect("test: should succeed");
455        log.append("trace-b", make_started("trace-b", 2000))
456            .expect("test: should succeed");
457        log.append("trace-a", make_peer_queried("peer-1", 1001))
458            .expect("test: should succeed");
459
460        let entries = log.entries_for_trace("trace-a");
461        assert_eq!(entries.len(), 2, "trace-a should have 2 entries");
462        for e in &entries {
463            assert_eq!(e.trace_id, "trace-a");
464        }
465    }
466
467    // ── 2. events_since sequence filtering ──────────────────────────────────
468
469    #[test]
470    fn test_events_since_filters_by_sequence() {
471        let log = InferenceAuditLog::default();
472        for i in 0..5_u64 {
473            log.append("t", make_started("t", i * 100))
474                .expect("test: should succeed");
475        }
476        // sequences 0..4 inclusive; ask for > 2 → [3, 4]
477        let since = log.events_since(2);
478        assert_eq!(since.len(), 2);
479        assert!(since.iter().all(|e| e.sequence > 2));
480    }
481
482    // ── 3. trim_before removes correct entries ───────────────────────────────
483
484    #[test]
485    fn test_trim_before_removes_correct_entries() {
486        let log = InferenceAuditLog::default();
487        for i in 0..10_u64 {
488            log.append("t", make_started("t", i))
489                .expect("test: should succeed");
490        }
491        // Remove sequences 0..4 (i.e. seq < 5)
492        let removed = log.trim_before(5);
493        assert_eq!(removed, 5, "should have removed 5 entries");
494
495        let remaining = log.entries_for_trace("t");
496        assert!(remaining.iter().all(|e| e.sequence >= 5));
497    }
498
499    // ── 4. trace_ids returns unique, sorted values ───────────────────────────
500
501    #[test]
502    fn test_trace_ids_unique_sorted() {
503        let log = InferenceAuditLog::default();
504        log.append("zebra", make_started("zebra", 1))
505            .expect("test: should succeed");
506        log.append("alpha", make_started("alpha", 2))
507            .expect("test: should succeed");
508        log.append("zebra", make_peer_queried("p", 3))
509            .expect("test: should succeed");
510        log.append("mango", make_started("mango", 4))
511            .expect("test: should succeed");
512
513        let ids = log.trace_ids();
514        assert_eq!(ids, vec!["alpha", "mango", "zebra"]);
515    }
516
517    // ── 5. entry_count decreases after trim ──────────────────────────────────
518
519    #[test]
520    fn test_entry_count_decreases_after_trim() {
521        let log = InferenceAuditLog::default();
522        for i in 0..20_u64 {
523            log.append("t", make_started("t", i))
524                .expect("test: should succeed");
525        }
526        assert_eq!(log.entry_count(), 20);
527        log.trim_before(10);
528        assert_eq!(log.entry_count(), 10);
529    }
530
531    // ── 6. AuditEvent::event_type for each variant ──────────────────────────
532
533    #[test]
534    fn test_event_type_query_started() {
535        assert_eq!(make_started("s", 0).event_type(), "query_started");
536    }
537
538    #[test]
539    fn test_event_type_peer_queried() {
540        assert_eq!(make_peer_queried("p", 0).event_type(), "peer_queried");
541    }
542
543    #[test]
544    fn test_event_type_rule_fired() {
545        assert_eq!(make_rule_fired("r", 0).event_type(), "rule_fired");
546    }
547
548    #[test]
549    fn test_event_type_result_received() {
550        assert_eq!(make_result_received("p", 0).event_type(), "result_received");
551    }
552
553    #[test]
554    fn test_event_type_query_completed() {
555        assert_eq!(make_completed("s", 0).event_type(), "query_completed");
556    }
557
558    #[test]
559    fn test_event_type_query_failed() {
560        assert_eq!(make_failed("s", 0).event_type(), "query_failed");
561    }
562
563    // ── 7. Stats auto-increment for started/completed/failed ─────────────────
564
565    #[test]
566    fn test_stats_queries_started_increments() {
567        let log = InferenceAuditLog::default();
568        log.append("t", make_started("t", 0))
569            .expect("test: should succeed");
570        log.append("t", make_started("t", 1))
571            .expect("test: should succeed");
572        let snap = log.stats.snapshot();
573        assert_eq!(snap.total_queries_started, 2);
574    }
575
576    #[test]
577    fn test_stats_queries_completed_increments() {
578        let log = InferenceAuditLog::default();
579        log.append("t", make_completed("t", 0))
580            .expect("test: should succeed");
581        let snap = log.stats.snapshot();
582        assert_eq!(snap.total_queries_completed, 1);
583        assert_eq!(snap.total_queries_started, 0);
584    }
585
586    #[test]
587    fn test_stats_queries_failed_increments() {
588        let log = InferenceAuditLog::default();
589        log.append("t", make_failed("t", 0))
590            .expect("test: should succeed");
591        log.append("t", make_failed("t", 1))
592            .expect("test: should succeed");
593        log.append("t", make_failed("t", 2))
594            .expect("test: should succeed");
595        let snap = log.stats.snapshot();
596        assert_eq!(snap.total_queries_failed, 3);
597    }
598
599    #[test]
600    fn test_stats_total_appended_and_trimmed() {
601        let log = InferenceAuditLog::default();
602        for i in 0..6_u64 {
603            log.append("t", make_started("t", i))
604                .expect("test: should succeed");
605        }
606        let removed = log.trim_before(3);
607        let snap = log.stats.snapshot();
608        assert_eq!(snap.total_appended, 6);
609        assert_eq!(snap.total_trimmed, removed as u64);
610    }
611
612    // ── 8. Capacity limit enforcement ────────────────────────────────────────
613
614    #[test]
615    fn test_capacity_exceeded_error() {
616        let log = InferenceAuditLog::new(3);
617        log.append("t", make_started("t", 0))
618            .expect("test: should succeed");
619        log.append("t", make_started("t", 1))
620            .expect("test: should succeed");
621        log.append("t", make_started("t", 2))
622            .expect("test: should succeed");
623
624        let err = log.append("t", make_started("t", 3)).unwrap_err();
625        match err {
626            AuditError::CapacityExceeded { current, max } => {
627                assert_eq!(current, 3);
628                assert_eq!(max, 3);
629            }
630        }
631    }
632
633    // ── 9. Multiple trace IDs coexist ────────────────────────────────────────
634
635    #[test]
636    fn test_multiple_trace_ids_coexist() {
637        let log = InferenceAuditLog::default();
638        for i in 0..5_u64 {
639            log.append("alpha", make_started("alpha", i))
640                .expect("test: should succeed");
641            log.append("beta", make_peer_queried("peer", i))
642                .expect("test: should succeed");
643        }
644        assert_eq!(log.entries_for_trace("alpha").len(), 5);
645        assert_eq!(log.entries_for_trace("beta").len(), 5);
646        assert_eq!(log.entry_count(), 10);
647    }
648
649    // ── 10. Sequence is monotonically increasing ──────────────────────────────
650
651    #[test]
652    fn test_sequence_monotonically_increasing() {
653        let log = InferenceAuditLog::default();
654        let mut seqs = Vec::new();
655        for i in 0..10_u64 {
656            let seq = log
657                .append("t", make_started("t", i))
658                .expect("test: should succeed");
659            seqs.push(seq);
660        }
661        // Each sequence must be strictly greater than the previous
662        for w in seqs.windows(2) {
663            assert!(
664                w[1] > w[0],
665                "sequences must be strictly increasing: {} <= {}",
666                w[1],
667                w[0]
668            );
669        }
670    }
671
672    // ── 11. events_since with sequence = 0 returns all after first ────────────
673
674    #[test]
675    fn test_events_since_zero_excludes_first() {
676        let log = InferenceAuditLog::default();
677        log.append("t", make_started("t", 0))
678            .expect("test: should succeed");
679        log.append("t", make_started("t", 1))
680            .expect("test: should succeed");
681        log.append("t", make_started("t", 2))
682            .expect("test: should succeed");
683        // sequence > 0 means sequences 1 and 2
684        let since = log.events_since(0);
685        assert_eq!(since.len(), 2);
686    }
687
688    // ── 12. trim_before with seq = 0 removes nothing ─────────────────────────
689
690    #[test]
691    fn test_trim_before_zero_removes_nothing() {
692        let log = InferenceAuditLog::default();
693        log.append("t", make_started("t", 0))
694            .expect("test: should succeed");
695        log.append("t", make_started("t", 1))
696            .expect("test: should succeed");
697        let removed = log.trim_before(0);
698        assert_eq!(removed, 0);
699        assert_eq!(log.entry_count(), 2);
700    }
701
702    // ── 13. timestamp_ms accessor correctness ────────────────────────────────
703
704    #[test]
705    fn test_timestamp_ms_accessor() {
706        assert_eq!(make_started("s", 42).timestamp_ms(), 42);
707        assert_eq!(make_peer_queried("p", 99).timestamp_ms(), 99);
708        assert_eq!(make_rule_fired("r", 7).timestamp_ms(), 7);
709        assert_eq!(make_result_received("p", 500).timestamp_ms(), 500);
710        assert_eq!(make_completed("s", 1234).timestamp_ms(), 1234);
711        assert_eq!(make_failed("s", 9999).timestamp_ms(), 9999);
712    }
713
714    // ── 14. trim_before returns 0 when nothing qualifies ─────────────────────
715
716    #[test]
717    fn test_trim_before_high_seq_removes_all() {
718        let log = InferenceAuditLog::default();
719        for i in 0..5_u64 {
720            log.append("t", make_started("t", i))
721                .expect("test: should succeed");
722        }
723        // trim_before 100 removes everything (all seq < 100)
724        let removed = log.trim_before(100);
725        assert_eq!(removed, 5);
726        assert_eq!(log.entry_count(), 0);
727    }
728
729    // ── 15. trace_ids is empty when log is empty ─────────────────────────────
730
731    #[test]
732    fn test_trace_ids_empty_log() {
733        let log = InferenceAuditLog::default();
734        assert!(log.trace_ids().is_empty());
735    }
736
737    // ── 16. capacity allows appending after trim ──────────────────────────────
738
739    #[test]
740    fn test_append_succeeds_after_trim_frees_capacity() {
741        let log = InferenceAuditLog::new(3);
742        log.append("t", make_started("t", 0))
743            .expect("test: should succeed");
744        log.append("t", make_started("t", 1))
745            .expect("test: should succeed");
746        log.append("t", make_started("t", 2))
747            .expect("test: should succeed");
748        // Full — trim one entry
749        log.trim_before(1);
750        // Now there's room for one more
751        log.append("t", make_started("t", 3))
752            .expect("test: should succeed");
753        assert_eq!(log.entry_count(), 3);
754    }
755
756    // ── 17. RuleFired bindings are preserved ─────────────────────────────────
757
758    #[test]
759    fn test_rule_fired_bindings_preserved() {
760        let log = InferenceAuditLog::default();
761        let mut bindings = HashMap::new();
762        bindings.insert("X".to_string(), "alice".to_string());
763        bindings.insert("Y".to_string(), "bob".to_string());
764        log.append(
765            "t",
766            AuditEvent::RuleFired {
767                rule_id: "rule:parent/2".to_string(),
768                bindings: bindings.clone(),
769                timestamp_ms: 55,
770            },
771        )
772        .expect("test: should succeed");
773
774        let entries = log.entries_for_trace("t");
775        if let AuditEvent::RuleFired {
776            rule_id,
777            bindings: b,
778            ..
779        } = &entries[0].event
780        {
781            assert_eq!(rule_id, "rule:parent/2");
782            assert_eq!(b["X"], "alice");
783            assert_eq!(b["Y"], "bob");
784        } else {
785            panic!("expected RuleFired event");
786        }
787    }
788}