Skip to main content

ipfrs_storage/
access_log.rs

1//! Append-only storage access log with configurable retention, pattern analysis, and export.
2//!
3//! Provides [`StorageAccessLog`] for recording every storage operation with
4//! a sliding-window of entries, cumulative statistics, and pattern detection.
5
6use std::collections::{HashMap, HashSet};
7
8// ─────────────────────────────────────────────────────────────────────────────
9// LogOperation
10// ─────────────────────────────────────────────────────────────────────────────
11
12/// The kind of storage operation that was performed.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum LogOperation {
15    /// A block was read.
16    Read,
17    /// A block was written.
18    Write,
19    /// A block was deleted.
20    Delete,
21    /// Block metadata / existence was queried.
22    Stat,
23    /// A directory or namespace was listed.
24    List,
25}
26
27// ─────────────────────────────────────────────────────────────────────────────
28// AccessLogEntry
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// One record in the access log.
32#[derive(Clone, Debug)]
33pub struct AccessLogEntry {
34    /// Monotonically increasing ID assigned at log time.
35    pub entry_id: u64,
36    /// The numeric block identifier.
37    pub block_id: u64,
38    /// Content identifier (CID) string.
39    pub cid: String,
40    /// Which operation was performed.
41    pub operation: LogOperation,
42    /// Number of bytes involved in the operation.
43    pub size_bytes: u64,
44    /// Logical tick at which the operation occurred.
45    pub tick: u64,
46    /// Peer that triggered this operation, if it was a remote request.
47    pub peer_id: Option<String>,
48}
49
50// ─────────────────────────────────────────────────────────────────────────────
51// AccessPattern
52// ─────────────────────────────────────────────────────────────────────────────
53
54/// Detected access pattern for the recent window of log entries.
55#[derive(Clone, Debug, PartialEq)]
56pub enum AccessPattern {
57    /// Recent reads have strictly increasing block IDs.
58    Sequential,
59    /// No discernible order in recent accesses.
60    Random,
61    /// The same block has been accessed multiple times recently.
62    Repeated {
63        /// The block ID that was repeatedly accessed.
64        block_id: u64,
65    },
66    /// Many [`LogOperation::Write`] operations in a short tick window.
67    BurstWrite,
68}
69
70// ─────────────────────────────────────────────────────────────────────────────
71// LogConfig
72// ─────────────────────────────────────────────────────────────────────────────
73
74/// Configuration for [`StorageAccessLog`].
75#[derive(Clone, Debug)]
76pub struct LogConfig {
77    /// Maximum number of entries kept in the sliding window (FIFO).
78    pub max_entries: usize,
79    /// Number of writes within `burst_window_ticks` that triggers [`AccessPattern::BurstWrite`].
80    pub burst_write_threshold: usize,
81    /// Tick window width used for burst-write detection.
82    pub burst_window_ticks: u64,
83}
84
85impl Default for LogConfig {
86    fn default() -> Self {
87        Self {
88            max_entries: 10_000,
89            burst_write_threshold: 10,
90            burst_window_ticks: 10,
91        }
92    }
93}
94
95// ─────────────────────────────────────────────────────────────────────────────
96// AccessLogStats
97// ─────────────────────────────────────────────────────────────────────────────
98
99/// Snapshot of cumulative statistics for the access log.
100#[derive(Clone, Debug)]
101pub struct AccessLogStats {
102    /// Total number of entries recorded (including evicted ones).
103    pub total_entries: usize,
104    /// Cumulative per-operation counts.
105    pub by_operation: HashMap<LogOperation, u64>,
106    /// Cumulative bytes read across all [`LogOperation::Read`] entries.
107    pub total_bytes_read: u64,
108    /// Cumulative bytes written across all [`LogOperation::Write`] entries.
109    pub total_bytes_written: u64,
110    /// Number of distinct block IDs ever accessed.
111    pub unique_blocks_accessed: usize,
112}
113
114// ─────────────────────────────────────────────────────────────────────────────
115// StorageAccessLog
116// ─────────────────────────────────────────────────────────────────────────────
117
118/// Append-only access log for storage operations.
119///
120/// Keeps a sliding window of at most `config.max_entries` entries; older
121/// entries are evicted FIFO. Cumulative statistics survive eviction.
122pub struct StorageAccessLog {
123    /// Sliding window of log entries.
124    pub entries: Vec<AccessLogEntry>,
125    /// Monotonically increasing counter used to assign [`AccessLogEntry::entry_id`].
126    pub next_entry_id: u64,
127    /// Active configuration.
128    pub config: LogConfig,
129    /// Cumulative per-operation counts (never decremented on eviction).
130    pub cumulative_ops: HashMap<LogOperation, u64>,
131    /// Total bytes read, cumulative.
132    pub cumulative_read_bytes: u64,
133    /// Total bytes written, cumulative.
134    pub cumulative_write_bytes: u64,
135    /// Set of every block ID ever logged.
136    pub seen_blocks: HashSet<u64>,
137}
138
139impl StorageAccessLog {
140    /// Create a new [`StorageAccessLog`] with the provided configuration.
141    pub fn new(config: LogConfig) -> Self {
142        Self {
143            entries: Vec::new(),
144            next_entry_id: 0,
145            config,
146            cumulative_ops: HashMap::new(),
147            cumulative_read_bytes: 0,
148            cumulative_write_bytes: 0,
149            seen_blocks: HashSet::new(),
150        }
151    }
152
153    /// Record a storage operation.
154    ///
155    /// If the sliding window is full (`entries.len() >= max_entries`) the
156    /// oldest entry is removed before the new one is appended.
157    pub fn log(
158        &mut self,
159        block_id: u64,
160        cid: String,
161        operation: LogOperation,
162        size_bytes: u64,
163        tick: u64,
164        peer_id: Option<String>,
165    ) {
166        // Enforce FIFO cap *before* inserting so we never exceed max_entries.
167        if self.entries.len() >= self.config.max_entries {
168            self.entries.remove(0);
169        }
170
171        let entry = AccessLogEntry {
172            entry_id: self.next_entry_id,
173            block_id,
174            cid,
175            operation,
176            size_bytes,
177            tick,
178            peer_id,
179        };
180
181        self.next_entry_id += 1;
182
183        // Update cumulative stats.
184        *self.cumulative_ops.entry(operation).or_insert(0) += 1;
185        match operation {
186            LogOperation::Read => self.cumulative_read_bytes += size_bytes,
187            LogOperation::Write => self.cumulative_write_bytes += size_bytes,
188            _ => {}
189        }
190        self.seen_blocks.insert(block_id);
191
192        self.entries.push(entry);
193    }
194
195    /// Detect the access pattern in the recent window.
196    ///
197    /// Priority order:
198    /// 1. **BurstWrite** — many writes in the last `burst_window_ticks` ticks.
199    /// 2. **Repeated** — any block ID appears ≥ 3 times in the last 10 reads.
200    /// 3. **Sequential** — the last 5 reads have strictly increasing block IDs.
201    /// 4. **Random** — fallthrough.
202    pub fn detect_pattern(&self, current_tick: u64) -> AccessPattern {
203        // ── BurstWrite check ──────────────────────────────────────────────
204        let window_start = current_tick.saturating_sub(self.config.burst_window_ticks);
205        let burst_writes = self
206            .entries
207            .iter()
208            .filter(|e| e.operation == LogOperation::Write && e.tick >= window_start)
209            .count();
210        if burst_writes >= self.config.burst_write_threshold {
211            return AccessPattern::BurstWrite;
212        }
213
214        // Take last 10 entries for the remaining checks.
215        let recent: Vec<&AccessLogEntry> = {
216            let start = self.entries.len().saturating_sub(10);
217            self.entries[start..].iter().collect()
218        };
219
220        // ── Repeated check ────────────────────────────────────────────────
221        // Count per-block occurrences in the last 10 entries (all ops).
222        let mut freq: HashMap<u64, usize> = HashMap::new();
223        for e in &recent {
224            *freq.entry(e.block_id).or_insert(0) += 1;
225        }
226        // Return the first (lowest entry_id order) block that appears ≥ 3 times.
227        for e in &recent {
228            if freq.get(&e.block_id).copied().unwrap_or(0) >= 3 {
229                return AccessPattern::Repeated {
230                    block_id: e.block_id,
231                };
232            }
233        }
234
235        // ── Sequential check ──────────────────────────────────────────────
236        // Collect block IDs of the last 5 Read entries (from the full window).
237        let last_five_reads: Vec<u64> = self
238            .entries
239            .iter()
240            .filter(|e| e.operation == LogOperation::Read)
241            .rev()
242            .take(5)
243            .map(|e| e.block_id)
244            .collect::<Vec<_>>()
245            .into_iter()
246            .rev()
247            .collect();
248
249        if last_five_reads.len() == 5 && last_five_reads.windows(2).all(|w| w[0] < w[1]) {
250            return AccessPattern::Sequential;
251        }
252
253        AccessPattern::Random
254    }
255
256    /// Return all entries for a specific block ID, in log order.
257    pub fn entries_for_block(&self, block_id: u64) -> Vec<&AccessLogEntry> {
258        self.entries
259            .iter()
260            .filter(|e| e.block_id == block_id)
261            .collect()
262    }
263
264    /// Return all entries whose tick falls in `[from_tick, to_tick]` (inclusive).
265    pub fn entries_in_range(&self, from_tick: u64, to_tick: u64) -> Vec<&AccessLogEntry> {
266        self.entries
267            .iter()
268            .filter(|e| e.tick >= from_tick && e.tick <= to_tick)
269            .collect()
270    }
271
272    /// Snapshot of cumulative statistics.
273    pub fn stats(&self) -> AccessLogStats {
274        AccessLogStats {
275            total_entries: self.next_entry_id as usize,
276            by_operation: self.cumulative_ops.clone(),
277            total_bytes_read: self.cumulative_read_bytes,
278            total_bytes_written: self.cumulative_write_bytes,
279            unique_blocks_accessed: self.seen_blocks.len(),
280        }
281    }
282
283    /// Clear the sliding window of entries.
284    ///
285    /// Cumulative statistics (ops counts, bytes, seen_blocks) are *not* reset.
286    pub fn clear(&mut self) {
287        self.entries.clear();
288    }
289}
290
291// ─────────────────────────────────────────────────────────────────────────────
292// Tests
293// ─────────────────────────────────────────────────────────────────────────────
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn make_log(max: usize) -> StorageAccessLog {
300        let cfg = LogConfig {
301            max_entries: max,
302            burst_write_threshold: 10,
303            burst_window_ticks: 10,
304        };
305        StorageAccessLog::new(cfg)
306    }
307
308    fn log_op(log: &mut StorageAccessLog, block_id: u64, op: LogOperation, size: u64, tick: u64) {
309        log.log(block_id, format!("cid-{block_id}"), op, size, tick, None);
310    }
311
312    // ── 1. log appends entry ─────────────────────────────────────────────────
313    #[test]
314    fn test_log_appends_entry() {
315        let mut log = make_log(100);
316        log_op(&mut log, 1, LogOperation::Read, 512, 1);
317        assert_eq!(log.entries.len(), 1);
318        assert_eq!(log.entries[0].block_id, 1);
319        assert_eq!(log.entries[0].operation, LogOperation::Read);
320        assert_eq!(log.entries[0].size_bytes, 512);
321        assert_eq!(log.entries[0].tick, 1);
322    }
323
324    // ── 2. entry_id is monotonically increasing ──────────────────────────────
325    #[test]
326    fn test_entry_id_monotonic() {
327        let mut log = make_log(100);
328        for i in 0..5_u64 {
329            log_op(&mut log, i, LogOperation::Read, 1, i);
330        }
331        for (i, e) in log.entries.iter().enumerate() {
332            assert_eq!(e.entry_id, i as u64);
333        }
334    }
335
336    // ── 3. FIFO eviction when over max_entries ───────────────────────────────
337    #[test]
338    fn test_fifo_eviction() {
339        let mut log = make_log(3);
340        for i in 0..5_u64 {
341            log_op(&mut log, i, LogOperation::Read, 1, i);
342        }
343        // Only last 3 entries should remain.
344        assert_eq!(log.entries.len(), 3);
345        assert_eq!(log.entries[0].block_id, 2);
346        assert_eq!(log.entries[2].block_id, 4);
347    }
348
349    // ── 4. FIFO eviction does not decrement next_entry_id ───────────────────
350    #[test]
351    fn test_next_entry_id_not_decremented_on_eviction() {
352        let mut log = make_log(2);
353        for i in 0..5_u64 {
354            log_op(&mut log, i, LogOperation::Read, 1, i);
355        }
356        assert_eq!(log.next_entry_id, 5);
357    }
358
359    // ── 5. cumulative ops stats update ────────────────────────────────────────
360    #[test]
361    fn test_cumulative_ops() {
362        let mut log = make_log(100);
363        log_op(&mut log, 1, LogOperation::Read, 100, 1);
364        log_op(&mut log, 2, LogOperation::Write, 200, 2);
365        log_op(&mut log, 3, LogOperation::Read, 300, 3);
366        let stats = log.stats();
367        assert_eq!(
368            *stats.by_operation.get(&LogOperation::Read).unwrap_or(&0),
369            2
370        );
371        assert_eq!(
372            *stats.by_operation.get(&LogOperation::Write).unwrap_or(&0),
373            1
374        );
375        assert_eq!(stats.total_bytes_read, 400);
376        assert_eq!(stats.total_bytes_written, 200);
377    }
378
379    // ── 6. cumulative stats survive eviction ─────────────────────────────────
380    #[test]
381    fn test_cumulative_stats_survive_eviction() {
382        let mut log = make_log(2);
383        log_op(&mut log, 1, LogOperation::Read, 100, 1);
384        log_op(&mut log, 2, LogOperation::Write, 200, 2);
385        log_op(&mut log, 3, LogOperation::Read, 300, 3);
386        // entry 0 (block 1, Read) should have been evicted.
387        assert_eq!(log.entries.len(), 2);
388        let stats = log.stats();
389        assert_eq!(stats.total_bytes_read, 400); // 100 + 300
390        assert_eq!(
391            *stats.by_operation.get(&LogOperation::Read).unwrap_or(&0),
392            2
393        );
394    }
395
396    // ── 7. total_entries equals next_entry_id ────────────────────────────────
397    #[test]
398    fn test_total_entries_equals_logged_count() {
399        let mut log = make_log(3);
400        for i in 0..7_u64 {
401            log_op(&mut log, i, LogOperation::Stat, 0, i);
402        }
403        assert_eq!(log.stats().total_entries, 7);
404    }
405
406    // ── 8. seen_blocks unique count ───────────────────────────────────────────
407    #[test]
408    fn test_seen_blocks_unique_count() {
409        let mut log = make_log(100);
410        for _ in 0..4 {
411            log_op(&mut log, 42, LogOperation::Read, 1, 1);
412        }
413        log_op(&mut log, 99, LogOperation::Write, 1, 2);
414        assert_eq!(log.stats().unique_blocks_accessed, 2);
415    }
416
417    // ── 9. seen_blocks survives eviction ─────────────────────────────────────
418    #[test]
419    fn test_seen_blocks_survive_eviction() {
420        let mut log = make_log(2);
421        log_op(&mut log, 10, LogOperation::Read, 1, 1);
422        log_op(&mut log, 20, LogOperation::Read, 1, 2);
423        log_op(&mut log, 30, LogOperation::Read, 1, 3);
424        // block 10 was evicted but still counted.
425        assert_eq!(log.stats().unique_blocks_accessed, 3);
426    }
427
428    // ── 10. detect_pattern BurstWrite ────────────────────────────────────────
429    #[test]
430    fn test_detect_pattern_burst_write() {
431        let _log = make_log(100);
432        let cfg = LogConfig {
433            max_entries: 100,
434            burst_write_threshold: 5,
435            burst_window_ticks: 10,
436        };
437        let mut log = StorageAccessLog::new(cfg);
438        for i in 0..5_u64 {
439            log_op(&mut log, i, LogOperation::Write, 1, 5 + i);
440        }
441        assert_eq!(log.detect_pattern(10), AccessPattern::BurstWrite);
442    }
443
444    // ── 11. BurstWrite not triggered below threshold ──────────────────────────
445    #[test]
446    fn test_detect_pattern_no_burst_below_threshold() {
447        let cfg = LogConfig {
448            max_entries: 100,
449            burst_write_threshold: 5,
450            burst_window_ticks: 10,
451        };
452        let mut log = StorageAccessLog::new(cfg);
453        for i in 0..4_u64 {
454            log_op(&mut log, i, LogOperation::Write, 1, 5 + i);
455        }
456        // 4 writes < threshold 5 → should not be BurstWrite.
457        assert_ne!(log.detect_pattern(10), AccessPattern::BurstWrite);
458    }
459
460    // ── 12. detect_pattern Repeated (block appears 3+ times) ─────────────────
461    #[test]
462    fn test_detect_pattern_repeated() {
463        let mut log = make_log(100);
464        for _ in 0..3 {
465            log_op(&mut log, 77, LogOperation::Read, 1, 1);
466        }
467        log_op(&mut log, 99, LogOperation::Read, 1, 2);
468        assert_eq!(
469            log.detect_pattern(2),
470            AccessPattern::Repeated { block_id: 77 }
471        );
472    }
473
474    // ── 13. Repeated not triggered at 2 occurrences ──────────────────────────
475    #[test]
476    fn test_detect_pattern_repeated_needs_three() {
477        let mut log = make_log(100);
478        log_op(&mut log, 77, LogOperation::Read, 1, 1);
479        log_op(&mut log, 77, LogOperation::Read, 1, 2);
480        log_op(&mut log, 10, LogOperation::Read, 1, 3);
481        log_op(&mut log, 20, LogOperation::Read, 1, 4);
482        log_op(&mut log, 30, LogOperation::Read, 1, 5);
483        // Only 2 occurrences of block 77 — should not be Repeated.
484        let pat = log.detect_pattern(5);
485        assert!(
486            pat != AccessPattern::Repeated { block_id: 77 },
487            "Should not be Repeated with only 2 hits"
488        );
489    }
490
491    // ── 14. detect_pattern Sequential (strictly increasing block IDs) ─────────
492    #[test]
493    fn test_detect_pattern_sequential() {
494        let mut log = make_log(100);
495        for i in 1..=5_u64 {
496            log_op(&mut log, i * 10, LogOperation::Read, 1, i);
497        }
498        assert_eq!(log.detect_pattern(5), AccessPattern::Sequential);
499    }
500
501    // ── 15. Sequential not triggered when not strictly increasing ─────────────
502    #[test]
503    fn test_detect_pattern_sequential_requires_strict_increase() {
504        let mut log = make_log(100);
505        // block IDs: 10, 20, 20, 30, 40 — not strictly increasing (dupe at 20).
506        for bid in [10_u64, 20, 20, 30, 40] {
507            log_op(&mut log, bid, LogOperation::Read, 1, 1);
508        }
509        assert_ne!(log.detect_pattern(1), AccessPattern::Sequential);
510    }
511
512    // ── 16. detect_pattern Random ────────────────────────────────────────────
513    #[test]
514    fn test_detect_pattern_random() {
515        let mut log = make_log(100);
516        for bid in [5_u64, 2, 9, 1] {
517            log_op(&mut log, bid, LogOperation::Read, 1, 1);
518        }
519        assert_eq!(log.detect_pattern(1), AccessPattern::Random);
520    }
521
522    // ── 17. detect_pattern Random on empty log ───────────────────────────────
523    #[test]
524    fn test_detect_pattern_empty_is_random() {
525        let log = make_log(100);
526        assert_eq!(log.detect_pattern(0), AccessPattern::Random);
527    }
528
529    // ── 18. entries_for_block filters correctly ───────────────────────────────
530    #[test]
531    fn test_entries_for_block() {
532        let mut log = make_log(100);
533        log_op(&mut log, 1, LogOperation::Read, 1, 1);
534        log_op(&mut log, 2, LogOperation::Write, 2, 2);
535        log_op(&mut log, 1, LogOperation::Stat, 3, 3);
536        log_op(&mut log, 3, LogOperation::Read, 4, 4);
537
538        let result = log.entries_for_block(1);
539        assert_eq!(result.len(), 2);
540        assert_eq!(result[0].tick, 1);
541        assert_eq!(result[1].tick, 3);
542    }
543
544    // ── 19. entries_for_block returns empty when block not present ────────────
545    #[test]
546    fn test_entries_for_block_not_found() {
547        let mut log = make_log(100);
548        log_op(&mut log, 42, LogOperation::Read, 1, 1);
549        assert!(log.entries_for_block(99).is_empty());
550    }
551
552    // ── 20. entries_in_range inclusive bounds ─────────────────────────────────
553    #[test]
554    fn test_entries_in_range_inclusive() {
555        let mut log = make_log(100);
556        for tick in 1..=5_u64 {
557            log_op(&mut log, tick, LogOperation::Read, 1, tick);
558        }
559        let result = log.entries_in_range(2, 4);
560        assert_eq!(result.len(), 3);
561        assert!(result.iter().all(|e| e.tick >= 2 && e.tick <= 4));
562    }
563
564    // ── 21. entries_in_range empty when range is outside ─────────────────────
565    #[test]
566    fn test_entries_in_range_outside() {
567        let mut log = make_log(100);
568        log_op(&mut log, 1, LogOperation::Read, 1, 5);
569        assert!(log.entries_in_range(10, 20).is_empty());
570    }
571
572    // ── 22. entries_in_range single-tick range ────────────────────────────────
573    #[test]
574    fn test_entries_in_range_single_tick() {
575        let mut log = make_log(100);
576        log_op(&mut log, 1, LogOperation::Read, 1, 7);
577        log_op(&mut log, 2, LogOperation::Read, 1, 7);
578        log_op(&mut log, 3, LogOperation::Write, 1, 8);
579        let result = log.entries_in_range(7, 7);
580        assert_eq!(result.len(), 2);
581    }
582
583    // ── 23. clear keeps cumulative stats ─────────────────────────────────────
584    #[test]
585    fn test_clear_keeps_cumulative_stats() {
586        let mut log = make_log(100);
587        log_op(&mut log, 1, LogOperation::Read, 100, 1);
588        log_op(&mut log, 2, LogOperation::Write, 200, 2);
589        log.clear();
590
591        assert!(log.entries.is_empty(), "entries should be cleared");
592        let stats = log.stats();
593        assert_eq!(stats.total_entries, 2); // next_entry_id not reset
594        assert_eq!(stats.total_bytes_read, 100);
595        assert_eq!(stats.total_bytes_written, 200);
596        assert_eq!(stats.unique_blocks_accessed, 2);
597    }
598
599    // ── 24. clear does not reset next_entry_id ───────────────────────────────
600    #[test]
601    fn test_clear_does_not_reset_next_entry_id() {
602        let mut log = make_log(100);
603        log_op(&mut log, 1, LogOperation::Read, 1, 1);
604        log_op(&mut log, 2, LogOperation::Read, 1, 2);
605        log.clear();
606        // Next log after clear should continue IDs.
607        log_op(&mut log, 3, LogOperation::Read, 1, 3);
608        assert_eq!(log.entries[0].entry_id, 2);
609    }
610
611    // ── 25. peer_id optional — None case ─────────────────────────────────────
612    #[test]
613    fn test_peer_id_none() {
614        let mut log = make_log(100);
615        log.log(1, "cid-1".into(), LogOperation::Read, 1, 1, None);
616        assert!(log.entries[0].peer_id.is_none());
617    }
618
619    // ── 26. peer_id optional — Some case ─────────────────────────────────────
620    #[test]
621    fn test_peer_id_some() {
622        let mut log = make_log(100);
623        log.log(
624            1,
625            "cid-1".into(),
626            LogOperation::Read,
627            1,
628            1,
629            Some("peer-abc".into()),
630        );
631        assert_eq!(log.entries[0].peer_id.as_deref(), Some("peer-abc"));
632    }
633
634    // ── 27. Delete and Stat ops accumulate in by_operation ────────────────────
635    #[test]
636    fn test_delete_stat_list_in_by_operation() {
637        let mut log = make_log(100);
638        log_op(&mut log, 1, LogOperation::Delete, 0, 1);
639        log_op(&mut log, 2, LogOperation::Stat, 0, 2);
640        log_op(&mut log, 3, LogOperation::List, 0, 3);
641        let stats = log.stats();
642        assert_eq!(
643            *stats.by_operation.get(&LogOperation::Delete).unwrap_or(&0),
644            1
645        );
646        assert_eq!(
647            *stats.by_operation.get(&LogOperation::Stat).unwrap_or(&0),
648            1
649        );
650        assert_eq!(
651            *stats.by_operation.get(&LogOperation::List).unwrap_or(&0),
652            1
653        );
654    }
655
656    // ── 28. BurstWrite ignores writes outside the window ──────────────────────
657    #[test]
658    fn test_burst_write_ignores_old_writes() {
659        let cfg = LogConfig {
660            max_entries: 100,
661            burst_write_threshold: 3,
662            burst_window_ticks: 5,
663        };
664        let mut log = StorageAccessLog::new(cfg);
665        // 3 writes at tick 1 (far outside window when current_tick = 100)
666        for i in 0..3_u64 {
667            log_op(&mut log, i, LogOperation::Write, 1, 1);
668        }
669        // Only 2 writes within the window.
670        log_op(&mut log, 10, LogOperation::Write, 1, 96);
671        log_op(&mut log, 11, LogOperation::Write, 1, 97);
672        // window_start = 100 - 5 = 95; only ticks 96 and 97 qualify → 2 < 3.
673        assert_ne!(log.detect_pattern(100), AccessPattern::BurstWrite);
674    }
675
676    // ── 29. cid stored correctly ──────────────────────────────────────────────
677    #[test]
678    fn test_cid_stored_correctly() {
679        let mut log = make_log(100);
680        log.log(7, "bafyxyz".into(), LogOperation::Read, 1, 1, None);
681        assert_eq!(log.entries[0].cid, "bafyxyz");
682    }
683
684    // ── 30. Repeated first-found ordering ────────────────────────────────────
685    #[test]
686    fn test_repeated_returns_first_found_block() {
687        let mut log = make_log(100);
688        // block 99 appears 3 times, block 77 appears 3 times; 99 comes first.
689        for tick in 1..=3_u64 {
690            log_op(&mut log, 99, LogOperation::Read, 1, tick);
691        }
692        for tick in 4..=6_u64 {
693            log_op(&mut log, 77, LogOperation::Read, 1, tick);
694        }
695        // Last 10 entries has both. First in iteration order should win.
696        let pat = log.detect_pattern(6);
697        assert_eq!(pat, AccessPattern::Repeated { block_id: 99 });
698    }
699}