Skip to main content

kime_core/
request.rs

1//! The `/v1/systemone` request, parsed from JSON and validated the way spec/03-api.md describes.
2//!
3//! Validation collects every problem instead of stopping at the first, and reports each one in
4//! FastAPI's shape (`loc`, `msg`, `type`, `input`), because that is what Jev returns and what its
5//! SDKs parse. Parsing works on a `serde_json::Value` built with `preserve_order`, so questions and
6//! choice labels keep the order they were sent in.
7
8use serde_json::{Map, Value};
9
10/// The three question types.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum QType {
13    /// Pick one of a set of labels.
14    Choice,
15    /// Pick a level on an ordered scale, answered as an expected value.
16    Score,
17    /// Yes or no, answered as the probability of yes.
18    Noul,
19}
20
21impl QType {
22    /// The name used on the wire and in Laya's rendered text.
23    #[must_use]
24    pub fn as_str(self) -> &'static str {
25        match self {
26            QType::Choice => "choice",
27            QType::Score => "score",
28            QType::Noul => "noul",
29        }
30    }
31
32    /// The index Laya's type embedding uses: choice 0, score 1, noul 2.
33    #[must_use]
34    pub fn index(self) -> usize {
35        match self {
36            QType::Choice => 0,
37            QType::Score => 1,
38            QType::Noul => 2,
39        }
40    }
41}
42
43/// One choice option. The description is `None` when it was null.
44#[derive(Debug, Clone, PartialEq)]
45pub struct ChoiceOption {
46    /// The label, exactly as sent.
47    pub label: String,
48    /// The description, or `None` for null or when the criteria were a list of labels.
49    pub description: Option<Value>,
50}
51
52/// The criteria of one question, in the shape its type takes.
53#[derive(Debug, Clone, PartialEq)]
54pub enum Criteria {
55    /// Options in the order they were sent.
56    Choice(Vec<ChoiceOption>),
57    /// Levels, index 0 first. Each is echoed back in the `legend` exactly as sent.
58    Score(Vec<Value>),
59    /// The optional descriptions of the false and true outcomes.
60    Noul {
61        /// What false means, when given.
62        when_false: Option<Value>,
63        /// What true means, when given.
64        when_true: Option<Value>,
65    },
66}
67
68impl Criteria {
69    /// The number of options the model scores for this question.
70    #[must_use]
71    pub fn len(&self) -> usize {
72        match self {
73            Criteria::Choice(o) => o.len(),
74            Criteria::Score(l) => l.len(),
75            Criteria::Noul { .. } => 2,
76        }
77    }
78
79    /// Never true for a validated question, but clippy wants it next to `len`.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.len() == 0
83    }
84}
85
86/// One validated question.
87#[derive(Debug, Clone, PartialEq)]
88pub struct Question {
89    /// The key it was sent under.
90    pub id: String,
91    /// Its type.
92    pub qtype: QType,
93    /// The instructions. `None` when the field was left out, which is not the same as null: Laya
94    /// renders null as the text `null`.
95    pub instructions: Option<Value>,
96    /// Its criteria.
97    pub criteria: Criteria,
98}
99
100/// A validated request.
101#[derive(Debug, Clone, PartialEq)]
102pub struct Request {
103    /// The state, as sent. Null only under [`Limits::LAYA`], for a null or missing state.
104    pub state: Value,
105    /// The requested model, if any.
106    pub model: Option<String>,
107    /// The questions in request order.
108    pub questions: Vec<Question>,
109    /// The `kime` extension object, not yet interpreted.
110    pub kime: Option<Map<String, Value>>,
111}
112
113/// What a request asks about: text, or any JSON value, which is rendered the way the model
114/// family expects.
115#[derive(Debug, Clone, PartialEq)]
116pub struct State(pub Value);
117
118impl State {
119    /// A structured state, such as a ticket or a record.
120    #[must_use]
121    pub fn json(v: &Value) -> Self {
122        State(v.clone())
123    }
124
125    /// A plain text state.
126    #[must_use]
127    pub fn text(s: impl Into<String>) -> Self {
128        State(Value::String(s.into()))
129    }
130}
131
132impl From<&str> for State {
133    fn from(s: &str) -> Self {
134        State::text(s)
135    }
136}
137
138impl From<String> for State {
139    fn from(s: String) -> Self {
140        State::text(s)
141    }
142}
143
144impl From<Value> for State {
145    fn from(v: Value) -> Self {
146        State(v)
147    }
148}
149
150/// Building a request in code. Nothing is checked until it is answered, where it goes through
151/// the same validation as a request that came in as JSON.
152impl Request {
153    /// A request about `state` with no questions yet.
154    #[must_use]
155    pub fn new(state: impl Into<State>) -> Self {
156        Request { state: state.into().0, model: None, questions: Vec::new(), kime: None }
157    }
158
159    /// Adds a question, or replaces the one with the same id where it stands, as a repeated key
160    /// in a JSON object would.
161    #[must_use]
162    pub fn question(mut self, q: Question) -> Self {
163        match self.questions.iter_mut().find(|x| x.id == q.id) {
164            Some(old) => *old = q,
165            None => self.questions.push(q),
166        }
167        self
168    }
169
170    /// Adds a choice question, with each option given as a label and its description.
171    #[must_use]
172    pub fn choice<L: Into<String>, D: Into<String>>(
173        self,
174        id: impl Into<String>,
175        instructions: impl Into<String>,
176        options: impl IntoIterator<Item = (L, D)>,
177    ) -> Self {
178        let options = options
179            .into_iter()
180            .map(|(l, d)| ChoiceOption {
181                label: l.into(),
182                description: Some(Value::String(d.into())),
183            })
184            .collect();
185        self.question(Question {
186            id: id.into(),
187            qtype: QType::Choice,
188            instructions: Some(Value::String(instructions.into())),
189            criteria: Criteria::Choice(options),
190        })
191    }
192
193    /// Adds a score question with its levels, lowest first.
194    #[must_use]
195    pub fn score<S: Into<String>>(
196        self,
197        id: impl Into<String>,
198        instructions: impl Into<String>,
199        levels: impl IntoIterator<Item = S>,
200    ) -> Self {
201        let levels = levels.into_iter().map(|l| Value::String(l.into())).collect();
202        self.question(Question {
203            id: id.into(),
204            qtype: QType::Score,
205            instructions: Some(Value::String(instructions.into())),
206            criteria: Criteria::Score(levels),
207        })
208    }
209
210    /// Adds a noul question: is `statement` true of the state.
211    #[must_use]
212    pub fn noul(self, id: impl Into<String>, statement: impl Into<String>) -> Self {
213        self.question(Question {
214            id: id.into(),
215            qtype: QType::Noul,
216            instructions: Some(Value::String(statement.into())),
217            criteria: Criteria::Noul { when_false: None, when_true: None },
218        })
219    }
220
221    /// The request as the JSON body [`parse`] reads.
222    #[must_use]
223    pub fn to_json(&self) -> Value {
224        let mut qs = Map::new();
225        for q in &self.questions {
226            let mut o = Map::new();
227            o.insert("type".into(), q.qtype.as_str().into());
228            if let Some(i) = &q.instructions {
229                o.insert("instructions".into(), i.clone());
230            }
231            let criteria = match &q.criteria {
232                Criteria::Choice(opts) => Some(Value::Object(
233                    opts.iter()
234                        .map(|c| (c.label.clone(), c.description.clone().unwrap_or(Value::Null)))
235                        .collect(),
236                )),
237                Criteria::Score(levels) => Some(Value::Array(levels.clone())),
238                Criteria::Noul { when_false: None, when_true: None } => None,
239                Criteria::Noul { when_false, when_true } => {
240                    let mut m = Map::new();
241                    for (k, v) in [("false", when_false), ("true", when_true)] {
242                        if let Some(v) = v {
243                            m.insert(k.into(), v.clone());
244                        }
245                    }
246                    Some(Value::Object(m))
247                }
248            };
249            if let Some(c) = criteria {
250                o.insert("criteria".into(), c);
251            }
252            qs.insert(q.id.clone(), Value::Object(o));
253        }
254        let mut body = Map::new();
255        body.insert("state".into(), self.state.clone());
256        if let Some(m) = &self.model {
257            body.insert("model".into(), m.clone().into());
258        }
259        body.insert("questions".into(), Value::Object(qs));
260        if let Some(k) = &self.kime {
261            body.insert("kime".into(), Value::Object(k.clone()));
262        }
263        Value::Object(body)
264    }
265}
266
267/// The limits and the few rules where Jev and Laya disagree.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct Limits {
270    /// The most questions in one request.
271    pub max_questions: usize,
272    /// The most options in one choice question.
273    pub max_options: usize,
274    /// The fewest levels a score question may have. Jev needs 2 and Laya takes 1.
275    pub min_levels: usize,
276    /// The most levels a score question may have.
277    pub max_levels: usize,
278    /// Whether an empty `questions` object is allowed. Jev rejects it and Laya answers it with no
279    /// answers.
280    pub allow_no_questions: bool,
281    /// Whether to take what Laya takes without a word: a null or missing `state`, which Laya
282    /// renders as the text `null`, and noul criteria keys other than true and false, which Laya
283    /// ignores.
284    pub lenient: bool,
285}
286
287impl Limits {
288    /// The defaults from spec/03-api.md.
289    pub const JEV: Limits = Limits {
290        max_questions: 256,
291        max_options: 255,
292        min_levels: 2,
293        max_levels: 32,
294        allow_no_questions: false,
295        lenient: false,
296    };
297
298    /// What Laya accepts, for requests to a compat model that did not send a `kime` object.
299    pub const LAYA: Limits = Limits {
300        max_questions: 256,
301        max_options: 255,
302        min_levels: 1,
303        max_levels: 32,
304        allow_no_questions: true,
305        lenient: true,
306    };
307}
308
309impl Default for Limits {
310    fn default() -> Self {
311        Limits::JEV
312    }
313}
314
315/// One element of an error location.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum Loc {
318    /// An object key.
319    Key(String),
320    /// An array index.
321    Index(usize),
322}
323
324impl From<&str> for Loc {
325    fn from(s: &str) -> Self {
326        Loc::Key(s.to_string())
327    }
328}
329
330/// One validation problem, in the shape FastAPI reports it.
331#[derive(Debug, Clone, PartialEq)]
332pub struct Problem {
333    /// Where the problem is, starting with `body`.
334    pub loc: Vec<Loc>,
335    /// A sentence for the person reading it.
336    pub msg: String,
337    /// A short machine readable kind, such as `missing` or `too_short`.
338    pub kind: &'static str,
339    /// The value that was rejected, or null when the field was missing.
340    pub input: Value,
341}
342
343impl Problem {
344    /// The FastAPI JSON form: `{"loc": [...], "msg": "...", "type": "...", "input": ...}`.
345    #[must_use]
346    pub fn to_json(&self) -> Value {
347        let loc: Vec<Value> = self
348            .loc
349            .iter()
350            .map(|l| match l {
351                Loc::Key(k) => Value::String(k.clone()),
352                Loc::Index(i) => Value::from(*i),
353            })
354            .collect();
355        serde_json::json!({"loc": loc, "msg": self.msg, "type": self.kind, "input": self.input})
356    }
357}
358
359struct Problems(Vec<Problem>);
360
361impl Problems {
362    fn add(&mut self, loc: &[Loc], kind: &'static str, msg: impl Into<String>, input: &Value) {
363        let mut full = vec![Loc::from("body")];
364        full.extend_from_slice(loc);
365        self.0.push(Problem { loc: full, msg: msg.into(), kind, input: input.clone() });
366    }
367}
368
369/// Parse and validate a request body.
370///
371/// # Errors
372///
373/// Every problem found, in the order the fields appear.
374pub fn parse(body: &Value, limits: &Limits) -> Result<Request, Vec<Problem>> {
375    let mut p = Problems(Vec::new());
376    let Some(obj) = body.as_object() else {
377        p.add(&[], "dict_type", "the request body must be a JSON object", body);
378        return Err(p.0);
379    };
380
381    let state = match obj.get("state") {
382        None | Some(Value::Null) if limits.lenient => Value::Null,
383        None => {
384            p.add(&["state".into()], "missing", "Field required", &Value::Null);
385            Value::Null
386        }
387        Some(Value::Null) => {
388            p.add(
389                &["state".into()],
390                "value_error",
391                "state must not be null, send \"\" for an empty state",
392                &Value::Null,
393            );
394            Value::Null
395        }
396        Some(s) => s.clone(),
397    };
398
399    let model = match obj.get("model") {
400        None | Some(Value::Null) => None,
401        Some(Value::String(s)) => Some(s.clone()),
402        Some(other) => {
403            p.add(&["model".into()], "string_type", "Input should be a valid string", other);
404            None
405        }
406    };
407
408    let kime = match obj.get("kime") {
409        None | Some(Value::Null) => None,
410        Some(Value::Object(m)) => Some(m.clone()),
411        Some(other) => {
412            p.add(&["kime".into()], "dict_type", "Input should be a valid dictionary", other);
413            None
414        }
415    };
416
417    let mut questions = Vec::new();
418    match obj.get("questions") {
419        None => p.add(&["questions".into()], "missing", "Field required", &Value::Null),
420        Some(Value::Object(qs)) => {
421            if qs.is_empty() && !limits.allow_no_questions {
422                p.add(
423                    &["questions".into()],
424                    "too_short",
425                    "questions must have at least one question",
426                    &Value::Object(qs.clone()),
427                );
428            }
429            if qs.len() > limits.max_questions {
430                p.add(
431                    &["questions".into()],
432                    "too_long",
433                    format!(
434                        "a request can have at most {} questions, got {}",
435                        limits.max_questions,
436                        qs.len()
437                    ),
438                    &Value::Null,
439                );
440            }
441            for (id, q) in qs {
442                if let Some(q) = question(id, q, limits, &mut p) {
443                    questions.push(q);
444                }
445            }
446        }
447        Some(other) => {
448            p.add(&["questions".into()], "dict_type", "Input should be a valid dictionary", other)
449        }
450    }
451
452    if p.0.is_empty() { Ok(Request { state, model, questions, kime }) } else { Err(p.0) }
453}
454
455fn question(id: &str, q: &Value, limits: &Limits, p: &mut Problems) -> Option<Question> {
456    let at = |rest: &[Loc]| {
457        let mut loc = vec![Loc::from("questions"), Loc::Key(id.to_string())];
458        loc.extend_from_slice(rest);
459        loc
460    };
461    let Some(obj) = q.as_object() else {
462        p.add(&at(&[]), "dict_type", "Input should be a valid dictionary", q);
463        return None;
464    };
465    let qtype = match obj.get("type") {
466        None => {
467            p.add(&at(&["type".into()]), "missing", "Field required", &Value::Null);
468            return None;
469        }
470        Some(Value::String(t)) if t == "choice" => QType::Choice,
471        Some(Value::String(t)) if t == "score" => QType::Score,
472        Some(Value::String(t)) if t == "noul" => QType::Noul,
473        Some(other) => {
474            p.add(
475                &at(&["type".into()]),
476                "literal_error",
477                "Input should be 'choice', 'score' or 'noul'",
478                other,
479            );
480            return None;
481        }
482    };
483    let instructions = obj.get("instructions").cloned();
484    let crit_loc = at(&["criteria".into()]);
485    let crit = obj.get("criteria");
486    let before = p.0.len();
487    let criteria = match qtype {
488        QType::Choice => choice(id, crit, limits, &crit_loc, p),
489        QType::Score => score(id, crit, limits, &crit_loc, p),
490        QType::Noul => noul(id, crit, &crit_loc, limits, p),
491    };
492    if p.0.len() > before {
493        return None;
494    }
495    Some(Question { id: id.to_string(), qtype, instructions, criteria })
496}
497
498fn with(loc: &[Loc], last: Loc) -> Vec<Loc> {
499    let mut v = loc.to_vec();
500    v.push(last);
501    v
502}
503
504fn choice(
505    id: &str,
506    crit: Option<&Value>,
507    limits: &Limits,
508    loc: &[Loc],
509    p: &mut Problems,
510) -> Criteria {
511    let mut options = Vec::new();
512    match crit {
513        None => p.add(loc, "missing", "Field required", &Value::Null),
514        Some(Value::Object(m)) => {
515            for (label, desc) in m {
516                let description = if desc.is_null() { None } else { Some(desc.clone()) };
517                options.push(ChoiceOption { label: label.clone(), description });
518            }
519        }
520        Some(Value::Array(items)) => {
521            for (i, item) in items.iter().enumerate() {
522                match item {
523                    Value::String(label) => options.push(ChoiceOption { label: label.clone(), description: None }),
524                    other => p.add(&with(loc, Loc::Index(i)), "string_type", "Input should be a valid string", other),
525                }
526            }
527        }
528        Some(other) => p.add(
529            loc,
530            "choice_criteria_type",
531            format!("choice question '{id}' takes criteria as an object of label to description, or an array of labels"),
532            other,
533        ),
534    }
535    if let Some(c) = crit.filter(|c| c.is_object() || c.is_array()) {
536        let n = match c {
537            Value::Object(m) => m.len(),
538            Value::Array(a) => a.len(),
539            _ => 0,
540        };
541        if n == 0 {
542            p.add(
543                loc,
544                "too_short",
545                format!("choice question '{id}' needs at least 1 option, got 0"),
546                c,
547            );
548        }
549        if n > limits.max_options {
550            p.add(
551                loc,
552                "too_long",
553                format!(
554                    "choice question '{id}' has {n} options, the limit is {}",
555                    limits.max_options
556                ),
557                &Value::Null,
558            );
559        }
560    }
561    let mut seen = std::collections::HashSet::new();
562    for o in &options {
563        let trimmed = o.label.trim();
564        if !seen.insert(trimmed) {
565            p.add(
566                loc,
567                "duplicate_label",
568                format!("choice question '{id}' has the label '{trimmed}' more than once"),
569                &Value::String(o.label.clone()),
570            );
571        }
572    }
573    Criteria::Choice(options)
574}
575
576fn score(
577    id: &str,
578    crit: Option<&Value>,
579    limits: &Limits,
580    loc: &[Loc],
581    p: &mut Problems,
582) -> Criteria {
583    let levels: Vec<Value> = match crit {
584        None => {
585            p.add(loc, "missing", "Field required", &Value::Null);
586            return Criteria::Score(Vec::new());
587        }
588        Some(Value::Array(items)) => items.clone(),
589        // Old Python SDK clients send {"0": ..., "1": ...}. Accepted when the keys are exactly 0 to n-1.
590        Some(Value::Object(m)) if (0..m.len()).all(|i| m.contains_key(&i.to_string())) => {
591            (0..m.len()).map(|i| m[&i.to_string()].clone()).collect()
592        }
593        Some(other) => {
594            p.add(
595                loc,
596                "score_criteria_type",
597                format!("score question '{id}' takes criteria as an array of level descriptions, level 0 first"),
598                other,
599            );
600            return Criteria::Score(Vec::new());
601        }
602    };
603    let n = levels.len();
604    if n < limits.min_levels {
605        let unit = if limits.min_levels == 1 { "level" } else { "levels" };
606        p.add(
607            loc,
608            "too_short",
609            format!("score question '{id}' needs at least {} {unit}, got {n}", limits.min_levels),
610            crit.unwrap_or(&Value::Null),
611        );
612    }
613    if n > limits.max_levels {
614        p.add(
615            loc,
616            "too_long",
617            format!("score question '{id}' has {n} levels, the limit is {}", limits.max_levels),
618            &Value::Null,
619        );
620    }
621    Criteria::Score(levels)
622}
623
624fn noul(
625    id: &str,
626    crit: Option<&Value>,
627    loc: &[Loc],
628    limits: &Limits,
629    p: &mut Problems,
630) -> Criteria {
631    let mut when_false = None;
632    let mut when_true = None;
633    match crit {
634        None | Some(Value::Null) => {}
635        Some(Value::Object(m)) => {
636            // Keys match case insensitively, as in Laya, which lower cases str(key). A later key
637            // wins over an earlier one that lower cases the same, as it does in a Python dict.
638            for (k, v) in m {
639                match k.to_lowercase().as_str() {
640                    "true" => when_true = Some(v.clone()),
641                    "false" => when_false = Some(v.clone()),
642                    _ if limits.lenient => {}
643                    _ => p.add(
644                        &with(loc, Loc::Key(k.clone())),
645                        "noul_key",
646                        format!("noul question '{id}' takes only the keys true and false, got '{k}'"),
647                        v,
648                    ),
649                }
650            }
651        }
652        Some(other) => p.add(
653            loc,
654            "noul_criteria_type",
655            format!("noul question '{id}' takes criteria as an object with optional true and false descriptions, or none"),
656            other,
657        ),
658    }
659    Criteria::Noul { when_false, when_true }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use serde_json::json;
666
667    #[test]
668    fn built_requests_round_trip() {
669        let r = Request::new(State::json(&json!({"msg": "refund please"})))
670            .choice("dept", "Which team", [("billing", "Payment issues"), ("technical", "Bugs")])
671            .score("urgency", "How urgent", ["not urgent", "soon", "critical"])
672            .noul("churn", "The customer threatens to leave")
673            .noul("dept", "Replaced where it stood");
674        let back = parse(&r.to_json(), &Limits::JEV).unwrap();
675        assert_eq!(back, r);
676        assert_eq!(back.questions[0].qtype, QType::Noul);
677    }
678
679    #[test]
680    fn the_spec_example_parses() {
681        let body = json!({
682            "state": {"ticket": {"subject": "Refund", "messages": [{"text": "charged twice"}]}},
683            "model": "jev-latest",
684            "questions": {
685                "topic": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "money", "technical": null, "sales": ""}},
686                "urgency": {"type": "score", "instructions": "How upset?", "criteria": ["Calm", "Frustrated", "Very angry"]},
687                "refund": {"type": "noul", "instructions": "Wants a refund."}
688            }
689        });
690        let r = parse(&body, &Limits::JEV).unwrap();
691        assert_eq!(r.model.as_deref(), Some("jev-latest"));
692        let ids: Vec<&str> = r.questions.iter().map(|q| q.id.as_str()).collect();
693        assert_eq!(ids, ["topic", "urgency", "refund"]);
694        let Criteria::Choice(o) = &r.questions[0].criteria else { panic!() };
695        assert_eq!(o[1].description, None);
696        assert_eq!(r.questions[2].criteria, Criteria::Noul { when_false: None, when_true: None });
697    }
698
699    #[test]
700    fn every_problem_is_reported() {
701        let body = json!({
702            "questions": {
703                "a": {"type": "score", "criteria": ["only"]},
704                "b": {"type": "maybe"},
705                "c": {"type": "choice", "criteria": {"x": 1, " x": 2}},
706                "d": {"type": "noul", "criteria": {"True": "yes", "perhaps": "?"}},
707                "e": {"type": "choice", "criteria": []}
708            }
709        });
710        let errs = parse(&body, &Limits::JEV).unwrap_err();
711        let msgs: Vec<&str> = errs.iter().map(|e| e.msg.as_str()).collect();
712        assert_eq!(
713            msgs,
714            [
715                "Field required",
716                "score question 'a' needs at least 2 levels, got 1",
717                "Input should be 'choice', 'score' or 'noul'",
718                "choice question 'c' has the label 'x' more than once",
719                "noul question 'd' takes only the keys true and false, got 'perhaps'",
720                "choice question 'e' needs at least 1 option, got 0",
721            ]
722        );
723        assert_eq!(errs[1].to_json()["loc"], json!(["body", "questions", "a", "criteria"]));
724        assert_eq!(
725            errs[4].to_json()["loc"],
726            json!(["body", "questions", "d", "criteria", "perhaps"])
727        );
728    }
729
730    #[test]
731    fn laya_rules() {
732        let body = json!({"state": "", "questions": {}});
733        assert!(parse(&body, &Limits::JEV).is_err());
734        assert!(parse(&body, &Limits::LAYA).unwrap().questions.is_empty());
735        let one = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": ["a"]}}});
736        assert!(parse(&one, &Limits::JEV).is_err());
737        assert!(parse(&one, &Limits::LAYA).is_ok());
738        // Laya renders a null or missing state as the text null.
739        for body in [json!({"state": null, "questions": {}}), json!({"questions": {}})] {
740            assert!(parse(&body, &Limits::JEV).is_err());
741            assert_eq!(parse(&body, &Limits::LAYA).unwrap().state, Value::Null);
742        }
743        // Laya ignores noul keys other than true and false.
744        let noul = json!({"state": "x", "questions": {"n": {"type": "noul", "instructions": "i", "criteria": {"maybe": "m", "True": "yes"}}}});
745        assert!(parse(&noul, &Limits::JEV).is_err());
746        assert_eq!(
747            parse(&noul, &Limits::LAYA).unwrap().questions[0].criteria,
748            Criteria::Noul { when_false: None, when_true: Some(json!("yes")) }
749        );
750    }
751
752    #[test]
753    fn old_sdk_score_objects() {
754        let body = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"1": "b", "0": "a"}}}});
755        let r = parse(&body, &Limits::JEV).unwrap();
756        assert_eq!(r.questions[0].criteria, Criteria::Score(vec![json!("a"), json!("b")]));
757        let bad = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"0": "a", "2": "b"}}}});
758        assert!(parse(&bad, &Limits::JEV).is_err());
759    }
760}