Skip to main content

exo_proofs/
zkml.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Zero-knowledge ML verification.
18//!
19//! Verifies that a given output was produced by a committed model on a
20//! committed input, without revealing the model weights or input data.
21//!
22//! # Provenance extensions (LEG-007)
23//!
24//! `InferenceProof` carries optional provenance fields required for FRE 702
25//! / Daubert admissibility:
26//!
27//! - `prompt_hash` — distinct from `input_hash`; captures the system/user
28//!   prompt separately from the contextual input data.
29//! - `human_attestation` — a signed record of whether the reviewing human
30//!   adopted, modified, or rejected the AI output.
31//! - `ai_delta` — records the divergence between AI recommendation and final
32//!   human decision.
33//! - `daubert_checklist` — structured metadata for FRE 702 admissibility.
34//!
35//! All new fields are `Option<T>` with `#[serde(default)]` so that existing
36//! serialized `InferenceProof` values continue to deserialize correctly
37//! (Architecture panel backward-compat requirement).
38
39use exo_core::{
40    hash::hash_structured,
41    types::{Hash256, PublicKey, Signature},
42};
43use serde::{Deserialize, Serialize};
44
45use crate::error::{ProofError, Result};
46
47// ---------------------------------------------------------------------------
48// ModelCommitment
49// ---------------------------------------------------------------------------
50
51/// A commitment to a machine learning model.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ModelCommitment {
54    /// Hash of the model architecture description.
55    pub architecture_hash: Hash256,
56    /// Hash of the model weights.
57    pub weights_hash: Hash256,
58    /// Model version identifier.
59    pub version: u64,
60}
61
62impl ModelCommitment {
63    /// Create a new model commitment.
64    #[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    /// Compute the canonical commitment hash.
74    #[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// ---------------------------------------------------------------------------
86// Provenance types (LEG-007)
87// ---------------------------------------------------------------------------
88
89/// Whether the reviewing human adopted, modified, or rejected the AI output.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub enum AttestationDecision {
92    /// Human accepted the AI output verbatim.
93    Adopted,
94    /// Human modified the AI output before finalising.
95    Modified,
96    /// Human rejected the AI output and decided independently.
97    Rejected,
98}
99
100/// Signed human attestation over an AI inference.
101///
102/// Required for FRE 702 / Daubert admissibility: the attestation proves that
103/// a qualified human reviewed the AI output and made an independent decision.
104///
105/// The `signature` field is an Ed25519 signature over the canonical message:
106/// `b"zkml:attestation:" || reviewer_did_len_le_u64 || reviewer_did_bytes || ai_recommendation_hash || final_decision_hash || decision_variant_byte`
107///
108/// Daubert admissibility requires the stronger inference-bound message from
109/// [`HumanAttestation::signing_message_for_inference`], which binds the
110/// reviewer decision to the prompt hash, input hash, output hash, model
111/// commitment, proof, and verification tag.
112///
113/// Callers must verify the signature against the reviewer's `public_key` before
114/// relying on the attestation for evidentiary purposes.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct HumanAttestation {
117    /// DID of the reviewing human.
118    pub reviewer_did: String,
119    /// Public key of the reviewer (for signature verification).
120    pub reviewer_public_key: PublicKey,
121    /// What the AI system recommended.
122    pub ai_recommendation_hash: Hash256,
123    /// What the human ultimately decided.
124    pub final_decision_hash: Hash256,
125    /// Whether the human adopted, modified, or rejected the AI output.
126    pub decision: AttestationDecision,
127    /// Ed25519 signature over the attestation payload.
128    pub signature: Signature,
129}
130
131impl HumanAttestation {
132    /// Compute the canonical message that must be signed by the reviewer.
133    ///
134    /// The reviewer DID length is part of the signed frame. If it cannot be
135    /// represented exactly, the message is rejected instead of being encoded
136    /// with a saturated sentinel length.
137    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    /// Verify the Ed25519 signature on this attestation.
165    #[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    /// Compute the canonical inference-bound message that must be signed when
179    /// the attestation is used for Daubert admissibility.
180    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    /// Verify this attestation against a concrete inference proof and its
209    /// provenance fields.
210    #[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/// Captures divergence between AI recommendation and final human decision.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct AiDelta {
243    /// Hash of what the AI recommended.
244    pub ai_output_hash: Hash256,
245    /// Hash of the final human decision.
246    pub human_output_hash: Hash256,
247    /// True when the AI and human outputs differ.
248    pub divergence_detected: bool,
249}
250
251impl AiDelta {
252    /// Compute an AiDelta, setting `divergence_detected` automatically.
253    #[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/// Structured metadata for FRE 702 / Daubert admissibility.
267///
268/// An AI inference without a completed Daubert checklist should be treated as
269/// `AdmissibilityStatus::Inadmissible` pending review.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct DaubertChecklist {
272    /// The AI methodology is documented and reproducible.
273    pub methodology_documented: bool,
274    /// The methodology has been subjected to peer review or publication.
275    pub peer_reviewable: bool,
276    /// The known or potential error rate of the technique (None = unknown).
277    pub known_error_rate: Option<String>,
278    /// The technique is generally accepted in the relevant scientific community.
279    pub generally_accepted: bool,
280}
281
282impl DaubertChecklist {
283    /// Returns true if all required Daubert elements are satisfied.
284    #[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/// Fail-closed Daubert admissibility decision for an inference proof.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub enum DaubertAdmissibility {
299    /// All provenance required for Daubert/FRE 702 review is present.
300    Admissible,
301    /// Required provenance is missing or invalid.
302    Inadmissible { reason: String },
303}
304
305// ---------------------------------------------------------------------------
306// InferenceProof
307// ---------------------------------------------------------------------------
308
309/// Proof that an inference was correctly executed.
310///
311/// The core fields (`model_commitment`, `input_hash`, `output_hash`, `proof`,
312/// `verification_tag`) are always present and backward-compatible with
313/// existing serialized proofs.
314///
315/// The provenance fields (`prompt_hash`, `human_attestation`, `ai_delta`,
316/// `daubert_checklist`) are `Option<T>` with `serde(default)` so that
317/// pre-existing serialized proofs continue to deserialize.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct InferenceProof {
320    /// The model commitment.
321    pub model_commitment: ModelCommitment,
322    /// Hash of the contextual input data (context window / user message).
323    pub input_hash: Hash256,
324    /// Hash of the output data.
325    pub output_hash: Hash256,
326    /// The cryptographic proof binding input -> model -> output.
327    pub proof: Hash256,
328    /// Auxiliary verification data.
329    pub verification_tag: Hash256,
330
331    // ---- LEG-007 provenance extensions (backward-compatible) ----
332    /// Hash of the system/user prompt (distinct from `input_hash`).
333    ///
334    /// Separating prompt from context allows courts to assess whether the
335    /// AI was directed toward a particular outcome.
336    #[serde(default)]
337    pub prompt_hash: Option<Hash256>,
338
339    /// Signed human attestation: did the reviewer adopt, modify, or reject?
340    #[serde(default)]
341    pub human_attestation: Option<HumanAttestation>,
342
343    /// Divergence record comparing AI recommendation to final human decision.
344    #[serde(default)]
345    pub ai_delta: Option<AiDelta>,
346
347    /// Daubert admissibility checklist for FRE 702 compliance.
348    #[serde(default)]
349    pub daubert_checklist: Option<DaubertChecklist>,
350}
351
352impl InferenceProof {
353    /// Return the fail-closed Daubert admissibility status for this proof.
354    #[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
441// ---------------------------------------------------------------------------
442// Prove
443// ---------------------------------------------------------------------------
444
445/// Generate a basic proof (backward-compatible, no provenance fields).
446///
447/// Equivalent to the previous API.  New callers should prefer
448/// `prove_inference_with_provenance()`.
449///
450/// **Unaudited** — gated behind the `unaudited-pedagogical-proofs` feature.
451pub 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    // Compute the proof: a deterministic binding of model + input + output.
462    // NOTE: In a production ZKML system this would execute the model inside a
463    // ZK circuit (R1CS or STARK).  This hash-based binding is the MVP
464    // implementation and is documented as such for Daubert disclosure purposes.
465    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
483/// Generate a proof with full LEG-007 provenance.
484///
485/// `prompt` is the system/user prompt (separate from `input` context data).
486/// The resulting proof carries a distinct `prompt_hash` for Daubert disclosure.
487///
488/// **Unaudited** — gated behind the `unaudited-pedagogical-proofs` feature.
489pub fn prove_inference_with_provenance(
490    model: &ModelCommitment,
491    prompt: &[u8],
492    input: &[u8],
493    output: &[u8],
494) -> Result<InferenceProof> {
495    // guard applied via the inner call
496    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
510/// Verify that an inference proof is valid.
511///
512/// This checks that the proof correctly binds the model commitment, input hash,
513/// and output hash without needing the actual model or input.
514///
515/// **Unaudited** — gated behind the `unaudited-pedagogical-proofs` feature.
516/// Returns `Err(UnauditedImplementation)` when the feature is disabled.
517pub 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    // Recompute the expected proof
522    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    // Recompute and check the verification tag
528    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
541// ---------------------------------------------------------------------------
542// Internals
543// ---------------------------------------------------------------------------
544
545fn 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// ---------------------------------------------------------------------------
617// Tests
618// ---------------------------------------------------------------------------
619
620#[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    // ---- original tests (backward compat) ----
631
632    #[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    // ---- backward compat: old proofs (no Option fields) still deserialize ----
769
770    #[test]
771    fn backward_compat_deserialize_without_provenance_fields() {
772        // A serialized proof without the new Option fields must deserialize with
773        // all provenance fields set to None.
774        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    // ---- LEG-007: prompt_hash distinct from input_hash ----
785
786    #[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        // prompt_hash and input_hash must differ when prompt != context
798        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    // ---- LEG-007: HumanAttestation with Ed25519 signature ----
830
831    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        // Swap the decision after signing — signature must fail.
981        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        // A proof without human_attestation is flagged as lacking oversight.
998        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        // Caller must explicitly attach an attestation; absence = no oversight record.
1005    }
1006
1007    // ---- LEG-007: AiDelta ----
1008
1009    #[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    // ---- LEG-007: DaubertChecklist ----
1024
1025    #[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        // Each false flag independently makes the checklist incomplete.
1065        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    // ---- zkml_tampered_model_detected (alias of existing test) ----
1232
1233    #[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}