Skip to main content

rustev_contract/
generation.rs

1//! Bounded generation declarations and plan bindings (spec 037).
2//! Template source is retained verbatim. Core owns template compilation.
3use crate::{
4    Decimal,
5    definition::{BackendReq, ItemSource, Literal},
6    ids::{ArtifactId, DescriptorId, PromptTemplateId},
7};
8use serde::{Deserialize, Deserializer, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct Generate {
14    pub when: GenerateWhen,
15    pub for_each: ModelForEach,
16    pub template: TemplateRef,
17    pub language: Language,
18    // Unbounded in the type, like a semantic step's `project` (spec 002
19    // 3.9.1): the definition's parse limits bound the list, and the compiler
20    // bounds what it projects through `max_projection_bytes` (3.1.2, phase g,
21    // category 7). `carry` has its own cap because the field table gives it one (0 to 16).
22    pub projection: Vec<String>,
23    #[serde(deserialize_with = "carry")]
24    pub carry: Vec<String>,
25    pub vocabularies: BTreeMap<String, Vocabulary>,
26    pub output: OutputSchema,
27    pub backstop: Backstop,
28    pub repair: Repair,
29    pub screen: GenerateScreen,
30    pub limits: ModelLimits,
31    pub backend: BackendReq,
32}
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
34#[serde(rename_all = "snake_case", deny_unknown_fields)]
35pub enum GenerateWhen {
36    AnyPropose,
37    Actions(Vec<String>),
38}
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
40#[serde(rename_all = "snake_case", deny_unknown_fields)]
41pub enum ModelForEach {
42    None,
43    #[serde(untagged)]
44    List(#[serde(deserialize_with = "object")] ModelList),
45}
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct ModelList {
49    pub over: String,
50    pub bind: String,
51    pub items: ItemSource,
52    pub max_items: ModelBound<256>,
53}
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
55#[serde(rename_all = "snake_case", deny_unknown_fields)]
56pub enum ModelBound<const MAX: u64> {
57    Literal(#[serde(deserialize_with = "bound::<_, MAX>")] u64),
58    Param(String),
59}
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
61#[serde(untagged, deny_unknown_fields)]
62pub enum Language {
63    Literal(String),
64    Parameter(#[serde(deserialize_with = "object")] crate::parameter::ParamRef),
65}
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
67#[serde(untagged, deny_unknown_fields)]
68pub enum Vocabulary {
69    Literal(#[serde(deserialize_with = "options")] Vec<String>),
70    Parameter(#[serde(deserialize_with = "object")] crate::parameter::ParamRef),
71}
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct TemplateRef {
75    #[serde(deserialize_with = "template_name")]
76    pub id: String,
77    pub digest: PromptTemplateId,
78    pub params: BTreeMap<String, TemplateBinding>,
79}
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
81#[serde(untagged, deny_unknown_fields)]
82pub enum TemplateBinding {
83    Literal { literal: Literal },
84    Parameter(#[serde(deserialize_with = "object")] crate::parameter::ParamRef),
85    Reference(String),
86}
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
88#[serde(deny_unknown_fields)]
89pub struct OutputSchema {
90    #[serde(deserialize_with = "fields")]
91    pub fields: Vec<GeneratedField>,
92}
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct GeneratedField {
96    pub name: String,
97    #[serde(rename = "type")]
98    pub ty: GeneratedType,
99    pub required: bool,
100}
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
102#[serde(rename_all = "snake_case", deny_unknown_fields)]
103pub enum GeneratedType {
104    Text {
105        #[serde(deserialize_with = "bound::<_, 16384>")]
106        max_bytes: u64,
107        lines: Lines,
108    },
109    List {
110        vocabulary: String,
111        #[serde(deserialize_with = "bound::<_, 64>")]
112        max_items: u64,
113    },
114}
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
116#[serde(rename_all = "snake_case", deny_unknown_fields)]
117pub enum Lines {
118    Single,
119    Multi,
120}
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
122#[serde(deny_unknown_fields)]
123pub struct Backstop {
124    pub locale: String,
125    pub escape: Escape,
126    pub overflow: Overflow,
127    pub fields: BTreeMap<String, BackstopField>,
128}
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
130#[serde(rename_all = "snake_case", deny_unknown_fields)]
131pub enum Escape {
132    Text,
133    Html,
134}
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
136#[serde(rename_all = "snake_case", deny_unknown_fields)]
137pub enum Overflow {
138    Refuse,
139    Truncate,
140}
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
142#[serde(rename_all = "snake_case", deny_unknown_fields)]
143pub enum BackstopField {
144    Text(String),
145    List(Vec<String>),
146}
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct Repair {
150    #[serde(deserialize_with = "repair_attempts")]
151    pub max_attempts: u64,
152    pub only_missing_fields: bool,
153}
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
155#[serde(deny_unknown_fields)]
156pub struct GenerateScreen {
157    pub input: ScreenInput,
158    pub output: ScreenOutput,
159}
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
161#[serde(rename_all = "snake_case", deny_unknown_fields)]
162pub enum ScreenInput {
163    UntrustedFields,
164}
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
166#[serde(rename_all = "snake_case", deny_unknown_fields)]
167pub enum ScreenOutput {
168    None,
169    Screener { backend: BackendReq },
170}
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
172#[serde(deny_unknown_fields)]
173pub struct ModelLimits {
174    #[serde(deserialize_with = "deadline_share")]
175    pub deadline_share: Decimal,
176    pub per_item_deadline_ms: ModelBound<600000>,
177    #[serde(deserialize_with = "bound::<_, 64>")]
178    pub concurrency: u64,
179    pub max_cost_units: CostLimit,
180}
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
182#[serde(rename_all = "snake_case", deny_unknown_fields)]
183pub enum CostLimit {
184    None,
185    Units {
186        #[serde(deserialize_with = "bound::<_, {u64::MAX}>")]
187        n: u64,
188    },
189}
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
191#[serde(deny_unknown_fields)]
192pub struct PromptTemplate {
193    pub schema: String,
194    #[serde(deserialize_with = "template_name")]
195    pub id: String,
196    #[serde(deserialize_with = "version")]
197    pub version: String,
198    #[serde(deserialize_with = "instructions")]
199    pub instructions: String,
200    #[serde(deserialize_with = "params")]
201    pub params: Vec<PromptParam>,
202}
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct PromptParam {
206    pub name: String,
207    #[serde(rename = "type")]
208    pub ty: PromptParamType,
209}
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
211#[serde(rename_all = "snake_case", deny_unknown_fields)]
212pub enum PromptParamType {
213    Bool,
214    Integer,
215    Decimal,
216    Text {
217        #[serde(deserialize_with = "bound::<_, 1024>")]
218        max_bytes: u64,
219    },
220    Enum {
221        #[serde(deserialize_with = "options")]
222        options: Vec<String>,
223    },
224}
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
226#[serde(deny_unknown_fields)]
227pub struct GenerateBinding {
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub output_screener: Option<crate::screening::ScreenerBinding>,
230    pub template_id: PromptTemplateId,
231    pub template: PromptTemplate,
232    pub helpers_version: String,
233    pub backend_id: String,
234    pub artifact: ArtifactId,
235    pub descriptor: DescriptorId,
236    pub output: OutputSchema,
237    pub vocabularies: BTreeMap<String, Vec<String>>,
238    pub max_items: u64,
239    pub worst_case_requests: u64,
240    pub worst_case_projection_bytes: u64,
241}
242
243crate::document::document!(
244    PromptTemplate,
245    crate::schema::PROMPT_TEMPLATE,
246    crate::limits::DEFINITION_V1,
247    PromptTemplateId
248);
249
250pub(crate) fn bound<'de, D: Deserializer<'de>, const MAX: u64>(d: D) -> Result<u64, D::Error> {
251    let n = u64::deserialize(d)?;
252    if !(1..=MAX).contains(&n) {
253        return Err(serde::de::Error::custom("generation bound out of range"));
254    }
255    Ok(n)
256}
257fn repair_attempts<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
258    let n = u64::deserialize(d)?;
259    if n > 2 {
260        return Err(serde::de::Error::custom("repair.max_attempts exceeds 2"));
261    }
262    Ok(n)
263}
264pub(crate) fn deadline_share<'de, D: Deserializer<'de>>(d: D) -> Result<Decimal, D::Error> {
265    let text = String::deserialize(d)?;
266    let n: Decimal = Decimal::parse(&text).map_err(serde::de::Error::custom)?;
267    if n <= Decimal::ZERO
268        || n > Decimal::from_i64(1)
269        || text.split_once('.').is_some_and(|(_, f)| f.len() > 4)
270    {
271        return Err(serde::de::Error::custom(
272            "deadline_share outside (0,1] or over four fractional digits",
273        ));
274    }
275    Ok(n)
276}
277fn carry<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
278    let v = Vec::deserialize(d)?;
279    if v.len() > 16 {
280        return Err(serde::de::Error::custom("carry exceeds 16 references"));
281    }
282    Ok(v)
283}
284fn options<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
285    let v = Vec::<String>::deserialize(d)?;
286    if !(1..=256).contains(&v.len()) || v.iter().any(|x| !(1..=64).contains(&x.len())) {
287        return Err(serde::de::Error::custom(
288            "vocabulary outside generation bounds",
289        ));
290    }
291    Ok(v)
292}
293fn fields<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<GeneratedField>, D::Error> {
294    let v = Vec::<GeneratedField>::deserialize(d)?;
295    let mut names = BTreeSet::new();
296    if !(1..=32).contains(&v.len())
297        || v.iter()
298            .any(|f| !crate::parameter::is_parameter_name(&f.name) || !names.insert(&f.name))
299    {
300        return Err(serde::de::Error::custom(
301            "invalid or duplicate generated field name or field count",
302        ));
303    }
304    Ok(v)
305}
306fn template_name<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
307    let s = String::deserialize(d)?;
308    if !(1..=128).contains(&s.len())
309        || !s
310            .bytes()
311            .next()
312            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
313        || !s
314            .bytes()
315            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b))
316    {
317        return Err(serde::de::Error::custom("invalid prompt template id"));
318    }
319    Ok(s)
320}
321fn version<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
322    let s = String::deserialize(d)?;
323    if s.split('.').count() != 3
324        || s.split('.').any(|p| {
325            p.is_empty()
326                || (p.len() > 1 && p.starts_with('0'))
327                || !p.bytes().all(|b| b.is_ascii_digit())
328        })
329    {
330        return Err(serde::de::Error::custom("invalid prompt template version"));
331    }
332    Ok(s)
333}
334fn instructions<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
335    let s = String::deserialize(d)?;
336    // Spec 037 section 3.3.1 sets only an upper bound (16 KiB), so empty is valid.
337    if s.len() > 16384 {
338        return Err(serde::de::Error::custom(
339            "prompt instructions exceed 16 KiB",
340        ));
341    }
342    Ok(s)
343}
344fn params<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<PromptParam>, D::Error> {
345    let v = Vec::<PromptParam>::deserialize(d)?;
346    let mut names = BTreeSet::new();
347    if v.len() > 32
348        || v.iter()
349            .any(|p| !crate::parameter::is_parameter_name(&p.name) || !names.insert(&p.name))
350    {
351        return Err(serde::de::Error::custom("invalid prompt parameters"));
352    }
353    Ok(v)
354}
355
356pub(crate) fn steps<'de, D: Deserializer<'de>>(
357    d: D,
358) -> Result<Vec<crate::definition::StepDecl>, D::Error> {
359    let v = Vec::<crate::definition::StepDecl>::deserialize(d)?;
360    if v.iter()
361        .filter(|s| matches!(s.body, crate::definition::StepBody::Generate(_)))
362        .count()
363        > 8
364    {
365        return Err(serde::de::Error::custom("more than eight generate steps"));
366    }
367    if v.iter()
368        .filter(|s| matches!(s.body, crate::definition::StepBody::Extract(_)))
369        .count()
370        > 8
371    {
372        return Err(serde::de::Error::custom("more than eight extract steps"));
373    }
374    Ok(v)
375}
376
377// Untagged alternatives must not reinterpret a list as a struct sequence.
378pub(crate) fn object<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(
379    d: D,
380) -> Result<T, D::Error> {
381    let v = serde_json::Value::deserialize(d)?;
382    if !v.is_object() {
383        return Err(serde::de::Error::custom("expected an object"));
384    }
385    serde_json::from_value(v).map_err(serde::de::Error::custom)
386}
387
388/// The record carried by the generate operation (spec 037, 3.7).
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
390#[serde(deny_unknown_fields)]
391pub struct GenerateRequest {
392    pub schema: String,
393    pub step: String,
394    pub instance: Vec<String>,
395    pub template: String,
396    pub template_id: PromptTemplateId,
397    pub instructions: String,
398    pub language: String,
399    pub projection: BTreeMap<String, serde_json::Value>,
400    pub output: OutputSchema,
401    pub vocabularies: BTreeMap<String, Vec<String>>,
402    pub repair: RepairRequest,
403}
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
405#[serde(rename_all = "snake_case", deny_unknown_fields)]
406pub enum RepairRequest {
407    None,
408    // Variant-level `untagged` (serde 1.0.181 and later; the lockfile has
409    // 1.0.229): `none` stays a tagged string and `Round` is the flat
410    // `{"round", "fields"}` object, as `ModelForEach::List` does.
411    // Serde applies the enum's `deny_unknown_fields` to this untagged
412    // struct variant too; extra keys are rejected, not silently dropped.
413    #[serde(untagged)]
414    Round {
415        round: u64,
416        fields: Vec<RepairField>,
417    },
418}
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct RepairField {
422    pub name: String,
423    pub reason: FieldInvalid,
424}
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
426#[serde(rename_all = "snake_case")]
427pub enum FieldInvalid {
428    Missing,
429    WrongType,
430    Empty,
431    TooLong,
432    ControlCharacter,
433    NotInVocabulary,
434    DuplicateItem,
435    TooManyItems,
436}
437crate::document::document!(
438    GenerateRequest,
439    crate::schema::GENERATE_REQUEST,
440    crate::limits::RECORD_V1
441);
442
443/// Identity of the single-field output screening request owned by a generate step.
444pub fn output_screen_step(step: &str, field: &str) -> String {
445    format!("screen-output:{step}:{field}")
446}
447
448/// Resolve a generated screening identity from plan bindings, never a prefix alone.
449pub fn output_screen_binding<'a>(
450    plan: &'a crate::plan::Plan,
451    step: &str,
452) -> Option<(&'a str, &'a crate::screening::ScreenerBinding)> {
453    plan.steps.iter().find_map(|s| {
454        let crate::plan::PlanStepDetail::Generate(g) = &s.detail else {
455            return None;
456        };
457        let binding = g.output_screener.as_ref()?;
458        g.output
459            .fields
460            .iter()
461            .any(|f| {
462                matches!(f.ty, GeneratedType::Text { .. })
463                    && output_screen_step(&s.id, &f.name) == step
464            })
465            .then_some((s.id.as_str(), binding))
466    })
467}
468
469#[cfg(test)]
470mod tests {
471    use super::RepairRequest;
472    use serde_json::json;
473
474    #[test]
475    fn repair_round_rejects_unknown_fields() {
476        let round = json!({"round": 1, "fields": []});
477        let parsed: RepairRequest = serde_json::from_value(round.clone()).unwrap();
478        assert!(matches!(parsed, RepairRequest::Round { round: 1, .. }));
479        assert_eq!(serde_json::to_value(parsed).unwrap(), round);
480
481        let mut extra = round;
482        extra["unexpected"] = json!(true);
483        assert!(serde_json::from_str::<RepairRequest>(&extra.to_string()).is_err());
484        assert!(serde_json::from_value::<RepairRequest>(extra).is_err());
485    }
486}