//! `lean-ctx evidence-export` — portable investor/customer evidence package.
use crate::core::{
integration_proof::prove_decision_loop,
savings_tracker::SessionSavings,
shadow::persistence::{list_reports, load_report},
task_benchmark::{config::BenchConfig, fixtures::canonical_suite, runner::run_benchmark},
triage::validation::validate_against_gold_set,
value_gate::ValueGateStore,
};
use serde_json::{Value, json};
use std::{fs, io, path::PathBuf};
pub(crate) fn cmd_evidence_export(args: &[String]) {
if args
.iter()
.any(|arg| matches!(arg.as_str(), "-h" | "--help"))
{
usage();
return;
}
match export(args) {
Ok((dir, format)) if format == "json" => println!("{}", json!({"output": dir})),
Ok((dir, _)) => println!("Evidence package written to {}", dir.display()),
Err(error) => {
eprintln!("evidence-export: {error}");
std::process::exit(2);
}
}
}
#[rustfmt::skip]
fn export(args: &[String]) -> io::Result<(PathBuf, String)> {
let (dir, format) = parse(args)?;
fs::create_dir_all(&dir)?;
let assessments = Value::Array(
ValueGateStore::load_from_disk()
.into_iter()
.filter_map(|item| serde_json::to_value(item).ok())
.collect(),
);
let aggregate = ValueGateStore::default().aggregate();
let shadow = list_reports()
.last()
.and_then(|path| load_report(path))
.map_or_else(
|| json!({"status":"No data"}),
|report| serde_json::to_value(report).unwrap_or(Value::Null),
);
let savings = savings();
let benchmark = run_benchmark(&canonical_suite(), &BenchConfig::default());
let proof = prove_decision_loop();
let gold = validate_against_gold_set();
let proof_json = json!({"evidence_chain_complete": proof.evidence_chain_complete,
"tasks_proven": proof.tasks.len(), "accepted_rate": proof.accepted_rate,
"total_cost_micros": proof.total_cost_micros, "aggregate_cpao_micros": proof.aggregate_cpao_micros,
"tasks": proof.tasks.iter().map(|task| json!({"task_id":task.task_id,
"intent":task.profile_intent,"accepted":task.outcome_accepted})).collect::<Vec<_>>()});
let gold_json = json!({"total":gold.total,"correct":gold.correct,"accuracy":gold.accuracy,
"intent_distribution":gold.per_intent});
let benchmark_json = serde_json::to_value(&benchmark).unwrap_or(Value::Null);
for (name, value) in [
("value_assessments.json", &assessments),
("shadow_report.json", &shadow),
("savings_summary.json", &savings),
("benchmark_results.json", &benchmark_json),
("proof_result.json", &proof_json),
("gold_set_stats.json", &gold_json),
] {
write_json(&dir, name, value)?;
}
let profile = benchmark
.profiles
.iter()
.find(|item| item.profile == "aggressive")
.or_else(|| {
benchmark
.profiles
.iter()
.find(|item| item.profile != "stock")
});
let (average, peak, tasks) = profile.map_or((0.0, 0.0, 0), |item| {
(
item.avg_savings_pct,
item.runs
.iter()
.map(|run| run.savings_pct)
.fold(0.0, f64::max),
item.tasks_total,
)
});
let field = |parent, key| shadow.get(parent).and_then(|item| item.get(key));
let percent = field("savings", "relative_percent")
.and_then(Value::as_f64)
.map_or_else(|| String::from("No data"), |value| format!("{value:.2}"));
let quality = field("savings", "quality_maintained")
.and_then(Value::as_bool)
.map_or_else(|| String::from("No data"), |value| String::from(if value { "YES" } else { "NO" }));
fs::write(
dir.join("evidence_summary.md"),
format!(
"# LeanCTX Evidence Package\n\nVersion: lean-ctx {}\n\n## Token Reduction\n- Average Reduction: {:.2}% ({} task benchmark)\n- Peak Reduction: {:.2}% (tree operations)\n\n## Value Gate\n- Total Tasks Tracked: {}\n- Accepted Rate: {:.2}%\n- Average CPAO: {}\n- Total Cost Saved: {}\n\n## Decision Loop Proof\n- Evidence Chain: {}\n- Tasks Proven: {}/5\n- All intents classified correctly: {}\n\n## Shadow Analysis\n- Baseline Cost: {}\n- Treatment Cost: {}\n- Savings: {} ({}%)\n- Quality Maintained: {}\n\n## Model Readiness\n- Gold Validation Set: {} tasks\n- Rules Baseline Accuracy: {:.2}%\n- Semantic Model: READY FOR TRAINING\n",
env!("CARGO_PKG_VERSION"),
average,
tasks,
peak,
aggregate.total,
aggregate.accepted as f64 * 100.0 / aggregate.total.max(1) as f64,
money(aggregate.avg_cpao),
money(Some(aggregate.total_cost)),
if proof.evidence_chain_complete {
"COMPLETE"
} else {
"INCOMPLETE"
},
proof.tasks.len(),
if proof.evidence_chain_complete {
"YES"
} else {
"NO"
},
money(field("baseline", "total_cost_micros").and_then(Value::as_u64)),
money(field("treatment", "total_cost_micros").and_then(Value::as_u64)),
money(field("savings", "absolute_micros").and_then(Value::as_u64)),
percent,
quality,
gold.total,
gold.accuracy * 100.0
),
)?;
Ok((dir, format))
}
#[rustfmt::skip]
fn savings() -> Value { let records = crate::core::paths::state_dir().ok().and_then(|dir| fs::read_to_string(dir.join("session_savings.jsonl")).ok()).map_or_else(Vec::new, |body| body.lines().filter_map(|line| serde_json::from_str::<SessionSavings>(line).ok()).filter_map(|item| serde_json::to_value(item).ok()).collect()); if records.is_empty() { json!({"status":"No data","records":records}) } else { json!({"records":records}) } }
fn parse(args: &[String]) -> io::Result<(PathBuf, String)> {
let mut output = PathBuf::from("lean-ctx-evidence");
let mut format = String::from("markdown");
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--output" => {
output = PathBuf::from(args.get(index + 1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--output requires a directory path",
)
})?);
index += 2;
}
"--format" => {
format.clone_from(args.get(index + 1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--format requires json or markdown",
)
})?);
index += 2;
}
flag => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown option: {flag}"),
));
}
}
}
if matches!(format.as_str(), "json" | "markdown") {
Ok((output, format))
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--format must be json or markdown",
))
}
}
fn usage() {
println!(
"Export a portable package of local value, shadow, savings, benchmark, and proof evidence.\n\nUsage: lean-ctx evidence-export [--output DIRECTORY] [--format <markdown|json>]\n\nExamples:\n lean-ctx evidence-export\n lean-ctx evidence-export --output evidence\n lean-ctx evidence-export --output evidence --format json"
);
}
#[rustfmt::skip]
fn write_json(dir: &std::path::Path, name: &str, value: &Value) -> io::Result<()> { fs::write(dir.join(name), serde_json::to_vec_pretty(value).map_err(io::Error::other)?) }
#[rustfmt::skip]
fn money(value: Option<u64>) -> String { value.map_or_else(|| "No data".into(), |value| format!("${:.2}", value as f64 / 1_000_000.0)) }
#[cfg(test)] #[rustfmt::skip]
mod tests { use super::*; use std::path::Path; fn args(dir: &Path) -> Vec<String> { vec!["--output".into(), dir.display().to_string()] }
#[test] fn test_parse_rejects_missing_or_unknown_options() { assert!(parse(&["--output".into()]).is_err()); assert!(parse(&["--unknown".into()]).is_err()); }
#[test] fn test_export_creates_directory() { let dir = std::env::temp_dir().join("evidence-export-dir"); let _ = fs::remove_dir_all(&dir); export(&args(&dir)).unwrap(); assert!(dir.is_dir()); }
#[test] fn test_export_creates_summary() { let dir = std::env::temp_dir().join("evidence-export-summary"); export(&args(&dir)).unwrap(); assert!(dir.join("evidence_summary.md").is_file()); }
#[test] fn test_export_json_valid() { let dir = std::env::temp_dir().join("evidence-export-json"); export(&args(&dir)).unwrap(); for name in ["value_assessments.json","shadow_report.json","savings_summary.json","benchmark_results.json","proof_result.json","gold_set_stats.json"] { assert!(serde_json::from_slice::<Value>(&fs::read(dir.join(name)).unwrap()).is_ok()); } }
}