grafos_profile/
sensitivity.rs

1//! Sensitivity inference — generate a [`WorkloadProfile`] from observed behavior.
2//!
3//! For each resource dimension, computes an activity score based on:
4//! - `cost_fraction`: this resource type's lease-cost / total lease-cost
5//! - `acquire_wait_fraction`: this resource type's acquire wait / total acquire wait
6//!
7//! Maps scores to sensitivity levels:
8//! - cost_fraction > 0.5 OR acquire_wait_fraction > 0.3 -> Critical
9//! - cost_fraction > 0.2 -> Moderate
10//! - cost_fraction > 0.05 -> Low
11//! - cost_fraction > 0 -> Indifferent
12//! - cost_fraction == 0 -> Unused
13
14use alloc::string::String;
15use alloc::vec::Vec;
16
17use grafos_observe::event::ResourceType;
18
19use crate::recording::ProfileRecording;
20use crate::summary::ProfileSummary;
21
22/// Sensitivity level for a resource dimension.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum SensitivityLevel {
25    /// Not used at all.
26    Unused,
27    /// Used but not sensitive to quality.
28    Indifferent,
29    /// Low sensitivity.
30    Low,
31    /// Moderate sensitivity.
32    Moderate,
33    /// Critical — dominant resource dimension.
34    Critical,
35}
36
37impl core::fmt::Display for SensitivityLevel {
38    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39        match self {
40            SensitivityLevel::Unused => write!(f, "unused"),
41            SensitivityLevel::Indifferent => write!(f, "indifferent"),
42            SensitivityLevel::Low => write!(f, "low"),
43            SensitivityLevel::Moderate => write!(f, "moderate"),
44            SensitivityLevel::Critical => write!(f, "critical"),
45        }
46    }
47}
48
49/// Sensitivity score for a single resource dimension.
50#[derive(Debug, Clone)]
51pub struct DimensionScore {
52    /// Resource type (Mem, Block, Gpu, Cpu).
53    pub resource_type: ResourceType,
54    /// Fraction of total lease cost attributable to this type.
55    pub cost_fraction: f64,
56    /// Fraction of total acquire wait attributable to this type.
57    pub acquire_wait_fraction: f64,
58    /// Operations per second for this resource type.
59    pub ops_per_sec: f64,
60    /// Bytes per second for this resource type.
61    pub bytes_per_sec: f64,
62    /// Inferred sensitivity level.
63    pub level: SensitivityLevel,
64}
65
66/// Workload sensitivity profile inferred from observed behavior.
67#[derive(Debug, Clone)]
68pub struct WorkloadProfile {
69    /// Per-dimension scores.
70    pub dimensions: Vec<DimensionScore>,
71    /// Total recording duration in microseconds.
72    pub total_duration_us: u64,
73    /// Total lease cost (byte-seconds).
74    pub total_lease_cost: u64,
75}
76
77impl WorkloadProfile {
78    /// Get the sensitivity level for a specific resource type.
79    pub fn level_for(&self, rt: ResourceType) -> SensitivityLevel {
80        self.dimensions
81            .iter()
82            .find(|d| d.resource_type == rt)
83            .map(|d| d.level)
84            .unwrap_or(SensitivityLevel::Unused)
85    }
86
87    /// Render a text summary of the profile.
88    pub fn render_text(&self) -> String {
89        let mut out = String::new();
90        out.push_str("Workload Sensitivity Profile\n\n");
91
92        for dim in &self.dimensions {
93            out.push_str(&alloc::format!(
94                "  {}: {} (cost: {:.1}%, wait: {:.1}%, ops/s: {:.1}, bytes/s: {:.1})\n",
95                dim.resource_type,
96                dim.level,
97                dim.cost_fraction * 100.0,
98                dim.acquire_wait_fraction * 100.0,
99                dim.ops_per_sec,
100                dim.bytes_per_sec,
101            ));
102        }
103
104        // Generate summary sentence
105        let critical: Vec<_> = self
106            .dimensions
107            .iter()
108            .filter(|d| d.level == SensitivityLevel::Critical)
109            .collect();
110        let indifferent: Vec<_> = self
111            .dimensions
112            .iter()
113            .filter(|d| d.level == SensitivityLevel::Indifferent)
114            .collect();
115
116        if !critical.is_empty() {
117            out.push_str(&alloc::format!(
118                "\nThis workload is {}-critical ({:.0}% of lease-cost)",
119                critical[0].resource_type,
120                critical[0].cost_fraction * 100.0,
121            ));
122            for d in &indifferent {
123                out.push_str(&alloc::format!(
124                    ", {}-indifferent ({:.0}%)",
125                    d.resource_type,
126                    d.cost_fraction * 100.0,
127                ));
128            }
129            out.push_str(".\n");
130        }
131
132        out
133    }
134}
135
136/// Infer a sensitivity profile from a recording.
137pub fn infer_sensitivity(rec: &ProfileRecording) -> WorkloadProfile {
138    if rec.spans.is_empty() {
139        return WorkloadProfile {
140            dimensions: Vec::new(),
141            total_duration_us: 0,
142            total_lease_cost: 0,
143        };
144    }
145
146    let summary = ProfileSummary::from_recording(rec);
147    let total_cost = summary.total_lease_cost_byte_secs;
148    let total_duration_us = summary.total_duration_us;
149    let duration_secs = total_duration_us as f64 / 1_000_000.0;
150
151    let total_acquire_wait: u64 = rec.spans.iter().map(|s| s.lease_acquire_wait_us).sum();
152
153    let resource_types = [
154        ResourceType::Mem,
155        ResourceType::Block,
156        ResourceType::Gpu,
157        ResourceType::Cpu,
158        ResourceType::Net,
159    ];
160
161    let mut dimensions = Vec::new();
162
163    for &rt in &resource_types {
164        let key = alloc::format!("{}", rt);
165        let type_summary = summary.by_resource_type.get(&key);
166
167        let type_cost = type_summary
168            .map(|s| s.total_lease_cost_byte_secs)
169            .unwrap_or(0);
170        let type_ops = type_summary.map(|s| s.total_ops).unwrap_or(0);
171        let type_bytes_read = type_summary.map(|s| s.total_bytes_read).unwrap_or(0);
172        let type_bytes_written = type_summary.map(|s| s.total_bytes_written).unwrap_or(0);
173        let type_wait = type_summary.map(|s| s.total_acquire_wait_us).unwrap_or(0);
174
175        let cost_fraction = if total_cost > 0 {
176            type_cost as f64 / total_cost as f64
177        } else {
178            0.0
179        };
180
181        let acquire_wait_fraction = if total_acquire_wait > 0 {
182            type_wait as f64 / total_acquire_wait as f64
183        } else {
184            0.0
185        };
186
187        let ops_per_sec = if duration_secs > 0.0 {
188            type_ops as f64 / duration_secs
189        } else {
190            0.0
191        };
192
193        let bytes_per_sec = if duration_secs > 0.0 {
194            (type_bytes_read + type_bytes_written) as f64 / duration_secs
195        } else {
196            0.0
197        };
198
199        let level = if cost_fraction > 0.5 || acquire_wait_fraction > 0.3 {
200            SensitivityLevel::Critical
201        } else if cost_fraction > 0.2 {
202            SensitivityLevel::Moderate
203        } else if cost_fraction > 0.05 {
204            SensitivityLevel::Low
205        } else if cost_fraction > 0.0 {
206            SensitivityLevel::Indifferent
207        } else {
208            SensitivityLevel::Unused
209        };
210
211        dimensions.push(DimensionScore {
212            resource_type: rt,
213            cost_fraction,
214            acquire_wait_fraction,
215            ops_per_sec,
216            bytes_per_sec,
217            level,
218        });
219    }
220
221    WorkloadProfile {
222        dimensions,
223        total_duration_us,
224        total_lease_cost: total_cost,
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use grafos_observe::event::OpType;
232    use grafos_observe::span::ResourceSpan;
233    use grafos_observe::trace::TraceContext;
234
235    fn test_ctx() -> TraceContext {
236        let mut bytes = [0u8; 24];
237        for (i, b) in bytes.iter_mut().enumerate() {
238            *b = (i as u8).wrapping_add(0x42);
239        }
240        TraceContext::new_root(&bytes)
241    }
242
243    #[test]
244    fn empty_recording() {
245        let rec = ProfileRecording::from_spans(Vec::new());
246        let profile = infer_sensitivity(&rec);
247        assert!(profile.dimensions.is_empty());
248        assert_eq!(profile.total_lease_cost, 0);
249    }
250
251    #[test]
252    fn memory_critical_block_indifferent() {
253        let ctx = test_ctx();
254        let c1 = ctx.child(&[0xAA; 8]);
255
256        // Memory-heavy span: 80% of cost
257        let mut mem_span = ResourceSpan::new("mem_heavy", ctx);
258        mem_span.start_time_unix_us = 0;
259        mem_span.end_time_unix_us = 1_000_000;
260        mem_span.lease_cost_byte_secs = 800;
261        mem_span.bytes_read = 100_000;
262        mem_span.record_op(ResourceType::Mem, OpType::Read, 1000);
263
264        // Block-light span: 2% of cost
265        let mut block_span = ResourceSpan::new("block_light", c1);
266        block_span.start_time_unix_us = 0;
267        block_span.end_time_unix_us = 1_000_000;
268        block_span.lease_cost_byte_secs = 20;
269        block_span.bytes_read = 100;
270        block_span.record_op(ResourceType::Block, OpType::ReadBlock, 2);
271
272        let rec = ProfileRecording::from_spans(vec![mem_span, block_span]);
273        let profile = infer_sensitivity(&rec);
274
275        assert_eq!(
276            profile.level_for(ResourceType::Mem),
277            SensitivityLevel::Critical
278        );
279        assert_eq!(
280            profile.level_for(ResourceType::Block),
281            SensitivityLevel::Indifferent
282        );
283        assert_eq!(
284            profile.level_for(ResourceType::Gpu),
285            SensitivityLevel::Unused
286        );
287    }
288
289    #[test]
290    fn moderate_sensitivity() {
291        let ctx = test_ctx();
292        let c1 = ctx.child(&[0xAA; 8]);
293
294        // Mem: 40% of cost -> moderate
295        let mut mem_span = ResourceSpan::new("mem_op", ctx);
296        mem_span.start_time_unix_us = 0;
297        mem_span.end_time_unix_us = 1_000_000;
298        mem_span.lease_cost_byte_secs = 400;
299        mem_span.record_op(ResourceType::Mem, OpType::Read, 100);
300
301        // Block: 60% of cost -> critical
302        let mut block_span = ResourceSpan::new("block_op", c1);
303        block_span.start_time_unix_us = 0;
304        block_span.end_time_unix_us = 1_000_000;
305        block_span.lease_cost_byte_secs = 600;
306        block_span.record_op(ResourceType::Block, OpType::ReadBlock, 100);
307
308        let rec = ProfileRecording::from_spans(vec![mem_span, block_span]);
309        let profile = infer_sensitivity(&rec);
310
311        assert_eq!(
312            profile.level_for(ResourceType::Mem),
313            SensitivityLevel::Moderate
314        );
315        assert_eq!(
316            profile.level_for(ResourceType::Block),
317            SensitivityLevel::Critical
318        );
319    }
320
321    #[test]
322    fn text_summary() {
323        let ctx = test_ctx();
324        let mut span = ResourceSpan::new("mem_op", ctx);
325        span.start_time_unix_us = 0;
326        span.end_time_unix_us = 1_000_000;
327        span.lease_cost_byte_secs = 1000;
328        span.record_op(ResourceType::Mem, OpType::Read, 100);
329
330        let rec = ProfileRecording::from_spans(vec![span]);
331        let profile = infer_sensitivity(&rec);
332        let text = profile.render_text();
333
334        assert!(text.contains("Workload Sensitivity Profile"));
335        assert!(text.contains("mem: critical"));
336    }
337}