Skip to main content

lean_ctx/core/context_kernel/
knowledge_health.rs

1//! Privacy-safe health and efficiency views for project knowledge stores.
2
3/// Privacy-safe health assessment of project knowledge stores.
4/// Contains only counts and ratios — never content or file paths.
5#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
6pub struct KnowledgeHealthReport {
7    /// Total number of assessed facts.
8    pub total_facts: usize,
9    /// Number of facts marked fresh.
10    pub fresh_facts: usize,
11    /// Number of facts not marked fresh.
12    pub stale_facts: usize,
13    /// Number of facts marked contradicted.
14    pub contradicted_facts: usize,
15    /// Fraction of facts marked fresh.
16    pub freshness_score: f64,
17    /// Fraction of facts marked contradicted.
18    pub contradiction_rate: f64,
19    /// Fraction of facts not marked fresh.
20    pub stale_ratio: f64,
21    /// Number of knowledge areas with no coverage.
22    pub coverage_gaps: usize,
23    /// Total number of episodes in the project store.
24    pub total_episodes: usize,
25    /// Total number of procedures in the project store.
26    pub total_procedures: usize,
27}
28
29/// Aggregated efficiency metrics for org-wide dashboards.
30#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
31pub struct EfficiencyView {
32    /// Fraction of original tokens avoided before sending context.
33    pub compression_ratio: f64,
34    /// Fraction of reads served from cache.
35    pub cache_hit_rate: f64,
36    /// Fraction of plans accepted.
37    pub plan_accept_rate: f64,
38    /// Average kernel planning latency in milliseconds.
39    pub kernel_overhead_ms: f64,
40    /// Total tokens avoided before sending context.
41    pub total_tokens_saved: u64,
42    /// Total tokens sent after context compression.
43    pub total_tokens_sent: u64,
44}
45
46fn ratio(numerator: usize, denominator: usize) -> f64 {
47    if denominator == 0 {
48        0.0
49    } else {
50        numerator as f64 / denominator as f64
51    }
52}
53
54fn ratio_u64(numerator: u64, denominator: u64) -> f64 {
55    if denominator == 0 {
56        0.0
57    } else {
58        numerator as f64 / denominator as f64
59    }
60}
61
62/// Assesses aggregate freshness, contradiction, coverage, and store counts.
63#[must_use]
64pub fn assess_health(
65    facts: &[(bool, bool)],
66    episodes: usize,
67    procedures: usize,
68    coverage_areas: usize,
69    covered_areas: usize,
70) -> KnowledgeHealthReport {
71    let total_facts = facts.len();
72    let fresh_facts = facts.iter().filter(|(fresh, _)| *fresh).count();
73    let stale_facts = total_facts - fresh_facts;
74    let contradicted_facts = facts
75        .iter()
76        .filter(|(_, contradicted)| *contradicted)
77        .count();
78
79    KnowledgeHealthReport {
80        total_facts,
81        fresh_facts,
82        stale_facts,
83        contradicted_facts,
84        freshness_score: ratio(fresh_facts, total_facts),
85        contradiction_rate: ratio(contradicted_facts, total_facts),
86        stale_ratio: ratio(stale_facts, total_facts),
87        coverage_gaps: coverage_areas.saturating_sub(covered_areas),
88        total_episodes: episodes,
89        total_procedures: procedures,
90    }
91}
92
93/// Builds aggregate compression, cache, acceptance, and latency metrics.
94#[allow(clippy::too_many_arguments)]
95#[must_use]
96pub fn build_efficiency_view(
97    original_tokens: u64,
98    sent_tokens: u64,
99    cache_hits: u64,
100    total_reads: u64,
101    accepted_plans: u64,
102    total_plans: u64,
103    kernel_overhead_ms: f64,
104) -> EfficiencyView {
105    EfficiencyView {
106        compression_ratio: if original_tokens == 0 {
107            0.0
108        } else {
109            1.0 - ratio_u64(sent_tokens, original_tokens)
110        },
111        cache_hit_rate: ratio_u64(cache_hits, total_reads),
112        plan_accept_rate: ratio_u64(accepted_plans, total_plans),
113        kernel_overhead_ms,
114        total_tokens_saved: original_tokens.saturating_sub(sent_tokens),
115        total_tokens_sent: sent_tokens,
116    }
117}
118
119/// Formats a multi-line summary containing only aggregate counts and metrics.
120#[must_use]
121pub fn format_org_summary(health: &KnowledgeHealthReport, efficiency: &EfficiencyView) -> String {
122    format!(
123        "Knowledge health\n\
124         Facts: {} total, {} fresh, {} stale, {} contradicted\n\
125         Freshness: {:.1}%\n\
126         Contradictions: {:.1}%\n\
127         Stale: {:.1}%\n\
128         Coverage gaps: {}\n\
129         Episodes: {}\n\
130         Procedures: {}\n\
131         Efficiency\n\
132         Compression: {:.1}%\n\
133         Cache hits: {:.1}%\n\
134         Plan acceptance: {:.1}%\n\
135         Kernel overhead: {:.1} ms\n\
136         Tokens saved: {}\n\
137         Tokens sent: {}",
138        health.total_facts,
139        health.fresh_facts,
140        health.stale_facts,
141        health.contradicted_facts,
142        health.freshness_score * 100.0,
143        health.contradiction_rate * 100.0,
144        health.stale_ratio * 100.0,
145        health.coverage_gaps,
146        health.total_episodes,
147        health.total_procedures,
148        efficiency.compression_ratio * 100.0,
149        efficiency.cache_hit_rate * 100.0,
150        efficiency.plan_accept_rate * 100.0,
151        efficiency.kernel_overhead_ms,
152        efficiency.total_tokens_saved,
153        efficiency.total_tokens_sent,
154    )
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{assess_health, build_efficiency_view, format_org_summary};
160
161    #[test]
162    fn empty_facts_returns_zero_scores() {
163        let report = assess_health(&[], 0, 0, 3, 0);
164        assert_eq!(report.freshness_score, 0.0);
165        assert_eq!(report.contradiction_rate, 0.0);
166        assert_eq!(report.stale_ratio, 0.0);
167        assert_eq!(report.coverage_gaps, 3);
168    }
169
170    #[test]
171    fn all_fresh_facts_score_one() {
172        let report = assess_health(&[(true, false); 10], 0, 0, 0, 0);
173        assert_eq!(report.freshness_score, 1.0);
174        assert_eq!(report.stale_facts, 0);
175    }
176
177    #[test]
178    fn stale_facts_reduce_score() {
179        let mut facts = [(true, false); 10];
180        facts[5..].fill((false, false));
181        let report = assess_health(&facts, 0, 0, 0, 0);
182        assert_eq!(report.freshness_score, 0.5);
183        assert_eq!(report.stale_ratio, 0.5);
184    }
185
186    #[test]
187    fn contradiction_rate_correct() {
188        let mut facts = [(true, false); 10];
189        facts[..2].fill((true, true));
190        let report = assess_health(&facts, 0, 0, 0, 0);
191        assert_eq!(report.contradiction_rate, 0.2);
192        assert_eq!(report.contradicted_facts, 2);
193    }
194
195    #[test]
196    fn efficiency_view_compression() {
197        let view = build_efficiency_view(1_000, 300, 7, 10, 4, 5, 2.5);
198        assert!((view.compression_ratio - 0.7).abs() < f64::EPSILON);
199        assert_eq!(view.total_tokens_saved, 700);
200        assert_eq!(view.total_tokens_sent, 300);
201    }
202
203    #[test]
204    fn format_summary_privacy_safe() {
205        let health = assess_health(&[(true, false)], 2, 3, 4, 1);
206        let efficiency = build_efficiency_view(100, 25, 3, 4, 4, 5, 1.5);
207        let summary = format_org_summary(&health, &efficiency);
208
209        assert!(summary.lines().count() > 1);
210        assert!(!summary.contains("alice@example.com"));
211        assert!(!summary.contains('/'));
212        assert!(summary.contains("75.0%"));
213    }
214}