1use arrow::array::{
19 ArrayRef, Int64Array, RecordBatch, StringArray, TimestampMillisecondArray, UInt64Array,
20};
21use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
22use std::collections::HashMap;
23use std::path::Path;
24use std::sync::Arc;
25
26pub mod artifact_col {
30 pub const BODY_HASH: usize = 0;
31 pub const ARTIFACT_KIND: usize = 1;
32 pub const ARTIFACT_SIZE: usize = 2;
33 pub const COMPILE_TIME_NS: usize = 3;
34 pub const CREATED_AT: usize = 4;
35}
36
37pub fn build_artifacts_schema() -> Schema {
39 Schema::new(vec![
40 Field::new("body_hash", DataType::Utf8, false),
41 Field::new("artifact_kind", DataType::Utf8, false),
42 Field::new("artifact_size", DataType::UInt64, false),
43 Field::new("compile_time_ns", DataType::Int64, false),
44 Field::new(
45 "created_at",
46 DataType::Timestamp(TimeUnit::Millisecond, None),
47 false,
48 ),
49 ])
50}
51
52#[derive(Debug, Clone)]
56pub struct CacheEntry {
57 pub body_hash: String,
58 pub artifact_kind: String,
59 pub artifact_size: u64,
60 pub compile_time_ns: i64,
61 pub created_at: i64,
62}
63
64#[derive(Debug, Clone, Default)]
68pub struct CacheStats {
69 pub hits: u64,
70 pub misses: u64,
71 pub total_artifact_bytes: u64,
72 pub total_compile_time_saved_ns: i64,
73}
74
75impl CacheStats {
76 pub fn hit_rate(&self) -> f64 {
77 let total = self.hits + self.misses;
78 if total == 0 {
79 return 0.0;
80 }
81 self.hits as f64 / total as f64
82 }
83}
84
85impl std::fmt::Display for CacheStats {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 write!(
88 f,
89 "hits={}, misses={}, hit_rate={:.1}%, saved={:.2}ms",
90 self.hits,
91 self.misses,
92 self.hit_rate() * 100.0,
93 self.total_compile_time_saved_ns as f64 / 1_000_000.0,
94 )
95 }
96}
97
98pub struct BuildCache {
105 entries: HashMap<String, CacheEntry>,
106 stats: CacheStats,
107}
108
109impl BuildCache {
110 pub fn new() -> Self {
112 Self {
113 entries: HashMap::new(),
114 stats: CacheStats::default(),
115 }
116 }
117
118 pub fn get(&mut self, body_hash: &str) -> Option<&CacheEntry> {
120 if let Some(entry) = self.entries.get(body_hash) {
121 self.stats.hits += 1;
122 self.stats.total_compile_time_saved_ns += entry.compile_time_ns;
123 Some(entry)
124 } else {
125 self.stats.misses += 1;
126 None
127 }
128 }
129
130 pub fn put(&mut self, entry: CacheEntry) {
132 self.stats.total_artifact_bytes += entry.artifact_size;
133 self.entries.insert(entry.body_hash.clone(), entry);
134 }
135
136 pub fn len(&self) -> usize {
138 self.entries.len()
139 }
140
141 pub fn is_empty(&self) -> bool {
143 self.entries.is_empty()
144 }
145
146 pub fn stats(&self) -> &CacheStats {
148 &self.stats
149 }
150
151 pub fn remove(&mut self, body_hash: &str) -> bool {
153 self.entries.remove(body_hash).is_some()
154 }
155
156 pub fn reset_stats(&mut self) {
158 self.stats = CacheStats::default();
159 }
160
161 pub fn to_record_batch(&self) -> Result<RecordBatch, arrow::error::ArrowError> {
163 let schema = Arc::new(build_artifacts_schema());
164
165 if self.entries.is_empty() {
166 return Ok(RecordBatch::new_empty(schema));
167 }
168
169 let mut hashes = Vec::with_capacity(self.entries.len());
170 let mut kinds = Vec::with_capacity(self.entries.len());
171 let mut sizes = Vec::with_capacity(self.entries.len());
172 let mut times = Vec::with_capacity(self.entries.len());
173 let mut created = Vec::with_capacity(self.entries.len());
174
175 for entry in self.entries.values() {
176 hashes.push(entry.body_hash.as_str());
177 kinds.push(entry.artifact_kind.as_str());
178 sizes.push(entry.artifact_size);
179 times.push(entry.compile_time_ns);
180 created.push(entry.created_at);
181 }
182
183 RecordBatch::try_new(
184 schema,
185 vec![
186 Arc::new(StringArray::from(hashes)) as ArrayRef,
187 Arc::new(StringArray::from(kinds)) as ArrayRef,
188 Arc::new(UInt64Array::from(sizes)) as ArrayRef,
189 Arc::new(Int64Array::from(times)) as ArrayRef,
190 Arc::new(TimestampMillisecondArray::from(created)) as ArrayRef,
191 ],
192 )
193 }
194
195 pub fn from_record_batch(batch: &RecordBatch) -> Result<Self, String> {
197 let hashes = batch
198 .column(artifact_col::BODY_HASH)
199 .as_any()
200 .downcast_ref::<StringArray>()
201 .ok_or("body_hash column not StringArray")?;
202 let kinds = batch
203 .column(artifact_col::ARTIFACT_KIND)
204 .as_any()
205 .downcast_ref::<StringArray>()
206 .ok_or("artifact_kind column not StringArray")?;
207 let sizes = batch
208 .column(artifact_col::ARTIFACT_SIZE)
209 .as_any()
210 .downcast_ref::<UInt64Array>()
211 .ok_or("artifact_size column not UInt64Array")?;
212 let times = batch
213 .column(artifact_col::COMPILE_TIME_NS)
214 .as_any()
215 .downcast_ref::<Int64Array>()
216 .ok_or("compile_time_ns column not Int64Array")?;
217 let created = batch
218 .column(artifact_col::CREATED_AT)
219 .as_any()
220 .downcast_ref::<TimestampMillisecondArray>()
221 .ok_or("created_at column not TimestampMillisecondArray")?;
222
223 let mut entries = HashMap::new();
224 for i in 0..batch.num_rows() {
225 let entry = CacheEntry {
226 body_hash: hashes.value(i).to_string(),
227 artifact_kind: kinds.value(i).to_string(),
228 artifact_size: sizes.value(i),
229 compile_time_ns: times.value(i),
230 created_at: created.value(i),
231 };
232 entries.insert(entry.body_hash.clone(), entry);
233 }
234
235 Ok(Self {
236 entries,
237 stats: CacheStats::default(),
238 })
239 }
240
241 pub fn save_to_parquet(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
243 let batch = self.to_record_batch()?;
244 let file = std::fs::File::create(path)?;
245 let mut writer = parquet::arrow::ArrowWriter::try_new(file, batch.schema(), None)?;
246 writer.write(&batch)?;
247 writer.close()?;
248 Ok(())
249 }
250
251 pub fn load_from_parquet(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
253 let file = std::fs::File::open(path)?;
254 let reader = parquet::arrow::arrow_reader::ParquetRecordBatchReader::try_new(file, 8192)?;
255 let mut all_entries = HashMap::new();
256 for batch_result in reader {
257 let batch = batch_result?;
258 let partial = Self::from_record_batch(&batch)?;
259 all_entries.extend(partial.entries);
260 }
261 Ok(Self {
262 entries: all_entries,
263 stats: CacheStats::default(),
264 })
265 }
266}
267
268impl Default for BuildCache {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274#[derive(Debug, Clone)]
278pub struct HashStabilityReport {
279 pub measurements: Vec<CommitPairMeasurement>,
281}
282
283#[derive(Debug, Clone)]
285pub struct CommitPairMeasurement {
286 pub from_commit: String,
287 pub to_commit: String,
288 pub total_files: usize,
289 pub unchanged_files: usize,
290 pub changed_files: usize,
291 pub cache_hit_rate: f64,
292}
293
294impl HashStabilityReport {
295 pub fn avg_hit_rate(&self) -> f64 {
297 if self.measurements.is_empty() {
298 return 0.0;
299 }
300 let sum: f64 = self.measurements.iter().map(|m| m.cache_hit_rate).sum();
301 sum / self.measurements.len() as f64
302 }
303
304 pub fn min_hit_rate(&self) -> f64 {
306 self.measurements
307 .iter()
308 .map(|m| m.cache_hit_rate)
309 .fold(f64::MAX, f64::min)
310 }
311
312 pub fn max_hit_rate(&self) -> f64 {
314 self.measurements
315 .iter()
316 .map(|m| m.cache_hit_rate)
317 .fold(f64::MIN, f64::max)
318 }
319
320 pub fn summary(&self) -> String {
322 format!(
323 "Hash Stability Report ({} commit pairs)\n\
324 ├── avg cache hit rate: {:.1}%\n\
325 ├── min cache hit rate: {:.1}%\n\
326 ├── max cache hit rate: {:.1}%\n\
327 └── go signal (>90%): {}",
328 self.measurements.len(),
329 self.avg_hit_rate() * 100.0,
330 self.min_hit_rate() * 100.0,
331 self.max_hit_rate() * 100.0,
332 if self.avg_hit_rate() > 0.90 {
333 "PASS ✓"
334 } else {
335 "FAIL ✗"
336 }
337 )
338 }
339}
340
341pub fn collect_file_hashes(
346 repo_root: &Path,
347 commit: &str,
348 prefix: &str,
349) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
350 let output = std::process::Command::new("git")
351 .args(["ls-tree", "-r", commit, "--", prefix])
352 .current_dir(repo_root)
353 .output()?;
354
355 if !output.status.success() {
356 return Err(format!(
357 "git ls-tree failed: {}",
358 String::from_utf8_lossy(&output.stderr)
359 )
360 .into());
361 }
362
363 let stdout = String::from_utf8(output.stdout)?;
364 let mut hashes = HashMap::new();
365
366 for line in stdout.lines() {
367 let parts: Vec<&str> = line.splitn(4, [' ', '\t']).collect();
369 if parts.len() == 4 && parts[3].ends_with(".rs") {
370 hashes.insert(parts[3].to_string(), parts[2].to_string());
371 }
372 }
373
374 Ok(hashes)
375}
376
377pub fn recent_commits(
379 repo_root: &Path,
380 count: usize,
381) -> Result<Vec<String>, Box<dyn std::error::Error>> {
382 let output = std::process::Command::new("git")
383 .args(["log", "--format=%H", "-n", &count.to_string()])
384 .current_dir(repo_root)
385 .output()?;
386
387 if !output.status.success() {
388 return Err(format!(
389 "git log failed: {}",
390 String::from_utf8_lossy(&output.stderr)
391 )
392 .into());
393 }
394
395 let stdout = String::from_utf8(output.stdout)?;
396 Ok(stdout.lines().map(String::from).collect())
397}
398
399pub fn measure_hash_stability(
404 repo_root: &Path,
405 prefix: &str,
406 commit_count: usize,
407) -> Result<HashStabilityReport, Box<dyn std::error::Error>> {
408 let commits = recent_commits(repo_root, commit_count)?;
409
410 if commits.len() < 2 {
411 return Ok(HashStabilityReport {
412 measurements: vec![],
413 });
414 }
415
416 let mut measurements = Vec::new();
417
418 let mut prev_hashes = collect_file_hashes(repo_root, &commits[0], prefix)?;
420
421 for i in 1..commits.len() {
423 let curr_hashes = collect_file_hashes(repo_root, &commits[i], prefix)?;
424
425 let all_files: std::collections::HashSet<&String> =
427 prev_hashes.keys().chain(curr_hashes.keys()).collect();
428 let total = all_files.len();
429
430 let unchanged = all_files
431 .iter()
432 .filter(|f| {
433 prev_hashes.get(**f) == curr_hashes.get(**f)
434 && prev_hashes.contains_key(**f)
435 && curr_hashes.contains_key(**f)
436 })
437 .count();
438
439 let changed = total - unchanged;
440 let hit_rate = if total > 0 {
441 unchanged as f64 / total as f64
442 } else {
443 1.0
444 };
445
446 measurements.push(CommitPairMeasurement {
447 from_commit: commits[i][..8].to_string(),
448 to_commit: commits[i - 1][..8].to_string(),
449 total_files: total,
450 unchanged_files: unchanged,
451 changed_files: changed,
452 cache_hit_rate: hit_rate,
453 });
454
455 prev_hashes = curr_hashes;
456 }
457
458 Ok(HashStabilityReport { measurements })
459}
460
461pub struct CrateDeps {
469 pub deps: HashMap<String, Vec<String>>,
471}
472
473impl CrateDeps {
474 pub fn nusy_workspace() -> Self {
479 let mut deps = HashMap::new();
480 deps.insert("nusy-arrow-core".into(), vec![]);
481 deps.insert(
482 "nusy-arrow-git".into(),
483 vec![
484 "nusy-arrow-core".into(),
485 "nusy-codegraph".into(),
486 "nusy-kanban".into(),
487 ],
488 );
489 deps.insert(
490 "nusy-codegraph".into(),
491 vec!["nusy-arrow-core".into(), "nusy-arrow-git".into()],
492 );
493 deps.insert("nusy-conductor".into(), vec![]);
494 deps.insert("nusy-dual-store".into(), vec!["nusy-arrow-core".into()]);
495 deps.insert("nusy-graph-adapter".into(), vec!["nusy-arrow-core".into()]);
496 deps.insert(
497 "nusy-graph-review".into(),
498 vec!["nusy-arrow-core".into(), "nusy-arrow-git".into()],
499 );
500 deps.insert(
501 "nusy-kanban".into(),
502 vec![
503 "nusy-arrow-core".into(),
504 "nusy-arrow-git".into(),
505 "nusy-codegraph".into(),
506 "nusy-conductor".into(),
507 "nusy-graph-review".into(),
508 ],
509 );
510 deps.insert(
511 "nusy-kanban-server".into(),
512 vec!["nusy-kanban".into(), "nusy-graph-review".into()],
513 );
514 deps.insert("nusy-ontology".into(), vec!["nusy-arrow-core".into()]);
515 deps.insert(
516 "nusy-perceive".into(),
517 vec![
518 "nusy-arrow-core".into(),
519 "nusy-dual-store".into(),
520 "nusy-signal-fusion".into(),
521 ],
522 );
523 deps.insert("nusy-reasoning-causal".into(), vec![]);
524 deps.insert("nusy-signal-fusion".into(), vec!["nusy-arrow-core".into()]);
525 Self { deps }
526 }
527
528 pub fn reverse_deps(&self) -> HashMap<String, Vec<String>> {
530 let mut rdeps: HashMap<String, Vec<String>> = HashMap::new();
531 for name in self.deps.keys() {
532 rdeps.entry(name.clone()).or_default();
533 }
534 for (name, dep_list) in &self.deps {
535 for dep in dep_list {
536 rdeps.entry(dep.clone()).or_default().push(name.clone());
537 }
538 }
539 rdeps
540 }
541
542 pub fn transitive_dirty(
545 &self,
546 dirty: &std::collections::HashSet<String>,
547 ) -> std::collections::HashSet<String> {
548 let rdeps = self.reverse_deps();
549 let mut result = dirty.clone();
550 let mut queue: Vec<String> = dirty.iter().cloned().collect();
551
552 while let Some(crate_name) = queue.pop() {
553 if let Some(dependents) = rdeps.get(&crate_name) {
554 for dep in dependents {
555 if result.insert(dep.clone()) {
556 queue.push(dep.clone());
557 }
558 }
559 }
560 }
561
562 result
563 }
564}
565
566#[derive(Debug, Clone)]
568pub struct DependencyAwareReport {
569 pub measurements: Vec<DepAwareMeasurement>,
570}
571
572#[derive(Debug, Clone)]
574pub struct DepAwareMeasurement {
575 pub from_commit: String,
576 pub to_commit: String,
577 pub total_crates: usize,
578 pub directly_dirty: usize,
579 pub transitively_dirty: usize,
580 pub clean_crates: usize,
581 pub file_hit_rate: f64,
583 pub crate_hit_rate: f64,
585 pub dep_aware_hit_rate: f64,
587}
588
589impl DependencyAwareReport {
590 pub fn avg_dep_aware_hit_rate(&self) -> f64 {
591 if self.measurements.is_empty() {
592 return 0.0;
593 }
594 let sum: f64 = self.measurements.iter().map(|m| m.dep_aware_hit_rate).sum();
595 sum / self.measurements.len() as f64
596 }
597
598 pub fn summary(&self) -> String {
599 let avg_file: f64 = self
600 .measurements
601 .iter()
602 .map(|m| m.file_hit_rate)
603 .sum::<f64>()
604 / self.measurements.len().max(1) as f64;
605 let avg_crate: f64 = self
606 .measurements
607 .iter()
608 .map(|m| m.crate_hit_rate)
609 .sum::<f64>()
610 / self.measurements.len().max(1) as f64;
611 let avg_dep = self.avg_dep_aware_hit_rate();
612
613 format!(
614 "Dependency-Aware Cache Analysis ({} commit pairs)\n\
615 ├── file-level hit rate (upper bound): {:.1}%\n\
616 ├── crate-level hit rate: {:.1}%\n\
617 ├── dep-aware hit rate (real number): {:.1}%\n\
618 └── go signal (dep-aware > 90%): {}",
619 self.measurements.len(),
620 avg_file * 100.0,
621 avg_crate * 100.0,
622 avg_dep * 100.0,
623 if avg_dep > 0.90 {
624 "PASS ✓"
625 } else {
626 "FAIL ✗"
627 }
628 )
629 }
630}
631
632fn crate_from_path(path: &str) -> Option<&str> {
634 let path = path.strip_prefix("crates/")?;
635 path.split('/').next()
636}
637
638pub fn measure_dep_aware_stability(
640 repo_root: &Path,
641 commit_count: usize,
642) -> Result<DependencyAwareReport, Box<dyn std::error::Error>> {
643 let crate_deps = CrateDeps::nusy_workspace();
644 let all_crates: std::collections::HashSet<String> = crate_deps.deps.keys().cloned().collect();
645 let total_crates = all_crates.len();
646
647 let commits = recent_commits(repo_root, commit_count)?;
648 if commits.len() < 2 {
649 return Ok(DependencyAwareReport {
650 measurements: vec![],
651 });
652 }
653
654 let mut measurements = Vec::new();
655 let mut prev_hashes = collect_file_hashes(repo_root, &commits[0], "crates/")?;
656
657 for i in 1..commits.len() {
658 let curr_hashes = collect_file_hashes(repo_root, &commits[i], "crates/")?;
659
660 let all_files: std::collections::HashSet<&String> =
662 prev_hashes.keys().chain(curr_hashes.keys()).collect();
663 let file_total = all_files.len();
664 let file_unchanged = all_files
665 .iter()
666 .filter(|f| {
667 prev_hashes.get(**f) == curr_hashes.get(**f)
668 && prev_hashes.contains_key(**f)
669 && curr_hashes.contains_key(**f)
670 })
671 .count();
672 let file_hit_rate = if file_total > 0 {
673 file_unchanged as f64 / file_total as f64
674 } else {
675 1.0
676 };
677
678 let mut directly_dirty = std::collections::HashSet::new();
680 for f in &all_files {
681 let old = prev_hashes.get(*f);
682 let new = curr_hashes.get(*f);
683 if (old != new || old.is_none() || new.is_none())
684 && let Some(crate_name) = crate_from_path(f)
685 {
686 directly_dirty.insert(crate_name.to_string());
687 }
688 }
689
690 let crate_clean = total_crates - directly_dirty.len();
691 let crate_hit_rate = crate_clean as f64 / total_crates as f64;
692
693 let all_dirty = crate_deps.transitive_dirty(&directly_dirty);
695 let dep_clean = total_crates - all_dirty.len();
696 let dep_aware_hit_rate = dep_clean as f64 / total_crates as f64;
697
698 measurements.push(DepAwareMeasurement {
699 from_commit: commits[i][..8].to_string(),
700 to_commit: commits[i - 1][..8].to_string(),
701 total_crates,
702 directly_dirty: directly_dirty.len(),
703 transitively_dirty: all_dirty.len(),
704 clean_crates: dep_clean,
705 file_hit_rate,
706 crate_hit_rate,
707 dep_aware_hit_rate,
708 });
709
710 prev_hashes = curr_hashes;
711 }
712
713 Ok(DependencyAwareReport { measurements })
714}
715
716#[cfg(test)]
719mod tests {
720 use super::*;
721
722 fn make_entry(hash: &str, kind: &str, size: u64) -> CacheEntry {
723 CacheEntry {
724 body_hash: hash.to_string(),
725 artifact_kind: kind.to_string(),
726 artifact_size: size,
727 compile_time_ns: 50_000_000, created_at: 1710700000000,
729 }
730 }
731
732 #[test]
733 fn test_cache_put_get() {
734 let mut cache = BuildCache::new();
735 assert!(cache.is_empty());
736
737 cache.put(make_entry("abc123", "rlib", 4096));
738 assert_eq!(cache.len(), 1);
739
740 let entry = cache.get("abc123");
742 assert!(entry.is_some());
743 assert_eq!(entry.unwrap().artifact_size, 4096);
744 assert_eq!(cache.stats().hits, 1);
745 assert_eq!(cache.stats().misses, 0);
746
747 let miss = cache.get("nonexistent");
749 assert!(miss.is_none());
750 assert_eq!(cache.stats().hits, 1);
751 assert_eq!(cache.stats().misses, 1);
752 assert!((cache.stats().hit_rate() - 0.5).abs() < f64::EPSILON);
753 }
754
755 #[test]
756 fn test_cache_overwrite() {
757 let mut cache = BuildCache::new();
758 cache.put(make_entry("abc123", "rlib", 4096));
759 cache.put(make_entry("abc123", "rlib", 8192)); assert_eq!(cache.len(), 1);
761
762 let entry = cache.get("abc123").unwrap();
763 assert_eq!(entry.artifact_size, 8192);
764 }
765
766 #[test]
767 fn test_arrow_round_trip() {
768 let mut cache = BuildCache::new();
769 cache.put(make_entry("hash_a", "rlib", 1024));
770 cache.put(make_entry("hash_b", "rmeta", 2048));
771 cache.put(make_entry("hash_c", "wasm", 512));
772
773 let batch = cache.to_record_batch().unwrap();
774 assert_eq!(batch.num_rows(), 3);
775 assert_eq!(batch.num_columns(), 5);
776
777 let restored = BuildCache::from_record_batch(&batch).unwrap();
778 assert_eq!(restored.len(), 3);
779
780 let mut restored = restored;
782 assert!(restored.get("hash_a").is_some());
783 assert!(restored.get("hash_b").is_some());
784 assert!(restored.get("hash_c").is_some());
785 assert_eq!(restored.get("hash_a").unwrap().artifact_size, 1024);
786 }
787
788 #[test]
789 fn test_parquet_round_trip() {
790 let mut cache = BuildCache::new();
791 cache.put(make_entry("hash_x", "rlib", 9999));
792 cache.put(make_entry("hash_y", "wasm", 1111));
793
794 let dir = tempfile::tempdir().unwrap();
795 let path = dir.path().join("cache.parquet");
796
797 cache.save_to_parquet(&path).unwrap();
798 assert!(path.exists());
799
800 let mut loaded = BuildCache::load_from_parquet(&path).unwrap();
801 assert_eq!(loaded.len(), 2);
802 assert_eq!(loaded.get("hash_x").unwrap().artifact_size, 9999);
803 assert_eq!(loaded.get("hash_y").unwrap().artifact_size, 1111);
804 }
805
806 #[test]
807 fn test_empty_cache_operations() {
808 let mut cache = BuildCache::new();
809 assert!(cache.is_empty());
810 assert!(cache.get("anything").is_none());
811 assert_eq!(cache.stats().hit_rate(), 0.0);
812
813 let batch = cache.to_record_batch().unwrap();
814 assert_eq!(batch.num_rows(), 0);
815 }
816
817 #[test]
818 fn test_stats_display() {
819 let stats = CacheStats {
820 hits: 90,
821 misses: 10,
822 total_artifact_bytes: 1024 * 1024,
823 total_compile_time_saved_ns: 5_000_000_000,
824 };
825 let display = format!("{}", stats);
826 assert!(display.contains("90.0%"));
827 assert!(display.contains("5000.00ms"));
828 }
829
830 #[test]
831 fn test_stability_report_summary() {
832 let report = HashStabilityReport {
833 measurements: vec![
834 CommitPairMeasurement {
835 from_commit: "aaa".into(),
836 to_commit: "bbb".into(),
837 total_files: 100,
838 unchanged_files: 95,
839 changed_files: 5,
840 cache_hit_rate: 0.95,
841 },
842 CommitPairMeasurement {
843 from_commit: "bbb".into(),
844 to_commit: "ccc".into(),
845 total_files: 100,
846 unchanged_files: 98,
847 changed_files: 2,
848 cache_hit_rate: 0.98,
849 },
850 ],
851 };
852 assert!((report.avg_hit_rate() - 0.965).abs() < 0.001);
853 assert!((report.min_hit_rate() - 0.95).abs() < f64::EPSILON);
854 assert!((report.max_hit_rate() - 0.98).abs() < f64::EPSILON);
855 let summary = report.summary();
856 assert!(summary.contains("PASS"));
857 }
858
859 #[test]
864 fn test_real_codebase_hash_stability() {
865 let output = std::process::Command::new("git")
867 .args(["rev-parse", "--show-toplevel"])
868 .output();
869 let repo_root = match output {
870 Ok(o) if o.status.success() => {
871 std::path::PathBuf::from(String::from_utf8_lossy(&o.stdout).trim())
872 }
873 _ => {
874 eprintln!("Skipping: not in a git repo");
875 return;
876 }
877 };
878
879 let report = measure_hash_stability(&repo_root, "crates/", 30).unwrap();
881
882 if report.measurements.is_empty() {
883 eprintln!("Skipping: not enough git history");
884 return;
885 }
886
887 println!("\n{}", report.summary());
889 println!("\nPer-commit breakdown:");
890 for m in &report.measurements {
891 println!(
892 " {} → {}: {}/{} unchanged ({:.1}%)",
893 m.from_commit,
894 m.to_commit,
895 m.unchanged_files,
896 m.total_files,
897 m.cache_hit_rate * 100.0,
898 );
899 }
900
901 println!(
903 "\nGo signal (avg > 90%): {:.1}% — {}",
904 report.avg_hit_rate() * 100.0,
905 if report.avg_hit_rate() > 0.90 {
906 "PASS"
907 } else {
908 "FAIL"
909 }
910 );
911 }
912
913 #[test]
914 fn test_transitive_dirty_propagation() {
915 let deps = CrateDeps::nusy_workspace();
916
917 let mut dirty = std::collections::HashSet::new();
920 dirty.insert("nusy-arrow-core".to_string());
921
922 let all_dirty = deps.transitive_dirty(&dirty);
923
924 assert!(all_dirty.contains("nusy-arrow-core"));
926 assert!(all_dirty.contains("nusy-arrow-git"));
927 assert!(all_dirty.contains("nusy-codegraph"));
928 assert!(all_dirty.contains("nusy-kanban"));
929 assert!(all_dirty.contains("nusy-kanban-server")); assert!(!all_dirty.contains("nusy-conductor")); assert!(!all_dirty.contains("nusy-reasoning-causal")); }
935
936 #[test]
937 fn test_leaf_crate_dirty_no_propagation() {
938 let deps = CrateDeps::nusy_workspace();
939
940 let mut dirty = std::collections::HashSet::new();
942 dirty.insert("nusy-reasoning-causal".to_string());
943
944 let all_dirty = deps.transitive_dirty(&dirty);
945 assert_eq!(all_dirty.len(), 1);
946 assert!(all_dirty.contains("nusy-reasoning-causal"));
947 }
948
949 #[test]
954 fn test_real_codebase_dep_aware_stability() {
955 let output = std::process::Command::new("git")
956 .args(["rev-parse", "--show-toplevel"])
957 .output();
958 let repo_root = match output {
959 Ok(o) if o.status.success() => {
960 std::path::PathBuf::from(String::from_utf8_lossy(&o.stdout).trim())
961 }
962 _ => {
963 eprintln!("Skipping: not in a git repo");
964 return;
965 }
966 };
967
968 let report = measure_dep_aware_stability(&repo_root, 30).unwrap();
969
970 if report.measurements.is_empty() {
971 eprintln!("Skipping: not enough git history");
972 return;
973 }
974
975 println!("\n{}", report.summary());
976 println!("\nPer-commit breakdown:");
977 for m in &report.measurements {
978 println!(
979 " {} → {}: files={:.1}% crate={:.1}% dep-aware={:.1}% (dirty: {} direct, {} transitive of {} crates)",
980 m.from_commit,
981 m.to_commit,
982 m.file_hit_rate * 100.0,
983 m.crate_hit_rate * 100.0,
984 m.dep_aware_hit_rate * 100.0,
985 m.directly_dirty,
986 m.transitively_dirty,
987 m.total_crates,
988 );
989 }
990
991 println!(
992 "\nDep-aware go signal (avg > 90%): {:.1}% — {}",
993 report.avg_dep_aware_hit_rate() * 100.0,
994 if report.avg_dep_aware_hit_rate() > 0.90 {
995 "PASS"
996 } else {
997 "NEEDS ANALYSIS"
998 }
999 );
1000 }
1001}