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>
531 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|