1use exo_core::{
40 hash::hash_structured,
41 types::{Hash256, PublicKey, Signature},
42};
43use serde::{Deserialize, Serialize};
44
45use crate::error::{ProofError, Result};
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ModelCommitment {
54 pub architecture_hash: Hash256,
56 pub weights_hash: Hash256,
58 pub version: u64,
60}
61
62impl ModelCommitment {
63 #[must_use]
65 pub fn new(architecture: &[u8], weights: &[u8], version: u64) -> Self {
66 Self {
67 architecture_hash: Hash256::digest(architecture),
68 weights_hash: Hash256::digest(weights),
69 version,
70 }
71 }
72
73 #[must_use]
75 pub fn commitment_hash(&self) -> Hash256 {
76 let mut hasher = blake3::Hasher::new();
77 hasher.update(b"zkml:model:");
78 hasher.update(self.architecture_hash.as_bytes());
79 hasher.update(self.weights_hash.as_bytes());
80 hasher.update(&self.version.to_le_bytes());
81 Hash256::from_bytes(*hasher.finalize().as_bytes())
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub enum AttestationDecision {
92 Adopted,
94 Modified,
96 Rejected,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct HumanAttestation {
117 pub reviewer_did: String,
119 pub reviewer_public_key: PublicKey,
121 pub ai_recommendation_hash: Hash256,
123 pub final_decision_hash: Hash256,
125 pub decision: AttestationDecision,
127 pub signature: Signature,
129}
130
131impl HumanAttestation {
132 pub fn signing_message(
138 reviewer_did: &str,
139 ai_recommendation_hash: &Hash256,
140 final_decision_hash: &Hash256,
141 decision: &AttestationDecision,
142 ) -> Result<Vec<u8>> {
143 let decision_byte: u8 = match decision {
144 AttestationDecision::Adopted => 0x01,
145 AttestationDecision::Modified => 0x02,
146 AttestationDecision::Rejected => 0x03,
147 };
148 let reviewer_did_bytes = reviewer_did.as_bytes();
149 let reviewer_did_len = u64::try_from(reviewer_did_bytes.len()).map_err(|_| {
150 ProofError::InvalidProofFormat(format!(
151 "reviewer DID length {} cannot be represented in canonical attestation frame",
152 reviewer_did_bytes.len()
153 ))
154 })?;
155 let mut msg = b"zkml:attestation:".to_vec();
156 msg.extend_from_slice(&reviewer_did_len.to_le_bytes());
157 msg.extend_from_slice(reviewer_did_bytes);
158 msg.extend_from_slice(ai_recommendation_hash.as_bytes());
159 msg.extend_from_slice(final_decision_hash.as_bytes());
160 msg.push(decision_byte);
161 Ok(msg)
162 }
163
164 #[must_use]
166 pub fn verify_signature(&self) -> bool {
167 let Ok(msg) = Self::signing_message(
168 &self.reviewer_did,
169 &self.ai_recommendation_hash,
170 &self.final_decision_hash,
171 &self.decision,
172 ) else {
173 return false;
174 };
175 exo_core::crypto::verify(&msg, &self.signature, &self.reviewer_public_key)
176 }
177
178 pub fn signing_message_for_inference(
181 reviewer_did: &str,
182 ai_recommendation_hash: &Hash256,
183 final_decision_hash: &Hash256,
184 decision: &AttestationDecision,
185 inference: &InferenceProof,
186 ) -> Result<Vec<u8>> {
187 let Some(prompt_hash) = inference.prompt_hash else {
188 return Err(ProofError::InvalidProofFormat(
189 "prompt_hash is required for inference-bound human attestation".into(),
190 ));
191 };
192 let payload = HumanAttestationInferenceSigningPayload {
193 domain: "exo.zkml.human_attestation.v2",
194 reviewer_did,
195 ai_recommendation_hash: *ai_recommendation_hash,
196 final_decision_hash: *final_decision_hash,
197 decision,
198 model_hash: inference.model_commitment.commitment_hash(),
199 input_hash: inference.input_hash,
200 output_hash: inference.output_hash,
201 proof: inference.proof,
202 verification_tag: inference.verification_tag,
203 prompt_hash,
204 };
205 canonical_cbor_message(&payload, "human attestation inference signing payload")
206 }
207
208 #[must_use]
211 pub fn verify_for_inference(&self, inference: &InferenceProof) -> bool {
212 let Ok(msg) = Self::signing_message_for_inference(
213 &self.reviewer_did,
214 &self.ai_recommendation_hash,
215 &self.final_decision_hash,
216 &self.decision,
217 inference,
218 ) else {
219 return false;
220 };
221 exo_core::crypto::verify(&msg, &self.signature, &self.reviewer_public_key)
222 }
223}
224
225#[derive(Serialize)]
226struct HumanAttestationInferenceSigningPayload<'a> {
227 domain: &'static str,
228 reviewer_did: &'a str,
229 ai_recommendation_hash: Hash256,
230 final_decision_hash: Hash256,
231 decision: &'a AttestationDecision,
232 model_hash: Hash256,
233 input_hash: Hash256,
234 output_hash: Hash256,
235 proof: Hash256,
236 verification_tag: Hash256,
237 prompt_hash: Hash256,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct AiDelta {
243 pub ai_output_hash: Hash256,
245 pub human_output_hash: Hash256,
247 pub divergence_detected: bool,
249}
250
251impl AiDelta {
252 #[must_use]
254 pub fn new(ai_output: &[u8], human_output: &[u8]) -> Self {
255 let ai_output_hash = Hash256::digest(ai_output);
256 let human_output_hash = Hash256::digest(human_output);
257 let divergence_detected = ai_output_hash != human_output_hash;
258 Self {
259 ai_output_hash,
260 human_output_hash,
261 divergence_detected,
262 }
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct DaubertChecklist {
272 pub methodology_documented: bool,
274 pub peer_reviewable: bool,
276 pub known_error_rate: Option<String>,
278 pub generally_accepted: bool,
280}
281
282impl DaubertChecklist {
283 #[must_use]
285 pub fn is_complete(&self) -> bool {
286 self.methodology_documented
287 && self.peer_reviewable
288 && self
289 .known_error_rate
290 .as_ref()
291 .is_some_and(|error_rate| !error_rate.trim().is_empty())
292 && self.generally_accepted
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub enum DaubertAdmissibility {
299 Admissible,
301 Inadmissible { reason: String },
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct InferenceProof {
320 pub model_commitment: ModelCommitment,
322 pub input_hash: Hash256,
324 pub output_hash: Hash256,
326 pub proof: Hash256,
328 pub verification_tag: Hash256,
330
331 #[serde(default)]
337 pub prompt_hash: Option<Hash256>,
338
339 #[serde(default)]
341 pub human_attestation: Option<HumanAttestation>,
342
343 #[serde(default)]
345 pub ai_delta: Option<AiDelta>,
346
347 #[serde(default)]
349 pub daubert_checklist: Option<DaubertChecklist>,
350}
351
352impl InferenceProof {
353 #[must_use]
355 pub fn daubert_admissibility_status(&self) -> DaubertAdmissibility {
356 if let Err(err) = crate::guard_unaudited("zkml::daubert_admissibility_status") {
357 return DaubertAdmissibility::Inadmissible {
358 reason: err.to_string(),
359 };
360 }
361
362 if self.prompt_hash.is_none() {
363 return DaubertAdmissibility::Inadmissible {
364 reason: "prompt_hash is required for Daubert admissibility".into(),
365 };
366 }
367
368 if !self.proof_integrity_valid() {
369 return DaubertAdmissibility::Inadmissible {
370 reason: "proof integrity check failed".into(),
371 };
372 }
373
374 let Some(attestation) = &self.human_attestation else {
375 return DaubertAdmissibility::Inadmissible {
376 reason: "human_attestation is required for Daubert admissibility".into(),
377 };
378 };
379
380 if !attestation.verify_for_inference(self) {
381 return DaubertAdmissibility::Inadmissible {
382 reason: "human_attestation signature failed verification".into(),
383 };
384 }
385
386 if attestation.ai_recommendation_hash != self.output_hash {
387 return DaubertAdmissibility::Inadmissible {
388 reason: "human_attestation AI recommendation does not match proof output_hash"
389 .into(),
390 };
391 }
392
393 let Some(ai_delta) = &self.ai_delta else {
394 return DaubertAdmissibility::Inadmissible {
395 reason: "ai_delta is required for Daubert admissibility".into(),
396 };
397 };
398
399 if ai_delta.ai_output_hash != attestation.ai_recommendation_hash
400 || ai_delta.human_output_hash != attestation.final_decision_hash
401 {
402 return DaubertAdmissibility::Inadmissible {
403 reason: "ai_delta does not match human_attestation hashes".into(),
404 };
405 }
406
407 let Some(checklist) = &self.daubert_checklist else {
408 return DaubertAdmissibility::Inadmissible {
409 reason: "daubert_checklist is required for Daubert admissibility".into(),
410 };
411 };
412
413 if !checklist.is_complete() {
414 return DaubertAdmissibility::Inadmissible {
415 reason: "daubert_checklist is incomplete".into(),
416 };
417 }
418
419 DaubertAdmissibility::Admissible
420 }
421
422 fn proof_integrity_valid(&self) -> bool {
423 let model_hash = self.model_commitment.commitment_hash();
424 let expected_proof =
425 compute_inference_proof(&model_hash, &self.input_hash, &self.output_hash);
426 let Ok(expected_tag) = compute_verification_tag(
427 &model_hash,
428 &self.input_hash,
429 &self.output_hash,
430 &self.proof,
431 self.prompt_hash.as_ref(),
432 ) else {
433 return false;
434 };
435
436 constant_time_hash256_eq(&expected_proof, &self.proof)
437 & constant_time_hash256_eq(&expected_tag, &self.verification_tag)
438 }
439}
440
441pub fn prove_inference(
452 model: &ModelCommitment,
453 input: &[u8],
454 output: &[u8],
455) -> Result<InferenceProof> {
456 crate::guard_unaudited("zkml::prove_inference")?;
457 let input_hash = Hash256::digest(input);
458 let output_hash = Hash256::digest(output);
459 let model_hash = model.commitment_hash();
460
461 let proof = compute_inference_proof(&model_hash, &input_hash, &output_hash);
466
467 let verification_tag =
468 compute_verification_tag(&model_hash, &input_hash, &output_hash, &proof, None)?;
469
470 Ok(InferenceProof {
471 model_commitment: model.clone(),
472 input_hash,
473 output_hash,
474 proof,
475 verification_tag,
476 prompt_hash: None,
477 human_attestation: None,
478 ai_delta: None,
479 daubert_checklist: None,
480 })
481}
482
483pub fn prove_inference_with_provenance(
490 model: &ModelCommitment,
491 prompt: &[u8],
492 input: &[u8],
493 output: &[u8],
494) -> Result<InferenceProof> {
495 let mut proof = prove_inference(model, input, output)?;
497 let prompt_hash = Hash256::digest(prompt);
498 proof.prompt_hash = Some(prompt_hash);
499 let model_hash = model.commitment_hash();
500 proof.verification_tag = compute_verification_tag(
501 &model_hash,
502 &proof.input_hash,
503 &proof.output_hash,
504 &proof.proof,
505 Some(&prompt_hash),
506 )?;
507 Ok(proof)
508}
509
510pub fn verify_inference(proof: &InferenceProof) -> Result<bool> {
518 crate::guard_unaudited("zkml::verify_inference")?;
519 let model_hash = proof.model_commitment.commitment_hash();
520
521 let expected_proof =
523 compute_inference_proof(&model_hash, &proof.input_hash, &proof.output_hash);
524
525 let proof_ok = constant_time_hash256_eq(&expected_proof, &proof.proof);
526
527 let expected_tag = compute_verification_tag(
529 &model_hash,
530 &proof.input_hash,
531 &proof.output_hash,
532 &proof.proof,
533 proof.prompt_hash.as_ref(),
534 )?;
535
536 let tag_ok = constant_time_hash256_eq(&expected_tag, &proof.verification_tag);
537
538 Ok(proof_ok & tag_ok)
539}
540
541fn compute_inference_proof(
546 model_hash: &Hash256,
547 input_hash: &Hash256,
548 output_hash: &Hash256,
549) -> Hash256 {
550 let mut hasher = blake3::Hasher::new();
551 hasher.update(b"zkml:proof:");
552 hasher.update(model_hash.as_bytes());
553 hasher.update(input_hash.as_bytes());
554 hasher.update(output_hash.as_bytes());
555 Hash256::from_bytes(*hasher.finalize().as_bytes())
556}
557
558fn compute_verification_tag(
559 model_hash: &Hash256,
560 input_hash: &Hash256,
561 output_hash: &Hash256,
562 proof: &Hash256,
563 prompt_hash: Option<&Hash256>,
564) -> Result<Hash256> {
565 if let Some(prompt_hash) = prompt_hash {
566 let payload = InferenceVerificationTagPayload {
567 domain: "exo.zkml.verification_tag.v2",
568 model_hash: *model_hash,
569 input_hash: *input_hash,
570 output_hash: *output_hash,
571 proof: *proof,
572 prompt_hash: *prompt_hash,
573 };
574 return hash_structured(&payload).map_err(|err| {
575 ProofError::InvalidProofFormat(format!(
576 "zkML verification tag canonical encoding failed: {err}"
577 ))
578 });
579 }
580
581 let mut hasher = blake3::Hasher::new();
582 hasher.update(b"zkml:verify:");
583 hasher.update(model_hash.as_bytes());
584 hasher.update(input_hash.as_bytes());
585 hasher.update(output_hash.as_bytes());
586 hasher.update(proof.as_bytes());
587 Ok(Hash256::from_bytes(*hasher.finalize().as_bytes()))
588}
589
590#[derive(Serialize)]
591struct InferenceVerificationTagPayload {
592 domain: &'static str,
593 model_hash: Hash256,
594 input_hash: Hash256,
595 output_hash: Hash256,
596 proof: Hash256,
597 prompt_hash: Hash256,
598}
599
600fn canonical_cbor_message<T: Serialize>(value: &T, label: &str) -> Result<Vec<u8>> {
601 let mut encoded = Vec::new();
602 ciborium::into_writer(value, &mut encoded).map_err(|err| {
603 ProofError::InvalidProofFormat(format!("{label} canonical CBOR encoding failed: {err}"))
604 })?;
605 Ok(encoded)
606}
607
608fn constant_time_hash256_eq(left: &Hash256, right: &Hash256) -> bool {
609 let mut diff = 0u8;
610 for idx in 0..32 {
611 diff |= left.as_bytes()[idx] ^ right.as_bytes()[idx];
612 }
613 diff == 0
614}
615
616#[cfg(all(test, feature = "unaudited-pedagogical-proofs"))]
621mod tests {
622 use exo_core::crypto;
623
624 use super::*;
625
626 fn make_model() -> ModelCommitment {
627 ModelCommitment::new(b"transformer-v1", b"weights-blob-1234", 1)
628 }
629
630 #[test]
633 fn model_commitment_deterministic() {
634 let m1 = ModelCommitment::new(b"arch", b"weights", 1);
635 let m2 = ModelCommitment::new(b"arch", b"weights", 1);
636 assert_eq!(m1, m2);
637 assert_eq!(m1.commitment_hash(), m2.commitment_hash());
638 }
639
640 #[test]
641 fn different_models_different_hashes() {
642 let m1 = ModelCommitment::new(b"arch1", b"weights1", 1);
643 let m2 = ModelCommitment::new(b"arch2", b"weights2", 1);
644 assert_ne!(m1.commitment_hash(), m2.commitment_hash());
645 }
646
647 #[test]
648 fn different_versions_different_hashes() {
649 let m1 = ModelCommitment::new(b"arch", b"weights", 1);
650 let m2 = ModelCommitment::new(b"arch", b"weights", 2);
651 assert_ne!(m1.commitment_hash(), m2.commitment_hash());
652 }
653
654 #[test]
655 fn prove_and_verify() {
656 let model = make_model();
657 let proof =
658 prove_inference(&model, b"classify this image", b"cat: 0.95, dog: 0.05").unwrap();
659 assert!(verify_inference(&proof).unwrap());
660 }
661
662 #[test]
663 fn verify_fails_tampered_model() {
664 let model = make_model();
665 let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
666 tampered.model_commitment = ModelCommitment::new(b"evil-arch", b"evil-weights", 99);
667 assert!(!verify_inference(&tampered).unwrap());
668 }
669
670 #[test]
671 fn verify_fails_tampered_input() {
672 let model = make_model();
673 let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
674 tampered.input_hash = Hash256::digest(b"different-input");
675 assert!(!verify_inference(&tampered).unwrap());
676 }
677
678 #[test]
679 fn verify_fails_tampered_output() {
680 let model = make_model();
681 let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
682 tampered.output_hash = Hash256::digest(b"different-output");
683 assert!(!verify_inference(&tampered).unwrap());
684 }
685
686 #[test]
687 fn verify_fails_tampered_proof_field() {
688 let model = make_model();
689 let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
690 tampered.proof = Hash256::ZERO;
691 assert!(!verify_inference(&tampered).unwrap());
692 }
693
694 #[test]
695 fn verify_fails_tampered_tag() {
696 let model = make_model();
697 let mut tampered = prove_inference(&model, b"input", b"output").unwrap();
698 tampered.verification_tag = Hash256::ZERO;
699 assert!(!verify_inference(&tampered).unwrap());
700 }
701
702 #[test]
703 fn verify_inference_uses_constant_time_hash_comparisons() {
704 let source = include_str!("zkml.rs");
705 let Some(verify_start) = source.find("pub fn verify_inference") else {
706 panic!("verify_inference must exist");
707 };
708 let Some(internals_start) = source.find("// Internals") else {
709 panic!("internals marker must exist");
710 };
711 let verify_source = &source[verify_start..internals_start];
712
713 assert!(
714 verify_source.contains("constant_time_hash256_eq"),
715 "verify_inference must use the constant-time Hash256 comparator"
716 );
717 assert!(
718 !verify_source.contains("expected_proof != proof.proof"),
719 "proof comparison must not use variable-time PartialEq"
720 );
721 assert!(
722 !verify_source.contains("expected_tag == proof.verification_tag"),
723 "verification tag comparison must not use variable-time PartialEq"
724 );
725 }
726
727 #[test]
728 fn different_inputs_different_proofs() {
729 let model = make_model();
730 let p1 = prove_inference(&model, b"input1", b"output1").unwrap();
731 let p2 = prove_inference(&model, b"input2", b"output2").unwrap();
732 assert_ne!(p1.proof, p2.proof);
733 }
734
735 #[test]
736 fn same_inputs_same_proof() {
737 let model = make_model();
738 let p1 = prove_inference(&model, b"input", b"output").unwrap();
739 let p2 = prove_inference(&model, b"input", b"output").unwrap();
740 assert_eq!(p1, p2);
741 }
742
743 #[test]
744 fn proof_hides_model_input() {
745 let model = make_model();
746 let proof = prove_inference(&model, b"secret input", b"secret output").unwrap();
747 assert_eq!(proof.input_hash, Hash256::digest(b"secret input"));
748 assert_eq!(proof.output_hash, Hash256::digest(b"secret output"));
749 assert_eq!(
750 proof.model_commitment.architecture_hash,
751 Hash256::digest(b"transformer-v1")
752 );
753 }
754
755 #[test]
756 fn empty_input_output() {
757 let model = make_model();
758 assert!(verify_inference(&prove_inference(&model, b"", b"").unwrap()).unwrap());
759 }
760
761 #[test]
762 fn large_input_output() {
763 let model = make_model();
764 let proof = prove_inference(&model, &vec![0xABu8; 10_000], &vec![0xCDu8; 5_000]).unwrap();
765 assert!(verify_inference(&proof).unwrap());
766 }
767
768 #[test]
771 fn backward_compat_deserialize_without_provenance_fields() {
772 let model = make_model();
775 let proof = prove_inference(&model, b"input", b"output").unwrap();
776 let json = serde_json::to_string(&proof).unwrap();
777 let restored: InferenceProof = serde_json::from_str(&json).unwrap();
778 assert!(restored.prompt_hash.is_none());
779 assert!(restored.human_attestation.is_none());
780 assert!(restored.ai_delta.is_none());
781 assert!(restored.daubert_checklist.is_none());
782 }
783
784 #[test]
787 fn zkml_proof_binds_model_and_prompt() {
788 let model = make_model();
789 let prompt = b"You are a board advisor. Recommend yes or no.";
790 let context = b"Q4 revenue declined 15%.";
791 let output = b"Recommend: reject the acquisition.";
792
793 let proof = prove_inference_with_provenance(&model, prompt, context, output).unwrap();
794
795 assert!(verify_inference(&proof).unwrap());
796 assert!(proof.prompt_hash.is_some(), "prompt_hash must be present");
797 assert_ne!(
799 proof.prompt_hash.unwrap(),
800 proof.input_hash,
801 "prompt_hash must be distinct from input_hash"
802 );
803 assert_eq!(proof.prompt_hash, Some(Hash256::digest(prompt)));
804 assert_eq!(proof.input_hash, Hash256::digest(context));
805 }
806
807 #[test]
808 fn prove_inference_with_provenance_verifies() {
809 let model = make_model();
810 let proof =
811 prove_inference_with_provenance(&model, b"prompt", b"context", b"output").unwrap();
812 assert!(verify_inference(&proof).unwrap());
813 }
814
815 #[test]
816 fn verify_inference_rejects_swapped_prompt_provenance() {
817 let model = make_model();
818 let mut proof =
819 prove_inference_with_provenance(&model, b"prompt", b"context", b"output").unwrap();
820
821 proof.prompt_hash = Some(Hash256::digest(b"different prompt"));
822
823 assert!(
824 !verify_inference(&proof).unwrap(),
825 "prompt provenance must be bound to the inference verification tag"
826 );
827 }
828
829 fn make_attestation(
832 decision: AttestationDecision,
833 ) -> (HumanAttestation, exo_core::types::SecretKey) {
834 let (public_key, secret_key) = crypto::generate_keypair();
835 let reviewer_did = "did:exo:reviewer-alice".to_string();
836 let ai_rec = Hash256::digest(b"ai says: approve");
837 let final_dec = Hash256::digest(b"human says: reject");
838
839 let msg = HumanAttestation::signing_message(&reviewer_did, &ai_rec, &final_dec, &decision)
840 .unwrap();
841 let signature = crypto::sign(&msg, &secret_key);
842
843 let att = HumanAttestation {
844 reviewer_did,
845 reviewer_public_key: public_key,
846 ai_recommendation_hash: ai_rec,
847 final_decision_hash: final_dec,
848 decision,
849 signature,
850 };
851 (att, secret_key)
852 }
853
854 fn make_inference_attestation(
855 proof: &InferenceProof,
856 final_decision_hash: Hash256,
857 decision: AttestationDecision,
858 ) -> (HumanAttestation, exo_core::types::SecretKey) {
859 let (public_key, secret_key) = crypto::generate_keypair();
860 let reviewer_did = "did:exo:reviewer-alice".to_string();
861 let msg = HumanAttestation::signing_message_for_inference(
862 &reviewer_did,
863 &proof.output_hash,
864 &final_decision_hash,
865 &decision,
866 proof,
867 )
868 .unwrap();
869 let signature = crypto::sign(&msg, &secret_key);
870
871 let att = HumanAttestation {
872 reviewer_did,
873 reviewer_public_key: public_key,
874 ai_recommendation_hash: proof.output_hash,
875 final_decision_hash,
876 decision,
877 signature,
878 };
879 (att, secret_key)
880 }
881
882 #[test]
883 fn human_attestation_signature_verifies() {
884 let (att, _) = make_attestation(AttestationDecision::Rejected);
885 assert!(
886 att.verify_signature(),
887 "Valid Ed25519 attestation must verify"
888 );
889 }
890
891 #[test]
892 fn human_attestation_signing_message_frames_reviewer_did() {
893 let reviewer_did = "did:exo:reviewer-alice";
894 let ai_rec = Hash256::digest(b"ai recommendation");
895 let final_dec = Hash256::digest(b"final decision");
896
897 let msg = HumanAttestation::signing_message(
898 reviewer_did,
899 &ai_rec,
900 &final_dec,
901 &AttestationDecision::Modified,
902 )
903 .unwrap();
904
905 let domain = b"zkml:attestation:";
906 assert!(msg.starts_with(domain));
907 let did_len_start = domain.len();
908 let did_len_end = did_len_start + 8;
909 let did_len_bytes: [u8; 8] = match msg[did_len_start..did_len_end].try_into() {
910 Ok(bytes) => bytes,
911 Err(_) => panic!("DID length prefix must be eight bytes"),
912 };
913 let expected_len = match u64::try_from(reviewer_did.len()) {
914 Ok(len) => len,
915 Err(_) => panic!("reviewer DID length must fit in u64"),
916 };
917 assert_eq!(u64::from_le_bytes(did_len_bytes), expected_len);
918 assert_eq!(
919 &msg[did_len_end..did_len_end + reviewer_did.len()],
920 reviewer_did.as_bytes()
921 );
922
923 let mut legacy = domain.to_vec();
924 legacy.extend_from_slice(reviewer_did.as_bytes());
925 legacy.extend_from_slice(ai_rec.as_bytes());
926 legacy.extend_from_slice(final_dec.as_bytes());
927 legacy.push(0x02);
928 assert_ne!(msg, legacy, "new attestations must not use legacy framing");
929 }
930
931 #[test]
932 fn human_attestation_signing_message_does_not_saturate_reviewer_did_length() {
933 let production = include_str!("zkml.rs");
934 let signing_message_section = production
935 .split("pub fn signing_message")
936 .nth(1)
937 .expect("signing_message function must exist")
938 .split("pub fn verify_signature")
939 .next()
940 .expect("verify_signature function must follow signing_message");
941
942 assert!(
943 !signing_message_section.contains("unwrap_or(u64::MAX)"),
944 "canonical attestation framing must fail closed instead of saturating reviewer DID length"
945 );
946 }
947
948 #[test]
949 fn human_attestation_rejects_legacy_unframed_signature() {
950 let (public_key, secret_key) = crypto::generate_keypair();
951 let reviewer_did = "did:exo:reviewer-alice".to_string();
952 let ai_rec = Hash256::digest(b"ai says: approve");
953 let final_dec = Hash256::digest(b"human says: reject");
954
955 let mut legacy = b"zkml:attestation:".to_vec();
956 legacy.extend_from_slice(reviewer_did.as_bytes());
957 legacy.extend_from_slice(ai_rec.as_bytes());
958 legacy.extend_from_slice(final_dec.as_bytes());
959 legacy.push(0x03);
960
961 let signature = crypto::sign(&legacy, &secret_key);
962 let att = HumanAttestation {
963 reviewer_did,
964 reviewer_public_key: public_key,
965 ai_recommendation_hash: ai_rec,
966 final_decision_hash: final_dec,
967 decision: AttestationDecision::Rejected,
968 signature,
969 };
970
971 assert!(
972 !att.verify_signature(),
973 "legacy unframed attestations must not verify"
974 );
975 }
976
977 #[test]
978 fn human_attestation_tampered_decision_fails() {
979 let (mut att, _) = make_attestation(AttestationDecision::Rejected);
980 att.decision = AttestationDecision::Adopted;
982 assert!(
983 !att.verify_signature(),
984 "Tampered decision must fail verification"
985 );
986 }
987
988 #[test]
989 fn human_attestation_tampered_recommendation_fails() {
990 let (mut att, _) = make_attestation(AttestationDecision::Adopted);
991 att.ai_recommendation_hash = Hash256::digest(b"different");
992 assert!(!att.verify_signature());
993 }
994
995 #[test]
996 fn human_attestation_required_for_ai_output() {
997 let model = make_model();
999 let proof = prove_inference(&model, b"input", b"output").unwrap();
1000 assert!(
1001 proof.human_attestation.is_none(),
1002 "Basic prove_inference must not fabricate attestation"
1003 );
1004 }
1006
1007 #[test]
1010 fn ai_delta_detects_divergence() {
1011 let delta = AiDelta::new(b"ai says approve", b"human says reject");
1012 assert!(delta.divergence_detected);
1013 assert_ne!(delta.ai_output_hash, delta.human_output_hash);
1014 }
1015
1016 #[test]
1017 fn ai_delta_no_divergence_when_same() {
1018 let delta = AiDelta::new(b"approve", b"approve");
1019 assert!(!delta.divergence_detected);
1020 assert_eq!(delta.ai_output_hash, delta.human_output_hash);
1021 }
1022
1023 #[test]
1026 fn daubert_checklist_complete_when_all_satisfied() {
1027 let checklist = DaubertChecklist {
1028 methodology_documented: true,
1029 peer_reviewable: true,
1030 known_error_rate: Some("< 2%".into()),
1031 generally_accepted: true,
1032 };
1033 assert!(checklist.is_complete());
1034 }
1035
1036 #[test]
1037 fn daubert_checklist_incomplete_without_methodology() {
1038 let checklist = DaubertChecklist {
1039 methodology_documented: false,
1040 peer_reviewable: true,
1041 known_error_rate: None,
1042 generally_accepted: true,
1043 };
1044 assert!(!checklist.is_complete());
1045 }
1046
1047 #[test]
1048 fn daubert_checklist_incomplete_without_known_error_rate() {
1049 let checklist = DaubertChecklist {
1050 methodology_documented: true,
1051 peer_reviewable: true,
1052 known_error_rate: None,
1053 generally_accepted: true,
1054 };
1055
1056 assert!(
1057 !checklist.is_complete(),
1058 "Daubert admissibility requires a known or potential error rate"
1059 );
1060 }
1061
1062 #[test]
1063 fn daubert_checklist_completeness_all_fields_required() {
1064 for (doc, peer, accepted) in [
1066 (false, true, true),
1067 (true, false, true),
1068 (true, true, false),
1069 ] {
1070 let c = DaubertChecklist {
1071 methodology_documented: doc,
1072 peer_reviewable: peer,
1073 known_error_rate: None,
1074 generally_accepted: accepted,
1075 };
1076 assert!(!c.is_complete(), "Incomplete checklist must not pass");
1077 }
1078 }
1079
1080 #[test]
1081 fn zkml_source_exposes_fail_closed_daubert_admissibility_status() {
1082 let source = include_str!("zkml.rs");
1083 let production = source
1084 .split("// ---------------------------------------------------------------------------\n// Tests")
1085 .next()
1086 .expect("production section exists");
1087
1088 assert!(
1089 production.contains("pub enum DaubertAdmissibility"),
1090 "InferenceProof needs a typed Daubert admissibility decision"
1091 );
1092 assert!(
1093 production.contains("pub fn daubert_admissibility_status(&self)"),
1094 "InferenceProof callers need a fail-closed admissibility status API"
1095 );
1096 }
1097
1098 fn complete_daubert_checklist() -> DaubertChecklist {
1099 DaubertChecklist {
1100 methodology_documented: true,
1101 peer_reviewable: true,
1102 known_error_rate: Some("< 2%".into()),
1103 generally_accepted: true,
1104 }
1105 }
1106
1107 fn admissible_inference_proof() -> InferenceProof {
1108 let model = make_model();
1109 let ai_output = b"ai says: approve";
1110 let human_output = b"human says: reject";
1111 let mut proof = prove_inference_with_provenance(
1112 &model,
1113 b"constitutionally bounded prompt",
1114 b"case context",
1115 ai_output,
1116 )
1117 .unwrap();
1118 let human_output_hash = Hash256::digest(human_output);
1119 let (attestation, _) =
1120 make_inference_attestation(&proof, human_output_hash, AttestationDecision::Rejected);
1121 proof.human_attestation = Some(attestation);
1122 proof.ai_delta = Some(AiDelta::new(ai_output, human_output));
1123 proof.daubert_checklist = Some(complete_daubert_checklist());
1124 proof
1125 }
1126
1127 #[test]
1128 fn daubert_admissibility_accepts_complete_verified_provenance() {
1129 let proof = admissible_inference_proof();
1130
1131 assert!(verify_inference(&proof).unwrap());
1132 assert_eq!(
1133 proof.daubert_admissibility_status(),
1134 DaubertAdmissibility::Admissible
1135 );
1136 }
1137
1138 #[test]
1139 fn daubert_admissibility_rejects_replayed_attestation_with_swapped_prompt_hash() {
1140 let mut proof = admissible_inference_proof();
1141 assert_eq!(
1142 proof.daubert_admissibility_status(),
1143 DaubertAdmissibility::Admissible
1144 );
1145
1146 proof.prompt_hash = Some(Hash256::digest(b"benign replacement prompt"));
1147
1148 let status = proof.daubert_admissibility_status();
1149 assert!(
1150 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("integrity") || reason.contains("human_attestation")),
1151 "replayed provenance with a swapped prompt hash must be inadmissible, got {status:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn daubert_admissibility_rejects_legacy_output_only_attestation() {
1157 let mut proof = admissible_inference_proof();
1158 let (legacy_attestation, _) = make_attestation(AttestationDecision::Rejected);
1159 proof.human_attestation = Some(legacy_attestation);
1160
1161 let status = proof.daubert_admissibility_status();
1162
1163 assert!(
1164 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("human_attestation")),
1165 "Daubert admissibility must reject attestations that do not bind proof provenance, got {status:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn daubert_admissibility_rejects_missing_prompt_hash() {
1171 let mut proof = admissible_inference_proof();
1172 proof.prompt_hash = None;
1173
1174 let status = proof.daubert_admissibility_status();
1175
1176 assert!(
1177 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("prompt_hash")),
1178 "missing prompt hash must be inadmissible, got {status:?}"
1179 );
1180 }
1181
1182 #[test]
1183 fn daubert_admissibility_rejects_invalid_human_attestation() {
1184 let mut proof = admissible_inference_proof();
1185 let attestation = proof
1186 .human_attestation
1187 .as_mut()
1188 .expect("test proof has attestation");
1189 attestation.decision = AttestationDecision::Adopted;
1190
1191 let status = proof.daubert_admissibility_status();
1192
1193 assert!(
1194 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("human_attestation")),
1195 "invalid attestation must be inadmissible, got {status:?}"
1196 );
1197 }
1198
1199 #[test]
1200 fn daubert_admissibility_rejects_inconsistent_ai_delta() {
1201 let mut proof = admissible_inference_proof();
1202 let delta = proof.ai_delta.as_mut().expect("test proof has AI delta");
1203 delta.ai_output_hash = Hash256::digest(b"different AI output");
1204
1205 let status = proof.daubert_admissibility_status();
1206
1207 assert!(
1208 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("ai_delta")),
1209 "inconsistent AI delta must be inadmissible, got {status:?}"
1210 );
1211 }
1212
1213 #[test]
1214 fn daubert_admissibility_rejects_incomplete_checklist() {
1215 let mut proof = admissible_inference_proof();
1216 proof.daubert_checklist = Some(DaubertChecklist {
1217 methodology_documented: true,
1218 peer_reviewable: true,
1219 known_error_rate: None,
1220 generally_accepted: true,
1221 });
1222
1223 let status = proof.daubert_admissibility_status();
1224
1225 assert!(
1226 matches!(status, DaubertAdmissibility::Inadmissible { ref reason } if reason.contains("daubert_checklist")),
1227 "incomplete checklist must be inadmissible, got {status:?}"
1228 );
1229 }
1230
1231 #[test]
1234 fn zkml_tampered_model_detected() {
1235 let model = make_model();
1236 let proof = prove_inference(&model, b"input", b"output").unwrap();
1237 let mut tampered = proof;
1238 tampered.model_commitment.weights_hash = Hash256::digest(b"evil-weights");
1239 assert!(!verify_inference(&tampered).unwrap());
1240 }
1241}