1use alloc::string::String;
7use alloc::vec::Vec;
8
9use crate::recording::ProfileRecording;
10use crate::timeline::{LeaseTimeline, LeaseTimelineEntry};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum WasteClassification {
15 Overprovisioned,
17 Idle,
19 Fragmented,
21 PremiumWaste,
23 Healthy,
25}
26
27#[derive(Debug, Clone)]
29pub struct WasteEntry {
30 pub lease_id: u128,
32 pub classification: WasteClassification,
34 pub utilization_pct: f64,
36 pub active_pct: f64,
38 pub cost_byte_secs: u64,
40 pub waste_byte_secs: u64,
42 pub recommendation: String,
44}
45
46#[derive(Debug, Clone)]
48pub struct WasteReport {
49 pub entries: Vec<WasteEntry>,
51 pub total_waste_byte_secs: u64,
53 pub total_cost_byte_secs: u64,
55 pub fragmented_groups: usize,
57}
58
59const OVERPROVISIONED_THRESHOLD: f64 = 25.0;
61const IDLE_THRESHOLD: f64 = 10.0;
62
63impl WasteReport {
64 pub fn from_recording(rec: &ProfileRecording) -> Self {
66 let timeline = LeaseTimeline::from_recording(rec);
67 Self::from_timeline(&timeline, rec)
68 }
69
70 pub fn from_timeline(timeline: &LeaseTimeline, rec: &ProfileRecording) -> Self {
72 let mut entries = Vec::new();
73 let total_duration = timeline.end_time.saturating_sub(timeline.start_time);
74
75 for tl_entry in &timeline.entries {
76 let entry = classify_lease(tl_entry, total_duration, rec);
77 entries.push(entry);
78 }
79
80 let fragmented_groups = detect_fragmentation(&timeline.entries);
82
83 if fragmented_groups > 0 {
84 let avg_cost = if entries.is_empty() {
85 0
86 } else {
87 entries.iter().map(|e| e.cost_byte_secs).sum::<u64>() / entries.len() as u64
88 };
89 for entry in &mut entries {
90 if entry.classification == WasteClassification::Healthy
91 && avg_cost > 0
92 && entry.cost_byte_secs < avg_cost / 4
93 {
94 entry.classification = WasteClassification::Fragmented;
95 entry.recommendation = String::from(
96 "use a single lease with arena allocation instead of many small leases",
97 );
98 }
99 }
100 }
101
102 entries.sort_by(|a, b| b.waste_byte_secs.cmp(&a.waste_byte_secs));
103
104 let total_waste_byte_secs = entries.iter().map(|e| e.waste_byte_secs).sum();
105 let total_cost_byte_secs = entries.iter().map(|e| e.cost_byte_secs).sum();
106
107 WasteReport {
108 entries,
109 total_waste_byte_secs,
110 total_cost_byte_secs,
111 fragmented_groups,
112 }
113 }
114
115 #[cfg(feature = "std")]
117 pub fn render_text(&self) -> String {
118 let mut out = String::new();
119 out.push_str("grafOS Waste Report\n");
120 out.push_str(&alloc::format!(
121 "Total cost: {} byte-secs | Total waste: {} byte-secs ({:.1}%)\n\n",
122 self.total_cost_byte_secs,
123 self.total_waste_byte_secs,
124 if self.total_cost_byte_secs > 0 {
125 (self.total_waste_byte_secs as f64 / self.total_cost_byte_secs as f64) * 100.0
126 } else {
127 0.0
128 }
129 ));
130
131 out.push_str(&alloc::format!(
132 "{:<16} {:<18} {:>8} {:>8} {:>12} {}\n",
133 "Lease",
134 "Classification",
135 "Util%",
136 "Active%",
137 "Waste",
138 "Recommendation"
139 ));
140 out.push_str(&alloc::format!("{}\n", "-".repeat(100)));
141
142 let top_n = 10.min(self.entries.len());
143 for entry in &self.entries[..top_n] {
144 let lease_hex = alloc::format!("{:x}", entry.lease_id);
145 let short = if lease_hex.len() > 12 {
146 &lease_hex[..12]
147 } else {
148 &lease_hex
149 };
150 let class_str = match entry.classification {
151 WasteClassification::Overprovisioned => "Overprovisioned",
152 WasteClassification::Idle => "Idle",
153 WasteClassification::Fragmented => "Fragmented",
154 WasteClassification::PremiumWaste => "PremiumWaste",
155 WasteClassification::Healthy => "Healthy",
156 };
157 out.push_str(&alloc::format!(
158 "{:<16} {:<18} {:>7.1} {:>7.1} {:>12} {}\n",
159 short,
160 class_str,
161 entry.utilization_pct,
162 entry.active_pct,
163 entry.waste_byte_secs,
164 entry.recommendation
165 ));
166 }
167
168 out
169 }
170
171 #[cfg(feature = "html")]
173 pub fn render_html(&self) -> String {
174 let mut html = String::new();
175 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
176 html.push_str("<meta charset=\"utf-8\">\n");
177 html.push_str("<title>grafOS Waste Report</title>\n");
178 html.push_str("<style>\n");
179 html.push_str("body { font-family: monospace; margin: 0; padding: 20px; background: #1a1a2e; color: #eee; }\n");
180 html.push_str("h1 { font-size: 18px; }\n");
181 html.push_str("table { border-collapse: collapse; width: 100%; margin-top: 20px; }\n");
182 html.push_str(
183 "th, td { padding: 6px 12px; text-align: left; border-bottom: 1px solid #333; }\n",
184 );
185 html.push_str("th { background: #2a2a4a; }\n");
186 html.push_str("tr:hover { background: #2a2a3a; }\n");
187 html.push_str(".overprov { color: #e74c3c; }\n");
188 html.push_str(".idle { color: #f39c12; }\n");
189 html.push_str(".fragmented { color: #9b59b6; }\n");
190 html.push_str(".premium { color: #e67e22; }\n");
191 html.push_str(".healthy { color: #2ecc71; }\n");
192 html.push_str(".summary { margin: 15px 0; padding: 15px; background: #2a2a4a; border-radius: 6px; }\n");
193 html.push_str("</style>\n</head>\n<body>\n");
194 html.push_str("<h1>grafOS Waste Report</h1>\n");
195
196 let waste_pct = if self.total_cost_byte_secs > 0 {
197 (self.total_waste_byte_secs as f64 / self.total_cost_byte_secs as f64) * 100.0
198 } else {
199 0.0
200 };
201 html.push_str(&alloc::format!(
202 "<div class=\"summary\">Total cost: {} byte-secs | Waste: {} byte-secs ({:.1}%)</div>\n",
203 self.total_cost_byte_secs, self.total_waste_byte_secs, waste_pct
204 ));
205
206 html.push_str("<table>\n<tr><th>Lease</th><th>Classification</th><th>Util%</th><th>Active%</th><th>Waste</th><th>Recommendation</th></tr>\n");
207
208 for entry in &self.entries {
209 let lease_hex = alloc::format!("{:x}", entry.lease_id);
210 let (class_str, class_css) = match entry.classification {
211 WasteClassification::Overprovisioned => ("Overprovisioned", "overprov"),
212 WasteClassification::Idle => ("Idle", "idle"),
213 WasteClassification::Fragmented => ("Fragmented", "fragmented"),
214 WasteClassification::PremiumWaste => ("PremiumWaste", "premium"),
215 WasteClassification::Healthy => ("Healthy", "healthy"),
216 };
217 html.push_str(&alloc::format!(
218 "<tr><td>{}</td><td class=\"{}\">{}</td><td>{:.1}</td><td>{:.1}</td><td>{}</td><td>{}</td></tr>\n",
219 lease_hex, class_css, class_str,
220 entry.utilization_pct, entry.active_pct,
221 entry.waste_byte_secs, entry.recommendation
222 ));
223 }
224
225 html.push_str("</table>\n</body>\n</html>\n");
226 html
227 }
228}
229
230fn classify_lease(
231 entry: &LeaseTimelineEntry,
232 _total_duration: u64,
233 rec: &ProfileRecording,
234) -> WasteEntry {
235 let utilization_pct = entry.utilization_pct();
236 let duration = entry.duration_us();
237 let active_pct = compute_active_pct(entry.lease_id, duration, rec);
238
239 let waste_byte_secs = if entry.capacity_approx > entry.peak_usage {
240 let unused_bytes = entry.capacity_approx - entry.peak_usage;
241 let duration_secs = duration as f64 / 1_000_000.0;
242 (unused_bytes as f64 * duration_secs) as u64
243 } else {
244 0
245 };
246
247 let (classification, recommendation) =
248 if utilization_pct < OVERPROVISIONED_THRESHOLD && entry.capacity_approx > 0 {
249 let recommended = (entry.peak_usage as f64 * 1.2) as u64;
250 (
251 WasteClassification::Overprovisioned,
252 alloc::format!(
253 "reduce capacity to {} (peak {} + 20% headroom)",
254 format_bytes_short(recommended),
255 format_bytes_short(entry.peak_usage)
256 ),
257 )
258 } else if active_pct < IDLE_THRESHOLD && duration > 0 {
259 (
260 WasteClassification::Idle,
261 String::from("consider on-demand lease acquisition instead of long-lived lease"),
262 )
263 } else {
264 (WasteClassification::Healthy, String::new())
265 };
266
267 WasteEntry {
268 lease_id: entry.lease_id,
269 classification,
270 utilization_pct,
271 active_pct,
272 cost_byte_secs: entry.lease_cost_byte_secs,
273 waste_byte_secs,
274 recommendation,
275 }
276}
277
278fn compute_active_pct(lease_id: u128, lease_duration_us: u64, rec: &ProfileRecording) -> f64 {
279 if lease_duration_us == 0 {
280 return 100.0;
281 }
282 let active_us: u64 = rec
283 .spans
284 .iter()
285 .filter(|s| s.lease_ids.contains(&lease_id))
286 .map(|s| s.duration_us())
287 .sum();
288 let pct = (active_us as f64 / lease_duration_us as f64) * 100.0;
289 pct.min(100.0)
290}
291
292fn detect_fragmentation(entries: &[LeaseTimelineEntry]) -> usize {
293 use alloc::collections::BTreeMap;
294
295 let mut by_type: BTreeMap<&str, Vec<&LeaseTimelineEntry>> = BTreeMap::new();
296 for entry in entries {
297 let key = match entry.resource_type {
298 Some(grafos_observe::event::ResourceType::Mem) => "mem",
299 Some(grafos_observe::event::ResourceType::Block) => "block",
300 Some(grafos_observe::event::ResourceType::Gpu) => "gpu",
301 Some(grafos_observe::event::ResourceType::GpuMem) => "gpu_mem",
302 Some(grafos_observe::event::ResourceType::Cpu) => "cpu",
303 Some(grafos_observe::event::ResourceType::Net) => "net",
304 None => "unknown",
305 };
306 by_type.entry(key).or_default().push(entry);
307 }
308
309 let mut groups = 0;
310 for leases in by_type.values() {
311 if leases.len() > 4 {
312 let any_overlap = leases.windows(2).any(|w| {
313 w[0].released_at > w[1].acquired_at && w[0].acquired_at < w[1].released_at
314 });
315 if any_overlap {
316 groups += 1;
317 }
318 }
319 }
320 groups
321}
322
323fn format_bytes_short(bytes: u64) -> String {
324 if bytes >= 1_073_741_824 {
325 alloc::format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
326 } else if bytes >= 1_048_576 {
327 alloc::format!("{:.1} MB", bytes as f64 / 1_048_576.0)
328 } else if bytes >= 1024 {
329 alloc::format!("{:.1} KB", bytes as f64 / 1024.0)
330 } else {
331 alloc::format!("{} B", bytes)
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use grafos_observe::event::{OpType, ResourceType};
339 use grafos_observe::span::ResourceSpan;
340 use grafos_observe::trace::TraceContext;
341
342 fn test_ctx() -> TraceContext {
343 let mut bytes = [0u8; 24];
344 for (i, b) in bytes.iter_mut().enumerate() {
345 *b = (i as u8).wrapping_add(0x42);
346 }
347 TraceContext::new_root(&bytes)
348 }
349
350 #[test]
351 fn empty_recording_no_waste() {
352 let rec = ProfileRecording::from_spans(Vec::new());
353 let report = WasteReport::from_recording(&rec);
354 assert_eq!(report.total_waste_byte_secs, 0);
355 assert!(report.entries.is_empty());
356 }
357
358 #[test]
359 fn overprovisioned_lease() {
360 let ctx = test_ctx();
361
362 let mut span = ResourceSpan::new("big_alloc", ctx);
364 span.start_time_unix_us = 0;
365 span.end_time_unix_us = 10_000_000; span.add_lease_id(1);
367 span.bytes_read = 100; span.lease_cost_byte_secs = 10_000; span.record_op(ResourceType::Mem, OpType::Read, 1);
370
371 let rec = ProfileRecording::from_spans(vec![span]);
372 let report = WasteReport::from_recording(&rec);
373
374 assert_eq!(report.entries.len(), 1);
375 let entry = &report.entries[0];
376 assert_eq!(entry.classification, WasteClassification::Overprovisioned);
377 assert!(entry.utilization_pct < OVERPROVISIONED_THRESHOLD);
378 assert!(entry.recommendation.contains("reduce capacity"));
379 }
380
381 #[test]
382 fn idle_lease_detected() {
383 let ctx = test_ctx();
384 let mut span = ResourceSpan::new("idle_op", ctx);
385 span.start_time_unix_us = 1_000_000;
386 span.end_time_unix_us = 2_000_000;
387 span.lease_cost_byte_secs = 4096;
388 span.add_lease_id(1);
389 let rec = ProfileRecording::from_spans(vec![span]);
394 let report = WasteReport::from_recording(&rec);
395 assert_eq!(report.entries.len(), 1);
398 }
399
400 #[test]
401 fn active_lease_no_waste() {
402 let ctx = test_ctx();
403 let mut span = ResourceSpan::new("active_op", ctx);
404 span.start_time_unix_us = 0;
405 span.end_time_unix_us = 1_000_000;
406 span.add_lease_id(1);
407 span.bytes_read = 5000;
408 span.bytes_written = 5000;
409 span.lease_cost_byte_secs = 10_000;
410 span.record_op(ResourceType::Mem, OpType::Read, 100);
411
412 let rec = ProfileRecording::from_spans(vec![span]);
413 let report = WasteReport::from_recording(&rec);
414
415 assert_eq!(report.entries.len(), 1);
416 assert_eq!(
417 report.entries[0].classification,
418 WasteClassification::Healthy
419 );
420 }
421
422 #[cfg(feature = "std")]
423 #[test]
424 fn text_output() {
425 let ctx = test_ctx();
426 let mut span = ResourceSpan::new("test", ctx);
427 span.start_time_unix_us = 0;
428 span.end_time_unix_us = 1_000_000;
429 span.add_lease_id(1);
430 span.lease_cost_byte_secs = 100;
431 span.record_op(ResourceType::Mem, OpType::Read, 1);
432
433 let rec = ProfileRecording::from_spans(vec![span]);
434 let report = WasteReport::from_recording(&rec);
435 let text = report.render_text();
436
437 assert!(text.contains("Waste Report"));
438 assert!(text.contains("Lease"));
439 }
440
441 #[cfg(feature = "html")]
442 #[test]
443 fn html_output() {
444 let ctx = test_ctx();
445 let mut span = ResourceSpan::new("test", ctx);
446 span.start_time_unix_us = 0;
447 span.end_time_unix_us = 1_000_000;
448 span.add_lease_id(1);
449 span.lease_cost_byte_secs = 100;
450
451 let rec = ProfileRecording::from_spans(vec![span]);
452 let report = WasteReport::from_recording(&rec);
453 let html = report.render_html();
454
455 assert!(html.contains("<!DOCTYPE html>"));
456 assert!(html.contains("Waste Report"));
457 assert!(html.contains("</html>"));
458 }
459}