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}