Skip to main content

protocol_droid/domain/ir/lower/
rule.rs

1//! `DegradeRule` — the enumerable set of downgrades a caller may opt into.
2//!
3//! # Why this is an enum and not a callback
4//!
5//! The alternative to an enum is a closure: "here is what to do when the value
6//! needs a feature the target lacks". That is more flexible and it is exactly
7//! wrong here, for two reasons.
8//!
9//! **A callback cannot be audited.** A `TranslationReport` is only useful if a
10//! reader can check, before running a bridge, exactly what it will and will not
11//! lose. A list of enum variants is greppable and snapshot-testable; a set of
12//! closures is neither.
13//!
14//! **A callback fails open.** Given a feature it has never seen — one added by
15//! a revision this build predates — the only way to stay forward-compatible is
16//! to do *something*, and "something" is where silent data loss comes from. An
17//! enum has no arm for a feature it does not name, so the default is an error.
18//!
19//! # Loss is a property of the rule, not of the caller
20//!
21//! Each rule declares whether it is [`Loss::None`], [`Loss::Flagged`], or
22//! [`Loss::Destructive`]. That is what lets a bridge say "this translation is
23//! lossless or flagged, as promised" and have that mean something checkable
24//! rather than a claim about intent. A rule that claimed to be lossless while
25//! dropping a field would pass a hand-written policy and fail the report.
26//!
27//! `AudioToTextPlaceholder` is [`Loss::Destructive`] and is the only rule with
28//! no lossless alternative in the specification — there is no textual stand-in
29//! for audio the way there is for structured JSON — so it is forbidden unless a
30//! caller names it explicitly.
31
32use crate::domain::Feature;
33
34/// How much a rule gives up.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum Loss {
37    /// Nothing is lost. The information is still there, just in another shape.
38    None,
39    /// Information is lost, but the loss is recorded in the report and the
40    /// caller has asked for it.
41    Flagged,
42    /// Information is lost in a way that changes what the value *means*.
43    ///
44    /// Not the same as [`Loss::Flagged`]: a text placeholder for audio is not a
45    /// degraded audio block, it is a different thing that says it stands in
46    /// for audio. A caller that did not ask for that is not getting what it
47    /// asked for.
48    Destructive,
49}
50
51impl Loss {
52    /// Whether nothing is lost.
53    #[must_use]
54    pub const fn is_lossless(self) -> bool {
55        matches!(self, Self::None)
56    }
57
58    /// Whether the rule is safe for a caller that promised "lossless or
59    /// flagged".
60    #[must_use]
61    pub const fn is_acceptable(self) -> bool {
62        matches!(self, Self::None | Self::Flagged)
63    }
64}
65
66/// One thing lowering may do to make a value expressible.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub enum DegradeRule {
69    /// Serialise `structuredContent` into a text content block.
70    ///
71    /// Added `2025-06-18`; the specification names this as the recommended
72    /// fallback. Lossless: the bytes are unchanged, only the carrier differs.
73    StructuredContentToText,
74
75    /// Drop annotations a revision does not define.
76    ///
77    /// [`Loss::Flagged`]. Annotations arrived in `2025-03-26` and a
78    /// `2024-11-05` peer simply ignores them, so nothing that peer could have
79    /// acted on is lost — but a bridge that drops them without saying so is
80    /// lying about what happened.
81    DropAnnotations,
82
83    /// Replace an audio block with a text placeholder naming it.
84    ///
85    /// [`Loss::Destructive`], and there is no lossless alternative: there is no
86    /// textual stand-in for audio. For this reason it is the one rule the
87    /// specification does not sanction, and a caller must name it.
88    AudioToTextPlaceholder,
89
90    /// Drop an audio block.
91    DropAudio,
92
93    /// Drop an image block.
94    DropImage,
95
96    /// Drop a resource link, `2025-06-18` and later.
97    DropResourceLink,
98
99    /// Drop an embedded resource.
100    DropResource,
101}
102
103impl DegradeRule {
104    /// Every rule a [`DegradePolicy`](crate::domain::DegradePolicy) can select
105    /// to satisfy a missing feature, in a stable order so a policy set or a
106    /// report reads the same way twice.
107    ///
108    /// Every rule here names a feature, which is the invariant that keeps the
109    /// list honest: a rule in this list is reachable by `rule_for`, and a rule
110    /// that is *not* in it is not reachable by a policy at all.
111    ///
112    /// The three that are not listed — [`AudioToTextPlaceholder`](Self::AudioToTextPlaceholder),
113    /// [`DropImage`](Self::DropImage) and [`DropResource`](Self::DropResource) — are opt-in
114    /// reductions rather than
115    /// fallbacks. The placeholder because the specification does not sanction it
116    /// and dropping the block is the only policy-selectable response to audio;
117    /// the other two because images and embedded resources are expressible at
118    /// every revision, so no revision's absence ever requires dropping them.
119    /// Listing any of them would let `rule_for` hand back something a caller
120    /// never asked for, or that could never apply.
121    pub const ALL: &'static [Self] = &[
122        Self::StructuredContentToText,
123        Self::DropAnnotations,
124        Self::DropAudio,
125        Self::DropResourceLink,
126    ];
127
128    /// The feature whose absence this rule exists to work around.
129    ///
130    /// `None` for a rule that is not a *fallback* — a reduction a caller opts
131    /// into for its own reasons, not something a revision forces. Images and
132    /// embedded resources have been expressible since `2024-11-05` and
133    /// annotations are simply ignored by older peers, so there is no revision
134    /// whose absence ever makes dropping them necessary. Reporting a feature
135    /// for them would be a lie about why the rule exists, and would make
136    /// [`rule_for`](super::rule_for) offer them as answers to a question they
137    /// cannot answer.
138    #[must_use]
139    pub const fn feature(self) -> Option<Feature> {
140        match self {
141            Self::StructuredContentToText => Some(Feature::ToolStructuredOutput),
142            Self::DropAnnotations => Some(Feature::ToolAnnotations),
143            Self::AudioToTextPlaceholder | Self::DropAudio => Some(Feature::ToolAudioContent),
144            Self::DropResourceLink => Some(Feature::ToolResourceLinks),
145            Self::DropImage | Self::DropResource => None,
146        }
147    }
148
149    /// How much this rule gives up.
150    ///
151    /// Only [`DropAnnotations`](Self::DropAnnotations) is [`Loss::Flagged`]. Every rule that
152    /// removes a *content block* is [`Loss::Destructive`], because a block the tool
153    /// produced is the tool's answer: dropping it does not degrade the message,
154    /// it makes it say something the tool did not. Calling that "flagged" would
155    /// let a bridge promise "lossless or flagged" and quietly remove content.
156    #[must_use]
157    pub const fn loss(self) -> Loss {
158        match self {
159            Self::StructuredContentToText => Loss::None,
160            Self::DropAnnotations => Loss::Flagged,
161            Self::AudioToTextPlaceholder
162            | Self::DropAudio
163            | Self::DropImage
164            | Self::DropResourceLink
165            | Self::DropResource => Loss::Destructive,
166        }
167    }
168
169    /// Whether this rule is the one that makes a value needing `feature`
170    /// expressible again.
171    ///
172    /// Not `const`: `Feature`'s `PartialEq` is not const-callable, the same
173    /// trade T05 made on `defined_in`.
174    #[must_use]
175    pub fn is_fallback_for(self, feature: Feature) -> bool {
176        matches!(self.feature(), Some(f) if f == feature)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    #![expect(
183        clippy::panic,
184        reason = "tests panic with the failing precondition, which is the useful part"
185    )]
186
187    use super::{DegradeRule, Loss};
188    use crate::domain::Feature;
189
190    #[test]
191    fn every_rule_names_a_loss() {
192        for rule in DegradeRule::ALL {
193            let _ = rule.loss();
194        }
195        assert_eq!(DegradeRule::ALL.len(), 4);
196    }
197
198    /// Images and embedded resources are expressible at every revision, so no
199    /// rule can be a *fallback* for them — they are reductions a caller asks
200    /// for, not answers to a missing feature.
201    #[test]
202    fn reductions_are_not_mistaken_for_fallbacks() {
203        assert_eq!(DegradeRule::DropImage.feature(), None);
204        assert_eq!(DegradeRule::DropResource.feature(), None);
205        assert!(!DegradeRule::DropImage.is_fallback_for(Feature::Tools));
206        assert_eq!(
207            DegradeRule::DropResourceLink.feature(),
208            Some(Feature::ToolResourceLinks),
209        );
210        assert!(DegradeRule::DropResourceLink.is_fallback_for(Feature::ToolResourceLinks));
211    }
212
213    /// Every rule that names a feature must match that feature. Catches a
214    /// `feature()` arm and a `loss()` arm drifting apart, which is the way a
215    /// rule ends up claiming to work around a feature it does not address.
216    #[test]
217    fn a_rule_matches_the_feature_it_names() {
218        for rule in DegradeRule::ALL {
219            let Some(feature) = rule.feature() else {
220                continue;
221            };
222            assert!(
223                rule.is_fallback_for(feature),
224                "{rule:?} names {feature} but does not match it",
225            );
226            for other in DegradeRule::ALL {
227                if other != rule {
228                    assert!(
229                        !other.is_fallback_for(feature) || other.feature() == Some(feature),
230                        "{rule:?} and {other:?} both claim {feature}",
231                    );
232                }
233            }
234        }
235    }
236
237    /// The headline claim: the structured-content fallback is lossless, and the
238    /// audio placeholder is not. If these two ever swap, the whole policy
239    /// vocabulary is lying.
240    #[test]
241    fn lossless_and_destructive_are_not_confused() {
242        assert_eq!(DegradeRule::StructuredContentToText.loss(), Loss::None);
243        assert!(DegradeRule::StructuredContentToText.loss().is_lossless());
244
245        assert_eq!(
246            DegradeRule::AudioToTextPlaceholder.loss(),
247            Loss::Destructive
248        );
249        assert!(!DegradeRule::AudioToTextPlaceholder.loss().is_lossless());
250        assert!(!DegradeRule::AudioToTextPlaceholder.loss().is_acceptable());
251
252        // Flagged is accepted by a "lossless or flagged" promise; destructive
253        // is not.
254        assert!(Loss::Flagged.is_acceptable());
255        assert!(!Loss::Destructive.is_acceptable());
256    }
257
258    /// Every rule that could destroy meaning must be non-acceptable, and the
259    /// one rule with a lossless alternative must be the lossless one.
260    #[test]
261    fn a_lossless_alternative_is_preferred_where_one_exists() {
262        // structuredContent has a spec-named lossless fallback.
263        assert!(DegradeRule::StructuredContentToText.loss().is_acceptable());
264        // Annotations are advisory; an old peer ignores them anyway.
265        assert!(DegradeRule::DropAnnotations.loss().is_acceptable());
266        // Everything that removes content is not.
267        for rule in [
268            DegradeRule::DropAudio,
269            DegradeRule::DropImage,
270            DegradeRule::DropResource,
271            DegradeRule::DropResourceLink,
272        ] {
273            assert!(!rule.loss().is_acceptable(), "{rule:?}");
274        }
275    }
276
277    /// The opt-in placeholder is never something a policy can select, because
278    /// `ALL` is what `rule_for` searches.
279    #[test]
280    fn the_opt_in_rule_is_not_selectable() {
281        assert!(!DegradeRule::ALL.contains(&DegradeRule::AudioToTextPlaceholder));
282        // It is still declared — it is opt-in, not gone.
283        assert_eq!(
284            DegradeRule::AudioToTextPlaceholder.loss(),
285            Loss::Destructive
286        );
287    }
288
289    #[test]
290    fn the_audio_placeholder_covers_only_audio() {
291        assert!(
292            DegradeRule::AudioToTextPlaceholder
293                .is_fallback_for(crate::domain::Feature::ToolAudioContent)
294        );
295        assert!(
296            !DegradeRule::AudioToTextPlaceholder
297                .is_fallback_for(crate::domain::Feature::ToolStructuredOutput)
298        );
299    }
300
301    #[test]
302    fn the_all_list_has_no_duplicates() {
303        let mut seen = Vec::new();
304        for rule in DegradeRule::ALL {
305            assert!(!seen.contains(rule), "{rule:?} twice");
306            seen.push(*rule);
307        }
308    }
309
310    /// Every rule in `ALL` names a feature, and every rule that does not is
311    /// absent from it. The two halves of one invariant: a listed rule must be
312    /// reachable by `rule_for`, and an unlisted rule must not be.
313    #[test]
314    fn a_listed_rule_is_findable_and_an_unlisted_one_is_not() {
315        for rule in DegradeRule::ALL {
316            let Some(feature) = rule.feature() else {
317                panic!("{rule:?} is listed but names no feature");
318            };
319            assert!(rule.is_fallback_for(feature), "{rule:?} / {feature}");
320        }
321        for rule in [
322            DegradeRule::AudioToTextPlaceholder,
323            DegradeRule::DropImage,
324            DegradeRule::DropResource,
325        ] {
326            assert!(
327                !DegradeRule::ALL.contains(&rule),
328                "{rule:?} is opt-in and must not be selectable",
329            );
330        }
331    }
332}