1#![allow(dead_code)]
22#![allow(clippy::cast_precision_loss)]
23
24use std::collections::HashMap;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum SignatureType {
29 PerceptualVisual,
31 PerceptualAudio,
33 Cryptographic,
35 NeuralEmbedding,
37 Thumbnail,
39}
40
41impl SignatureType {
42 #[must_use]
44 pub const fn is_perceptual(self) -> bool {
45 matches!(
46 self,
47 Self::PerceptualVisual | Self::PerceptualAudio | Self::NeuralEmbedding
48 )
49 }
50
51 #[must_use]
53 pub const fn supports_exact_match(self) -> bool {
54 matches!(self, Self::Cryptographic)
55 }
56
57 #[must_use]
59 pub const fn label(self) -> &'static str {
60 match self {
61 Self::PerceptualVisual => "perceptual-visual",
62 Self::PerceptualAudio => "perceptual-audio",
63 Self::Cryptographic => "cryptographic",
64 Self::NeuralEmbedding => "neural-embedding",
65 Self::Thumbnail => "thumbnail",
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct ContentSignature {
73 pub asset_id: String,
75 pub sig_type: SignatureType,
77 pub data: Vec<u8>,
79 pub confidence: f64,
81}
82
83impl ContentSignature {
84 #[must_use]
86 pub fn new(
87 asset_id: impl Into<String>,
88 sig_type: SignatureType,
89 data: Vec<u8>,
90 confidence: f64,
91 ) -> Self {
92 Self {
93 asset_id: asset_id.into(),
94 sig_type,
95 data,
96 confidence,
97 }
98 }
99
100 #[must_use]
104 pub fn matches(&self, other: &Self, tolerance: u32) -> bool {
105 if self.sig_type != other.sig_type {
106 return false;
107 }
108 if self.data.len() != other.data.len() {
109 return false;
110 }
111 if self.sig_type.supports_exact_match() {
112 return self.data == other.data;
113 }
114 let diff: u32 = self
116 .data
117 .iter()
118 .zip(&other.data)
119 .map(|(a, b)| u32::from(*a != *b))
120 .sum();
121 diff <= tolerance
122 }
123
124 #[must_use]
126 pub fn data_len(&self) -> usize {
127 self.data.len()
128 }
129}
130
131#[derive(Debug, Default)]
133pub struct SignatureDatabase {
134 entries: HashMap<String, Vec<ContentSignature>>,
135}
136
137impl SignatureDatabase {
138 #[must_use]
140 pub fn new() -> Self {
141 Self::default()
142 }
143
144 pub fn store(&mut self, sig: ContentSignature) {
146 self.entries
147 .entry(sig.asset_id.clone())
148 .or_default()
149 .push(sig);
150 }
151
152 #[must_use]
154 pub fn lookup(&self, asset_id: &str) -> &[ContentSignature] {
155 self.entries.get(asset_id).map(Vec::as_slice).unwrap_or(&[])
156 }
157
158 #[must_use]
160 pub fn match_count(&self) -> usize {
161 self.entries.values().map(Vec::len).sum()
162 }
163
164 #[must_use]
168 pub fn find_matches(&self, query: &ContentSignature, tolerance: u32) -> Vec<(String, usize)> {
169 self.entries
170 .iter()
171 .filter_map(|(id, sigs)| {
172 let count = sigs.iter().filter(|s| query.matches(s, tolerance)).count();
173 if count > 0 && id != &query.asset_id {
174 Some((id.clone(), count))
175 } else {
176 None
177 }
178 })
179 .collect()
180 }
181
182 pub fn remove_asset(&mut self, asset_id: &str) -> Vec<ContentSignature> {
184 self.entries.remove(asset_id).unwrap_or_default()
185 }
186
187 #[must_use]
189 pub fn asset_count(&self) -> usize {
190 self.entries.len()
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn make_sig(asset_id: &str, sig_type: SignatureType, data: Vec<u8>) -> ContentSignature {
199 ContentSignature::new(asset_id, sig_type, data, 1.0)
200 }
201
202 #[test]
203 fn test_sig_type_is_perceptual_visual() {
204 assert!(SignatureType::PerceptualVisual.is_perceptual());
205 }
206
207 #[test]
208 fn test_sig_type_is_perceptual_audio() {
209 assert!(SignatureType::PerceptualAudio.is_perceptual());
210 }
211
212 #[test]
213 fn test_sig_type_not_perceptual_crypto() {
214 assert!(!SignatureType::Cryptographic.is_perceptual());
215 }
216
217 #[test]
218 fn test_sig_type_supports_exact_match() {
219 assert!(SignatureType::Cryptographic.supports_exact_match());
220 assert!(!SignatureType::PerceptualVisual.supports_exact_match());
221 }
222
223 #[test]
224 fn test_sig_type_label_nonempty() {
225 for t in [
226 SignatureType::PerceptualVisual,
227 SignatureType::PerceptualAudio,
228 SignatureType::Cryptographic,
229 SignatureType::NeuralEmbedding,
230 SignatureType::Thumbnail,
231 ] {
232 assert!(!t.label().is_empty());
233 }
234 }
235
236 #[test]
237 fn test_signature_exact_match_identical() {
238 let s1 = make_sig("a1", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
239 let s2 = make_sig("a2", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
240 assert!(s1.matches(&s2, 0));
241 }
242
243 #[test]
244 fn test_signature_exact_match_different() {
245 let s1 = make_sig("a1", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
246 let s2 = make_sig("a2", SignatureType::Cryptographic, vec![1, 2, 3, 5]);
247 assert!(!s1.matches(&s2, 0));
248 }
249
250 #[test]
251 fn test_signature_perceptual_within_tolerance() {
252 let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0, 0, 0, 0]);
253 let s2 = make_sig("a2", SignatureType::PerceptualVisual, vec![1, 0, 0, 0]);
254 assert!(s1.matches(&s2, 1));
255 }
256
257 #[test]
258 fn test_signature_perceptual_exceeds_tolerance() {
259 let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0, 0, 0, 0]);
260 let s2 = make_sig("a2", SignatureType::PerceptualVisual, vec![1, 1, 0, 0]);
261 assert!(!s1.matches(&s2, 1));
262 }
263
264 #[test]
265 fn test_signature_type_mismatch() {
266 let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0; 4]);
267 let s2 = make_sig("a2", SignatureType::Cryptographic, vec![0; 4]);
268 assert!(!s1.matches(&s2, 10));
269 }
270
271 #[test]
272 fn test_database_store_and_lookup() {
273 let mut db = SignatureDatabase::new();
274 db.store(make_sig(
275 "asset1",
276 SignatureType::Cryptographic,
277 vec![0xAB; 4],
278 ));
279 let sigs = db.lookup("asset1");
280 assert_eq!(sigs.len(), 1);
281 }
282
283 #[test]
284 fn test_database_lookup_missing() {
285 let db = SignatureDatabase::new();
286 assert!(db.lookup("nonexistent").is_empty());
287 }
288
289 #[test]
290 fn test_database_match_count() {
291 let mut db = SignatureDatabase::new();
292 db.store(make_sig("a", SignatureType::Cryptographic, vec![1; 4]));
293 db.store(make_sig("a", SignatureType::PerceptualVisual, vec![1; 4]));
294 db.store(make_sig("b", SignatureType::Cryptographic, vec![1; 4]));
295 assert_eq!(db.match_count(), 3);
296 }
297
298 #[test]
299 fn test_database_find_matches() {
300 let mut db = SignatureDatabase::new();
301 db.store(make_sig(
302 "other",
303 SignatureType::PerceptualVisual,
304 vec![0, 0, 0, 0],
305 ));
306 let query = make_sig("query", SignatureType::PerceptualVisual, vec![0, 0, 0, 1]);
307 let matches = db.find_matches(&query, 1);
308 assert_eq!(matches.len(), 1);
309 assert_eq!(matches[0].0, "other");
310 }
311
312 #[test]
313 fn test_database_remove_asset() {
314 let mut db = SignatureDatabase::new();
315 db.store(make_sig("x", SignatureType::Cryptographic, vec![0; 4]));
316 assert_eq!(db.asset_count(), 1);
317 let removed = db.remove_asset("x");
318 assert_eq!(removed.len(), 1);
319 assert_eq!(db.asset_count(), 0);
320 }
321}
322
323const RADIAL_ZONES: usize = 8;
329
330const TEMPORAL_BINS: usize = 16;
332
333const SPECTRAL_PEAKS: usize = 32;
335
336#[derive(Debug, Clone)]
348pub struct RadialVarianceProfile {
349 pub zones: [f64; RADIAL_ZONES],
351}
352
353impl RadialVarianceProfile {
354 #[must_use]
356 pub fn compute(width: usize, height: usize, data: &[u8]) -> Self {
357 let cx = width as f64 / 2.0;
358 let cy = height as f64 / 2.0;
359 let max_r = cx.min(cy).max(1.0);
360
361 let mut sums = [0.0f64; RADIAL_ZONES];
362 let mut sq_sums = [0.0f64; RADIAL_ZONES];
363 let mut counts = [0usize; RADIAL_ZONES];
364
365 for y in 0..height {
366 for x in 0..width {
367 let dx = x as f64 - cx;
368 let dy = y as f64 - cy;
369 let r = (dx * dx + dy * dy).sqrt();
370 let zone_idx = ((r / max_r) * RADIAL_ZONES as f64) as usize;
371 let zone_idx = zone_idx.min(RADIAL_ZONES - 1);
372
373 let idx = y * width + x;
374 if idx < data.len() {
375 let val = f64::from(data[idx]);
376 sums[zone_idx] += val;
377 sq_sums[zone_idx] += val * val;
378 counts[zone_idx] += 1;
379 }
380 }
381 }
382
383 let mut zones = [0.0f64; RADIAL_ZONES];
384 for i in 0..RADIAL_ZONES {
385 if counts[i] > 1 {
386 let mean = sums[i] / counts[i] as f64;
387 let variance = sq_sums[i] / counts[i] as f64 - mean * mean;
388 zones[i] = variance.max(0.0);
389 }
390 }
391
392 Self { zones }
393 }
394
395 #[must_use]
397 pub fn similarity(&self, other: &Self) -> f64 {
398 let dot: f64 = self
399 .zones
400 .iter()
401 .zip(other.zones.iter())
402 .map(|(a, b)| a * b)
403 .sum();
404 let mag_a: f64 = self.zones.iter().map(|x| x * x).sum::<f64>().sqrt();
405 let mag_b: f64 = other.zones.iter().map(|x| x * x).sum::<f64>().sqrt();
406 if mag_a < f64::EPSILON || mag_b < f64::EPSILON {
407 return 0.0;
408 }
409 (dot / (mag_a * mag_b)).clamp(0.0, 1.0)
410 }
411}
412
413#[derive(Debug, Clone)]
424pub struct TemporalRhythm {
425 pub bins: [f64; TEMPORAL_BINS],
427}
428
429impl TemporalRhythm {
430 #[must_use]
436 pub fn from_frame_changes(frame_changes: &[f64]) -> Self {
437 let mut bins = [0.0f64; TEMPORAL_BINS];
438 if frame_changes.is_empty() {
439 return Self { bins };
440 }
441
442 let n = frame_changes.len();
443 let bin_size = (n as f64 / TEMPORAL_BINS as f64).max(1.0);
444
445 for (i, &val) in frame_changes.iter().enumerate() {
446 let bin_idx = (i as f64 / bin_size) as usize;
447 let bin_idx = bin_idx.min(TEMPORAL_BINS - 1);
448 bins[bin_idx] += val;
449 }
450
451 let mut counts = [0usize; TEMPORAL_BINS];
453 for i in 0..n {
454 let bin_idx = ((i as f64 / bin_size) as usize).min(TEMPORAL_BINS - 1);
455 counts[bin_idx] += 1;
456 }
457 for i in 0..TEMPORAL_BINS {
458 if counts[i] > 0 {
459 bins[i] /= counts[i] as f64;
460 }
461 }
462
463 let max_val = bins.iter().cloned().fold(0.0f64, f64::max);
465 if max_val > f64::EPSILON {
466 for b in &mut bins {
467 *b /= max_val;
468 }
469 }
470
471 Self { bins }
472 }
473
474 #[must_use]
476 pub fn similarity(&self, other: &Self) -> f64 {
477 let dot: f64 = self
478 .bins
479 .iter()
480 .zip(other.bins.iter())
481 .map(|(a, b)| a * b)
482 .sum();
483 let mag_a: f64 = self.bins.iter().map(|x| x * x).sum::<f64>().sqrt();
484 let mag_b: f64 = other.bins.iter().map(|x| x * x).sum::<f64>().sqrt();
485 if mag_a < f64::EPSILON || mag_b < f64::EPSILON {
486 return 0.0;
487 }
488 (dot / (mag_a * mag_b)).clamp(0.0, 1.0)
489 }
490}
491
492#[derive(Debug, Clone)]
504pub struct SpectralPeakConstellation {
505 pub peaks: Vec<(u32, u32)>,
507}
508
509impl SpectralPeakConstellation {
510 #[must_use]
512 pub fn new(mut peaks: Vec<(u32, u32)>) -> Self {
513 peaks.sort();
514 if peaks.len() > SPECTRAL_PEAKS {
515 peaks.truncate(SPECTRAL_PEAKS);
516 }
517 Self { peaks }
518 }
519
520 #[must_use]
522 pub fn similarity(&self, other: &Self) -> f64 {
523 if self.peaks.is_empty() && other.peaks.is_empty() {
524 return 1.0;
525 }
526 if self.peaks.is_empty() || other.peaks.is_empty() {
527 return 0.0;
528 }
529
530 let mut matched = 0usize;
532 for &(t1, f1) in &self.peaks {
533 for &(t2, f2) in &other.peaks {
534 let dt = (t1 as i64 - t2 as i64).unsigned_abs();
535 let df = (f1 as i64 - f2 as i64).unsigned_abs();
536 if dt <= 1 && df <= 1 {
537 matched += 1;
538 break;
539 }
540 }
541 }
542
543 let union = self.peaks.len() + other.peaks.len() - matched;
544 if union == 0 {
545 return 0.0;
546 }
547 matched as f64 / union as f64
548 }
549}
550
551#[derive(Debug, Clone)]
564pub struct RobustSignature {
565 pub asset_id: String,
567 pub phash: Option<u64>,
569 pub radial: Option<RadialVarianceProfile>,
571 pub temporal: Option<TemporalRhythm>,
573 pub spectral: Option<SpectralPeakConstellation>,
575 pub duration_secs: Option<f64>,
577}
578
579impl RobustSignature {
580 #[must_use]
582 pub fn new(asset_id: impl Into<String>) -> Self {
583 Self {
584 asset_id: asset_id.into(),
585 phash: None,
586 radial: None,
587 temporal: None,
588 spectral: None,
589 duration_secs: None,
590 }
591 }
592
593 #[must_use]
595 pub fn with_phash(mut self, hash: u64) -> Self {
596 self.phash = Some(hash);
597 self
598 }
599
600 #[must_use]
602 pub fn with_radial(mut self, profile: RadialVarianceProfile) -> Self {
603 self.radial = Some(profile);
604 self
605 }
606
607 #[must_use]
609 pub fn with_temporal(mut self, rhythm: TemporalRhythm) -> Self {
610 self.temporal = Some(rhythm);
611 self
612 }
613
614 #[must_use]
616 pub fn with_spectral(mut self, peaks: SpectralPeakConstellation) -> Self {
617 self.spectral = Some(peaks);
618 self
619 }
620
621 #[must_use]
623 pub fn with_duration(mut self, secs: f64) -> Self {
624 self.duration_secs = Some(secs);
625 self
626 }
627
628 #[must_use]
630 pub fn signal_count(&self) -> usize {
631 let mut count = 0;
632 if self.phash.is_some() {
633 count += 1;
634 }
635 if self.radial.is_some() {
636 count += 1;
637 }
638 if self.temporal.is_some() {
639 count += 1;
640 }
641 if self.spectral.is_some() {
642 count += 1;
643 }
644 count
645 }
646
647 #[must_use]
651 pub fn compare(&self, other: &Self) -> RobustMatchResult {
652 let mut total_weight = 0.0f64;
653 let mut weighted_sum = 0.0f64;
654
655 let duration_ok = match (self.duration_secs, other.duration_secs) {
658 (Some(a), Some(b)) => (a - b).abs() <= 2.0,
659 _ => true,
660 };
661
662 if !duration_ok {
663 return RobustMatchResult {
664 overall_score: 0.0,
665 phash_score: None,
666 radial_score: None,
667 temporal_score: None,
668 spectral_score: None,
669 };
670 }
671
672 let phash_score = match (self.phash, other.phash) {
674 (Some(a), Some(b)) => {
675 let dist = (a ^ b).count_ones();
676 let sim = 1.0 - dist as f64 / 64.0;
677 total_weight += 0.35;
678 weighted_sum += sim * 0.35;
679 Some(sim)
680 }
681 _ => None,
682 };
683
684 let radial_score = match (&self.radial, &other.radial) {
686 (Some(a), Some(b)) => {
687 let sim = a.similarity(b);
688 total_weight += 0.20;
689 weighted_sum += sim * 0.20;
690 Some(sim)
691 }
692 _ => None,
693 };
694
695 let temporal_score = match (&self.temporal, &other.temporal) {
697 (Some(a), Some(b)) => {
698 let sim = a.similarity(b);
699 total_weight += 0.25;
700 weighted_sum += sim * 0.25;
701 Some(sim)
702 }
703 _ => None,
704 };
705
706 let spectral_score = match (&self.spectral, &other.spectral) {
708 (Some(a), Some(b)) => {
709 let sim = a.similarity(b);
710 total_weight += 0.20;
711 weighted_sum += sim * 0.20;
712 Some(sim)
713 }
714 _ => None,
715 };
716
717 let overall = if total_weight > f64::EPSILON {
718 weighted_sum / total_weight
719 } else {
720 0.0
721 };
722
723 RobustMatchResult {
724 overall_score: overall,
725 phash_score,
726 radial_score,
727 temporal_score,
728 spectral_score,
729 }
730 }
731}
732
733#[derive(Debug, Clone)]
735pub struct RobustMatchResult {
736 pub overall_score: f64,
738 pub phash_score: Option<f64>,
740 pub radial_score: Option<f64>,
742 pub temporal_score: Option<f64>,
744 pub spectral_score: Option<f64>,
746}
747
748impl RobustMatchResult {
749 #[must_use]
751 pub fn is_match(&self, threshold: f64) -> bool {
752 self.overall_score >= threshold
753 }
754
755 #[must_use]
757 pub fn contributing_signals(&self) -> usize {
758 let mut count = 0;
759 if self.phash_score.is_some() {
760 count += 1;
761 }
762 if self.radial_score.is_some() {
763 count += 1;
764 }
765 if self.temporal_score.is_some() {
766 count += 1;
767 }
768 if self.spectral_score.is_some() {
769 count += 1;
770 }
771 count
772 }
773}
774
775#[cfg(test)]
780mod robust_tests {
781 use super::*;
782
783 #[test]
784 fn test_radial_variance_uniform_image() {
785 let data = vec![128u8; 64 * 64];
787 let profile = RadialVarianceProfile::compute(64, 64, &data);
788 for &v in &profile.zones {
789 assert!(
790 v < 1e-6,
791 "uniform image should have near-zero variance: {v}"
792 );
793 }
794 }
795
796 #[test]
797 fn test_radial_variance_self_similarity() {
798 let data: Vec<u8> = (0..64 * 64).map(|i| (i % 256) as u8).collect();
799 let profile = RadialVarianceProfile::compute(64, 64, &data);
800 let sim = profile.similarity(&profile);
801 assert!((sim - 1.0).abs() < 1e-10, "self-similarity should be 1.0");
802 }
803
804 #[test]
805 fn test_radial_variance_different_images() {
806 let data_a = vec![100u8; 64 * 64];
807 let data_b: Vec<u8> = (0..64 * 64).map(|i| ((i * 7) % 256) as u8).collect();
808 let pa = RadialVarianceProfile::compute(64, 64, &data_a);
809 let pb = RadialVarianceProfile::compute(64, 64, &data_b);
810 let sim = pa.similarity(&pb);
811 assert!(
813 sim < 0.5,
814 "different images should have low radial similarity: {sim}"
815 );
816 }
817
818 #[test]
819 fn test_temporal_rhythm_constant() {
820 let changes = vec![5.0; 100];
821 let rhythm = TemporalRhythm::from_frame_changes(&changes);
822 for &b in &rhythm.bins {
824 assert!((b - 1.0).abs() < 1e-6, "constant changes -> all bins = 1.0");
825 }
826 }
827
828 #[test]
829 fn test_temporal_rhythm_empty() {
830 let rhythm = TemporalRhythm::from_frame_changes(&[]);
831 for &b in &rhythm.bins {
832 assert_eq!(b, 0.0);
833 }
834 }
835
836 #[test]
837 fn test_temporal_rhythm_self_similarity() {
838 let changes: Vec<f64> = (0..200)
839 .map(|i| (i as f64 * 0.1).sin().abs() * 10.0)
840 .collect();
841 let rhythm = TemporalRhythm::from_frame_changes(&changes);
842 let sim = rhythm.similarity(&rhythm);
843 assert!((sim - 1.0).abs() < 1e-10);
844 }
845
846 #[test]
847 fn test_spectral_peaks_identical() {
848 let peaks = vec![(1, 10), (2, 20), (5, 50)];
849 let a = SpectralPeakConstellation::new(peaks.clone());
850 let b = SpectralPeakConstellation::new(peaks);
851 let sim = a.similarity(&b);
852 assert!((sim - 1.0).abs() < 1e-10, "identical peaks should be 1.0");
853 }
854
855 #[test]
856 fn test_spectral_peaks_no_overlap() {
857 let a = SpectralPeakConstellation::new(vec![(0, 0), (1, 1)]);
858 let b = SpectralPeakConstellation::new(vec![(100, 100), (200, 200)]);
859 let sim = a.similarity(&b);
860 assert_eq!(sim, 0.0);
861 }
862
863 #[test]
864 fn test_spectral_peaks_tolerance() {
865 let a = SpectralPeakConstellation::new(vec![(10, 20)]);
867 let b = SpectralPeakConstellation::new(vec![(11, 21)]);
868 let sim = a.similarity(&b);
869 assert!(sim > 0.0, "peaks within tolerance should match");
870 }
871
872 #[test]
873 fn test_spectral_peaks_empty() {
874 let a = SpectralPeakConstellation::new(vec![]);
875 let b = SpectralPeakConstellation::new(vec![]);
876 assert_eq!(a.similarity(&b), 1.0);
877 }
878
879 #[test]
880 fn test_spectral_peaks_truncation() {
881 let many: Vec<(u32, u32)> = (0..100).map(|i| (i, i * 2)).collect();
882 let constellation = SpectralPeakConstellation::new(many);
883 assert!(constellation.peaks.len() <= SPECTRAL_PEAKS);
884 }
885
886 #[test]
887 fn test_robust_signature_identical() {
888 let peaks = vec![(1, 10), (5, 50)];
889 let radial_data: Vec<u8> = (0..32 * 32).map(|i| (i % 256) as u8).collect();
890 let changes: Vec<f64> = (0..100).map(|i| (i as f64).sin().abs() * 20.0).collect();
891
892 let sig_a = RobustSignature::new("asset_a")
893 .with_phash(0xDEAD_BEEF_CAFE_BABE)
894 .with_radial(RadialVarianceProfile::compute(32, 32, &radial_data))
895 .with_temporal(TemporalRhythm::from_frame_changes(&changes))
896 .with_spectral(SpectralPeakConstellation::new(peaks.clone()))
897 .with_duration(120.0);
898
899 let sig_b = RobustSignature::new("asset_b")
900 .with_phash(0xDEAD_BEEF_CAFE_BABE)
901 .with_radial(RadialVarianceProfile::compute(32, 32, &radial_data))
902 .with_temporal(TemporalRhythm::from_frame_changes(&changes))
903 .with_spectral(SpectralPeakConstellation::new(peaks))
904 .with_duration(120.0);
905
906 let result = sig_a.compare(&sig_b);
907 assert!(
908 result.overall_score > 0.99,
909 "identical sigs should match: {}",
910 result.overall_score
911 );
912 assert!(result.is_match(0.95));
913 assert_eq!(result.contributing_signals(), 4);
914 }
915
916 #[test]
917 fn test_robust_signature_different() {
918 let sig_a = RobustSignature::new("a")
919 .with_phash(0x0000_0000_0000_0000)
920 .with_duration(120.0);
921 let sig_b = RobustSignature::new("b")
922 .with_phash(0xFFFF_FFFF_FFFF_FFFF)
923 .with_duration(120.0);
924
925 let result = sig_a.compare(&sig_b);
926 assert!(
927 result.overall_score < 0.1,
928 "very different sigs: {}",
929 result.overall_score
930 );
931 }
932
933 #[test]
934 fn test_robust_signature_duration_reject() {
935 let sig_a = RobustSignature::new("a")
936 .with_phash(0xDEAD_BEEF)
937 .with_duration(60.0);
938 let sig_b = RobustSignature::new("b")
939 .with_phash(0xDEAD_BEEF)
940 .with_duration(120.0);
941
942 let result = sig_a.compare(&sig_b);
943 assert_eq!(result.overall_score, 0.0, "duration mismatch should reject");
944 }
945
946 #[test]
947 fn test_robust_signature_partial_signals() {
948 let sig_a = RobustSignature::new("a").with_phash(0xAAAA);
950 let sig_b = RobustSignature::new("b").with_phash(0xAAAA);
951
952 let result = sig_a.compare(&sig_b);
953 assert!(result.overall_score > 0.99);
954 assert_eq!(result.contributing_signals(), 1);
955 }
956
957 #[test]
958 fn test_robust_signature_no_signals() {
959 let sig_a = RobustSignature::new("a");
960 let sig_b = RobustSignature::new("b");
961 let result = sig_a.compare(&sig_b);
962 assert_eq!(result.overall_score, 0.0);
963 assert_eq!(result.contributing_signals(), 0);
964 }
965
966 #[test]
967 fn test_robust_signature_signal_count() {
968 let sig = RobustSignature::new("a")
969 .with_phash(0x1234)
970 .with_spectral(SpectralPeakConstellation::new(vec![(1, 2)]));
971 assert_eq!(sig.signal_count(), 2);
972 }
973
974 #[test]
975 fn test_robust_signature_watermark_resilience() {
976 let base: Vec<u8> = (0..64 * 64).map(|i| (i % 256) as u8).collect();
980 let mut watermarked = base.clone();
981 for i in 0..100 {
983 if i < watermarked.len() {
984 watermarked[i] = 255;
985 }
986 }
987
988 let pa = RadialVarianceProfile::compute(64, 64, &base);
989 let pb = RadialVarianceProfile::compute(64, 64, &watermarked);
990 let sim = pa.similarity(&pb);
991 assert!(
992 sim > 0.8,
993 "watermarked image should still be similar: {sim}"
994 );
995 }
996}