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