Skip to main content

aida_core/
analytics.rs

1// trace:ARCH-analytics | ai:claude
2//! Analytics engine — deeper metrics for requirements management.
3//!
4//! Computes velocity trends, requirement churn, AI contribution metrics,
5//! quality score trends, cycle time distributions, and more from the
6//! existing requirement data (history entries, AI evaluations, timestamps).
7
8use chrono::{DateTime, Duration, Utc, Datelike, NaiveDate};
9use serde::Serialize;
10use std::collections::HashMap;
11
12use crate::models::{Requirement, RequirementStatus};
13
14/// Full analytics report.
15#[derive(Debug, Clone, Serialize)]
16pub struct AnalyticsReport {
17    /// When this report was generated
18    pub generated_at: DateTime<Utc>,
19    /// Total requirements
20    pub total_requirements: usize,
21    /// Requirements by status
22    pub status_distribution: HashMap<String, usize>,
23    /// Requirements by type
24    pub type_distribution: HashMap<String, usize>,
25    /// Velocity trend (requirements completed per week)
26    pub velocity_trend: Vec<TimeBucket>,
27    /// Creation trend (requirements created per week)
28    pub creation_trend: Vec<TimeBucket>,
29    /// Churn metrics
30    pub churn: ChurnMetrics,
31    /// Cycle time stats
32    pub cycle_time: CycleTimeStats,
33    /// AI contribution metrics
34    pub ai_metrics: AiMetrics,
35    /// Quality score trends
36    pub quality_trend: Vec<QualityPoint>,
37    /// Top contributors by requirement changes
38    pub top_contributors: Vec<(String, usize)>,
39    /// Traceability coverage
40    pub traceability: TraceabilityMetrics,
41}
42
43/// A time bucket with a count value.
44#[derive(Debug, Clone, Serialize)]
45pub struct TimeBucket {
46    pub period: String, // "2026-W12" or "2026-03"
47    pub count: usize,
48}
49
50/// Churn metrics — how much requirements change after creation.
51#[derive(Debug, Clone, Serialize)]
52pub struct ChurnMetrics {
53    /// Total field changes across all requirements
54    pub total_changes: usize,
55    /// Requirements that have never been modified after creation
56    pub stable_count: usize,
57    /// Requirements modified more than 5 times
58    pub high_churn_count: usize,
59    /// Average changes per requirement
60    pub avg_changes_per_req: f64,
61    /// Most churned requirements (spec_id, change_count)
62    pub most_churned: Vec<(String, usize)>,
63}
64
65/// Cycle time statistics.
66#[derive(Debug, Clone, Serialize)]
67pub struct CycleTimeStats {
68    /// Average hours from creation to completion
69    pub avg_hours: Option<f64>,
70    /// Median hours
71    pub median_hours: Option<f64>,
72    /// 90th percentile hours
73    pub p90_hours: Option<f64>,
74    /// Fastest completion (hours)
75    pub min_hours: Option<f64>,
76    /// Slowest completion (hours)
77    pub max_hours: Option<f64>,
78    /// Number of completed requirements with timing data
79    pub sample_count: usize,
80}
81
82/// AI contribution metrics.
83#[derive(Debug, Clone, Serialize)]
84pub struct AiMetrics {
85    /// Requirements with trace comments (ai:claude or similar)
86    pub ai_traced_count: usize,
87    /// Requirements with AI evaluations
88    pub ai_evaluated_count: usize,
89    /// Average AI quality score (0-100)
90    pub avg_quality_score: Option<f64>,
91    /// Quality score distribution (buckets of 10)
92    pub score_distribution: HashMap<String, usize>,
93    /// Requirements with stale evaluations (content changed since eval)
94    pub stale_evaluations: usize,
95}
96
97/// Quality score over time.
98#[derive(Debug, Clone, Serialize)]
99pub struct QualityPoint {
100    pub period: String,
101    pub avg_score: f64,
102    pub count: usize,
103}
104
105/// Traceability coverage metrics.
106#[derive(Debug, Clone, Serialize)]
107pub struct TraceabilityMetrics {
108    /// Total requirements
109    pub total: usize,
110    /// Requirements with at least one trace link
111    pub with_trace_links: usize,
112    /// Coverage percentage
113    pub coverage_pct: f64,
114    /// Requirements with commit references in comments
115    pub with_commit_refs: usize,
116}
117
118/// Compute the full analytics report from a set of requirements.
119pub fn compute_analytics(requirements: &[Requirement]) -> AnalyticsReport {
120    AnalyticsReport {
121        generated_at: Utc::now(),
122        total_requirements: requirements.len(),
123        status_distribution: compute_status_distribution(requirements),
124        type_distribution: compute_type_distribution(requirements),
125        velocity_trend: compute_velocity_trend(requirements),
126        creation_trend: compute_creation_trend(requirements),
127        churn: compute_churn(requirements),
128        cycle_time: compute_cycle_time(requirements),
129        ai_metrics: compute_ai_metrics(requirements),
130        quality_trend: compute_quality_trend(requirements),
131        top_contributors: compute_top_contributors(requirements),
132        traceability: compute_traceability(requirements),
133    }
134}
135
136fn compute_status_distribution(reqs: &[Requirement]) -> HashMap<String, usize> {
137    let mut dist = HashMap::new();
138    for req in reqs {
139        *dist.entry(req.effective_status()).or_insert(0) += 1;
140    }
141    dist
142}
143
144fn compute_type_distribution(reqs: &[Requirement]) -> HashMap<String, usize> {
145    let mut dist = HashMap::new();
146    for req in reqs {
147        *dist.entry(format!("{:?}", req.req_type)).or_insert(0) += 1;
148    }
149    dist
150}
151
152fn compute_velocity_trend(reqs: &[Requirement]) -> Vec<TimeBucket> {
153    // Count requirements completed per week (based on modified_at for completed items)
154    let mut weekly: HashMap<String, usize> = HashMap::new();
155
156    for req in reqs {
157        if matches!(req.status, RequirementStatus::Completed) {
158            let week = format!("{}-W{:02}", req.modified_at.year(), req.modified_at.iso_week().week());
159            *weekly.entry(week).or_insert(0) += 1;
160        }
161    }
162
163    let mut trend: Vec<TimeBucket> = weekly
164        .into_iter()
165        .map(|(period, count)| TimeBucket { period, count })
166        .collect();
167    trend.sort_by(|a, b| a.period.cmp(&b.period));
168
169    // Keep last 12 weeks
170    if trend.len() > 12 {
171        trend = trend.split_off(trend.len() - 12);
172    }
173    trend
174}
175
176fn compute_creation_trend(reqs: &[Requirement]) -> Vec<TimeBucket> {
177    let mut weekly: HashMap<String, usize> = HashMap::new();
178
179    for req in reqs {
180        let week = format!("{}-W{:02}", req.created_at.year(), req.created_at.iso_week().week());
181        *weekly.entry(week).or_insert(0) += 1;
182    }
183
184    let mut trend: Vec<TimeBucket> = weekly
185        .into_iter()
186        .map(|(period, count)| TimeBucket { period, count })
187        .collect();
188    trend.sort_by(|a, b| a.period.cmp(&b.period));
189
190    if trend.len() > 12 {
191        trend = trend.split_off(trend.len() - 12);
192    }
193    trend
194}
195
196fn compute_churn(reqs: &[Requirement]) -> ChurnMetrics {
197    let mut total_changes = 0;
198    let mut stable_count = 0;
199    let mut high_churn_count = 0;
200    let mut per_req_changes: Vec<(String, usize)> = Vec::new();
201
202    for req in reqs {
203        let changes = req.history.len();
204        total_changes += changes;
205
206        if changes == 0 {
207            stable_count += 1;
208        }
209        if changes > 5 {
210            high_churn_count += 1;
211        }
212
213        per_req_changes.push((
214            req.spec_id.clone().unwrap_or_else(|| req.id.to_string()),
215            changes,
216        ));
217    }
218
219    per_req_changes.sort_by(|a, b| b.1.cmp(&a.1));
220    let most_churned = per_req_changes.into_iter().take(10).collect();
221
222    let avg = if reqs.is_empty() {
223        0.0
224    } else {
225        total_changes as f64 / reqs.len() as f64
226    };
227
228    ChurnMetrics {
229        total_changes,
230        stable_count,
231        high_churn_count,
232        avg_changes_per_req: avg,
233        most_churned,
234    }
235}
236
237fn compute_cycle_time(reqs: &[Requirement]) -> CycleTimeStats {
238    let mut cycle_times: Vec<f64> = Vec::new();
239
240    for req in reqs {
241        if matches!(req.status, RequirementStatus::Completed) {
242            let hours = (req.modified_at - req.created_at).num_hours() as f64;
243            if hours >= 0.0 {
244                cycle_times.push(hours);
245            }
246        }
247    }
248
249    if cycle_times.is_empty() {
250        return CycleTimeStats {
251            avg_hours: None,
252            median_hours: None,
253            p90_hours: None,
254            min_hours: None,
255            max_hours: None,
256            sample_count: 0,
257        };
258    }
259
260    cycle_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
261
262    let sum: f64 = cycle_times.iter().sum();
263    let avg = sum / cycle_times.len() as f64;
264    let median = cycle_times[cycle_times.len() / 2];
265    let p90_idx = (cycle_times.len() as f64 * 0.9) as usize;
266    let p90 = cycle_times[p90_idx.min(cycle_times.len() - 1)];
267
268    CycleTimeStats {
269        avg_hours: Some(avg),
270        median_hours: Some(median),
271        p90_hours: Some(p90),
272        min_hours: cycle_times.first().copied(),
273        max_hours: cycle_times.last().copied(),
274        sample_count: cycle_times.len(),
275    }
276}
277
278fn compute_ai_metrics(reqs: &[Requirement]) -> AiMetrics {
279    let mut ai_traced = 0;
280    let mut ai_evaluated = 0;
281    let mut scores: Vec<f64> = Vec::new();
282    let mut score_dist: HashMap<String, usize> = HashMap::new();
283    let mut stale = 0;
284
285    for req in reqs {
286        // Check for AI trace links
287        if req.trace_links.iter().any(|t| {
288            t.notes.as_deref().unwrap_or("").contains("ai:")
289        }) {
290            ai_traced += 1;
291        }
292
293        // Check AI evaluation
294        if let Some(ref eval) = req.ai_evaluation {
295            ai_evaluated += 1;
296            let score = eval.evaluation.quality_score as f64;
297            scores.push(score);
298
299            // Bucket: 0-10, 10-20, ..., 90-100
300            let bucket = format!("{}-{}", (score as u32 / 10) * 10, ((score as u32 / 10) + 1) * 10);
301            *score_dist.entry(bucket).or_insert(0) += 1;
302
303            // Check staleness
304            if eval.content_hash.is_empty() {
305                stale += 1;
306            }
307        }
308    }
309
310    let avg_score = if scores.is_empty() {
311        None
312    } else {
313        Some(scores.iter().sum::<f64>() / scores.len() as f64)
314    };
315
316    AiMetrics {
317        ai_traced_count: ai_traced,
318        ai_evaluated_count: ai_evaluated,
319        avg_quality_score: avg_score,
320        score_distribution: score_dist,
321        stale_evaluations: stale,
322    }
323}
324
325fn compute_quality_trend(reqs: &[Requirement]) -> Vec<QualityPoint> {
326    // Group AI evaluation scores by month
327    let mut monthly: HashMap<String, (f64, usize)> = HashMap::new();
328
329    for req in reqs {
330        if let Some(ref eval) = req.ai_evaluation {
331            let month = format!("{}-{:02}", eval.evaluated_at.year(), eval.evaluated_at.month());
332            let entry = monthly.entry(month).or_insert((0.0, 0));
333            entry.0 += eval.evaluation.quality_score as f64;
334            entry.1 += 1;
335        }
336    }
337
338    let mut trend: Vec<QualityPoint> = monthly
339        .into_iter()
340        .map(|(period, (sum, count))| QualityPoint {
341            period,
342            avg_score: sum / count as f64,
343            count,
344        })
345        .collect();
346    trend.sort_by(|a, b| a.period.cmp(&b.period));
347    trend
348}
349
350fn compute_top_contributors(reqs: &[Requirement]) -> Vec<(String, usize)> {
351    let mut contributions: HashMap<String, usize> = HashMap::new();
352
353    for req in reqs {
354        // Count by owner
355        if !req.owner.is_empty() {
356            *contributions.entry(req.owner.clone()).or_insert(0) += 1;
357        }
358        // Count by history authors
359        for entry in &req.history {
360            if !entry.author.is_empty() {
361                *contributions.entry(entry.author.clone()).or_insert(0) += 1;
362            }
363        }
364    }
365
366    let mut sorted: Vec<_> = contributions.into_iter().collect();
367    sorted.sort_by(|a, b| b.1.cmp(&a.1));
368    sorted.truncate(10);
369    sorted
370}
371
372fn compute_traceability(reqs: &[Requirement]) -> TraceabilityMetrics {
373    let total = reqs.len();
374    let with_trace = reqs.iter().filter(|r| !r.trace_links.is_empty()).count();
375    let with_commits = reqs
376        .iter()
377        .filter(|r| {
378            r.comments.iter().any(|c| {
379                c.content.contains("Committed in") || c.content.contains("commit")
380            })
381        })
382        .count();
383
384    TraceabilityMetrics {
385        total,
386        with_trace_links: with_trace,
387        coverage_pct: if total > 0 {
388            (with_trace as f64 / total as f64) * 100.0
389        } else {
390            0.0
391        },
392        with_commit_refs: with_commits,
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::models::RequirementType;
400
401    fn make_req(title: &str, status: RequirementStatus, days_ago_created: i64) -> Requirement {
402        let mut req = Requirement::new(title.into(), "desc".into());
403        req.status = status;
404        req.created_at = Utc::now() - Duration::days(days_ago_created);
405        req.modified_at = Utc::now();
406        req.spec_id = Some(format!("FR-{:03}", days_ago_created));
407        req
408    }
409
410    #[test]
411    fn test_status_distribution() {
412        let reqs = vec![
413            make_req("A", RequirementStatus::Draft, 10),
414            make_req("B", RequirementStatus::Draft, 9),
415            make_req("C", RequirementStatus::Completed, 8),
416            make_req("D", RequirementStatus::Approved, 7),
417        ];
418        let dist = compute_status_distribution(&reqs);
419        assert_eq!(dist.get("Draft"), Some(&2));
420        assert_eq!(dist.get("Completed"), Some(&1));
421        assert_eq!(dist.get("Approved"), Some(&1));
422    }
423
424    #[test]
425    fn test_cycle_time() {
426        let reqs = vec![
427            make_req("Fast", RequirementStatus::Completed, 1),
428            make_req("Slow", RequirementStatus::Completed, 30),
429            make_req("Draft", RequirementStatus::Draft, 5),
430        ];
431        let ct = compute_cycle_time(&reqs);
432        assert_eq!(ct.sample_count, 2);
433        assert!(ct.avg_hours.is_some());
434        assert!(ct.min_hours.unwrap() < ct.max_hours.unwrap());
435    }
436
437    #[test]
438    fn test_churn() {
439        let mut req = make_req("Churny", RequirementStatus::Draft, 5);
440        for i in 0..10 {
441            req.history.push(crate::models::HistoryEntry {
442                id: uuid::Uuid::now_v7(),
443                timestamp: Utc::now(),
444                author: "joe".into(),
445                changes: vec![crate::models::FieldChange {
446                    field_name: "title".into(),
447                    old_value: format!("v{}", i),
448                    new_value: format!("v{}", i + 1),
449                }],
450            });
451        }
452
453        let reqs = vec![req, make_req("Stable", RequirementStatus::Draft, 3)];
454        let churn = compute_churn(&reqs);
455        assert_eq!(churn.total_changes, 10);
456        assert_eq!(churn.stable_count, 1);
457        assert_eq!(churn.high_churn_count, 1);
458    }
459
460    #[test]
461    fn test_full_report() {
462        let reqs = vec![
463            make_req("A", RequirementStatus::Completed, 10),
464            make_req("B", RequirementStatus::Draft, 5),
465            make_req("C", RequirementStatus::Approved, 3),
466        ];
467        let report = compute_analytics(&reqs);
468        assert_eq!(report.total_requirements, 3);
469        assert!(report.cycle_time.sample_count >= 1);
470    }
471
472    #[test]
473    fn test_traceability() {
474        let mut req = make_req("Traced", RequirementStatus::Completed, 5);
475        req.trace_links.push(crate::models::TraceLink {
476            id: uuid::Uuid::now_v7(),
477            artifact_type: crate::models::ArtifactType::SourceCode,
478            file_path: "src/main.rs".into(),
479            symbol: Some("validate".into()),
480            line_start: Some(10),
481            line_end: Some(20),
482            notes: Some("ai:claude".into()),
483            created_at: Utc::now(),
484            created_by: Some("test".into()),
485            commit_hash: None,
486        });
487
488        let reqs = vec![req, make_req("Untraced", RequirementStatus::Draft, 3)];
489        let trace = compute_traceability(&reqs);
490        assert_eq!(trace.total, 2);
491        assert_eq!(trace.with_trace_links, 1);
492        assert!((trace.coverage_pct - 50.0).abs() < 0.1);
493    }
494}