Skip to main content

protocol_droid/domain/ir/lower/
report.rs

1//! `TranslationReport` — the record of what lowering changed.
2//!
3//! # The property this type exists to make checkable
4//!
5//! "Nothing is silently dropped." That is the crate's claim, and a claim is
6//! worth nothing without a way to check it. This report is the check: after any
7//! translation, the caller can enumerate every rule that fired, see what it
8//! cost, and decide whether the cost was one they agreed to.
9//!
10//! It matters most in the *quiet* case. A translation that succeeds and drops a
11//! resource link is the one nobody notices, because there is no error to grep
12//! for. A report turns it into a value that can be counted, logged, or
13//! asserted on in a test — and the distinction between
14//! [`Loss::None`] and [`Loss::Flagged`] is exactly the distinction between "we
15//! agreed to this" and "we did not know".
16//!
17//! # Why it accumulates rather than summarising
18//!
19//! A count of "3 rules fired" loses the *which*, and the which is the part a
20//! reviewer needs. Two results that both dropped content can be perfectly fine
21//! and a serious bug, and the difference is whether it was a resource link the
22//! caller agreed to lose or an image nobody mentioned.
23//!
24//! The count is still available via [`TranslationReport::len`], so both
25//! questions can be asked.
26
27use super::rule::{DegradeRule, Loss};
28use crate::domain::ProtocolVersion;
29
30/// One thing lowering did, and what it cost.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct TranslationStep {
33    /// The rule that fired.
34    pub rule: DegradeRule,
35    /// The revision the value was being lowered *to*, which is the only
36    /// context that makes the step meaningful.
37    pub target: ProtocolVersion,
38}
39
40impl TranslationStep {
41    /// A step, with the revision it happened at.
42    #[must_use]
43    pub const fn new(rule: DegradeRule, target: ProtocolVersion) -> Self {
44        Self { rule, target }
45    }
46
47    /// What the step cost.
48    #[must_use]
49    pub const fn loss(&self) -> Loss {
50        self.rule.loss()
51    }
52}
53
54/// What one translation changed.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct TranslationReport {
57    steps: Vec<TranslationStep>,
58    target: Option<ProtocolVersion>,
59}
60
61impl TranslationReport {
62    /// A report for a translation to `target`, with nothing changed yet.
63    #[must_use]
64    pub const fn new(target: ProtocolVersion) -> Self {
65        Self {
66            steps: Vec::new(),
67            target: Some(target),
68        }
69    }
70
71    /// Records a rule firing.
72    pub fn record(&mut self, rule: DegradeRule) {
73        let target = self.target.unwrap_or_else(ProtocolVersion::latest_known);
74        self.steps.push(TranslationStep::new(rule, target));
75    }
76
77    /// Whether anything changed.
78    #[must_use]
79    pub const fn is_empty(&self) -> bool {
80        self.steps.is_empty()
81    }
82
83    /// How many rules fired.
84    #[must_use]
85    pub const fn len(&self) -> usize {
86        self.steps.len()
87    }
88
89    /// The steps, in the order they were applied.
90    #[must_use]
91    pub fn steps(&self) -> &[TranslationStep] {
92        &self.steps
93    }
94
95    /// The worst loss any step caused.
96    ///
97    /// [`None`] when nothing fired, so an empty report is lossless rather than
98    /// accidentally alarming.
99    #[must_use]
100    pub fn worst_loss(&self) -> Loss {
101        self.steps
102            .iter()
103            .map(TranslationStep::loss)
104            .max()
105            .unwrap_or(Loss::None)
106    }
107
108    /// Whether every step was one a caller could accept as "lossless or
109    /// flagged".
110    #[must_use]
111    pub fn is_lossless_or_flagged(&self) -> bool {
112        self.worst_loss().is_acceptable()
113    }
114
115    /// Whether the translation was lossless.
116    #[must_use]
117    pub fn is_lossless(&self) -> bool {
118        self.worst_loss().is_lossless()
119    }
120
121    /// The features that were given up, deduplicated.
122    ///
123    /// Skips reductions, which name no feature — a caller asking "which features
124    /// did this cost me" wants the answerable half, and a reduction has no
125    /// feature because no revision forced it.
126    #[must_use]
127    pub fn lost_features(&self) -> Vec<crate::domain::Feature> {
128        let mut out: Vec<crate::domain::Feature> = Vec::new();
129        for step in &self.steps {
130            let Some(feature) = step.rule.feature() else {
131                continue;
132            };
133            if !out.contains(&feature) {
134                out.push(feature);
135            }
136        }
137        out
138    }
139
140    /// Merges another report into this one.
141    ///
142    /// Used when a batch of messages is translated and the caller wants one
143    /// answer. The target is the receiver's — the reports being merged came
144    /// from translations to it — so it is taken from `other`.
145    pub fn absorb(&mut self, other: &Self) {
146        if self.target.is_none() {
147            self.target = other.target;
148        }
149        self.steps.extend_from_slice(&other.steps);
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    #![expect(
156        clippy::panic,
157        reason = "tests panic with the failing precondition, which is the useful part"
158    )]
159
160    use super::TranslationReport;
161    use crate::domain::ir::lower::rule::{DegradeRule, Loss};
162    use crate::domain::{Feature, ProtocolVersion};
163
164    fn report() -> TranslationReport {
165        TranslationReport::new(ProtocolVersion::V2025_03_26)
166    }
167
168    #[test]
169    fn a_fresh_report_is_lossless() {
170        let report = report();
171        assert!(report.is_empty());
172        assert_eq!(report.len(), 0);
173        assert_eq!(report.worst_loss(), Loss::None);
174        assert!(report.is_lossless());
175        assert!(report.is_lossless_or_flagged());
176    }
177
178    #[test]
179    fn a_lossless_step_stays_lossless() {
180        let mut report = report();
181        report.record(DegradeRule::StructuredContentToText);
182        assert_eq!(report.len(), 1);
183        assert!(report.is_lossless());
184        assert!(report.is_lossless_or_flagged());
185        assert!(!report.is_empty());
186    }
187
188    /// The distinction that makes the report worth having.
189    #[test]
190    fn flagged_is_not_lossless_but_is_acceptable() {
191        let mut report = report();
192        report.record(DegradeRule::DropAnnotations);
193        assert_eq!(report.worst_loss(), Loss::Flagged);
194        assert!(!report.is_lossless());
195        assert!(report.is_lossless_or_flagged());
196    }
197
198    #[test]
199    fn a_destructive_step_is_neither() {
200        let mut report = report();
201        report.record(DegradeRule::AudioToTextPlaceholder);
202        assert_eq!(report.worst_loss(), Loss::Destructive);
203        assert!(!report.is_lossless());
204        assert!(!report.is_lossless_or_flagged());
205    }
206
207    /// The worst loss wins, so a report containing one acceptable and one
208    /// destructive step is destructive. Anything else would let a caller scan
209    /// for a bad step and miss it.
210    #[test]
211    fn the_worst_loss_dominates() {
212        let mut report = report();
213        report.record(DegradeRule::StructuredContentToText);
214        report.record(DegradeRule::AudioToTextPlaceholder);
215        report.record(DegradeRule::DropAnnotations);
216        assert_eq!(report.len(), 3);
217        assert_eq!(report.worst_loss(), Loss::Destructive);
218        assert!(!report.is_lossless_or_flagged());
219    }
220
221    #[test]
222    fn every_step_keeps_the_target_revision() {
223        let mut report = report();
224        report.record(DegradeRule::DropAudio);
225        let Some(step) = report.steps().first() else {
226            panic!("expected one step");
227        };
228        assert_eq!(step.target, ProtocolVersion::V2025_03_26);
229    }
230
231    /// The *which* is the part a reviewer needs, so deduplication is only for
232    /// the summary — the steps themselves keep every occurrence.
233    #[test]
234    fn steps_accumulate_and_features_deduplicate() {
235        let mut report = report();
236        report.record(DegradeRule::DropAudio);
237        report.record(DegradeRule::AudioToTextPlaceholder);
238        report.record(DegradeRule::DropImage);
239        assert_eq!(report.len(), 3);
240        // Both audio rules give up the same feature, so it appears once.
241        let lost = report.lost_features();
242        assert_eq!(lost, vec![Feature::ToolAudioContent]);
243    }
244
245    #[test]
246    fn absorbing_merges_steps() {
247        let mut first = report();
248        first.record(DegradeRule::DropAnnotations);
249
250        let mut second = TranslationReport::new(ProtocolVersion::V2025_03_26);
251        second.record(DegradeRule::StructuredContentToText);
252
253        first.absorb(&second);
254        assert_eq!(first.len(), 2);
255        assert!(first.is_lossless_or_flagged());
256    }
257}