use exo_core::{
hash::hash_structured,
types::{Hash256, PublicKey, Signature},
};
use serde::{Deserialize, Serialize};
use crate::error::{ProofError, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelCommitment {
pub architecture_hash: Hash256,
pub weights_hash: Hash256,
pub version: u64,
}
impl ModelCommitment {
#[must_use]
pub fn new(architecture: &[u8], weights: &[u8], version: u64) -> Self {
Self {
architecture_hash: Hash256::digest(architecture),
weights_hash: Hash256::digest(weights),
version,
}
}
#[must_use]
pub fn commitment_hash(&self) -> Hash256 {
let mut hasher = blake3::Hasher::new();
hasher.update(b"zkml:model:");
hasher.update(self.architecture_hash.as_bytes());
hasher.update(self.weights_hash.as_bytes());
hasher.update(&self.version.to_le_bytes());
Hash256::from_bytes(*hasher.finalize().as_bytes())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AttestationDecision {
Adopted,
Modified,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HumanAttestation {
pub reviewer_did: String,
pub reviewer_public_key: PublicKey,
pub ai_recommendation_hash: Hash256,
pub final_decision_hash: Hash256,
pub decision: AttestationDecision,
pub signature: Signature,
}
impl HumanAttestation {
pub fn signing_message(
reviewer_did: &str,
ai_recommendation_hash: &Hash256,
final_decision_hash: &Hash256,
decision: &AttestationDecision,
) -> Result<Vec<u8>> {
let decision_byte: u8 = match decision {
AttestationDecision::Adopted => 0x01,
AttestationDecision::Modified => 0x02,
AttestationDecision::Rejected => 0x03,
};
let reviewer_did_bytes = reviewer_did.as_bytes();
let reviewer_did_len = u64::try_from(reviewer_did_bytes.len()).map_err(|_| {
ProofError::InvalidProofFormat(format!(
"reviewer DID length {} cannot be represented in canonical attestation frame",
reviewer_did_bytes.len()
))
})?;
let mut msg = b"zkml:attestation:".to_vec();
msg.extend_from_slice(&reviewer_did_len.to_le_bytes());
msg.extend_from_slice(reviewer_did_bytes);
msg.extend_from_slice(ai_recommendation_hash.as_bytes());
msg.extend_from_slice(final_decision_hash.as_bytes());
msg.push(decision_byte);
Ok(msg)
}
#[must_use]
pub fn verify_signature(&self) -> bool {
let Ok(msg) = Self::signing_message(
&self.reviewer_did,
&self.ai_recommendation_hash,
&self.final_decision_hash,
&self.decision,
) else {
return false;
};
exo_core::crypto::verify(&msg, &self.signature, &self.reviewer_public_key)
}
pub fn signing_message_for_inference(
reviewer_did: &str,
ai_recommendation_hash: &Hash256,
final_decision_hash: &Hash256,
decision: &AttestationDecision,
inference: &InferenceProof,
) -> Result<Vec<u8>> {
let Some(prompt_hash) = inference.prompt_hash else {
return Err(ProofError::InvalidProofFormat(
"prompt_hash is required for inference-bound human attestation".into(),
));
};
let payload = HumanAttestationInferenceSigningPayload {
domain: "exo.zkml.human_attestation.v2",
reviewer_did,
ai_recommendation_hash: *ai_recommendation_hash,
final_decision_hash: *final_decision_hash,
decision,
model_hash: inference.model_commitment.commitment_hash(),
input_hash: inference.input_hash,
output_hash: inference.output_hash,
proof: inference.proof,
verification_tag: inference.verification_tag,
prompt_hash,
};
canonical_cbor_message(&payload, "human attestation inference signing payload")
}
#[must_use]
pub fn verify_for_inference(&self, inference: &InferenceProof) -> bool {
let Ok(msg) = Self::signing_message_for_inference(
&self.reviewer_did,
&self.ai_recommendation_hash,
&self.final_decision_hash,
&self.decision,
inference,
) else {
return false;
};
exo_core::crypto::verify(&msg, &self.signature, &self.reviewer_public_key)
}
}
#[derive(Serialize)]
struct HumanAttestationInferenceSigningPayload<'a> {
domain: &'static str,
reviewer_did: &'a str,
ai_recommendation_hash: Hash256,
final_decision_hash: Hash256,
decision: &'a AttestationDecision,
model_hash: Hash256,
input_hash: Hash256,
output_hash: Hash256,
proof: Hash256,
verification_tag: Hash256,
prompt_hash: Hash256,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiDelta {
pub ai_output_hash: Hash256,
pub human_output_hash: Hash256,
pub divergence_detected: bool,
}
impl AiDelta {
#[must_use]
pub fn new(ai_output: &[u8], human_output: &[u8]) -> Self {
let ai_output_hash = Hash256::digest(ai_output);
let human_output_hash = Hash256::digest(human_output);
let divergence_detected = ai_output_hash != human_output_hash;
Self {
ai_output_hash,
human_output_hash,
divergence_detected,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaubertChecklist {
pub methodology_documented: bool,
pub peer_reviewable: bool,
pub known_error_rate: Option<String>,
pub generally_accepted: bool,
}
impl DaubertChecklist {
#[must_use]
pub fn is_complete(&self) -> bool {
self.methodology_documented
&& self.peer_reviewable
&& self
.known_error_rate
.as_ref()
.is_some_and(|error_rate| !error_rate.trim().is_empty())
&& self.generally_accepted
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DaubertAdmissibility {
Admissible,
Inadmissible { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InferenceProof {
pub model_commitment: ModelCommitment,
pub input_hash: Hash256,
pub output_hash: Hash256,
pub proof: Hash256,
pub verification_tag: Hash256,
#[serde(default)]
pub prompt_hash: Option<Hash256>,
#[serde(default)]
pub human_attestation: Option<HumanAttestation>,
#[serde(default)]
pub ai_delta: Option<AiDelta>,
#[serde(default)]
pub daubert_checklist: Option<DaubertChecklist>,
}
impl InferenceProof {
#[must_use]
pub fn daubert_admissibility_status(&self) -> DaubertAdmissibility {
if let Err(err) = crate::guard_unaudited("zkml::daubert_admissibility_status") {
return DaubertAdmissibility::Inadmissible {
reason: err.to_string(),
};
}
if self.prompt_hash.is_none() {
return DaubertAdmissibility::Inadmissible {
reason: "prompt_hash is required for Daubert admissibility".into(),
};
}
if !self.proof_integrity_valid() {
return DaubertAdmissibility::Inadmissible {
reason: "proof integrity check failed".into(),
};
}
let Some(attestation) = &self.human_attestation else {
return DaubertAdmissibility::Inadmissible {
reason: "human_attestation is required for Daubert admissibility".into(),
};
};
if !attestation.verify_for_inference(self) {
return DaubertAdmissibility::Inadmissible {
reason: "human_attestation signature failed verification".into(),
};
}
if attestation.ai_recommendation_hash != self.output_hash {
return DaubertAdmissibility::Inadmissible {
reason: "human_attestation AI recommendation does not match proof output_hash"
.into(),
};
}
let Some(ai_delta) = &self.ai_delta else {
return DaubertAdmissibility::Inadmissible {
reason: "ai_delta is required for Daubert admissibility".into(),
};
};
if ai_delta.ai_output_hash != attestation.ai_recommendation_hash
|| ai_delta.human_output_hash != attestation.final_decision_hash
{
return DaubertAdmissibility::Inadmissible {
reason: "ai_delta does not match human_attestation hashes".into(),
};
}
let Some(checklist) = &self.daubert_checklist else {
return DaubertAdmissibility::Inadmissible {
reason: "daubert_checklist is required for Daubert admissibility".into(),
};
};
if !checklist.is_complete() {
return DaubertAdmissibility::Inadmissible {
reason: "daubert_checklist is incomplete".into(),
};
}
DaubertAdmissibility::Admissible
}
fn proof_integrity_valid(&self) -> bool {
let model_hash = self.model_commitment.commitment_hash();
let expected_proof =
compute_inference_proof(&model_hash, &self.input_hash, &self.output_hash);
let Ok(expected_tag) = compute_verification_tag(
&model_hash,
&self.input_hash,
&self.output_hash,
&self.proof,
self.prompt_hash.as_ref(),
) else {
return false;
};
constant_time_hash256_eq(&expected_proof, &self.proof)
& constant_time_hash256_eq(&expected_tag, &self.verification_tag)
}
}
pub fn prove_inference(
model: &ModelCommitment,
input: &[u8],
output: &[u8],
) -> Result<InferenceProof> {
crate::guard_unaudited("zkml::prove_inference")?;
let input_hash = Hash256::digest(input);
let output_hash = Hash256::digest(output);
let model_hash = model.commitment_hash();
let proof = compute_inference_proof(&model_hash, &input_hash, &output_hash);
let verification_tag =
compute_verification_tag(&model_hash, &input_hash, &output_hash, &proof, None)?;
Ok(InferenceProof {
model_commitment: model.clone(),
input_hash,
output_hash,
proof,
verification_tag,
prompt_hash: None,
human_attestation: None,
ai_delta: None,
daubert_checklist: None,
})
}
pub fn prove_inference_with_provenance(
model: &ModelCommitment,
prompt: &[u8],
input: &[u8],
output: &[u8],
) -> Result<InferenceProof> {
let mut proof = prove_inference(model, input, output)?;
let prompt_hash = Hash256::digest(prompt);
proof.prompt_hash = Some(prompt_hash);
let model_hash = model.commitment_hash();
proof.verification_tag = compute_verification_tag(
&model_hash,
&proof.input_hash,
&proof.output_hash,
&proof.proof,
Some(&prompt_hash),
)?;
Ok(proof)
}
pub fn verify_inference(proof: &InferenceProof) -> Result<bool> {
crate::guard_unaudited("zkml::verify_inference")?;
let model_hash = proof.model_commitment.commitment_hash();
let expected_proof =
compute_inference_proof(&model_hash, &proof.input_hash, &proof.output_hash);
let proof_ok = constant_time_hash256_eq(&expected_proof, &proof.proof);
let expected_tag = compute_verification_tag(
&model_hash,
&proof.input_hash,
&proof.output_hash,
&proof.proof,
proof.prompt_hash.as_ref(),
)?;
let tag_ok = constant_time_hash256_eq(&expected_tag, &proof.verification_tag);
Ok(proof_ok & tag_ok)
}
fn compute_inference_proof(
model_hash: &Hash256,
input_hash: &Hash256,
output_hash: &Hash256,
) -> Hash256 {
let mut hasher = blake3::Hasher::new();
hasher.update(b"zkml:proof:");
hasher.update(model_hash.as_bytes());
hasher.update(input_hash.as_bytes());
hasher.update(output_hash.as_bytes());
Hash256::from_bytes(*hasher.finalize().as_bytes())
}
fn compute_verification_tag(
model_hash: &Hash256,
input_hash: &Hash256,
output_hash: &Hash256,
proof: &Hash256,
prompt_hash: Option<&Hash256>,
) -> Result<Hash256> {
if let Some(prompt_hash) = prompt_hash {
let payload = InferenceVerificationTagPayload {
domain: "exo.zkml.verification_tag.v2",
model_hash: *model_hash,
input_hash: *input_hash,
output_hash: *output_hash,
proof: *proof,
prompt_hash: *prompt_hash,
};
return hash_structured(&payload).map_err(|err| {
ProofError::InvalidProofFormat(format!(
"zkML verification tag canonical encoding failed: {err}"
))
});
}
let mut hasher = blake3::Hasher::new();
hasher.update(b"zkml:verify:");
hasher.update(model_hash.as_bytes());
hasher.update(input_hash.as_bytes());
hasher.update(output_hash.as_bytes());
hasher.update(proof.as_bytes());
Ok(Hash256::from_bytes(*hasher.finalize().as_bytes()))
}
#[derive(Serialize)]
struct InferenceVerificationTagPayload {
domain: &'static str,
model_hash: Hash256,
input_hash: Hash256,
output_hash: Hash256,
proof: Hash256,
prompt_hash: Hash256,
}
fn canonical_cbor_message<T: Serialize>(value: &T, label: &str) -> Result<Vec<u8>> {
let mut encoded = Vec::new();
ciborium::into_writer(value, &mut encoded).map_err(|err| {
ProofError::InvalidProofFormat(format!("{label} canonical CBOR encoding failed: {err}"))
})?;
Ok(encoded)
}
fn constant_time_hash256_eq(left: &Hash256, right: &Hash256) -> bool {
let mut diff = 0u8;
for idx in 0..32 {
diff |= left.as_bytes()[idx] ^ right.as_bytes()[idx];
}
diff == 0
}
#[cfg(all(test, feature = "unaudited-pedagogical-proofs"))]
mod tests {
use exo_core::crypto;
use super::*;
fn make_model() -> ModelCommitment {
ModelCommitment::new(b"transformer-v1", b"weights-blob-1234", 1)
}
#[test]
fn model_commitment_deterministic() {
let m1 = ModelCommitment::new(b"arch", b"weights", 1);
let m2 = ModelCommitment::new(b"arch", b"weights", 1);
assert_eq!(m1, m2);
assert_eq!(m1.commitment_hash(), m2.commitment_hash());
}
#[test]
fn different_models_different_hashes() {
let m1 = ModelCommitment::new(b"arch1", b"weights1", 1);
let m2 = ModelCommitment::new(b"arch2", b"weights2", 1);
assert_ne!(m1.commitment_hash(), m2.commitment_hash());
}
#[test]
fn different_versions_different_hashes() {
let m1 = ModelCommitment::new(b"arch", b"weights", 1);
let m2 = ModelCommitment::new(b"arch", b"weights", 2);
assert_ne!(m1.commitment_hash(), m2.commitment_hash());
}
#[test]
fn prove_and_verify() {
let model = make_model();
let proof =
prove_inference(&model, b"classify this image", b"cat: 0.95, dog: 0.05").unwrap();
assert!(verify_inference(&proof).unwrap());
}
#[test]
fn verify_fails_tampered_model() {
let model = make_model();
let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
tampered.model_commitment = ModelCommitment::new(b"evil-arch", b"evil-weights", 99);
assert!(!verify_inference(&tampered).unwrap());
}
#[test]
fn verify_fails_tampered_input() {
let model = make_model();
let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
tampered.input_hash = Hash256::digest(b"different-input");
assert!(!verify_inference(&tampered).unwrap());
}
#[test]
fn verify_fails_tampered_output() {
let model = make_model();
let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
tampered.output_hash = Hash256::digest(b"different-output");
assert!(!verify_inference(&tampered).unwrap());
}
#[test]
fn verify_fails_tampered_proof_field() {
let model = make_model();
let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
tampered.proof = Hash256::ZERO;
assert!(!verify_inference(&tampered).unwrap());
}
#[test]
fn verify_fails_tampered_tag() {
let model = make_model();
let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
tampered.verification_tag = Hash256::ZERO;
assert!(!verify_inference(&tampered).unwrap());
}
#[test]
fn verify_inference_uses_constant_time_hash_comparisons() {
let source = include_str!("zkml.rs");
let Some(verify_start) = source.find("pub fn verify_inference") else {
panic!("verify_inference must exist");
};
let Some(internals_start) = source.find("// Internals") else {
panic!("internals marker must exist");
};
let verify_source = &source[verify_start..internals_start];
assert!(
verify_source.contains("constant_time_hash256_eq"),
"verify_inference must use the constant-time Hash256 comparator"
);
assert!(
!verify_source.contains("expected_proof != proof.proof"),
"proof comparison must not use variable-time PartialEq"
);
assert!(
!verify_source.contains("expected_tag == proof.verification_tag"),
"verification tag comparison must not use variable-time PartialEq"
);
}
#[test]
fn different_inputs_different_proofs() {
let model = make_model();
let p1 = prove_inference(&model, b"input1", b"output1").unwrap();
let p2 = prove_inference(&model, b"input2", b"output2").unwrap();
assert_ne!(p1.proof, p2.proof);
}
#[test]
fn same_inputs_same_proof() {
let model = make_model();
let p1 = prove_inference(&model, b"input", b"output").unwrap();
let p2 = prove_inference(&model, b"input", b"output").unwrap();
assert_eq!(p1, p2);
}
#[test]
fn proof_hides_model_input() {
let model = make_model();
let proof = prove_inference(&model, b"secret input", b"secret output").unwrap();
assert_eq!(proof.input_hash, Hash256::digest(b"secret input"));
assert_eq!(proof.output_hash, Hash256::digest(b"secret output"));
assert_eq!(
proof.model_commitment.architecture_hash,
Hash256::digest(b"transformer-v1")
);
}
#[test]
fn empty_input_output() {
let model = make_model();
assert!(verify_inference(&prove_inference(&model, b"", b"").unwrap()).unwrap());
}
#[test]
fn large_input_output() {
let model = make_model();
let proof = prove_inference(&model, &vec![0xABu8; 10_000], &vec![0xCDu8; 5_000]).unwrap();
assert!(verify_inference(&proof).unwrap());
}
#[test]
fn backward_compat_deserialize_without_provenance_fields() {
let model = make_model();
let proof = prove_inference(&model, b"input", b"output").unwrap();
let json = serde_json::to_string(&proof).unwrap();
let restored: InferenceProof = serde_json::from_str(&json).unwrap();
assert!(restored.prompt_hash.is_none());
assert!(restored.human_attestation.is_none());
assert!(restored.ai_delta.is_none());
assert!(restored.daubert_checklist.is_none());
}
#[test]
fn zkml_proof_binds_model_and_prompt() {
let model = make_model();
let prompt = b"You are a board advisor. Recommend yes or no.";
let context = b"Q4 revenue declined 15%.";
let output = b"Recommend: reject the acquisition.";
let proof = prove_inference_with_provenance(&model, prompt, context, output).unwrap();
assert!(verify_inference(&proof).unwrap());
assert!(proof.prompt_hash.is_some(), "prompt_hash must be present");
assert_ne!(
proof.prompt_hash.unwrap(),
proof.input_hash,
"prompt_hash must be distinct from input_hash"
);
assert_eq!(proof.prompt_hash, Some(Hash256::digest(prompt)));
assert_eq!(proof.input_hash, Hash256::digest(context));
}
#[test]
fn prove_inference_with_provenance_verifies() {
let model = make_model();
let proof =
prove_inference_with_provenance(&model, b"prompt", b"context", b"output").unwrap();
assert!(verify_inference(&proof).unwrap());
}
#[test]
fn verify_inference_rejects_swapped_prompt_provenance() {
let model = make_model();
let mut proof =
prove_inference_with_provenance(&model, b"prompt", b"context", b"output").unwrap();
proof.prompt_hash = Some(Hash256::digest(b"different prompt"));
assert!(
!verify_inference(&proof).unwrap(),
"prompt provenance must be bound to the inference verification tag"
);
}
fn make_attestation(
decision: AttestationDecision,
) -> (HumanAttestation, exo_core::types::SecretKey) {
let (public_key, secret_key) = crypto::generate_keypair();
let reviewer_did = "did:exo:reviewer-alice".to_string();
let ai_rec = Hash256::digest(b"ai says: approve");
let final_dec = Hash256::digest(b"human says: reject");
let msg = HumanAttestation::signing_message(&reviewer_did, &ai_rec, &final_dec, &decision)
.unwrap();
let signature = crypto::sign(&msg, &secret_key);
let att = HumanAttestation {
reviewer_did,
reviewer_public_key: public_key,
ai_recommendation_hash: ai_rec,
final_decision_hash: final_dec,
decision,
signature,
};
(att, secret_key)
}
fn make_inference_attestation(
proof: &InferenceProof,
final_decision_hash: Hash256,
decision: AttestationDecision,
) -> (HumanAttestation, exo_core::types::SecretKey) {
let (public_key, secret_key) = crypto::generate_keypair();
let reviewer_did = "did:exo:reviewer-alice".to_string();
let msg = HumanAttestation::signing_message_for_inference(
&reviewer_did,
&proof.output_hash,
&final_decision_hash,
&decision,
proof,
)
.unwrap();
let signature = crypto::sign(&msg, &secret_key);
let att = HumanAttestation {
reviewer_did,
reviewer_public_key: public_key,
ai_recommendation_hash: proof.output_hash,
final_decision_hash,
decision,
signature,
};
(att, secret_key)
}
#[test]
fn human_attestation_signature_verifies() {
let (att, _) = make_attestation(AttestationDecision::Rejected);
assert!(
att.verify_signature(),
"Valid Ed25519 attestation must verify"
);
}
#[test]
fn human_attestation_signing_message_frames_reviewer_did() {
let reviewer_did = "did:exo:reviewer-alice";
let ai_rec = Hash256::digest(b"ai recommendation");
let final_dec = Hash256::digest(b"final decision");
let msg = HumanAttestation::signing_message(
reviewer_did,
&ai_rec,
&final_dec,
&AttestationDecision::Modified,
)
.unwrap();
let domain = b"zkml:attestation:";
assert!(msg.starts_with(domain));
let did_len_start = domain.len();
let did_len_end = did_len_start + 8;
let did_len_bytes: [u8; 8] = match msg[did_len_start..did_len_end].try_into() {
Ok(bytes) => bytes,
Err(_) => panic!("DID length prefix must be eight bytes"),
};
let expected_len = match u64::try_from(reviewer_did.len()) {
Ok(len) => len,
Err(_) => panic!("reviewer DID length must fit in u64"),
};
assert_eq!(u64::from_le_bytes(did_len_bytes), expected_len);
assert_eq!(
&msg[did_len_end..did_len_end + reviewer_did.len()],
reviewer_did.as_bytes()
);
let mut legacy = domain.to_vec();
legacy.extend_from_slice(reviewer_did.as_bytes());
legacy.extend_from_slice(ai_rec.as_bytes());
legacy.extend_from_slice(final_dec.as_bytes());
legacy.push(0x02);
assert_ne!(msg, legacy, "new attestations must not use legacy framing");
}
#[test]
fn human_attestation_signing_message_does_not_saturate_reviewer_did_length() {
let production = include_str!("zkml.rs");
let signing_message_section = production
.split("pub fn signing_message")
.nth(1)
.expect("signing_message function must exist")
.split("pub fn verify_signature")
.next()
.expect("verify_signature function must follow signing_message");
assert!(
!signing_message_section.contains("unwrap_or(u64::MAX)"),
"canonical attestation framing must fail closed instead of saturating reviewer DID length"
);
}
#[test]
fn human_attestation_rejects_legacy_unframed_signature() {
let (public_key, secret_key) = crypto::generate_keypair();
let reviewer_did = "did:exo:reviewer-alice".to_string();
let ai_rec = Hash256::digest(b"ai says: approve");
let final_dec = Hash256::digest(b"human says: reject");
let mut legacy = b"zkml:attestation:".to_vec();
legacy.extend_from_slice(reviewer_did.as_bytes());
legacy.extend_from_slice(ai_rec.as_bytes());
legacy.extend_from_slice(final_dec.as_bytes());
legacy.push(0x03);
let signature = crypto::sign(&legacy, &secret_key);
let att = HumanAttestation {
reviewer_did,
reviewer_public_key: public_key,
ai_recommendation_hash: ai_rec,
final_decision_hash: final_dec,
decision: AttestationDecision::Rejected,
signature,
};
assert!(
!att.verify_signature(),
"legacy unframed attestations must not verify"
);
}
#[test]
fn human_attestation_tampered_decision_fails() {
let (mut att, _) = make_attestation(AttestationDecision::Rejected);
att.decision = AttestationDecision::Adopted;
assert!(
!att.verify_signature(),
"Tampered decision must fail verification"
);
}
#[test]
fn human_attestation_tampered_recommendation_fails() {
let (mut att, _) = make_attestation(AttestationDecision::Adopted);
att.ai_recommendation_hash = Hash256::digest(b"different");
assert!(!att.verify_signature());
}
#[test]
fn human_attestation_required_for_ai_output() {
let model = make_model();
let proof = prove_inference(&model, b"input", b"output").unwrap();
assert!(
proof.human_attestation.is_none(),
"Basic prove_inference must not fabricate attestation"
);
}
#[test]
fn ai_delta_detects_divergence() {
let delta = AiDelta::new(b"ai says approve", b"human says reject");
assert!(delta.divergence_detected);
assert_ne!(delta.ai_output_hash, delta.human_output_hash);
}
#[test]
fn ai_delta_no_divergence_when_same() {
let delta = AiDelta::new(b"approve", b"approve");
assert!(!delta.divergence_detected);
assert_eq!(delta.ai_output_hash, delta.human_output_hash);
}
#[test]
fn daubert_checklist_complete_when_all_satisfied() {
let checklist = DaubertChecklist {
methodology_documented: true,
peer_reviewable: true,
known_error_rate: Some("< 2%".into()),
generally_accepted: true,
};
assert!(checklist.is_complete());
}
#[test]
fn daubert_checklist_incomplete_without_methodology() {
let checklist = DaubertChecklist {
methodology_documented: false,
peer_reviewable: true,
known_error_rate: None,
generally_accepted: true,
};
assert!(!checklist.is_complete());
}
#[test]
fn daubert_checklist_incomplete_without_known_error_rate() {
let checklist = DaubertChecklist {
methodology_documented: true,
peer_reviewable: true,
known_error_rate: None,
generally_accepted: true,
};
assert!(
!checklist.is_complete(),
"Daubert admissibility requires a known or potential error rate"
);
}
#[test]
fn daubert_checklist_completeness_all_fields_required() {
for (doc, peer, accepted) in [
(false, true, true),
(true, false, true),
(true, true, false),
] {
let c = DaubertChecklist {
methodology_documented: doc,
peer_reviewable: peer,
known_error_rate: None,
generally_accepted: accepted,
};
assert!(!c.is_complete(), "Incomplete checklist must not pass");
}
}
#[test]
fn zkml_source_exposes_fail_closed_daubert_admissibility_status() {
let source = include_str!("zkml.rs");
let production = source
.split("// ---------------------------------------------------------------------------\n// Tests")
.next()
.expect("production section exists");
assert!(
production.contains("pub enum DaubertAdmissibility"),
"InferenceProof needs a typed Daubert admissibility decision"
);
assert!(
production.contains("pub fn daubert_admissibility_status(&self)"),
"InferenceProof callers need a fail-closed admissibility status API"
);
}
fn complete_daubert_checklist() -> DaubertChecklist {
DaubertChecklist {
methodology_documented: true,
peer_reviewable: true,
known_error_rate: Some("< 2%".into()),
generally_accepted: true,
}
}
fn admissible_inference_proof() -> InferenceProof {
let model = make_model();
let ai_output = b"ai says: approve";
let human_output = b"human says: reject";
let mut proof = prove_inference_with_provenance(
&model,
b"constitutionally bounded prompt",
b"case context",
ai_output,
)
.unwrap();
let human_output_hash = Hash256::digest(human_output);
let (attestation, _) =
make_inference_attestation(&proof, human_output_hash, AttestationDecision::Rejected);
proof.human_attestation = Some(attestation);
proof.ai_delta = Some(AiDelta::new(ai_output, human_output));
proof.daubert_checklist = Some(complete_daubert_checklist());
proof
}
#[test]
fn daubert_admissibility_accepts_complete_verified_provenance() {
let proof = admissible_inference_proof();
assert!(verify_inference(&proof).unwrap());
assert_eq!(
proof.daubert_admissibility_status(),
DaubertAdmissibility::Admissible
);
}
#[test]
fn daubert_admissibility_rejects_replayed_attestation_with_swapped_prompt_hash() {
let mut proof = admissible_inference_proof();
assert_eq!(
proof.daubert_admissibility_status(),
DaubertAdmissibility::Admissible
);
proof.prompt_hash = Some(Hash256::digest(b"benign replacement prompt"));
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("integrity") || reason.contains("human_attestation")),
"replayed provenance with a swapped prompt hash must be inadmissible, got {status:?}"
);
}
#[test]
fn daubert_admissibility_rejects_legacy_output_only_attestation() {
let mut proof = admissible_inference_proof();
let (legacy_attestation, _) = make_attestation(AttestationDecision::Rejected);
proof.human_attestation = Some(legacy_attestation);
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("human_attestation")),
"Daubert admissibility must reject attestations that do not bind proof provenance, got {status:?}"
);
}
#[test]
fn daubert_admissibility_rejects_missing_prompt_hash() {
let mut proof = admissible_inference_proof();
proof.prompt_hash = None;
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("prompt_hash")),
"missing prompt hash must be inadmissible, got {status:?}"
);
}
#[test]
fn daubert_admissibility_rejects_invalid_human_attestation() {
let mut proof = admissible_inference_proof();
let attestation = proof
.human_attestation
.as_mut()
.expect("test proof has attestation");
attestation.decision = AttestationDecision::Adopted;
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("human_attestation")),
"invalid attestation must be inadmissible, got {status:?}"
);
}
#[test]
fn daubert_admissibility_rejects_inconsistent_ai_delta() {
let mut proof = admissible_inference_proof();
let delta = proof.ai_delta.as_mut().expect("test proof has AI delta");
delta.ai_output_hash = Hash256::digest(b"different AI output");
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("ai_delta")),
"inconsistent AI delta must be inadmissible, got {status:?}"
);
}
#[test]
fn daubert_admissibility_rejects_incomplete_checklist() {
let mut proof = admissible_inference_proof();
proof.daubert_checklist = Some(DaubertChecklist {
methodology_documented: true,
peer_reviewable: true,
known_error_rate: None,
generally_accepted: true,
});
let status = proof.daubert_admissibility_status();
assert!(
matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("daubert_checklist")),
"incomplete checklist must be inadmissible, got {status:?}"
);
}
#[test]
fn zkml_tampered_model_detected() {
let model = make_model();
let proof = prove_inference(&model, b"input", b"output").unwrap();
let mut tampered = proof;
tampered.model_commitment.weights_hash = Hash256::digest(b"evil-weights");
assert!(!verify_inference(&tampered).unwrap());
}
}