Skip to main content

rustev_contract/
descriptor.rs

1//! Backend capability descriptors, `rustev.backend/1` (spec 002, 3.9.3).
2//! A backend states what it returns; the compiler never assumes more.
3
4use serde::{Deserialize, Serialize};
5
6use crate::definition::{Determinism, Operation};
7use crate::ids::{ArtifactId, DescriptorId};
8use crate::limits::DESCRIPTOR_V1;
9use crate::schema;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct BackendDescriptor {
14    pub schema: String,
15    pub backend_id: String,
16    pub artifact: ArtifactId,
17    pub operations: Vec<OperationSupport>,
18    pub input_limit: InputLimit,
19    pub determinism: Determinism,
20    /// Optional ranking capability; omission preserves existing identities.
21    #[serde(
22        default,
23        skip_serializing_if = "Option::is_none",
24        deserialize_with = "present"
25    )]
26    #[schemars(with = "Ranking")]
27    pub ranking: Option<Ranking>,
28}
29
30crate::document::document!(
31    BackendDescriptor,
32    schema::BACKEND,
33    DESCRIPTOR_V1,
34    DescriptorId
35);
36
37// Parsing goes through `OperationSupportWire`, which denies unknown fields.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(try_from = "OperationSupportWire")]
40pub struct OperationSupport {
41    pub operation: Operation,
42    pub output: OutputKind,
43    /// The most options, levels or candidates one request may carry.
44    pub max_options: u64,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub isolation: Option<Isolation>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub screen: Option<ScreenProfile>,
49}
50
51/// What a backend returns for an operation (design section 8.2).
52#[derive(
53    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
54)]
55#[serde(rename_all = "snake_case", deny_unknown_fields)]
56pub enum OutputKind {
57    Ranking,
58    Label,
59    Scores,
60    Distribution,
61    Logits,
62    Screen,
63    TypedRecord {
64        structure: TextStructure,
65        #[serde(deserialize_with = "crate::generation::bound::<_, { u64::MAX }>")]
66        max_output_bytes: u64,
67        #[serde(deserialize_with = "crate::generation::bound::<_, 32>")]
68        max_options: u64,
69        option_logprobs: bool,
70    },
71    TextRecord {
72        structure: TextStructure,
73        max_output_bytes: u64,
74        languages: Vec<String>,
75        streaming: bool,
76    },
77}
78
79#[derive(
80    Debug,
81    Clone,
82    Copy,
83    PartialEq,
84    Eq,
85    PartialOrd,
86    Ord,
87    Hash,
88    Serialize,
89    Deserialize,
90    schemars::JsonSchema,
91)]
92#[serde(rename_all = "snake_case")]
93pub enum TextStructure {
94    JsonSchema,
95    ToolCall,
96    Grammar,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct InputLimit {
102    /// Largest projected input, in canonical JSON bytes.
103    pub max_bytes: u64,
104    pub on_excess: InputExcess,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum InputExcess {
110    Refuse,
111    TruncateStart,
112    TruncateEnd,
113}
114
115/// Shared isolation has no wire member. An explicit value is always per-item.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
117#[serde(rename_all = "snake_case")]
118pub enum Isolation {
119    PerItem,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
123#[serde(deny_unknown_fields)]
124pub struct ScreenProfile {
125    pub policy_version: String,
126    pub categories: Vec<String>,
127    pub max_text_bytes: u64,
128    pub text_unit_bytes: u64,
129}
130
131#[derive(Deserialize, schemars::JsonSchema)]
132#[serde(deny_unknown_fields)]
133#[schemars(rename = "OperationSupport")]
134struct OperationSupportWire {
135    operation: Operation,
136    output: OutputKind,
137    /// The most options, levels or candidates one request may carry.
138    max_options: u64,
139    // `with` keeps `null` out of the schema: the members are optional, never
140    // nullable, as `present` enforces.
141    #[serde(default, deserialize_with = "present")]
142    #[schemars(with = "Isolation")]
143    isolation: Option<Isolation>,
144    #[serde(default, deserialize_with = "present")]
145    #[schemars(with = "ScreenProfile")]
146    screen: Option<ScreenProfile>,
147}
148
149// Optional members may be absent, but never explicitly null.
150fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
151    deserializer: D,
152) -> Result<Option<T>, D::Error> {
153    T::deserialize(deserializer).map(Some)
154}
155
156impl TryFrom<OperationSupportWire> for OperationSupport {
157    type Error = String;
158
159    fn try_from(w: OperationSupportWire) -> Result<Self, Self::Error> {
160        if (w.output == OutputKind::Screen) != w.screen.is_some() {
161            return Err("screen output requires exactly a screen profile".into());
162        }
163        if let Some(p) = &w.screen
164            && (w.operation != Operation::Proposition
165                || p.policy_version.is_empty()
166                || p.categories.is_empty()
167                || p.categories.len() > 64
168                || p.categories.iter().any(String::is_empty)
169                || p.categories.windows(2).any(|w| w[0] >= w[1])
170                || p.max_text_bytes == 0
171                || p.text_unit_bytes == 0)
172        {
173            return Err("invalid proposition screen profile".into());
174        }
175        if let OutputKind::TextRecord {
176            max_output_bytes,
177            languages,
178            ..
179        } = &w.output
180        {
181            if w.operation != Operation::Generate
182                || *max_output_bytes == 0
183                || w.max_options == 0
184                || languages.is_empty()
185                || languages.iter().any(|s| !language_tag(s))
186                || languages
187                    .iter()
188                    .map(|s| s.to_ascii_lowercase())
189                    .collect::<std::collections::BTreeSet<_>>()
190                    .len()
191                    != languages.len()
192            {
193                return Err("invalid generate text_record capability".into());
194            }
195        } else if w.operation == Operation::Generate {
196            return Err("generate requires text_record output".into());
197        }
198        if matches!(w.output, OutputKind::TypedRecord { .. }) != (w.operation == Operation::Extract)
199        {
200            return Err("extract requires typed_record output exclusively".into());
201        }
202        Ok(Self {
203            operation: w.operation,
204            output: w.output,
205            max_options: w.max_options,
206            isolation: w.isolation,
207            screen: w.screen,
208        })
209    }
210}
211
212impl OperationSupport {
213    /// Screening has its own required kind and is never coerced to a label.
214    pub fn satisfies_screen_verdict(&self) -> bool {
215        self.operation == Operation::Proposition
216            && self.output == OutputKind::Screen
217            && self.screen.is_some()
218    }
219}
220
221// Syntactic language tags, including private-use and grandfathered tags.
222// Registry membership is deliberately not a parse-time network dependency.
223fn language_tag(s: &str) -> bool {
224    let lower = s.to_ascii_lowercase();
225    if [
226        "en-gb-oed",
227        "i-ami",
228        "i-bnn",
229        "i-default",
230        "i-enochian",
231        "i-hak",
232        "i-klingon",
233        "i-lux",
234        "i-mingo",
235        "i-navajo",
236        "i-pwn",
237        "i-tao",
238        "i-tay",
239        "i-tsu",
240        "sgn-be-fr",
241        "sgn-be-nl",
242        "sgn-ch-de",
243    ]
244    .contains(&lower.as_str())
245    {
246        return true;
247    }
248    let parts: Vec<_> = lower.split('-').collect();
249    let alpha = |p: &str| p.bytes().all(|b| b.is_ascii_alphabetic());
250    let alnum = |p: &str| p.bytes().all(|b| b.is_ascii_alphanumeric());
251    if parts
252        .iter()
253        .any(|p| p.is_empty() || p.len() > 8 || !alnum(p))
254    {
255        return false;
256    }
257    if parts[0] == "x" {
258        return parts.len() > 1;
259    }
260    if !(2..=8).contains(&parts[0].len()) || !alpha(parts[0]) {
261        return false;
262    }
263    let mut i = 1;
264    if parts[0].len() <= 3 {
265        for _ in 0..3 {
266            if i < parts.len() && parts[i].len() == 3 && alpha(parts[i]) {
267                i += 1;
268            } else {
269                break;
270            }
271        }
272    }
273    if i < parts.len() && parts[i].len() == 4 && alpha(parts[i]) {
274        i += 1;
275    }
276    if i < parts.len()
277        && ((parts[i].len() == 2 && alpha(parts[i]))
278            || (parts[i].len() == 3 && parts[i].bytes().all(|b| b.is_ascii_digit())))
279    {
280        i += 1;
281    }
282    let mut variants = std::collections::BTreeSet::new();
283    while i < parts.len()
284        && (parts[i].len() >= 5 || (parts[i].len() == 4 && parts[i].as_bytes()[0].is_ascii_digit()))
285    {
286        if !variants.insert(parts[i]) {
287            return false;
288        }
289        i += 1;
290    }
291    let mut extensions = std::collections::BTreeSet::new();
292    while i < parts.len() && parts[i].len() == 1 && parts[i] != "x" {
293        if !extensions.insert(parts[i]) {
294            return false;
295        }
296        i += 1;
297        let start = i;
298        while i < parts.len() && parts[i].len() >= 2 {
299            i += 1;
300        }
301        if start == i {
302            return false;
303        }
304    }
305    if i < parts.len() && parts[i] == "x" {
306        return i + 1 < parts.len();
307    }
308    i == parts.len()
309}
310
311/// Ranking capability (spec 039, 3.3).
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct Ranking {
315    #[serde(deserialize_with = "ranking_modes")]
316    pub modes: Vec<RankingMode>,
317    #[serde(deserialize_with = "crate::ranking::bound::<_, 1, 1024>")]
318    pub max_candidates: u64,
319    pub listwise_scores: ListwiseScores,
320    pub listwise_partial: ListwisePartial,
321}
322#[derive(
323    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
324)]
325#[serde(rename_all = "snake_case")]
326pub enum RankingMode {
327    Listwise,
328    Pointwise,
329    Pairwise,
330}
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
332#[serde(rename_all = "snake_case")]
333pub enum ListwiseScores {
334    Never,
335    Always,
336    Optional,
337}
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
339#[serde(rename_all = "snake_case")]
340pub enum ListwisePartial {
341    Possible,
342    Never,
343}
344fn ranking_modes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<RankingMode>, D::Error> {
345    let modes = Vec::<RankingMode>::deserialize(d)?;
346    if modes.is_empty() || modes.windows(2).any(|w| w[0] >= w[1]) {
347        return Err(serde::de::Error::custom(
348            "ranking modes must be nonempty, ordered and unique",
349        ));
350    }
351    Ok(modes)
352}