Skip to main content

ipfrs_tensorlogic/
rule_profiler.rs

1//! Rule Execution Profiler — invocation counts, latencies, hit rates, and hotspot detection.
2//!
3//! This module provides [`RuleExecutionProfiler`], which tracks per-rule performance
4//! metrics across a running inference engine: invocation counts, success/failure rates,
5//! cumulative and per-call latencies, and automatic hotspot detection.
6
7use std::collections::HashMap;
8
9// ─── RuleProfile ─────────────────────────────────────────────────────────────
10
11/// Per-rule execution statistics.
12#[derive(Debug, Clone)]
13pub struct RuleProfile {
14    /// Stable numeric identifier for the rule.
15    pub rule_id: u64,
16    /// Human-readable rule name.
17    pub rule_name: String,
18    /// Total number of times the rule was attempted.
19    pub invocations: u64,
20    /// Number of times the rule fired and produced at least one binding.
21    pub successes: u64,
22    /// Number of times the rule was tried but did not match.
23    pub failures: u64,
24    /// Cumulative wall-clock time spent in this rule (microseconds).
25    pub total_time_us: u64,
26    /// Minimum single-invocation time observed (microseconds).
27    pub min_time_us: u64,
28    /// Maximum single-invocation time observed (microseconds).
29    pub max_time_us: u64,
30}
31
32impl RuleProfile {
33    /// Fraction of invocations that resulted in a successful match.
34    ///
35    /// Returns `0.0` when `invocations == 0` (avoids division by zero).
36    #[inline]
37    pub fn success_rate(&self) -> f64 {
38        self.successes as f64 / self.invocations.max(1) as f64
39    }
40
41    /// Average time per invocation in microseconds.
42    ///
43    /// Returns `0.0` when `invocations == 0`.
44    #[inline]
45    pub fn avg_time_us(&self) -> f64 {
46        self.total_time_us as f64 / self.invocations.max(1) as f64
47    }
48
49    /// Returns `true` when the average invocation time exceeds `threshold_us`.
50    #[inline]
51    pub fn is_hotspot(&self, threshold_us: u64) -> bool {
52        self.avg_time_us() > threshold_us as f64
53    }
54}
55
56// ─── ProfilerStats ───────────────────────────────────────────────────────────
57
58/// Aggregate statistics across all tracked rules.
59#[derive(Debug, Clone)]
60pub struct ProfilerStats {
61    /// Number of distinct rules being tracked.
62    pub total_rules: usize,
63    /// Sum of invocations across all rules.
64    pub total_invocations: u64,
65    /// Sum of wall-clock time across all rules (microseconds).
66    pub total_time_us: u64,
67    /// Number of rules currently classified as hotspots.
68    pub hotspot_count: usize,
69}
70
71impl ProfilerStats {
72    /// Mean success rate across the supplied rule profiles.
73    ///
74    /// Returns `0.0` when `profiles` is empty.
75    pub fn avg_success_rate(&self, profiles: &[RuleProfile]) -> f64 {
76        if profiles.is_empty() {
77            return 0.0;
78        }
79        let sum: f64 = profiles.iter().map(|p| p.success_rate()).sum();
80        sum / profiles.len() as f64
81    }
82}
83
84// ─── RuleExecutionProfiler ───────────────────────────────────────────────────
85
86/// Profiles individual rule execution performance.
87///
88/// Tracks invocation counts, latencies, hit rates, and hotspot detection for
89/// every rule that passes through [`record_invocation`](RuleExecutionProfiler::record_invocation).
90///
91/// # Example
92///
93/// ```
94/// use ipfrs_tensorlogic::rule_profiler::RuleExecutionProfiler;
95///
96/// let mut profiler = RuleExecutionProfiler::new(1_000);
97/// profiler.record_invocation(1, "ancestor", 500, true);
98/// profiler.record_invocation(1, "ancestor", 1_500, false);
99///
100/// let hotspots = profiler.hotspots();
101/// // avg = (500 + 1500) / 2 = 1000, threshold = 1000 → NOT > threshold
102/// assert!(hotspots.is_empty());
103/// ```
104pub struct RuleExecutionProfiler {
105    /// Per-rule profiles indexed by `rule_id`.
106    pub profiles: HashMap<u64, RuleProfile>,
107    /// Average time above which a rule is considered a hotspot (microseconds).
108    pub hotspot_threshold_us: u64,
109}
110
111impl RuleExecutionProfiler {
112    /// Creates a new profiler with the specified hotspot threshold.
113    ///
114    /// A rule is a hotspot when its `avg_time_us()` **strictly exceeds**
115    /// `hotspot_threshold_us`.
116    pub fn new(hotspot_threshold_us: u64) -> Self {
117        Self {
118            profiles: HashMap::new(),
119            hotspot_threshold_us,
120        }
121    }
122
123    /// Records a single rule invocation.
124    ///
125    /// # Parameters
126    ///
127    /// - `rule_id`   — stable numeric identifier for the rule
128    /// - `rule_name` — human-readable label (used only on first insertion)
129    /// - `time_us`   — wall-clock duration of this invocation in microseconds
130    /// - `success`   — `true` if the rule produced at least one binding
131    pub fn record_invocation(
132        &mut self,
133        rule_id: u64,
134        rule_name: &str,
135        time_us: u64,
136        success: bool,
137    ) {
138        let profile = self.profiles.entry(rule_id).or_insert_with(|| RuleProfile {
139            rule_id,
140            rule_name: rule_name.to_owned(),
141            invocations: 0,
142            successes: 0,
143            failures: 0,
144            total_time_us: 0,
145            // Sentinel: will be lowered to the first observed value on the first update.
146            min_time_us: u64::MAX,
147            max_time_us: 0,
148        });
149
150        profile.invocations += 1;
151        if success {
152            profile.successes += 1;
153        } else {
154            profile.failures += 1;
155        }
156        profile.total_time_us += time_us;
157        profile.min_time_us = profile.min_time_us.min(time_us);
158        profile.max_time_us = profile.max_time_us.max(time_us);
159    }
160
161    /// Returns all rules whose average invocation time exceeds the hotspot
162    /// threshold, sorted by `avg_time_us` in **descending** order.
163    pub fn hotspots(&self) -> Vec<&RuleProfile> {
164        let threshold = self.hotspot_threshold_us;
165        let mut result: Vec<&RuleProfile> = self
166            .profiles
167            .values()
168            .filter(|p| p.is_hotspot(threshold))
169            .collect();
170        result.sort_by(|a, b| {
171            b.avg_time_us()
172                .partial_cmp(&a.avg_time_us())
173                .unwrap_or(std::cmp::Ordering::Equal)
174        });
175        result
176    }
177
178    /// Returns up to `n` rules ordered by **total invocation count** (highest first).
179    pub fn top_rules_by_invocations(&self, n: usize) -> Vec<&RuleProfile> {
180        let mut result: Vec<&RuleProfile> = self.profiles.values().collect();
181        result.sort_by_key(|b| std::cmp::Reverse(b.invocations));
182        result.truncate(n);
183        result
184    }
185
186    /// Returns up to `n` rules ordered by **average time per invocation** (highest first).
187    pub fn slowest_rules(&self, n: usize) -> Vec<&RuleProfile> {
188        let mut result: Vec<&RuleProfile> = self.profiles.values().collect();
189        result.sort_by(|a, b| {
190            b.avg_time_us()
191                .partial_cmp(&a.avg_time_us())
192                .unwrap_or(std::cmp::Ordering::Equal)
193        });
194        result.truncate(n);
195        result
196    }
197
198    /// Removes the profile for `rule_id`.
199    ///
200    /// Returns `true` if the rule was present and has been removed, `false`
201    /// if no profile existed for that id.
202    pub fn reset_rule(&mut self, rule_id: u64) -> bool {
203        self.profiles.remove(&rule_id).is_some()
204    }
205
206    /// Returns aggregate statistics for all currently tracked rules.
207    pub fn stats(&self) -> ProfilerStats {
208        let threshold = self.hotspot_threshold_us;
209        let total_invocations = self.profiles.values().map(|p| p.invocations).sum();
210        let total_time_us = self.profiles.values().map(|p| p.total_time_us).sum();
211        let hotspot_count = self
212            .profiles
213            .values()
214            .filter(|p| p.is_hotspot(threshold))
215            .count();
216
217        ProfilerStats {
218            total_rules: self.profiles.len(),
219            total_invocations,
220            total_time_us,
221            hotspot_count,
222        }
223    }
224}
225
226// ─── Tests ───────────────────────────────────────────────────────────────────
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    // ── helpers ──────────────────────────────────────────────────────────────
233
234    fn make_profiler() -> RuleExecutionProfiler {
235        RuleExecutionProfiler::new(1_000)
236    }
237
238    // ── test 1: record_invocation creates a profile ───────────────────────────
239    #[test]
240    fn test_record_creates_profile() {
241        let mut p = make_profiler();
242        p.record_invocation(42, "ancestor", 200, true);
243        assert!(p.profiles.contains_key(&42));
244        let prof = &p.profiles[&42];
245        assert_eq!(prof.rule_id, 42);
246        assert_eq!(prof.rule_name, "ancestor");
247    }
248
249    // ── test 2: invocation count increments correctly ─────────────────────────
250    #[test]
251    fn test_invocation_count() {
252        let mut p = make_profiler();
253        p.record_invocation(1, "rule_a", 100, true);
254        p.record_invocation(1, "rule_a", 200, false);
255        p.record_invocation(1, "rule_a", 300, true);
256        assert_eq!(p.profiles[&1].invocations, 3);
257    }
258
259    // ── test 3: success tracking ──────────────────────────────────────────────
260    #[test]
261    fn test_success_tracking() {
262        let mut p = make_profiler();
263        p.record_invocation(2, "rule_b", 100, true);
264        p.record_invocation(2, "rule_b", 100, true);
265        p.record_invocation(2, "rule_b", 100, false);
266        assert_eq!(p.profiles[&2].successes, 2);
267    }
268
269    // ── test 4: failure tracking ──────────────────────────────────────────────
270    #[test]
271    fn test_failure_tracking() {
272        let mut p = make_profiler();
273        p.record_invocation(3, "rule_c", 50, false);
274        p.record_invocation(3, "rule_c", 50, false);
275        assert_eq!(p.profiles[&3].failures, 2);
276    }
277
278    // ── test 5: min time update ───────────────────────────────────────────────
279    #[test]
280    fn test_min_time_update() {
281        let mut p = make_profiler();
282        p.record_invocation(4, "rule_d", 500, true);
283        p.record_invocation(4, "rule_d", 100, true);
284        p.record_invocation(4, "rule_d", 300, false);
285        assert_eq!(p.profiles[&4].min_time_us, 100);
286    }
287
288    // ── test 6: max time update ───────────────────────────────────────────────
289    #[test]
290    fn test_max_time_update() {
291        let mut p = make_profiler();
292        p.record_invocation(5, "rule_e", 200, true);
293        p.record_invocation(5, "rule_e", 800, false);
294        p.record_invocation(5, "rule_e", 50, true);
295        assert_eq!(p.profiles[&5].max_time_us, 800);
296    }
297
298    // ── test 7: min_time_us initialized to u64::MAX then lowered ─────────────
299    #[test]
300    fn test_min_time_initialized_correctly() {
301        let mut p = make_profiler();
302        // First call — min must equal exactly the first time_us.
303        p.record_invocation(6, "rule_f", 777, true);
304        assert_eq!(p.profiles[&6].min_time_us, 777);
305    }
306
307    // ── test 8: avg_time_us correctness ──────────────────────────────────────
308    #[test]
309    fn test_avg_time_us() {
310        let mut p = make_profiler();
311        p.record_invocation(7, "rule_g", 200, true);
312        p.record_invocation(7, "rule_g", 400, false);
313        // avg = 600 / 2 = 300.0
314        let avg = p.profiles[&7].avg_time_us();
315        assert!((avg - 300.0).abs() < f64::EPSILON);
316    }
317
318    // ── test 9: avg_time_us with zero invocations ─────────────────────────────
319    #[test]
320    fn test_avg_time_us_zero_invocations() {
321        let prof = RuleProfile {
322            rule_id: 99,
323            rule_name: "ghost".to_owned(),
324            invocations: 0,
325            successes: 0,
326            failures: 0,
327            total_time_us: 0,
328            min_time_us: u64::MAX,
329            max_time_us: 0,
330        };
331        assert_eq!(prof.avg_time_us(), 0.0);
332    }
333
334    // ── test 10: success_rate correctness ─────────────────────────────────────
335    #[test]
336    fn test_success_rate() {
337        let mut p = make_profiler();
338        p.record_invocation(8, "rule_h", 100, true);
339        p.record_invocation(8, "rule_h", 100, false);
340        p.record_invocation(8, "rule_h", 100, false);
341        p.record_invocation(8, "rule_h", 100, true);
342        // 2 successes / 4 = 0.5
343        let rate = p.profiles[&8].success_rate();
344        assert!((rate - 0.5).abs() < f64::EPSILON);
345    }
346
347    // ── test 11: is_hotspot ───────────────────────────────────────────────────
348    #[test]
349    fn test_is_hotspot() {
350        let mut p = make_profiler(); // threshold = 1000
351                                     // avg = 2000 → hotspot
352        p.record_invocation(9, "slow_rule", 2_000, true);
353        assert!(p.profiles[&9].is_hotspot(1_000));
354        // avg = 500 → not a hotspot
355        let mut p2 = make_profiler();
356        p2.record_invocation(10, "fast_rule", 500, true);
357        assert!(!p2.profiles[&10].is_hotspot(1_000));
358    }
359
360    // ── test 12: hotspots returns correct rules sorted desc ───────────────────
361    #[test]
362    fn test_hotspots_sorted_desc() {
363        let mut p = RuleExecutionProfiler::new(500);
364        // avg = 1500 (hotspot)
365        p.record_invocation(1, "hot_a", 1_500, true);
366        // avg = 2500 (hotspot, slower)
367        p.record_invocation(2, "hot_b", 2_500, true);
368        // avg = 300 (not hotspot)
369        p.record_invocation(3, "cold", 300, false);
370
371        let hs = p.hotspots();
372        assert_eq!(hs.len(), 2);
373        // Sorted descending by avg_time_us: hot_b (2500) first
374        assert_eq!(hs[0].rule_id, 2);
375        assert_eq!(hs[1].rule_id, 1);
376    }
377
378    // ── test 13: top_rules_by_invocations ────────────────────────────────────
379    #[test]
380    fn test_top_rules_by_invocations() {
381        let mut p = make_profiler();
382        for _ in 0..5 {
383            p.record_invocation(1, "frequent", 10, true);
384        }
385        for _ in 0..2 {
386            p.record_invocation(2, "infrequent", 10, true);
387        }
388        for _ in 0..8 {
389            p.record_invocation(3, "most_frequent", 10, true);
390        }
391
392        let top = p.top_rules_by_invocations(2);
393        assert_eq!(top.len(), 2);
394        assert_eq!(top[0].rule_id, 3); // 8 invocations
395        assert_eq!(top[1].rule_id, 1); // 5 invocations
396    }
397
398    // ── test 14: slowest_rules ────────────────────────────────────────────────
399    #[test]
400    fn test_slowest_rules() {
401        let mut p = make_profiler();
402        p.record_invocation(1, "fast", 100, true);
403        p.record_invocation(2, "medium", 500, true);
404        p.record_invocation(3, "slow", 3_000, true);
405
406        let slowest = p.slowest_rules(2);
407        assert_eq!(slowest.len(), 2);
408        assert_eq!(slowest[0].rule_id, 3);
409        assert_eq!(slowest[1].rule_id, 2);
410    }
411
412    // ── test 15: reset_rule returns true when rule existed ────────────────────
413    #[test]
414    fn test_reset_rule_existing() {
415        let mut p = make_profiler();
416        p.record_invocation(11, "rule_to_reset", 200, true);
417        let removed = p.reset_rule(11);
418        assert!(removed);
419        assert!(!p.profiles.contains_key(&11));
420    }
421
422    // ── test 16: reset_rule returns false when rule absent ────────────────────
423    #[test]
424    fn test_reset_rule_absent() {
425        let mut p = make_profiler();
426        assert!(!p.reset_rule(999));
427    }
428
429    // ── test 17: stats.total_invocations ─────────────────────────────────────
430    #[test]
431    fn test_stats_total_invocations() {
432        let mut p = make_profiler();
433        p.record_invocation(1, "r1", 100, true);
434        p.record_invocation(1, "r1", 200, false);
435        p.record_invocation(2, "r2", 300, true);
436        let stats = p.stats();
437        assert_eq!(stats.total_invocations, 3);
438        assert_eq!(stats.total_rules, 2);
439    }
440
441    // ── test 18: stats.total_time_us ─────────────────────────────────────────
442    #[test]
443    fn test_stats_total_time_us() {
444        let mut p = make_profiler();
445        p.record_invocation(1, "r1", 400, true);
446        p.record_invocation(2, "r2", 600, false);
447        let stats = p.stats();
448        assert_eq!(stats.total_time_us, 1_000);
449    }
450
451    // ── test 19: stats.hotspot_count ─────────────────────────────────────────
452    #[test]
453    fn test_stats_hotspot_count() {
454        let mut p = RuleExecutionProfiler::new(1_000);
455        p.record_invocation(1, "slow", 2_000, true); // hotspot
456        p.record_invocation(2, "fast", 500, true); // not hotspot
457        let stats = p.stats();
458        assert_eq!(stats.hotspot_count, 1);
459    }
460
461    // ── test 20: avg_success_rate ─────────────────────────────────────────────
462    #[test]
463    fn test_avg_success_rate() {
464        let mut p = make_profiler();
465        // rule 1: 1/2 = 0.5
466        p.record_invocation(1, "r1", 100, true);
467        p.record_invocation(1, "r1", 100, false);
468        // rule 2: 2/2 = 1.0
469        p.record_invocation(2, "r2", 100, true);
470        p.record_invocation(2, "r2", 100, true);
471
472        let profiles: Vec<RuleProfile> = p.profiles.values().cloned().collect();
473        let stats = p.stats();
474        // avg = (0.5 + 1.0) / 2 = 0.75
475        let avg_rate = stats.avg_success_rate(&profiles);
476        assert!((avg_rate - 0.75).abs() < 1e-10);
477    }
478
479    // ── test 21: avg_success_rate with empty slice ────────────────────────────
480    #[test]
481    fn test_avg_success_rate_empty() {
482        let p = make_profiler();
483        let stats = p.stats();
484        assert_eq!(stats.avg_success_rate(&[]), 0.0);
485    }
486
487    // ── test 22: multiple rules isolated from each other ─────────────────────
488    #[test]
489    fn test_multiple_rules_isolated() {
490        let mut p = make_profiler();
491        p.record_invocation(10, "rule_x", 1_000, true);
492        p.record_invocation(20, "rule_y", 2_000, false);
493
494        assert_eq!(p.profiles[&10].invocations, 1);
495        assert_eq!(p.profiles[&20].invocations, 1);
496        assert_eq!(p.profiles[&10].failures, 0);
497        assert_eq!(p.profiles[&20].successes, 0);
498    }
499
500    // ── test 23: total_time_us accumulates across invocations ─────────────────
501    #[test]
502    fn test_total_time_us_accumulation() {
503        let mut p = make_profiler();
504        p.record_invocation(5, "acc_rule", 100, true);
505        p.record_invocation(5, "acc_rule", 200, false);
506        p.record_invocation(5, "acc_rule", 700, true);
507        assert_eq!(p.profiles[&5].total_time_us, 1_000);
508    }
509}