Skip to main content

rustev_backend_rules/
eval.rs

1//! Evaluating one projection (spec 005, 3.4 to 3.7 and 3.13).
2
3use std::collections::BTreeMap;
4
5use rustev_contract::bounded::{ParseLimits, parse_bounded};
6use rustev_contract::decimal::{Decimal, Rounding};
7use rustev_contract::output::RawOutput;
8use serde_json::{Map, Value as Json};
9
10use crate::program::{CCond, CContrib, CTask, CompareOp, FieldType, NoMatch, OnMissing};
11
12/// What one evaluation produced.
13#[derive(Debug, Clone, PartialEq)]
14pub enum Evaluated {
15    /// Logits with exactly the task's options as keys.
16    Output(RawOutput),
17    /// The request cannot be answered; it recurs for the same program and
18    /// projection (reported as `permanent`).
19    Failed(String),
20    /// A cancellation was observed at the named point; evaluation stopped
21    /// there.
22    Cancelled(String),
23}
24
25/// Where cancellation is observed (spec 005, 3.13.1).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum Point {
28    BeforeParse,
29    AfterFields,
30    BeforeRule(usize),
31}
32
33/// A field value read from the projection.
34enum FieldValue {
35    /// The text and its ASCII lower-cased form.
36    Text(String, String),
37    Bool(bool),
38    Integer(i64),
39    Decimal(Decimal),
40}
41
42impl FieldValue {
43    /// Canonical text for `equals` and `lookup` (3.4.3); decimals have none.
44    fn canonical(&self) -> Option<String> {
45        match self {
46            FieldValue::Text(s, _) => Some(s.clone()),
47            FieldValue::Bool(b) => Some(b.to_string()),
48            FieldValue::Integer(i) => Some(i.to_string()),
49            FieldValue::Decimal(_) => None,
50        }
51    }
52
53    fn number(&self) -> Option<Decimal> {
54        match self {
55            FieldValue::Integer(i) => Some(Decimal::from_i64(*i)),
56            FieldValue::Decimal(d) => Some(*d),
57            _ => None,
58        }
59    }
60}
61
62/// The projection parse limits derived from the program (3.5).
63fn projection_limits(max_bytes: u64) -> ParseLimits {
64    let max_bytes = usize::try_from(max_bytes).unwrap_or(usize::MAX);
65    ParseLimits {
66        max_bytes,
67        max_depth: 16,
68        max_string_bytes: max_bytes,
69        max_collection_len: 4_096,
70        max_total_values: 50_000,
71        // Spec 005 3.5 sets no separate number cap; the byte limit bounds it.
72        max_number_bytes: max_bytes,
73        allow_fractional_numbers: false,
74    }
75}
76
77/// Evaluate `projection`; `cancelled(point)` is asked at every observation
78/// point and stops evaluation when it answers true.
79pub(crate) fn evaluate(
80    tasks: &[CTask],
81    max_projection_bytes: u64,
82    projection: &[u8],
83    cancelled: &mut dyn FnMut(Point) -> bool,
84) -> Evaluated {
85    if cancelled(Point::BeforeParse) {
86        return Evaluated::Cancelled("before parsing the projection".into());
87    }
88    if projection.len() as u64 > max_projection_bytes {
89        return Evaluated::Failed(format!(
90            "projection of {} bytes exceeds the program's limit of {max_projection_bytes}",
91            projection.len()
92        ));
93    }
94    let doc: Json = match parse_bounded(projection, &projection_limits(max_projection_bytes)) {
95        Ok(v) => v,
96        Err(e) => return Evaluated::Failed(format!("projection refused: {e}")),
97    };
98    let task = match choose(tasks, &doc) {
99        Ok(t) => t,
100        Err(e) => return Evaluated::Failed(e),
101    };
102    let values = match read_fields(task, &doc) {
103        Ok(v) => v,
104        Err(e) => return Evaluated::Failed(format!("task {:?}: {e}", task.name)),
105    };
106    if cancelled(Point::AfterFields) {
107        return Evaluated::Cancelled("after field validation".into());
108    }
109    let mut logits = task.base.clone();
110    let mut applied = false;
111    for (i, rule) in task.rules.iter().enumerate() {
112        if cancelled(Point::BeforeRule(i)) {
113            return Evaluated::Cancelled(format!("before rule {:?}", rule.name));
114        }
115        if !holds(&rule.when, &values) {
116            continue;
117        }
118        applied = true;
119        for c in &rule.then {
120            if let Err(e) = apply(c, &values, &mut logits, task) {
121                return Evaluated::Failed(format!(
122                    "task {:?}, rule {:?}: {e}",
123                    task.name, rule.name
124                ));
125            }
126        }
127        if rule.stop {
128            break;
129        }
130    }
131    if !applied && task.on_no_match == NoMatch::Fail {
132        return Evaluated::Failed(format!("task {:?}: no rule applied", task.name));
133    }
134    Evaluated::Output(RawOutput::Logits(
135        task.options
136            .iter()
137            .zip(&logits)
138            .map(|(o, d)| (o.clone(), d.to_f64_nearest()))
139            .collect::<BTreeMap<_, _>>(),
140    ))
141}
142
143/// The task whose operation, name, question and options equal the
144/// projection's (3.5).
145fn choose<'t>(tasks: &'t [CTask], doc: &Json) -> Result<&'t CTask, String> {
146    let name = doc
147        .get("task")
148        .and_then(Json::as_str)
149        .ok_or("projection has no task")?;
150    let task = tasks
151        .iter()
152        .find(|t| t.name == name)
153        .ok_or_else(|| format!("no rules for task {name:?}"))?;
154    let operation = serde_json::to_value(task.operation).unwrap_or(Json::Null);
155    if doc.get("operation") != Some(&operation) {
156        return Err(format!(
157            "task {name:?}: operation differs from the program's"
158        ));
159    }
160    if doc.get("question").and_then(Json::as_str) != Some(task.question.as_str()) {
161        return Err(format!(
162            "task {name:?}: question differs from the program's"
163        ));
164    }
165    let options: Option<Vec<&str>> = doc
166        .get("options")
167        .and_then(Json::as_array)
168        .map(|a| a.iter().filter_map(Json::as_str).collect());
169    let expected: Vec<&str> = task.options.iter().map(String::as_str).collect();
170    if options.as_ref() != Some(&expected)
171        || doc.get("options").and_then(Json::as_array).map(Vec::len) != Some(expected.len())
172    {
173        return Err(format!("task {name:?}: options differ from the program's"));
174    }
175    Ok(task)
176}
177
178/// Read and check every declared field before any rule runs (3.4.2).
179fn read_fields(task: &CTask, doc: &Json) -> Result<Vec<FieldValue>, String> {
180    let empty = Map::new();
181    let values = doc
182        .get("values")
183        .and_then(Json::as_object)
184        .unwrap_or(&empty);
185    task.fields
186        .iter()
187        .map(|f| {
188            let mut v = values.get(&f.key);
189            if let Some(m) = &f.member {
190                v = match v {
191                    Some(Json::Object(o)) => o.get(m),
192                    Some(Json::Null) | None => None,
193                    Some(_) => {
194                        return Err(format!(
195                            "incompatible field {:?}: {} is not a record",
196                            f.name, f.key
197                        ));
198                    }
199                };
200            }
201            let v = match v {
202                None | Some(Json::Null) => {
203                    return Err(format!("missing field {:?}", f.name));
204                }
205                Some(v) => v,
206            };
207            let bad = || format!("incompatible field {:?}: not {:?}", f.name, f.ty);
208            Ok(match f.ty {
209                FieldType::Text => {
210                    let s = v.as_str().ok_or_else(bad)?;
211                    FieldValue::Text(s.to_string(), s.to_ascii_lowercase())
212                }
213                FieldType::Bool => FieldValue::Bool(v.as_bool().ok_or_else(bad)?),
214                FieldType::Integer => FieldValue::Integer(v.as_i64().ok_or_else(bad)?),
215                FieldType::Decimal => FieldValue::Decimal(
216                    v.as_str()
217                        .and_then(|s| Decimal::parse(s).ok())
218                        .ok_or_else(bad)?,
219                ),
220            })
221        })
222        .collect()
223}
224
225fn holds(c: &CCond, values: &[FieldValue]) -> bool {
226    match c {
227        CCond::Always => true,
228        CCond::ContainsAny { field, terms } => match &values[*field] {
229            FieldValue::Text(_, lower) => terms.iter().any(|t| lower.contains(t.as_str())),
230            _ => false,
231        },
232        CCond::Equals { field, value } => {
233            values[*field].canonical().as_deref() == Some(value.as_str())
234        }
235        CCond::Compare { field, op, value } => match values[*field].number() {
236            Some(n) => match op {
237                CompareOp::Lt => n < *value,
238                CompareOp::Le => n <= *value,
239                CompareOp::Ge => n >= *value,
240                CompareOp::Gt => n > *value,
241            },
242            None => false,
243        },
244        CCond::All(v) => v.iter().all(|c| holds(c, values)),
245        CCond::Any(v) => v.iter().any(|c| holds(c, values)),
246        CCond::Not(c) => !holds(c, values),
247    }
248}
249
250fn add_all(adds: &[(usize, Decimal)], logits: &mut [Decimal], task: &CTask) -> Result<(), String> {
251    for (k, d) in adds {
252        logits[*k] = logits[*k]
253            .checked_add(*d)
254            .map_err(|_| format!("overflow in option {:?}", task.options[*k]))?;
255    }
256    Ok(())
257}
258
259fn apply(
260    c: &CContrib,
261    values: &[FieldValue],
262    logits: &mut [Decimal],
263    task: &CTask,
264) -> Result<(), String> {
265    match c {
266        CContrib::Add(a) => add_all(a, logits, task),
267        CContrib::Linear {
268            field,
269            coefficients,
270        } => {
271            let x = values[*field]
272                .number()
273                .ok_or("linear field is not numeric")?;
274            for (k, coefficient) in coefficients {
275                let term = coefficient
276                    .checked_mul(x, Rounding::HalfEven)
277                    .map_err(|_| format!("overflow in option {:?}", task.options[*k]))?;
278                add_all(&[(*k, term)], logits, task)?;
279            }
280            Ok(())
281        }
282        CContrib::Lookup {
283            field,
284            table,
285            on_missing,
286        } => {
287            let key = values[*field]
288                .canonical()
289                .ok_or("lookup field has no canonical text")?;
290            match (table.get(&key), on_missing) {
291                (Some(a), _) => add_all(a, logits, task),
292                (None, OnMissing::Skip) => Ok(()),
293                (None, OnMissing::Fail) => Err(format!("no lookup entry for {key:?}")),
294            }
295        }
296    }
297}