//! Claim coverage metrics engine. //! //! Computes per-module coverage: how many observations are claimed, //! how many claims are verified, what's uncovered. Uses `verify_claims()` //! as the source of truth for claim-observation matching. use std::collections::BTreeMap; use serde::Serialize; use crate::types::authored_claim::AuthoredClaim; use crate::types::Observation; use crate::verify::{tail_path, verify_claims, AuditVerdict, VerifyReport}; /// Per-module coverage metrics. #[derive(Debug, Clone, Serialize)] pub struct ModuleCoverage { /// Module path (e.g., "wallet/atomics", "tls"). pub module_path: String, /// Files belonging to this module. pub files: Vec, /// Total observations found by extractors in this module. pub observation_count: usize, /// Active authored claims covering this module. pub claim_count: usize, /// Observations matched by at least one claim. pub claimed_observations: usize, /// Observations with no covering claim. pub unclaimed_observations: usize, /// Claims with no matching observation (MISSING verdicts). pub missing_claims: usize, /// Claim density: claim_count / observation_count (0.0 if no observations). pub density: f32, } /// Full coverage report for a project. #[derive(Debug, Clone, Serialize)] pub struct CoverageReport { /// Project name. pub project: String, /// Per-module metrics, sorted by module path. pub modules: Vec, /// Aggregate summary. pub summary: CoverageSummary, } /// Aggregate coverage summary. #[derive(Debug, Clone, Serialize)] pub struct CoverageSummary { /// Total observations across all modules. pub total_observations: usize, /// Total active claims. pub total_claims: usize, /// Percentage of observations covered by claims. pub claimed_percentage: f32, /// Count of observations with no covering claim. pub unclaimed_count: usize, /// Number of modules that have at least one claim. pub modules_with_claims: usize, /// Number of modules with zero claims. pub modules_without_claims: usize, } /// Derive a module path from a file path. /// /// Takes the first 2 directory segments after stripping common prefixes like `src/`. /// Examples: /// - `src/wallet/atomics/sync.rs` → `wallet/atomics` /// - `src/tls/config.rs` → `tls` /// - `config.toml` → `(root)` fn derive_module(file_path: &str) -> String { let path = file_path .strip_prefix("src/") .or_else(|| file_path.strip_prefix("lib/")) .unwrap_or(file_path); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); // Take directory segments only (skip the filename) let dir_segments: Vec<&str> = if segments.len() > 1 { segments[..segments.len() - 1].to_vec() } else { return "(root)".to_string(); }; // Take up to 2 directory segments let module_depth = dir_segments.len().min(2); if module_depth == 0 { "(root)".to_string() } else { dir_segments[..module_depth].join("/") } } /// Derive a module path from a claim's concept_path. /// /// Uses `tail_path` to get the last 2 segments, then takes the first segment /// as the module. For claims without a valid tail path, uses the full concept_path. fn derive_module_from_claim(concept_path: &str) -> String { if let Some(tp) = tail_path(concept_path) { // tail_path gives us "penultimate/last" — use the penultimate as module if let Some(slash) = tp.find('/') { tp[..slash].to_string() } else { tp } } else { // Fallback: strip scheme, use what we have let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path); path.to_string() } } /// Compute coverage metrics from claims, observations, and verification results. pub fn compute_coverage( claims: &[AuthoredClaim], observations: &[Observation], project_name: &str, ) -> CoverageReport { let report = verify_claims(claims, observations); compute_coverage_from_report(claims, observations, &report, project_name) } /// Compute coverage from pre-computed verification report. /// /// Useful when the caller already has a `VerifyReport` and doesn't want /// to re-run verification. pub fn compute_coverage_from_report( claims: &[AuthoredClaim], observations: &[Observation], report: &VerifyReport, project_name: &str, ) -> CoverageReport { // Group observations by module (from file path) let mut obs_by_module: BTreeMap> = BTreeMap::new(); for obs in observations { let module = derive_module(&obs.file); obs_by_module.entry(module).or_default().push(obs); } // Build claim-to-module mapping from verification results. // For claims with matching observations (Pass/Conflict), derive the module // from the observation's file path so claims land in the same bucket as // their observations. For Missing claims, fall back to concept_path. let mut claim_to_module: std::collections::HashMap = std::collections::HashMap::new(); let mut claimed_tails: std::collections::HashSet = std::collections::HashSet::new(); let mut missing_claim_ids: std::collections::HashSet = std::collections::HashSet::new(); for result in &report.results { match result.verdict { AuditVerdict::Pass | AuditVerdict::Conflict => { if let Some(ref claim) = result.claim { if let Some(tp) = tail_path(&claim.concept_path) { claimed_tails.insert(tp); } // Derive module from the first matching observation's file path if let Some(obs) = result.matching_observations.first() { claim_to_module.insert(claim.id.clone(), derive_module(&obs.file)); } } } AuditVerdict::Missing => { if let Some(ref claim) = result.claim { missing_claim_ids.insert(claim.id.clone()); } } AuditVerdict::Unclaimed => {} } } // Group claims by module, using observation-derived module when available let mut claims_by_module: BTreeMap> = BTreeMap::new(); for claim in claims { if claim.status == crate::types::ClaimStatus::Active { let module = claim_to_module .get(&claim.id) .cloned() .unwrap_or_else(|| derive_module_from_claim(&claim.concept_path)); claims_by_module.entry(module).or_default().push(claim); } } // Collect all module names from both observations and claims let mut all_modules: std::collections::BTreeSet = std::collections::BTreeSet::new(); for key in obs_by_module.keys() { all_modules.insert(key.clone()); } for key in claims_by_module.keys() { all_modules.insert(key.clone()); } // Build per-module coverage let mut modules = Vec::new(); let mut total_observations = 0usize; let mut total_claimed = 0usize; let mut total_unclaimed = 0usize; let mut modules_with_claims = 0usize; let mut modules_without_claims = 0usize; for module in &all_modules { let obs_list = obs_by_module.get(module); let claim_list = claims_by_module.get(module); let observation_count = obs_list.map(|v| v.len()).unwrap_or(0); let claim_count = claim_list.map(|v| v.len()).unwrap_or(0); // Count how many observations in this module are claimed let claimed_obs = obs_list .map(|obs| { obs.iter() .filter(|o| { tail_path(&o.concept_path) .map(|tp| claimed_tails.contains(&tp)) .unwrap_or(false) }) .count() }) .unwrap_or(0); let unclaimed_obs = observation_count.saturating_sub(claimed_obs); // Count missing claims in this module let missing_in_module = claim_list .map(|cls| cls.iter().filter(|c| missing_claim_ids.contains(&c.id)).count()) .unwrap_or(0); let density = if observation_count > 0 { claim_count as f32 / observation_count as f32 } else { 0.0 }; // Collect unique files in this module let files: Vec = obs_list .map(|obs| { let mut file_set: std::collections::BTreeSet = std::collections::BTreeSet::new(); for o in obs { file_set.insert(o.file.clone()); } file_set.into_iter().collect() }) .unwrap_or_default(); if claim_count > 0 { modules_with_claims += 1; } else { modules_without_claims += 1; } total_observations += observation_count; total_claimed += claimed_obs; total_unclaimed += unclaimed_obs; modules.push(ModuleCoverage { module_path: module.clone(), files, observation_count, claim_count, claimed_observations: claimed_obs, unclaimed_observations: unclaimed_obs, missing_claims: missing_in_module, density, }); } let active_claims = claims.iter().filter(|c| c.status == crate::types::ClaimStatus::Active).count(); let claimed_percentage = if total_observations > 0 { (total_claimed as f32 / total_observations as f32) * 100.0 } else { 0.0 }; CoverageReport { project: project_name.to_string(), modules, summary: CoverageSummary { total_observations, total_claims: active_claims, claimed_percentage, unclaimed_count: total_unclaimed, modules_with_claims, modules_without_claims, }, } } /// Format coverage report as a terminal table. pub fn format_coverage_table(report: &CoverageReport, sort_by: &str) -> String { let mut out = String::new(); out.push_str(&format!("Aphoria Coverage: {}\n\n", report.project)); if report.modules.is_empty() { out.push_str("No observations or claims found.\n"); return out; } let mut modules = report.modules.clone(); match sort_by { "unclaimed" => { modules.sort_by(|a, b| b.unclaimed_observations.cmp(&a.unclaimed_observations)) } "observations" => modules.sort_by(|a, b| b.observation_count.cmp(&a.observation_count)), "density" => modules.sort_by(|a, b| { b.density .partial_cmp(&a.density) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| b.observation_count.cmp(&a.observation_count)) }), _ => {} // default: alphabetical (already sorted by BTreeMap) } let mut table = comfy_table::Table::new(); table.set_header(vec![ "Module", "Claims", "Observations", "Claimed", "Unclaimed", "Missing", "Density", ]); for m in &modules { table.add_row(vec![ m.module_path.clone(), m.claim_count.to_string(), m.observation_count.to_string(), m.claimed_observations.to_string(), m.unclaimed_observations.to_string(), m.missing_claims.to_string(), format!("{:.1}%", m.density * 100.0), ]); } out.push_str(&table.to_string()); out.push_str(&format!( "\n\nSummary: {} claims, {} observations, {:.1}% claimed, {} unclaimed", report.summary.total_claims, report.summary.total_observations, report.summary.claimed_percentage, report.summary.unclaimed_count, )); out.push_str(&format!( "\nModules: {} with claims, {} without claims", report.summary.modules_with_claims, report.summary.modules_without_claims, )); out } /// Format coverage report as JSON. pub fn format_coverage_json(report: &CoverageReport) -> String { serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) } /// Format coverage report as markdown. pub fn format_coverage_markdown(report: &CoverageReport) -> String { let mut out = String::new(); out.push_str(&format!("# Aphoria Coverage: {}\n\n", report.project)); out.push_str("## Summary\n\n"); out.push_str(&format!( "- **Claims:** {}\n- **Observations:** {}\n- **Claimed:** {:.1}%\n- **Unclaimed:** {}\n- **Modules with claims:** {}\n- **Modules without claims:** {}\n\n", report.summary.total_claims, report.summary.total_observations, report.summary.claimed_percentage, report.summary.unclaimed_count, report.summary.modules_with_claims, report.summary.modules_without_claims, )); if report.modules.is_empty() { out.push_str("No observations or claims found.\n"); return out; } out.push_str("## Modules\n\n"); out.push_str("| Module | Claims | Observations | Claimed | Unclaimed | Missing | Density |\n"); out.push_str("|--------|--------|--------------|---------|-----------|---------|----------|\n"); for m in &report.modules { out.push_str(&format!( "| {} | {} | {} | {} | {} | {} | {:.1}% |\n", m.module_path, m.claim_count, m.observation_count, m.claimed_observations, m.unclaimed_observations, m.missing_claims, m.density * 100.0, )); } // Highlight modules with 0 claims let uncovered: Vec<&ModuleCoverage> = report.modules.iter().filter(|m| m.claim_count == 0 && m.observation_count > 0).collect(); if !uncovered.is_empty() { out.push_str("\n## Coverage Gaps\n\n"); out.push_str("These modules have observations but no authored claims:\n\n"); for m in uncovered { out.push_str(&format!( "- **{}** ({} unclaimed observations)\n", m.module_path, m.unclaimed_observations, )); } } out } #[cfg(test)] mod tests { use super::*; use crate::types::authored_claim::{AuthoredValue, ClaimStatus, ComparisonMode}; use stemedb_core::types::ObjectValue; fn make_claim(id: &str, concept_path: &str, category: &str) -> AuthoredClaim { AuthoredClaim { id: id.to_string(), concept_path: concept_path.to_string(), predicate: "test".to_string(), value: AuthoredValue::Text("test".to_string()), comparison: ComparisonMode::Equals, provenance: "test".to_string(), invariant: "test".to_string(), consequence: "test".to_string(), authority_tier: "expert".to_string(), evidence: vec![], category: category.to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "tester".to_string(), created_at: "2026-02-08".to_string(), updated_at: None, } } fn make_obs(concept_path: &str, file: &str) -> Observation { Observation { concept_path: concept_path.to_string(), predicate: "test".to_string(), value: ObjectValue::Text("test".to_string()), file: file.to_string(), line: 1, matched_text: "test".to_string(), confidence: 1.0, description: "test".to_string(), } } #[test] fn test_derive_module() { assert_eq!(derive_module("src/wallet/atomics/sync.rs"), "wallet/atomics"); assert_eq!(derive_module("src/tls/config.rs"), "tls"); assert_eq!(derive_module("config.toml"), "(root)"); assert_eq!(derive_module("src/main.rs"), "(root)"); assert_eq!(derive_module("src/auth/jwt/token.rs"), "auth/jwt"); } #[test] fn test_derive_module_from_claim() { assert_eq!(derive_module_from_claim("project/wallet/atomics/ordering"), "atomics"); assert_eq!(derive_module_from_claim("code://rust/core/imports/tokio"), "imports"); } #[test] fn test_compute_coverage_empty() { let report = compute_coverage(&[], &[], "test"); assert_eq!(report.summary.total_observations, 0); assert_eq!(report.summary.total_claims, 0); assert_eq!(report.summary.claimed_percentage, 0.0); } #[test] fn test_compute_coverage_with_matches() { let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")]; let observations = vec![ make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs"), make_obs("code://rust/project/tls/config", "src/tls/config.rs"), ]; let report = compute_coverage(&claims, &observations, "test"); assert_eq!(report.summary.total_claims, 1); assert_eq!(report.summary.total_observations, 2); assert!(report.summary.unclaimed_count > 0); } #[test] fn test_coverage_table_output() { let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")]; let observations = vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")]; let report = compute_coverage(&claims, &observations, "myproject"); let table = format_coverage_table(&report, "name"); assert!(table.contains("Aphoria Coverage: myproject")); assert!(table.contains("Summary:")); } #[test] fn test_coverage_json_output() { let report = compute_coverage(&[], &[], "test"); let json = format_coverage_json(&report); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); assert_eq!(parsed["project"], "test"); } #[test] fn test_coverage_markdown_output() { let report = compute_coverage(&[], &[], "test"); let md = format_coverage_markdown(&report); assert!(md.starts_with("# Aphoria Coverage: test")); } #[test] fn test_deprecated_claims_excluded() { let mut claim = make_claim("c1", "project/atomics/ordering", "safety"); claim.status = ClaimStatus::Deprecated; let report = compute_coverage(&[claim], &[], "test"); assert_eq!(report.summary.total_claims, 0); } #[test] fn test_claims_map_to_observation_modules() { // Claim concept_path and observation concept_path share tail "atomics/ordering" let claims = vec![make_claim("c1", "project/atomics/ordering", "safety")]; let observations = vec![make_obs("code://rust/project/atomics/ordering", "src/wallet/atomics/sync.rs")]; let report = compute_coverage(&claims, &observations, "test"); // The claim should land in "wallet/atomics" (from observation file path), // NOT "atomics" (from concept_path tail). This means the module should // have both a claim and an observation with non-zero density. let wallet_mod = report.modules.iter().find(|m| m.module_path == "wallet/atomics"); assert!(wallet_mod.is_some(), "Expected wallet/atomics module"); let Some(wallet_mod) = wallet_mod else { panic!("wallet/atomics module not found"); }; assert_eq!(wallet_mod.claim_count, 1); assert_eq!(wallet_mod.observation_count, 1); assert!(wallet_mod.density > 0.0, "density should be non-zero"); } }