1use bytes::Bytes;
7use rustfft::FftPlanner;
8use rustfft::num_complex::Complex;
9
10use crate::domain::ports::AiGenProfile;
11use crate::domain::types::{
12 CoverMedia, CoverMediaKind, DetectabilityRisk, SpectralScore, StegoTechnique,
13};
14
15const HIGH_THRESHOLD_DB: f64 = -6.0;
17const MEDIUM_THRESHOLD_DB: f64 = -12.0;
18
19#[must_use]
21pub fn classify_risk(chi_square_db: f64) -> DetectabilityRisk {
22 if chi_square_db > HIGH_THRESHOLD_DB {
23 DetectabilityRisk::High
24 } else if chi_square_db > MEDIUM_THRESHOLD_DB {
25 DetectabilityRisk::Medium
26 } else {
27 DetectabilityRisk::Low
28 }
29}
30
31#[must_use]
33pub const fn recommended_payload(capacity_bytes: u64, risk: DetectabilityRisk) -> u64 {
34 match risk {
35 DetectabilityRisk::Low => capacity_bytes / 2,
36 DetectabilityRisk::Medium => capacity_bytes / 4,
37 DetectabilityRisk::High => capacity_bytes / 8,
38 }
39}
40
41#[must_use]
45pub fn estimate_capacity(cover: &CoverMedia, technique: StegoTechnique) -> u64 {
46 match technique {
47 StegoTechnique::LsbImage => estimate_image_lsb_capacity(cover),
48 StegoTechnique::DctJpeg => estimate_jpeg_dct_capacity(cover),
49 StegoTechnique::Palette => estimate_palette_capacity(cover),
50 StegoTechnique::LsbAudio => estimate_audio_lsb_capacity(cover),
51 StegoTechnique::PhaseEncoding | StegoTechnique::EchoHiding => {
52 estimate_audio_lsb_capacity(cover) / 8
54 }
55 StegoTechnique::ZeroWidthText => estimate_text_capacity(cover),
56 StegoTechnique::PdfContentStream => estimate_pdf_content_capacity(cover),
57 StegoTechnique::PdfMetadata => estimate_pdf_metadata_capacity(cover),
58 StegoTechnique::CorpusSelection => {
59 estimate_image_lsb_capacity(cover)
61 }
62 StegoTechnique::DualPayload => {
63 estimate_image_lsb_capacity(cover) / 2
65 }
66 }
67}
68
69#[must_use]
74#[expect(
75 clippy::cast_precision_loss,
76 reason = "byte histogram counts are small enough for f64"
77)]
78pub fn chi_square_score(data: &[u8]) -> f64 {
79 if data.is_empty() {
80 return 0.0;
81 }
82
83 let mut histogram = [0u64; 256];
85 for &b in data {
86 #[expect(
88 clippy::indexing_slicing,
89 reason = "u8 index into [_; 256] cannot be out of bounds"
90 )]
91 {
92 histogram[usize::from(b)] = histogram[usize::from(b)].strict_add(1);
93 }
94 }
95
96 let expected = data.len() as f64 / 256.0;
97 if expected < f64::EPSILON {
98 return 0.0;
99 }
100
101 let chi_sq: f64 = histogram
102 .iter()
103 .map(|&count| {
104 let diff = count as f64 - expected;
105 (diff * diff) / expected
106 })
107 .sum();
108
109 let normalised = chi_sq / 255.0;
111 if normalised < f64::EPSILON {
112 -100.0 } else {
114 10.0 * normalised.log10()
115 }
116}
117
118#[must_use]
130#[expect(
131 clippy::cast_precision_loss,
132 reason = "pair counts are small enough for f64"
133)]
134pub fn pair_delta_chi_square_score(data: &[u8]) -> f64 {
135 if data.len() < 2 {
136 return 0.0;
137 }
138
139 let mut histogram = [0u64; 256];
140 for pair in data.array_windows::<2>() {
141 let delta = pair[1].wrapping_sub(pair[0]);
142 #[expect(
143 clippy::indexing_slicing,
144 reason = "delta is a u8, always 0..=255, histogram has 256 entries"
145 )]
146 {
147 histogram[usize::from(delta)] = histogram[usize::from(delta)].strict_add(1);
148 }
149 }
150
151 let n_pairs = data.len().strict_sub(1);
152 let expected = n_pairs as f64 / 256.0;
153 if expected < f64::EPSILON {
154 return 0.0;
155 }
156
157 let chi_sq: f64 = histogram
158 .iter()
159 .map(|&count| {
160 let diff = count as f64 - expected;
161 (diff * diff) / expected
162 })
163 .sum();
164
165 let normalised = chi_sq / 255.0;
166 if normalised < f64::EPSILON {
167 -100.0
168 } else {
169 10.0 * normalised.log10()
170 }
171}
172
173const fn estimate_image_lsb_capacity(cover: &CoverMedia) -> u64 {
176 match cover.kind {
177 CoverMediaKind::PngImage | CoverMediaKind::BmpImage => {
178 let usable = cover.data.len().saturating_sub(54); (usable / 8) as u64
182 }
183 CoverMediaKind::GifImage => (cover.data.len().saturating_sub(128) / 16) as u64,
184 _ => 0,
185 }
186}
187
188fn estimate_jpeg_dct_capacity(cover: &CoverMedia) -> u64 {
189 if cover.kind != CoverMediaKind::JpegImage {
190 return 0;
191 }
192 (cover.data.len() / 16) as u64
194}
195
196const fn estimate_palette_capacity(cover: &CoverMedia) -> u64 {
197 match cover.kind {
198 CoverMediaKind::GifImage | CoverMediaKind::PngImage => {
199 (cover.data.len().saturating_sub(128) / 32) as u64
201 }
202 _ => 0,
203 }
204}
205
206fn estimate_audio_lsb_capacity(cover: &CoverMedia) -> u64 {
207 if cover.kind != CoverMediaKind::WavAudio {
208 return 0;
209 }
210 let usable = cover.data.len().saturating_sub(44); (usable / 16) as u64
213}
214
215use unicode_segmentation::UnicodeSegmentation;
216
217fn estimate_text_capacity(cover: &CoverMedia) -> u64 {
218 if cover.kind != CoverMediaKind::PlainText {
219 return 0;
220 }
221 let text = String::from_utf8_lossy(&cover.data);
223 let grapheme_count = text.graphemes(true).count();
224 (grapheme_count / 4) as u64
226}
227
228fn estimate_pdf_content_capacity(cover: &CoverMedia) -> u64 {
229 if cover.kind != CoverMediaKind::PdfDocument {
230 return 0;
231 }
232 (cover.data.len() / 80) as u64
234}
235
236const fn estimate_pdf_metadata_capacity(_cover: &CoverMedia) -> u64 {
237 256
239}
240
241#[must_use]
254pub fn spectral_detectability_score(
255 original: &CoverMedia,
256 stego: &CoverMedia,
257 profile: Option<&AiGenProfile>,
258) -> SpectralScore {
259 let orig_pixels = green_channel_f32(&original.data);
260 let stego_pixels = green_channel_f32(&stego.data);
261
262 let n = orig_pixels.len().min(stego_pixels.len());
263 if n < 4 {
264 return SpectralScore {
265 phase_coherence_drop: 0.0,
266 carrier_snr_drop_db: 0.0,
267 sample_pair_asymmetry: 0.0,
268 combined_risk: DetectabilityRisk::Low,
269 };
270 }
271
272 let fft_len = n.next_power_of_two();
274 let orig_freq = run_fft(&orig_pixels, fft_len);
275 let stego_freq = run_fft(&stego_pixels, fft_len);
276
277 let img_width: usize = original
281 .metadata
282 .get("width")
283 .and_then(|v| v.parse::<usize>().ok())
284 .unwrap_or(fft_len);
285 let img_height: usize = original
286 .metadata
287 .get("height")
288 .and_then(|v| v.parse::<usize>().ok())
289 .unwrap_or(1);
290 let width = u32::try_from(img_width).unwrap_or(u32::MAX);
291 let height = u32::try_from(img_height).unwrap_or(u32::MAX);
292
293 let carrier_bins: Vec<(u32, u32)> = profile.map_or_else(Vec::new, |prof| {
297 prof.carrier_bins_for(width, height)
298 .map(|bins| {
299 bins.iter()
300 .filter(|b| b.is_strong())
301 .map(|b| b.freq)
302 .collect()
303 })
304 .unwrap_or_default()
305 });
306
307 let flat_bins: Vec<usize> = if carrier_bins.is_empty() {
308 top_magnitude_bins(&orig_freq, 16)
310 } else {
311 carrier_bins
312 .into_iter()
313 .map(|(r, c)| {
314 (r as usize)
315 .saturating_mul(img_width)
316 .saturating_add(c as usize)
317 })
318 .collect()
319 };
320
321 let phase_coherence_drop = compute_phase_coherence_drop(&orig_freq, &stego_freq, &flat_bins);
323
324 let carrier_snr_drop_db = compute_carrier_snr_drop_db(&orig_freq, &stego_freq, &flat_bins);
326
327 let sample_pair_asymmetry = match (orig_pixels.get(..n), stego_pixels.get(..n)) {
329 (Some(orig), Some(stego)) => compute_sample_pair_asymmetry(orig, stego),
330 _ => 0.0,
331 };
332
333 let combined_risk = classify_spectral_risk(phase_coherence_drop, carrier_snr_drop_db);
334
335 SpectralScore {
336 phase_coherence_drop,
337 carrier_snr_drop_db,
338 sample_pair_asymmetry,
339 combined_risk,
340 }
341}
342
343fn green_channel_f32(data: &Bytes) -> Vec<f32> {
349 if data.len() >= 4 && data.len().is_multiple_of(4) {
350 data.chunks_exact(4)
351 .filter_map(|ch| match ch {
352 [_, g, _, _] => Some(f32::from(*g)),
353 _ => None,
354 })
355 .collect()
356 } else {
357 data.iter().map(|&b| f32::from(b)).collect()
358 }
359}
360
361fn run_fft(samples: &[f32], fft_len: usize) -> Vec<Complex<f32>> {
363 let mut input: Vec<Complex<f32>> = samples.iter().map(|&x| Complex::new(x, 0.0)).collect();
364 input.resize(fft_len, Complex::new(0.0, 0.0));
365
366 let mut planner = FftPlanner::<f32>::new();
367 let fft = planner.plan_fft_forward(fft_len);
368 fft.process(&mut input);
369 input
370}
371
372fn top_magnitude_bins(freq: &[Complex<f32>], n: usize) -> Vec<usize> {
375 let mut indexed: Vec<(usize, f64)> = freq
376 .iter()
377 .enumerate()
378 .skip(1)
379 .map(|(i, c)| (i, f64::from(c.norm())))
380 .collect();
381 indexed.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
382 indexed.truncate(n);
383 indexed.into_iter().map(|(i, _)| i).collect()
384}
385
386fn compute_phase_coherence_drop(
388 orig: &[Complex<f32>],
389 stego: &[Complex<f32>],
390 bins: &[usize],
391) -> f64 {
392 if bins.is_empty() {
393 return 0.0;
394 }
395 let mut sum = 0.0f64;
396 let mut count = 0usize;
397 for &idx in bins {
398 if let (Some(o), Some(s)) = (orig.get(idx), stego.get(idx)) {
399 let phase_diff = f64::from(s.arg() - o.arg());
400 sum += phase_diff.cos().abs();
401 count = count.strict_add(1);
402 }
403 }
404 if count == 0 {
405 return 0.0;
406 }
407 let count_f = match u32::try_from(count) {
408 Ok(v) => f64::from(v),
409 Err(_) => return 0.0,
410 };
411 let avg_coherence = sum / count_f;
412 (1.0 - avg_coherence).clamp(0.0, 1.0)
413}
414
415fn compute_carrier_snr_drop_db(
418 orig: &[Complex<f32>],
419 stego: &[Complex<f32>],
420 bins: &[usize],
421) -> f64 {
422 if bins.is_empty() {
423 return 0.0;
424 }
425 let mut sum = 0.0f64;
426 let mut count = 0usize;
427 for &idx in bins {
428 if let (Some(o), Some(s)) = (orig.get(idx), stego.get(idx)) {
429 let mag_orig = f64::from(o.norm());
430 let mag_stego = f64::from(s.norm());
431 if mag_orig > 0.0 && mag_stego > 0.0 {
432 sum += 10.0 * (mag_stego / mag_orig).log10();
433 count = count.strict_add(1);
434 }
435 }
436 }
437 if count == 0 {
438 return 0.0;
439 }
440 let count_f = match u32::try_from(count) {
441 Ok(v) => f64::from(v),
442 Err(_) => return 0.0,
443 };
444 let result = sum / count_f;
445 if result.is_nan() { 0.0 } else { result }
446}
447
448fn compute_sample_pair_asymmetry(orig: &[f32], stego: &[f32]) -> f64 {
452 if stego.len() < 2 {
453 return 0.0;
454 }
455
456 let pairs = stego.len() / 2;
457 let asym: usize = stego
458 .chunks_exact(2)
459 .filter(|pair| match pair {
460 [a, b] => sample_is_odd(*a) != sample_is_odd(*b),
461 _ => false,
462 })
463 .count();
464 let orig_asym: usize = orig
465 .chunks_exact(2)
466 .filter(|pair| match pair {
467 [a, b] => sample_is_odd(*a) != sample_is_odd(*b),
468 _ => false,
469 })
470 .count();
471 let pairs_f = match u32::try_from(pairs) {
472 Ok(v) if v > 0 => f64::from(v),
473 _ => return 0.0,
474 };
475 let asym_f = match u32::try_from(asym) {
476 Ok(v) => f64::from(v),
477 Err(_) => return 0.0,
478 };
479 let orig_asym_f = match u32::try_from(orig_asym) {
480 Ok(v) => f64::from(v),
481 Err(_) => return 0.0,
482 };
483 let stego_frac = asym_f / pairs_f;
484 let orig_frac = orig_asym_f / pairs_f;
485 (stego_frac - orig_frac).abs().clamp(0.0, 1.0)
486}
487
488fn sample_is_odd(sample: f32) -> bool {
489 sample.rem_euclid(2.0) >= 1.0
492}
493
494fn classify_spectral_risk(
496 phase_coherence_drop: f64,
497 carrier_snr_drop_db: f64,
498) -> DetectabilityRisk {
499 if phase_coherence_drop > 0.20 || carrier_snr_drop_db.abs() > 0.15 {
500 DetectabilityRisk::High
501 } else if phase_coherence_drop > 0.05 || carrier_snr_drop_db.abs() > 0.05 {
502 DetectabilityRisk::Medium
503 } else {
504 DetectabilityRisk::Low
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use bytes::Bytes;
512 use std::collections::HashMap;
513
514 fn make_cover(kind: CoverMediaKind, size: usize) -> CoverMedia {
515 CoverMedia {
516 kind,
517 data: Bytes::from(vec![0u8; size]),
518 metadata: HashMap::new(),
519 }
520 }
521
522 #[test]
523 fn classify_risk_thresholds() {
524 assert_eq!(classify_risk(-1.0), DetectabilityRisk::High);
525 assert_eq!(classify_risk(-5.9), DetectabilityRisk::High);
526 assert_eq!(classify_risk(-7.0), DetectabilityRisk::Medium);
527 assert_eq!(classify_risk(-11.9), DetectabilityRisk::Medium);
528 assert_eq!(classify_risk(-13.0), DetectabilityRisk::Low);
529 assert_eq!(classify_risk(-50.0), DetectabilityRisk::Low);
530 }
531
532 #[test]
533 fn recommended_payload_scales_with_risk() {
534 assert_eq!(recommended_payload(1000, DetectabilityRisk::Low), 500);
535 assert_eq!(recommended_payload(1000, DetectabilityRisk::Medium), 250);
536 assert_eq!(recommended_payload(1000, DetectabilityRisk::High), 125);
537 }
538
539 #[test]
540 fn estimate_capacity_png_lsb() {
541 let cover = make_cover(CoverMediaKind::PngImage, 8192);
542 let cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
543 assert!(cap > 0);
544 assert_eq!(cap, 1017);
546 }
547
548 #[test]
549 fn estimate_capacity_wav_lsb() {
550 let cover = make_cover(CoverMediaKind::WavAudio, 44100);
551 let cap = estimate_capacity(&cover, StegoTechnique::LsbAudio);
552 assert!(cap > 0);
553 }
554
555 #[test]
556 fn estimate_capacity_wrong_kind_returns_zero() {
557 let cover = make_cover(CoverMediaKind::WavAudio, 1000);
558 assert_eq!(estimate_capacity(&cover, StegoTechnique::LsbImage), 0);
559 }
560
561 #[test]
562 fn chi_square_uniform_data_low_score() {
563 let data: Vec<u8> = (0..=255).cycle().take(256 * 100).collect();
565 let score = chi_square_score(&data);
566 assert!(
567 score < HIGH_THRESHOLD_DB,
568 "uniform data should score low: {score}"
569 );
570 }
571
572 #[test]
573 fn chi_square_biased_data_high_score() {
574 let data = vec![0u8; 10000];
576 let score = chi_square_score(&data);
577 assert!(
578 score > HIGH_THRESHOLD_DB,
579 "biased data should score high: {score}"
580 );
581 }
582
583 #[test]
584 fn chi_square_empty_returns_zero() {
585 assert!((chi_square_score(&[]) - 0.0).abs() < f64::EPSILON);
586 }
587
588 #[test]
589 fn corpus_selection_uses_image_capacity() {
590 let cover = make_cover(CoverMediaKind::PngImage, 4096);
591 let lsb_cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
592 let corpus_cap = estimate_capacity(&cover, StegoTechnique::CorpusSelection);
593 assert_eq!(lsb_cap, corpus_cap);
594 }
595
596 #[test]
597 fn pdf_content_stream_has_capacity() {
598 let cover = make_cover(CoverMediaKind::PdfDocument, 100_000);
599 let cap = estimate_capacity(&cover, StegoTechnique::PdfContentStream);
600 assert!(cap > 0);
601 }
602
603 #[test]
606 fn jpeg_dct_capacity_for_jpeg() {
607 let cover = make_cover(CoverMediaKind::JpegImage, 16_000);
608 let cap = estimate_capacity(&cover, StegoTechnique::DctJpeg);
609 assert_eq!(cap, 1000); }
611
612 #[test]
613 fn jpeg_dct_capacity_wrong_kind_returns_zero() {
614 let cover = make_cover(CoverMediaKind::PngImage, 16_000);
615 assert_eq!(estimate_capacity(&cover, StegoTechnique::DctJpeg), 0);
616 }
617
618 #[test]
619 fn palette_capacity_for_gif() {
620 let cover = make_cover(CoverMediaKind::GifImage, 4096);
621 let cap = estimate_capacity(&cover, StegoTechnique::Palette);
622 assert!(cap > 0);
623 assert_eq!(cap, 124);
625 }
626
627 #[test]
628 fn palette_capacity_wrong_kind_returns_zero() {
629 let cover = make_cover(CoverMediaKind::WavAudio, 4096);
630 assert_eq!(estimate_capacity(&cover, StegoTechnique::Palette), 0);
631 }
632
633 #[test]
634 fn text_capacity_for_plain_text() {
635 let cover = CoverMedia {
637 kind: CoverMediaKind::PlainText,
638 data: Bytes::from(
639 "hello world, this is a test of capacity estimation for zero-width text",
640 ),
641 metadata: HashMap::new(),
642 };
643 let cap = estimate_capacity(&cover, StegoTechnique::ZeroWidthText);
644 assert!(cap > 0);
645 }
646
647 #[test]
648 fn text_capacity_wrong_kind_returns_zero() {
649 let cover = make_cover(CoverMediaKind::PngImage, 1000);
650 assert_eq!(estimate_capacity(&cover, StegoTechnique::ZeroWidthText), 0);
651 }
652
653 #[test]
654 fn pdf_content_capacity_wrong_kind_returns_zero() {
655 let cover = make_cover(CoverMediaKind::PngImage, 100_000);
656 assert_eq!(
657 estimate_capacity(&cover, StegoTechnique::PdfContentStream),
658 0
659 );
660 }
661
662 #[test]
663 fn pdf_metadata_capacity_always_256() {
664 let cover = make_cover(CoverMediaKind::PdfDocument, 1000);
665 assert_eq!(estimate_capacity(&cover, StegoTechnique::PdfMetadata), 256);
666 let cover2 = make_cover(CoverMediaKind::PngImage, 1000);
668 assert_eq!(estimate_capacity(&cover2, StegoTechnique::PdfMetadata), 256);
669 }
670
671 #[test]
672 fn audio_lsb_wrong_kind_returns_zero() {
673 let cover = make_cover(CoverMediaKind::PngImage, 44100);
674 assert_eq!(estimate_capacity(&cover, StegoTechnique::LsbAudio), 0);
675 }
676
677 #[test]
678 fn phase_encoding_is_audio_lsb_div_8() {
679 let cover = make_cover(CoverMediaKind::WavAudio, 44100);
680 let audio_cap = estimate_capacity(&cover, StegoTechnique::LsbAudio);
681 let phase_cap = estimate_capacity(&cover, StegoTechnique::PhaseEncoding);
682 assert_eq!(phase_cap, audio_cap / 8);
683 }
684
685 #[test]
686 fn echo_hiding_same_as_phase_encoding() {
687 let cover = make_cover(CoverMediaKind::WavAudio, 44100);
688 let phase_cap = estimate_capacity(&cover, StegoTechnique::PhaseEncoding);
689 let echo_cap = estimate_capacity(&cover, StegoTechnique::EchoHiding);
690 assert_eq!(phase_cap, echo_cap);
691 }
692
693 #[test]
694 fn dual_payload_is_half_image_lsb() {
695 let cover = make_cover(CoverMediaKind::PngImage, 8192);
696 let lsb_cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
697 let dual_cap = estimate_capacity(&cover, StegoTechnique::DualPayload);
698 assert_eq!(dual_cap, lsb_cap / 2);
699 }
700
701 #[test]
702 fn gif_lsb_image_capacity() {
703 let cover = make_cover(CoverMediaKind::GifImage, 4096);
704 let cap = estimate_capacity(&cover, StegoTechnique::LsbImage);
705 assert_eq!(cap, 248);
707 }
708
709 #[test]
710 fn bmp_lsb_same_as_png() {
711 let cover_png = make_cover(CoverMediaKind::PngImage, 8192);
712 let cover_bmp = make_cover(CoverMediaKind::BmpImage, 8192);
713 assert_eq!(
714 estimate_capacity(&cover_png, StegoTechnique::LsbImage),
715 estimate_capacity(&cover_bmp, StegoTechnique::LsbImage)
716 );
717 }
718
719 #[test]
720 fn palette_capacity_for_png() {
721 let cover = make_cover(CoverMediaKind::PngImage, 4096);
722 let cap = estimate_capacity(&cover, StegoTechnique::Palette);
723 assert_eq!(cap, 124); }
725
726 fn make_spectral_cover(data: Vec<u8>) -> CoverMedia {
729 CoverMedia {
730 kind: CoverMediaKind::PngImage,
731 data: Bytes::from(data),
732 metadata: HashMap::new(),
733 }
734 }
735
736 #[test]
737 fn spectral_identical_buffers_low_risk() {
738 let data: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
739 let orig = make_spectral_cover(data.clone());
740 let stego = make_spectral_cover(data);
741 let score = spectral_detectability_score(&orig, &stego, None);
742 assert!(
743 (score.phase_coherence_drop).abs() < 1e-6,
744 "identical buffers: phase_coherence_drop should be ~0"
745 );
746 assert!(
747 (score.carrier_snr_drop_db).abs() < 1e-3,
748 "identical buffers: carrier_snr_drop_db should be ~0"
749 );
750 assert_eq!(score.combined_risk, DetectabilityRisk::Low);
751 }
752
753 #[test]
754 fn spectral_heavily_modified_differs_from_identical() {
755 let orig_data: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
760 let stego_data: Vec<u8> = orig_data.iter().map(|&b| b ^ 0xFF).collect();
761 let orig = make_spectral_cover(orig_data);
762 let stego = make_spectral_cover(stego_data);
763 let score = spectral_detectability_score(&orig, &stego, None);
764 assert!(score.phase_coherence_drop.is_finite());
766 assert!(score.sample_pair_asymmetry >= 0.0);
767 let _ = score.combined_risk;
769 }
770
771 #[test]
772 fn spectral_empty_orig_no_panic() {
773 let orig = make_spectral_cover(vec![]);
774 let stego = make_spectral_cover(vec![0u8; 64]);
775 let score = spectral_detectability_score(&orig, &stego, None);
776 assert_eq!(score.combined_risk, DetectabilityRisk::Low);
777 }
778
779 #[test]
780 fn spectral_single_pixel_no_panic() {
781 let orig = make_spectral_cover(vec![128]);
782 let stego = make_spectral_cover(vec![129]);
783 let score = spectral_detectability_score(&orig, &stego, None);
784 assert_eq!(score.combined_risk, DetectabilityRisk::Low);
785 }
786
787 #[test]
788 fn spectral_with_ai_gen_profile_checks_carrier_bins() {
789 use crate::domain::ports::{AiGenProfile, CarrierBin};
790 let bins = vec![CarrierBin::new((0, 5), 0.0, 1.0)];
792 let mut carrier_map = HashMap::new();
793 carrier_map.insert("64x1".to_string(), bins);
795 let profile = AiGenProfile {
796 model_id: "test-model".to_string(),
797 channel_weights: [1.0, 1.0, 1.0],
798 carrier_map,
799 };
800 let data: Vec<u8> = (0u8..64).collect();
801 let orig = make_spectral_cover(data.clone());
802 let stego = make_spectral_cover(data);
803 let score = spectral_detectability_score(&orig, &stego, Some(&profile));
804 assert_eq!(score.combined_risk, DetectabilityRisk::Low);
806 }
807
808 #[test]
809 fn spectral_score_serde_round_trip() {
810 use crate::domain::types::SpectralScore;
811 let score = SpectralScore {
812 phase_coherence_drop: 0.12,
813 carrier_snr_drop_db: -0.08,
814 sample_pair_asymmetry: 0.03,
815 combined_risk: DetectabilityRisk::Medium,
816 };
817 let json = serde_json::to_string(&score);
818 assert!(json.is_ok());
819 let Some(json) = json.ok() else {
820 return;
821 };
822 let back: Result<SpectralScore, _> = serde_json::from_str(&json);
823 assert!(back.is_ok());
824 let Some(back) = back.ok() else {
825 return;
826 };
827 assert!((back.phase_coherence_drop - score.phase_coherence_drop).abs() < 1e-10);
828 assert!((back.carrier_snr_drop_db - score.carrier_snr_drop_db).abs() < 1e-10);
829 assert_eq!(back.combined_risk, score.combined_risk);
830 }
831
832 #[test]
833 fn analysis_report_has_spectral_score_field() {
834 use crate::domain::types::{AnalysisReport, Capacity};
835 let report = AnalysisReport {
836 technique: StegoTechnique::LsbImage,
837 cover_capacity: Capacity {
838 bytes: 100,
839 technique: StegoTechnique::LsbImage,
840 },
841 chi_square_score: -13.5,
842 detectability_risk: DetectabilityRisk::Low,
843 recommended_max_payload_bytes: 50,
844 ai_watermark: None,
845 spectral_score: None,
846 };
847 assert!(report.spectral_score.is_none());
848 }
849}