chie_core/
pinning.rs

1//! Selective pinning optimizer for CHIE Protocol.
2//!
3//! This module provides:
4//! - Content profitability scoring
5//! - Storage allocation optimization
6//! - Pin/unpin recommendations
7
8use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11/// Configuration for the pinning optimizer.
12#[derive(Debug, Clone)]
13pub struct PinningConfig {
14    /// Maximum storage to allocate (bytes).
15    pub max_storage_bytes: u64,
16    /// Minimum expected revenue per GB per day.
17    pub min_revenue_per_gb: f64,
18    /// Weight for popularity score (0.0-1.0).
19    pub popularity_weight: f64,
20    /// Weight for revenue score (0.0-1.0).
21    pub revenue_weight: f64,
22    /// Weight for freshness score (0.0-1.0).
23    pub freshness_weight: f64,
24    /// How often to recalculate recommendations.
25    pub recalc_interval: Duration,
26    /// Minimum time to keep content pinned.
27    pub min_pin_duration: Duration,
28}
29
30impl Default for PinningConfig {
31    fn default() -> Self {
32        Self {
33            max_storage_bytes: 100 * 1024 * 1024 * 1024, // 100 GB
34            min_revenue_per_gb: 0.01,
35            popularity_weight: 0.4,
36            revenue_weight: 0.4,
37            freshness_weight: 0.2,
38            recalc_interval: Duration::from_secs(3600), // 1 hour
39            min_pin_duration: Duration::from_secs(86400), // 1 day
40        }
41    }
42}
43
44/// Content metrics for scoring.
45#[derive(Debug, Clone)]
46pub struct ContentMetrics {
47    /// Content identifier.
48    pub cid: String,
49    /// Size in bytes.
50    pub size_bytes: u64,
51    /// Total requests served.
52    pub total_requests: u64,
53    /// Requests in last 24 hours.
54    pub daily_requests: u64,
55    /// Total revenue earned (points).
56    pub total_revenue: u64,
57    /// Revenue in last 24 hours.
58    pub daily_revenue: u64,
59    /// When content was first pinned.
60    pub pinned_at: Instant,
61    /// Last time content was requested.
62    pub last_request: Option<Instant>,
63    /// Current demand multiplier.
64    pub demand_multiplier: f64,
65}
66
67impl ContentMetrics {
68    /// Create new metrics for content.
69    pub fn new(cid: String, size_bytes: u64) -> Self {
70        Self {
71            cid,
72            size_bytes,
73            total_requests: 0,
74            daily_requests: 0,
75            total_revenue: 0,
76            daily_revenue: 0,
77            pinned_at: Instant::now(),
78            last_request: None,
79            demand_multiplier: 1.0,
80        }
81    }
82
83    /// Record a request.
84    pub fn record_request(&mut self, revenue: u64) {
85        self.total_requests += 1;
86        self.daily_requests += 1;
87        self.total_revenue += revenue;
88        self.daily_revenue += revenue;
89        self.last_request = Some(Instant::now());
90    }
91
92    /// Calculate revenue per GB.
93    #[must_use]
94    #[inline]
95    pub fn revenue_per_gb(&self) -> f64 {
96        let size_gb = self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
97        if size_gb > 0.0 {
98            self.total_revenue as f64 / size_gb
99        } else {
100            0.0
101        }
102    }
103
104    /// Calculate daily revenue per GB.
105    #[must_use]
106    #[inline]
107    pub fn daily_revenue_per_gb(&self) -> f64 {
108        let size_gb = self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
109        if size_gb > 0.0 {
110            self.daily_revenue as f64 / size_gb
111        } else {
112            0.0
113        }
114    }
115
116    /// Get time since last request.
117    #[must_use]
118    #[inline]
119    pub fn time_since_last_request(&self) -> Duration {
120        self.last_request
121            .map(|t| t.elapsed())
122            .unwrap_or(self.pinned_at.elapsed())
123    }
124}
125
126/// Scored content for optimization.
127#[derive(Debug, Clone)]
128pub struct ScoredContent {
129    /// Content identifier.
130    pub cid: String,
131    /// Size in bytes.
132    pub size_bytes: u64,
133    /// Composite score (0.0-1.0).
134    pub score: f64,
135    /// Individual score components.
136    pub components: ScoreComponents,
137    /// Recommendation.
138    pub recommendation: PinRecommendation,
139}
140
141/// Score components for analysis.
142#[derive(Debug, Clone)]
143pub struct ScoreComponents {
144    /// Popularity score (0.0-1.0).
145    pub popularity: f64,
146    /// Revenue score (0.0-1.0).
147    pub revenue: f64,
148    /// Freshness score (0.0-1.0).
149    pub freshness: f64,
150    /// Demand multiplier impact.
151    pub demand: f64,
152}
153
154/// Pin recommendation.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum PinRecommendation {
157    /// Keep pinned (high value).
158    Keep,
159    /// Consider unpinning (low value).
160    Unpin,
161    /// New content to pin.
162    Pin,
163    /// Content is being evaluated.
164    Evaluate,
165}
166
167/// Selective pinning optimizer.
168pub struct PinningOptimizer {
169    config: PinningConfig,
170    /// Metrics for pinned content.
171    content_metrics: HashMap<String, ContentMetrics>,
172    /// Current storage usage.
173    used_storage: u64,
174    /// Last optimization time.
175    #[allow(dead_code)]
176    last_optimization: Option<Instant>,
177}
178
179impl Default for PinningOptimizer {
180    fn default() -> Self {
181        Self::new(PinningConfig::default())
182    }
183}
184
185impl PinningOptimizer {
186    /// Create a new optimizer.
187    pub fn new(config: PinningConfig) -> Self {
188        Self {
189            config,
190            content_metrics: HashMap::new(),
191            used_storage: 0,
192            last_optimization: None,
193        }
194    }
195
196    /// Register pinned content.
197    pub fn register_content(&mut self, cid: String, size_bytes: u64) {
198        let metrics = ContentMetrics::new(cid.clone(), size_bytes);
199        self.content_metrics.insert(cid, metrics);
200        self.used_storage += size_bytes;
201    }
202
203    /// Unregister content.
204    pub fn unregister_content(&mut self, cid: &str) -> Option<ContentMetrics> {
205        if let Some(metrics) = self.content_metrics.remove(cid) {
206            self.used_storage = self.used_storage.saturating_sub(metrics.size_bytes);
207            Some(metrics)
208        } else {
209            None
210        }
211    }
212
213    /// Record a request for content.
214    pub fn record_request(&mut self, cid: &str, revenue: u64) {
215        if let Some(metrics) = self.content_metrics.get_mut(cid) {
216            metrics.record_request(revenue);
217        }
218    }
219
220    /// Update demand multiplier for content.
221    pub fn update_demand(&mut self, cid: &str, multiplier: f64) {
222        if let Some(metrics) = self.content_metrics.get_mut(cid) {
223            metrics.demand_multiplier = multiplier;
224        }
225    }
226
227    /// Calculate score for content.
228    fn calculate_score(&self, metrics: &ContentMetrics) -> (f64, ScoreComponents) {
229        // Popularity score based on daily requests (normalized)
230        let max_daily = self
231            .content_metrics
232            .values()
233            .map(|m| m.daily_requests)
234            .max()
235            .unwrap_or(1);
236        let popularity = if max_daily > 0 {
237            metrics.daily_requests as f64 / max_daily as f64
238        } else {
239            0.0
240        };
241
242        // Revenue score based on revenue per GB (normalized)
243        let daily_rev_per_gb = metrics.daily_revenue_per_gb();
244        let revenue = if daily_rev_per_gb >= self.config.min_revenue_per_gb {
245            (daily_rev_per_gb / self.config.min_revenue_per_gb).min(1.0)
246        } else {
247            daily_rev_per_gb / self.config.min_revenue_per_gb
248        };
249
250        // Freshness score (how recently accessed)
251        let time_since = metrics.time_since_last_request();
252        let freshness = if time_since < Duration::from_secs(3600) {
253            1.0
254        } else if time_since < Duration::from_secs(86400) {
255            0.7
256        } else if time_since < Duration::from_secs(604_800) {
257            0.4
258        } else {
259            0.1
260        };
261
262        // Demand multiplier boost
263        let demand = (metrics.demand_multiplier - 1.0).max(0.0) / 2.0; // 0.0 to 1.0 range
264
265        let components = ScoreComponents {
266            popularity,
267            revenue,
268            freshness,
269            demand,
270        };
271
272        // Weighted composite score
273        let score = (self.config.popularity_weight * popularity)
274            + (self.config.revenue_weight * revenue)
275            + (self.config.freshness_weight * freshness)
276            + (demand * 0.2); // Bonus for high demand
277
278        (score.clamp(0.0, 1.0), components)
279    }
280
281    /// Get optimization recommendations.
282    #[must_use]
283    #[inline]
284    pub fn get_recommendations(&self) -> Vec<ScoredContent> {
285        let mut scored: Vec<ScoredContent> = self
286            .content_metrics
287            .values()
288            .map(|metrics| {
289                let (score, components) = self.calculate_score(metrics);
290                let pin_duration = metrics.pinned_at.elapsed();
291
292                let recommendation = if pin_duration < self.config.min_pin_duration {
293                    PinRecommendation::Evaluate
294                } else if score >= 0.6 {
295                    PinRecommendation::Keep
296                } else if score < 0.3 {
297                    PinRecommendation::Unpin
298                } else {
299                    PinRecommendation::Evaluate
300                };
301
302                ScoredContent {
303                    cid: metrics.cid.clone(),
304                    size_bytes: metrics.size_bytes,
305                    score,
306                    components,
307                    recommendation,
308                }
309            })
310            .collect();
311
312        // Sort by score descending
313        scored.sort_by(|a, b| {
314            b.score
315                .partial_cmp(&a.score)
316                .unwrap_or(std::cmp::Ordering::Equal)
317        });
318
319        scored
320    }
321
322    /// Get content to unpin to free space.
323    #[must_use]
324    #[inline]
325    pub fn get_unpin_candidates(&self, bytes_needed: u64) -> Vec<String> {
326        let mut recommendations = self.get_recommendations();
327
328        // Sort by score ascending (lowest first)
329        recommendations.sort_by(|a, b| {
330            a.score
331                .partial_cmp(&b.score)
332                .unwrap_or(std::cmp::Ordering::Equal)
333        });
334
335        let mut candidates = Vec::new();
336        let mut freed = 0u64;
337
338        for scored in recommendations {
339            if freed >= bytes_needed {
340                break;
341            }
342
343            // Only unpin content past minimum duration
344            if let Some(metrics) = self.content_metrics.get(&scored.cid) {
345                if metrics.pinned_at.elapsed() >= self.config.min_pin_duration {
346                    candidates.push(scored.cid);
347                    freed += scored.size_bytes;
348                }
349            }
350        }
351
352        candidates
353    }
354
355    /// Check if new content should be pinned.
356    #[must_use]
357    pub fn should_pin(&self, _cid: &str, size_bytes: u64, expected_demand: f64) -> PinDecision {
358        // Check storage capacity
359        if self.used_storage + size_bytes > self.config.max_storage_bytes {
360            // Need to free space
361            let needed = (self.used_storage + size_bytes) - self.config.max_storage_bytes;
362            let candidates = self.get_unpin_candidates(needed);
363
364            if candidates.is_empty() {
365                return PinDecision::Reject {
366                    reason: "Insufficient storage and no low-value content to unpin".to_string(),
367                };
368            }
369
370            return PinDecision::PinAfterUnpin {
371                unpin_cids: candidates,
372            };
373        }
374
375        // Check expected profitability
376        if expected_demand < 0.5 {
377            return PinDecision::Evaluate {
378                reason: "Low expected demand, consider pinning later".to_string(),
379            };
380        }
381
382        PinDecision::Accept
383    }
384
385    /// Get optimizer statistics.
386    #[must_use]
387    #[inline]
388    pub fn stats(&self) -> OptimizerStats {
389        let recommendations = self.get_recommendations();
390
391        let keep_count = recommendations
392            .iter()
393            .filter(|r| r.recommendation == PinRecommendation::Keep)
394            .count();
395        let unpin_count = recommendations
396            .iter()
397            .filter(|r| r.recommendation == PinRecommendation::Unpin)
398            .count();
399
400        let avg_score = if recommendations.is_empty() {
401            0.0
402        } else {
403            recommendations.iter().map(|r| r.score).sum::<f64>() / recommendations.len() as f64
404        };
405
406        OptimizerStats {
407            total_content: self.content_metrics.len(),
408            used_storage: self.used_storage,
409            max_storage: self.config.max_storage_bytes,
410            storage_utilization: self.used_storage as f64 / self.config.max_storage_bytes as f64,
411            avg_score,
412            keep_count,
413            unpin_count,
414        }
415    }
416
417    /// Reset daily metrics (call once per day).
418    pub fn reset_daily_metrics(&mut self) {
419        for metrics in self.content_metrics.values_mut() {
420            metrics.daily_requests = 0;
421            metrics.daily_revenue = 0;
422        }
423    }
424}
425
426/// Decision for pinning new content.
427#[derive(Debug, Clone)]
428pub enum PinDecision {
429    /// Accept and pin immediately.
430    Accept,
431    /// Pin after unpinning specified content.
432    PinAfterUnpin { unpin_cids: Vec<String> },
433    /// Evaluate later (borderline).
434    Evaluate { reason: String },
435    /// Reject pinning.
436    Reject { reason: String },
437}
438
439/// Optimizer statistics.
440#[derive(Debug, Clone)]
441pub struct OptimizerStats {
442    /// Total pinned content.
443    pub total_content: usize,
444    /// Used storage in bytes.
445    pub used_storage: u64,
446    /// Maximum storage in bytes.
447    pub max_storage: u64,
448    /// Storage utilization (0.0-1.0).
449    pub storage_utilization: f64,
450    /// Average content score.
451    pub avg_score: f64,
452    /// Content recommended to keep.
453    pub keep_count: usize,
454    /// Content recommended to unpin.
455    pub unpin_count: usize,
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn test_content_metrics() {
464        let mut metrics = ContentMetrics::new("QmTest".to_string(), 1024 * 1024 * 100); // 100 MB
465
466        metrics.record_request(100);
467        metrics.record_request(200);
468
469        assert_eq!(metrics.total_requests, 2);
470        assert_eq!(metrics.total_revenue, 300);
471        assert!(metrics.last_request.is_some());
472    }
473
474    #[test]
475    fn test_optimizer_register() {
476        let mut optimizer = PinningOptimizer::default();
477
478        optimizer.register_content("QmTest1".to_string(), 1024 * 1024 * 100);
479        optimizer.register_content("QmTest2".to_string(), 1024 * 1024 * 200);
480
481        assert_eq!(optimizer.content_metrics.len(), 2);
482        assert_eq!(optimizer.used_storage, 1024 * 1024 * 300);
483    }
484
485    #[test]
486    fn test_optimizer_recommendations() {
487        let mut optimizer = PinningOptimizer::default();
488
489        optimizer.register_content("QmHigh".to_string(), 1024 * 1024 * 100);
490        optimizer.register_content("QmLow".to_string(), 1024 * 1024 * 100);
491
492        // Simulate high activity for one content
493        for _ in 0..100 {
494            optimizer.record_request("QmHigh", 10);
495        }
496
497        let recommendations = optimizer.get_recommendations();
498        assert_eq!(recommendations.len(), 2);
499
500        // High activity content should have higher score
501        let high = recommendations.iter().find(|r| r.cid == "QmHigh").unwrap();
502        let low = recommendations.iter().find(|r| r.cid == "QmLow").unwrap();
503        assert!(high.score > low.score);
504    }
505
506    #[test]
507    fn test_pin_decision() {
508        let config = PinningConfig {
509            max_storage_bytes: 1024 * 1024 * 500,     // 500 MB
510            min_pin_duration: Duration::from_secs(0), // Allow immediate unpin for testing
511            ..Default::default()
512        };
513        let mut optimizer = PinningOptimizer::new(config);
514
515        optimizer.register_content("QmExisting".to_string(), 1024 * 1024 * 400);
516
517        // Should accept small content
518        let decision = optimizer.should_pin("QmNew1", 1024 * 1024 * 50, 1.0);
519        assert!(matches!(decision, PinDecision::Accept));
520
521        // Should require unpin for large content
522        let decision = optimizer.should_pin("QmNew2", 1024 * 1024 * 200, 1.0);
523        assert!(matches!(decision, PinDecision::PinAfterUnpin { .. }));
524    }
525}