Skip to main content

vyre_driver/
evidence.rs

1//! Backend-neutral evidence, provenance, and replay metadata.
2//!
3//! This module is the shared driver-layer contract for source provenance and
4//! dispatch evidence. Benchmark reports, conformance artifacts, replay
5//! capsules, and consumer APIs should import this surface instead of owning
6//! parallel fingerprint or artifact schemas.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::Read;
11use std::path::Path;
12use std::process::Command;
13
14use serde::{Deserialize, Serialize};
15use vyre_foundation::hashing::update_length_delimited_field as update_hash_field;
16use vyre_foundation::ir::Program;
17use vyre_foundation::serial::wire::encode::PROGRAM_WIRE_DIGEST_VERSION;
18
19use crate::backend::{BackendError, DispatchConfig, TimedDispatchResult, VyreBackend};
20use crate::pipeline::{
21    dispatch_policy_cache_digest, dispatch_policy_cache_string, hex_encode,
22    try_normalized_program_cache_digest, PipelineReproManifest,
23};
24
25/// Version label for the normalized Program digest used by compiled-pipeline
26/// caches.
27///
28/// Aliased to the constant the digest algorithm itself uses as its domain
29/// separator, so the label cannot describe an algorithm the digest no longer
30/// implements. It was hand-mirrored before, which is exactly the silent drift
31/// this ledger exists to prevent.
32pub const NORMALIZED_PROGRAM_DIGEST_VERSION: &str =
33    vyre_foundation::ir::NORMALIZED_PROGRAM_CACHE_DIGEST_VERSION;
34
35/// Version label for commit/dirty-state source fingerprints.
36pub const SOURCE_FINGERPRINT_VERSION: &str = "vyre-source-fingerprint-v1";
37const MAX_SOURCE_FINGERPRINT_FILE_BYTES: u64 = 64 * 1024 * 1024;
38
39/// Version label for source-tree content fingerprints.
40pub const SOURCE_TREE_FINGERPRINT_VERSION: &str = "source-tree-v1";
41
42/// Version label for dispatch workload/config fingerprints.
43pub const WORKLOAD_FINGERPRINT_VERSION: &str = "vyre-dispatch-workload-v1";
44
45/// Version label for backend environment fingerprints.
46pub const ENVIRONMENT_FINGERPRINT_VERSION: &str = "vyre-evidence-environment-v1";
47
48/// Git and source-tree provenance for evidence-producing runs.
49#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50pub struct SourceProvenance {
51    /// Raw git facts captured from the source workspace.
52    pub git: BTreeMap<String, String>,
53    /// Commit/dirty-state source identity used by release evidence gates.
54    pub source_fingerprint: String,
55    /// Source-tree content identity used to tolerate evidence-only commit drift.
56    pub source_tree_fingerprint: String,
57}
58
59impl SourceProvenance {
60    /// Capture provenance for the current working directory.
61    #[must_use]
62    pub fn capture_current() -> Self {
63        Self::capture_at(Path::new("."))
64    }
65
66    /// Capture provenance for `workspace_root`.
67    #[must_use]
68    pub fn capture_at(workspace_root: &Path) -> Self {
69        let git = capture_git_info_at(workspace_root);
70        let source_fingerprint = source_fingerprint(&git);
71        let source_tree_fingerprint = source_tree_fingerprint_at(workspace_root);
72        Self {
73            git,
74            source_fingerprint,
75            source_tree_fingerprint,
76        }
77    }
78
79    /// Validate that required provenance fields are non-empty and shaped.
80    ///
81    /// # Errors
82    /// Returns [`BackendError::InvalidProgram`] when an evidence producer
83    /// attempts to emit a weak source identity.
84    pub fn validate(&self) -> Result<(), BackendError> {
85        if self.source_fingerprint.trim().is_empty() {
86            return Err(BackendError::InvalidProgram {
87                fix: "Fix: source_fingerprint must be non-empty before emitting driver evidence."
88                    .to_string(),
89            });
90        }
91        if self.source_tree_fingerprint.trim().is_empty() {
92            return Err(BackendError::InvalidProgram {
93                fix: "Fix: source_tree_fingerprint must be non-empty before emitting driver evidence."
94                    .to_string(),
95            });
96        }
97        Ok(())
98    }
99}
100
101/// Capture git facts for the current working directory.
102#[must_use]
103pub fn capture_git_info() -> BTreeMap<String, String> {
104    capture_git_info_at(Path::new("."))
105}
106
107/// Capture git facts for `workspace_root`.
108#[must_use]
109pub fn capture_git_info_at(workspace_root: &Path) -> BTreeMap<String, String> {
110    let mut info = BTreeMap::new();
111
112    if let Ok(commit) = shell(workspace_root, &["rev-parse", "HEAD"]) {
113        info.insert("commit".to_string(), commit);
114    }
115    if let Ok(branch) = shell(workspace_root, &["rev-parse", "--abbrev-ref", "HEAD"]) {
116        info.insert("branch".to_string(), branch);
117    }
118    let dirty_status = shell_bytes(
119        workspace_root,
120        &[
121            "status",
122            "--porcelain=v1",
123            "-z",
124            "--untracked-files=all",
125            "--",
126            ".",
127            ":!release/evidence/**",
128        ],
129    );
130    let dirty = match dirty_status.as_ref() {
131        Ok(status) if status.is_empty() => "false",
132        Ok(status) => {
133            if let Some(fingerprint) = dirty_worktree_fingerprint(workspace_root, status) {
134                info.insert("dirty_worktree_fingerprint".to_string(), fingerprint);
135            }
136            "true"
137        }
138        Err(_) => "unknown",
139    };
140    info.insert("dirty".to_string(), dirty.to_string());
141
142    if let Ok(parent) = shell(workspace_root, &["rev-parse", "HEAD^"]) {
143        info.insert("parent_commit".to_string(), parent);
144    }
145    if let Ok(timestamp) = shell(workspace_root, &["log", "-1", "--format=%ct"]) {
146        info.insert("commit_timestamp".to_string(), timestamp);
147    }
148
149    info
150}
151
152/// Build the commit/dirty-state source fingerprint used by release evidence.
153#[must_use]
154pub fn source_fingerprint(git: &BTreeMap<String, String>) -> String {
155    if let Some(commit) = git.get("commit").filter(|commit| !commit.is_empty()) {
156        let dirty = git.get("dirty").map(String::as_str).unwrap_or("unknown");
157        if dirty == "true" {
158            let worktree = git
159                .get("dirty_worktree_fingerprint")
160                .filter(|fingerprint| !fingerprint.is_empty())
161                .map(String::as_str)
162                .unwrap_or("unknown");
163            return format!("git:{commit}:dirty=true:worktree={worktree}");
164        }
165        return format!("git:{commit}:dirty={dirty}");
166    }
167    format!(
168        "crate:{}:{}",
169        env!("CARGO_PKG_NAME"),
170        env!("CARGO_PKG_VERSION")
171    )
172}
173
174/// Capture the runtime source-tree fingerprint for the current working directory.
175#[must_use]
176pub fn source_tree_fingerprint() -> String {
177    source_tree_fingerprint_at(Path::new("."))
178}
179
180/// Capture the runtime source-tree fingerprint for `workspace_root`.
181///
182/// Generated evidence, release tooling, tests, and operator-internal files are
183/// excluded because they do not change the benchmarked runtime.
184#[must_use]
185pub fn source_tree_fingerprint_at(workspace_root: &Path) -> String {
186    match shell_bytes(
187        workspace_root,
188        &[
189            "ls-files",
190            "-z",
191            "--cached",
192            "--others",
193            "--exclude-standard",
194        ],
195    ) {
196        Ok(paths) => format!(
197            "source-tree-v1:{}",
198            source_tree_fingerprint_from_paths(workspace_root, &paths)
199        ),
200        Err(_) => format!(
201            "crate-source:{}:{}",
202            env!("CARGO_PKG_NAME"),
203            env!("CARGO_PKG_VERSION")
204        ),
205    }
206}
207
208fn source_tree_fingerprint_from_paths(workspace_root: &Path, paths: &[u8]) -> String {
209    let mut hasher = blake3::Hasher::new();
210    update_hash_field(&mut hasher, b"format", b"vyre-bench-source-tree-v1");
211    for path in paths
212        .split(|byte| *byte == 0)
213        .filter(|path| !path.is_empty())
214        .filter(|path| !source_tree_path_is_benchmark_provenance_ignored(path))
215    {
216        update_hash_field(&mut hasher, b"path", path);
217        let path = String::from_utf8_lossy(path);
218        match read_source_fingerprint_file_bounded(&workspace_root.join(path.as_ref())) {
219            Ok(Some(bytes)) => update_hash_field(&mut hasher, b"content", &bytes),
220            Ok(None) => update_hash_field(
221                &mut hasher,
222                b"content-oversized",
223                MAX_SOURCE_FINGERPRINT_FILE_BYTES.to_string().as_bytes(),
224            ),
225            Err(error) => {
226                update_hash_field(&mut hasher, b"read-error", error.to_string().as_bytes())
227            }
228        }
229    }
230    hasher.finalize().to_hex().to_string()
231}
232
233fn source_tree_path_is_benchmark_provenance_ignored(path: &[u8]) -> bool {
234    path == b"cargo_full"
235        || path.starts_with(b".github/")
236        || path.starts_with(b"release/evidence/")
237        || path.starts_with(b"scripts/")
238        || path.starts_with(b"xtask/")
239        || source_tree_path_is_operator_internal(path)
240        || source_tree_path_is_test_evidence(path)
241}
242
243fn source_tree_path_is_operator_internal(path: &[u8]) -> bool {
244    const FILE_NAMES: &[&[u8]] = &[
245        b"AGENTS.md",
246        b"BACKLOG.md",
247        b"CLAUDE.md",
248        b"GEMINI.md",
249        b"SKILL.md",
250    ];
251
252    FILE_NAMES.iter().any(|file_name| {
253        path == *file_name
254            || path
255                .strip_suffix(*file_name)
256                .is_some_and(|prefix| prefix.ends_with(b"/"))
257    })
258}
259
260fn source_tree_path_is_test_evidence(path: &[u8]) -> bool {
261    path.starts_with(b"tests/")
262        || path_contains(path, b"/tests/")
263        || path.ends_with(b"/tests.rs")
264        || path.ends_with(b"_tests.rs")
265        || path.ends_with(b"_test.rs")
266        || path_contains(path, b"_tests_")
267        || path_contains(path, b"_test_")
268}
269
270fn path_contains(path: &[u8], needle: &[u8]) -> bool {
271    !needle.is_empty() && path.windows(needle.len()).any(|window| window == needle)
272}
273
274fn dirty_worktree_fingerprint(workspace_root: &Path, status: &[u8]) -> Option<String> {
275    let diff = shell_bytes(
276        workspace_root,
277        &[
278            "diff",
279            "--binary",
280            "HEAD",
281            "--",
282            ".",
283            ":!release/evidence/**",
284        ],
285    )
286    .ok()?;
287    let untracked = shell_bytes(
288        workspace_root,
289        &[
290            "ls-files",
291            "--others",
292            "--exclude-standard",
293            "-z",
294            "--",
295            ".",
296            ":!release/evidence/**",
297        ],
298    )
299    .unwrap_or_default();
300    Some(dirty_worktree_fingerprint_from_parts(
301        workspace_root,
302        status,
303        &diff,
304        &untracked,
305    ))
306}
307
308fn dirty_worktree_fingerprint_from_parts(
309    workspace_root: &Path,
310    status: &[u8],
311    diff: &[u8],
312    untracked: &[u8],
313) -> String {
314    let mut hasher = blake3::Hasher::new();
315    update_hash_field(&mut hasher, b"format", b"vyre-bench-dirty-source-v1");
316    update_hash_field(&mut hasher, b"status", status);
317    update_hash_field(&mut hasher, b"diff", diff);
318    for path in untracked
319        .split(|byte| *byte == 0)
320        .filter(|path| !path.is_empty())
321    {
322        update_hash_field(&mut hasher, b"untracked-path", path);
323        let path = String::from_utf8_lossy(path);
324        match read_source_fingerprint_file_bounded(&workspace_root.join(path.as_ref())) {
325            Ok(Some(bytes)) => update_hash_field(&mut hasher, b"untracked-content", &bytes),
326            Ok(None) => update_hash_field(
327                &mut hasher,
328                b"untracked-content-oversized",
329                MAX_SOURCE_FINGERPRINT_FILE_BYTES.to_string().as_bytes(),
330            ),
331            Err(_) => {}
332        }
333    }
334    hasher.finalize().to_hex().to_string()
335}
336
337fn read_source_fingerprint_file_bounded(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
338    let mut reader = fs::File::open(path)?;
339    let mut bytes = Vec::new();
340    let mut total = 0u64;
341    let mut chunk = [0u8; 8192];
342    loop {
343        let read = reader.read(&mut chunk)?;
344        if read == 0 {
345            return Ok(Some(bytes));
346        }
347        let read = read as u64;
348        total = total.saturating_add(read);
349        if total > MAX_SOURCE_FINGERPRINT_FILE_BYTES {
350            return Ok(None);
351        }
352        bytes.extend_from_slice(&chunk[..read as usize]);
353    }
354}
355
356fn digest_to_hex(digest: [u8; 32]) -> String {
357    hex_encode(&digest)
358}
359
360fn evidence_environment_digest(backend_id: &str, backend_version: &str) -> String {
361    let mut hasher = blake3::Hasher::new();
362    update_hash_field(
363        &mut hasher,
364        b"format",
365        ENVIRONMENT_FINGERPRINT_VERSION.as_bytes(),
366    );
367    update_hash_field(&mut hasher, b"backend-id", backend_id.as_bytes());
368    update_hash_field(&mut hasher, b"backend-version", backend_version.as_bytes());
369    hasher.finalize().to_hex().to_string()
370}
371
372fn shell(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
373    let stdout = shell_bytes(workspace_root, args)?;
374    Ok(String::from_utf8_lossy(&stdout).trim().to_string())
375}
376
377fn shell_bytes(workspace_root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
378    let output = Command::new("git")
379        .args(args)
380        .current_dir(workspace_root)
381        .output()
382        .map_err(|e| e.to_string())?;
383    if output.status.success() {
384        Ok(output.stdout)
385    } else {
386        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
387    }
388}
389
390/// Timing evidence normalized across host and device timing sources.
391#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
392pub struct DispatchTimingEvidence {
393    /// Host-observed dispatch duration.
394    pub wall_ns: Option<u64>,
395    /// Device-observed elapsed time when available.
396    pub device_ns: Option<u64>,
397    /// Host enqueue duration when available.
398    pub enqueue_ns: Option<u64>,
399    /// Host wait/readback duration when available.
400    pub wait_ns: Option<u64>,
401}
402
403impl DispatchTimingEvidence {
404    /// Build timing evidence from a timed dispatch result.
405    #[must_use]
406    pub fn from_timed_dispatch(result: &TimedDispatchResult) -> Self {
407        Self {
408            wall_ns: Some(result.wall_ns),
409            device_ns: result.device_ns,
410            enqueue_ns: result.enqueue_ns,
411            wait_ns: result.wait_ns,
412        }
413    }
414
415    /// Return true when the evidence has at least one timing source.
416    #[must_use]
417    pub fn has_timing(&self) -> bool {
418        self.wall_ns.is_some()
419            || self.device_ns.is_some()
420            || self.enqueue_ns.is_some()
421            || self.wait_ns.is_some()
422    }
423}
424
425/// One artifact referenced by an evidence bundle.
426#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
427pub struct EvidenceArtifact {
428    /// Stable artifact kind, such as `pipeline_manifest`, `benchmark_report`, or `replay_capsule`.
429    pub kind: String,
430    /// Backend that produced or owns the artifact when applicable.
431    pub backend_id: Option<String>,
432    /// Relative or consumer-provided artifact path.
433    pub path: Option<String>,
434    /// Content digest or identity digest when available.
435    pub digest: Option<String>,
436}
437
438impl EvidenceArtifact {
439    /// Build an artifact row.
440    #[must_use]
441    pub fn new(
442        kind: impl Into<String>,
443        backend_id: Option<String>,
444        path: Option<String>,
445        digest: Option<String>,
446    ) -> Self {
447        Self {
448            kind: kind.into(),
449            backend_id,
450            path,
451            digest,
452        }
453    }
454
455    /// Build an artifact row from a compiled-pipeline manifest.
456    #[must_use]
457    pub fn from_pipeline_manifest(manifest: &PipelineReproManifest) -> Self {
458        Self {
459            kind: "pipeline_manifest".to_string(),
460            backend_id: Some(manifest.backend_id.clone()),
461            path: None,
462            digest: Some(manifest.program_digest.clone()),
463        }
464    }
465}
466
467/// Replay metadata attached to a dispatch or conformance failure.
468#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
469pub struct ReplayEvidence {
470    /// Human-runnable replay command.
471    pub command: String,
472    /// Capsule digest when the replay payload has been materialized.
473    pub capsule_digest: Option<String>,
474}
475
476impl ReplayEvidence {
477    /// Build replay evidence.
478    #[must_use]
479    pub fn new(command: impl Into<String>, capsule_digest: Option<String>) -> Self {
480        Self {
481            command: command.into(),
482            capsule_digest,
483        }
484    }
485}
486
487/// Versioned digest ledger for every identity lane that participates in
488/// evidence replay, provenance, and cache correlation.
489#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
490pub struct EvidenceDigestLedger {
491    /// Ledger schema version.
492    pub schema: u32,
493    /// Version label for `program_wire_digest`.
494    pub program_wire_version: String,
495    /// BLAKE3 digest of canonical VIR0 Program wire bytes.
496    pub program_wire_digest: String,
497    /// Version label for `normalized_program_digest`.
498    pub normalized_program_version: String,
499    /// Normalized Program digest used by pipeline caches.
500    pub normalized_program_digest: String,
501    /// Version label for `workload_digest`.
502    pub workload_version: String,
503    /// Dispatch workload/config digest.
504    pub workload_digest: String,
505    /// Version label for `source_fingerprint`.
506    pub source_version: String,
507    /// Commit/dirty-state source fingerprint.
508    pub source_fingerprint: String,
509    /// Version label for `source_tree_fingerprint`.
510    pub source_tree_version: String,
511    /// Source-tree content fingerprint.
512    pub source_tree_fingerprint: String,
513    /// Version label for `environment_digest`.
514    pub environment_version: String,
515    /// Backend id/version digest.
516    pub environment_digest: String,
517}
518
519impl EvidenceDigestLedger {
520    /// Current digest-ledger schema.
521    pub const SCHEMA: u32 = 1;
522
523    /// Build the digest ledger from the same inputs used to build an evidence
524    /// bundle.
525    ///
526    /// # Errors
527    ///
528    /// Returns [`BackendError::InvalidProgram`] when the normalized Program
529    /// digest cannot be built.
530    pub fn for_inputs(
531        backend_id: &str,
532        backend_version: &str,
533        program: &Program,
534        config: &DispatchConfig,
535        source: &SourceProvenance,
536    ) -> Result<Self, BackendError> {
537        let normalized_program_digest =
538            try_normalized_program_cache_digest(program).map_err(|error| {
539                BackendError::InvalidProgram {
540                    fix: format!(
541                        "Fix: failed to build evidence Program digest: {error}. Validate and normalize the Program before dispatch evidence emission."
542                    ),
543                }
544            })?;
545        Ok(Self {
546            schema: Self::SCHEMA,
547            program_wire_version: PROGRAM_WIRE_DIGEST_VERSION.to_string(),
548            program_wire_digest: digest_to_hex(program.fingerprint()),
549            normalized_program_version: NORMALIZED_PROGRAM_DIGEST_VERSION.to_string(),
550            normalized_program_digest: digest_to_hex(normalized_program_digest),
551            workload_version: WORKLOAD_FINGERPRINT_VERSION.to_string(),
552            workload_digest: digest_to_hex(dispatch_policy_cache_digest(config)),
553            source_version: SOURCE_FINGERPRINT_VERSION.to_string(),
554            source_fingerprint: source.source_fingerprint.clone(),
555            source_tree_version: SOURCE_TREE_FINGERPRINT_VERSION.to_string(),
556            source_tree_fingerprint: source.source_tree_fingerprint.clone(),
557            environment_version: ENVIRONMENT_FINGERPRINT_VERSION.to_string(),
558            environment_digest: evidence_environment_digest(backend_id, backend_version),
559        })
560    }
561
562    /// Validate ledger version labels and digest shapes.
563    ///
564    /// # Errors
565    ///
566    /// Returns [`BackendError::InvalidProgram`] when any ledger lane is missing,
567    /// malformed, or versioned against the wrong contract.
568    pub fn validate(&self) -> Result<(), BackendError> {
569        if self.schema != Self::SCHEMA {
570            return Err(BackendError::InvalidProgram {
571                fix: format!(
572                    "Fix: evidence digest ledger schema {} is unsupported; regenerate evidence with schema {}.",
573                    self.schema,
574                    Self::SCHEMA
575                ),
576            });
577        }
578        validate_ledger_version(
579            "program_wire_version",
580            &self.program_wire_version,
581            PROGRAM_WIRE_DIGEST_VERSION,
582        )?;
583        validate_ledger_version(
584            "normalized_program_version",
585            &self.normalized_program_version,
586            NORMALIZED_PROGRAM_DIGEST_VERSION,
587        )?;
588        validate_ledger_version(
589            "workload_version",
590            &self.workload_version,
591            WORKLOAD_FINGERPRINT_VERSION,
592        )?;
593        validate_ledger_version(
594            "source_version",
595            &self.source_version,
596            SOURCE_FINGERPRINT_VERSION,
597        )?;
598        validate_ledger_version(
599            "source_tree_version",
600            &self.source_tree_version,
601            SOURCE_TREE_FINGERPRINT_VERSION,
602        )?;
603        validate_ledger_version(
604            "environment_version",
605            &self.environment_version,
606            ENVIRONMENT_FINGERPRINT_VERSION,
607        )?;
608        validate_hex_digest("program_wire_digest", &self.program_wire_digest)?;
609        validate_hex_digest("normalized_program_digest", &self.normalized_program_digest)?;
610        validate_hex_digest("workload_digest", &self.workload_digest)?;
611        validate_hex_digest("environment_digest", &self.environment_digest)?;
612        if self.source_fingerprint.trim().is_empty() {
613            return Err(BackendError::InvalidProgram {
614                fix: "Fix: evidence digest ledger source_fingerprint must be non-empty."
615                    .to_string(),
616            });
617        }
618        if self.source_tree_fingerprint.trim().is_empty() {
619            return Err(BackendError::InvalidProgram {
620                fix: "Fix: evidence digest ledger source_tree_fingerprint must be non-empty."
621                    .to_string(),
622            });
623        }
624        Ok(())
625    }
626}
627
628fn validate_ledger_version(label: &str, actual: &str, expected: &str) -> Result<(), BackendError> {
629    if actual != expected {
630        return Err(BackendError::InvalidProgram {
631            fix: format!(
632                "Fix: evidence digest ledger {label} must be `{expected}`, got `{actual}`."
633            ),
634        });
635    }
636    Ok(())
637}
638
639fn validate_hex_digest(label: &str, value: &str) -> Result<(), BackendError> {
640    if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
641        return Err(BackendError::InvalidProgram {
642            fix: format!("Fix: evidence digest ledger {label} must be a 64-character hex digest."),
643        });
644    }
645    Ok(())
646}
647
648/// Shared evidence bundle for dispatch, benchmark, conformance, and replay surfaces.
649#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
650pub struct EvidenceBundle {
651    /// Bundle schema version.
652    pub schema: u32,
653    /// Backend that produced the result or artifact.
654    pub backend_id: String,
655    /// Backend implementation version.
656    pub backend_version: String,
657    /// Canonical normalized Program digest as lowercase hex.
658    pub program_digest: String,
659    /// Dispatch policy fields that affect generated backend code.
660    pub dispatch_policy: String,
661    /// Versioned digest ledger binding Program, workload, source, and backend
662    /// environment identity.
663    pub digest_ledger: EvidenceDigestLedger,
664    /// Source provenance for the code that produced this evidence.
665    pub source: SourceProvenance,
666    /// Timing evidence for the dispatch or run.
667    pub timing: DispatchTimingEvidence,
668    /// Artifacts referenced by this bundle.
669    pub artifacts: Vec<EvidenceArtifact>,
670    /// Replay metadata when a replay capsule exists.
671    pub replay: Option<ReplayEvidence>,
672}
673
674impl EvidenceBundle {
675    /// Current evidence bundle schema.
676    pub const SCHEMA: u32 = 1;
677
678    /// Build an evidence bundle for a backend/program/config tuple.
679    ///
680    /// # Errors
681    /// Returns [`BackendError`] when the Program cannot be fingerprinted or
682    /// provenance is too weak to emit.
683    pub fn for_program(
684        backend: &dyn VyreBackend,
685        program: &Program,
686        config: &DispatchConfig,
687        source: SourceProvenance,
688    ) -> Result<Self, BackendError> {
689        source.validate()?;
690        let backend_id = backend.id();
691        let backend_version = backend.version();
692        let digest_ledger = EvidenceDigestLedger::for_inputs(
693            backend_id,
694            backend_version,
695            program,
696            config,
697            &source,
698        )?;
699        Ok(Self {
700            schema: Self::SCHEMA,
701            backend_id: backend_id.to_string(),
702            backend_version: backend_version.to_string(),
703            program_digest: digest_ledger.normalized_program_digest.clone(),
704            dispatch_policy: dispatch_policy_cache_string(config),
705            digest_ledger,
706            source,
707            timing: DispatchTimingEvidence::default(),
708            artifacts: Vec::new(),
709            replay: None,
710        })
711    }
712
713    /// Attach timing from a backend dispatch result.
714    #[must_use]
715    pub fn with_timed_dispatch(mut self, result: &TimedDispatchResult) -> Self {
716        self.timing = DispatchTimingEvidence::from_timed_dispatch(result);
717        self
718    }
719
720    /// Attach an artifact row.
721    #[must_use]
722    pub fn with_artifact(mut self, artifact: EvidenceArtifact) -> Self {
723        self.artifacts.push(artifact);
724        self
725    }
726
727    /// Attach replay metadata.
728    #[must_use]
729    pub fn with_replay(mut self, replay: ReplayEvidence) -> Self {
730        self.replay = Some(replay);
731        self
732    }
733
734    /// Validate the bundle's load-bearing fields.
735    ///
736    /// # Errors
737    /// Returns [`BackendError::InvalidProgram`] when a bundle is missing a
738    /// required identity field or carries malformed digest metadata.
739    pub fn validate(&self) -> Result<(), BackendError> {
740        if self.schema != Self::SCHEMA {
741            return Err(BackendError::InvalidProgram {
742                fix: format!(
743                    "Fix: evidence bundle schema {} is unsupported; regenerate evidence with schema {}.",
744                    self.schema,
745                    Self::SCHEMA
746                ),
747            });
748        }
749        if self.backend_id.trim().is_empty() {
750            return Err(BackendError::InvalidProgram {
751                fix: "Fix: evidence bundle backend_id must be non-empty.".to_string(),
752            });
753        }
754        if self.program_digest.len() != 64
755            || !self
756                .program_digest
757                .bytes()
758                .all(|byte| byte.is_ascii_hexdigit())
759        {
760            return Err(BackendError::InvalidProgram {
761                fix: "Fix: evidence bundle program_digest must be a 64-character hex digest."
762                    .to_string(),
763            });
764        }
765        self.digest_ledger.validate()?;
766        if self.digest_ledger.normalized_program_digest != self.program_digest {
767            return Err(BackendError::InvalidProgram {
768                fix: "Fix: evidence bundle program_digest must match digest_ledger.normalized_program_digest.".to_string(),
769            });
770        }
771        if self.digest_ledger.source_fingerprint != self.source.source_fingerprint {
772            return Err(BackendError::InvalidProgram {
773                fix: "Fix: evidence bundle source_fingerprint must match digest_ledger.source_fingerprint.".to_string(),
774            });
775        }
776        if self.digest_ledger.source_tree_fingerprint != self.source.source_tree_fingerprint {
777            return Err(BackendError::InvalidProgram {
778                fix: "Fix: evidence bundle source_tree_fingerprint must match digest_ledger.source_tree_fingerprint.".to_string(),
779            });
780        }
781        self.source.validate()
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use std::sync::Arc;
788
789    use super::*;
790    use crate::backend::{private, CompiledPipeline, OutputBuffers};
791    use vyre_foundation::ir::{BufferDecl, DataType, Expr, Node};
792
793    #[derive(Clone)]
794    struct EvidenceTestBackend;
795
796    impl private::Sealed for EvidenceTestBackend {}
797
798    impl VyreBackend for EvidenceTestBackend {
799        fn id(&self) -> &'static str {
800            "evidence-test"
801        }
802
803        fn version(&self) -> &'static str {
804            "test-version"
805        }
806
807        fn dispatch(
808            &self,
809            _program: &Program,
810            _inputs: &[Vec<u8>],
811            _config: &DispatchConfig,
812        ) -> Result<Vec<Vec<u8>>, BackendError> {
813            Ok(vec![42_u32.to_le_bytes().to_vec()])
814        }
815    }
816
817    #[derive(Clone)]
818    struct VersionedEvidenceTestBackend {
819        id: &'static str,
820        version: &'static str,
821    }
822
823    impl private::Sealed for VersionedEvidenceTestBackend {}
824
825    impl VyreBackend for VersionedEvidenceTestBackend {
826        fn id(&self) -> &'static str {
827            self.id
828        }
829
830        fn version(&self) -> &'static str {
831            self.version
832        }
833
834        fn dispatch(
835            &self,
836            _program: &Program,
837            _inputs: &[Vec<u8>],
838            _config: &DispatchConfig,
839        ) -> Result<Vec<Vec<u8>>, BackendError> {
840            Ok(vec![42_u32.to_le_bytes().to_vec()])
841        }
842    }
843
844    struct EvidencePipeline;
845
846    impl private::Sealed for EvidencePipeline {}
847
848    impl CompiledPipeline for EvidencePipeline {
849        fn id(&self) -> &str {
850            "evidence-test:pipeline"
851        }
852
853        fn dispatch(
854            &self,
855            _inputs: &[Vec<u8>],
856            _config: &DispatchConfig,
857        ) -> Result<OutputBuffers, BackendError> {
858            Ok(vec![42_u32.to_le_bytes().to_vec()])
859        }
860    }
861
862    fn evidence_program() -> Program {
863        Program::wrapped(
864            vec![
865                BufferDecl::read("input", 0, DataType::U32).with_count(1),
866                BufferDecl::output("output", 1, DataType::U32).with_count(1),
867            ],
868            [1, 1, 1],
869            vec![Node::store(
870                "output",
871                Expr::u32(0),
872                Expr::load("input", Expr::u32(0)),
873            )],
874        )
875    }
876
877    fn source() -> SourceProvenance {
878        SourceProvenance {
879            git: BTreeMap::from([
880                ("commit".to_string(), "abc123".to_string()),
881                ("dirty".to_string(), "false".to_string()),
882            ]),
883            source_fingerprint: "git:abc123:dirty=false".to_string(),
884            source_tree_fingerprint: "source-tree-v1:test".to_string(),
885        }
886    }
887
888    fn changed_ledger_lanes(
889        left: &EvidenceDigestLedger,
890        right: &EvidenceDigestLedger,
891    ) -> Vec<&'static str> {
892        let mut changed = Vec::new();
893        if left.program_wire_digest != right.program_wire_digest {
894            changed.push("program_wire_digest");
895        }
896        if left.normalized_program_digest != right.normalized_program_digest {
897            changed.push("normalized_program_digest");
898        }
899        if left.workload_digest != right.workload_digest {
900            changed.push("workload_digest");
901        }
902        if left.source_fingerprint != right.source_fingerprint {
903            changed.push("source_fingerprint");
904        }
905        if left.source_tree_fingerprint != right.source_tree_fingerprint {
906            changed.push("source_tree_fingerprint");
907        }
908        if left.environment_digest != right.environment_digest {
909            changed.push("environment_digest");
910        }
911        changed
912    }
913
914    #[test]
915    fn evidence_bundle_records_backend_program_policy_source_timing_and_artifacts() {
916        let backend = EvidenceTestBackend;
917        let program = evidence_program();
918        let mut config = DispatchConfig::default();
919        config.workgroup_override = Some([8, 1, 1]);
920        let timed = TimedDispatchResult {
921            outputs: vec![42_u32.to_le_bytes().to_vec()],
922            wall_ns: 100,
923            device_ns: Some(70),
924            enqueue_ns: Some(10),
925            wait_ns: Some(20),
926        };
927        let pipeline = Arc::new(EvidencePipeline);
928        let manifest = PipelineReproManifest::new(
929            backend.id(),
930            pipeline.id(),
931            try_normalized_program_cache_digest(&program)
932                .expect("Fix: evidence test Program must fingerprint"),
933            dispatch_policy_cache_string(&config),
934            Some(true),
935        );
936
937        let bundle = EvidenceBundle::for_program(&backend, &program, &config, source())
938            .expect("Fix: evidence bundle should build for valid source/program")
939            .with_timed_dispatch(&timed)
940            .with_artifact(EvidenceArtifact::from_pipeline_manifest(&manifest))
941            .with_replay(ReplayEvidence::new(
942                "vyre-conform dispatch --backend evidence-test --ops evidence.test",
943                Some("capsule-digest".to_string()),
944            ));
945
946        bundle
947            .validate()
948            .expect("Fix: complete evidence bundle should validate");
949        assert_eq!(bundle.backend_id, "evidence-test");
950        assert_eq!(bundle.backend_version, "test-version");
951        assert_eq!(bundle.program_digest.len(), 64);
952        assert_eq!(
953            bundle.program_digest,
954            bundle.digest_ledger.normalized_program_digest
955        );
956        assert_eq!(
957            bundle.digest_ledger.program_wire_version,
958            PROGRAM_WIRE_DIGEST_VERSION
959        );
960        assert_eq!(
961            bundle.digest_ledger.normalized_program_version,
962            NORMALIZED_PROGRAM_DIGEST_VERSION
963        );
964        assert_eq!(
965            bundle.digest_ledger.workload_version,
966            WORKLOAD_FINGERPRINT_VERSION
967        );
968        assert_eq!(bundle.dispatch_policy, "ulp=None:wg=Some([8, 1, 1])");
969        assert_eq!(bundle.source.source_fingerprint, "git:abc123:dirty=false");
970        assert_eq!(bundle.timing.device_ns, Some(70));
971        assert_eq!(bundle.artifacts[0].kind, "pipeline_manifest");
972        assert_eq!(
973            bundle.replay.as_ref().map(|replay| replay.command.as_str()),
974            Some("vyre-conform dispatch --backend evidence-test --ops evidence.test")
975        );
976    }
977
978    #[test]
979    fn digest_ledger_scopes_program_source_workload_and_environment_changes() {
980        let backend = VersionedEvidenceTestBackend {
981            id: "evidence-test",
982            version: "test-version",
983        };
984        let program = evidence_program();
985        let config = DispatchConfig::default();
986        let source = source();
987        let base = EvidenceBundle::for_program(&backend, &program, &config, source.clone())
988            .expect("Fix: base evidence bundle must build")
989            .digest_ledger;
990
991        let changed_program = Program::wrapped(
992            vec![
993                BufferDecl::read("input", 0, DataType::U32).with_count(1),
994                BufferDecl::output("output", 1, DataType::U32).with_count(1),
995            ],
996            [1, 1, 1],
997            vec![Node::store("output", Expr::u32(0), Expr::u32(7))],
998        );
999        let program_changed =
1000            EvidenceBundle::for_program(&backend, &changed_program, &config, source.clone())
1001                .expect("Fix: changed Program evidence bundle must build")
1002                .digest_ledger;
1003        assert_eq!(
1004            changed_ledger_lanes(&base, &program_changed),
1005            vec!["program_wire_digest", "normalized_program_digest"],
1006            "Fix: Program body mutations must not perturb source, workload, or environment digest lanes."
1007        );
1008
1009        let source_changed = SourceProvenance {
1010            source_fingerprint: "git:def456:dirty=false".to_string(),
1011            ..source.clone()
1012        };
1013        let source_ledger =
1014            EvidenceBundle::for_program(&backend, &program, &config, source_changed)
1015                .expect("Fix: changed source evidence bundle must build")
1016                .digest_ledger;
1017        assert_eq!(
1018            changed_ledger_lanes(&base, &source_ledger),
1019            vec!["source_fingerprint"],
1020            "Fix: source fingerprint mutations must stay in the source lane."
1021        );
1022
1023        let source_tree_changed = SourceProvenance {
1024            source_tree_fingerprint: "source-tree-v1:changed".to_string(),
1025            ..source.clone()
1026        };
1027        let source_tree_ledger =
1028            EvidenceBundle::for_program(&backend, &program, &config, source_tree_changed)
1029                .expect("Fix: changed source-tree evidence bundle must build")
1030                .digest_ledger;
1031        assert_eq!(
1032            changed_ledger_lanes(&base, &source_tree_ledger),
1033            vec!["source_tree_fingerprint"],
1034            "Fix: source-tree mutations must stay in the source-tree lane."
1035        );
1036
1037        let mut workload_changed = DispatchConfig::default();
1038        workload_changed.workgroup_override = Some([8, 1, 1]);
1039        let workload_ledger =
1040            EvidenceBundle::for_program(&backend, &program, &workload_changed, source.clone())
1041                .expect("Fix: changed workload evidence bundle must build")
1042                .digest_ledger;
1043        assert_eq!(
1044            changed_ledger_lanes(&base, &workload_ledger),
1045            vec!["workload_digest"],
1046            "Fix: workload/config mutations must stay in the workload digest lane."
1047        );
1048
1049        let environment_changed = VersionedEvidenceTestBackend {
1050            id: "evidence-test",
1051            version: "test-version-2",
1052        };
1053        let environment_ledger =
1054            EvidenceBundle::for_program(&environment_changed, &program, &config, source)
1055                .expect("Fix: changed environment evidence bundle must build")
1056                .digest_ledger;
1057        assert_eq!(
1058            changed_ledger_lanes(&base, &environment_ledger),
1059            vec!["environment_digest"],
1060            "Fix: backend environment mutations must stay in the environment digest lane."
1061        );
1062    }
1063
1064    #[test]
1065    fn evidence_bundle_rejects_digest_ledger_mismatch() {
1066        let backend = EvidenceTestBackend;
1067        let program = evidence_program();
1068        let mut bundle =
1069            EvidenceBundle::for_program(&backend, &program, &DispatchConfig::default(), source())
1070                .expect("Fix: evidence bundle should build before ledger mutation");
1071        bundle.digest_ledger.normalized_program_digest =
1072            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
1073
1074        let error = bundle
1075            .validate()
1076            .expect_err("Fix: evidence validation must reject a mismatched digest ledger");
1077        assert!(
1078            error.to_string().contains("digest_ledger"),
1079            "Fix: digest ledger mismatch rejection must name the mismatched field: {error}"
1080        );
1081    }
1082
1083    #[test]
1084    fn evidence_bundle_rejects_weak_source_provenance() {
1085        let backend = EvidenceTestBackend;
1086        let program = evidence_program();
1087        let invalid = SourceProvenance {
1088            git: BTreeMap::new(),
1089            source_fingerprint: " ".to_string(),
1090            source_tree_fingerprint: "source-tree-v1:test".to_string(),
1091        };
1092
1093        let error =
1094            EvidenceBundle::for_program(&backend, &program, &DispatchConfig::default(), invalid)
1095                .expect_err("Fix: evidence bundle must reject blank source_fingerprint");
1096
1097        assert!(
1098            error.to_string().contains("source_fingerprint"),
1099            "Fix: source provenance rejection must name the weak field: {error}"
1100        );
1101    }
1102
1103    #[test]
1104    fn clean_source_fingerprint_keeps_commit_dirty_contract() {
1105        let git = BTreeMap::from([
1106            ("commit".to_string(), "abc123".to_string()),
1107            ("dirty".to_string(), "false".to_string()),
1108        ]);
1109
1110        assert_eq!(
1111            source_fingerprint(&git),
1112            "git:abc123:dirty=false",
1113            "Fix: clean source fingerprints must remain stable for existing release evidence contracts."
1114        );
1115    }
1116
1117    #[test]
1118    fn dirty_source_fingerprint_carries_worktree_digest() {
1119        let git = BTreeMap::from([
1120            ("commit".to_string(), "abc123".to_string()),
1121            ("dirty".to_string(), "true".to_string()),
1122            (
1123                "dirty_worktree_fingerprint".to_string(),
1124                "worktree-hash".to_string(),
1125            ),
1126        ]);
1127
1128        assert_eq!(
1129            source_fingerprint(&git),
1130            "git:abc123:dirty=true:worktree=worktree-hash",
1131            "Fix: dirty source fingerprints must distinguish different dirty worktree states."
1132        );
1133    }
1134
1135    #[test]
1136    fn dirty_source_fingerprint_without_digest_fails_closed() {
1137        let git = BTreeMap::from([
1138            ("commit".to_string(), "abc123".to_string()),
1139            ("dirty".to_string(), "true".to_string()),
1140        ]);
1141
1142        assert_eq!(
1143            source_fingerprint(&git),
1144            "git:abc123:dirty=true:worktree=unknown",
1145            "Fix: dirty source fingerprints must not fall back to the broad legacy dirty=true contract."
1146        );
1147    }
1148
1149    #[test]
1150    fn dirty_worktree_digest_changes_with_status_diff_and_untracked_content() {
1151        let workspace = Path::new(".");
1152        let base =
1153            dirty_worktree_fingerprint_from_parts(workspace, b" M a.rs\0", b"-old\n+new\n", b"");
1154        let changed_status =
1155            dirty_worktree_fingerprint_from_parts(workspace, b" M b.rs\0", b"-old\n+new\n", b"");
1156        let changed_diff =
1157            dirty_worktree_fingerprint_from_parts(workspace, b" M a.rs\0", b"-old\n+newer\n", b"");
1158        let changed_untracked_inventory =
1159            dirty_worktree_fingerprint_from_parts(workspace, b"?? c.rs\0", b"", b"c.rs\0");
1160        let untracked_workspace = temp_workspace("vyre-driver-dirty-fingerprint");
1161        fs::write(untracked_workspace.join("c.rs"), b"one")
1162            .expect("Fix: write first untracked content fingerprint fixture.");
1163        let untracked_one = dirty_worktree_fingerprint_from_parts(
1164            &untracked_workspace,
1165            b"?? c.rs\0",
1166            b"",
1167            b"c.rs\0",
1168        );
1169        fs::write(untracked_workspace.join("c.rs"), b"two")
1170            .expect("Fix: write second untracked content fingerprint fixture.");
1171        let untracked_two = dirty_worktree_fingerprint_from_parts(
1172            &untracked_workspace,
1173            b"?? c.rs\0",
1174            b"",
1175            b"c.rs\0",
1176        );
1177        let _ = fs::remove_dir_all(&untracked_workspace);
1178
1179        assert_ne!(
1180            base, changed_status,
1181            "Fix: dirty source fingerprints must change when modified paths change."
1182        );
1183        assert_ne!(
1184            base, changed_diff,
1185            "Fix: dirty source fingerprints must change when tracked diff bytes change."
1186        );
1187        assert_ne!(
1188            base, changed_untracked_inventory,
1189            "Fix: dirty source fingerprints must change when untracked inventory changes."
1190        );
1191        assert_ne!(
1192            untracked_one, untracked_two,
1193            "Fix: dirty source fingerprints must change when untracked file content changes."
1194        );
1195    }
1196
1197    #[test]
1198    fn source_tree_fingerprint_ignores_generated_release_evidence() {
1199        let workspace = temp_workspace("vyre-driver-source-tree-fingerprint");
1200        fs::create_dir_all(workspace.join("src")).expect("Fix: create source fixture directory.");
1201        fs::create_dir_all(workspace.join("release/evidence/benchmarks"))
1202            .expect("Fix: create generated evidence fixture directory.");
1203        fs::write(workspace.join("src/lib.rs"), b"pub fn source() {}\n")
1204            .expect("Fix: write source-tree fingerprint source fixture.");
1205        fs::write(
1206            workspace.join("release/evidence/benchmarks/workload.json"),
1207            b"{\"old\":true}\n",
1208        )
1209        .expect("Fix: write source-tree fingerprint evidence fixture.");
1210        let paths = b"src/lib.rs\0release/evidence/benchmarks/workload.json\0";
1211
1212        let base = source_tree_fingerprint_from_paths(&workspace, paths);
1213        fs::write(
1214            workspace.join("release/evidence/benchmarks/workload.json"),
1215            b"{\"new\":true}\n",
1216        )
1217        .expect("Fix: mutate generated evidence fixture.");
1218        let evidence_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1219        fs::write(
1220            workspace.join("src/lib.rs"),
1221            b"pub fn source_changed() {}\n",
1222        )
1223        .expect("Fix: mutate source fixture.");
1224        let source_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1225        let _ = fs::remove_dir_all(&workspace);
1226
1227        assert_eq!(
1228            base, evidence_changed,
1229            "Fix: generated release evidence must not invalidate committed benchmark source provenance."
1230        );
1231        assert_ne!(
1232            base, source_changed,
1233            "Fix: source-tree provenance must still change when real source files change."
1234        );
1235    }
1236
1237    #[test]
1238    fn source_tree_fingerprint_ignores_release_tooling_source() {
1239        let workspace = temp_workspace("vyre-driver-source-tree-tooling");
1240        fs::create_dir_all(workspace.join("vyre-bench/src"))
1241            .expect("Fix: create benchmark source fixture directory.");
1242        fs::create_dir_all(workspace.join(".github/workflows"))
1243            .expect("Fix: create workflow fixture directory.");
1244        fs::create_dir_all(workspace.join("scripts"))
1245            .expect("Fix: create release script fixture directory.");
1246        fs::create_dir_all(workspace.join("xtask/src"))
1247            .expect("Fix: create release tooling fixture directory.");
1248        fs::write(workspace.join("cargo_full"), b"#!/usr/bin/env bash\n")
1249            .expect("Fix: write cargo wrapper fixture.");
1250        fs::write(
1251            workspace.join("vyre-bench/src/lib.rs"),
1252            b"pub fn benchmark() {}\n",
1253        )
1254        .expect("Fix: write benchmark source fixture.");
1255        fs::write(
1256            workspace.join("xtask/src/hygiene_matrix.rs"),
1257            b"pub fn tooling() {}\n",
1258        )
1259        .expect("Fix: write release tooling fixture.");
1260        fs::write(
1261            workspace.join("scripts/install_lego_quick_hook.sh"),
1262            b"#!/usr/bin/env bash\n",
1263        )
1264        .expect("Fix: write release script fixture.");
1265        fs::write(
1266            workspace.join(".github/workflows/ci.yml"),
1267            b"run: ./cargo_full test --workspace\n",
1268        )
1269        .expect("Fix: write workflow fixture.");
1270        let paths = b".github/workflows/ci.yml\0cargo_full\0scripts/install_lego_quick_hook.sh\0vyre-bench/src/lib.rs\0xtask/src/hygiene_matrix.rs\0";
1271
1272        let base = source_tree_fingerprint_from_paths(&workspace, paths);
1273        fs::write(
1274            workspace.join("cargo_full"),
1275            b"#!/usr/bin/env bash\nexec cargo \"$@\"\n",
1276        )
1277        .expect("Fix: mutate cargo wrapper fixture.");
1278        let wrapper_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1279        fs::write(
1280            workspace.join("scripts/install_lego_quick_hook.sh"),
1281            b"#!/usr/bin/env bash\n./cargo_full run --bin xtask -- lego-quick\n",
1282        )
1283        .expect("Fix: mutate release script fixture.");
1284        let script_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1285        fs::write(
1286            workspace.join(".github/workflows/ci.yml"),
1287            b"run: ./cargo_full test --workspace --all-targets\n",
1288        )
1289        .expect("Fix: mutate workflow fixture.");
1290        let workflow_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1291        fs::write(
1292            workspace.join("xtask/src/hygiene_matrix.rs"),
1293            b"pub fn tooling_changed() {}\n",
1294        )
1295        .expect("Fix: mutate release tooling fixture.");
1296        let tooling_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1297        fs::write(
1298            workspace.join("vyre-bench/src/lib.rs"),
1299            b"pub fn benchmark_changed() {}\n",
1300        )
1301        .expect("Fix: mutate benchmark source fixture.");
1302        let benchmark_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1303        let _ = fs::remove_dir_all(&workspace);
1304
1305        assert_eq!(
1306            base, tooling_changed,
1307            "Fix: release evidence/tooling generators must not invalidate benchmark runtime source provenance."
1308        );
1309        assert_eq!(
1310            base, wrapper_changed,
1311            "Fix: bounded cargo wrapper changes must not invalidate benchmark runtime source provenance."
1312        );
1313        assert_eq!(
1314            base, script_changed,
1315            "Fix: release scripts must not invalidate benchmark runtime source provenance."
1316        );
1317        assert_eq!(
1318            base, workflow_changed,
1319            "Fix: CI workflow edits must not invalidate benchmark runtime source provenance."
1320        );
1321        assert_ne!(
1322            base, benchmark_changed,
1323            "Fix: benchmark source edits must still invalidate benchmark source provenance."
1324        );
1325    }
1326
1327    #[test]
1328    fn source_tree_fingerprint_ignores_test_evidence() {
1329        let workspace = temp_workspace("vyre-driver-source-tree-tests");
1330        fs::create_dir_all(workspace.join("vyre-libs/src"))
1331            .expect("Fix: create library source fixture directory.");
1332        fs::create_dir_all(workspace.join("vyre-libs/tests/support"))
1333            .expect("Fix: create integration test support fixture directory.");
1334        fs::create_dir_all(workspace.join("vyre-libs/src/graph"))
1335            .expect("Fix: create inline test fixture directory.");
1336        fs::write(
1337            workspace.join("vyre-libs/src/lib.rs"),
1338            b"pub fn source() {}\n",
1339        )
1340        .expect("Fix: write source-tree fingerprint source fixture.");
1341        fs::write(
1342            workspace.join("vyre-libs/tests/filter_roundtrip.rs"),
1343            b"#[test]\nfn roundtrip() {}\n",
1344        )
1345        .expect("Fix: write integration test fixture.");
1346        fs::write(
1347            workspace.join("vyre-libs/tests/support/filter.rs"),
1348            b"pub fn helper() {}\n",
1349        )
1350        .expect("Fix: write test support fixture.");
1351        fs::write(
1352            workspace.join("vyre-libs/src/graph/tests.rs"),
1353            b"#[test]\nfn graph_contract() {}\n",
1354        )
1355        .expect("Fix: write inline tests fixture.");
1356        let paths = b"vyre-libs/src/lib.rs\0vyre-libs/tests/filter_roundtrip.rs\0vyre-libs/tests/support/filter.rs\0vyre-libs/src/graph/tests.rs\0";
1357
1358        let base = source_tree_fingerprint_from_paths(&workspace, paths);
1359        fs::write(
1360            workspace.join("vyre-libs/tests/filter_roundtrip.rs"),
1361            b"#[test]\nfn roundtrip_modularized() {}\n",
1362        )
1363        .expect("Fix: mutate integration test fixture.");
1364        fs::write(
1365            workspace.join("vyre-libs/tests/support/filter.rs"),
1366            b"pub fn helper_modularized() {}\n",
1367        )
1368        .expect("Fix: mutate test support fixture.");
1369        fs::write(
1370            workspace.join("vyre-libs/src/graph/tests.rs"),
1371            b"#[test]\nfn graph_contract_modularized() {}\n",
1372        )
1373        .expect("Fix: mutate inline tests fixture.");
1374        let tests_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1375        fs::write(
1376            workspace.join("vyre-libs/src/lib.rs"),
1377            b"pub fn source_changed() {}\n",
1378        )
1379        .expect("Fix: mutate production source fixture.");
1380        let source_changed = source_tree_fingerprint_from_paths(&workspace, paths);
1381        let _ = fs::remove_dir_all(&workspace);
1382
1383        assert_eq!(
1384            base, tests_changed,
1385            "Fix: test-only modularization must not invalidate runtime benchmark source provenance."
1386        );
1387        assert_ne!(
1388            base, source_changed,
1389            "Fix: source-tree provenance must still change when production source changes."
1390        );
1391    }
1392
1393    #[test]
1394    fn source_fingerprint_ignores_generated_release_evidence_dirty_status() {
1395        let workspace = temp_workspace("vyre-driver-source-fingerprint-evidence");
1396        fs::create_dir_all(workspace.join("src"))
1397            .expect("Fix: create source fingerprint fixture source directory.");
1398        fs::create_dir_all(workspace.join("release/evidence/benchmarks"))
1399            .expect("Fix: create source fingerprint fixture evidence directory.");
1400        fs::write(workspace.join("src/lib.rs"), b"pub fn source() {}\n")
1401            .expect("Fix: write source fingerprint source fixture.");
1402        fs::write(
1403            workspace.join("release/evidence/benchmarks/workload.json"),
1404            b"{\"old\":true}\n",
1405        )
1406        .expect("Fix: write tracked generated evidence fixture.");
1407        git_fixture(&workspace, &["init", "--quiet", "--initial-branch", "main"]);
1408        git_fixture(
1409            &workspace,
1410            &["config", "user.email", "vyre@example.invalid"],
1411        );
1412        git_fixture(&workspace, &["config", "user.name", "Vyre Test"]);
1413        git_fixture(
1414            &workspace,
1415            &[
1416                "add",
1417                "src/lib.rs",
1418                "release/evidence/benchmarks/workload.json",
1419            ],
1420        );
1421        git_fixture(&workspace, &["commit", "--quiet", "-m", "seed"]);
1422
1423        fs::write(
1424            workspace.join("release/evidence/benchmarks/workload.json"),
1425            b"{\"new\":true}\n",
1426        )
1427        .expect("Fix: mutate tracked generated evidence fixture.");
1428        fs::write(
1429            workspace.join("release/evidence/benchmarks/new-workload.json"),
1430            b"{\"new\":true}\n",
1431        )
1432        .expect("Fix: write untracked generated evidence fixture.");
1433        let evidence_only = capture_git_info_at(&workspace);
1434        fs::write(
1435            workspace.join("src/lib.rs"),
1436            b"pub fn source_changed() {}\n",
1437        )
1438        .expect("Fix: mutate real source fixture.");
1439        let source_changed = capture_git_info_at(&workspace);
1440        let _ = fs::remove_dir_all(&workspace);
1441
1442        assert_eq!(
1443            evidence_only.get("dirty").map(String::as_str),
1444            Some("false"),
1445            "Fix: generated release evidence writes must not mark benchmark source provenance dirty."
1446        );
1447        assert_eq!(
1448            source_changed.get("dirty").map(String::as_str),
1449            Some("true"),
1450            "Fix: real source edits must still mark benchmark source provenance dirty."
1451        );
1452    }
1453
1454    fn temp_workspace(prefix: &str) -> std::path::PathBuf {
1455        let workspace = std::env::temp_dir().join(format!(
1456            "{prefix}-{}-{}",
1457            std::process::id(),
1458            std::time::SystemTime::now()
1459                .duration_since(std::time::UNIX_EPOCH)
1460                .expect("Fix: system clock must support unix epoch duration for temp test id.")
1461                .as_nanos()
1462        ));
1463        fs::create_dir_all(&workspace).expect("Fix: create temporary provenance test workspace.");
1464        workspace
1465    }
1466
1467    fn git_fixture(workspace: &Path, args: &[&str]) {
1468        let output = Command::new("git")
1469            .args(args)
1470            .current_dir(workspace)
1471            .output()
1472            .expect("Fix: git fixture command must start.");
1473        assert!(
1474            output.status.success(),
1475            "Fix: git fixture command `git {}` failed: {}",
1476            args.join(" "),
1477            String::from_utf8_lossy(&output.stderr).trim()
1478        );
1479    }
1480}