Skip to main content

nusy_codegraph/
build_cache.rs

1//! Content-addressed build cache — V12-Spike-2.
2//!
3//! Maps `body_hash` (SHA-256 of source text) to compiled artifacts.
4//! If a CodeNode's body hasn't changed, its compiled output is reusable.
5//!
6//! # Architecture
7//!
8//! ```text
9//! CodeNode.body_hash ──► BuildCache lookup
10//!                            ├── hit  → return cached artifact (skip compilation)
11//!                            └── miss → compile → store artifact → return
12//! ```
13//!
14//! The cache is backed by an Arrow RecordBatch, persisted to Parquet.
15//! This is a spike — validating that content addressing gives >90% cache
16//! hits on real NuSy development before committing to the full V13 build.
17
18use 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
26// ─── Schema ─────────────────────────────────────────────────────────────────
27
28/// Column indices for the build artifacts table.
29pub 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
37/// Schema for the build artifacts cache table.
38pub 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// ─── Cache entry ────────────────────────────────────────────────────────────
53
54/// A single cached build artifact.
55#[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// ─── Stats ──────────────────────────────────────────────────────────────────
65
66/// Cache performance statistics.
67#[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
98// ─── BuildCache ─────────────────────────────────────────────────────────────
99
100/// Content-addressed build cache backed by Arrow.
101///
102/// Stores compiled artifacts keyed by the SHA-256 hash of the source body.
103/// Same body → same hash → same compiled output → cache hit.
104pub struct BuildCache {
105    entries: HashMap<String, CacheEntry>,
106    stats: CacheStats,
107}
108
109impl BuildCache {
110    /// Create an empty cache.
111    pub fn new() -> Self {
112        Self {
113            entries: HashMap::new(),
114            stats: CacheStats::default(),
115        }
116    }
117
118    /// Look up a cached artifact by body hash.
119    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    /// Store a compiled artifact in the cache.
131    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    /// Number of cached artifacts.
137    pub fn len(&self) -> usize {
138        self.entries.len()
139    }
140
141    /// Whether the cache is empty.
142    pub fn is_empty(&self) -> bool {
143        self.entries.is_empty()
144    }
145
146    /// Current cache statistics.
147    pub fn stats(&self) -> &CacheStats {
148        &self.stats
149    }
150
151    /// Remove a cached artifact by body hash. Returns true if found.
152    pub fn remove(&mut self, body_hash: &str) -> bool {
153        self.entries.remove(body_hash).is_some()
154    }
155
156    /// Reset statistics counters (keeps cached entries).
157    pub fn reset_stats(&mut self) {
158        self.stats = CacheStats::default();
159    }
160
161    /// Serialize cache to an Arrow RecordBatch.
162    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    /// Deserialize cache from an Arrow RecordBatch.
196    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    /// Save cache to a Parquet file.
242    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    /// Load cache from a Parquet file.
252    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// ─── Hash stability measurement ─────────────────────────────────────────────
275
276/// Result of measuring hash stability across git commits.
277#[derive(Debug, Clone)]
278pub struct HashStabilityReport {
279    /// Per-commit-pair measurements.
280    pub measurements: Vec<CommitPairMeasurement>,
281}
282
283/// Measurement between two consecutive commits.
284#[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    /// Average cache hit rate across all commit pairs.
296    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    /// Minimum cache hit rate observed.
305    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    /// Maximum cache hit rate observed.
313    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    /// Format as a human-readable report.
321    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
341/// Collect file hashes for a single git commit.
342///
343/// Returns a map of (file_path → blob_hash) for all .rs files under `prefix`.
344/// Uses `git ls-tree` which gives us the blob hash — this IS content addressing.
345pub 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        // Format: <mode> <type> <hash>\t<path>
368        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
377/// Get the last N commit hashes from git log.
378pub 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
399/// Measure hash stability across recent git commits.
400///
401/// For each consecutive commit pair, compares the set of .rs file blob hashes.
402/// Files with the same blob hash between commits = cache hits.
403pub 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    // Collect hashes for the most recent commit first
419    let mut prev_hashes = collect_file_hashes(repo_root, &commits[0], prefix)?;
420
421    // Walk backwards through history
422    for i in 1..commits.len() {
423        let curr_hashes = collect_file_hashes(repo_root, &commits[i], prefix)?;
424
425        // Compare: files present in BOTH commits with the same hash = unchanged
426        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
461// ─── Dependency-aware invalidation ───────────────────────────────────────────
462
463/// Crate dependency graph for transitive invalidation analysis.
464///
465/// When a file in crate A changes, crate A needs recompilation.
466/// If crate B depends on A, B also needs recompilation (its inputs changed).
467/// This propagates transitively through the dependency graph.
468pub struct CrateDeps {
469    /// Crate name → list of crate names it depends on.
470    pub deps: HashMap<String, Vec<String>>,
471}
472
473impl CrateDeps {
474    /// Build the NuSy workspace dependency graph.
475    ///
476    /// Hardcoded for the spike — a production version would parse Cargo.toml
477    /// or use `cargo metadata`.
478    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    /// Compute reverse dependencies: crate → set of crates that depend on it.
529    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    /// Given a set of directly dirty crates, compute the full set of crates
543    /// needing recompilation (transitive dependents).
544    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/// Result of dependency-aware cache analysis.
567#[derive(Debug, Clone)]
568pub struct DependencyAwareReport {
569    pub measurements: Vec<DepAwareMeasurement>,
570}
571
572/// Per-commit dependency-aware measurement.
573#[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    /// File-level hit rate (upper bound).
582    pub file_hit_rate: f64,
583    /// Crate-level hit rate (what cargo sees).
584    pub crate_hit_rate: f64,
585    /// Dependency-aware hit rate (the real number).
586    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
632/// Extract crate name from a file path like "crates/nusy-arrow-core/src/lib.rs".
633fn crate_from_path(path: &str) -> Option<&str> {
634    let path = path.strip_prefix("crates/")?;
635    path.split('/').next()
636}
637
638/// Measure dependency-aware cache hit rates across git history.
639pub 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        // File-level: count unchanged files
661        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        // Crate-level: which crates have ANY changed file?
679        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        // Dependency-aware: propagate dirtiness transitively
694        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// ─── Tests ──────────────────────────────────────────────────────────────────
717
718#[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, // 50ms
728            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        // Hit
741        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        // Miss
748        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)); // overwrite
760        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        // Verify all entries survived
781        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    /// Integration test: measure hash stability on the actual NuSy codebase.
860    ///
861    /// This is the core spike measurement. It walks real git history and
862    /// reports what cache hit rate we'd get with content-addressed builds.
863    #[test]
864    fn test_real_codebase_hash_stability() {
865        // Find repo root (we're in crates/nusy-codegraph/)
866        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        // Measure across last 30 commits
880        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        // Print detailed results
888        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        // The go signal: >90% average cache hit rate
902        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        // If nusy-arrow-core changes, everything that depends on it
918        // (transitively) should be dirty
919        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        // arrow-core is the root dep — almost everything depends on it
925        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")); // transitive via kanban
930
931        // These have no internal deps — should NOT be dirty
932        assert!(!all_dirty.contains("nusy-conductor")); // leaf, but kanban depends on it
933        assert!(!all_dirty.contains("nusy-reasoning-causal")); // true leaf
934    }
935
936    #[test]
937    fn test_leaf_crate_dirty_no_propagation() {
938        let deps = CrateDeps::nusy_workspace();
939
940        // If a leaf crate changes, only it should be dirty
941        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    /// Integration test: dependency-aware cache hit rates on real NuSy codebase.
950    ///
951    /// This is the REAL measurement — it accounts for transitive invalidation.
952    /// File-level hits are the upper bound; this is the actual number.
953    #[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}