Skip to main content

perfgate_host_detect/
lib.rs

1//! Host mismatch detection for benchmarking noise reduction.
2//!
3//! This crate provides detection of host environment differences between
4//! baseline and current benchmark runs. Host mismatches can introduce
5//! significant noise into performance measurements, leading to false
6//! positives or negatives in regression detection.
7//!
8//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
9//!
10//! # Example
11//!
12//! ```
13//! use perfgate_host_detect::detect_host_mismatch;
14//! use perfgate_types::HostInfo;
15//!
16//! let baseline = HostInfo {
17//!     os: "linux".to_string(),
18//!     arch: "x86_64".to_string(),
19//!     cpu_count: Some(8),
20//!     memory_bytes: Some(16 * 1024 * 1024 * 1024),
21//!     hostname_hash: Some("abc123".to_string()),
22//! };
23//!
24//! let current = HostInfo {
25//!     os: "linux".to_string(),
26//!     arch: "x86_64".to_string(),
27//!     cpu_count: Some(8),
28//!     memory_bytes: Some(16 * 1024 * 1024 * 1024),
29//!     hostname_hash: Some("abc123".to_string()),
30//! };
31//!
32//! assert!(detect_host_mismatch(&baseline, &current).is_none());
33//! ```
34//!
35//! # Detection Criteria
36//!
37//! The function detects mismatches based on:
38//!
39//! - **OS mismatch**: Different operating systems (e.g., `linux` vs `windows`)
40//! - **Architecture mismatch**: Different CPU architectures (e.g., `x86_64` vs `aarch64`)
41//! - **CPU count**: Significant difference (> 2x) in logical CPU count
42//! - **Memory**: Significant difference (> 2x) in total system memory
43//! - **Hostname hash**: Different hashed hostnames (different machines)
44//!
45//! The 2x threshold for CPU and memory is chosen to avoid false positives
46//! from minor variations (e.g., 8 vs 10 CPUs) while catching significant
47//! differences (e.g., 4 vs 16 CPUs) that could affect benchmark results.
48
49use perfgate_types::{HostInfo, HostMismatchInfo};
50
51/// Detect host mismatches between baseline and current runs.
52///
53/// Returns `Some(HostMismatchInfo)` if any mismatch is detected, `None` otherwise.
54///
55/// # Detection Criteria
56///
57/// - Different `os` or `arch`
58/// - Significant difference in `cpu_count` (> 2x)
59/// - Significant difference in `memory_bytes` (> 2x)
60/// - Different `hostname_hash` (if both present)
61///
62/// # Examples
63///
64/// Detect an OS mismatch (e.g., running benchmarks on a different platform):
65///
66/// ```
67/// use perfgate_host_detect::detect_host_mismatch;
68/// use perfgate_types::HostInfo;
69///
70/// let baseline = HostInfo {
71///     os: "linux".to_string(),
72///     arch: "x86_64".to_string(),
73///     cpu_count: None,
74///     memory_bytes: None,
75///     hostname_hash: None,
76/// };
77///
78/// let current = HostInfo {
79///     os: "windows".to_string(),
80///     arch: "x86_64".to_string(),
81///     cpu_count: None,
82///     memory_bytes: None,
83///     hostname_hash: None,
84/// };
85///
86/// let mismatch = detect_host_mismatch(&baseline, &current);
87/// assert!(mismatch.is_some());
88/// assert!(mismatch.unwrap().reasons[0].contains("OS mismatch"));
89/// ```
90///
91/// Detect an architecture mismatch (e.g., `x86_64` vs `aarch64`):
92///
93/// ```
94/// # use perfgate_host_detect::detect_host_mismatch;
95/// # use perfgate_types::HostInfo;
96/// let baseline = HostInfo {
97///     os: "linux".to_string(),
98///     arch: "x86_64".to_string(),
99///     cpu_count: None,
100///     memory_bytes: None,
101///     hostname_hash: None,
102/// };
103/// let current = HostInfo {
104///     os: "linux".to_string(),
105///     arch: "aarch64".to_string(),
106///     cpu_count: None,
107///     memory_bytes: None,
108///     hostname_hash: None,
109/// };
110///
111/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
112/// assert!(mismatch.reasons[0].contains("architecture mismatch"));
113/// ```
114///
115/// Detect significant CPU count differences (> 2x ratio indicates a
116/// different cloud instance type or machine class):
117///
118/// ```
119/// # use perfgate_host_detect::detect_host_mismatch;
120/// # use perfgate_types::HostInfo;
121/// let baseline = HostInfo {
122///     os: "linux".to_string(),
123///     arch: "x86_64".to_string(),
124///     cpu_count: Some(4),
125///     memory_bytes: None,
126///     hostname_hash: None,
127/// };
128/// let current = HostInfo {
129///     os: "linux".to_string(),
130///     arch: "x86_64".to_string(),
131///     cpu_count: Some(32),
132///     memory_bytes: None,
133///     hostname_hash: None,
134/// };
135///
136/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
137/// assert!(mismatch.reasons[0].contains("CPU count differs"));
138/// ```
139///
140/// Minor CPU differences (≤ 2x) are ignored to reduce false positives:
141///
142/// ```
143/// # use perfgate_host_detect::detect_host_mismatch;
144/// # use perfgate_types::HostInfo;
145/// let baseline = HostInfo {
146///     os: "linux".to_string(),
147///     arch: "x86_64".to_string(),
148///     cpu_count: Some(8),
149///     memory_bytes: None,
150///     hostname_hash: None,
151/// };
152/// let current = HostInfo {
153///     os: "linux".to_string(),
154///     arch: "x86_64".to_string(),
155///     cpu_count: Some(16),
156///     memory_bytes: None,
157///     hostname_hash: None,
158/// };
159///
160/// // Exactly 2x is still within tolerance
161/// assert!(detect_host_mismatch(&baseline, &current).is_none());
162/// ```
163///
164/// Detect significant memory differences (different cloud instance sizes):
165///
166/// ```
167/// # use perfgate_host_detect::detect_host_mismatch;
168/// # use perfgate_types::HostInfo;
169/// let baseline = HostInfo {
170///     os: "linux".to_string(),
171///     arch: "x86_64".to_string(),
172///     cpu_count: None,
173///     memory_bytes: Some(8 * 1024 * 1024 * 1024),   // 8 GB
174///     hostname_hash: None,
175/// };
176/// let current = HostInfo {
177///     os: "linux".to_string(),
178///     arch: "x86_64".to_string(),
179///     cpu_count: None,
180///     memory_bytes: Some(64 * 1024 * 1024 * 1024),  // 64 GB
181///     hostname_hash: None,
182/// };
183///
184/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
185/// assert!(mismatch.reasons[0].contains("memory differs"));
186/// ```
187///
188/// Detect hostname hash mismatch (benchmarks ran on different machines):
189///
190/// ```
191/// # use perfgate_host_detect::detect_host_mismatch;
192/// # use perfgate_types::HostInfo;
193/// let baseline = HostInfo {
194///     os: "linux".to_string(),
195///     arch: "x86_64".to_string(),
196///     cpu_count: None,
197///     memory_bytes: None,
198///     hostname_hash: Some("abc123".to_string()),
199/// };
200/// let current = HostInfo {
201///     os: "linux".to_string(),
202///     arch: "x86_64".to_string(),
203///     cpu_count: None,
204///     memory_bytes: None,
205///     hostname_hash: Some("def456".to_string()),
206/// };
207///
208/// let mismatch = detect_host_mismatch(&baseline, &current).unwrap();
209/// assert!(mismatch.reasons[0].contains("hostname mismatch"));
210/// ```
211///
212/// Optional fields that are `None` on either side are silently skipped:
213///
214/// ```
215/// # use perfgate_host_detect::detect_host_mismatch;
216/// # use perfgate_types::HostInfo;
217/// let baseline = HostInfo {
218///     os: "linux".to_string(),
219///     arch: "x86_64".to_string(),
220///     cpu_count: Some(4),
221///     memory_bytes: Some(16 * 1024 * 1024 * 1024),
222///     hostname_hash: Some("abc".to_string()),
223/// };
224/// let current = HostInfo {
225///     os: "linux".to_string(),
226///     arch: "x86_64".to_string(),
227///     cpu_count: None,   // unknown — skipped
228///     memory_bytes: None, // unknown — skipped
229///     hostname_hash: None, // unknown — skipped
230/// };
231///
232/// assert!(detect_host_mismatch(&baseline, &current).is_none());
233/// ```
234pub fn detect_host_mismatch(baseline: &HostInfo, current: &HostInfo) -> Option<HostMismatchInfo> {
235    let mut reasons = Vec::new();
236
237    if baseline.os != current.os {
238        reasons.push(format!(
239            "OS mismatch: baseline={}, current={}",
240            baseline.os, current.os
241        ));
242    }
243
244    if baseline.arch != current.arch {
245        reasons.push(format!(
246            "architecture mismatch: baseline={}, current={}",
247            baseline.arch, current.arch
248        ));
249    }
250
251    if let (Some(base_cpu), Some(curr_cpu)) = (baseline.cpu_count, current.cpu_count) {
252        let ratio = if base_cpu > 0 && curr_cpu > 0 {
253            (base_cpu as f64 / curr_cpu as f64).max(curr_cpu as f64 / base_cpu as f64)
254        } else {
255            1.0
256        };
257        if ratio > 2.0 {
258            reasons.push(format!(
259                "CPU count differs significantly: baseline={}, current={} ({:.1}x)",
260                base_cpu, curr_cpu, ratio
261            ));
262        }
263    }
264
265    if let (Some(base_mem), Some(curr_mem)) = (baseline.memory_bytes, current.memory_bytes) {
266        let ratio = if base_mem > 0 && curr_mem > 0 {
267            (base_mem as f64 / curr_mem as f64).max(curr_mem as f64 / base_mem as f64)
268        } else {
269            1.0
270        };
271        if ratio > 2.0 {
272            let base_gb = base_mem as f64 / (1024.0 * 1024.0 * 1024.0);
273            let curr_gb = curr_mem as f64 / (1024.0 * 1024.0 * 1024.0);
274            reasons.push(format!(
275                "memory differs significantly: baseline={:.1}GB, current={:.1}GB ({:.1}x)",
276                base_gb, curr_gb, ratio
277            ));
278        }
279    }
280
281    if let (Some(base_hash), Some(curr_hash)) = (&baseline.hostname_hash, &current.hostname_hash)
282        && base_hash != curr_hash
283    {
284        reasons.push("hostname mismatch (different machines)".to_string());
285    }
286
287    if reasons.is_empty() {
288        None
289    } else {
290        Some(HostMismatchInfo { reasons })
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn make_host_info(os: &str, arch: &str) -> HostInfo {
299        HostInfo {
300            os: os.to_string(),
301            arch: arch.to_string(),
302            cpu_count: None,
303            memory_bytes: None,
304            hostname_hash: None,
305        }
306    }
307
308    #[test]
309    fn no_mismatch_when_identical() {
310        let baseline = make_host_info("linux", "x86_64");
311        let current = make_host_info("linux", "x86_64");
312        assert!(detect_host_mismatch(&baseline, &current).is_none());
313    }
314
315    #[test]
316    fn detects_os_mismatch() {
317        let baseline = make_host_info("linux", "x86_64");
318        let current = make_host_info("windows", "x86_64");
319        let mismatch = detect_host_mismatch(&baseline, &current);
320        assert!(mismatch.is_some());
321        let reasons = mismatch.unwrap().reasons;
322        assert!(reasons.iter().any(|r| r.contains("OS mismatch")));
323        assert!(reasons.iter().any(|r| r.contains("baseline=linux")));
324        assert!(reasons.iter().any(|r| r.contains("current=windows")));
325    }
326
327    #[test]
328    fn detects_arch_mismatch() {
329        let baseline = make_host_info("linux", "x86_64");
330        let current = make_host_info("linux", "aarch64");
331        let mismatch = detect_host_mismatch(&baseline, &current);
332        assert!(mismatch.is_some());
333        let reasons = mismatch.unwrap().reasons;
334        assert!(reasons.iter().any(|r| r.contains("architecture mismatch")));
335        assert!(reasons.iter().any(|r| r.contains("baseline=x86_64")));
336        assert!(reasons.iter().any(|r| r.contains("current=aarch64")));
337    }
338
339    #[test]
340    fn detects_cpu_count_significant_difference() {
341        let mut baseline = make_host_info("linux", "x86_64");
342        let mut current = make_host_info("linux", "x86_64");
343        baseline.cpu_count = Some(4);
344        current.cpu_count = Some(16);
345        let mismatch = detect_host_mismatch(&baseline, &current);
346        assert!(mismatch.is_some());
347        let reasons = mismatch.unwrap().reasons;
348        assert!(reasons.iter().any(|r| r.contains("CPU count differs")));
349        assert!(reasons.iter().any(|r| r.contains("4.0x")));
350    }
351
352    #[test]
353    fn ignores_cpu_count_minor_difference() {
354        let mut baseline = make_host_info("linux", "x86_64");
355        let mut current = make_host_info("linux", "x86_64");
356        baseline.cpu_count = Some(8);
357        current.cpu_count = Some(12);
358        let mismatch = detect_host_mismatch(&baseline, &current);
359        assert!(mismatch.is_none());
360    }
361
362    #[test]
363    fn cpu_count_at_exact_2x_threshold_is_not_mismatch() {
364        let mut baseline = make_host_info("linux", "x86_64");
365        let mut current = make_host_info("linux", "x86_64");
366        baseline.cpu_count = Some(4);
367        current.cpu_count = Some(8);
368        let mismatch = detect_host_mismatch(&baseline, &current);
369        assert!(mismatch.is_none());
370    }
371
372    #[test]
373    fn cpu_count_just_over_2x_is_mismatch() {
374        let mut baseline = make_host_info("linux", "x86_64");
375        let mut current = make_host_info("linux", "x86_64");
376        baseline.cpu_count = Some(4);
377        current.cpu_count = Some(9);
378        let mismatch = detect_host_mismatch(&baseline, &current);
379        assert!(mismatch.is_some());
380        let reasons = mismatch.unwrap().reasons;
381        assert!(reasons.iter().any(|r| r.contains("CPU count differs")));
382    }
383
384    #[test]
385    fn detects_memory_significant_difference() {
386        let mut baseline = make_host_info("linux", "x86_64");
387        let mut current = make_host_info("linux", "x86_64");
388        baseline.memory_bytes = Some(8 * 1024 * 1024 * 1024);
389        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
390        let mismatch = detect_host_mismatch(&baseline, &current);
391        assert!(mismatch.is_some());
392        let reasons = mismatch.unwrap().reasons;
393        assert!(reasons.iter().any(|r| r.contains("memory differs")));
394        assert!(reasons.iter().any(|r| r.contains("8.0GB")));
395        assert!(reasons.iter().any(|r| r.contains("32.0GB")));
396    }
397
398    #[test]
399    fn ignores_memory_minor_difference() {
400        let mut baseline = make_host_info("linux", "x86_64");
401        let mut current = make_host_info("linux", "x86_64");
402        baseline.memory_bytes = Some(16 * 1024 * 1024 * 1024);
403        current.memory_bytes = Some(24 * 1024 * 1024 * 1024);
404        let mismatch = detect_host_mismatch(&baseline, &current);
405        assert!(mismatch.is_none());
406    }
407
408    #[test]
409    fn memory_at_exact_2x_threshold_is_not_mismatch() {
410        let mut baseline = make_host_info("linux", "x86_64");
411        let mut current = make_host_info("linux", "x86_64");
412        baseline.memory_bytes = Some(8 * 1024 * 1024 * 1024);
413        current.memory_bytes = Some(16 * 1024 * 1024 * 1024);
414        let mismatch = detect_host_mismatch(&baseline, &current);
415        assert!(mismatch.is_none());
416    }
417
418    #[test]
419    fn detects_hostname_difference() {
420        let mut baseline = make_host_info("linux", "x86_64");
421        let mut current = make_host_info("linux", "x86_64");
422        baseline.hostname_hash = Some("abc123".to_string());
423        current.hostname_hash = Some("def456".to_string());
424        let mismatch = detect_host_mismatch(&baseline, &current);
425        assert!(mismatch.is_some());
426        let reasons = mismatch.unwrap().reasons;
427        assert!(reasons.iter().any(|r| r.contains("hostname mismatch")));
428    }
429
430    #[test]
431    fn ignores_hostname_when_only_baseline_has_it() {
432        let mut baseline = make_host_info("linux", "x86_64");
433        let current = make_host_info("linux", "x86_64");
434        baseline.hostname_hash = Some("abc123".to_string());
435        let mismatch = detect_host_mismatch(&baseline, &current);
436        assert!(mismatch.is_none());
437    }
438
439    #[test]
440    fn ignores_hostname_when_only_current_has_it() {
441        let baseline = make_host_info("linux", "x86_64");
442        let mut current = make_host_info("linux", "x86_64");
443        current.hostname_hash = Some("def456".to_string());
444        let mismatch = detect_host_mismatch(&baseline, &current);
445        assert!(mismatch.is_none());
446    }
447
448    #[test]
449    fn ignores_hostname_when_both_are_none() {
450        let baseline = make_host_info("linux", "x86_64");
451        let current = make_host_info("linux", "x86_64");
452        let mismatch = detect_host_mismatch(&baseline, &current);
453        assert!(mismatch.is_none());
454    }
455
456    #[test]
457    fn same_hostname_hash_is_not_mismatch() {
458        let mut baseline = make_host_info("linux", "x86_64");
459        let mut current = make_host_info("linux", "x86_64");
460        baseline.hostname_hash = Some("abc123".to_string());
461        current.hostname_hash = Some("abc123".to_string());
462        let mismatch = detect_host_mismatch(&baseline, &current);
463        assert!(mismatch.is_none());
464    }
465
466    #[test]
467    fn detects_multiple_simultaneous_mismatches() {
468        let baseline = HostInfo {
469            os: "linux".to_string(),
470            arch: "x86_64".to_string(),
471            cpu_count: Some(4),
472            memory_bytes: Some(8 * 1024 * 1024 * 1024),
473            hostname_hash: Some("abc".to_string()),
474        };
475        let current = HostInfo {
476            os: "windows".to_string(),
477            arch: "aarch64".to_string(),
478            cpu_count: Some(32),
479            memory_bytes: Some(64 * 1024 * 1024 * 1024),
480            hostname_hash: Some("def".to_string()),
481        };
482        let mismatch = detect_host_mismatch(&baseline, &current);
483        assert!(mismatch.is_some());
484        let reasons = mismatch.unwrap().reasons;
485        assert_eq!(reasons.len(), 5);
486    }
487
488    #[test]
489    fn partial_fields_none_handling_cpu() {
490        let mut baseline = make_host_info("linux", "x86_64");
491        let mut current = make_host_info("linux", "x86_64");
492        baseline.cpu_count = Some(4);
493        current.cpu_count = None;
494        let mismatch = detect_host_mismatch(&baseline, &current);
495        assert!(mismatch.is_none());
496    }
497
498    #[test]
499    fn partial_fields_none_handling_memory() {
500        let mut baseline = make_host_info("linux", "x86_64");
501        let mut current = make_host_info("linux", "x86_64");
502        baseline.memory_bytes = None;
503        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
504        let mismatch = detect_host_mismatch(&baseline, &current);
505        assert!(mismatch.is_none());
506    }
507
508    #[test]
509    fn zero_cpu_count_is_handled_gracefully() {
510        let mut baseline = make_host_info("linux", "x86_64");
511        let mut current = make_host_info("linux", "x86_64");
512        baseline.cpu_count = Some(0);
513        current.cpu_count = Some(8);
514        let mismatch = detect_host_mismatch(&baseline, &current);
515        assert!(mismatch.is_none());
516    }
517
518    #[test]
519    fn zero_memory_is_handled_gracefully() {
520        let mut baseline = make_host_info("linux", "x86_64");
521        let mut current = make_host_info("linux", "x86_64");
522        baseline.memory_bytes = Some(0);
523        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
524        let mismatch = detect_host_mismatch(&baseline, &current);
525        assert!(mismatch.is_none());
526    }
527
528    #[test]
529    fn cpu_count_ratio_works_both_directions() {
530        let mut baseline = make_host_info("linux", "x86_64");
531        let mut current = make_host_info("linux", "x86_64");
532
533        baseline.cpu_count = Some(16);
534        current.cpu_count = Some(4);
535        let mismatch = detect_host_mismatch(&baseline, &current);
536        assert!(mismatch.is_some());
537    }
538
539    #[test]
540    fn memory_ratio_works_both_directions() {
541        let mut baseline = make_host_info("linux", "x86_64");
542        let mut current = make_host_info("linux", "x86_64");
543
544        baseline.memory_bytes = Some(64 * 1024 * 1024 * 1024);
545        current.memory_bytes = Some(8 * 1024 * 1024 * 1024);
546        let mismatch = detect_host_mismatch(&baseline, &current);
547        assert!(mismatch.is_some());
548    }
549
550    #[test]
551    fn identical_fully_populated_no_mismatch() {
552        let host = HostInfo {
553            os: "linux".to_string(),
554            arch: "x86_64".to_string(),
555            cpu_count: Some(8),
556            memory_bytes: Some(16 * 1024 * 1024 * 1024),
557            hostname_hash: Some("abc123def456".to_string()),
558        };
559        assert!(detect_host_mismatch(&host, &host.clone()).is_none());
560    }
561
562    #[test]
563    fn equal_cpu_count_no_mismatch() {
564        let mut baseline = make_host_info("linux", "x86_64");
565        let mut current = make_host_info("linux", "x86_64");
566        baseline.cpu_count = Some(16);
567        current.cpu_count = Some(16);
568        assert!(detect_host_mismatch(&baseline, &current).is_none());
569    }
570
571    #[test]
572    fn equal_memory_no_mismatch() {
573        let mut baseline = make_host_info("linux", "x86_64");
574        let mut current = make_host_info("linux", "x86_64");
575        baseline.memory_bytes = Some(32 * 1024 * 1024 * 1024);
576        current.memory_bytes = Some(32 * 1024 * 1024 * 1024);
577        assert!(detect_host_mismatch(&baseline, &current).is_none());
578    }
579
580    #[test]
581    fn os_mismatch_reason_contains_both_values() {
582        let baseline = make_host_info("macos", "x86_64");
583        let current = make_host_info("linux", "x86_64");
584        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
585        assert_eq!(reasons.len(), 1);
586        assert!(reasons[0].contains("macos"));
587        assert!(reasons[0].contains("linux"));
588    }
589
590    #[test]
591    fn arch_mismatch_reason_contains_both_values() {
592        let baseline = make_host_info("linux", "arm64");
593        let current = make_host_info("linux", "x86_64");
594        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
595        assert_eq!(reasons.len(), 1);
596        assert!(reasons[0].contains("arm64"));
597        assert!(reasons[0].contains("x86_64"));
598    }
599
600    #[test]
601    fn cpu_mismatch_reason_contains_counts_and_ratio() {
602        let mut baseline = make_host_info("linux", "x86_64");
603        let mut current = make_host_info("linux", "x86_64");
604        baseline.cpu_count = Some(2);
605        current.cpu_count = Some(8);
606        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
607        assert!(reasons[0].contains("baseline=2"));
608        assert!(reasons[0].contains("current=8"));
609        assert!(reasons[0].contains("4.0x"));
610    }
611
612    #[test]
613    fn hostname_mismatch_only_one_reason() {
614        let mut baseline = make_host_info("linux", "x86_64");
615        let mut current = make_host_info("linux", "x86_64");
616        baseline.hostname_hash = Some("aaa".to_string());
617        current.hostname_hash = Some("bbb".to_string());
618        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
619        assert_eq!(reasons.len(), 1);
620        assert!(reasons[0].contains("hostname mismatch"));
621    }
622
623    #[test]
624    fn multiple_mismatches_os_and_arch() {
625        let baseline = make_host_info("linux", "x86_64");
626        let current = make_host_info("windows", "aarch64");
627        let reasons = detect_host_mismatch(&baseline, &current).unwrap().reasons;
628        assert_eq!(reasons.len(), 2);
629        assert!(reasons.iter().any(|r| r.contains("OS mismatch")));
630        assert!(reasons.iter().any(|r| r.contains("architecture mismatch")));
631    }
632
633    #[test]
634    fn all_none_optional_fields_no_mismatch() {
635        let baseline = HostInfo {
636            os: "linux".to_string(),
637            arch: "x86_64".to_string(),
638            cpu_count: None,
639            memory_bytes: None,
640            hostname_hash: None,
641        };
642        let current = baseline.clone();
643        assert!(detect_host_mismatch(&baseline, &current).is_none());
644    }
645
646    #[test]
647    fn both_zero_cpu_and_zero_memory_no_mismatch() {
648        let mut baseline = make_host_info("linux", "x86_64");
649        let mut current = make_host_info("linux", "x86_64");
650        baseline.cpu_count = Some(0);
651        current.cpu_count = Some(0);
652        baseline.memory_bytes = Some(0);
653        current.memory_bytes = Some(0);
654        assert!(detect_host_mismatch(&baseline, &current).is_none());
655    }
656}
657
658#[cfg(test)]
659mod property_tests {
660    use super::*;
661    use proptest::prelude::*;
662
663    fn host_info_strategy() -> impl Strategy<Value = HostInfo> {
664        (
665            "[a-z]{3,10}",
666            "[a-z0-9_]{3,10}",
667            proptest::option::of(1u32..256u32),
668            proptest::option::of(1u64..68719476736u64),
669            proptest::option::of("[a-f0-9]{16}"),
670        )
671            .prop_map(
672                |(os, arch, cpu_count, memory_bytes, hostname_hash)| HostInfo {
673                    os,
674                    arch,
675                    cpu_count,
676                    memory_bytes,
677                    hostname_hash,
678                },
679            )
680    }
681
682    proptest! {
683        #[test]
684        fn idempotence_same_host_returns_none(host in host_info_strategy()) {
685            prop_assert!(detect_host_mismatch(&host, &host).is_none());
686        }
687
688        #[test]
689        fn symmetry_detect_a_b_implies_detect_b_a(
690            baseline in host_info_strategy(),
691            current in host_info_strategy()
692        ) {
693            let forward = detect_host_mismatch(&baseline, &current);
694            let reverse = detect_host_mismatch(&current, &baseline);
695
696            match (&forward, &reverse) {
697                (None, None) => prop_assert!(true),
698                (Some(f), Some(r)) => {
699                    prop_assert_eq!(f.reasons.len(), r.reasons.len());
700                }
701                _ => prop_assert!(false, "symmetry violated: forward={:?}, reverse={:?}", forward, reverse),
702            }
703        }
704
705        #[test]
706        fn os_difference_always_detected(
707            os1 in "[a-z]{3,10}",
708            os2 in "[a-z]{3,10}",
709            arch in "[a-z0-9_]{3,10}"
710        ) {
711            prop_assume!(os1 != os2);
712            let baseline = HostInfo {
713                os: os1.clone(),
714                arch: arch.clone(),
715                cpu_count: None,
716                memory_bytes: None,
717                hostname_hash: None,
718            };
719            let current = HostInfo {
720                os: os2.clone(),
721                arch,
722                cpu_count: None,
723                memory_bytes: None,
724                hostname_hash: None,
725            };
726            let mismatch = detect_host_mismatch(&baseline, &current);
727            prop_assert!(mismatch.is_some());
728            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("OS mismatch")));
729        }
730
731        #[test]
732        fn arch_difference_always_detected(
733            arch1 in "[a-z0-9_]{3,10}",
734            arch2 in "[a-z0-9_]{3,10}",
735            os in "[a-z]{3,10}"
736        ) {
737            prop_assume!(arch1 != arch2);
738            let baseline = HostInfo {
739                os: os.clone(),
740                arch: arch1.clone(),
741                cpu_count: None,
742                memory_bytes: None,
743                hostname_hash: None,
744            };
745            let current = HostInfo {
746                os,
747                arch: arch2.clone(),
748                cpu_count: None,
749                memory_bytes: None,
750                hostname_hash: None,
751            };
752            let mismatch = detect_host_mismatch(&baseline, &current);
753            prop_assert!(mismatch.is_some());
754            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("architecture mismatch")));
755        }
756
757        #[test]
758        fn cpu_count_2x_plus_1_always_detected(
759            small_cpu in 1u32..100u32,
760        ) {
761            let large_cpu = small_cpu * 2 + 1;
762            let mut baseline = HostInfo {
763                os: "linux".to_string(),
764                arch: "x86_64".to_string(),
765                cpu_count: Some(small_cpu),
766                memory_bytes: None,
767                hostname_hash: None,
768            };
769            let mut current = HostInfo {
770                os: "linux".to_string(),
771                arch: "x86_64".to_string(),
772                cpu_count: Some(large_cpu),
773                memory_bytes: None,
774                hostname_hash: None,
775            };
776
777            let mismatch_forward = detect_host_mismatch(&baseline, &current);
778            prop_assert!(mismatch_forward.is_some());
779
780            std::mem::swap(&mut baseline.cpu_count, &mut current.cpu_count);
781            let mismatch_reverse = detect_host_mismatch(&baseline, &current);
782            prop_assert!(mismatch_reverse.is_some());
783        }
784
785        #[test]
786        fn cpu_count_exact_2x_not_detected(
787            cpu in 1u32..100u32,
788        ) {
789            let baseline = HostInfo {
790                os: "linux".to_string(),
791                arch: "x86_64".to_string(),
792                cpu_count: Some(cpu),
793                memory_bytes: None,
794                hostname_hash: None,
795            };
796            let current = HostInfo {
797                os: "linux".to_string(),
798                arch: "x86_64".to_string(),
799                cpu_count: Some(cpu * 2),
800                memory_bytes: None,
801                hostname_hash: None,
802            };
803            let mismatch = detect_host_mismatch(&baseline, &current);
804            prop_assert!(mismatch.is_none());
805        }
806
807        #[test]
808        fn memory_2x_plus_1gb_always_detected(
809            small_mem_gb in 1u64..32u64,
810        ) {
811            let small_mem = small_mem_gb * 1024 * 1024 * 1024;
812            let large_mem = small_mem * 2 + (1024 * 1024 * 1024);
813            let mut baseline = HostInfo {
814                os: "linux".to_string(),
815                arch: "x86_64".to_string(),
816                cpu_count: None,
817                memory_bytes: Some(small_mem),
818                hostname_hash: None,
819            };
820            let mut current = HostInfo {
821                os: "linux".to_string(),
822                arch: "x86_64".to_string(),
823                cpu_count: None,
824                memory_bytes: Some(large_mem),
825                hostname_hash: None,
826            };
827
828            let mismatch_forward = detect_host_mismatch(&baseline, &current);
829            prop_assert!(mismatch_forward.is_some());
830
831            std::mem::swap(&mut baseline.memory_bytes, &mut current.memory_bytes);
832            let mismatch_reverse = detect_host_mismatch(&baseline, &current);
833            prop_assert!(mismatch_reverse.is_some());
834        }
835
836        #[test]
837        fn hostname_hash_difference_detected_when_both_present(
838            hash1 in "[a-f0-9]{16}",
839            hash2 in "[a-f0-9]{16}"
840        ) {
841            prop_assume!(hash1 != hash2);
842            let baseline = HostInfo {
843                os: "linux".to_string(),
844                arch: "x86_64".to_string(),
845                cpu_count: None,
846                memory_bytes: None,
847                hostname_hash: Some(hash1),
848            };
849            let current = HostInfo {
850                os: "linux".to_string(),
851                arch: "x86_64".to_string(),
852                cpu_count: None,
853                memory_bytes: None,
854                hostname_hash: Some(hash2),
855            };
856            let mismatch = detect_host_mismatch(&baseline, &current);
857            prop_assert!(mismatch.is_some());
858            prop_assert!(mismatch.unwrap().reasons.iter().any(|r| r.contains("hostname mismatch")));
859        }
860
861        #[test]
862        fn none_fields_do_not_cause_mismatch(
863            host in host_info_strategy()
864        ) {
865            let minimal = HostInfo {
866                os: host.os.clone(),
867                arch: host.arch.clone(),
868                cpu_count: None,
869                memory_bytes: None,
870                hostname_hash: None,
871            };
872            let mismatch = detect_host_mismatch(&host, &minimal);
873            prop_assert!(mismatch.is_none());
874        }
875    }
876}