Skip to main content

jev_sdk/
question.rs

1//! Typed questions that can be asked of a System One model.
2//!
3//! There are three question primitives, mirroring the TypeSafe API:
4//!
5//! * [`Noul`] — a yes/no question; answers with the probability of "yes".
6//! * [`Choice`] — pick one option from a defined set; answers with the chosen
7//!   option, the full probability distribution, and a confidence.
8//! * [`Score`] — rate the state against ordered rubric levels; answers with a
9//!   probability-weighted score, per-level probabilities, and a confidence.
10
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Any content that can be sent as part of a question: a plain string or
16/// arbitrary JSON structure.
17///
18/// Implemented for `&str`, `String`, and [`serde_json::Value`].
19pub trait IntoContent {
20    /// Convert into a JSON value.
21    fn into_content(self) -> Value;
22}
23
24impl IntoContent for &str {
25    fn into_content(self) -> Value {
26        Value::String(self.to_owned())
27    }
28}
29
30impl IntoContent for String {
31    fn into_content(self) -> Value {
32        Value::String(self)
33    }
34}
35
36impl IntoContent for &String {
37    fn into_content(self) -> Value {
38        Value::String(self.clone())
39    }
40}
41
42impl IntoContent for Value {
43    fn into_content(self) -> Value {
44        self
45    }
46}
47
48impl IntoContent for &Value {
49    fn into_content(self) -> Value {
50        self.clone()
51    }
52}
53
54/// An optional description for a [`Choice`] option.
55///
56/// Implemented for `&str`, `String`, `Option<&str>`, `Option<String>` and
57/// `()`, so both `Some("detail")` / `"detail"` and `None` / `()` work.
58pub trait IntoDescription {
59    /// Convert into an optional description.
60    fn into_description(self) -> Option<String>;
61}
62
63impl IntoDescription for &str {
64    fn into_description(self) -> Option<String> {
65        Some(self.to_owned())
66    }
67}
68
69impl IntoDescription for String {
70    fn into_description(self) -> Option<String> {
71        Some(self)
72    }
73}
74
75impl IntoDescription for Option<&str> {
76    fn into_description(self) -> Option<String> {
77        self.map(str::to_owned)
78    }
79}
80
81impl IntoDescription for Option<String> {
82    fn into_description(self) -> Option<String> {
83        self
84    }
85}
86
87impl IntoDescription for Option<&String> {
88    fn into_description(self) -> Option<String> {
89        self.cloned()
90    }
91}
92
93impl IntoDescription for () {
94    fn into_description(self) -> Option<String> {
95        None
96    }
97}
98
99/// A typed question to evaluate against the state.
100///
101/// Use [`Noul`], [`Choice`], or [`Score`] directly — they all convert into
102/// `Question` via `From`.
103///
104/// `Deserialize` is derived too, so question specs can round-trip through
105/// JSON (e.g. tools that read question batteries from files).
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "lowercase")]
108pub enum Question {
109    /// A yes/no question.
110    Noul(Noul),
111    /// Pick one option from a set.
112    Choice(Choice),
113    /// Rate the state against ordered levels.
114    Score(Score),
115}
116
117/// A yes/no question. The answer is the probability that the answer is "yes".
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Noul {
120    /// The yes/no question to evaluate. A string or arbitrary JSON structure.
121    pub instructions: Value,
122    /// Optional descriptions of what a "yes" and a "no" mean.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub criteria: Option<NoulCriteria>,
125}
126
127/// Optional descriptions of the two outcomes of a [`Noul`] question.
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129pub struct NoulCriteria {
130    /// What a "yes" (value near 1) means.
131    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
132    pub yes: Option<String>,
133    /// What a "no" (value near 0) means.
134    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
135    pub no: Option<String>,
136}
137
138/// Pick one option from a defined set.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct Choice {
141    /// What the model should decide. A string or arbitrary JSON structure.
142    pub instructions: Value,
143    /// Map of option name to an optional rubric description for that option.
144    /// Insertion order is preserved.
145    pub criteria: IndexMap<String, Option<String>>,
146}
147
148/// Rate the state along an ordered rubric.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Score {
151    /// What the model should rate. A string or arbitrary JSON structure.
152    pub instructions: Value,
153    /// Ordered level descriptions, from lowest to highest.
154    pub criteria: Vec<String>,
155}
156
157impl Noul {
158    /// Create a yes/no question.
159    pub fn new(instructions: impl IntoContent) -> Self {
160        Self {
161            instructions: instructions.into_content(),
162            criteria: None,
163        }
164    }
165
166    /// Describe what "yes" and "no" mean.
167    pub fn criteria(mut self, criteria: NoulCriteria) -> Self {
168        self.criteria = Some(criteria);
169        self
170    }
171}
172
173impl NoulCriteria {
174    /// Describe both outcomes.
175    pub fn new(yes: impl Into<String>, no: impl Into<String>) -> Self {
176        Self {
177            yes: Some(yes.into()),
178            no: Some(no.into()),
179        }
180    }
181
182    /// Describe only the "yes" outcome.
183    pub fn yes(yes: impl Into<String>) -> Self {
184        Self {
185            yes: Some(yes.into()),
186            no: None,
187        }
188    }
189
190    /// Describe only the "no" outcome.
191    pub fn no(no: impl Into<String>) -> Self {
192        Self {
193            yes: None,
194            no: Some(no.into()),
195        }
196    }
197}
198
199impl Choice {
200    /// Create a choice question from option/description pairs.
201    ///
202    /// ```
203    /// use jev_sdk::Choice;
204    ///
205    /// let q = Choice::new(
206    ///     "What is the tone?",
207    ///     [("calm", None), ("angry", Some("Strong language"))],
208    /// );
209    /// assert_eq!(q.criteria.len(), 2);
210    /// ```
211    pub fn new<K, D, I>(instructions: impl IntoContent, criteria: I) -> Self
212    where
213        K: Into<String>,
214        D: IntoDescription,
215        I: IntoIterator<Item = (K, D)>,
216    {
217        Self {
218            instructions: instructions.into_content(),
219            criteria: criteria
220                .into_iter()
221                .map(|(k, d)| (k.into(), d.into_description()))
222                .collect(),
223        }
224    }
225
226    /// Create a choice question from bare option names, with no per-option
227    /// descriptions.
228    ///
229    /// ```
230    /// use jev_sdk::Choice;
231    ///
232    /// let q = Choice::options("What is the tone?", ["calm", "angry"]);
233    /// assert_eq!(q.criteria.len(), 2);
234    /// ```
235    pub fn options<I, S>(instructions: impl IntoContent, options: I) -> Self
236    where
237        I: IntoIterator<Item = S>,
238        S: Into<String>,
239    {
240        Self {
241            instructions: instructions.into_content(),
242            criteria: options.into_iter().map(|o| (o.into(), None)).collect(),
243        }
244    }
245}
246
247impl Score {
248    /// Create a score question from ordered level descriptions (low to high).
249    ///
250    /// The API requires at least two levels; this is validated when the
251    /// request is sent.
252    pub fn new<I, S>(instructions: impl IntoContent, criteria: I) -> Self
253    where
254        I: IntoIterator<Item = S>,
255        S: Into<String>,
256    {
257        Self {
258            instructions: instructions.into_content(),
259            criteria: criteria.into_iter().map(Into::into).collect(),
260        }
261    }
262}
263
264impl From<Noul> for Question {
265    fn from(q: Noul) -> Self {
266        Question::Noul(q)
267    }
268}
269
270impl From<Choice> for Question {
271    fn from(q: Choice) -> Self {
272        Question::Choice(q)
273    }
274}
275
276impl From<Score> for Question {
277    fn from(q: Score) -> Self {
278        Question::Score(q)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use serde_json::json;
286
287    #[test]
288    fn noul_serializes_with_tag() {
289        let q = Question::from(Noul::new("Is this urgent?"));
290        let v = serde_json::to_value(&q).unwrap();
291        assert_eq!(
292            v,
293            json!({"type": "noul", "instructions": "Is this urgent?"})
294        );
295    }
296
297    #[test]
298    fn noul_criteria_uses_true_false_keys() {
299        let q = Question::from(
300            Noul::new("Urgent?").criteria(NoulCriteria::new("Time-sensitive", "No urgency")),
301        );
302        let v = serde_json::to_value(&q).unwrap();
303        assert_eq!(
304            v,
305            json!({
306                "type": "noul",
307                "instructions": "Urgent?",
308                "criteria": {"true": "Time-sensitive", "false": "No urgency"},
309            })
310        );
311    }
312
313    #[test]
314    fn choice_serializes_with_null_descriptions() {
315        let q = Question::from(Choice::new(
316            "Tone?",
317            [("calm", None), ("angry", Some("Strong language"))],
318        ));
319        let v = serde_json::to_value(&q).unwrap();
320        assert_eq!(
321            v,
322            json!({
323                "type": "choice",
324                "instructions": "Tone?",
325                "criteria": {"calm": null, "angry": "Strong language"},
326            })
327        );
328    }
329
330    #[test]
331    fn score_serializes_ordered_levels() {
332        let q = Question::from(Score::new("Frustration?", ["Calm", "Frustrated", "Angry"]));
333        let v = serde_json::to_value(&q).unwrap();
334        assert_eq!(
335            v,
336            json!({
337                "type": "score",
338                "instructions": "Frustration?",
339                "criteria": ["Calm", "Frustrated", "Angry"],
340            })
341        );
342    }
343
344    #[test]
345    fn structured_instructions_are_supported() {
346        let q = Question::from(Noul::new(
347            json!({"question": "Urgent?", "context": {"n": 1}}),
348        ));
349        let v = serde_json::to_value(&q).unwrap();
350        assert_eq!(v["instructions"]["question"], "Urgent?");
351    }
352
353    #[test]
354    fn questions_deserialize_from_api_json() {
355        // The wire shape tools emit (and the API accepts) must parse back.
356        let raw = json!({
357            "type": "choice",
358            "instructions": "Which path?",
359            "criteria": {"a": "Fast path", "b": null},
360        });
361        let q: Question = serde_json::from_value(raw).unwrap();
362        let Question::Choice(c) = &q else {
363            panic!("expected choice, got {q:?}");
364        };
365        assert_eq!(c.instructions, json!("Which path?"));
366        assert_eq!(c.criteria["a"], Some("Fast path".to_owned()));
367        assert_eq!(c.criteria["b"], None);
368        // Insertion order survives the round trip.
369        let keys: Vec<_> = c.criteria.keys().collect();
370        assert_eq!(keys, ["a", "b"]);
371    }
372
373    #[test]
374    fn questions_round_trip_through_json() {
375        let questions = [
376            Question::from(Noul::new("Will it work?").criteria(NoulCriteria::new("Yes", "No"))),
377            Question::from(Choice::new("Pick", [("x", None), ("y", Some("Why y"))])),
378            Question::from(Score::new("Rate", ["low", "mid", "high"])),
379        ];
380        for q in questions {
381            let v = serde_json::to_value(&q).unwrap();
382            let back: Question = serde_json::from_value(v.clone()).unwrap();
383            assert_eq!(serde_json::to_value(&back).unwrap(), v);
384        }
385    }
386
387    #[test]
388    fn deserialize_rejects_unknown_type() {
389        let raw = json!({"type": "maybe", "instructions": "?"});
390        assert!(serde_json::from_value::<Question>(raw).is_err());
391    }
392
393    #[test]
394    fn noul_criteria_partial_round_trip() {
395        let raw = json!({"type": "noul", "instructions": "Q?", "criteria": {"true": "yes means"}});
396        let q: Question = serde_json::from_value(raw).unwrap();
397        let Question::Noul(n) = &q else {
398            panic!("expected noul, got {q:?}");
399        };
400        let criteria = n.criteria.as_ref().unwrap();
401        assert_eq!(criteria.yes.as_deref(), Some("yes means"));
402        assert_eq!(criteria.no, None);
403    }
404}