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