use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::util::sha256_hex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProofPackageManifest {
pub version: String,
pub generated_at: DateTime<Utc>,
pub campaign_name: String,
pub profile_name: String,
pub profile_hash: String,
pub binary_hash: String,
pub invariant_version: String,
pub summary: CampaignSummary,
pub file_hashes: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CampaignSummary {
pub total_commands: u64,
pub commands_approved: u64,
pub commands_rejected: u64,
pub violation_escapes: u64,
pub adversarial_commands: u64,
pub adversarial_escapes: u64,
pub escape_rate_point: f64,
pub escape_rate_upper_95: f64,
pub escape_rate_upper_99: f64,
pub mtbf_hours: Option<f64>,
pub control_frequency_hz: f64,
}
impl CampaignSummary {
pub fn compute(
total: u64,
approved: u64,
rejected: u64,
escapes: u64,
adversarial: u64,
adversarial_esc: u64,
control_hz: f64,
) -> Self {
let point = if total == 0 {
0.0
} else {
escapes as f64 / total as f64
};
let upper_95 = clopper_pearson_upper(total, escapes, 0.95);
let upper_99 = clopper_pearson_upper(total, escapes, 0.99);
let mtbf = if upper_99 > 0.0 && control_hz > 0.0 {
let failures_per_sec = upper_99 * control_hz;
Some(1.0 / (failures_per_sec * 3600.0))
} else if total > 0 && escapes == 0 {
None
} else {
None
};
Self {
total_commands: total,
commands_approved: approved,
commands_rejected: rejected,
violation_escapes: escapes,
adversarial_commands: adversarial,
adversarial_escapes: adversarial_esc,
escape_rate_point: point,
escape_rate_upper_95: upper_95,
escape_rate_upper_99: upper_99,
mtbf_hours: mtbf,
control_frequency_hz: control_hz,
}
}
}
pub fn clopper_pearson_upper(n: u64, k: u64, confidence: f64) -> f64 {
if n == 0 {
return 1.0; }
let alpha = 1.0 - confidence;
if k == 0 {
1.0 - alpha.powf(1.0 / n as f64)
} else if k == n {
1.0 } else {
let p_hat = k as f64 / n as f64;
let z = z_score(1.0 - alpha / 2.0);
let n_f = n as f64;
let denom = 1.0 + z * z / n_f;
let center = p_hat + z * z / (2.0 * n_f);
let margin = z * (p_hat * (1.0 - p_hat) / n_f + z * z / (4.0 * n_f * n_f)).sqrt();
((center + margin) / denom).min(1.0)
}
}
fn z_score(p: f64) -> f64 {
if p < 0.5 {
return -z_score(1.0 - p);
}
if p >= 1.0 {
return f64::INFINITY;
}
let t = (-2.0 * (1.0 - p).ln()).sqrt();
let c0 = 2.515517;
let c1 = 0.802853;
let c2 = 0.010328;
let d1 = 1.432788;
let d2 = 0.189269;
let d3 = 0.001308;
t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t)
}
pub struct PackageInputs {
pub campaign_config: Option<PathBuf>,
pub profile: Option<PathBuf>,
pub audit_log: Option<PathBuf>,
pub adversarial_reports: HashMap<String, PathBuf>,
pub compliance_mappings: HashMap<String, PathBuf>,
pub public_keys: Option<PathBuf>,
pub campaign_name: String,
pub profile_name: String,
pub binary_hash: String,
pub summary: CampaignSummary,
}
pub fn assemble(inputs: &PackageInputs, output_dir: &Path) -> Result<ProofPackageManifest, String> {
let dirs = [
output_dir.to_path_buf(),
output_dir.join("campaign"),
output_dir.join("results"),
output_dir.join("adversarial"),
output_dir.join("integrity"),
output_dir.join("compliance"),
];
for dir in &dirs {
std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {:?}: {e}", dir))?;
}
let mut file_hashes: HashMap<String, String> = HashMap::new();
if let Some(src) = &inputs.campaign_config {
copy_and_hash(
src,
&output_dir.join("campaign/config.yaml"),
&mut file_hashes,
)?;
}
if let Some(src) = &inputs.profile {
copy_and_hash(
src,
&output_dir.join("campaign/profile.json"),
&mut file_hashes,
)?;
}
if let Some(src) = &inputs.audit_log {
copy_and_hash(
src,
&output_dir.join("results/audit.jsonl"),
&mut file_hashes,
)?;
}
for (name, src) in &inputs.adversarial_reports {
let dest = output_dir.join("adversarial").join(name);
copy_and_hash(src, &dest, &mut file_hashes)?;
}
for (name, src) in &inputs.compliance_mappings {
let dest = output_dir.join("compliance").join(name);
copy_and_hash(src, &dest, &mut file_hashes)?;
}
if let Some(src) = &inputs.public_keys {
copy_and_hash(
src,
&output_dir.join("integrity/public_keys.json"),
&mut file_hashes,
)?;
}
let binary_hash_path = output_dir.join("integrity/binary_hash.txt");
std::fs::write(&binary_hash_path, &inputs.binary_hash)
.map_err(|e| format!("write binary_hash.txt: {e}"))?;
file_hashes.insert(
"integrity/binary_hash.txt".into(),
sha256_hex(inputs.binary_hash.as_bytes()),
);
let summary_json = serde_json::to_string_pretty(&inputs.summary)
.map_err(|e| format!("serialize summary: {e}"))?;
let summary_path = output_dir.join("results/summary.json");
std::fs::write(&summary_path, &summary_json).map_err(|e| format!("write summary.json: {e}"))?;
file_hashes.insert(
"results/summary.json".into(),
sha256_hex(summary_json.as_bytes()),
);
let manifest = ProofPackageManifest {
version: "1.0.0".into(),
generated_at: Utc::now(),
campaign_name: inputs.campaign_name.clone(),
profile_name: inputs.profile_name.clone(),
profile_hash: inputs
.profile
.as_ref()
.and_then(|p| std::fs::read(p).ok())
.map(|b| sha256_hex(&b))
.unwrap_or_default(),
binary_hash: inputs.binary_hash.clone(),
invariant_version: env!("CARGO_PKG_VERSION").into(),
summary: inputs.summary.clone(),
file_hashes,
};
let manifest_json =
serde_json::to_string_pretty(&manifest).map_err(|e| format!("serialize manifest: {e}"))?;
std::fs::write(output_dir.join("manifest.json"), &manifest_json)
.map_err(|e| format!("write manifest.json: {e}"))?;
let readme = generate_readme(&manifest);
std::fs::write(output_dir.join("README.md"), &readme)
.map_err(|e| format!("write README.md: {e}"))?;
Ok(manifest)
}
fn copy_and_hash(
src: &Path,
dest: &Path,
hashes: &mut HashMap<String, String>,
) -> Result<(), String> {
let bytes = std::fs::read(src).map_err(|e| format!("read {:?}: {e}", src))?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {:?}: {e}", parent))?;
}
std::fs::write(dest, &bytes).map_err(|e| format!("write {:?}: {e}", dest))?;
let rel_path = relative_package_path(dest);
hashes.insert(rel_path, sha256_hex(&bytes));
Ok(())
}
fn relative_package_path(path: &Path) -> String {
let components: Vec<_> = path
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
let known_dirs = [
"campaign",
"results",
"adversarial",
"integrity",
"compliance",
];
for (i, comp) in components.iter().enumerate() {
if known_dirs.contains(&comp.as_str()) {
return components[i..].join("/");
}
}
path.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default()
}
fn generate_readme(manifest: &ProofPackageManifest) -> String {
format!(
r#"# Invariant Proof Package
## Campaign: {}
Generated: {}
Profile: {} ({})
Invariant version: {}
Binary hash: {}
## Summary
- Total commands validated: {}
- Commands approved: {}
- Commands rejected: {}
- Violation escapes: {}
- Adversarial commands: {}
- Adversarial escapes: {}
## Statistical Claims
- Escape rate (point estimate): {:.6}%
- Escape rate (95% upper bound): {:.6}%
- Escape rate (99% upper bound): {:.6}%
{}
## How to Verify
```bash
invariant verify-package --path .
```
This command checks:
- Manifest integrity (file hashes match)
- Audit log hash chain and signatures
- Campaign result consistency
- Adversarial report presence
- Public key availability
All data in this package is cryptographically signed and independently verifiable.
"#,
manifest.campaign_name,
manifest.generated_at.to_rfc3339(),
manifest.profile_name,
manifest.profile_hash,
manifest.invariant_version,
manifest.binary_hash,
manifest.summary.total_commands,
manifest.summary.commands_approved,
manifest.summary.commands_rejected,
manifest.summary.violation_escapes,
manifest.summary.adversarial_commands,
manifest.summary.adversarial_escapes,
manifest.summary.escape_rate_point * 100.0,
manifest.summary.escape_rate_upper_95 * 100.0,
manifest.summary.escape_rate_upper_99 * 100.0,
manifest
.summary
.mtbf_hours
.map(|h| format!(
"- Equivalent MTBF at {}Hz: {:.0} hours",
manifest.summary.control_frequency_hz, h
))
.unwrap_or_default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clopper_pearson_zero_escapes_10m() {
let upper_95 = clopper_pearson_upper(10_240_000, 0, 0.95);
let upper_99 = clopper_pearson_upper(10_240_000, 0, 0.99);
assert!(
upper_95 < 0.000001,
"95% upper bound {upper_95} should be < 0.0001%"
);
assert!(
upper_99 < 0.000001,
"99% upper bound {upper_99} should be < 0.0001%"
);
assert!(upper_99 > upper_95, "99% bound should be wider than 95%");
}
#[test]
fn clopper_pearson_zero_trials() {
let upper = clopper_pearson_upper(0, 0, 0.95);
assert_eq!(upper, 1.0);
}
#[test]
fn clopper_pearson_all_escaped() {
let upper = clopper_pearson_upper(100, 100, 0.95);
assert_eq!(upper, 1.0);
}
#[test]
fn clopper_pearson_some_escapes() {
let upper = clopper_pearson_upper(1000, 5, 0.95);
assert!(upper > 0.005, "upper {upper} should be > 0.005");
assert!(upper < 0.02, "upper {upper} should be < 0.02");
}
#[test]
fn clopper_pearson_zero_escapes_small_n() {
let upper = clopper_pearson_upper(100, 0, 0.95);
assert!(
(upper - 0.03).abs() < 0.005,
"upper {upper} should be near 0.03 (rule of three)"
);
}
#[test]
fn z_score_50_percent_is_zero() {
let z = z_score(0.5);
assert!(z.abs() < 0.001, "z(0.5) = {z}, should be ~0");
}
#[test]
fn z_score_975_is_about_196() {
let z = z_score(0.975);
assert!((z - 1.96).abs() < 0.01, "z(0.975) = {z}, should be ~1.96");
}
#[test]
fn z_score_995_is_about_258() {
let z = z_score(0.995);
assert!((z - 2.576).abs() < 0.02, "z(0.995) = {z}, should be ~2.576");
}
#[test]
fn campaign_summary_zero_escapes() {
let s = CampaignSummary::compute(10_000_000, 9_500_000, 500_000, 0, 2_500_000, 0, 100.0);
assert_eq!(s.escape_rate_point, 0.0);
assert!(s.escape_rate_upper_95 > 0.0);
assert!(s.escape_rate_upper_99 > s.escape_rate_upper_95);
assert!(s.mtbf_hours.is_some());
let mtbf = s.mtbf_hours.unwrap();
assert!(
mtbf > 0.5,
"MTBF {mtbf} hours should be positive for 10M commands with 0 escapes"
);
}
#[test]
fn campaign_summary_zero_commands() {
let s = CampaignSummary::compute(0, 0, 0, 0, 0, 0, 100.0);
assert_eq!(s.escape_rate_point, 0.0);
assert_eq!(s.escape_rate_upper_95, 1.0);
}
#[test]
fn assemble_creates_directory_structure() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("proof-package");
let summary = CampaignSummary::compute(1000, 950, 50, 0, 100, 0, 100.0);
let inputs = PackageInputs {
campaign_config: None,
profile: None,
audit_log: None,
adversarial_reports: HashMap::new(),
compliance_mappings: HashMap::new(),
public_keys: None,
campaign_name: "test_campaign".into(),
profile_name: "test_robot".into(),
binary_hash: "sha256:abc123".into(),
summary,
};
let manifest = assemble(&inputs, &output).unwrap();
assert!(output.join("manifest.json").exists());
assert!(output.join("README.md").exists());
assert!(output.join("results/summary.json").exists());
assert!(output.join("integrity/binary_hash.txt").exists());
assert!(output.join("campaign").is_dir());
assert!(output.join("adversarial").is_dir());
assert!(output.join("compliance").is_dir());
assert_eq!(manifest.campaign_name, "test_campaign");
assert_eq!(manifest.version, "1.0.0");
}
#[test]
fn assemble_copies_and_hashes_files() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("proof-package");
let profile_path = dir.path().join("test_profile.json");
std::fs::write(&profile_path, r#"{"name":"test"}"#).unwrap();
let audit_path = dir.path().join("audit.jsonl");
std::fs::write(&audit_path, "{\"entry\":1}\n{\"entry\":2}\n").unwrap();
let summary = CampaignSummary::compute(100, 90, 10, 0, 0, 0, 100.0);
let inputs = PackageInputs {
campaign_config: None,
profile: Some(profile_path),
audit_log: Some(audit_path),
adversarial_reports: HashMap::new(),
compliance_mappings: HashMap::new(),
public_keys: None,
campaign_name: "hash_test".into(),
profile_name: "test".into(),
binary_hash: "sha256:def456".into(),
summary,
};
let manifest = assemble(&inputs, &output).unwrap();
assert!(output.join("campaign/profile.json").exists());
assert!(output.join("results/audit.jsonl").exists());
assert!(!manifest.file_hashes.is_empty());
assert!(manifest.profile_hash.starts_with("sha256:"));
}
#[test]
fn assemble_includes_adversarial_reports() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("proof-package");
let report_path = dir.path().join("protocol_report.json");
std::fs::write(&report_path, r#"{"attacks": 1000, "escapes": 0}"#).unwrap();
let summary = CampaignSummary::compute(1000, 950, 50, 0, 1000, 0, 100.0);
let mut adversarial = HashMap::new();
adversarial.insert("protocol_report.json".into(), report_path);
let inputs = PackageInputs {
campaign_config: None,
profile: None,
audit_log: None,
adversarial_reports: adversarial,
compliance_mappings: HashMap::new(),
public_keys: None,
campaign_name: "adv_test".into(),
profile_name: "test".into(),
binary_hash: "sha256:000".into(),
summary,
};
let manifest = assemble(&inputs, &output).unwrap();
assert!(output.join("adversarial/protocol_report.json").exists());
assert!(manifest
.file_hashes
.keys()
.any(|k| k.contains("protocol_report")));
}
#[test]
fn readme_contains_summary_stats() {
let manifest = ProofPackageManifest {
version: "1.0.0".into(),
generated_at: Utc::now(),
campaign_name: "readme_test".into(),
profile_name: "test_robot".into(),
profile_hash: "sha256:abc".into(),
binary_hash: "sha256:def".into(),
invariant_version: "0.1.0".into(),
summary: CampaignSummary::compute(10_000, 9_500, 500, 0, 2_000, 0, 100.0),
file_hashes: HashMap::new(),
};
let readme = generate_readme(&manifest);
assert!(readme.contains("readme_test"));
assert!(readme.contains("10000"));
assert!(readme.contains("verify-package"));
}
#[test]
fn manifest_serde_round_trip() {
let manifest = ProofPackageManifest {
version: "1.0.0".into(),
generated_at: Utc::now(),
campaign_name: "serde_test".into(),
profile_name: "test".into(),
profile_hash: "sha256:abc".into(),
binary_hash: "sha256:def".into(),
invariant_version: "0.1.0".into(),
summary: CampaignSummary::compute(100, 90, 10, 0, 0, 0, 100.0),
file_hashes: HashMap::new(),
};
let json = serde_json::to_string(&manifest).unwrap();
let back: ProofPackageManifest = serde_json::from_str(&json).unwrap();
assert_eq!(back.campaign_name, "serde_test");
assert_eq!(back.summary.total_commands, 100);
}
}