stemedb/applications/aphoria/src/coverage.rs
jml 6430ff0fd6 fix(aphoria): move claims.toml to project root and fix verify integration
## Root Cause
Claims file was in applications/aphoria/.aphoria/ but all commands looked
for .aphoria/claims.toml relative to project root. Additionally, .aphoria/
was fully gitignored, preventing version control of claims.

## Changes

### Path Fixes
- Move claims.toml from applications/aphoria/.aphoria/ to .aphoria/ at project root
- Update .gitignore: .aphoria/ → .aphoria/* with !.aphoria/claims.toml exception
- Now claims can be version controlled while keys remain secret

### Verify Integration (Scanner)
- scanner.rs: Load claims from ClaimsFile and call verify_claims()
- ScanResult: Add verify field with VerifyReport
- Report formatters: Add claim verification sections showing PASS/CONFLICT/MISSING

### Clippy Fix
- report/json.rs: Replace filter().map().expect() with filter_map()

## Verification
- aphoria scan . → Shows claim verification with verdicts
- aphoria verify run → Per-claim verification results
- aphoria verify map → Extractor coverage mapping (7/10 claims = 70%)
- aphoria claims list → Reads from project root
- aphoria claims create → Writes to project root
- All tests pass (1120+ aphoria tests)
- clippy --workspace passes

## Impact
Both primary use cases now work:
1. Day-to-day (commit-time): Skills can read/create claims via CLI
2. Audit (scan-time): Scanner verifies code against authored claims

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 11:09:57 +00:00

548 lines
19 KiB
Rust

//! 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<String>,
/// 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<ModuleCoverage>,
/// 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<String, Vec<&Observation>> = 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<String, String> =
std::collections::HashMap::new();
let mut claimed_tails: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut missing_claim_ids: std::collections::HashSet<String> = 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<String, Vec<&AuthoredClaim>> = 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<String> = 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<String> = obs_list
.map(|obs| {
let mut file_set: std::collections::BTreeSet<String> =
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");
}
}