1use 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub struct SourceProvenance {
25 pub git: BTreeMap<String, String>,
27 pub source_fingerprint: String,
29 pub source_tree_fingerprint: String,
31}
32
33impl SourceProvenance {
34 #[must_use]
36 pub fn capture_current() -> Self {
37 Self::capture_at(Path::new("."))
38 }
39
40 #[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 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#[must_use]
77pub fn capture_git_info() -> BTreeMap<String, String> {
78 capture_git_info_at(Path::new("."))
79}
80
81#[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#[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#[must_use]
150pub fn source_tree_fingerprint() -> String {
151 source_tree_fingerprint_at(Path::new("."))
152}
153
154#[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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
306pub struct DispatchTimingEvidence {
307 pub wall_ns: Option<u64>,
309 pub device_ns: Option<u64>,
311 pub enqueue_ns: Option<u64>,
313 pub wait_ns: Option<u64>,
315}
316
317impl DispatchTimingEvidence {
318 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
341pub struct EvidenceArtifact {
342 pub kind: String,
344 pub backend_id: Option<String>,
346 pub path: Option<String>,
348 pub digest: Option<String>,
350}
351
352impl EvidenceArtifact {
353 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
383pub struct ReplayEvidence {
384 pub command: String,
386 pub capsule_digest: Option<String>,
388}
389
390impl ReplayEvidence {
391 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
403pub struct EvidenceBundle {
404 pub schema: u32,
406 pub backend_id: String,
408 pub backend_version: String,
410 pub program_digest: String,
412 pub dispatch_policy: String,
414 pub source: SourceProvenance,
416 pub timing: DispatchTimingEvidence,
418 pub artifacts: Vec<EvidenceArtifact>,
420 pub replay: Option<ReplayEvidence>,
422}
423
424impl EvidenceBundle {
425 pub const SCHEMA: u32 = 1;
427
428 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 #[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 #[must_use]
469 pub fn with_artifact(mut self, artifact: EvidenceArtifact) -> Self {
470 self.artifacts.push(artifact);
471 self
472 }
473
474 #[must_use]
476 pub fn with_replay(mut self, replay: ReplayEvidence) -> Self {
477 self.replay = Some(replay);
478 self
479 }
480
481 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}