stemedb/applications/aphoria/src/explain.rs
jml 3b5f88b4f0 feat(aphoria): implement claims architecture (A1-A5) with verify engine, corpus, coverage, and explain
Complete Aphoria claims system overhaul:
- A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims)
- A2: Add AuthoredClaim with full provenance, invariants, and authority tiers
- A3: Verify engine comparing observations against authored claims, CLI + formatters
- A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs
- A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill

Also includes: 42 extractors updated for Observation type, verifiable_predicates trait,
conflict detection with comparison modes, claims TOML persistence, Grafana dashboard,
backup/restore scripts, and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:11:47 +00:00

531 lines
18 KiB
Rust

//! Narrative explanation generation for project claims.
//!
//! Three distinct outputs:
//! - `generate_onboarding()` — lightweight summary for `aphoria explain`
//! - `generate_full_docs()` — comprehensive reference for `aphoria docs generate`
//! - `generate_explanation()` — legacy function (kept for backward compat, delegates to `generate_onboarding`)
use std::collections::BTreeMap;
use crate::coverage::CoverageReport;
use crate::types::authored_claim::{AuthoredClaim, ClaimStatus};
use crate::verify::{AuditVerdict, VerifyReport};
/// Generate an onboarding overview for `aphoria explain`.
///
/// Lightweight narrative: category counts, verification health, coverage snapshot.
/// Takes pre-computed data so the caller handles scanning.
pub fn generate_onboarding(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
format: &str,
) -> String {
match format {
"json" => generate_onboarding_json(claims, verify_report, coverage_report, project_name),
_ => generate_onboarding_markdown(claims, verify_report, coverage_report, project_name),
}
}
/// Generate comprehensive reference docs for `aphoria docs generate`.
///
/// Full claim details + verification results + coverage table.
/// Takes pre-computed data so the caller handles scanning.
pub fn generate_full_docs(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
format: &str,
) -> String {
match format {
"json" => generate_full_docs_json(claims, verify_report, coverage_report, project_name),
_ => generate_full_docs_markdown(claims, verify_report, coverage_report, project_name),
}
}
// --- Onboarding (aphoria explain) ---
fn generate_onboarding_markdown(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
) -> String {
let mut out = String::new();
out.push_str(&format!("# {project_name} — Claim Overview\n\n"));
// Category summary
let categories = group_by_category(claims);
let active_count = claims.iter().filter(|c| c.status == ClaimStatus::Active).count();
out.push_str(&format!(
"**{project_name}** has **{active_count}** active claims across **{}** categories.\n\n",
categories.len()
));
if !categories.is_empty() {
out.push_str("## Categories\n\n");
out.push_str("| Category | Active | Total |\n");
out.push_str("|----------|--------|-------|\n");
for (cat, cat_claims) in &categories {
let active = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count();
out.push_str(&format!("| {} | {} | {} |\n", capitalize(cat), active, cat_claims.len()));
}
out.push('\n');
}
// Verification health
let summary = &verify_report.summary;
out.push_str("## Verification Health\n\n");
out.push_str(&format!(
"- **Pass:** {}\n- **Conflict:** {}\n- **Missing:** {}\n- **Unclaimed observations:** {}\n\n",
summary.pass, summary.conflict, summary.missing, summary.unclaimed,
));
if summary.conflict > 0 {
out.push_str("*Conflicts indicate code behavior differs from authored claims.*\n\n");
}
// Coverage snapshot
let cov = &coverage_report.summary;
out.push_str("## Coverage Snapshot\n\n");
out.push_str(&format!(
"- **Claimed:** {:.1}% of {} observations\n",
cov.claimed_percentage, cov.total_observations,
));
out.push_str(&format!(
"- **Modules with claims:** {} / {}\n",
cov.modules_with_claims,
cov.modules_with_claims + cov.modules_without_claims,
));
// Top uncovered modules
let uncovered: Vec<_> = coverage_report
.modules
.iter()
.filter(|m| m.claim_count == 0 && m.observation_count > 0)
.take(5)
.collect();
if !uncovered.is_empty() {
out.push_str("\n**Top uncovered modules:**\n");
for m in uncovered {
out.push_str(&format!(
"- `{}` ({} observations)\n",
m.module_path, m.observation_count,
));
}
}
out.push_str("\n---\n");
out.push_str("Run `aphoria claims explain` for full claim details.\n");
out.push_str("Run `aphoria docs generate` for comprehensive reference documentation.\n");
out
}
fn generate_onboarding_json(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
) -> String {
let categories = group_by_category(claims);
let active_count = claims.iter().filter(|c| c.status == ClaimStatus::Active).count();
let cat_summary: Vec<serde_json::Value> = categories
.iter()
.map(|(cat, cat_claims)| {
let active = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count();
serde_json::json!({
"category": cat,
"active": active,
"total": cat_claims.len(),
})
})
.collect();
let json = serde_json::json!({
"project": project_name,
"type": "onboarding",
"active_claims": active_count,
"categories": cat_summary,
"verification": {
"pass": verify_report.summary.pass,
"conflict": verify_report.summary.conflict,
"missing": verify_report.summary.missing,
"unclaimed": verify_report.summary.unclaimed,
},
"coverage": {
"claimed_percentage": coverage_report.summary.claimed_percentage,
"total_observations": coverage_report.summary.total_observations,
"modules_with_claims": coverage_report.summary.modules_with_claims,
"modules_without_claims": coverage_report.summary.modules_without_claims,
},
});
serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string())
}
// --- Full docs (aphoria docs generate) ---
fn generate_full_docs_markdown(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
) -> String {
let mut out = String::new();
out.push_str(&format!("# {project_name} — Reference Documentation\n\n"));
// Section 1: Full claim details (reuse claims_explain)
out.push_str(&crate::claims_explain::render_claims_markdown(claims, project_name));
out.push('\n');
// Section 2: Verification results
out.push_str("---\n\n");
out.push_str("# Verification Results\n\n");
let summary = &verify_report.summary;
out.push_str(&format!(
"**Total:** {} claims verified — {} pass, {} conflict, {} missing, {} unclaimed observations\n\n",
summary.total_claims, summary.pass, summary.conflict, summary.missing, summary.unclaimed,
));
// Group verify results by verdict
let mut conflicts = Vec::new();
let mut missing = Vec::new();
for result in &verify_report.results {
match result.verdict {
AuditVerdict::Conflict => conflicts.push(result),
AuditVerdict::Missing => missing.push(result),
_ => {}
}
}
if !conflicts.is_empty() {
out.push_str("## Conflicts\n\n");
for r in &conflicts {
if let Some(ref claim) = r.claim {
out.push_str(&format!("- **{}**: {}\n", claim.id, r.explanation));
}
}
out.push('\n');
}
if !missing.is_empty() {
out.push_str("## Missing Observations\n\n");
for r in &missing {
if let Some(ref claim) = r.claim {
out.push_str(&format!("- **{}**: {}\n", claim.id, r.explanation));
}
}
out.push('\n');
}
// Section 3: Coverage table
out.push_str("---\n\n");
out.push_str(&crate::coverage::format_coverage_markdown(coverage_report));
out
}
fn generate_full_docs_json(
claims: &[AuthoredClaim],
verify_report: &VerifyReport,
coverage_report: &CoverageReport,
project_name: &str,
) -> String {
let claims_json: Vec<serde_json::Value> = claims
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"concept_path": c.concept_path,
"predicate": c.predicate,
"value": format!("{}", c.value),
"provenance": c.provenance,
"invariant": c.invariant,
"consequence": c.consequence,
"authority_tier": c.authority_tier,
"category": c.category,
"status": format!("{:?}", c.status),
})
})
.collect();
let verify_json: Vec<serde_json::Value> = verify_report
.results
.iter()
.filter(|r| r.claim.is_some())
.map(|r| {
let claim = r.claim.as_ref().unwrap_or_else(|| {
// Safety: filtered above
unreachable!()
});
serde_json::json!({
"claim_id": claim.id,
"verdict": format!("{}", r.verdict),
"explanation": r.explanation,
"matching_observations": r.matching_observations.len(),
})
})
.collect();
let json = serde_json::json!({
"project": project_name,
"type": "full_docs",
"claims": claims_json,
"verification": {
"summary": {
"total_claims": verify_report.summary.total_claims,
"pass": verify_report.summary.pass,
"conflict": verify_report.summary.conflict,
"missing": verify_report.summary.missing,
"unclaimed": verify_report.summary.unclaimed,
},
"results": verify_json,
},
"coverage": coverage_report,
});
serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string())
}
// --- Helpers ---
fn group_by_category(claims: &[AuthoredClaim]) -> BTreeMap<String, Vec<&AuthoredClaim>> {
let mut categories: BTreeMap<String, Vec<&AuthoredClaim>> = BTreeMap::new();
for claim in claims {
categories.entry(claim.category.clone()).or_default().push(claim);
}
categories
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::authored_claim::{AuthoredValue, ComparisonMode};
use crate::verify::{VerifyResult, VerifySummary};
use crate::coverage::{CoverageSummary, ModuleCoverage};
fn sample_claim(id: &str, category: &str) -> AuthoredClaim {
AuthoredClaim {
id: id.to_string(),
concept_path: "test/concept".to_string(),
predicate: "test_pred".to_string(),
value: AuthoredValue::Text("test_value".to_string()),
comparison: ComparisonMode::default(),
provenance: "Test provenance".to_string(),
invariant: "Test invariant".to_string(),
consequence: "Bad things happen".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 empty_verify_report() -> VerifyReport {
VerifyReport {
results: vec![],
summary: VerifySummary::default(),
}
}
fn empty_coverage_report() -> CoverageReport {
CoverageReport {
project: "test".to_string(),
modules: vec![],
summary: CoverageSummary {
total_observations: 0,
total_claims: 0,
claimed_percentage: 0.0,
unclaimed_count: 0,
modules_with_claims: 0,
modules_without_claims: 0,
},
}
}
fn sample_verify_report() -> VerifyReport {
VerifyReport {
results: vec![
VerifyResult {
claim: Some(sample_claim("c1", "safety")),
verdict: AuditVerdict::Pass,
matching_observations: vec![],
explanation: "Matches".to_string(),
},
VerifyResult {
claim: Some(sample_claim("c2", "architecture")),
verdict: AuditVerdict::Conflict,
matching_observations: vec![],
explanation: "Value mismatch".to_string(),
},
],
summary: VerifySummary {
total_claims: 2,
pass: 1,
conflict: 1,
missing: 0,
unclaimed: 3,
},
}
}
fn sample_coverage_report() -> CoverageReport {
CoverageReport {
project: "test".to_string(),
modules: vec![
ModuleCoverage {
module_path: "tls".to_string(),
files: vec!["src/tls/config.rs".to_string()],
observation_count: 5,
claim_count: 2,
claimed_observations: 3,
unclaimed_observations: 2,
missing_claims: 0,
density: 0.4,
},
ModuleCoverage {
module_path: "auth".to_string(),
files: vec!["src/auth/jwt.rs".to_string()],
observation_count: 3,
claim_count: 0,
claimed_observations: 0,
unclaimed_observations: 3,
missing_claims: 0,
density: 0.0,
},
],
summary: CoverageSummary {
total_observations: 8,
total_claims: 2,
claimed_percentage: 37.5,
unclaimed_count: 5,
modules_with_claims: 1,
modules_without_claims: 1,
},
}
}
#[test]
fn test_onboarding_has_category_table() {
let claims = vec![
sample_claim("s1", "safety"),
sample_claim("a1", "architecture"),
sample_claim("s2", "safety"),
];
let out = generate_onboarding(&claims, &empty_verify_report(), &empty_coverage_report(), "myproject", "markdown");
assert!(out.contains("# myproject"));
assert!(out.contains("3** active claims"));
assert!(out.contains("| Safety"));
assert!(out.contains("| Architecture"));
}
#[test]
fn test_onboarding_shows_verification_health() {
let claims = vec![sample_claim("c1", "safety")];
let vr = sample_verify_report();
let out = generate_onboarding(&claims, &vr, &empty_coverage_report(), "proj", "markdown");
assert!(out.contains("**Pass:** 1"));
assert!(out.contains("**Conflict:** 1"));
assert!(out.contains("Conflicts indicate"));
}
#[test]
fn test_onboarding_shows_coverage_snapshot() {
let claims = vec![sample_claim("c1", "safety")];
let cr = sample_coverage_report();
let out = generate_onboarding(&claims, &empty_verify_report(), &cr, "proj", "markdown");
assert!(out.contains("37.5%"));
assert!(out.contains("`auth`"));
}
#[test]
fn test_onboarding_pointers() {
let out = generate_onboarding(&[], &empty_verify_report(), &empty_coverage_report(), "proj", "markdown");
assert!(out.contains("aphoria claims explain"));
assert!(out.contains("aphoria docs generate"));
}
#[test]
fn test_full_docs_includes_claim_details() {
let claims = vec![sample_claim("c1", "safety")];
let out = generate_full_docs(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "markdown");
// Should contain per-claim fields from claims_explain
assert!(out.contains("**Concept:**"));
assert!(out.contains("**Invariant:**"));
}
#[test]
fn test_full_docs_includes_verify_results() {
let claims = vec![sample_claim("c1", "safety")];
let vr = sample_verify_report();
let out = generate_full_docs(&claims, &vr, &empty_coverage_report(), "proj", "markdown");
assert!(out.contains("# Verification Results"));
assert!(out.contains("## Conflicts"));
assert!(out.contains("Value mismatch"));
}
#[test]
fn test_full_docs_includes_coverage_table() {
let claims = vec![sample_claim("c1", "safety")];
let cr = sample_coverage_report();
let out = generate_full_docs(&claims, &empty_verify_report(), &cr, "proj", "markdown");
assert!(out.contains("# Aphoria Coverage:"));
assert!(out.contains("Coverage Gaps"));
}
#[test]
fn test_onboarding_and_full_docs_differ() {
let claims = vec![sample_claim("c1", "safety")];
let vr = sample_verify_report();
let cr = sample_coverage_report();
let onboarding = generate_onboarding(&claims, &vr, &cr, "proj", "markdown");
let full_docs = generate_full_docs(&claims, &vr, &cr, "proj", "markdown");
assert_ne!(onboarding, full_docs);
// Onboarding should NOT have per-claim concept details
assert!(!onboarding.contains("**Concept:**"));
// Full docs should NOT have the "Run `aphoria claims explain`" pointer
assert!(!full_docs.contains("Run `aphoria claims explain`"));
}
#[test]
fn test_onboarding_json() {
let claims = vec![sample_claim("c1", "safety")];
let out = generate_onboarding(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "json");
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap_or_default();
assert_eq!(parsed["type"], "onboarding");
assert_eq!(parsed["active_claims"], 1);
}
#[test]
fn test_full_docs_json() {
let claims = vec![sample_claim("c1", "safety")];
let out = generate_full_docs(&claims, &empty_verify_report(), &empty_coverage_report(), "proj", "json");
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap_or_default();
assert_eq!(parsed["type"], "full_docs");
assert!(parsed["claims"].is_array());
assert!(parsed["verification"].is_object());
assert!(parsed["coverage"].is_object());
}
}