Skip to main content

claudectl_core/
health.rs

1#![allow(dead_code)]
2
3use crate::session::ClaudeSession;
4
5/// Tunable thresholds for the health checks below. Lives here (not in
6/// `config`) because health is a foundational `claudectl-core` concern; the
7/// binary's `config::Config` re-exports this type and parses TOML overrides
8/// against it.
9#[derive(Debug, Clone)]
10pub struct HealthThresholds {
11    pub cache_critical_pct: f64,
12    pub cache_warning_pct: f64,
13    pub cache_min_tokens: u64,
14    pub cost_spike_critical: f64,
15    pub cost_spike_warning: f64,
16    pub loop_max_calls: u32,
17    pub stall_min_cost: f64,
18    pub stall_min_minutes: u64,
19    pub context_critical_pct: f64,
20    pub context_warning_pct: f64,
21    pub decay_compaction_pct: f64,
22    pub efficiency_critical_factor: f64,
23    pub error_accel_factor: f64,
24    pub repetition_threshold: u32,
25}
26
27impl Default for HealthThresholds {
28    fn default() -> Self {
29        Self {
30            cache_critical_pct: 10.0,
31            cache_warning_pct: 30.0,
32            cache_min_tokens: 10_000,
33            cost_spike_critical: 5.0,
34            cost_spike_warning: 2.5,
35            loop_max_calls: 10,
36            stall_min_cost: 5.0,
37            stall_min_minutes: 10,
38            context_critical_pct: 90.0,
39            context_warning_pct: 80.0,
40            decay_compaction_pct: 50.0,
41            efficiency_critical_factor: 2.0,
42            error_accel_factor: 2.0,
43            repetition_threshold: 3,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Severity {
50    Info,
51    Warning,
52    Critical,
53}
54
55#[derive(Debug, Clone)]
56pub struct HealthCheck {
57    pub icon: &'static str,
58    pub name: &'static str,
59    pub severity: Severity,
60    pub message: String,
61}
62
63/// Run all health checks against a session. Returns warnings sorted by severity.
64pub fn check_session(session: &ClaudeSession, t: &HealthThresholds) -> Vec<HealthCheck> {
65    let mut checks = Vec::new();
66
67    if let Some(c) = check_cache_health(session, t) {
68        checks.push(c);
69    }
70    if let Some(c) = check_cost_spike(session, t) {
71        checks.push(c);
72    }
73    if let Some(c) = check_loop_detection(session, t) {
74        checks.push(c);
75    }
76    if let Some(c) = check_stalled(session, t) {
77        checks.push(c);
78    }
79    if let Some(c) = check_context_saturation(session, t) {
80        checks.push(c);
81    }
82    if let Some(c) = check_cognitive_decay(session, t) {
83        checks.push(c);
84    }
85    if let Some(c) = check_proactive_compaction(session, t) {
86        checks.push(c);
87    }
88    if let Some(c) = check_token_efficiency(session, t) {
89        checks.push(c);
90    }
91    if let Some(c) = check_error_acceleration(session, t) {
92        checks.push(c);
93    }
94    if let Some(c) = check_repetition(session, t) {
95        checks.push(c);
96    }
97
98    // Sort: Critical first, then Warning, then Info
99    checks.sort_by_key(|c| match c.severity {
100        Severity::Critical => 0,
101        Severity::Warning => 1,
102        Severity::Info => 2,
103    });
104
105    checks
106}
107
108/// Return the most severe health icon for display in the table, or empty string if healthy.
109pub fn status_icon(session: &ClaudeSession, t: &HealthThresholds) -> &'static str {
110    let checks = check_session(session, t);
111    match checks.first() {
112        Some(c) if c.severity == Severity::Critical => c.icon,
113        Some(c) if c.severity == Severity::Warning => c.icon,
114        _ => "",
115    }
116}
117
118/// Format a compact health summary for the status bar.
119pub fn format_health_summary(sessions: &[ClaudeSession], t: &HealthThresholds) -> Option<String> {
120    let mut warnings = 0;
121    let mut criticals = 0;
122    let mut worst_msg = String::new();
123
124    for session in sessions {
125        for check in check_session(session, t) {
126            match check.severity {
127                Severity::Critical => {
128                    criticals += 1;
129                    if worst_msg.is_empty() {
130                        worst_msg =
131                            format!("{} {}: {}", check.icon, session.display_name(), check.name);
132                    }
133                }
134                Severity::Warning => warnings += 1,
135                Severity::Info => {}
136            }
137        }
138    }
139
140    if criticals == 0 && warnings == 0 {
141        return None;
142    }
143
144    let count = criticals + warnings;
145    Some(format!(
146        "{} health issue{} | {}",
147        count,
148        if count == 1 { "" } else { "s" },
149        worst_msg,
150    ))
151}
152
153// ────────────────────────────────────────────────────────────────────────────
154// Individual health checks
155// ────────────────────────────────────────────────────────────────────────────
156
157/// Detect low cache hit ratio (e.g., cache TTL bug causing 12x cost).
158fn check_cache_health(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
159    let total_input = session.total_input_tokens;
160    let cache_read = session.cache_read_tokens;
161
162    if total_input < t.cache_min_tokens {
163        return None;
164    }
165
166    let hit_ratio = cache_read as f64 / total_input as f64;
167    let critical_threshold = t.cache_critical_pct / 100.0;
168    let warning_threshold = t.cache_warning_pct / 100.0;
169
170    if hit_ratio < critical_threshold {
171        Some(HealthCheck {
172            icon: "🔥",
173            name: "low cache",
174            severity: Severity::Critical,
175            message: format!(
176                "Cache hit ratio is {:.0}% — expected >50% for long sessions. \
177                 Possible cache TTL issue (check telemetry settings).",
178                hit_ratio * 100.0
179            ),
180        })
181    } else if hit_ratio < warning_threshold {
182        Some(HealthCheck {
183            icon: "⚠",
184            name: "low cache",
185            severity: Severity::Warning,
186            message: format!(
187                "Cache hit ratio is {:.0}% — below typical range. \
188                 May indicate cache TTL or model configuration issue.",
189                hit_ratio * 100.0
190            ),
191        })
192    } else {
193        None
194    }
195}
196
197/// Detect burn rate spikes — paying more for less output.
198fn check_cost_spike(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
199    if session.cost_usd < 1.0 || session.burn_rate_per_hr <= 0.0 {
200        return None;
201    }
202
203    let elapsed_hrs = session.elapsed.as_secs_f64() / 3600.0;
204    if elapsed_hrs < 0.01 {
205        return None;
206    }
207    let avg_rate = session.cost_usd / elapsed_hrs;
208
209    if avg_rate <= 0.0 {
210        return None;
211    }
212
213    let spike_factor = session.burn_rate_per_hr / avg_rate;
214
215    if spike_factor > t.cost_spike_critical {
216        Some(HealthCheck {
217            icon: "💸",
218            name: "cost spike",
219            severity: Severity::Critical,
220            message: format!(
221                "Burn rate ${:.1}/hr is {:.0}x the session average ${:.1}/hr.",
222                session.burn_rate_per_hr, spike_factor, avg_rate,
223            ),
224        })
225    } else if spike_factor > t.cost_spike_warning {
226        Some(HealthCheck {
227            icon: "💰",
228            name: "cost spike",
229            severity: Severity::Warning,
230            message: format!(
231                "Burn rate ${:.1}/hr is {:.1}x the session average.",
232                session.burn_rate_per_hr, spike_factor,
233            ),
234        })
235    } else {
236        None
237    }
238}
239
240/// Detect tool error loops — same tool failing repeatedly.
241fn check_loop_detection(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
242    if !session.last_tool_error {
243        return None;
244    }
245
246    let max_calls = session
247        .tool_usage
248        .values()
249        .map(|ts| ts.calls)
250        .max()
251        .unwrap_or(0);
252
253    if max_calls >= t.loop_max_calls && session.last_tool_error {
254        let tool_name = session
255            .tool_usage
256            .iter()
257            .max_by_key(|(_, ts)| ts.calls)
258            .map(|(name, _)| name.as_str())
259            .unwrap_or("?");
260
261        Some(HealthCheck {
262            icon: "🔄",
263            name: "looping",
264            severity: Severity::Warning,
265            message: format!(
266                "{tool_name} called {max_calls} times with recent errors — may be stuck in a retry loop.",
267            ),
268        })
269    } else {
270        None
271    }
272}
273
274/// Detect stalled sessions — high cost but no file output.
275fn check_stalled(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
276    if session.cost_usd < t.stall_min_cost {
277        return None;
278    }
279
280    let files_edited: u32 = session.files_modified.values().sum();
281    let elapsed_mins = session.elapsed.as_secs() / 60;
282
283    if files_edited == 0 && elapsed_mins > t.stall_min_minutes {
284        Some(HealthCheck {
285            icon: "🐌",
286            name: "stalled",
287            severity: Severity::Warning,
288            message: format!(
289                "Spent ${:.1} over {} min with no file edits.",
290                session.cost_usd, elapsed_mins,
291            ),
292        })
293    } else {
294        None
295    }
296}
297
298/// Detect context window saturation.
299fn check_context_saturation(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
300    if session.context_max == 0 {
301        return None;
302    }
303
304    let pct = (session.context_tokens as f64 / session.context_max as f64) * 100.0;
305
306    if pct > t.context_critical_pct {
307        Some(HealthCheck {
308            icon: "🧠",
309            name: "context full",
310            severity: Severity::Critical,
311            message: format!(
312                "Context at {:.0}% — session may degrade or auto-compact. \
313                 Consider spawning a fresh session.",
314                pct,
315            ),
316        })
317    } else if pct > t.context_warning_pct {
318        Some(HealthCheck {
319            icon: "🧠",
320            name: "context high",
321            severity: Severity::Warning,
322            message: format!("Context at {:.0}% — approaching limit.", pct),
323        })
324    } else {
325        None
326    }
327}
328
329// ────────────────────────────────────────────────────────────────────────────
330// Cognitive decay checks
331// ────────────────────────────────────────────────────────────────────────────
332
333/// Compute a composite cognitive decay score (0-100) from multiple signals.
334pub fn compute_decay_score(session: &ClaudeSession, _t: &HealthThresholds) -> u32 {
335    let mut score: f64 = 0.0;
336
337    // Context contribution: 0-40 points (linear from 40% to 100%)
338    let ctx_pct = session.context_percent();
339    if ctx_pct > 40.0 {
340        score += ((ctx_pct - 40.0) / 60.0) * 40.0;
341    }
342
343    // Error acceleration contribution: 0-25 points
344    if let Some(baseline) = session.baseline_error_rate {
345        if baseline > 0.0 && session.error_counts_per_window.len() >= 2 {
346            let recent_count = session.error_counts_per_window.len().min(3);
347            let recent: f64 = session
348                .error_counts_per_window
349                .iter()
350                .rev()
351                .take(recent_count)
352                .sum::<u32>() as f64
353                / recent_count as f64;
354            let ratio = recent / baseline;
355            score += (ratio - 1.0).clamp(0.0, 1.0) * 25.0;
356        }
357    }
358
359    // Token efficiency contribution: 0-20 points
360    if let Some(baseline) = session.baseline_tokens_per_edit {
361        if baseline > 0.0 && session.edit_event_count > 5 {
362            let current =
363                session.total_tokens_at_edit_count as f64 / session.edit_event_count as f64;
364            let ratio = current / baseline;
365            score += (ratio - 1.0).clamp(0.0, 1.0) * 20.0;
366        }
367    }
368
369    // Repetition contribution: 0-15 points
370    let max_rereads = session
371        .file_reads_since_edit
372        .values()
373        .copied()
374        .max()
375        .unwrap_or(0);
376    if max_rereads >= 2 {
377        score += ((max_rereads as f64 - 1.0) / 4.0).min(1.0) * 15.0;
378    }
379
380    (score.round() as u32).min(100)
381}
382
383/// Composite cognitive decay check — wraps the decay score into a HealthCheck.
384fn check_cognitive_decay(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
385    let score = compute_decay_score(session, t);
386    if score >= 80 {
387        Some(HealthCheck {
388            icon: "⊘",
389            name: "severe decay",
390            severity: Severity::Critical,
391            message: format!(
392                "Decay score {}/100 — session is severely compromised. Restart with fresh context.",
393                score,
394            ),
395        })
396    } else if score >= 60 {
397        Some(HealthCheck {
398            icon: "◉",
399            name: "significant decay",
400            severity: Severity::Warning,
401            message: format!(
402                "Decay score {}/100 — consider restarting. Generate a state transfer summary first.",
403                score,
404            ),
405        })
406    } else if score >= 30 {
407        Some(HealthCheck {
408            icon: "◐",
409            name: "early decay",
410            severity: Severity::Info,
411            message: format!(
412                "Decay score {}/100 — consider /compact with preservation notes.",
413                score,
414            ),
415        })
416    } else {
417        None
418    }
419}
420
421/// Suggest proactive compaction at moderate context usage (before degradation starts).
422fn check_proactive_compaction(
423    session: &ClaudeSession,
424    t: &HealthThresholds,
425) -> Option<HealthCheck> {
426    if session.context_max == 0 {
427        return None;
428    }
429
430    let pct = session.context_percent();
431    if pct > t.decay_compaction_pct && pct <= t.context_warning_pct {
432        Some(HealthCheck {
433            icon: "📋",
434            name: "consider compact",
435            severity: Severity::Info,
436            message: format!(
437                "Context at {:.0}% — research shows degradation begins here. Consider /compact.",
438                pct,
439            ),
440        })
441    } else {
442        None
443    }
444}
445
446/// Detect token efficiency degradation — spending more tokens per file edit over time.
447fn check_token_efficiency(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
448    let baseline = session.baseline_tokens_per_edit?;
449    if baseline < 100.0 || session.edit_event_count < 8 {
450        return None;
451    }
452
453    let current = session.total_tokens_at_edit_count as f64 / session.edit_event_count as f64;
454    let ratio = current / baseline;
455
456    if ratio > t.efficiency_critical_factor {
457        Some(HealthCheck {
458            icon: "📉",
459            name: "low efficiency",
460            severity: Severity::Warning,
461            message: format!(
462                "Tokens per edit is {:.1}x baseline — agent is working harder for less output.",
463                ratio,
464            ),
465        })
466    } else {
467        None
468    }
469}
470
471/// Detect error rate acceleration — errors are increasing over time.
472fn check_error_acceleration(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
473    let baseline = session.baseline_error_rate?;
474    if baseline <= 0.0 || session.error_counts_per_window.len() < 4 {
475        return None;
476    }
477
478    let recent_count = session.error_counts_per_window.len().min(3);
479    let recent: f64 = session
480        .error_counts_per_window
481        .iter()
482        .rev()
483        .take(recent_count)
484        .sum::<u32>() as f64
485        / recent_count as f64;
486    let ratio = recent / baseline;
487
488    if ratio > t.error_accel_factor {
489        Some(HealthCheck {
490            icon: "⚠",
491            name: "error acceleration",
492            severity: Severity::Warning,
493            message: format!(
494                "Error rate is {:.1}x baseline — agent may be stuck or confused.",
495                ratio,
496            ),
497        })
498    } else {
499        None
500    }
501}
502
503/// Detect file re-reads without intervening edits — possible confusion or looping.
504fn check_repetition(session: &ClaudeSession, t: &HealthThresholds) -> Option<HealthCheck> {
505    let max_rereads = session
506        .file_reads_since_edit
507        .values()
508        .copied()
509        .max()
510        .unwrap_or(0);
511
512    if max_rereads >= t.repetition_threshold {
513        let file = session
514            .file_reads_since_edit
515            .iter()
516            .max_by_key(|(_, v)| *v)
517            .map(|(k, _)| {
518                // Show just filename
519                k.rsplit('/').next().unwrap_or(k)
520            })
521            .unwrap_or("?");
522        Some(HealthCheck {
523            icon: "🔁",
524            name: "repetition",
525            severity: Severity::Warning,
526            message: format!(
527                "{} read {} times without editing — agent may be looping.",
528                file, max_rereads,
529            ),
530        })
531    } else {
532        None
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::session::{RawSession, SessionStatus, TelemetryStatus};
540
541    fn defaults() -> HealthThresholds {
542        HealthThresholds::default()
543    }
544
545    fn make_session() -> ClaudeSession {
546        let raw = RawSession {
547            pid: 1,
548            session_id: "test".into(),
549            cwd: "/tmp/test".into(),
550            started_at: 0,
551        };
552        let mut s = ClaudeSession::from_raw(raw);
553        s.status = SessionStatus::Processing;
554        s.telemetry_status = TelemetryStatus::Available;
555        s.model = "opus".into();
556        s
557    }
558
559    #[test]
560    fn healthy_session_no_warnings() {
561        let s = make_session();
562        assert!(check_session(&s, &defaults()).is_empty());
563    }
564
565    #[test]
566    fn low_cache_critical() {
567        let mut s = make_session();
568        s.total_input_tokens = 100_000;
569        s.cache_read_tokens = 5_000; // 5% hit ratio
570        let checks = check_session(&s, &defaults());
571        assert!(
572            checks
573                .iter()
574                .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
575        );
576    }
577
578    #[test]
579    fn low_cache_warning() {
580        let mut s = make_session();
581        s.total_input_tokens = 100_000;
582        s.cache_read_tokens = 20_000; // 20% hit ratio
583        let checks = check_session(&s, &defaults());
584        assert!(
585            checks
586                .iter()
587                .any(|c| c.name == "low cache" && c.severity == Severity::Warning)
588        );
589    }
590
591    #[test]
592    fn healthy_cache_no_warning() {
593        let mut s = make_session();
594        s.total_input_tokens = 100_000;
595        s.cache_read_tokens = 60_000; // 60% hit ratio
596        assert!(check_cache_health(&s, &defaults()).is_none());
597    }
598
599    #[test]
600    fn context_saturation_critical() {
601        let mut s = make_session();
602        s.context_tokens = 190_000;
603        s.context_max = 200_000;
604        let checks = check_session(&s, &defaults());
605        assert!(
606            checks
607                .iter()
608                .any(|c| c.name == "context full" && c.severity == Severity::Critical)
609        );
610    }
611
612    #[test]
613    fn context_saturation_warning() {
614        let mut s = make_session();
615        s.context_tokens = 170_000;
616        s.context_max = 200_000;
617        let checks = check_session(&s, &defaults());
618        assert!(
619            checks
620                .iter()
621                .any(|c| c.name == "context high" && c.severity == Severity::Warning)
622        );
623    }
624
625    #[test]
626    fn stalled_detection() {
627        let mut s = make_session();
628        s.cost_usd = 10.0;
629        s.elapsed = std::time::Duration::from_secs(15 * 60);
630        // No files modified
631        let checks = check_session(&s, &defaults());
632        assert!(checks.iter().any(|c| c.name == "stalled"));
633    }
634
635    #[test]
636    fn status_icon_returns_worst() {
637        let mut s = make_session();
638        s.context_tokens = 190_000;
639        s.context_max = 200_000;
640        assert_eq!(status_icon(&s, &defaults()), "🧠");
641    }
642
643    #[test]
644    fn status_icon_empty_when_healthy() {
645        let s = make_session();
646        assert_eq!(status_icon(&s, &defaults()), "");
647    }
648
649    #[test]
650    fn sorted_by_severity() {
651        let mut s = make_session();
652        s.total_input_tokens = 100_000;
653        s.cache_read_tokens = 5_000; // Critical cache
654        s.context_tokens = 170_000;
655        s.context_max = 200_000; // Warning context
656        let checks = check_session(&s, &defaults());
657        assert!(checks.len() >= 2);
658        assert_eq!(checks[0].severity, Severity::Critical);
659    }
660
661    #[test]
662    fn custom_thresholds_change_trigger() {
663        let mut s = make_session();
664        s.total_input_tokens = 100_000;
665        s.cache_read_tokens = 8_000; // 8% hit ratio — critical at default 10%
666
667        // With defaults, this is critical
668        let checks = check_session(&s, &defaults());
669        assert!(
670            checks
671                .iter()
672                .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
673        );
674
675        // With relaxed threshold, this should only be a warning
676        let mut relaxed = defaults();
677        relaxed.cache_critical_pct = 5.0;
678        let checks = check_session(&s, &relaxed);
679        assert!(
680            checks
681                .iter()
682                .any(|c| c.name == "low cache" && c.severity == Severity::Warning)
683        );
684        assert!(
685            !checks
686                .iter()
687                .any(|c| c.name == "low cache" && c.severity == Severity::Critical)
688        );
689    }
690
691    #[test]
692    fn custom_context_thresholds() {
693        let mut s = make_session();
694        s.context_tokens = 170_000;
695        s.context_max = 200_000; // 85% — warning at default 80%
696
697        // With defaults, this triggers warning
698        let checks = check_session(&s, &defaults());
699        assert!(
700            checks
701                .iter()
702                .any(|c| c.name == "context high" && c.severity == Severity::Warning)
703        );
704
705        // With tighter threshold (84%), 85% usage should trigger critical
706        let mut tight = defaults();
707        tight.context_critical_pct = 84.0;
708        let checks = check_session(&s, &tight);
709        assert!(
710            checks
711                .iter()
712                .any(|c| c.name == "context full" && c.severity == Severity::Critical)
713        );
714    }
715
716    // ── Cognitive decay tests ────────────────────────────────────────
717
718    #[test]
719    fn proactive_compaction_fires_at_50pct() {
720        let mut s = make_session();
721        s.context_tokens = 110_000;
722        s.context_max = 200_000; // 55%
723        s.usage_metrics_available = true;
724        let check = check_proactive_compaction(&s, &defaults());
725        assert!(check.is_some());
726        let c = check.unwrap();
727        assert_eq!(c.name, "consider compact");
728        assert_eq!(c.severity, Severity::Info);
729    }
730
731    #[test]
732    fn proactive_compaction_silent_below_threshold() {
733        let mut s = make_session();
734        s.context_tokens = 70_000;
735        s.context_max = 200_000; // 35%
736        assert!(check_proactive_compaction(&s, &defaults()).is_none());
737    }
738
739    #[test]
740    fn token_efficiency_detects_degradation() {
741        let mut s = make_session();
742        s.baseline_tokens_per_edit = Some(1000.0);
743        s.edit_event_count = 10;
744        s.total_tokens_at_edit_count = 25_000; // 2500 per edit = 2.5x baseline
745        let check = check_token_efficiency(&s, &defaults());
746        assert!(check.is_some());
747        let c = check.unwrap();
748        assert_eq!(c.name, "low efficiency");
749        assert_eq!(c.severity, Severity::Warning);
750    }
751
752    #[test]
753    fn token_efficiency_silent_when_healthy() {
754        let mut s = make_session();
755        s.baseline_tokens_per_edit = Some(1000.0);
756        s.edit_event_count = 10;
757        s.total_tokens_at_edit_count = 12_000; // 1200 per edit = 1.2x baseline
758        assert!(check_token_efficiency(&s, &defaults()).is_none());
759    }
760
761    #[test]
762    fn error_acceleration_detects_increase() {
763        let mut s = make_session();
764        s.baseline_error_rate = Some(1.0);
765        s.error_counts_per_window = vec![1, 1, 1, 2, 3, 4]; // rising
766        let check = check_error_acceleration(&s, &defaults());
767        assert!(check.is_some());
768        let c = check.unwrap();
769        assert_eq!(c.name, "error acceleration");
770        assert_eq!(c.severity, Severity::Warning);
771    }
772
773    #[test]
774    fn error_acceleration_silent_when_stable() {
775        let mut s = make_session();
776        s.baseline_error_rate = Some(1.0);
777        s.error_counts_per_window = vec![1, 1, 1, 1]; // stable
778        assert!(check_error_acceleration(&s, &defaults()).is_none());
779    }
780
781    #[test]
782    fn repetition_detects_rereads() {
783        let mut s = make_session();
784        s.file_reads_since_edit
785            .insert("/tmp/test/src/main.rs".into(), 4);
786        let check = check_repetition(&s, &defaults());
787        assert!(check.is_some());
788        let c = check.unwrap();
789        assert_eq!(c.name, "repetition");
790        assert_eq!(c.severity, Severity::Warning);
791        assert!(c.message.contains("main.rs"));
792    }
793
794    #[test]
795    fn repetition_silent_below_threshold() {
796        let mut s = make_session();
797        s.file_reads_since_edit.insert("/tmp/test/foo.rs".into(), 2);
798        assert!(check_repetition(&s, &defaults()).is_none());
799    }
800
801    #[test]
802    fn decay_score_zero_for_fresh_session() {
803        let s = make_session();
804        assert_eq!(compute_decay_score(&s, &defaults()), 0);
805    }
806
807    #[test]
808    fn decay_score_context_only_contribution() {
809        let mut s = make_session();
810        s.context_tokens = 140_000;
811        s.context_max = 200_000; // 70%
812        s.usage_metrics_available = true;
813        let score = compute_decay_score(&s, &defaults());
814        // 70% context: (70-40)/60 * 40 = 20 points
815        assert_eq!(score, 20);
816    }
817
818    #[test]
819    fn decay_score_high_for_saturated_session() {
820        let mut s = make_session();
821        s.context_tokens = 180_000;
822        s.context_max = 200_000; // 90% → 33 context points
823        s.usage_metrics_available = true;
824        s.baseline_error_rate = Some(1.0);
825        s.error_counts_per_window = vec![1, 1, 1, 3, 4, 5]; // accelerating
826        s.file_reads_since_edit
827            .insert("/tmp/test/main.rs".into(), 5); // repetition
828        let score = compute_decay_score(&s, &defaults());
829        assert!(score >= 60, "expected >= 60, got {score}");
830    }
831
832    #[test]
833    fn cognitive_decay_check_critical_at_80() {
834        let mut s = make_session();
835        s.context_tokens = 200_000;
836        s.context_max = 200_000; // 100% → 40 context points
837        s.usage_metrics_available = true;
838        s.baseline_tokens_per_edit = Some(1000.0);
839        s.edit_event_count = 10;
840        s.total_tokens_at_edit_count = 20_000; // 2x baseline → 20 efficiency points
841        s.baseline_error_rate = Some(1.0);
842        s.error_counts_per_window = vec![1, 1, 1, 3, 4, 5]; // → 25 error points
843        let check = check_cognitive_decay(&s, &defaults());
844        assert!(check.is_some());
845        let c = check.unwrap();
846        assert_eq!(c.name, "severe decay");
847        assert_eq!(c.severity, Severity::Critical);
848        assert!(c.icon == "⊘");
849    }
850}