chie_core/
popularity.rs

1//! Content popularity tracking for CHIE Protocol.
2//!
3//! This module tracks content access patterns to help with:
4//! - Dynamic pricing based on demand
5//! - Smart caching decisions
6//! - Investment recommendations
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// Default time windows for popularity calculation.
12pub const WINDOW_1_HOUR: Duration = Duration::from_secs(3600);
13pub const WINDOW_24_HOURS: Duration = Duration::from_secs(24 * 3600);
14pub const WINDOW_7_DAYS: Duration = Duration::from_secs(7 * 24 * 3600);
15
16/// Configuration for popularity tracking.
17#[derive(Debug, Clone)]
18pub struct PopularityConfig {
19    /// Maximum number of content items to track.
20    pub max_tracked_content: usize,
21    /// Time window for "hot" content (default: 1 hour).
22    pub hot_window: Duration,
23    /// Time window for "trending" content (default: 24 hours).
24    pub trending_window: Duration,
25    /// Minimum requests to be considered popular.
26    pub min_requests_for_popular: u64,
27    /// How often to prune old data.
28    pub prune_interval: Duration,
29}
30
31impl Default for PopularityConfig {
32    #[inline]
33    fn default() -> Self {
34        Self {
35            max_tracked_content: 10000,
36            hot_window: WINDOW_1_HOUR,
37            trending_window: WINDOW_24_HOURS,
38            min_requests_for_popular: 10,
39            prune_interval: Duration::from_secs(3600), // 1 hour
40        }
41    }
42}
43
44/// Access record for a single content request.
45#[derive(Debug, Clone)]
46#[allow(dead_code)]
47struct AccessRecord {
48    timestamp: Instant,
49    bytes_transferred: u64,
50    peer_count: u32,
51}
52
53/// Popularity data for a single content item.
54#[derive(Debug, Clone)]
55pub struct ContentPopularity {
56    /// Content CID.
57    pub cid: String,
58    /// Total requests (all time).
59    pub total_requests: u64,
60    /// Total bytes transferred (all time).
61    pub total_bytes: u64,
62    /// Unique peers that requested this content.
63    pub unique_peers: u64,
64    /// First access timestamp.
65    pub first_seen: Instant,
66    /// Last access timestamp.
67    pub last_access: Instant,
68    /// Access records for time-windowed calculations.
69    access_history: Vec<AccessRecord>,
70}
71
72impl ContentPopularity {
73    #[inline]
74    fn new(cid: String) -> Self {
75        let now = Instant::now();
76        Self {
77            cid,
78            total_requests: 0,
79            total_bytes: 0,
80            unique_peers: 0,
81            first_seen: now,
82            last_access: now,
83            access_history: Vec::new(),
84        }
85    }
86
87    /// Record an access.
88    fn record_access(&mut self, bytes: u64, is_new_peer: bool) {
89        self.total_requests += 1;
90        self.total_bytes += bytes;
91        if is_new_peer {
92            self.unique_peers += 1;
93        }
94        self.last_access = Instant::now();
95
96        self.access_history.push(AccessRecord {
97            timestamp: Instant::now(),
98            bytes_transferred: bytes,
99            peer_count: if is_new_peer { 1 } else { 0 },
100        });
101    }
102
103    /// Get the number of requests within a time window.
104    #[inline]
105    fn requests_in_window(&self, window: Duration) -> u64 {
106        let cutoff = Instant::now() - window;
107        self.access_history
108            .iter()
109            .filter(|r| r.timestamp > cutoff)
110            .count() as u64
111    }
112
113    /// Get bytes transferred within a time window.
114    #[inline]
115    fn bytes_in_window(&self, window: Duration) -> u64 {
116        let cutoff = Instant::now() - window;
117        self.access_history
118            .iter()
119            .filter(|r| r.timestamp > cutoff)
120            .map(|r| r.bytes_transferred)
121            .sum()
122    }
123
124    /// Prune old access history.
125    #[inline]
126    fn prune_history(&mut self, max_age: Duration) {
127        let cutoff = Instant::now() - max_age;
128        self.access_history.retain(|r| r.timestamp > cutoff);
129    }
130}
131
132/// Popularity score calculation result.
133#[derive(Debug, Clone)]
134pub struct PopularityScore {
135    /// Content CID.
136    pub cid: String,
137    /// Overall popularity score (0-100).
138    pub score: f64,
139    /// Requests in the last hour.
140    pub hourly_requests: u64,
141    /// Requests in the last 24 hours.
142    pub daily_requests: u64,
143    /// Bytes transferred in the last 24 hours.
144    pub daily_bytes: u64,
145    /// Total unique peers.
146    pub unique_peers: u64,
147    /// Demand level classification.
148    pub demand_level: DemandLevel,
149    /// Recommended multiplier for pricing.
150    pub price_multiplier: f64,
151}
152
153/// Demand level classification.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum DemandLevel {
156    Low,
157    Medium,
158    High,
159    VeryHigh,
160}
161
162impl DemandLevel {
163    /// Get the price multiplier for this demand level.
164    #[inline]
165    #[must_use]
166    pub fn price_multiplier(&self) -> f64 {
167        match self {
168            DemandLevel::Low => 0.5,
169            DemandLevel::Medium => 1.0,
170            DemandLevel::High => 1.5,
171            DemandLevel::VeryHigh => 3.0,
172        }
173    }
174}
175
176/// Content popularity tracker.
177pub struct PopularityTracker {
178    config: PopularityConfig,
179    content: HashMap<String, ContentPopularity>,
180    peer_seen: HashMap<String, std::collections::HashSet<String>>,
181    last_prune: Instant,
182}
183
184impl Default for PopularityTracker {
185    #[inline]
186    fn default() -> Self {
187        Self::new(PopularityConfig::default())
188    }
189}
190
191impl PopularityTracker {
192    /// Create a new popularity tracker.
193    #[inline]
194    #[must_use]
195    pub fn new(config: PopularityConfig) -> Self {
196        Self {
197            config,
198            content: HashMap::new(),
199            peer_seen: HashMap::new(),
200            last_prune: Instant::now(),
201        }
202    }
203
204    /// Record a content access.
205    pub fn record_access(&mut self, cid: &str, bytes: u64, peer_id: &str) {
206        // Check if this is a new peer for this content
207        let is_new_peer = self
208            .peer_seen
209            .entry(cid.to_string())
210            .or_default()
211            .insert(peer_id.to_string());
212
213        // Get or create popularity data
214        let popularity = self
215            .content
216            .entry(cid.to_string())
217            .or_insert_with(|| ContentPopularity::new(cid.to_string()));
218
219        popularity.record_access(bytes, is_new_peer);
220
221        // Periodically prune old data
222        self.maybe_prune();
223    }
224
225    /// Get popularity data for a content item.
226    #[inline]
227    #[must_use]
228    pub fn get_popularity(&self, cid: &str) -> Option<&ContentPopularity> {
229        self.content.get(cid)
230    }
231
232    /// Calculate popularity score for a content item.
233    #[must_use]
234    #[inline]
235    pub fn calculate_score(&self, cid: &str) -> Option<PopularityScore> {
236        let popularity = self.content.get(cid)?;
237
238        let hourly_requests = popularity.requests_in_window(self.config.hot_window);
239        let daily_requests = popularity.requests_in_window(self.config.trending_window);
240        let daily_bytes = popularity.bytes_in_window(self.config.trending_window);
241
242        // Calculate score based on multiple factors
243        let recency_score = calculate_recency_score(popularity.last_access);
244        let volume_score = calculate_volume_score(daily_requests);
245        let diversity_score = calculate_diversity_score(popularity.unique_peers, daily_requests);
246
247        // Weighted combination
248        let score = (recency_score * 0.3 + volume_score * 0.5 + diversity_score * 0.2) * 100.0;
249        let score = score.clamp(0.0, 100.0);
250
251        let demand_level = classify_demand(daily_requests, self.config.min_requests_for_popular);
252        let price_multiplier = demand_level.price_multiplier();
253
254        Some(PopularityScore {
255            cid: cid.to_string(),
256            score,
257            hourly_requests,
258            daily_requests,
259            daily_bytes,
260            unique_peers: popularity.unique_peers,
261            demand_level,
262            price_multiplier,
263        })
264    }
265
266    /// Get the top N most popular content items.
267    #[must_use]
268    #[inline]
269    pub fn get_top_content(&self, n: usize) -> Vec<PopularityScore> {
270        let mut scores: Vec<PopularityScore> = self
271            .content
272            .keys()
273            .filter_map(|cid| self.calculate_score(cid))
274            .collect();
275
276        scores.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
277        scores.truncate(n);
278        scores
279    }
280
281    /// Get "hot" content (high activity in the last hour).
282    #[must_use]
283    #[inline]
284    pub fn get_hot_content(&self) -> Vec<PopularityScore> {
285        let min_hourly = self.config.min_requests_for_popular / 24;
286
287        self.content
288            .keys()
289            .filter_map(|cid| self.calculate_score(cid))
290            .filter(|s| s.hourly_requests >= min_hourly)
291            .collect()
292    }
293
294    /// Get "trending" content (growing popularity).
295    #[must_use]
296    #[inline]
297    pub fn get_trending_content(&self) -> Vec<PopularityScore> {
298        let mut scores: Vec<PopularityScore> = self
299            .content
300            .keys()
301            .filter_map(|cid| {
302                let score = self.calculate_score(cid)?;
303
304                // Check if hourly rate > daily average rate
305                let hourly_rate = score.hourly_requests as f64;
306                let daily_avg_rate = score.daily_requests as f64 / 24.0;
307
308                if hourly_rate > daily_avg_rate * 1.5 {
309                    Some(score)
310                } else {
311                    None
312                }
313            })
314            .collect();
315
316        scores.sort_by(|a, b| b.hourly_requests.cmp(&a.hourly_requests));
317        scores
318    }
319
320    /// Get content statistics.
321    #[must_use]
322    #[inline]
323    pub fn get_stats(&self) -> PopularityStats {
324        let total_content = self.content.len();
325        let total_requests: u64 = self.content.values().map(|p| p.total_requests).sum();
326        let total_bytes: u64 = self.content.values().map(|p| p.total_bytes).sum();
327
328        PopularityStats {
329            tracked_content: total_content,
330            total_requests,
331            total_bytes_transferred: total_bytes,
332        }
333    }
334
335    /// Prune old data if needed.
336    fn maybe_prune(&mut self) {
337        if Instant::now().duration_since(self.last_prune) < self.config.prune_interval {
338            return;
339        }
340
341        // Prune old access history
342        let max_history = self.config.trending_window * 2;
343        for popularity in self.content.values_mut() {
344            popularity.prune_history(max_history);
345        }
346
347        // If we have too many content items, remove the least popular
348        if self.content.len() > self.config.max_tracked_content {
349            let mut by_score: Vec<(String, u64)> = self
350                .content
351                .iter()
352                .map(|(cid, p)| {
353                    (
354                        cid.clone(),
355                        p.requests_in_window(self.config.trending_window),
356                    )
357                })
358                .collect();
359
360            by_score.sort_by(|a, b| a.1.cmp(&b.1));
361
362            // Remove bottom 10%
363            let to_remove = self.content.len() - self.config.max_tracked_content;
364            for (cid, _) in by_score.into_iter().take(to_remove) {
365                self.content.remove(&cid);
366                self.peer_seen.remove(&cid);
367            }
368        }
369
370        self.last_prune = Instant::now();
371    }
372}
373
374/// Statistics about the popularity tracker.
375#[derive(Debug, Clone)]
376pub struct PopularityStats {
377    /// Number of content items being tracked.
378    pub tracked_content: usize,
379    /// Total requests across all content.
380    pub total_requests: u64,
381    /// Total bytes transferred across all content.
382    pub total_bytes_transferred: u64,
383}
384
385// Helper functions
386
387fn calculate_recency_score(last_access: Instant) -> f64 {
388    let age = Instant::now().duration_since(last_access);
389    let hours = age.as_secs_f64() / 3600.0;
390
391    // Exponential decay: score halves every 24 hours
392    0.5_f64.powf(hours / 24.0)
393}
394
395fn calculate_volume_score(daily_requests: u64) -> f64 {
396    // Logarithmic scale: score increases with requests but with diminishing returns
397    if daily_requests == 0 {
398        return 0.0;
399    }
400    let log_requests = (daily_requests as f64).ln();
401    (log_requests / 10.0).min(1.0) // Normalize to 0-1
402}
403
404fn calculate_diversity_score(unique_peers: u64, total_requests: u64) -> f64 {
405    if total_requests == 0 {
406        return 0.0;
407    }
408    let ratio = unique_peers as f64 / total_requests as f64;
409    ratio.min(1.0) // Higher ratio = more diverse audience
410}
411
412fn classify_demand(daily_requests: u64, min_popular: u64) -> DemandLevel {
413    if daily_requests < min_popular / 2 {
414        DemandLevel::Low
415    } else if daily_requests < min_popular {
416        DemandLevel::Medium
417    } else if daily_requests < min_popular * 5 {
418        DemandLevel::High
419    } else {
420        DemandLevel::VeryHigh
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_record_access() {
430        let mut tracker = PopularityTracker::default();
431
432        tracker.record_access("QmTest123", 1024, "peer1");
433        tracker.record_access("QmTest123", 2048, "peer2");
434        tracker.record_access("QmTest123", 1024, "peer1"); // Same peer
435
436        let popularity = tracker.get_popularity("QmTest123").unwrap();
437        assert_eq!(popularity.total_requests, 3);
438        assert_eq!(popularity.total_bytes, 1024 + 2048 + 1024);
439        assert_eq!(popularity.unique_peers, 2);
440    }
441
442    #[test]
443    fn test_calculate_score() {
444        let mut tracker = PopularityTracker::default();
445
446        for i in 0..20 {
447            tracker.record_access("QmPopular", 1024, &format!("peer{}", i));
448        }
449
450        let score = tracker.calculate_score("QmPopular").unwrap();
451        assert!(score.score > 0.0);
452        assert_eq!(score.daily_requests, 20);
453        assert_eq!(score.unique_peers, 20);
454    }
455
456    #[test]
457    fn test_get_top_content() {
458        let mut tracker = PopularityTracker::default();
459
460        // Create content with different popularity
461        for i in 0..10 {
462            tracker.record_access("QmLow", 1024, &format!("peer{}", i));
463        }
464        for i in 0..50 {
465            tracker.record_access("QmMedium", 1024, &format!("peer{}", i));
466        }
467        for i in 0..100 {
468            tracker.record_access("QmHigh", 1024, &format!("peer{}", i));
469        }
470
471        let top = tracker.get_top_content(3);
472        assert_eq!(top.len(), 3);
473        assert_eq!(top[0].cid, "QmHigh");
474    }
475
476    #[test]
477    fn test_demand_classification() {
478        assert_eq!(classify_demand(0, 10), DemandLevel::Low);
479        assert_eq!(classify_demand(3, 10), DemandLevel::Low);
480        assert_eq!(classify_demand(7, 10), DemandLevel::Medium);
481        assert_eq!(classify_demand(15, 10), DemandLevel::High);
482        assert_eq!(classify_demand(100, 10), DemandLevel::VeryHigh);
483    }
484}