Skip to main content

rustev_contract/
extraction_results.rs

1//! Plan and result wire members for structured extraction (spec 038).
2use std::collections::BTreeMap;
3
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::{
7    extraction::{
8        AbsenceReason, Distributions, ExtractionLimits, ExtractionSchema, ExtractionType, Fill,
9        RequestFailure,
10    },
11    extraction_documents::ExtractRules,
12    generation::{PromptTemplate, TemplateRef},
13    ids::{ArtifactId, CalibrationId, ContentDigest, DescriptorId, PromptTemplateId},
14    judgment::{Derivation, Unresolved},
15    snapshot::Producer,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
19#[serde(deny_unknown_fields)]
20pub struct ExtractBinding {
21    pub template_id: PromptTemplateId,
22    pub template: PromptTemplate,
23    pub template_binding: TemplateRef,
24    pub projection: Vec<String>,
25    pub schema: ExtractionSchema,
26    pub schema_digest: ContentDigest,
27    pub vocabularies: BTreeMap<String, ResolvedVocabulary>,
28    pub distributions: Distributions,
29    pub limits: ExtractionLimits,
30    pub backend_id: String,
31    pub artifact: ArtifactId,
32    pub descriptor: DescriptorId,
33    pub output: crate::descriptor::OutputKind,
34    pub worst_case_request_bytes: u64,
35    pub fallback: BoundExtractionFallback,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
39#[serde(rename_all = "snake_case", deny_unknown_fields)]
40pub enum ResolvedVocabulary {
41    Options(Vec<String>),
42    Input(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
46#[serde(rename_all = "snake_case", deny_unknown_fields)]
47pub enum BoundExtractionFallback {
48    None,
49    Bound {
50        identity: ContentDigest,
51        program: ExtractRules,
52        fill: Fill,
53    },
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct ExtractionEvidence {
59    pub step: String,
60    pub template_id: PromptTemplateId,
61    pub schema_digest: ContentDigest,
62    pub artifact: ArtifactId,
63    pub fallback: FallbackIdentity,
64    pub request_outcome: ExtractionRequestOutcome,
65    pub fields: Vec<ExtractionFieldEvidence>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
69#[serde(rename_all = "snake_case", deny_unknown_fields)]
70pub enum FallbackIdentity {
71    None,
72    Identity(ContentDigest),
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
76#[serde(rename_all = "snake_case", deny_unknown_fields)]
77pub enum ExtractionRequestOutcome {
78    Succeeded,
79    Failed { reason: RequestFailure },
80    Withheld { reason: AbsenceReason },
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
84#[serde(rename_all = "snake_case", deny_unknown_fields)]
85pub enum ExtractionOrigin {
86    Extracted,
87    Fallback,
88    Absent { reason: AbsenceReason },
89}
90
91/// Field evidence members follow spec 038, 3.12.1.
92/// Core must construct evidence with `new` to enforce digest-only text retention (R-19).
93#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct ExtractionFieldEvidence {
96    pub name: String,
97    pub origin: ExtractionOrigin,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub derivation: Option<Derivation>,
100    /// A failing record subfield, when applicable.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub subfield: Option<String>,
103    pub rejected: Vec<RejectedExtractionItem>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub value_digest: Option<ContentDigest>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    value: Option<serde_json::Value>,
108    /// Present list items in returned order, retaining their original indices.
109    items: Vec<ExtractionItemEvidence>,
110    pub distribution: FieldDistribution,
111    pub calibration: ExtractionCalibration,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub provider_reported: Option<String>,
114}
115
116impl ExtractionFieldEvidence {
117    /// Applies spec 038, 3.12.1 and R-19 using the declared type, which is not
118    /// carried on the wire. Digests are preserved for present fields even when
119    /// values are omitted; absent fields have no value digest.
120    /// Distribution, calibration and optional metadata can be filled afterward.
121    pub fn new(
122        name: String,
123        ty: &ExtractionType,
124        origin: ExtractionOrigin,
125        derivation: Option<Derivation>,
126        value_digest: Option<ContentDigest>,
127        value: Option<serde_json::Value>,
128        mut items: Vec<ExtractionItemEvidence>,
129    ) -> Self {
130        let value_digest =
131            value_digest.filter(|_| !matches!(origin, ExtractionOrigin::Absent { .. }));
132        for item in &mut items {
133            item.value = item.value.take().filter(|value| match ty {
134                ExtractionType::List { item, .. } => retained_value(item, value),
135                _ => false,
136            });
137        }
138        Self {
139            name,
140            origin,
141            derivation,
142            subfield: None,
143            rejected: Vec::new(),
144            value_digest,
145            value: value.filter(|value| retained_value(ty, value)),
146            items,
147            distribution: FieldDistribution::Absent {
148                reason: DistributionAbsence::NotReturned,
149            },
150            calibration: ExtractionCalibration::None,
151            provider_reported: None,
152        }
153    }
154
155    pub fn value(&self) -> Option<&serde_json::Value> {
156        self.value.as_ref()
157    }
158
159    pub fn items(&self) -> &[ExtractionItemEvidence] {
160        &self.items
161    }
162}
163
164impl<'de> Deserialize<'de> for ExtractionFieldEvidence {
165    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
166        #[derive(Deserialize)]
167        #[serde(remote = "ExtractionFieldEvidence", deny_unknown_fields)]
168        struct Wire {
169            name: String,
170            origin: ExtractionOrigin,
171            #[serde(default, deserialize_with = "crate::screening::present")]
172            derivation: Option<Derivation>,
173            #[serde(default, deserialize_with = "crate::screening::present")]
174            subfield: Option<String>,
175            rejected: Vec<RejectedExtractionItem>,
176            #[serde(default, deserialize_with = "crate::screening::present")]
177            value_digest: Option<ContentDigest>,
178            #[serde(default, deserialize_with = "crate::screening::present")]
179            value: Option<serde_json::Value>,
180            items: Vec<ExtractionItemEvidence>,
181            distribution: FieldDistribution,
182            calibration: ExtractionCalibration,
183            #[serde(default, deserialize_with = "crate::screening::present")]
184            provider_reported: Option<String>,
185        }
186        let v = Wire::deserialize(d)?;
187        let valid_origin = match v.origin {
188            ExtractionOrigin::Absent { .. } => {
189                v.derivation.is_none()
190                    && v.value.is_none()
191                    && v.items.is_empty()
192                    && v.value_digest.is_none()
193            }
194            ExtractionOrigin::Extracted => {
195                v.derivation == Some(Derivation::ModelDerived) && v.value_digest.is_some()
196            }
197            ExtractionOrigin::Fallback => {
198                matches!(
199                    v.derivation,
200                    Some(Derivation::ExactDerived | Derivation::MixedDerived)
201                ) && v.value_digest.is_some()
202            }
203        };
204        if !valid_origin || v.provider_reported.as_ref().is_some_and(|s| s.len() > 256) {
205            return Err(serde::de::Error::custom(
206                "invalid extraction origin, derivation or provider report",
207            ));
208        }
209        Ok(v)
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
214#[serde(deny_unknown_fields)]
215pub struct RejectedExtractionItem {
216    pub index: u64,
217    pub reason: AbsenceReason,
218    pub value_digest: ContentDigest,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(deny_unknown_fields)]
223pub struct ExtractionItemEvidence {
224    pub index: u64,
225    pub value_digest: ContentDigest,
226    #[serde(
227        default,
228        skip_serializing_if = "Option::is_none",
229        deserialize_with = "crate::screening::present"
230    )]
231    pub value: Option<serde_json::Value>,
232    pub distribution: FieldDistribution,
233    pub calibration: ExtractionCalibration,
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
237#[serde(rename_all = "snake_case", deny_unknown_fields)]
238pub enum FieldDistribution {
239    Masses(#[serde(deserialize_with = "masses")] Vec<f64>),
240    Absent { reason: DistributionAbsence },
241}
242
243fn masses<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<f64>, D::Error> {
244    let v = Vec::<f64>::deserialize(d)?;
245    if v.is_empty() || v.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n)) {
246        return Err(serde::de::Error::custom("invalid distribution masses"));
247    }
248    Ok(v)
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
252#[serde(rename_all = "snake_case")]
253pub enum DistributionAbsence {
254    IncompleteLogprobs,
255    NotReturned,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(rename_all = "snake_case", deny_unknown_fields)]
260pub enum ExtractionCalibration {
261    None,
262    Applied(CalibrationId),
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
266#[serde(rename_all = "snake_case", deny_unknown_fields)]
267pub enum AcceptedOrigin {
268    Supplied,
269    Extracted,
270    Fallback,
271    HostExtracted {
272        #[serde(
273            default,
274            skip_serializing_if = "Option::is_none",
275            deserialize_with = "crate::screening::present"
276        )]
277        producer: Option<Producer>,
278    },
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
282#[serde(deny_unknown_fields)]
283pub struct AcceptanceEvidence {
284    pub step: String,
285    pub field: String,
286    /// The value read, subject to the same retention rule as extraction evidence.
287    #[serde(
288        default,
289        skip_serializing_if = "Option::is_none",
290        deserialize_with = "crate::screening::present"
291    )]
292    pub value_read: Option<AcceptanceRead>,
293    #[serde(
294        default,
295        skip_serializing_if = "Option::is_none",
296        deserialize_with = "crate::screening::present"
297    )]
298    pub origin: Option<AcceptedOrigin>,
299    pub outcome: AcceptanceOutcome,
300    pub validation: AcceptanceValidation,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
304#[serde(rename_all = "snake_case", deny_unknown_fields)]
305pub enum AcceptanceOutcome {
306    Accepted,
307    PassedThrough,
308    Dropped { cause: AcceptanceFailure },
309    Unresolved,
310}
311
312#[derive(
313    Debug,
314    Clone,
315    Copy,
316    PartialEq,
317    Eq,
318    PartialOrd,
319    Ord,
320    Hash,
321    Serialize,
322    Deserialize,
323    schemars::JsonSchema,
324)]
325#[serde(rename_all = "snake_case", deny_unknown_fields)]
326pub enum AcceptanceFailure {
327    Absence(AbsenceReason),
328    ValidationFailed,
329    ValidationFault,
330    MinMassNotMet,
331    DistributionAbsent,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
335#[serde(rename_all = "snake_case", deny_unknown_fields)]
336pub enum AcceptanceValidation {
337    NotApplied,
338    Passed,
339    Failed,
340    Fault(Unresolved),
341}
342
343/// Input names are keys, giving canonical serialization its required sorted order.
344pub type Producers = BTreeMap<String, Producer>;
345
346fn retained_value(ty: &ExtractionType, value: &serde_json::Value) -> bool {
347    match ty {
348        ExtractionType::Bool {} => value.is_boolean(),
349        ExtractionType::Integer { .. } => value.as_i64().is_some(),
350        ExtractionType::Decimal(_) | ExtractionType::Date {} | ExtractionType::Enum { .. } => {
351            value.is_string()
352        }
353        ExtractionType::List { item, .. } if matches!(**item, ExtractionType::Enum { .. }) => value
354            .as_array()
355            .is_some_and(|a| a.iter().all(serde_json::Value::is_string)),
356        _ => false,
357    }
358}
359
360/// A read can be supplied by a host without claiming an extraction-step origin.
361#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
362#[serde(deny_unknown_fields)]
363pub struct AcceptanceRead {
364    pub value_digest: ContentDigest,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    value: Option<serde_json::Value>,
367}
368impl AcceptanceRead {
369    /// Applies the retention rule of spec 038, 3.12.1 and R-19 to the value
370    /// read (3.12.2), preserving its digest. Core must use this constructor.
371    pub fn new(
372        ty: &ExtractionType,
373        value_digest: ContentDigest,
374        value: Option<serde_json::Value>,
375    ) -> Self {
376        Self {
377            value_digest,
378            value: value.filter(|value| retained_value(ty, value)),
379        }
380    }
381
382    pub fn value(&self) -> Option<&serde_json::Value> {
383        self.value.as_ref()
384    }
385}
386
387impl<'de> Deserialize<'de> for AcceptanceRead {
388    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
389        #[derive(Deserialize)]
390        #[serde(remote = "AcceptanceRead", deny_unknown_fields)]
391        struct Wire {
392            value_digest: ContentDigest,
393            #[serde(default, deserialize_with = "crate::screening::present")]
394            value: Option<serde_json::Value>,
395        }
396        Wire::deserialize(d)
397    }
398}