1use std::collections::HashMap;
9use std::time::{Duration, Instant};
10
11#[derive(Debug, Clone)]
13pub struct PinningConfig {
14 pub max_storage_bytes: u64,
16 pub min_revenue_per_gb: f64,
18 pub popularity_weight: f64,
20 pub revenue_weight: f64,
22 pub freshness_weight: f64,
24 pub recalc_interval: Duration,
26 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, 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), min_pin_duration: Duration::from_secs(86400), }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct ContentMetrics {
47 pub cid: String,
49 pub size_bytes: u64,
51 pub total_requests: u64,
53 pub daily_requests: u64,
55 pub total_revenue: u64,
57 pub daily_revenue: u64,
59 pub pinned_at: Instant,
61 pub last_request: Option<Instant>,
63 pub demand_multiplier: f64,
65}
66
67impl ContentMetrics {
68 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 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 #[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 #[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 #[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#[derive(Debug, Clone)]
128pub struct ScoredContent {
129 pub cid: String,
131 pub size_bytes: u64,
133 pub score: f64,
135 pub components: ScoreComponents,
137 pub recommendation: PinRecommendation,
139}
140
141#[derive(Debug, Clone)]
143pub struct ScoreComponents {
144 pub popularity: f64,
146 pub revenue: f64,
148 pub freshness: f64,
150 pub demand: f64,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum PinRecommendation {
157 Keep,
159 Unpin,
161 Pin,
163 Evaluate,
165}
166
167pub struct PinningOptimizer {
169 config: PinningConfig,
170 content_metrics: HashMap<String, ContentMetrics>,
172 used_storage: u64,
174 #[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 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 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 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 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 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 fn calculate_score(&self, metrics: &ContentMetrics) -> (f64, ScoreComponents) {
229 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 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 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 let demand = (metrics.demand_multiplier - 1.0).max(0.0) / 2.0; let components = ScoreComponents {
266 popularity,
267 revenue,
268 freshness,
269 demand,
270 };
271
272 let score = (self.config.popularity_weight * popularity)
274 + (self.config.revenue_weight * revenue)
275 + (self.config.freshness_weight * freshness)
276 + (demand * 0.2); (score.clamp(0.0, 1.0), components)
279 }
280
281 #[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 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 #[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 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 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 #[must_use]
357 pub fn should_pin(&self, _cid: &str, size_bytes: u64, expected_demand: f64) -> PinDecision {
358 if self.used_storage + size_bytes > self.config.max_storage_bytes {
360 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 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 #[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 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#[derive(Debug, Clone)]
428pub enum PinDecision {
429 Accept,
431 PinAfterUnpin { unpin_cids: Vec<String> },
433 Evaluate { reason: String },
435 Reject { reason: String },
437}
438
439#[derive(Debug, Clone)]
441pub struct OptimizerStats {
442 pub total_content: usize,
444 pub used_storage: u64,
446 pub max_storage: u64,
448 pub storage_utilization: f64,
450 pub avg_score: f64,
452 pub keep_count: usize,
454 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); 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 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 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, min_pin_duration: Duration::from_secs(0), ..Default::default()
512 };
513 let mut optimizer = PinningOptimizer::new(config);
514
515 optimizer.register_content("QmExisting".to_string(), 1024 * 1024 * 400);
516
517 let decision = optimizer.should_pin("QmNew1", 1024 * 1024 * 50, 1.0);
519 assert!(matches!(decision, PinDecision::Accept));
520
521 let decision = optimizer.should_pin("QmNew2", 1024 * 1024 * 200, 1.0);
523 assert!(matches!(decision, PinDecision::PinAfterUnpin { .. }));
524 }
525}