From 435e689281c3d15db009d42bff4cfa434862fc3e Mon Sep 17 00:00:00 2001 From: copyleftdev Date: Thu, 9 Apr 2026 20:26:48 -0700 Subject: [PATCH] feat: add health scoring with letter grades and CLI score command (#26) Add LetterGrade/DimensionScore/HealthScore types with grading rubrics for commit entropy, fix ratio, one-commit rate, velocity trend, and zero-comment rate. Wire the Score command into the CLI with configurable JSONPath fields for author, time, message, and comments. Co-Authored-By: Claude Opus 4.6 (1M context) --- vajra-cli/src/main.rs | 353 +++++++++++++++++++-- vajra-types/src/lib.rs | 6 +- vajra-types/src/scoring.rs | 634 ++++++++++++++++++++++++++++++++++++- 3 files changed, 960 insertions(+), 33 deletions(-) diff --git a/vajra-cli/src/main.rs b/vajra-cli/src/main.rs index bbd105e..ca33640 100644 --- a/vajra-cli/src/main.rs +++ b/vajra-cli/src/main.rs @@ -18,7 +18,11 @@ use vajra_essence::{ use vajra_fingerprint::{ cluster_documents, FingerprintAnalyzer, FingerprintResult, StreamingFingerprintAccumulator, }; -use vajra_stats::{StatsAnalyzer, StatsResult, StreamingStatsAccumulator}; +use vajra_stats::{ + extract_json_path, linear_regression, shannon_entropy_from_counts, StatsAnalyzer, StatsResult, + StreamingStatsAccumulator, +}; +use vajra_types::scoring::{compute_health_score, HealthMetrics, HealthScore, HealthWeights}; use vajra_types::traits::{ConcernProfile, OutputFormat}; use vajra_types::{Analyzer, Document}; @@ -197,6 +201,23 @@ enum Command { #[arg(long, default_value = "fix,revert")] response_values: String, }, + /// Automated health scoring with letter grades + Score { + /// Path to JSON file, or `-` for stdin + input: String, + /// JSONPath to the author/contributor field (e.g. '$.author') + #[arg(long, default_value = "$.author")] + author_field: String, + /// JSONPath to the timestamp field (e.g. '$.date') + #[arg(long, default_value = "$.date")] + time_field: String, + /// JSONPath to the commit message field (e.g. '$.message') + #[arg(long, default_value = "$.message")] + message_field: String, + /// JSONPath to the issue comments count field (e.g. '$.comments') + #[arg(long)] + comments_field: Option, + }, /// List all available profiles (built-in and custom) Profiles, } @@ -253,6 +274,20 @@ fn main() { response_values, &cli, ), + Command::Score { + input, + author_field, + time_field, + message_field, + comments_field, + } => cmd_score( + input, + author_field, + time_field, + message_field, + comments_field.as_deref(), + &cli, + ), Command::Profiles => cmd_profiles(&cli), }; @@ -403,6 +438,246 @@ fn maybe_redact(output: &str, cli: &Cli) -> String { } } +// --------------------------------------------------------------------------- +// score command +// --------------------------------------------------------------------------- + +fn cmd_score( + input: &str, + author_field: &str, + time_field: &str, + message_field: &str, + comments_field: Option<&str>, + cli: &Cli, +) -> Result<()> { + let doc = load_document(input, cli)?; + let records = match doc.value().as_array() { + Some(arr) => arr.clone(), + None => { + anyhow::bail!( + "score command expects a JSON array of records (e.g. commit or issue data)" + ); + } + }; + + if records.is_empty() { + anyhow::bail!("score command received an empty array — no records to score"); + } + + let metrics = extract_health_metrics( + &records, + author_field, + time_field, + message_field, + comments_field, + ); + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + + match score { + Some(ref s) => match cli.format { + Format::Json => { + let j = score_to_json(s); + let out = serde_json::to_string_pretty(&j).context("JSON serialization failed")?; + let out = maybe_redact(&out, cli); + println!("{out}"); + } + Format::Text | Format::Markdown => { + let t = score_to_text(s); + let t = maybe_redact(&t, cli); + print!("{t}"); + } + Format::CompactAi => { + let j = score_to_json(s); + let out = serde_json::to_string(&j).context("JSON serialization failed")?; + let out = maybe_redact(&out, cli); + println!("{out}"); + } + }, + None => { + anyhow::bail!( + "could not compute health score: no scorable dimensions found in the data" + ); + } + } + + Ok(()) +} + +/// Extract health metrics from a JSON array of records. +fn extract_health_metrics( + records: &[serde_json::Value], + author_field: &str, + time_field: &str, + message_field: &str, + comments_field: Option<&str>, +) -> HealthMetrics { + let mut metrics = HealthMetrics::default(); + + // --- Bus factor: commit entropy from author distribution --- + let mut author_map: BTreeMap = BTreeMap::new(); + let mut total_authors = 0_u64; + for record in records { + if let Some(author_val) = extract_json_path(record, author_field) { + if let Some(name) = author_val.as_str() { + *author_map.entry(name.to_owned()).or_insert(0) += 1; + total_authors += 1; + } + } + } + if total_authors > 0 { + let counts: Vec = author_map.values().copied().collect(); + let entropy = shannon_entropy_from_counts(&counts); + metrics.commit_entropy = Some(entropy); + } + + // --- Code stability: fix ratio --- + let fix_patterns = ["fix", "bug", "hotfix", "patch", "revert"]; + let mut total_commits = 0_u64; + let mut fix_commits = 0_u64; + for record in records { + if let Some(msg_val) = extract_json_path(record, message_field) { + if let Some(msg) = msg_val.as_str() { + total_commits += 1; + let lower = msg.to_lowercase(); + if fix_patterns.iter().any(|p| lower.contains(p)) { + fix_commits += 1; + } + } + } + } + if total_commits > 0 { + #[allow(clippy::cast_precision_loss)] // u64 counts are well within f64 range + let ratio = fix_commits as f64 / total_commits as f64; + metrics.fix_ratio = Some(ratio); + } + + // --- Contributor retention: one-commit rate --- + if total_authors > 0 { + let one_commit_authors = author_map.values().filter(|&&c| c == 1).count(); + #[allow(clippy::cast_precision_loss)] // counts are small + let rate = one_commit_authors as f64 / author_map.len() as f64; + metrics.one_commit_rate = Some(rate); + } + + // --- Velocity trend: linear regression on monthly commit counts --- + let mut monthly_counts: BTreeMap = BTreeMap::new(); + for record in records { + if let Some(date_val) = extract_json_path(record, time_field) { + if let Some(date_str) = date_val.as_str() { + // Extract YYYY-MM prefix for monthly bucketing + if date_str.len() >= 7 { + let month_key = &date_str[..7]; + *monthly_counts.entry(month_key.to_owned()).or_insert(0) += 1; + } + } + } + } + if monthly_counts.len() >= 2 { + #[allow(clippy::cast_precision_loss)] // monthly counts are small + let values: Vec = monthly_counts.values().map(|&c| c as f64).collect(); + if let Some(trend) = linear_regression(&values) { + metrics.velocity_slope = Some(trend.slope); + } + } + + // --- Issue response: zero-comment rate --- + if let Some(cf) = comments_field { + let mut total_issues = 0_u64; + let mut zero_comment_issues = 0_u64; + for record in records { + if let Some(comments_val) = extract_json_path(record, cf) { + total_issues += 1; + let count = comments_val.as_u64().or_else(|| { + comments_val.as_f64().map(|f| { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let u = f as u64; + u + }) + }); + if count == Some(0) { + zero_comment_issues += 1; + } + } + } + if total_issues > 0 { + #[allow(clippy::cast_precision_loss)] // counts are small + let rate = zero_comment_issues as f64 / total_issues as f64; + metrics.zero_comment_rate = Some(rate); + } + } + + metrics +} + +fn score_to_json(score: &HealthScore) -> serde_json::Value { + let mut dims = serde_json::Map::new(); + for (name, ds) in &score.dimensions { + dims.insert( + name.clone(), + serde_json::json!({ + "grade": ds.grade.as_str(), + "value": ds.value, + "metric": ds.metric_name, + "description": ds.description, + }), + ); + } + serde_json::json!({ + "overall": score.overall.as_str(), + "overall_numeric": score.overall_numeric, + "dimensions": dims, + }) +} + +fn score_to_text(score: &HealthScore) -> String { + use std::fmt::Write; + let mut out = String::new(); + let _ = writeln!(out, "=== Health Score ==="); + let _ = writeln!( + out, + " Overall: {} ({:.2})", + score.overall, score.overall_numeric + ); + let _ = writeln!(out); + let _ = writeln!(out, "=== Dimensions ==="); + + // Find max dimension name width for alignment + let name_width = score + .dimensions + .keys() + .map(|k| k.len()) + .max() + .unwrap_or(10) + .max(10); + + let _ = writeln!( + out, + " {:5} {:>10} METRIC", + "DIMENSION", + "GRADE", + "VALUE", + nw = name_width + ); + for (name, ds) in &score.dimensions { + let _ = writeln!( + out, + " {:5} {:>10.4} {}", + name, + ds.grade, + ds.value, + ds.metric_name, + nw = name_width + ); + } + let _ = writeln!(out); + let _ = writeln!(out, "=== Descriptions ==="); + for (name, ds) in &score.dimensions { + let _ = writeln!(out, " {}: {}", name, ds.description); + } + out +} + // --------------------------------------------------------------------------- // profiles command // --------------------------------------------------------------------------- @@ -892,31 +1167,36 @@ fn cmd_stats_windowed( WindowArg::Day => WindowGranularity::Day, }; - let fmt = to_input_format(cli.input_format); - let docs = vajra_core::input::load_documents(input, fmt).map_err(|e| anyhow::anyhow!("{e}"))?; + // Use the unified load_document path so git repos are handled. + let doc = load_document(input, cli)?; let mut records: Vec = Vec::new(); - for doc in &docs { - match doc.value() { - serde_json::Value::Array(arr) => { - records.extend(arr.iter().cloned()); - } - obj @ serde_json::Value::Object(_) => { - records.push(obj.clone()); - } - _ => {} + match doc.value() { + serde_json::Value::Array(arr) => { + records.extend(arr.iter().cloned()); + } + obj @ serde_json::Value::Object(_) => { + records.push(obj.clone()); } + _ => {} } if records.is_empty() { anyhow::bail!("no records found in input"); } + // When input is git, default time_field to $.date let resolved_time_field = match time_field { Some(tf) => tf.to_owned(), - None => auto_detect_time_field(&records).ok_or_else(|| { - anyhow::anyhow!("could not auto-detect time field; use --time-field to specify") - })?, + None => { + if is_git_input(input, cli) { + "$.date".to_owned() + } else { + auto_detect_time_field(&records).ok_or_else(|| { + anyhow::anyhow!("could not auto-detect time field; use --time-field to specify") + })? + } + } }; let result = windowed_analysis(&records, &resolved_time_field, granularity) @@ -1942,31 +2222,42 @@ fn cmd_cascade( response_values_str: &str, cli: &Cli, ) -> Result<()> { - let raw = if input == "-" { - let mut buf = String::new(); - std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf) - .context("failed to read stdin")?; - buf - } else { - std::fs::read_to_string(input).with_context(|| format!("failed to read file: {input}"))? - }; - let parsed: serde_json::Value = - serde_json::from_str(&raw).with_context(|| format!("failed to parse JSON from {input}"))?; - let records = match parsed.as_array() { - Some(arr) => arr.clone(), - None => { + // Use unified load_document so git repos are handled. + let doc = load_document(input, cli)?; + let records = match doc.value() { + serde_json::Value::Array(arr) => arr.clone(), + _ => { anyhow::bail!("cascade command expects a JSON array of records"); } }; + + // When input is git, apply smart defaults for unmapped fields. + let git_mode = is_git_input(input, cli); + let effective_entity = if git_mode && entity_field == "file" { + "author_name" + } else { + entity_field + }; + let effective_time = if git_mode && time_field == "date" { + "date" + } else { + time_field + }; + let effective_event = if git_mode && event_field == "intent" { + "subject" + } else { + event_field + }; + let response_values: Vec = response_values_str .split(',') .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .collect(); let config = vajra_cascade::CascadeConfig { - entity_field: entity_field.to_owned(), - time_field: time_field.to_owned(), - event_field: event_field.to_owned(), + entity_field: effective_entity.to_owned(), + time_field: effective_time.to_owned(), + event_field: effective_event.to_owned(), trigger_values: Vec::new(), response_values, }; diff --git a/vajra-types/src/lib.rs b/vajra-types/src/lib.rs index e157036..d2bfd3f 100644 --- a/vajra-types/src/lib.rs +++ b/vajra-types/src/lib.rs @@ -18,7 +18,11 @@ pub use error::VajraError; pub use features::FeatureStore; pub use json_type::JsonType; pub use path::{PathSegment, WildcardPath}; -pub use scoring::ScoreWeights; +pub use scoring::{ + compute_health_score, grade_commit_entropy, grade_fix_ratio, grade_one_commit_rate, + grade_velocity_trend, grade_zero_comment_rate, DimensionScore, HealthMetrics, HealthScore, + HealthWeights, LetterGrade, ScoreWeights, +}; pub use traits::{ Analyzer, ConcernProfile, DriftDetector, FeatureExtractor, Fingerprinter, InferenceConfidence, RelationshipHint, RelationshipType, TypeRecognizer, VajraPlugin, diff --git a/vajra-types/src/scoring.rs b/vajra-types/src/scoring.rs index 2c7d863..d62442b 100644 --- a/vajra-types/src/scoring.rs +++ b/vajra-types/src/scoring.rs @@ -1,4 +1,7 @@ -//! Scoring weights and composite score computation. +//! Scoring weights, composite score computation, and health grading. + +use std::collections::BTreeMap; +use std::fmt; use crate::traits::CandidateObservation; use serde::{Deserialize, Serialize}; @@ -122,6 +125,347 @@ impl ScoreWeights { } } +// --------------------------------------------------------------------------- +// Health scoring: letter grades and dimension-level health assessment +// --------------------------------------------------------------------------- + +/// Letter grades for health scoring dimensions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum LetterGrade { + F, + DPlus, + D, + CMinus, + C, + CPlus, + BMinus, + B, + BPlus, + AMinus, + A, +} + +impl LetterGrade { + /// Convert a letter grade to its numeric value on a 0.0-4.0 scale. + #[must_use] + pub fn to_numeric(self) -> f64 { + match self { + Self::A => 4.0, + Self::AMinus => 3.7, + Self::BPlus => 3.3, + Self::B => 3.0, + Self::BMinus => 2.7, + Self::CPlus => 2.3, + Self::C => 2.0, + Self::CMinus => 1.7, + Self::DPlus => 1.3, + Self::D => 1.0, + Self::F => 0.0, + } + } + + /// Convert a numeric value (0.0-4.0 scale) to a letter grade. + #[must_use] + pub fn from_numeric(value: f64) -> Self { + match () { + _ if value >= 3.85 => Self::A, + _ if value >= 3.5 => Self::AMinus, + _ if value >= 3.15 => Self::BPlus, + _ if value >= 2.85 => Self::B, + _ if value >= 2.5 => Self::BMinus, + _ if value >= 2.15 => Self::CPlus, + _ if value >= 1.85 => Self::C, + _ if value >= 1.5 => Self::CMinus, + _ if value >= 1.15 => Self::DPlus, + _ if value >= 0.5 => Self::D, + _ => Self::F, + } + } + + /// Return the display string for this grade. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::A => "A", + Self::AMinus => "A-", + Self::BPlus => "B+", + Self::B => "B", + Self::BMinus => "B-", + Self::CPlus => "C+", + Self::C => "C", + Self::CMinus => "C-", + Self::DPlus => "D+", + Self::D => "D", + Self::F => "F", + } + } +} + +impl fmt::Display for LetterGrade { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Score for a single health dimension. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DimensionScore { + /// Letter grade for this dimension. + pub grade: LetterGrade, + /// Raw metric value used for grading. + pub value: f64, + /// Name of the underlying metric. + pub metric_name: String, + /// Human-readable description of what this dimension measures. + pub description: String, +} + +/// Overall health score combining multiple dimensions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthScore { + /// Overall letter grade. + pub overall: LetterGrade, + /// Overall numeric score on 0.0-4.0 scale. + pub overall_numeric: f64, + /// Per-dimension scores, keyed by dimension name. + pub dimensions: BTreeMap, +} + +/// Weights for the five health scoring dimensions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthWeights { + pub bus_factor: f64, + pub code_stability: f64, + pub contributor_retention: f64, + pub velocity_trend: f64, + pub issue_response: f64, +} + +impl Default for HealthWeights { + fn default() -> Self { + Self { + bus_factor: 0.25, + code_stability: 0.20, + contributor_retention: 0.20, + velocity_trend: 0.15, + issue_response: 0.20, + } + } +} + +impl HealthWeights { + /// Returns the sum of all weights (should be ~1.0). + #[must_use] + pub fn total(&self) -> f64 { + self.bus_factor + + self.code_stability + + self.contributor_retention + + self.velocity_trend + + self.issue_response + } +} + +// --------------------------------------------------------------------------- +// Grading rubrics: deterministic threshold-based grading +// --------------------------------------------------------------------------- + +/// Grade commit entropy (Shannon entropy of author distribution). +/// +/// Higher entropy means more diverse contributions (better bus factor). +#[must_use] +pub fn grade_commit_entropy(entropy: f64) -> LetterGrade { + match () { + _ if entropy > 5.0 => LetterGrade::A, + _ if entropy > 4.5 => LetterGrade::AMinus, + _ if entropy > 4.0 => LetterGrade::BPlus, + _ if entropy > 3.5 => LetterGrade::B, + _ if entropy > 3.0 => LetterGrade::C, + _ if entropy > 2.5 => LetterGrade::D, + _ => LetterGrade::F, + } +} + +/// Grade fix ratio (fix commits / total commits). +/// +/// Lower fix ratio means more stable code. +#[must_use] +pub fn grade_fix_ratio(ratio: f64) -> LetterGrade { + match () { + _ if ratio < 0.05 => LetterGrade::A, + _ if ratio < 0.10 => LetterGrade::B, + _ if ratio < 0.15 => LetterGrade::C, + _ if ratio < 0.20 => LetterGrade::D, + _ => LetterGrade::F, + } +} + +/// Grade one-commit contributor rate. +/// +/// Lower one-commit rate means better contributor retention. +#[must_use] +pub fn grade_one_commit_rate(rate: f64) -> LetterGrade { + match () { + _ if rate < 0.20 => LetterGrade::A, + _ if rate < 0.35 => LetterGrade::B, + _ if rate < 0.50 => LetterGrade::C, + _ if rate < 0.65 => LetterGrade::D, + _ => LetterGrade::F, + } +} + +/// Grade velocity trend from a slope value. +/// +/// Positive slope means increasing velocity (good). +/// The slope is normalized: values near zero are stable. +#[must_use] +pub fn grade_velocity_trend(slope: f64) -> LetterGrade { + match () { + _ if slope > 2.0 => LetterGrade::A, + _ if slope > 1.0 => LetterGrade::BPlus, + _ if slope > 0.0 => LetterGrade::B, + _ if slope > -0.5 => LetterGrade::C, + _ if slope > -1.0 => LetterGrade::D, + _ => LetterGrade::F, + } +} + +/// Grade zero-comment issue rate. +/// +/// Lower zero-comment rate means better issue responsiveness. +#[must_use] +pub fn grade_zero_comment_rate(rate: f64) -> LetterGrade { + match () { + _ if rate < 0.10 => LetterGrade::A, + _ if rate < 0.20 => LetterGrade::B, + _ if rate < 0.35 => LetterGrade::C, + _ if rate < 0.50 => LetterGrade::D, + _ => LetterGrade::F, + } +} + +/// Input metrics for computing a health score. +/// +/// Each field is `Option` so that scoring works with partial data. +/// Missing dimensions are excluded from the weighted average. +#[derive(Debug, Clone, Default)] +pub struct HealthMetrics { + /// Shannon entropy of the commit author distribution. + pub commit_entropy: Option, + /// Fix commits / total commits. + pub fix_ratio: Option, + /// Fraction of contributors with exactly one commit. + pub one_commit_rate: Option, + /// Slope from linear regression of commit counts over time windows. + pub velocity_slope: Option, + /// Fraction of issues with zero comments. + pub zero_comment_rate: Option, +} + +/// Compute the overall health score from the provided metrics and weights. +/// +/// Dimensions with `None` values are excluded from the weighted average, +/// and their weight is redistributed proportionally among available dimensions. +/// Returns `None` if no dimensions have data. +#[must_use] +pub fn compute_health_score( + metrics: &HealthMetrics, + weights: &HealthWeights, +) -> Option { + let mut dimensions = BTreeMap::new(); + let mut weighted_sum = 0.0_f64; + let mut total_weight = 0.0_f64; + + if let Some(entropy) = metrics.commit_entropy { + let grade = grade_commit_entropy(entropy); + dimensions.insert( + "bus_factor".to_owned(), + DimensionScore { + grade, + value: entropy, + metric_name: "commit_entropy".to_owned(), + description: "Contributor diversity measured by commit author entropy".to_owned(), + }, + ); + weighted_sum += weights.bus_factor * grade.to_numeric(); + total_weight += weights.bus_factor; + } + + if let Some(ratio) = metrics.fix_ratio { + let grade = grade_fix_ratio(ratio); + dimensions.insert( + "code_stability".to_owned(), + DimensionScore { + grade, + value: ratio, + metric_name: "fix_ratio".to_owned(), + description: "Code stability measured by fix commit ratio".to_owned(), + }, + ); + weighted_sum += weights.code_stability * grade.to_numeric(); + total_weight += weights.code_stability; + } + + if let Some(rate) = metrics.one_commit_rate { + let grade = grade_one_commit_rate(rate); + dimensions.insert( + "contributor_retention".to_owned(), + DimensionScore { + grade, + value: rate, + metric_name: "one_commit_rate".to_owned(), + description: "Contributor retention measured by one-commit contributor rate" + .to_owned(), + }, + ); + weighted_sum += weights.contributor_retention * grade.to_numeric(); + total_weight += weights.contributor_retention; + } + + if let Some(slope) = metrics.velocity_slope { + let grade = grade_velocity_trend(slope); + dimensions.insert( + "velocity_trend".to_owned(), + DimensionScore { + grade, + value: slope, + metric_name: "velocity_slope".to_owned(), + description: "Development velocity trend from commit frequency over time" + .to_owned(), + }, + ); + weighted_sum += weights.velocity_trend * grade.to_numeric(); + total_weight += weights.velocity_trend; + } + + if let Some(rate) = metrics.zero_comment_rate { + let grade = grade_zero_comment_rate(rate); + dimensions.insert( + "issue_response".to_owned(), + DimensionScore { + grade, + value: rate, + metric_name: "zero_comment_rate".to_owned(), + description: "Issue responsiveness measured by zero-comment issue rate".to_owned(), + }, + ); + weighted_sum += weights.issue_response * grade.to_numeric(); + total_weight += weights.issue_response; + } + + if total_weight < f64::EPSILON { + return None; + } + + let overall_numeric = weighted_sum / total_weight; + let overall = LetterGrade::from_numeric(overall_numeric); + + Some(HealthScore { + overall, + overall_numeric, + dimensions, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +515,292 @@ mod tests { "expected 0.5*0.8 + 0.5*0.6 = 0.7, got {score}" ); } + + // ---- LetterGrade ---- + + #[test] + fn letter_grade_display() { + assert_eq!(LetterGrade::A.to_string(), "A"); + assert_eq!(LetterGrade::AMinus.to_string(), "A-"); + assert_eq!(LetterGrade::BPlus.to_string(), "B+"); + assert_eq!(LetterGrade::B.to_string(), "B"); + assert_eq!(LetterGrade::BMinus.to_string(), "B-"); + assert_eq!(LetterGrade::CPlus.to_string(), "C+"); + assert_eq!(LetterGrade::C.to_string(), "C"); + assert_eq!(LetterGrade::CMinus.to_string(), "C-"); + assert_eq!(LetterGrade::DPlus.to_string(), "D+"); + assert_eq!(LetterGrade::D.to_string(), "D"); + assert_eq!(LetterGrade::F.to_string(), "F"); + } + + #[test] + fn letter_grade_numeric_roundtrip() { + let grades = [ + LetterGrade::A, + LetterGrade::AMinus, + LetterGrade::BPlus, + LetterGrade::B, + LetterGrade::BMinus, + LetterGrade::CPlus, + LetterGrade::C, + LetterGrade::CMinus, + LetterGrade::DPlus, + LetterGrade::D, + LetterGrade::F, + ]; + for grade in &grades { + let numeric = grade.to_numeric(); + assert!( + (0.0..=4.0).contains(&numeric), + "grade {grade} numeric {numeric} out of range" + ); + let back = LetterGrade::from_numeric(numeric); + assert_eq!( + *grade, back, + "roundtrip failed: {grade} -> {numeric} -> {back}" + ); + } + } + + #[test] + fn from_numeric_boundary_values() { + assert_eq!(LetterGrade::from_numeric(4.0), LetterGrade::A); + assert_eq!(LetterGrade::from_numeric(3.85), LetterGrade::A); + assert_eq!(LetterGrade::from_numeric(3.84), LetterGrade::AMinus); + assert_eq!(LetterGrade::from_numeric(0.49), LetterGrade::F); + assert_eq!(LetterGrade::from_numeric(0.0), LetterGrade::F); + assert_eq!(LetterGrade::from_numeric(0.5), LetterGrade::D); + } + + // ---- Individual grading rubrics ---- + + #[test] + fn grade_commit_entropy_thresholds() { + assert_eq!(grade_commit_entropy(6.0), LetterGrade::A); + assert_eq!(grade_commit_entropy(5.1), LetterGrade::A); + assert_eq!(grade_commit_entropy(4.6), LetterGrade::AMinus); + assert_eq!(grade_commit_entropy(4.1), LetterGrade::BPlus); + assert_eq!(grade_commit_entropy(3.6), LetterGrade::B); + assert_eq!(grade_commit_entropy(3.1), LetterGrade::C); + assert_eq!(grade_commit_entropy(2.6), LetterGrade::D); + assert_eq!(grade_commit_entropy(2.0), LetterGrade::F); + assert_eq!(grade_commit_entropy(0.0), LetterGrade::F); + } + + #[test] + fn grade_fix_ratio_thresholds() { + assert_eq!(grade_fix_ratio(0.01), LetterGrade::A); + assert_eq!(grade_fix_ratio(0.04), LetterGrade::A); + assert_eq!(grade_fix_ratio(0.05), LetterGrade::B); + assert_eq!(grade_fix_ratio(0.09), LetterGrade::B); + assert_eq!(grade_fix_ratio(0.10), LetterGrade::C); + assert_eq!(grade_fix_ratio(0.14), LetterGrade::C); + assert_eq!(grade_fix_ratio(0.15), LetterGrade::D); + assert_eq!(grade_fix_ratio(0.19), LetterGrade::D); + assert_eq!(grade_fix_ratio(0.20), LetterGrade::F); + assert_eq!(grade_fix_ratio(0.50), LetterGrade::F); + } + + #[test] + fn grade_one_commit_rate_thresholds() { + assert_eq!(grade_one_commit_rate(0.10), LetterGrade::A); + assert_eq!(grade_one_commit_rate(0.19), LetterGrade::A); + assert_eq!(grade_one_commit_rate(0.20), LetterGrade::B); + assert_eq!(grade_one_commit_rate(0.34), LetterGrade::B); + assert_eq!(grade_one_commit_rate(0.35), LetterGrade::C); + assert_eq!(grade_one_commit_rate(0.49), LetterGrade::C); + assert_eq!(grade_one_commit_rate(0.50), LetterGrade::D); + assert_eq!(grade_one_commit_rate(0.65), LetterGrade::F); + assert_eq!(grade_one_commit_rate(0.90), LetterGrade::F); + } + + #[test] + fn grade_velocity_trend_thresholds() { + assert_eq!(grade_velocity_trend(3.0), LetterGrade::A); + assert_eq!(grade_velocity_trend(1.5), LetterGrade::BPlus); + assert_eq!(grade_velocity_trend(0.5), LetterGrade::B); + assert_eq!(grade_velocity_trend(-0.2), LetterGrade::C); + assert_eq!(grade_velocity_trend(-0.7), LetterGrade::D); + assert_eq!(grade_velocity_trend(-2.0), LetterGrade::F); + } + + #[test] + fn grade_zero_comment_rate_thresholds() { + assert_eq!(grade_zero_comment_rate(0.05), LetterGrade::A); + assert_eq!(grade_zero_comment_rate(0.15), LetterGrade::B); + assert_eq!(grade_zero_comment_rate(0.25), LetterGrade::C); + assert_eq!(grade_zero_comment_rate(0.45), LetterGrade::D); + assert_eq!(grade_zero_comment_rate(0.60), LetterGrade::F); + } + + // ---- Overall health score ---- + + #[test] + fn health_score_perfect() { + let metrics = HealthMetrics { + commit_entropy: Some(6.0), + fix_ratio: Some(0.01), + one_commit_rate: Some(0.10), + velocity_slope: Some(3.0), + zero_comment_rate: Some(0.05), + }; + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + assert!(score.is_some()); + let score = score.unwrap_or_else(|| HealthScore { + overall: LetterGrade::F, + overall_numeric: 0.0, + dimensions: BTreeMap::new(), + }); + assert_eq!(score.overall, LetterGrade::A); + assert!((score.overall_numeric - 4.0).abs() < 1e-10); + assert_eq!(score.dimensions.len(), 5); + } + + #[test] + fn health_score_worst() { + let metrics = HealthMetrics { + commit_entropy: Some(0.0), + fix_ratio: Some(0.50), + one_commit_rate: Some(0.90), + velocity_slope: Some(-2.0), + zero_comment_rate: Some(0.80), + }; + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + assert!(score.is_some()); + let score = score.unwrap_or_else(|| HealthScore { + overall: LetterGrade::A, + overall_numeric: 4.0, + dimensions: BTreeMap::new(), + }); + assert_eq!(score.overall, LetterGrade::F); + assert!((score.overall_numeric - 0.0).abs() < 1e-10); + assert_eq!(score.dimensions.len(), 5); + } + + #[test] + fn health_score_empty_input() { + let metrics = HealthMetrics::default(); + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + assert!(score.is_none()); + } + + #[test] + fn health_score_partial_data() { + let metrics = HealthMetrics { + commit_entropy: Some(4.5), + fix_ratio: Some(0.08), + one_commit_rate: None, + velocity_slope: None, + zero_comment_rate: None, + }; + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + assert!(score.is_some()); + let score = score.unwrap_or_else(|| HealthScore { + overall: LetterGrade::F, + overall_numeric: 0.0, + dimensions: BTreeMap::new(), + }); + assert_eq!(score.dimensions.len(), 2); + // commit_entropy=4.5 grades to BPlus (3.3) since threshold is > 4.5 for A- + // fix_ratio=0.08 grades to B (3.0) since threshold is < 0.10 + let expected = (0.25 * 3.3 + 0.20 * 3.0) / (0.25 + 0.20); + assert!( + (score.overall_numeric - expected).abs() < 1e-10, + "expected {expected}, got {}", + score.overall_numeric + ); + } + + #[test] + fn health_score_mixed_grades() { + let metrics = HealthMetrics { + commit_entropy: Some(3.6), + fix_ratio: Some(0.12), + one_commit_rate: Some(0.40), + velocity_slope: Some(0.5), + zero_comment_rate: Some(0.30), + }; + let weights = HealthWeights::default(); + let score = compute_health_score(&metrics, &weights); + assert!(score.is_some()); + let score = score.unwrap_or_else(|| HealthScore { + overall: LetterGrade::F, + overall_numeric: 0.0, + dimensions: BTreeMap::new(), + }); + let expected = 0.25 * 3.0 + 0.20 * 2.0 + 0.20 * 2.0 + 0.15 * 3.0 + 0.20 * 2.0; + assert!( + (score.overall_numeric - expected).abs() < 1e-10, + "expected {expected}, got {}", + score.overall_numeric + ); + } + + #[test] + fn health_weights_default_sum_to_one() { + let weights = HealthWeights::default(); + let total = weights.total(); + assert!( + (total - 1.0).abs() < 1e-10, + "default health weights sum to {total}, expected 1.0" + ); + } + + #[test] + fn all_grades_are_valid_variants() { + let all_grades = [ + LetterGrade::A, + LetterGrade::AMinus, + LetterGrade::BPlus, + LetterGrade::B, + LetterGrade::BMinus, + LetterGrade::CPlus, + LetterGrade::C, + LetterGrade::CMinus, + LetterGrade::DPlus, + LetterGrade::D, + LetterGrade::F, + ]; + for grade in &all_grades { + let n = grade.to_numeric(); + assert!((0.0..=4.0).contains(&n), "grade {grade} out of range: {n}"); + assert!(!grade.as_str().is_empty()); + } + } + + #[test] + fn overall_numeric_in_valid_range() { + let test_cases = [ + (6.0, 0.01, 0.10, 3.0, 0.05), + (0.0, 0.50, 0.90, -2.0, 0.80), + (3.6, 0.12, 0.40, 0.5, 0.30), + (5.5, 0.03, 0.55, -0.8, 0.15), + ]; + let weights = HealthWeights::default(); + for (ent, fix, ocr, slope, zcr) in &test_cases { + let metrics = HealthMetrics { + commit_entropy: Some(*ent), + fix_ratio: Some(*fix), + one_commit_rate: Some(*ocr), + velocity_slope: Some(*slope), + zero_comment_rate: Some(*zcr), + }; + let score = compute_health_score(&metrics, &weights); + assert!(score.is_some()); + let score = score.unwrap_or_else(|| HealthScore { + overall: LetterGrade::F, + overall_numeric: 0.0, + dimensions: BTreeMap::new(), + }); + assert!( + (0.0..=4.0).contains(&score.overall_numeric), + "overall_numeric out of range: {}", + score.overall_numeric + ); + } + } }