Skip to main content

typesafe_system_one/
questions.rs

1//! Question builders and wire serialization.
2//!
3//! A request's `questions` field is a map of caller-chosen IDs to question
4//! objects. The IDs are for code only — they are not sent to the model — and
5//! the response uses the same IDs to key its answers.
6//!
7//! Each question is exactly one of three kinds, discriminated on the wire by a
8//! `type` field (`"noul"`, `"choice"`, or `"score"`). Unset optional fields are
9//! omitted from the wire, never sent as `null`; nulls the caller deliberately
10//! places *inside* criteria are preserved.
11//!
12//! Choice option order is preserved exactly as supplied, because
13//! `serde_json` is built with the `preserve_order` feature.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::errors::Error;
19
20/// Optional descriptions of the yes and no outcomes of a noul question.
21///
22/// Either or both may be `None`, in which case the corresponding key is
23/// omitted from the wire. Each present value may be a string, object, or
24/// array — or deliberately `null` to leave that outcome undescribed while
25/// still sending the criteria object.
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27pub struct NoulCriteria {
28    /// What counts as a yes answer. Serialized as `"true"`, omitted when `None`.
29    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
30    pub true_: Option<Value>,
31    /// What counts as a no answer. Serialized as `"false"`, omitted when `None`.
32    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
33    pub false_: Option<Value>,
34}
35
36impl NoulCriteria {
37    /// Creates criteria from optional descriptions of the yes and no outcomes.
38    ///
39    /// `Some(value)` sends that key; `None` omits it. Pass
40    /// `Some(Value::Null)` to send an explicit `null` for a side.
41    pub fn new(true_: Option<impl Into<Value>>, false_: Option<impl Into<Value>>) -> Self {
42        Self {
43            true_: true_.map(Into::into),
44            false_: false_.map(Into::into),
45        }
46    }
47}
48
49/// A yes/no question, answered with the probability of yes.
50///
51/// ```no_run
52/// # use typesafe_system_one::Noul;
53/// let spam = Noul::new("Is this message spam?")
54///     .criteria_true("Unsolicited advertising")
55///     .criteria_false("A legitimate conversation");
56/// ```
57///
58/// Instructions and criteria are optional on the wire; both accept strings,
59/// objects, or arrays for advanced structure.
60#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "type", rename = "noul")]
62pub struct Noul {
63    /// The question or statement to evaluate: string, object, or array.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub instructions: Option<Value>,
66    /// Optional descriptions of what counts as yes and no.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub criteria: Option<NoulCriteria>,
69}
70
71impl Noul {
72    /// Creates a noul question with instructions and no criteria.
73    pub fn new(instructions: impl Into<Value>) -> Self {
74        Self {
75            instructions: Some(instructions.into()),
76            criteria: None,
77        }
78    }
79
80    /// Creates a noul question with no instructions and no criteria.
81    pub fn without_instructions() -> Self {
82        Self::default()
83    }
84
85    /// Replaces the instructions.
86    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
87        self.instructions = Some(instructions.into());
88        self
89    }
90
91    /// Replaces the criteria from optional true/false descriptions.
92    ///
93    /// `Some(value)` sends that side; `None` omits it. Pass
94    /// `Some(Value::Null)` to send an explicit `null` for a side.
95    ///
96    /// ```no_run
97    /// # use typesafe_system_one::Noul;
98    /// let urgent = Noul::new("Is this urgent?")
99    ///     .criteria(Some("Time-sensitive"), None::<&str>);
100    /// ```
101    pub fn criteria(
102        mut self,
103        true_: Option<impl Into<Value>>,
104        false_: Option<impl Into<Value>>,
105    ) -> Self {
106        self.criteria = Some(NoulCriteria::new(true_, false_));
107        self
108    }
109
110    /// Replaces the criteria from a [`NoulCriteria`] value directly.
111    pub fn criteria_value(mut self, criteria: NoulCriteria) -> Self {
112        self.criteria = Some(criteria);
113        self
114    }
115
116    /// Sets the "what counts as yes" side of the criteria, leaving the false
117    /// side unset.
118    pub fn criteria_true(mut self, true_: impl Into<Value>) -> Self {
119        let criteria = self.criteria.take().unwrap_or_default();
120        self.criteria = Some(NoulCriteria {
121            true_: Some(true_.into()),
122            ..criteria
123        });
124        self
125    }
126
127    /// Sets the "what counts as no" side of the criteria, leaving the true
128    /// side unset.
129    pub fn criteria_false(mut self, false_: impl Into<Value>) -> Self {
130        let criteria = self.criteria.take().unwrap_or_default();
131        self.criteria = Some(NoulCriteria {
132            false_: Some(false_.into()),
133            ..criteria
134        });
135        self
136    }
137}
138
139/// A question that selects one option from choices the caller defines.
140///
141/// Options serialize in the order supplied, each mapped to a description that
142/// is a string, object, array — or `null` for an option interpreted by its
143/// name alone.
144///
145/// ```no_run
146/// # use typesafe_system_one::Choice;
147/// let tone = Choice::new("What is the tone of this message?")
148///     .option("calm", "A neutral or polite message")
149///     .option("angry", "An upset or hostile message");
150/// ```
151#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
152#[serde(tag = "type", rename = "choice")]
153pub struct Choice {
154    /// What the model should decide when choosing an option.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub instructions: Option<Value>,
157    /// Options in the order the caller supplied them, mapped to descriptions
158    /// (a JSON `null` description means "interpret the name alone").
159    pub criteria: serde_json::Map<String, Value>,
160}
161
162impl Choice {
163    /// Creates a choice question with instructions and no options yet.
164    pub fn new(instructions: impl Into<Value>) -> Self {
165        Self {
166            instructions: Some(instructions.into()),
167            criteria: serde_json::Map::new(),
168        }
169    }
170
171    /// Creates a choice question with no instructions.
172    pub fn without_instructions() -> Self {
173        Self::default()
174    }
175
176    /// Creates a choice question whose options all have `null` descriptions.
177    ///
178    /// Options serialize in iteration order of `options`.
179    pub fn from_options<I, S>(instructions: impl Into<Value>, options: I) -> Self
180    where
181        I: IntoIterator<Item = S>,
182        S: Into<String>,
183    {
184        let mut criteria = serde_json::Map::new();
185        for option in options {
186            criteria.insert(option.into(), Value::Null);
187        }
188        Self {
189            instructions: Some(instructions.into()),
190            criteria,
191        }
192    }
193
194    /// Creates a choice question from an ordered list of `(option, description)` pairs.
195    ///
196    /// Each description may be any JSON value; use `Value::Null` for an
197    /// undescribed option.
198    pub fn from_pairs<I, S>(instructions: impl Into<Value>, pairs: I) -> Self
199    where
200        I: IntoIterator<Item = (S, Value)>,
201        S: Into<String>,
202    {
203        let mut criteria = serde_json::Map::new();
204        for (option, description) in pairs {
205            criteria.insert(option.into(), description);
206        }
207        Self {
208            instructions: Some(instructions.into()),
209            criteria,
210        }
211    }
212
213    /// Replaces the instructions.
214    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
215        self.instructions = Some(instructions.into());
216        self
217    }
218
219    /// Appends an option with a description.
220    ///
221    /// The description may be a string, object, or array; pass
222    /// `Value::Null` for an undescribed option.
223    pub fn option(mut self, option: impl Into<String>, description: impl Into<Value>) -> Self {
224        self.criteria.insert(option.into(), description.into());
225        self
226    }
227
228    /// Appends an option whose description is `null` (interpreted by name alone).
229    pub fn bare_option(self, option: impl Into<String>) -> Self {
230        self.option(option, Value::Null)
231    }
232}
233
234/// A question that rates the content against an ordered rubric.
235///
236/// Each level's position determines its score, starting at zero. The rubric
237/// must have at least two levels and no level may be null; the client
238/// validates both before any network I/O.
239///
240/// ```no_run
241/// # use typesafe_system_one::Score;
242/// let urgency = Score::new(
243///     "How urgent is this?",
244///     ["Can wait", "Needs attention this week", "Needs attention today"],
245/// );
246/// ```
247#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
248#[serde(tag = "type", rename = "score")]
249pub struct Score {
250    /// What the model should rate.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub instructions: Option<Value>,
253    /// Ordered level descriptions, lowest first.
254    pub criteria: Vec<Value>,
255}
256
257impl Score {
258    /// Creates a score question with instructions and an ordered rubric.
259    pub fn new<I>(instructions: impl Into<Value>, levels: I) -> Self
260    where
261        I: IntoIterator,
262        I::Item: Into<Value>,
263    {
264        Self {
265            instructions: Some(instructions.into()),
266            criteria: levels.into_iter().map(Into::into).collect(),
267        }
268    }
269
270    /// Creates a score question with no instructions.
271    pub fn without_instructions<I>(levels: I) -> Self
272    where
273        I: IntoIterator,
274        I::Item: Into<Value>,
275    {
276        Self {
277            instructions: None,
278            criteria: levels.into_iter().map(Into::into).collect(),
279        }
280    }
281
282    /// Replaces the instructions.
283    pub fn instructions(mut self, instructions: impl Into<Value>) -> Self {
284        self.instructions = Some(instructions.into());
285        self
286    }
287}
288
289/// Any of the three question kinds, discriminated on the wire by `type`.
290///
291/// ```
292/// # use typesafe_system_one::{Noul, Question};
293/// let q: Question = Noul::new("Is this about billing?").into();
294/// assert_eq!(
295///     serde_json::to_value(&q).unwrap()["type"],
296///     serde_json::json!("noul")
297/// );
298/// ```
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300#[serde(tag = "type", rename_all = "lowercase")]
301#[non_exhaustive]
302pub enum Question {
303    /// A yes/no question.
304    Noul(Noul),
305    /// A one-of-many selection.
306    Choice(Choice),
307    /// A rubric rating.
308    Score(Score),
309}
310
311impl From<Noul> for Question {
312    fn from(q: Noul) -> Self {
313        Question::Noul(q)
314    }
315}
316
317impl From<Choice> for Question {
318    fn from(q: Choice) -> Self {
319        Question::Choice(q)
320    }
321}
322
323impl From<Score> for Question {
324    fn from(q: Score) -> Self {
325        Question::Score(q)
326    }
327}
328
329pub(super) fn validate(
330    state: &Value,
331    questions: &std::collections::BTreeMap<String, Question>,
332) -> Result<(), Error> {
333    if questions.is_empty() {
334        return Err(Error::InvalidRequest(
335            "At least one question is required.".into(),
336        ));
337    }
338    match state {
339        Value::String(_) | Value::Object(_) | Value::Array(_) => {}
340        Value::Number(_) => {
341            return Err(Error::InvalidRequest(
342                "state must be a JSON string, object, or array; numbers are rejected.".into(),
343            ))
344        }
345        Value::Bool(_) => {
346            return Err(Error::InvalidRequest(
347                "state must be a JSON string, object, or array; booleans are rejected.".into(),
348            ))
349        }
350        Value::Null => {
351            return Err(Error::InvalidRequest(
352                "state must be a JSON string, object, or array; null is rejected.".into(),
353            ))
354        }
355    }
356    for (name, question) in questions {
357        match question {
358            Question::Choice(choice) => {
359                if choice.criteria.is_empty() {
360                    return Err(Error::InvalidRequest(format!(
361                        "Choice question \"{name}\" has no options; at least one is required."
362                    )));
363                }
364            }
365            Question::Score(score) => {
366                if score.criteria.len() < 2 {
367                    return Err(Error::InvalidRequest(format!(
368                        "Score question \"{name}\" has {} {}; at least two are required.",
369                        score.criteria.len(),
370                        if score.criteria.len() == 1 {
371                            "criterion"
372                        } else {
373                            "criteria"
374                        }
375                    )));
376                }
377                if let Some(position) = score.criteria.iter().position(Value::is_null) {
378                    return Err(Error::InvalidRequest(format!(
379                        "Score question \"{name}\" has a null level at position {position}; levels must be strings, objects, or arrays."
380                    )));
381                }
382            }
383            Question::Noul(_) => {}
384        }
385    }
386    Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use serde_json::json;
393    use std::collections::BTreeMap;
394
395    #[test]
396    fn serializes_noul_with_omitted_optionals() {
397        let q = Noul::new("Is this spam?");
398        let v = serde_json::to_value(&q).unwrap();
399        assert_eq!(v, json!({"type": "noul", "instructions": "Is this spam?"}));
400    }
401
402    #[test]
403    fn serializes_noul_criteria_renames_and_omits() {
404        let q = Noul::new("Spam?").criteria(Some("Unsolicited"), None::<&str>);
405        let v = serde_json::to_value(&q).unwrap();
406        assert_eq!(
407            v,
408            json!({
409                "type": "noul",
410                "instructions": "Spam?",
411                "criteria": {"true": "Unsolicited"}
412            })
413        );
414        let q = Noul::without_instructions().criteria(None::<&str>, Some("Legit"));
415        let v = serde_json::to_value(&q).unwrap();
416        assert_eq!(v, json!({"type": "noul", "criteria": {"false": "Legit"}}));
417    }
418
419    #[test]
420    fn preserves_null_criteria_inside_noul() {
421        let q = Noul::new("Spam?").criteria(Some("Unsolicited"), Some(Value::Null));
422        let v = serde_json::to_value(&q).unwrap();
423        assert_eq!(v["criteria"], json!({"true": "Unsolicited", "false": null}));
424    }
425
426    #[test]
427    fn preserves_choice_option_order() {
428        let mut choice = Choice::new("Tone?")
429            .option("b", "Second")
430            .option("a", "First");
431        choice = choice.bare_option("z").option("m", json!({"x": 1}));
432        let v = serde_json::to_value(&choice).unwrap();
433        assert_eq!(
434            v["criteria"]
435                .as_object()
436                .unwrap()
437                .keys()
438                .collect::<Vec<_>>(),
439            vec!["b", "a", "z", "m"]
440        );
441        assert_eq!(v["criteria"]["z"], json!(null));
442        assert_eq!(v["criteria"]["m"], json!({"x": 1}));
443    }
444
445    #[test]
446    fn from_options_uses_null_descriptions_in_order() {
447        let q = Choice::from_options("Team?", ["billing", "tech", "sales"]);
448        let v = serde_json::to_value(&q).unwrap();
449        assert_eq!(
450            v["criteria"]
451                .as_object()
452                .unwrap()
453                .keys()
454                .collect::<Vec<_>>(),
455            vec!["billing", "tech", "sales"]
456        );
457        assert!(v["criteria"]["billing"].is_null());
458    }
459
460    #[test]
461    fn score_serializes_ordered_levels() {
462        let q = Score::new("Urgency?", ["Low", "High"]);
463        let v = serde_json::to_value(&q).unwrap();
464        assert_eq!(
465            v,
466            json!({"type": "score", "instructions": "Urgency?", "criteria": ["Low", "High"]})
467        );
468    }
469
470    #[test]
471    fn tag_serialization_round_trips() {
472        for question in [
473            Question::Noul(Noul::new("a")),
474            Question::Choice(Choice::new("b").option("x", "y")),
475            Question::Score(Score::new("c", ["1", "2"])),
476        ] {
477            let v = serde_json::to_value(&question).unwrap();
478            let back: Question = serde_json::from_value(v).unwrap();
479            assert_eq!(back, question);
480        }
481    }
482
483    #[test]
484    fn validation_rejects_bad_input() {
485        let mut questions = BTreeMap::new();
486        // Empty questions.
487        let state = json!("text");
488        assert!(validate(&state, &questions).is_err());
489        // Number state.
490        questions.insert("x".into(), Question::Noul(Noul::new("a")));
491        for bad in [json!(1), json!(true), json!(null)] {
492            let err = validate(&bad, &questions).unwrap_err();
493            assert!(matches!(err, Error::InvalidRequest(_)), "{err}");
494        }
495        // Empty choice.
496        questions.insert("c".into(), Question::Choice(Choice::new("c")));
497        let err = validate(&state, &questions).unwrap_err();
498        let msg = err.to_string();
499        assert!(msg.contains('c') && msg.contains("at least one"), "{msg}");
500        // One-level score.
501        questions.remove("c");
502        questions.insert("s".into(), Question::Score(Score::new("s", ["only"])));
503        let err = validate(&state, &questions).unwrap_err();
504        assert!(err.to_string().contains("at least two"), "{err}");
505        // Null score level.
506        let criteria: Vec<serde_json::Value> = vec!["a".into(), serde_json::Value::Null];
507        questions.insert(
508            "s".into(),
509            Question::Score(Score {
510                instructions: Some("s".into()),
511                criteria,
512            }),
513        );
514        let err = validate(&state, &questions).unwrap_err();
515        assert!(err.to_string().contains("null level"), "{err}");
516    }
517}