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::path::Path;
11use std::process::Command;
12
13use serde::{Deserialize, Serialize};
14use vyre_foundation::ir::Program;
15
16use crate::backend::{BackendError, DispatchConfig, TimedDispatchResult, VyreBackend};
17use crate::pipeline::{
18    dispatch_policy_cache_string, hex_encode, try_normalized_program_cache_digest,
19    PipelineReproManifest,
20};
21
22/// Git and source-tree provenance for evidence-producing runs.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct SourceProvenance {
25    /// Raw git facts captured from the source workspace.
26    pub git: BTreeMap<String, String>,
27    /// Commit/dirty-state source identity used by release evidence gates.
28    pub source_fingerprint: String,
29    /// Source-tree content identity used to tolerate evidence-only commit drift.
30    pub source_tree_fingerprint: String,
31}
32
33impl SourceProvenance {
34    /// Capture provenance for the current working directory.
35    #[must_use]
36    pub fn capture_current() -> Self {
37        Self::capture_at(Path::new("."))
38    }
39
40    /// Capture provenance for `workspace_root`.
41    #[must_use]
42    pub fn capture_at(workspace_root: &Path) -> Self {
43        let git = capture_git_info_at(workspace_root);
44        let source_fingerprint = source_fingerprint(&git);
45        let source_tree_fingerprint = source_tree_fingerprint_at(workspace_root);
46        Self {
47            git,
48            source_fingerprint,
49            source_tree_fingerprint,
50        }
51    }
52
53    /// Validate that required provenance fields are non-empty and shaped.
54    ///
55    /// # Errors
56    /// Returns [`BackendError::InvalidProgram`] when an evidence producer
57    /// attempts to emit a weak source identity.
58    pub fn validate(&self) -> Result<(), BackendError> {
59        if self.source_fingerprint.trim().is_empty() {
60            return Err(BackendError::InvalidProgram {
61                fix: "Fix: source_fingerprint must be non-empty before emitting driver evidence."
62                    .to_string(),
63            });
64        }
65        if self.source_tree_fingerprint.trim().is_empty() {
66            return Err(BackendError::InvalidProgram {
67                fix: "Fix: source_tree_fingerprint must be non-empty before emitting driver evidence."
68                    .to_string(),
69            });
70        }
71        Ok(())
72    }
73}
74
75/// Capture git facts for the current working directory.
76#[must_use]
77pub fn capture_git_info() -> BTreeMap<String, String> {
78    capture_git_info_at(Path::new("."))
79}
80
81/// Capture git facts for `workspace_root`.
82#[must_use]
83pub fn capture_git_info_at(workspace_root: &Path) -> BTreeMap<String, String> {
84    let mut info = BTreeMap::new();
85
86    if let Ok(commit) = shell(workspace_root, &["rev-parse", "HEAD"]) {
87        info.insert("commit".to_string(), commit);
88    }
89    if let Ok(branch) = shell(workspace_root, &["rev-parse", "--abbrev-ref", "HEAD"]) {
90        info.insert("branch".to_string(), branch);
91    }
92    let dirty_status = shell_bytes(
93        workspace_root,
94        &[
95            "status",
96            "--porcelain=v1",
97            "-z",
98            "--untracked-files=all",
99            "--",
100            ".",
101            ":!release/evidence/**",
102        ],
103    );
104    let dirty = match dirty_status.as_ref() {
105        Ok(status) if status.is_empty() => "false",
106        Ok(status) => {
107            if let Some(fingerprint) = dirty_worktree_fingerprint(workspace_root, status) {
108                info.insert("dirty_worktree_fingerprint".to_string(), fingerprint);
109            }
110            "true"
111        }
112        Err(_) => "unknown",
113    };
114    info.insert("dirty".to_string(), dirty.to_string());
115
116    if let Ok(parent) = shell(workspace_root, &["rev-parse", "HEAD^"]) {
117        info.insert("parent_commit".to_string(), parent);
118    }
119    if let Ok(timestamp) = shell(workspace_root, &["log", "-1", "--format=%ct"]) {
120        info.insert("commit_timestamp".to_string(), timestamp);
121    }
122
123    info
124}
125
126/// Build the commit/dirty-state source fingerprint used by release evidence.
127#[must_use]
128pub fn source_fingerprint(git: &BTreeMap<String, String>) -> String {
129    if let Some(commit) = git.get("commit").filter(|commit| !commit.is_empty()) {
130        let dirty = git.get("dirty").map(String::as_str).unwrap_or("unknown");
131        if dirty == "true" {
132            let worktree = git
133                .get("dirty_worktree_fingerprint")
134                .filter(|fingerprint| !fingerprint.is_empty())
135                .map(String::as_str)
136                .unwrap_or("unknown");
137            return format!("git:{commit}:dirty=true:worktree={worktree}");
138        }
139        return format!("git:{commit}:dirty={dirty}");
140    }
141    format!(
142        "crate:{}:{}",
143        env!("CARGO_PKG_NAME"),
144        env!("CARGO_PKG_VERSION")
145    )
146}
147
148/// Capture a source-tree fingerprint for the current working directory.
149#[must_use]
150pub fn source_tree_fingerprint() -> String {
151    source_tree_fingerprint_at(Path::new("."))
152}
153
154/// Capture a source-tree fingerprint for `workspace_root`.
155#[must_use]
156pub fn source_tree_fingerprint_at(workspace_root: &Path) -> String {
157    match shell_bytes(
158        workspace_root,
159        &[
160            "ls-files",
161            "-z",
162            "--cached",
163            "--others",
164            "--exclude-standard",
165        ],
166    ) {
167        Ok(paths) => format!(
168            "source-tree-v1:{}",
169            source_tree_fingerprint_from_paths(workspace_root, &paths)
170        ),
171        Err(_) => format!(
172            "crate-source:{}:{}",
173            env!("CARGO_PKG_NAME"),
174            env!("CARGO_PKG_VERSION")
175        ),
176    }
177}
178
179fn source_tree_fingerprint_from_paths(workspace_root: &Path, paths: &[u8]) -> String {
180    let mut hasher = blake3::Hasher::new();
181    update_hash_field(&mut hasher, b"format", b"vyre-bench-source-tree-v1");
182    for path in paths
183        .split(|byte| *byte == 0)
184        .filter(|path| !path.is_empty())
185        .filter(|path| !source_tree_path_is_benchmark_provenance_ignored(path))
186    {
187        update_hash_field(&mut hasher, b"path", path);
188        let path = String::from_utf8_lossy(path);
189        match fs::read(workspace_root.join(path.as_ref())) {
190            Ok(bytes) => update_hash_field(&mut hasher, b"content", &bytes),
191            Err(error) => {
192                update_hash_field(&mut hasher, b"read-error", error.to_string().as_bytes())
193            }
194        }
195    }
196    hasher.finalize().to_hex().to_string()
197}
198
199fn source_tree_path_is_benchmark_provenance_ignored(path: &[u8]) -> bool {
200    path == b"cargo_full"
201        || path.starts_with(b".github/")
202        || path.starts_with(b"release/evidence/")
203        || path.starts_with(b"scripts/")
204        || path.starts_with(b"xtask/")
205        || source_tree_path_is_test_evidence(path)
206}
207
208fn source_tree_path_is_test_evidence(path: &[u8]) -> bool {
209    path.starts_with(b"tests/")
210        || path_contains(path, b"/tests/")
211        || path.ends_with(b"/tests.rs")
212        || path.ends_with(b"_tests.rs")
213        || path.ends_with(b"_test.rs")
214        || path_contains(path, b"_tests_")
215        || path_contains(path, b"_test_")
216}
217
218fn path_contains(path: &[u8], needle: &[u8]) -> bool {
219    !needle.is_empty() && path.windows(needle.len()).any(|window| window == needle)
220}
221
222fn dirty_worktree_fingerprint(workspace_root: &Path, status: &[u8]) -> Option<String> {
223    let diff = shell_bytes(
224        workspace_root,
225        &[
226            "diff",
227            "--binary",
228            "HEAD",
229            "--",
230            ".",
231            ":!release/evidence/**",
232        ],
233    )
234    .ok()?;
235    let untracked = shell_bytes(
236        workspace_root,
237        &[
238            "ls-files",
239            "--others",
240            "--exclude-standard",
241            "-z",
242            "--",
243            ".",
244            ":!release/evidence/**",
245        ],
246    )
247    .unwrap_or_default();
248    Some(dirty_worktree_fingerprint_from_parts(
249        workspace_root,
250        status,
251        &diff,
252        &untracked,
253    ))
254}
255
256fn dirty_worktree_fingerprint_from_parts(
257    workspace_root: &Path,
258    status: &[u8],
259    diff: &[u8],
260    untracked: &[u8],
261) -> String {
262    let mut hasher = blake3::Hasher::new();
263    update_hash_field(&mut hasher, b"format", b"vyre-bench-dirty-source-v1");
264    update_hash_field(&mut hasher, b"status", status);
265    update_hash_field(&mut hasher, b"diff", diff);
266    for path in untracked
267        .split(|byte| *byte == 0)
268        .filter(|path| !path.is_empty())
269    {
270        update_hash_field(&mut hasher, b"untracked-path", path);
271        let path = String::from_utf8_lossy(path);
272        if let Ok(bytes) = fs::read(workspace_root.join(path.as_ref())) {
273            update_hash_field(&mut hasher, b"untracked-content", &bytes);
274        }
275    }
276    hasher.finalize().to_hex().to_string()
277}
278
279fn update_hash_field(hasher: &mut blake3::Hasher, label: &[u8], value: &[u8]) {
280    hasher.update(&(label.len() as u64).to_le_bytes());
281    hasher.update(label);
282    hasher.update(&(value.len() as u64).to_le_bytes());
283    hasher.update(value);
284}
285
286fn shell(workspace_root: &Path, args: &[&str]) -> Result<String, String> {
287    let stdout = shell_bytes(workspace_root, args)?;
288    Ok(String::from_utf8_lossy(&stdout).trim().to_string())
289}
290
291fn shell_bytes(workspace_root: &Path, args: &[&str]) -> Result<Vec<u8>, String> {
292    let output = Command::new("git")
293        .args(args)
294        .current_dir(workspace_root)
295        .output()
296        .map_err(|e| e.to_string())?;
297    if output.status.success() {
298        Ok(output.stdout)
299    } else {
300        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
301    }
302}
303
304/// Timing evidence normalized across host and device timing sources.
305#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
306pub struct DispatchTimingEvidence {
307    /// Host-observed dispatch duration.
308    pub wall_ns: Option<u64>,
309    /// Device-observed elapsed time when available.
310    pub device_ns: Option<u64>,
311    /// Host enqueue duration when available.
312    pub enqueue_ns: Option<u64>,
313    /// Host wait/readback duration when available.
314    pub wait_ns: Option<u64>,
315}
316
317impl DispatchTimingEvidence {
318    /// Build timing evidence from a timed dispatch result.
319    #[must_use]
320    pub fn from_timed_dispatch(result: &TimedDispatchResult) -> Self {
321        Self {
322            wall_ns: Some(result.wall_ns),
323            device_ns: result.device_ns,
324            enqueue_ns: result.enqueue_ns,
325            wait_ns: result.wait_ns,
326        }
327    }
328
329    /// Return true when the evidence has at least one timing source.
330    #[must_use]
331    pub fn has_timing(&self) -> bool {
332        self.wall_ns.is_some()
333            || self.device_ns.is_some()
334            || self.enqueue_ns.is_some()
335            || self.wait_ns.is_some()
336    }
337}
338
339/// One artifact referenced by an evidence bundle.
340#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
341pub struct EvidenceArtifact {
342    /// Stable artifact kind, such as `pipeline_manifest`, `benchmark_report`, or `replay_capsule`.
343    pub kind: String,
344    /// Backend that produced or owns the artifact when applicable.
345    pub backend_id: Option<String>,
346    /// Relative or consumer-provided artifact path.
347    pub path: Option<String>,
348    /// Content digest or identity digest when available.
349    pub digest: Option<String>,
350}
351
352impl EvidenceArtifact {
353    /// Build an artifact row.
354    #[must_use]
355    pub fn new(
356        kind: impl Into<String>,
357        backend_id: Option<String>,
358        path: Option<String>,
359        digest: Option<String>,
360    ) -> Self {
361        Self {
362            kind: kind.into(),
363            backend_id,
364            path,
365            digest,
366        }
367    }
368
369    /// Build an artifact row from a compiled-pipeline manifest.
370    #[must_use]
371    pub fn from_pipeline_manifest(manifest: &PipelineReproManifest) -> Self {
372        Self {
373            kind: "pipeline_manifest".to_string(),
374            backend_id: Some(manifest.backend_id.clone()),
375            path: None,
376            digest: Some(manifest.program_digest.clone()),
377        }
378    }
379}
380
381/// Replay metadata attached to a dispatch or conformance failure.
382#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
383pub struct ReplayEvidence {
384    /// Human-runnable replay command.
385    pub command: String,
386    /// Capsule digest when the replay payload has been materialized.
387    pub capsule_digest: Option<String>,
388}
389
390impl ReplayEvidence {
391    /// Build replay evidence.
392    #[must_use]
393    pub fn new(command: impl Into<String>, capsule_digest: Option<String>) -> Self {
394        Self {
395            command: command.into(),
396            capsule_digest,
397        }
398    }
399}
400
401/// Shared evidence bundle for dispatch, benchmark, conformance, and replay surfaces.
402#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
403pub struct EvidenceBundle {
404    /// Bundle schema version.
405    pub schema: u32,
406    /// Backend that produced the result or artifact.
407    pub backend_id: String,
408    /// Backend implementation version.
409    pub backend_version: String,
410    /// Canonical normalized Program digest as lowercase hex.
411    pub program_digest: String,
412    /// Dispatch policy fields that affect generated backend code.
413    pub dispatch_policy: String,
414    /// Source provenance for the code that produced this evidence.
415    pub source: SourceProvenance,
416    /// Timing evidence for the dispatch or run.
417    pub timing: DispatchTimingEvidence,
418    /// Artifacts referenced by this bundle.
419    pub artifacts: Vec<EvidenceArtifact>,
420    /// Replay metadata when a replay capsule exists.
421    pub replay: Option<ReplayEvidence>,
422}
423
424impl EvidenceBundle {
425    /// Current evidence bundle schema.
426    pub const SCHEMA: u32 = 1;
427
428    /// Build an evidence bundle for a backend/program/config tuple.
429    ///
430    /// # Errors
431    /// Returns [`BackendError`] when the Program cannot be fingerprinted or
432    /// provenance is too weak to emit.
433    pub fn for_program(
434        backend: &dyn VyreBackend,
435        program: &Program,
436        config: &DispatchConfig,
437        source: SourceProvenance,
438    ) -> Result<Self, BackendError> {
439        let program_digest = try_normalized_program_cache_digest(program).map_err(|error| {
440            BackendError::InvalidProgram {
441                fix: format!(
442                    "Fix: failed to build evidence Program digest: {error}. Validate and normalize the Program before dispatch evidence emission."
443                ),
444            }
445        })?;
446        source.validate()?;
447        Ok(Self {
448            schema: Self::SCHEMA,
449            backend_id: backend.id().to_string(),
450            backend_version: backend.version().to_string(),
451            program_digest: hex_encode(&program_digest),
452            dispatch_policy: dispatch_policy_cache_string(config),
453            source,
454            timing: DispatchTimingEvidence::default(),
455            artifacts: Vec::new(),
456            replay: None,
457        })
458    }
459
460    /// Attach timing from a backend dispatch result.
461    #[must_use]
462    pub fn with_timed_dispatch(mut self, result: &TimedDispatchResult) -> Self {
463        self.timing = DispatchTimingEvidence::from_timed_dispatch(result);
464        self
465    }
466
467    /// Attach an artifact row.
468    #[must_use]
469    pub fn with_artifact(mut self, artifact: EvidenceArtifact) -> Self {
470        self.artifacts.push(artifact);
471        self
472    }
473
474    /// Attach replay metadata.
475    #[must_use]
476    pub fn with_replay(mut self, replay: ReplayEvidence) -> Self {
477        self.replay = Some(replay);
478        self
479    }
480
481    /// Validate the bundle's load-bearing fields.
482    ///
483    /// # Errors
484    /// Returns [`BackendError::InvalidProgram`] when a bundle is missing a
485    /// required identity field or carries malformed digest metadata.
486    pub fn validate(&self) -> Result<(), BackendError> {
487        if self.schema != Self::SCHEMA {
488            return Err(BackendError::InvalidProgram {
489                fix: format!(
490                    "Fix: evidence bundle schema {} is unsupported; regenerate evidence with schema {}.",
491                    self.schema,
492                    Self::SCHEMA
493                ),
494            });
495        }
496        if self.backend_id.trim().is_empty() {
497            return Err(BackendError::InvalidProgram {
498                fix: "Fix: evidence bundle backend_id must be non-empty.".to_string(),
499            });
500        }
501        if self.program_digest.len() != 64
502            || !self.program_digest.bytes().all(|byte| byte.is_ascii_hexdigit())
503        {
504            return Err(BackendError::InvalidProgram {
505                fix: "Fix: evidence bundle program_digest must be a 64-character hex digest."
506                    .to_string(),
507            });
508        }
509        self.source.validate()
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use std::sync::Arc;
516
517    use super::*;
518    use crate::backend::{private, CompiledPipeline, OutputBuffers};
519    use vyre_foundation::ir::{BufferDecl, DataType, Expr, Node};
520
521    #[derive(Clone)]
522    struct EvidenceTestBackend;
523
524    impl private::Sealed for EvidenceTestBackend {}
525
526    impl VyreBackend for EvidenceTestBackend {
527        fn id(&self) -> &'static str {
528            "evidence-test"
529        }
530
531        fn version(&self) -> &'static str {
532            "test-version"
533        }
534
535        fn dispatch(
536            &self,
537            _program: &Program,
538            _inputs: &[Vec<u8>],
539            _config: &DispatchConfig,
540        ) -> Result<Vec<Vec<u8>>, BackendError> {
541            Ok(vec![42_u32.to_le_bytes().to_vec()])
542        }
543    }
544
545    struct EvidencePipeline;
546
547    impl private::Sealed for EvidencePipeline {}
548
549    impl CompiledPipeline for EvidencePipeline {
550        fn id(&self) -> &str {
551            "evidence-test:pipeline"
552        }
553
554        fn dispatch(
555            &self,
556            _inputs: &[Vec<u8>],
557            _config: &DispatchConfig,
558        ) -> Result<OutputBuffers, BackendError> {
559            Ok(vec![42_u32.to_le_bytes().to_vec()])
560        }
561    }
562
563    fn evidence_program() -> Program {
564        Program::wrapped(
565            vec![
566                BufferDecl::read("input", 0, DataType::U32).with_count(1),
567                BufferDecl::output("output", 1, DataType::U32).with_count(1),
568            ],
569            [1, 1, 1],
570            vec![Node::store(
571                "output",
572                Expr::u32(0),
573                Expr::load("input", Expr::u32(0)),
574            )],
575        )
576    }
577
578    fn source() -> SourceProvenance {
579        SourceProvenance {
580            git: BTreeMap::from([
581                ("commit".to_string(), "abc123".to_string()),
582                ("dirty".to_string(), "false".to_string()),
583            ]),
584            source_fingerprint: "git:abc123:dirty=false".to_string(),
585            source_tree_fingerprint: "source-tree-v1:test".to_string(),
586        }
587    }
588
589    #[test]
590    fn evidence_bundle_records_backend_program_policy_source_timing_and_artifacts() {
591        let backend = EvidenceTestBackend;
592        let program = evidence_program();
593        let mut config = DispatchConfig::default();
594        config.workgroup_override = Some([8, 1, 1]);
595        let timed = TimedDispatchResult {
596            outputs: vec![42_u32.to_le_bytes().to_vec()],
597            wall_ns: 100,
598            device_ns: Some(70),
599            enqueue_ns: Some(10),
600            wait_ns: Some(20),
601        };
602        let pipeline = Arc::new(EvidencePipeline);
603        let manifest = PipelineReproManifest::new(
604            backend.id(),
605            pipeline.id(),
606            try_normalized_program_cache_digest(&program)
607                .expect("Fix: evidence test Program must fingerprint"),
608            dispatch_policy_cache_string(&config),
609            Some(true),
610        );
611
612        let bundle = EvidenceBundle::for_program(&backend, &program, &config, source())
613            .expect("Fix: evidence bundle should build for valid source/program")
614            .with_timed_dispatch(&timed)
615            .with_artifact(EvidenceArtifact::from_pipeline_manifest(&manifest))
616            .with_replay(ReplayEvidence::new(
617                "vyre-conform dispatch --backend evidence-test --ops evidence.test",
618                Some("capsule-digest".to_string()),
619            ));
620
621        bundle
622            .validate()
623            .expect("Fix: complete evidence bundle should validate");
624        assert_eq!(bundle.backend_id, "evidence-test");
625        assert_eq!(bundle.backend_version, "test-version");
626        assert_eq!(bundle.program_digest.len(), 64);
627        assert_eq!(bundle.dispatch_policy, "ulp=None:wg=Some([8, 1, 1])");
628        assert_eq!(bundle.source.source_fingerprint, "git:abc123:dirty=false");
629        assert_eq!(bundle.timing.device_ns, Some(70));
630        assert_eq!(bundle.artifacts[0].kind, "pipeline_manifest");
631        assert_eq!(
632            bundle.replay.as_ref().map(|replay| replay.command.as_str()),
633            Some("vyre-conform dispatch --backend evidence-test --ops evidence.test")
634        );
635    }
636
637    #[test]
638    fn evidence_bundle_rejects_weak_source_provenance() {
639        let backend = EvidenceTestBackend;
640        let program = evidence_program();
641        let invalid = SourceProvenance {
642            git: BTreeMap::new(),
643            source_fingerprint: " ".to_string(),
644            source_tree_fingerprint: "source-tree-v1:test".to_string(),
645        };
646
647        let error = EvidenceBundle::for_program(
648            &backend,
649            &program,
650            &DispatchConfig::default(),
651            invalid,
652        )
653        .expect_err("Fix: evidence bundle must reject blank source_fingerprint");
654
655        assert!(
656            error.to_string().contains("source_fingerprint"),
657            "Fix: source provenance rejection must name the weak field: {error}"
658        );
659    }
660
661    #[test]
662    fn clean_source_fingerprint_keeps_commit_dirty_contract() {
663        let git = BTreeMap::from([
664            ("commit".to_string(), "abc123".to_string()),
665            ("dirty".to_string(), "false".to_string()),
666        ]);
667
668        assert_eq!(
669            source_fingerprint(&git),
670            "git:abc123:dirty=false",
671            "Fix: clean source fingerprints must remain stable for existing release evidence contracts."
672        );
673    }
674
675    #[test]
676    fn dirty_source_fingerprint_carries_worktree_digest() {
677        let git = BTreeMap::from([
678            ("commit".to_string(), "abc123".to_string()),
679            ("dirty".to_string(), "true".to_string()),
680            (
681                "dirty_worktree_fingerprint".to_string(),
682                "worktree-hash".to_string(),
683            ),
684        ]);
685
686        assert_eq!(
687            source_fingerprint(&git),
688            "git:abc123:dirty=true:worktree=worktree-hash",
689            "Fix: dirty source fingerprints must distinguish different dirty worktree states."
690        );
691    }
692
693    #[test]
694    fn dirty_source_fingerprint_without_digest_fails_closed() {
695        let git = BTreeMap::from([
696            ("commit".to_string(), "abc123".to_string()),
697            ("dirty".to_string(), "true".to_string()),
698        ]);
699
700        assert_eq!(
701            source_fingerprint(&git),
702            "git:abc123:dirty=true:worktree=unknown",
703            "Fix: dirty source fingerprints must not fall back to the broad legacy dirty=true contract."
704        );
705    }
706
707    #[test]
708    fn dirty_worktree_digest_changes_with_status_diff_and_untracked_content() {
709        let workspace = Path::new(".");
710        let base =
711            dirty_worktree_fingerprint_from_parts(workspace, b" M a.rs\0", b"-old\n+new\n", b"");
712        let changed_status =
713            dirty_worktree_fingerprint_from_parts(workspace, b" M b.rs\0", b"-old\n+new\n", b"");
714        let changed_diff =
715            dirty_worktree_fingerprint_from_parts(workspace, b" M a.rs\0", b"-old\n+newer\n", b"");
716        let changed_untracked_inventory =
717            dirty_worktree_fingerprint_from_parts(workspace, b"?? c.rs\0", b"", b"c.rs\0");
718        let untracked_workspace = temp_workspace("vyre-driver-dirty-fingerprint");
719        fs::write(untracked_workspace.join("c.rs"), b"one")
720            .expect("Fix: write first untracked content fingerprint fixture.");
721        let untracked_one = dirty_worktree_fingerprint_from_parts(
722            &untracked_workspace,
723            b"?? c.rs\0",
724            b"",
725            b"c.rs\0",
726        );
727        fs::write(untracked_workspace.join("c.rs"), b"two")
728            .expect("Fix: write second untracked content fingerprint fixture.");
729        let untracked_two = dirty_worktree_fingerprint_from_parts(
730            &untracked_workspace,
731            b"?? c.rs\0",
732            b"",
733            b"c.rs\0",
734        );
735        let _ = fs::remove_dir_all(&untracked_workspace);
736
737        assert_ne!(
738            base, changed_status,
739            "Fix: dirty source fingerprints must change when modified paths change."
740        );
741        assert_ne!(
742            base, changed_diff,
743            "Fix: dirty source fingerprints must change when tracked diff bytes change."
744        );
745        assert_ne!(
746            base, changed_untracked_inventory,
747            "Fix: dirty source fingerprints must change when untracked inventory changes."
748        );
749        assert_ne!(
750            untracked_one, untracked_two,
751            "Fix: dirty source fingerprints must change when untracked file content changes."
752        );
753    }
754
755    #[test]
756    fn source_tree_fingerprint_ignores_generated_release_evidence() {
757        let workspace = temp_workspace("vyre-driver-source-tree-fingerprint");
758        fs::create_dir_all(workspace.join("src")).expect("Fix: create source fixture directory.");
759        fs::create_dir_all(workspace.join("release/evidence/benchmarks"))
760            .expect("Fix: create generated evidence fixture directory.");
761        fs::write(workspace.join("src/lib.rs"), b"pub fn source() {}\n")
762            .expect("Fix: write source-tree fingerprint source fixture.");
763        fs::write(
764            workspace.join("release/evidence/benchmarks/workload.json"),
765            b"{\"old\":true}\n",
766        )
767        .expect("Fix: write source-tree fingerprint evidence fixture.");
768        let paths = b"src/lib.rs\0release/evidence/benchmarks/workload.json\0";
769
770        let base = source_tree_fingerprint_from_paths(&workspace, paths);
771        fs::write(
772            workspace.join("release/evidence/benchmarks/workload.json"),
773            b"{\"new\":true}\n",
774        )
775        .expect("Fix: mutate generated evidence fixture.");
776        let evidence_changed = source_tree_fingerprint_from_paths(&workspace, paths);
777        fs::write(
778            workspace.join("src/lib.rs"),
779            b"pub fn source_changed() {}\n",
780        )
781        .expect("Fix: mutate source fixture.");
782        let source_changed = source_tree_fingerprint_from_paths(&workspace, paths);
783        let _ = fs::remove_dir_all(&workspace);
784
785        assert_eq!(
786            base, evidence_changed,
787            "Fix: generated release evidence must not invalidate committed benchmark source provenance."
788        );
789        assert_ne!(
790            base, source_changed,
791            "Fix: source-tree provenance must still change when real source files change."
792        );
793    }
794
795    #[test]
796    fn source_tree_fingerprint_ignores_release_tooling_source() {
797        let workspace = temp_workspace("vyre-driver-source-tree-tooling");
798        fs::create_dir_all(workspace.join("vyre-bench/src"))
799            .expect("Fix: create benchmark source fixture directory.");
800        fs::create_dir_all(workspace.join(".github/workflows"))
801            .expect("Fix: create workflow fixture directory.");
802        fs::create_dir_all(workspace.join("scripts"))
803            .expect("Fix: create release script fixture directory.");
804        fs::create_dir_all(workspace.join("xtask/src"))
805            .expect("Fix: create release tooling fixture directory.");
806        fs::write(workspace.join("cargo_full"), b"#!/usr/bin/env bash\n")
807            .expect("Fix: write cargo wrapper fixture.");
808        fs::write(
809            workspace.join("vyre-bench/src/lib.rs"),
810            b"pub fn benchmark() {}\n",
811        )
812        .expect("Fix: write benchmark source fixture.");
813        fs::write(
814            workspace.join("xtask/src/hygiene_matrix.rs"),
815            b"pub fn tooling() {}\n",
816        )
817        .expect("Fix: write release tooling fixture.");
818        fs::write(
819            workspace.join("scripts/install_lego_quick_hook.sh"),
820            b"#!/usr/bin/env bash\n",
821        )
822        .expect("Fix: write release script fixture.");
823        fs::write(
824            workspace.join(".github/workflows/ci.yml"),
825            b"run: ./cargo_full test --workspace\n",
826        )
827        .expect("Fix: write workflow fixture.");
828        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";
829
830        let base = source_tree_fingerprint_from_paths(&workspace, paths);
831        fs::write(
832            workspace.join("cargo_full"),
833            b"#!/usr/bin/env bash\nexec cargo \"$@\"\n",
834        )
835        .expect("Fix: mutate cargo wrapper fixture.");
836        let wrapper_changed = source_tree_fingerprint_from_paths(&workspace, paths);
837        fs::write(
838            workspace.join("scripts/install_lego_quick_hook.sh"),
839            b"#!/usr/bin/env bash\n./cargo_full run --bin xtask -- lego-quick\n",
840        )
841        .expect("Fix: mutate release script fixture.");
842        let script_changed = source_tree_fingerprint_from_paths(&workspace, paths);
843        fs::write(
844            workspace.join(".github/workflows/ci.yml"),
845            b"run: ./cargo_full test --workspace --all-targets\n",
846        )
847        .expect("Fix: mutate workflow fixture.");
848        let workflow_changed = source_tree_fingerprint_from_paths(&workspace, paths);
849        fs::write(
850            workspace.join("xtask/src/hygiene_matrix.rs"),
851            b"pub fn tooling_changed() {}\n",
852        )
853        .expect("Fix: mutate release tooling fixture.");
854        let tooling_changed = source_tree_fingerprint_from_paths(&workspace, paths);
855        fs::write(
856            workspace.join("vyre-bench/src/lib.rs"),
857            b"pub fn benchmark_changed() {}\n",
858        )
859        .expect("Fix: mutate benchmark source fixture.");
860        let benchmark_changed = source_tree_fingerprint_from_paths(&workspace, paths);
861        let _ = fs::remove_dir_all(&workspace);
862
863        assert_eq!(
864            base, tooling_changed,
865            "Fix: release evidence/tooling generators must not invalidate benchmark runtime source provenance."
866        );
867        assert_eq!(
868            base, wrapper_changed,
869            "Fix: bounded cargo wrapper changes must not invalidate benchmark runtime source provenance."
870        );
871        assert_eq!(
872            base, script_changed,
873            "Fix: release scripts must not invalidate benchmark runtime source provenance."
874        );
875        assert_eq!(
876            base, workflow_changed,
877            "Fix: CI workflow edits must not invalidate benchmark runtime source provenance."
878        );
879        assert_ne!(
880            base, benchmark_changed,
881            "Fix: benchmark source edits must still invalidate benchmark source provenance."
882        );
883    }
884
885    #[test]
886    fn source_tree_fingerprint_ignores_test_evidence() {
887        let workspace = temp_workspace("vyre-driver-source-tree-tests");
888        fs::create_dir_all(workspace.join("vyre-libs/src"))
889            .expect("Fix: create library source fixture directory.");
890        fs::create_dir_all(workspace.join("vyre-libs/tests/support"))
891            .expect("Fix: create integration test support fixture directory.");
892        fs::create_dir_all(workspace.join("vyre-libs/src/graph"))
893            .expect("Fix: create inline test fixture directory.");
894        fs::write(
895            workspace.join("vyre-libs/src/lib.rs"),
896            b"pub fn source() {}\n",
897        )
898        .expect("Fix: write source-tree fingerprint source fixture.");
899        fs::write(
900            workspace.join("vyre-libs/tests/filter_roundtrip.rs"),
901            b"#[test]\nfn roundtrip() {}\n",
902        )
903        .expect("Fix: write integration test fixture.");
904        fs::write(
905            workspace.join("vyre-libs/tests/support/filter.rs"),
906            b"pub fn helper() {}\n",
907        )
908        .expect("Fix: write test support fixture.");
909        fs::write(
910            workspace.join("vyre-libs/src/graph/tests.rs"),
911            b"#[test]\nfn graph_contract() {}\n",
912        )
913        .expect("Fix: write inline tests fixture.");
914        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";
915
916        let base = source_tree_fingerprint_from_paths(&workspace, paths);
917        fs::write(
918            workspace.join("vyre-libs/tests/filter_roundtrip.rs"),
919            b"#[test]\nfn roundtrip_modularized() {}\n",
920        )
921        .expect("Fix: mutate integration test fixture.");
922        fs::write(
923            workspace.join("vyre-libs/tests/support/filter.rs"),
924            b"pub fn helper_modularized() {}\n",
925        )
926        .expect("Fix: mutate test support fixture.");
927        fs::write(
928            workspace.join("vyre-libs/src/graph/tests.rs"),
929            b"#[test]\nfn graph_contract_modularized() {}\n",
930        )
931        .expect("Fix: mutate inline tests fixture.");
932        let tests_changed = source_tree_fingerprint_from_paths(&workspace, paths);
933        fs::write(
934            workspace.join("vyre-libs/src/lib.rs"),
935            b"pub fn source_changed() {}\n",
936        )
937        .expect("Fix: mutate production source fixture.");
938        let source_changed = source_tree_fingerprint_from_paths(&workspace, paths);
939        let _ = fs::remove_dir_all(&workspace);
940
941        assert_eq!(
942            base, tests_changed,
943            "Fix: test-only modularization must not invalidate runtime benchmark source provenance."
944        );
945        assert_ne!(
946            base, source_changed,
947            "Fix: source-tree provenance must still change when production source changes."
948        );
949    }
950
951    #[test]
952    fn source_fingerprint_ignores_generated_release_evidence_dirty_status() {
953        let workspace = temp_workspace("vyre-driver-source-fingerprint-evidence");
954        fs::create_dir_all(workspace.join("src"))
955            .expect("Fix: create source fingerprint fixture source directory.");
956        fs::create_dir_all(workspace.join("release/evidence/benchmarks"))
957            .expect("Fix: create source fingerprint fixture evidence directory.");
958        fs::write(workspace.join("src/lib.rs"), b"pub fn source() {}\n")
959            .expect("Fix: write source fingerprint source fixture.");
960        fs::write(
961            workspace.join("release/evidence/benchmarks/workload.json"),
962            b"{\"old\":true}\n",
963        )
964        .expect("Fix: write tracked generated evidence fixture.");
965        git_fixture(&workspace, &["init", "--quiet", "--initial-branch", "main"]);
966        git_fixture(
967            &workspace,
968            &["config", "user.email", "vyre@example.invalid"],
969        );
970        git_fixture(&workspace, &["config", "user.name", "Vyre Test"]);
971        git_fixture(
972            &workspace,
973            &[
974                "add",
975                "src/lib.rs",
976                "release/evidence/benchmarks/workload.json",
977            ],
978        );
979        git_fixture(&workspace, &["commit", "--quiet", "-m", "seed"]);
980
981        fs::write(
982            workspace.join("release/evidence/benchmarks/workload.json"),
983            b"{\"new\":true}\n",
984        )
985        .expect("Fix: mutate tracked generated evidence fixture.");
986        fs::write(
987            workspace.join("release/evidence/benchmarks/new-workload.json"),
988            b"{\"new\":true}\n",
989        )
990        .expect("Fix: write untracked generated evidence fixture.");
991        let evidence_only = capture_git_info_at(&workspace);
992        fs::write(
993            workspace.join("src/lib.rs"),
994            b"pub fn source_changed() {}\n",
995        )
996        .expect("Fix: mutate real source fixture.");
997        let source_changed = capture_git_info_at(&workspace);
998        let _ = fs::remove_dir_all(&workspace);
999
1000        assert_eq!(
1001            evidence_only.get("dirty").map(String::as_str),
1002            Some("false"),
1003            "Fix: generated release evidence writes must not mark benchmark source provenance dirty."
1004        );
1005        assert_eq!(
1006            source_changed.get("dirty").map(String::as_str),
1007            Some("true"),
1008            "Fix: real source edits must still mark benchmark source provenance dirty."
1009        );
1010    }
1011
1012    fn temp_workspace(prefix: &str) -> std::path::PathBuf {
1013        let workspace = std::env::temp_dir().join(format!(
1014            "{prefix}-{}-{}",
1015            std::process::id(),
1016            std::time::SystemTime::now()
1017                .duration_since(std::time::UNIX_EPOCH)
1018                .expect("Fix: system clock must support unix epoch duration for temp test id.")
1019                .as_nanos()
1020        ));
1021        fs::create_dir_all(&workspace).expect("Fix: create temporary provenance test workspace.");
1022        workspace
1023    }
1024
1025    fn git_fixture(workspace: &Path, args: &[&str]) {
1026        let output = Command::new("git")
1027            .args(args)
1028            .current_dir(workspace)
1029            .output()
1030            .expect("Fix: git fixture command must start.");
1031        assert!(
1032            output.status.success(),
1033            "Fix: git fixture command `git {}` failed: {}",
1034            args.join(" "),
1035            String::from_utf8_lossy(&output.stderr).trim()
1036        );
1037    }
1038}