Skip to main content

phasm_core/stego/ghost/
quality.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Encode quality scoring: Stealth (Ghost) and Robustness (Armor).
6//!
7//! After encoding, these functions compute a 0-100% quality score that
8//! indicates how well-hidden (Ghost) or how robust (Armor) the encoded
9//! message is. The score is displayed in the mobile app HUD and encode
10//! success screen.
11
12use crate::det_math::det_exp;
13
14/// Encode quality result returned alongside stego bytes.
15#[derive(Debug, Clone)]
16pub struct EncodeQuality {
17    /// Overall score 0-100.
18    pub score: u8,
19    /// Localization key for a contextual hint (e.g. "hint_high_rate").
20    pub hint_key: String,
21    /// Mode: 1 = Ghost (stealth), 2 = Armor (robustness).
22    pub mode: u8,
23}
24
25/// Maximum embedding rate for Ghost stealth scoring.
26/// alpha = modifications / positions. At alpha_max, rate_score = 0.
27const ALPHA_MAX: f64 = 0.5;
28
29/// Inputs for Ghost stealth score computation.
30pub struct GhostMetrics {
31    /// Number of STC modifications (cover != stego).
32    pub num_modifications: usize,
33    /// Number of cover positions used by STC (n_used).
34    pub n_used: usize,
35    /// STC width parameter.
36    pub w: usize,
37    /// Total distortion cost from STC embedding.
38    pub total_cost: f64,
39    /// Median cost across all positions (before STC).
40    pub median_cost: f32,
41    /// Whether SI-UNIWARD (Deep Cover) was used.
42    pub is_si: bool,
43    /// Number of shadow LSB modifications (0 if no shadows).
44    pub shadow_modifications: usize,
45    /// Total number of Y-channel coefficients in the image.
46    pub total_coefficients: usize,
47}
48
49/// Compute Ghost stealth score (0-100).
50///
51/// Five weighted factors + shadow penalty:
52/// - 30% embedding rate
53/// - 25% cost distribution quality (median cost proxy)
54/// - 20% STC width
55/// - 15% avg distortion per change
56/// - 10% SI-UNIWARD bonus
57/// - Shadow penalty: up to -15 points for LSB modifications
58pub fn ghost_stealth_score(m: &GhostMetrics) -> EncodeQuality {
59    let alpha = if m.n_used > 0 {
60        m.num_modifications as f64 / m.n_used as f64
61    } else {
62        1.0
63    };
64
65    // Factor 1: Embedding rate (30%)
66    // Lower alpha = stealthier. Quadratic falloff.
67    let rate_ratio = (alpha / ALPHA_MAX).min(1.0);
68    let rate_score = 100.0 * (1.0 - rate_ratio) * (1.0 - rate_ratio);
69
70    // Factor 2: Cost distribution quality (25%)
71    // Higher median cost = image has more texture = better hiding.
72    // Median cost of 20+ is excellent; below 3 is poor.
73    let cost_score = 100.0 * (1.0 - ((m.median_cost as f64 - 3.0).max(0.0) / 17.0).min(1.0));
74    // Invert: high median = hiding in texture = good
75    let cost_score = 100.0 - cost_score;
76
77    // Factor 3: STC width (20%)
78    // Higher w = more coding slack = fewer modifications.
79    // tanh(w/5) gives ~100 for w>=5, ~38 for w=1.
80    let w_f = m.w as f64;
81    let width_score = 100.0 * det_tanh(w_f / 5.0);
82
83    // Factor 4: Avg distortion per change (15%)
84    // Lower avg cost per modification = changes blend into noise.
85    let avg_cost = if m.num_modifications > 0 {
86        m.total_cost / m.num_modifications as f64
87    } else {
88        0.0
89    };
90    let distort_score = 100.0 * (1.0 - ((avg_cost - 3.0) / 17.0).clamp(0.0, 1.0));
91
92    // Factor 5: SI-UNIWARD bonus (10%)
93    let si_score = if m.is_si { 100.0 } else { 0.0 };
94
95    let base_stealth = 0.30 * rate_score
96        + 0.25 * cost_score
97        + 0.20 * width_score
98        + 0.15 * distort_score
99        + 0.10 * si_score;
100
101    // Shadow penalty: up to -15 points for LSB modifications.
102    let shadow_penalty = if m.shadow_modifications > 0 && m.total_coefficients > 0 {
103        let shadow_mod_ratio = m.shadow_modifications as f64 / m.total_coefficients as f64;
104        15.0 * (shadow_mod_ratio / 0.01).min(1.0)
105    } else {
106        0.0
107    };
108
109    let stealth = (base_stealth - shadow_penalty).clamp(0.0, 100.0);
110    let score = stealth.round() as u8;
111
112    // Pick hint based on weakest factor.
113    let factors = [
114        (rate_score, "hint_high_rate"),
115        (cost_score, "hint_low_texture"),
116        (width_score, "hint_low_width"),
117        (distort_score, "hint_high_distortion"),
118    ];
119    let hint_key = if shadow_penalty > 5.0 {
120        "hint_shadow_penalty"
121    } else if m.is_si && score >= 70 {
122        "hint_si_bonus"
123    } else {
124        // Pick the weakest factor.
125        factors.iter()
126            .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
127            .map_or("hint_high_rate", |(_, key)| *key)
128    };
129
130    EncodeQuality {
131        score,
132        hint_key: hint_key.to_string(),
133        mode: 1,
134    }
135}
136
137/// Inputs for Armor robustness score computation.
138pub struct ArmorMetrics {
139    /// Repetition factor (1 = Phase 1, 3+ = Phase 2, 15+ = Fortress).
140    pub repetition_factor: usize,
141    /// RS parity symbols per block (64/128/192/240).
142    pub parity_symbols: usize,
143    /// Whether Fortress sub-mode (BA-QIM) was used.
144    pub fortress: bool,
145    /// Mean quantization table value (AC positions zigzag 1..=15).
146    /// Higher = lower QF = more robust STDM embedding.
147    pub mean_qt: f64,
148    /// Payload fill ratio (0.0-1.0). Lower = more room for redundancy.
149    pub fill_ratio: f64,
150    /// STDM delta strength.
151    pub delta: f64,
152}
153
154/// Compute Armor robustness score (0-100).
155///
156/// When Fortress is active, six weighted factors:
157/// - 30% repetition, 20% parity, 15% Fortress bonus, 15% QT resilience,
158///   10% fill, 10% delta
159///
160/// When Fortress is NOT active, the 15% Fortress weight is redistributed
161/// proportionally to the other five factors (~35/24/18/12/12%).
162/// This prevents a hard cap on standard Armor scores.
163pub fn armor_robustness_score(m: &ArmorMetrics) -> EncodeQuality {
164    // Factor 1: Repetition factor
165    // r >= 7 is strong. Linear scale.
166    let rep_score = 100.0 * (m.repetition_factor as f64 / 7.0).min(1.0);
167
168    // Factor 2: Parity ratio
169    // 240 is maximum.
170    let parity_score = 100.0 * (m.parity_symbols as f64 / 240.0).min(1.0);
171
172    // Factor 3: Fortress activation
173    let fortress_score = if m.fortress { 100.0 } else { 0.0 };
174
175    // Factor 4: QT resilience (continuous, from mean quantization table value)
176    // Higher mean_qt = lower QF = larger quant steps = more robust STDM.
177    // mean_qt >= 20 (roughly QF 55-60) is excellent; mean_qt ~2 (QF 95) is fragile.
178    let qt_score = 100.0 * (m.mean_qt / 20.0).min(1.0);
179
180    // Factor 5: Fill ratio
181    // Lower fill = more room for redundancy.
182    let fill_score = 100.0 * (1.0 - m.fill_ratio.clamp(0.0, 1.0));
183
184    // Factor 6: Delta strength
185    // delta >= 40 is excellent.
186    let delta_score = 100.0 * (m.delta / 40.0).min(1.0);
187
188    // When Fortress is active, use full 6-factor weights.
189    // When not active, redistribute Fortress's 15% to the other 5 factors
190    // so standard Armor isn't hard-capped at ~85%.
191    let robustness = if m.fortress {
192        0.30 * rep_score
193            + 0.20 * parity_score
194            + 0.15 * fortress_score
195            + 0.15 * qt_score
196            + 0.10 * fill_score
197            + 0.10 * delta_score
198    } else {
199        // Redistribute: divide each weight by 0.85 (sum of non-fortress weights)
200        (0.30 / 0.85) * rep_score
201            + (0.20 / 0.85) * parity_score
202            + (0.15 / 0.85) * qt_score
203            + (0.10 / 0.85) * fill_score
204            + (0.10 / 0.85) * delta_score
205    };
206
207    let score = robustness.round().clamp(0.0, 100.0) as u8;
208
209    // Pick hint based on dominant/weakest factor.
210    let hint_key = if m.fortress {
211        "hint_fortress_active"
212    } else {
213        let factors = [
214            (rep_score, "hint_low_repetition"),
215            (parity_score, "hint_low_parity"),
216            (qt_score, "hint_high_qf"),
217            (fill_score, "hint_high_fill"),
218            (delta_score, "hint_low_delta"),
219        ];
220        // Pick the weakest factor.
221        factors.iter()
222            .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
223            .map_or("hint_low_repetition", |(_, key)| *key)
224    };
225
226    EncodeQuality {
227        score,
228        hint_key: hint_key.to_string(),
229        mode: 2,
230    }
231}
232
233/// Deterministic tanh approximation (avoids f64::tanh which is not
234/// deterministic on WASM). Uses the identity tanh(x) = (e^2x - 1)/(e^2x + 1)
235/// with det_math::det_exp for the exponential.
236fn det_tanh(x: f64) -> f64 {
237    if x > 10.0 { return 1.0; }
238    if x < -10.0 { return -1.0; }
239    // f64::exp is NOT IEEE 754 mandated to be correctly-rounded — it lowers to
240    // libm / Math.exp on WASM, breaking cross-platform reproducibility. Use
241    // det_exp for bit-exact behaviour across iOS / Android / x86_64 / WASM.
242    let e2x = det_exp(2.0 * x);
243    (e2x - 1.0) / (e2x + 1.0)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn ghost_perfect_stealth() {
252        let m = GhostMetrics {
253            num_modifications: 0,
254            n_used: 10000,
255            w: 10,
256            total_cost: 0.0,
257            median_cost: 20.0,
258            is_si: true,
259            shadow_modifications: 0,
260            total_coefficients: 100000,
261        };
262        let q = ghost_stealth_score(&m);
263        assert_eq!(q.mode, 1);
264        assert!(q.score >= 90, "expected >= 90 for perfect stealth, got {}", q.score);
265    }
266
267    #[test]
268    fn ghost_worst_case() {
269        let m = GhostMetrics {
270            num_modifications: 5000,
271            n_used: 10000,
272            w: 1,
273            total_cost: 100000.0,
274            median_cost: 1.0,
275            is_si: false,
276            shadow_modifications: 0,
277            total_coefficients: 100000,
278        };
279        let q = ghost_stealth_score(&m);
280        assert!(q.score <= 30, "expected <= 30 for worst case, got {}", q.score);
281    }
282
283    #[test]
284    fn ghost_shadow_penalty() {
285        let base = GhostMetrics {
286            num_modifications: 100,
287            n_used: 10000,
288            w: 7,
289            total_cost: 500.0,
290            median_cost: 15.0,
291            is_si: false,
292            shadow_modifications: 0,
293            total_coefficients: 100000,
294        };
295        let no_shadow = ghost_stealth_score(&base);
296
297        let with_shadow = GhostMetrics {
298            num_modifications: 100,
299            n_used: 10000,
300            w: 7,
301            total_cost: 500.0,
302            median_cost: 15.0,
303            is_si: false,
304            shadow_modifications: 1000,
305            total_coefficients: 100000,
306        };
307        let shadow_q = ghost_stealth_score(&with_shadow);
308        assert!(shadow_q.score < no_shadow.score, "shadow penalty should reduce score");
309        assert_eq!(shadow_q.hint_key, "hint_shadow_penalty");
310    }
311
312    #[test]
313    fn ghost_si_bonus() {
314        let without_si = GhostMetrics {
315            num_modifications: 50,
316            n_used: 10000,
317            w: 8,
318            total_cost: 200.0,
319            median_cost: 15.0,
320            is_si: false,
321            shadow_modifications: 0,
322            total_coefficients: 100000,
323        };
324        let with_si = GhostMetrics {
325            num_modifications: 50,
326            n_used: 10000,
327            w: 8,
328            total_cost: 200.0,
329            median_cost: 15.0,
330            is_si: true,
331            shadow_modifications: 0,
332            total_coefficients: 100000,
333        };
334        let q1 = ghost_stealth_score(&without_si);
335        let q2 = ghost_stealth_score(&with_si);
336        assert!(q2.score > q1.score, "SI should increase score");
337        assert_eq!(q2.hint_key, "hint_si_bonus");
338    }
339
340    #[test]
341    fn armor_fortress_high_score() {
342        let m = ArmorMetrics {
343            repetition_factor: 15,
344            parity_symbols: 240,
345            fortress: true,
346            mean_qt: 10.0,
347            fill_ratio: 0.3,
348            delta: 12.0,
349        };
350        let q = armor_robustness_score(&m);
351        assert_eq!(q.mode, 2);
352        assert!(q.score >= 75, "expected >= 75 for fortress, got {}", q.score);
353        assert_eq!(q.hint_key, "hint_fortress_active");
354    }
355
356    #[test]
357    fn armor_phase1_low_score() {
358        let m = ArmorMetrics {
359            repetition_factor: 1,
360            parity_symbols: 64,
361            fortress: false,
362            mean_qt: 3.0,
363            fill_ratio: 0.9,
364            delta: 5.0,
365        };
366        let q = armor_robustness_score(&m);
367        assert!(q.score <= 40, "expected <= 40 for phase1 near capacity, got {}", q.score);
368    }
369
370    #[test]
371    fn armor_phase2_medium_score() {
372        let m = ArmorMetrics {
373            repetition_factor: 5,
374            parity_symbols: 192,
375            fortress: false,
376            mean_qt: 15.0,
377            fill_ratio: 0.5,
378            delta: 20.0,
379        };
380        let q = armor_robustness_score(&m);
381        assert!(q.score >= 50 && q.score <= 85, "expected 50-85, got {}", q.score);
382    }
383
384    #[test]
385    fn armor_qf50_max_resilience() {
386        let m = ArmorMetrics {
387            repetition_factor: 7,
388            parity_symbols: 240,
389            fortress: false,
390            mean_qt: 25.0,
391            fill_ratio: 0.2,
392            delta: 40.0,
393        };
394        let q = armor_robustness_score(&m);
395        assert!(q.score >= 80, "expected >= 80 for QF50 + r=7, got {}", q.score);
396    }
397
398    #[test]
399    fn score_clamped_0_100() {
400        // Extreme metrics shouldn't produce values outside 0-100.
401        let q = ghost_stealth_score(&GhostMetrics {
402            num_modifications: 100000,
403            n_used: 1,
404            w: 0,
405            total_cost: 999999.0,
406            median_cost: 0.0,
407            is_si: false,
408            shadow_modifications: 100000,
409            total_coefficients: 1,
410        });
411        assert!(q.score <= 100);
412
413        let q = armor_robustness_score(&ArmorMetrics {
414            repetition_factor: 100,
415            parity_symbols: 500,
416            fortress: true,
417            mean_qt: 50.0,
418            fill_ratio: -1.0,
419            delta: 100.0,
420        });
421        assert!(q.score <= 100);
422    }
423
424    #[test]
425    fn det_tanh_basics() {
426        assert!((det_tanh(0.0)).abs() < 1e-10);
427        assert!((det_tanh(1.0) - 0.7615941559557649).abs() < 1e-10);
428        assert!((det_tanh(20.0) - 1.0).abs() < 1e-10);
429        assert!((det_tanh(-20.0) + 1.0).abs() < 1e-10);
430    }
431}