Skip to main content

rustev_contract/
generation_result.rs

1//! Text results and digest-only evidence (spec 037, 3.10 and 3.15).
2use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5
6use crate::ids::{ContentDigest, PlanId, PromptTemplateId};
7use crate::judgment::{Derivation, Lineage, OutValue, Unresolved};
8use crate::output::TextValue;
9
10#[derive(
11    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
12)]
13#[serde(rename_all = "snake_case")]
14pub enum TextReason {
15    ScreenBlocked,
16    ScreenUnavailable,
17    Deadline,
18    RetriesExhausted,
19    InvalidOutput,
20    BackendUnavailable,
21    BudgetExhausted,
22    NotAttempted,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(rename_all = "snake_case", deny_unknown_fields)]
27pub enum FieldOrigin {
28    Generated,
29    Repaired,
30    Backstop { reason: TextReason },
31    Absent { reason: TextReason },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35#[serde(rename_all = "snake_case", deny_unknown_fields)]
36pub enum TextSource {
37    Fresh,
38    ServedFromCache { original_decision_id: String },
39}
40
41/// The lineage of the value, separate from cache delivery provenance.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
43#[serde(rename_all = "snake_case", deny_unknown_fields)]
44pub enum TextLineage {
45    Model {
46        step: String,
47        template: PromptTemplateId,
48        projection: Vec<String>,
49        attempt: String,
50    },
51    Backstop {
52        paths: Vec<String>,
53    },
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct TextField {
59    pub origin: FieldOrigin,
60    pub truncated: bool,
61    pub source: TextSource,
62    #[serde(
63        default,
64        skip_serializing_if = "Option::is_none",
65        deserialize_with = "crate::screening::present"
66    )]
67    #[schemars(with = "TextValue")]
68    pub value: Option<TextValue>,
69    #[serde(
70        default,
71        skip_serializing_if = "Option::is_none",
72        deserialize_with = "crate::screening::present"
73    )]
74    #[schemars(with = "Derivation")]
75    pub derivation: Option<Derivation>,
76    #[serde(
77        default,
78        skip_serializing_if = "Option::is_none",
79        deserialize_with = "crate::screening::present"
80    )]
81    #[schemars(with = "TextLineage")]
82    pub lineage: Option<TextLineage>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
86#[serde(rename_all = "snake_case", deny_unknown_fields)]
87pub enum GenerationState {
88    Ran,
89    Skipped { reason: GenerationSkipReason },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
93#[serde(rename_all = "snake_case", deny_unknown_fields)]
94pub enum GenerationSkipReason {
95    OutcomeNotMatched,
96    SourceUnresolved(Unresolved),
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum TextOrigin {
102    None,
103    Generated,
104    Backstop,
105    Partial,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
109#[serde(deny_unknown_fields)]
110pub struct TextCounts {
111    pub generated: u64,
112    pub repaired: u64,
113    pub backstop: u64,
114    pub absent: u64,
115    pub reasons: BTreeMap<TextReason, u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
119#[serde(deny_unknown_fields)]
120pub struct TextItem {
121    /// Empty for the single record; otherwise the ordered item instance.
122    pub id: Vec<String>,
123    pub context: BTreeMap<String, ContextValue>,
124    pub generated: BTreeMap<String, TextField>,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
128#[serde(deny_unknown_fields)]
129pub struct ContextValue {
130    pub value: OutValue,
131    pub derivation: Derivation,
132    pub lineage: Lineage,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct TextStep {
138    pub step: String,
139    pub state: GenerationState,
140    /// The item list inherits the judgment's lineage.
141    pub lineage: Lineage,
142    pub items: Vec<TextItem>,
143    pub text_origin: TextOrigin,
144    pub counts: TextCounts,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
148#[serde(rename_all = "snake_case")]
149pub enum TextNoticeKind {
150    OwnershipViolation,
151    OwnershipRestored,
152    BackstopTruncated,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct TextNotice {
158    pub kind: TextNoticeKind,
159    pub step: String,
160    pub instance: Vec<String>,
161    pub field: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct TextResult {
167    pub schema: String,
168    pub decision_id: String,
169    pub plan_id: PlanId,
170    pub steps: Vec<TextStep>,
171    pub text_origin: TextOrigin,
172    pub counts: TextCounts,
173    pub notices: Vec<TextNotice>,
174}
175
176crate::document::versioned_document!(
177    TextResult,
178    [crate::schema::TEXT_RESULT],
179    "rustev.text-result/1",
180    crate::limits::RECORD_V1,
181    check = TextResult::validate
182);
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub struct TextFieldEvidence {
187    pub origin: FieldOrigin,
188    pub truncated: bool,
189    pub source: TextSource,
190    /// Absent slots have neither a byte length nor a value digest.
191    #[serde(
192        default,
193        skip_serializing_if = "Option::is_none",
194        deserialize_with = "crate::screening::present"
195    )]
196    #[schemars(with = "u64")]
197    pub byte_length: Option<u64>,
198    #[serde(
199        default,
200        skip_serializing_if = "Option::is_none",
201        deserialize_with = "crate::screening::present"
202    )]
203    #[schemars(with = "ContentDigest")]
204    pub value_digest: Option<ContentDigest>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208#[serde(deny_unknown_fields)]
209pub struct GenerationItemEvidence {
210    pub id: Vec<String>,
211    pub context: BTreeMap<String, ContentDigest>,
212    pub generated: BTreeMap<String, TextFieldEvidence>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
216#[serde(deny_unknown_fields)]
217pub struct GenerationEvidence {
218    pub step: String,
219    pub state: GenerationState,
220    pub items: Vec<GenerationItemEvidence>,
221    pub text_origin: TextOrigin,
222    pub counts: TextCounts,
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub notices: Vec<TextNotice>,
225}
226
227fn slot(origin: &FieldOrigin, truncated: bool, source: &TextSource) -> Result<(), String> {
228    if truncated && !matches!(origin, FieldOrigin::Backstop { .. }) {
229        return Err("only backstop fields may be truncated".into());
230    }
231    if matches!(source, TextSource::ServedFromCache { original_decision_id } if original_decision_id.is_empty())
232    {
233        return Err("cache source requires original_decision_id".into());
234    }
235    Ok(())
236}
237
238fn summary<'a>(
239    origins: impl Iterator<Item = &'a FieldOrigin>,
240    origin: TextOrigin,
241    counts: &TextCounts,
242) -> Result<(), String> {
243    let mut actual = TextCounts::default();
244    for o in origins {
245        match o {
246            FieldOrigin::Generated => actual.generated += 1,
247            FieldOrigin::Repaired => actual.repaired += 1,
248            FieldOrigin::Backstop { reason } => {
249                actual.backstop += 1;
250                *actual.reasons.entry(*reason).or_default() += 1;
251            }
252            FieldOrigin::Absent { reason } => {
253                actual.absent += 1;
254                *actual.reasons.entry(*reason).or_default() += 1;
255            }
256        }
257    }
258    let expected = if actual.generated + actual.repaired + actual.backstop == 0 {
259        TextOrigin::None
260    } else if actual.backstop + actual.absent == 0 {
261        TextOrigin::Generated
262    } else if actual.generated + actual.repaired == 0 {
263        TextOrigin::Backstop
264    } else {
265        TextOrigin::Partial
266    };
267    if expected != origin || &actual != counts {
268        return Err("text origin or counts disagree with slots".into());
269    }
270    Ok(())
271}
272
273fn names<'a>(names: impl Iterator<Item = &'a String>) -> Result<(), String> {
274    for n in names {
275        if n.is_empty()
276            || n.len() > 64
277            || !n.as_bytes()[0].is_ascii_lowercase()
278            || !n
279                .bytes()
280                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
281        {
282            return Err("invalid generated field name".into());
283        }
284    }
285    Ok(())
286}
287
288impl TextResult {
289    fn validate(&self) -> Result<(), String> {
290        if self.steps.is_empty() || self.steps.len() > 8 {
291            return Err("generation requires one to eight steps".into());
292        }
293        let mut steps = BTreeSet::new();
294        for s in &self.steps {
295            if s.step.is_empty() || !steps.insert(&s.step) {
296                return Err("invalid or duplicate generate step".into());
297            }
298            if s.state != GenerationState::Ran && !s.items.is_empty() {
299                return Err("skipped step has items".into());
300            }
301            let mut items = BTreeSet::new();
302            for i in &s.items {
303                if !items.insert(&i.id) {
304                    return Err("duplicate text item".into());
305                }
306                if i.context.len() > 16 || i.generated.is_empty() || i.generated.len() > 32 {
307                    return Err("text item field bounds".into());
308                }
309                names(i.generated.keys())?;
310                if i.context
311                    .values()
312                    .any(|v| v.derivation != Derivation::ExactDerived)
313                    || i.context.keys().any(|k| i.generated.contains_key(k))
314                {
315                    return Err("invalid carried context".into());
316                }
317                for f in i.generated.values() {
318                    slot(&f.origin, f.truncated, &f.source)?;
319                    let valid = match &f.origin {
320                        FieldOrigin::Absent { .. } => {
321                            f.value.is_none() && f.derivation.is_none() && f.lineage.is_none()
322                        }
323                        FieldOrigin::Backstop { .. } => {
324                            f.value.is_some()
325                                && f.derivation == Some(Derivation::ExactDerived)
326                                && matches!(&f.lineage, Some(TextLineage::Backstop { .. }))
327                        }
328                        FieldOrigin::Generated | FieldOrigin::Repaired => {
329                            f.value.is_some()
330                                && f.derivation == Some(Derivation::ModelDerived)
331                                && matches!(&f.lineage, Some(TextLineage::Model { step, attempt, .. }) if step == &s.step && !attempt.is_empty())
332                        }
333                    };
334                    if !valid {
335                        return Err(
336                            "field value, derivation or lineage disagrees with origin".into()
337                        );
338                    }
339                }
340            }
341            summary(
342                s.items
343                    .iter()
344                    .flat_map(|i| i.generated.values().map(|f| &f.origin)),
345                s.text_origin,
346                &s.counts,
347            )?;
348        }
349        for n in &self.notices {
350            if n.field.is_empty() {
351                return Err("text notice requires a field".into());
352            }
353            if n.kind == TextNoticeKind::OwnershipRestored
354                && n.step.is_empty()
355                && n.instance.is_empty()
356                && n.field == "judgment"
357            {
358                continue;
359            }
360            let s = self
361                .steps
362                .iter()
363                .find(|s| s.step == n.step)
364                .ok_or("text notice names unknown step")?;
365            let i = s
366                .items
367                .iter()
368                .find(|i| i.id == n.instance)
369                .ok_or("text notice names unknown item")?;
370            match n.kind {
371                TextNoticeKind::OwnershipViolation => {}
372                TextNoticeKind::OwnershipRestored => {
373                    if !i.context.contains_key(&n.field) {
374                        return Err("ownership restored notice requires context field".into());
375                    }
376                }
377                TextNoticeKind::BackstopTruncated => {
378                    if !matches!(
379                        i.generated.get(&n.field).map(|f| &f.origin),
380                        Some(FieldOrigin::Backstop { .. })
381                    ) {
382                        return Err("backstop truncated notice requires backstop field".into());
383                    }
384                }
385            }
386        }
387        summary(
388            self.steps.iter().flat_map(|s| {
389                s.items
390                    .iter()
391                    .flat_map(|i| i.generated.values().map(|f| &f.origin))
392            }),
393            self.text_origin,
394            &self.counts,
395        )
396    }
397}
398
399pub(crate) fn evidence<'de, D: serde::Deserializer<'de>>(
400    d: D,
401) -> Result<Option<Vec<GenerationEvidence>>, D::Error> {
402    let v = Vec::<GenerationEvidence>::deserialize(d)?;
403    let check = || -> Result<(), String> {
404        if v.is_empty() || v.len() > 8 {
405            return Err("generation requires one to eight steps".into());
406        }
407        let mut steps = BTreeSet::new();
408        for s in &v {
409            if s.step.is_empty() || !steps.insert(&s.step) {
410                return Err("invalid or duplicate generate step".into());
411            }
412            if s.state != GenerationState::Ran && !s.items.is_empty() {
413                return Err("skipped step has items".into());
414            }
415            let mut items = BTreeSet::new();
416            for i in &s.items {
417                if !items.insert(&i.id) {
418                    return Err("duplicate generation evidence item".into());
419                }
420                if i.context.len() > 16 || i.generated.is_empty() || i.generated.len() > 32 {
421                    return Err("generation evidence field bounds".into());
422                }
423                names(i.generated.keys())?;
424                if i.context.keys().any(|k| i.generated.contains_key(k)) {
425                    return Err("overlapping context and generated fields".into());
426                }
427                for f in i.generated.values() {
428                    slot(&f.origin, f.truncated, &f.source)?;
429                    let absent = matches!(f.origin, FieldOrigin::Absent { .. });
430                    if f.byte_length.is_some() == absent || f.value_digest.is_some() == absent {
431                        return Err("evidence value metadata disagrees with origin".into());
432                    }
433                }
434            }
435            summary(
436                s.items
437                    .iter()
438                    .flat_map(|i| i.generated.values().map(|f| &f.origin)),
439                s.text_origin,
440                &s.counts,
441            )?;
442        }
443        Ok(())
444    };
445    check().map_err(serde::de::Error::custom)?;
446    Ok(Some(v))
447}
448
449/// SHA-256 of the record-canonical field value, without a domain prefix
450/// (spec 037, 3.15.1). Text itself never enters generation evidence.
451pub fn generation_value_digest<T: serde::Serialize>(
452    value: &T,
453) -> Result<ContentDigest, crate::canonical::CanonicalError> {
454    use sha2::{Digest, Sha256};
455    let bytes = crate::canonical::canonical_bytes(value)?;
456    let hash = Sha256::digest(bytes);
457    Ok(ContentDigest::from_digest(format!("sha256:{hash:x}")))
458}