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    if task.operation == rustev_contract::definition::Operation::Rank {
135        // choose validated the single presented id before any rules ran.
136        let id = doc["presented"][0]["id"].as_str().unwrap();
137        return Evaluated::Output(RawOutput::Scores(BTreeMap::from([(
138            id.to_owned(),
139            logits[0].to_f64_nearest(),
140        )])));
141    }
142    Evaluated::Output(RawOutput::Logits(
143        task.options
144            .iter()
145            .zip(&logits)
146            .map(|(o, d)| (o.clone(), d.to_f64_nearest()))
147            .collect::<BTreeMap<_, _>>(),
148    ))
149}
150
151/// The task whose operation, name, question and options equal the
152/// projection's (3.5).
153fn choose<'t>(tasks: &'t [CTask], doc: &Json) -> Result<&'t CTask, String> {
154    let name = doc
155        .get("task")
156        .and_then(Json::as_str)
157        .ok_or("projection has no task")?;
158    let task = tasks
159        .iter()
160        .find(|t| t.name == name)
161        .ok_or_else(|| format!("no rules for task {name:?}"))?;
162    let operation = serde_json::to_value(task.operation).unwrap_or(Json::Null);
163    if doc.get("operation") != Some(&operation) {
164        return Err(format!(
165            "task {name:?}: operation differs from the program's"
166        ));
167    }
168    if doc.get("question").and_then(Json::as_str) != Some(task.question.as_str()) {
169        return Err(format!(
170            "task {name:?}: question differs from the program's"
171        ));
172    }
173    if task.operation == rustev_contract::definition::Operation::Rank {
174        let presented = doc.get("presented").and_then(Json::as_array);
175        let candidate = presented
176            .filter(|p| p.len() == 1)
177            .and_then(|p| p[0].get("id"))
178            .and_then(Json::as_str);
179        if doc.get("mode").and_then(Json::as_str) != Some("pointwise")
180            || candidate.is_none_or(str::is_empty)
181            || doc.get("candidates") != Some(&serde_json::json!([candidate]))
182            || doc.get("options") != Some(&serde_json::json!([]))
183            || presented
184                .is_none_or(|p| !matches!(p[0].get("item"), Some(Json::Null | Json::Object(_))))
185        {
186            return Err("capability: rank requires one pointwise candidate".into());
187        }
188        return Ok(task);
189    }
190    let options: Option<Vec<&str>> = doc
191        .get("options")
192        .and_then(Json::as_array)
193        .map(|a| a.iter().filter_map(Json::as_str).collect());
194    let expected: Vec<&str> = task.options.iter().map(String::as_str).collect();
195    if options.as_ref() != Some(&expected)
196        || doc.get("options").and_then(Json::as_array).map(Vec::len) != Some(expected.len())
197    {
198        return Err(format!("task {name:?}: options differ from the program's"));
199    }
200    Ok(task)
201}
202
203/// Read and check every declared field before any rule runs (3.4.2).
204fn read_fields(task: &CTask, doc: &Json) -> Result<Vec<FieldValue>, String> {
205    let empty = Map::new();
206    let values = doc
207        .get("values")
208        .and_then(Json::as_object)
209        .unwrap_or(&empty);
210    task.fields
211        .iter()
212        .map(|f| {
213            let mut v = if task.operation == rustev_contract::definition::Operation::Rank
214                && f.key.starts_with("bind:")
215            {
216                doc.pointer("/presented/0/item")
217            } else {
218                values.get(&f.key)
219            };
220            if let Some(m) = &f.member {
221                v = match v {
222                    Some(Json::Object(o)) => o.get(m),
223                    Some(Json::Null) | None => None,
224                    Some(_) => {
225                        return Err(format!(
226                            "incompatible field {:?}: {} is not a record",
227                            f.name, f.key
228                        ));
229                    }
230                };
231            }
232            let v = match v {
233                None | Some(Json::Null) => {
234                    return Err(format!("missing field {:?}", f.name));
235                }
236                Some(v) => v,
237            };
238            let bad = || format!("incompatible field {:?}: not {:?}", f.name, f.ty);
239            Ok(match f.ty {
240                FieldType::Text => {
241                    let s = v.as_str().ok_or_else(bad)?;
242                    FieldValue::Text(s.to_string(), s.to_ascii_lowercase())
243                }
244                FieldType::Bool => FieldValue::Bool(v.as_bool().ok_or_else(bad)?),
245                FieldType::Integer => FieldValue::Integer(v.as_i64().ok_or_else(bad)?),
246                FieldType::Decimal => FieldValue::Decimal(
247                    v.as_str()
248                        .and_then(|s| Decimal::parse(s).ok())
249                        .ok_or_else(bad)?,
250                ),
251            })
252        })
253        .collect()
254}
255
256fn holds(c: &CCond, values: &[FieldValue]) -> bool {
257    match c {
258        CCond::Always => true,
259        CCond::ContainsAny { field, terms } => match &values[*field] {
260            FieldValue::Text(_, lower) => terms.iter().any(|t| lower.contains(t.as_str())),
261            _ => false,
262        },
263        CCond::Equals { field, value } => {
264            values[*field].canonical().as_deref() == Some(value.as_str())
265        }
266        CCond::Compare { field, op, value } => match values[*field].number() {
267            Some(n) => match op {
268                CompareOp::Lt => n < *value,
269                CompareOp::Le => n <= *value,
270                CompareOp::Ge => n >= *value,
271                CompareOp::Gt => n > *value,
272            },
273            None => false,
274        },
275        CCond::All(v) => v.iter().all(|c| holds(c, values)),
276        CCond::Any(v) => v.iter().any(|c| holds(c, values)),
277        CCond::Not(c) => !holds(c, values),
278    }
279}
280
281fn add_all(adds: &[(usize, Decimal)], logits: &mut [Decimal], task: &CTask) -> Result<(), String> {
282    for (k, d) in adds {
283        logits[*k] = logits[*k]
284            .checked_add(*d)
285            .map_err(|_| format!("overflow in option {:?}", task.options[*k]))?;
286    }
287    Ok(())
288}
289
290fn apply(
291    c: &CContrib,
292    values: &[FieldValue],
293    logits: &mut [Decimal],
294    task: &CTask,
295) -> Result<(), String> {
296    match c {
297        CContrib::Add(a) => add_all(a, logits, task),
298        CContrib::Linear {
299            field,
300            coefficients,
301        } => {
302            let x = values[*field]
303                .number()
304                .ok_or("linear field is not numeric")?;
305            for (k, coefficient) in coefficients {
306                let term = coefficient
307                    .checked_mul(x, Rounding::HalfEven)
308                    .map_err(|_| format!("overflow in option {:?}", task.options[*k]))?;
309                add_all(&[(*k, term)], logits, task)?;
310            }
311            Ok(())
312        }
313        CContrib::Lookup {
314            field,
315            table,
316            on_missing,
317        } => {
318            let key = values[*field]
319                .canonical()
320                .ok_or("lookup field has no canonical text")?;
321            match (table.get(&key), on_missing) {
322                (Some(a), _) => add_all(a, logits, task),
323                (None, OnMissing::Skip) => Ok(()),
324                (None, OnMissing::Fail) => Err(format!("no lookup entry for {key:?}")),
325            }
326        }
327    }
328}