Skip to main content

rustev_core/
extraction_fallback.rs

1//! Pure fallback extraction (spec 038, 3.7), without runtime or compiler wiring.
2use std::collections::{BTreeMap, BTreeSet};
3
4use rustev_contract::{
5    Document,
6    extraction::{
7        AbsenceReason as Reason, Distributions, Extract, ExtractionSchema, ExtractionType, Fill,
8    },
9    extraction_documents::{
10        ExtractRules, ExtractionCondition as Condition, ExtractionValue as Expr,
11    },
12    extraction_results::{ExtractionFieldEvidence, ExtractionOrigin},
13    judgment::Derivation,
14    output::RawOutput,
15};
16use serde_json::Value as Json;
17
18use crate::extraction_validate::{self, ValidatedField, fallback_derivation};
19
20/// Screening for exact steps has already been applied by the caller. A source
21/// withheld only from model steps belongs in `Readable`, not `Unavailable`.
22#[derive(Debug, Clone, Copy)]
23pub enum Source<'a> {
24    Readable {
25        bytes: &'a [u8],
26        derivation: Derivation,
27    },
28    Unavailable,
29}
30
31/// A validated field plus sorted, unique input references actually read while
32/// evaluating it, including reads by rules that did not produce its value.
33#[derive(Debug, Clone, PartialEq)]
34pub struct FallbackField {
35    pub field: ValidatedField,
36    pub sources_read: Vec<String>,
37}
38
39/// Only `bind` constructs this, checking document bounds and step compatibility.
40#[derive(Debug, Clone)]
41pub struct BoundProgram {
42    program: ExtractRules,
43    schema: ExtractionSchema,
44}
45
46/// Check spec 038, 3.7.2 and 3.7.3. Errors are for the compiler to map to
47/// `invalid_extraction`. Literal bounds and vocabulary membership are evaluated
48/// later by the shared field validator, against resolved vocabularies.
49pub fn bind(program: &ExtractRules, step: &Extract) -> Result<BoundProgram, String> {
50    // Reuse document-local checks even for directly constructed Rust values.
51    let bytes = serde_json::to_vec(program).map_err(|e| e.to_string())?;
52    let program = ExtractRules::parse(&bytes).map_err(|e| e.to_string())?;
53    let mut names = BTreeSet::new();
54    for s in &program.sources {
55        if !step.projection.contains(&s.reference) || !s.reference.starts_with("input:") {
56            return Err(format!("source {} is not in the input projection", s.name));
57        }
58        if !names.insert(s.name.as_str()) {
59            return Err(format!("duplicate source {}", s.name));
60        }
61    }
62    let mut fields = BTreeSet::new();
63    for f in &program.fields {
64        if !fields.insert(&f.field) {
65            return Err(format!("duplicate field {}", f.field));
66        }
67        let declared = step
68            .output
69            .fields
70            .iter()
71            .find(|d| d.name == f.field)
72            .ok_or_else(|| format!("undeclared field {}", f.field))?;
73        let mut literals = Vec::new();
74        for rule in &f.rules {
75            let mut used = BTreeSet::new();
76            condition_sources(&rule.when, &mut used);
77            value_sources(&rule.then, &mut used);
78            if let Some(s) = used.iter().find(|s| !names.contains(**s)) {
79                return Err(format!("undeclared source {s}"));
80            }
81            if let ExtractionType::List { item, .. } = &declared.ty {
82                let Expr::Literal(v) = &rule.then else {
83                    return Err(format!("list field {} requires literal items", f.field));
84                };
85                if !literal_type(v, item) {
86                    return Err(format!("wrong list item type for {}", f.field));
87                }
88                if literals.contains(&v) {
89                    return Err(format!("duplicate literal item for {}", f.field));
90                }
91                literals.push(v);
92            } else if !value_type(&rule.then, &declared.ty) {
93                return Err(format!("incompatible value for {}", f.field));
94            }
95        }
96    }
97    Ok(BoundProgram {
98        program,
99        schema: step.output.clone(),
100    })
101}
102
103fn literal_type(v: &Json, ty: &ExtractionType) -> bool {
104    match ty {
105        ExtractionType::Bool {} => v.is_boolean(),
106        ExtractionType::Integer { .. } => v.is_i64() || v.is_u64(),
107        ExtractionType::Decimal(_)
108        | ExtractionType::Text { .. }
109        | ExtractionType::Date {}
110        | ExtractionType::Enum { .. } => v.is_string(),
111        ExtractionType::Record { fields } => v.as_object().is_some_and(|obj| {
112            obj.iter().all(|(name, value)| {
113                fields
114                    .iter()
115                    .any(|f| f.name == *name && literal_type(value, &f.ty))
116            })
117        }),
118        ExtractionType::List { .. } => false,
119    }
120}
121
122fn value_type(v: &Expr, ty: &ExtractionType) -> bool {
123    match (v, ty) {
124        (Expr::Literal(v), ty) => literal_type(v, ty),
125        (Expr::IntegerAfter { .. }, ExtractionType::Integer { .. })
126        | (Expr::DateIso { .. }, ExtractionType::Date {}) => true,
127        (Expr::Record(values), ExtractionType::Record { fields }) => values
128            .iter()
129            .all(|(n, v)| fields.iter().any(|f| f.name == *n && value_type(v, &f.ty))),
130        _ => false,
131    }
132}
133
134fn condition_sources<'a>(c: &'a Condition, out: &mut BTreeSet<&'a str>) {
135    match c {
136        Condition::Always => {}
137        Condition::ContainsAny { source, .. } | Condition::ContainsAll { source, .. } => {
138            out.insert(source);
139        }
140        Condition::All(cs) | Condition::Any(cs) => {
141            for c in cs {
142                condition_sources(c, out);
143            }
144        }
145        Condition::Not(c) => condition_sources(c, out),
146    }
147}
148fn value_sources<'a>(v: &'a Expr, out: &mut BTreeSet<&'a str>) {
149    match v {
150        Expr::Literal(_) => {}
151        Expr::IntegerAfter { source, .. } | Expr::DateIso { source, .. } => {
152            out.insert(source);
153        }
154        Expr::Record(vs) => {
155            for v in vs.values() {
156                value_sources(v, out);
157            }
158        }
159    }
160}
161
162struct Reads<'a> {
163    sources: &'a BTreeMap<String, Source<'a>>,
164    references: &'a BTreeMap<&'a str, &'a str>,
165    max_bytes: u64,
166    read: BTreeMap<String, Derivation>,
167}
168impl<'a> Reads<'a> {
169    fn get(&mut self, source: &str) -> Result<&'a [u8], Reason> {
170        let reference = self
171            .references
172            .get(source)
173            .ok_or(Reason::SourceUnavailable)?;
174        match self.sources.get(*reference) {
175            Some(Source::Readable { bytes, derivation })
176                if bytes.len() as u64 <= self.max_bytes =>
177            {
178                self.read.insert((*reference).into(), *derivation);
179                Ok(bytes)
180            }
181            _ => Err(Reason::SourceUnavailable),
182        }
183    }
184}
185
186// Spec 005, 3.4.4: the backend uses to_ascii_lowercase; its private
187// implementation cannot be imported into core. Other bytes remain unchanged.
188fn occurrence(text: &[u8], term: &str) -> Option<usize> {
189    text.windows(term.len())
190        .position(|w| w.eq_ignore_ascii_case(term.as_bytes()))
191}
192fn condition(c: &Condition, reads: &mut Reads<'_>) -> Result<bool, Reason> {
193    Ok(match c {
194        Condition::Always => true,
195        Condition::ContainsAny { source, terms } => {
196            let text = reads.get(source)?;
197            terms.iter().any(|t| occurrence(text, t).is_some())
198        }
199        Condition::ContainsAll { source, terms } => {
200            let text = reads.get(source)?;
201            terms.iter().all(|t| occurrence(text, t).is_some())
202        }
203        Condition::All(cs) => {
204            for c in cs {
205                if !condition(c, reads)? {
206                    return Ok(false);
207                }
208            }
209            true
210        }
211        Condition::Any(cs) => {
212            for c in cs {
213                if condition(c, reads)? {
214                    return Ok(true);
215                }
216            }
217            false
218        }
219        Condition::Not(c) => !condition(c, reads)?,
220    })
221}
222fn value(v: &Expr, reads: &mut Reads<'_>) -> Result<Option<Json>, Reason> {
223    Ok(match v {
224        Expr::Literal(v) => Some(v.clone()),
225        Expr::IntegerAfter {
226            source,
227            terms,
228            max_digits,
229        } => {
230            let text = reads.get(source)?;
231            // Earliest byte offset wins; equal offsets keep declared term order.
232            let first = terms
233                .iter()
234                .filter_map(|t| occurrence(text, t).map(|i| (i, t.len())))
235                .enumerate()
236                .min_by_key(|(order, (i, _))| (*i, *order));
237            let Some((_, (i, len))) = first else {
238                return Ok(None);
239            };
240            let tail = &text[i + len..];
241            let spaces = tail.iter().take_while(|b| **b == b' ').count();
242            let digits = &tail[spaces..];
243            let n = digits.iter().take_while(|b| b.is_ascii_digit()).count();
244            if n == 0 || n > usize::from(*max_digits) {
245                return Ok(None);
246            }
247            Some(Json::from(
248                digits[..n]
249                    .iter()
250                    .fold(0i64, |v, b| v * 10 + i64::from(b - b'0')),
251            ))
252        }
253        Expr::DateIso { source, occurrence } => {
254            let text = reads.get(source)?;
255            text.windows(10)
256                .filter(|w| {
257                    w.iter().enumerate().all(|(i, b)| {
258                        if i == 4 || i == 7 {
259                            *b == b'-'
260                        } else {
261                            b.is_ascii_digit()
262                        }
263                    })
264                })
265                .nth(usize::from(*occurrence) - 1)
266                .map(|w| Json::String(w.iter().map(|b| char::from(*b)).collect()))
267        }
268        Expr::Record(fields) => {
269            let mut obj = serde_json::Map::new();
270            for (name, v) in fields {
271                let Some(v) = value(v, reads)? else {
272                    return Ok(None);
273                };
274                obj.insert(name.clone(), v);
275            }
276            Some(Json::Object(obj))
277        }
278    })
279}
280
281impl BoundProgram {
282    /// Sources are keyed by input reference, vocabularies by declared name.
283    /// Boolean conditions short-circuit in declared order; only reached reads
284    /// contribute lineage. An unavailable reached source stops that field.
285    pub fn evaluate(
286        &self,
287        sources: &BTreeMap<String, Source<'_>>,
288        vocabularies: &BTreeMap<String, Vec<String>>,
289        max_projection_bytes: u64,
290    ) -> Vec<FallbackField> {
291        let references = self
292            .program
293            .sources
294            .iter()
295            .map(|s| (s.name.as_str(), s.reference.as_str()))
296            .collect();
297        self.schema
298            .fields
299            .iter()
300            .map(|field| {
301                let mut reads = Reads {
302                    sources,
303                    references: &references,
304                    max_bytes: max_projection_bytes,
305                    read: BTreeMap::new(),
306                };
307                let result = (|| {
308                    let mut items = Vec::new();
309                    if let Some(f) = self.program.fields.iter().find(|f| f.field == field.name) {
310                        for rule in &f.rules {
311                            if condition(&rule.when, &mut reads)?
312                                && let Some(v) = value(&rule.then, &mut reads)?
313                            {
314                                if matches!(field.ty, ExtractionType::List { .. }) {
315                                    items.push(v);
316                                } else {
317                                    return Ok(v);
318                                }
319                            }
320                        }
321                    }
322                    if items.is_empty() {
323                        Err(Reason::FallbackNoMatch)
324                    } else {
325                        Ok(Json::Array(items))
326                    }
327                })();
328                let validated = result
329                    .and_then(|v| {
330                        let schema = ExtractionSchema {
331                            fields: vec![field.clone()],
332                        };
333                        let raw = RawOutput::TypedRecord {
334                            fields: BTreeMap::from([(field.name.clone(), v)]),
335                            logprobs: vec![],
336                            provider_reported: BTreeMap::new(),
337                        };
338                        let mut validated = extraction_validate::validate(
339                            &schema,
340                            vocabularies,
341                            &raw,
342                            Distributions::None,
343                            false,
344                        )
345                        .map_err(|_| Reason::FallbackInvalid)?
346                        .remove(0);
347                        // A failing fallback item invalidates the fallback field; never
348                        // silently salvage a partially invalid deterministic result.
349                        if validated.value.is_none() || !validated.evidence.rejected.is_empty() {
350                            return Err(Reason::FallbackInvalid);
351                        }
352                        validated.evidence.origin = ExtractionOrigin::Fallback;
353                        validated.evidence.derivation =
354                            Some(fallback_derivation(reads.read.values().copied()));
355                        Ok(validated)
356                    })
357                    .unwrap_or_else(|reason| ValidatedField {
358                        value: None,
359                        evidence: ExtractionFieldEvidence::new(
360                            field.name.clone(),
361                            &field.ty,
362                            ExtractionOrigin::Absent { reason },
363                            None,
364                            None,
365                            None,
366                            vec![],
367                        ),
368                    });
369                FallbackField {
370                    field: validated,
371                    sources_read: reads.read.into_keys().collect(),
372                }
373            })
374            .collect()
375    }
376}
377
378/// Merge by field name, preserving model order and every present model field.
379/// `request_produced_output` distinguishes an accepted record with absent fields
380/// from a request failure. The caller supplies model entries for the full schema.
381/// Returns source lineage only for fallback entries actually selected by the fill.
382pub fn merge(
383    model: &mut [ValidatedField],
384    fallback: &[FallbackField],
385    fill: Fill,
386    request_produced_output: bool,
387) -> BTreeMap<String, Vec<String>> {
388    let mut reads = BTreeMap::new();
389    if fill == Fill::RequestFailure && request_produced_output {
390        return reads;
391    }
392    for field in model {
393        if field.value.is_none()
394            && let Some(f) = fallback
395                .iter()
396                .find(|f| f.field.evidence.name == field.evidence.name)
397        {
398            *field = f.field.clone();
399            reads.insert(field.evidence.name.clone(), f.sources_read.clone());
400        }
401    }
402    reads
403}