stemedb/applications/aphoria/src/claims_explain.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

- Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping)
- LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index
- EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation)
- CLI handlers: all claim commands read/write through StemeDB
- Scanner: loads claims from StemeDB with auto-migration fallback to TOML
- Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON

Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations,
fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes
pre-existing clippy warnings (too_many_arguments, filter_next→rfind).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 02:02:51 -07:00

210 lines
7.3 KiB
Rust

//! Markdown rendering for authored claims.
//!
//! Generates `claims-explained.md` style output, grouping claims by category
//! and rendering full provenance details.
use crate::types::authored_claim::{
format_authority_tier, parse_authority_tier, AuthoredClaim, ClaimStatus,
};
/// Render all claims as a markdown document grouped by category.
pub fn render_claims_markdown(claims: &[AuthoredClaim], project_name: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# Claims for {project_name}\n\n"));
// Group by category
let mut categories: std::collections::BTreeMap<String, Vec<&AuthoredClaim>> =
std::collections::BTreeMap::new();
for claim in claims {
categories.entry(claim.category.clone()).or_default().push(claim);
}
if categories.is_empty() {
out.push_str("No claims authored yet.\n");
return out;
}
for (category, cat_claims) in &categories {
let active_count = cat_claims.iter().filter(|c| c.status == ClaimStatus::Active).count();
let title = capitalize(category);
out.push_str(&format!(
"## {title} Claims ({active_count} active, {} total)\n\n",
cat_claims.len()
));
for claim in cat_claims {
render_single_claim(&mut out, claim);
out.push('\n');
}
}
out
}
/// Render a single claim as a markdown section.
pub fn render_single_claim(out: &mut String, claim: &AuthoredClaim) {
let status_badge = match claim.status {
ClaimStatus::Draft => " [DRAFT]",
ClaimStatus::Active => "",
ClaimStatus::Deprecated => " [DEPRECATED]",
ClaimStatus::Superseded => " [SUPERSEDED]",
};
out.push_str(&format!("### {}: {}{status_badge}\n", claim.id, claim.invariant));
out.push_str(&format!("- **Concept:** `{}`\n", claim.concept_path));
out.push_str(&format!("- **Predicate:** `{}` = `{}`\n", claim.predicate, claim.value));
out.push_str(&format!("- **Invariant:** {}\n", claim.invariant));
out.push_str(&format!("- **Consequence:** {}\n", claim.consequence));
out.push_str(&format!("- **Provenance:** {}\n", claim.provenance));
let tier_display = parse_authority_tier(&claim.authority_tier)
.map(format_authority_tier)
.unwrap_or_else(|_| {
tracing::warn!(
claim_id = %claim.id,
raw_tier = %claim.authority_tier,
"Failed to parse authority tier, using raw value"
);
claim.authority_tier.clone()
});
out.push_str(&format!("- **Authority:** {tier_display}\n"));
if !claim.evidence.is_empty() {
out.push_str(&format!("- **Evidence:** {}\n", claim.evidence.join(", ")));
}
out.push_str(&format!("- **Status:** {}\n", claim.status));
out.push_str(&format!("- **Author:** {} ({})\n", claim.created_by, claim.created_at));
if let Some(ref supersedes) = claim.supersedes {
out.push_str(&format!("- **Supersedes:** {supersedes}\n"));
}
if let Some(ref updated) = claim.updated_at {
out.push_str(&format!("- **Updated:** {updated}\n"));
}
}
/// Render a single claim as JSON wrapped in a structured envelope.
pub fn render_claim_json(
claim: &AuthoredClaim,
project_name: &str,
) -> Result<String, serde_json::Error> {
let envelope = serde_json::json!({
"type": "claim_detail",
"project": project_name,
"claim": claim
});
serde_json::to_string_pretty(&envelope)
}
/// Render all claims as JSON wrapped in a structured envelope.
pub fn render_claims_json(
claims: &[AuthoredClaim],
project_name: &str,
) -> Result<String, serde_json::Error> {
let envelope = serde_json::json!({
"type": "claims_explain",
"project": project_name,
"total": claims.len(),
"claims": claims
});
serde_json::to_string_pretty(&envelope)
}
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;
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: Default::default(),
provenance: "Test provenance".to_string(),
invariant: "Test invariant MUST hold".to_string(),
consequence: "Bad things happen".to_string(),
authority_tier: "expert".to_string(),
evidence: vec!["ADR-001".to_string()],
category: category.to_string(),
status: ClaimStatus::Active,
supersedes: None,
created_by: "tester".to_string(),
created_at: "2026-02-08T12:00:00Z".to_string(),
updated_at: None,
}
}
#[test]
fn test_render_empty() {
let md = render_claims_markdown(&[], "test-project");
assert!(md.contains("No claims authored yet"));
}
#[test]
fn test_render_groups_by_category() {
let claims = vec![
sample_claim("safety-001", "safety"),
sample_claim("arch-001", "architecture"),
sample_claim("safety-002", "safety"),
];
let md = render_claims_markdown(&claims, "test-project");
assert!(md.contains("## Architecture Claims (1 active, 1 total)"));
assert!(md.contains("## Safety Claims (2 active, 2 total)"));
}
#[test]
fn test_render_single_claim_fields() {
let claim = sample_claim("test-001", "safety");
let mut out = String::new();
render_single_claim(&mut out, &claim);
assert!(out.contains("### test-001:"));
assert!(out.contains("**Concept:** `test/concept`"));
assert!(out.contains("**Predicate:** `test_pred` = `test_value`"));
assert!(out.contains("**Invariant:**"));
assert!(out.contains("**Consequence:**"));
assert!(out.contains("**Provenance:**"));
assert!(out.contains("Expert (Tier 3)"));
assert!(out.contains("**Evidence:** ADR-001"));
}
#[test]
fn test_deprecated_badge() {
let mut claim = sample_claim("dep-001", "safety");
claim.status = ClaimStatus::Deprecated;
let mut out = String::new();
render_single_claim(&mut out, &claim);
assert!(out.contains("[DEPRECATED]"));
}
#[test]
fn test_render_json() {
let claim = sample_claim("test-001", "safety");
let json = render_claim_json(&claim, "test-project").expect("json");
assert!(json.contains("\"type\": \"claim_detail\""));
assert!(json.contains("\"project\": \"test-project\""));
assert!(json.contains("\"id\": \"test-001\""));
}
#[test]
fn test_render_claims_json_envelope() {
let claims = vec![sample_claim("c-001", "arch"), sample_claim("c-002", "safety")];
let json = render_claims_json(&claims, "my-project").expect("json");
assert!(json.contains("\"type\": \"claims_explain\""));
assert!(json.contains("\"total\": 2"));
assert!(json.contains("\"project\": \"my-project\""));
}
}