Skip to main content

laya_core/
question.rs

1//! Typed question definitions, validated and rendered exactly as upstream does.
2
3use crate::error::{Error, Result};
4use crate::json_compat::{dumps_ascii, dumps_unicode};
5use serde_json::{Map, Value};
6
7/// Question type, and the integer the model's type embedding expects.
8///
9/// Mirrors upstream's `QTYPES = {"choice": 0, "score": 1, "noul": 2}`.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum QuestionKind {
12    Choice = 0,
13    Score = 1,
14    Noul = 2,
15}
16
17impl QuestionKind {
18    pub fn index(self) -> usize {
19        self as usize
20    }
21
22    pub fn name(self) -> &'static str {
23        match self {
24            Self::Choice => "choice",
25            Self::Score => "score",
26            Self::Noul => "noul",
27        }
28    }
29
30    pub fn parse(value: &str) -> Option<Self> {
31        match value {
32            "choice" => Some(Self::Choice),
33            "score" => Some(Self::Score),
34            "noul" => Some(Self::Noul),
35            _ => None,
36        }
37    }
38}
39
40/// Validated criteria, in label order.
41#[derive(Clone, Debug, PartialEq)]
42pub enum Criteria {
43    /// Ordered `(label, description)` pairs. A `Null` or `""` description means
44    /// the label is rendered on its own.
45    Choice(Vec<(String, Value)>),
46    /// Ordered rubric levels, rendered as `level 0: ...`.
47    Score(Vec<Value>),
48    /// Optional `false` / `true` descriptions.
49    Noul {
50        false_desc: Option<Value>,
51        true_desc: Option<Value>,
52    },
53}
54
55/// A question after validation, equivalent to upstream's internal
56/// `{"t": ..., "ins": ..., "crit": ...}` triple.
57#[derive(Clone, Debug, PartialEq)]
58pub struct Question {
59    pub kind: QuestionKind,
60    pub instructions: String,
61    pub criteria: Criteria,
62}
63
64fn is_blank(value: &Value) -> bool {
65    // Upstream treats only None and "" as "no description"; 0 and false are
66    // legitimate criterion values and must still render.
67    value.is_null() || value.as_str() == Some("")
68}
69
70/// Upstream's `render_criterion`: strings pass through, anything structured
71/// becomes compact JSON so a rubric reads as JSON rather than a Python repr.
72pub fn render_criterion(value: &Value) -> String {
73    match value {
74        Value::String(text) => text.clone(),
75        other => dumps_unicode(other),
76    }
77}
78
79impl Question {
80    /// Validate one question definition, mirroring `Agent._to_internal`.
81    pub fn from_value(value: &Value) -> Result<Self> {
82        let object = value
83            .as_object()
84            .ok_or_else(|| Error::Question("Each question must be a dictionary".into()))?;
85
86        let kind = object
87            .get("type")
88            .and_then(Value::as_str)
89            .and_then(QuestionKind::parse)
90            .ok_or_else(|| {
91                let shown = object.get("type").map_or("None".to_string(), render_repr);
92                Error::Question(format!(
93                    "Unknown question type {shown}; expected choice, score, or noul"
94                ))
95            })?;
96
97        let instructions_value = object
98            .get("instructions")
99            .ok_or_else(|| Error::Question("Question is missing instructions".into()))?;
100        let instructions = match instructions_value {
101            Value::String(text) => text.clone(),
102            // Upstream falls back to `json.dumps(instructions)` with CPython's
103            // defaults here, which means ensure_ascii stays on.
104            other => dumps_ascii(other),
105        };
106
107        let criteria = object.get("criteria").unwrap_or(&Value::Null);
108        let criteria = match kind {
109            QuestionKind::Choice => Self::choice_criteria(criteria)?,
110            QuestionKind::Score => {
111                let items = criteria.as_array().filter(|items| !items.is_empty());
112                let items = items.ok_or_else(|| {
113                    Error::Question("Score criteria must be a nonempty list".into())
114                })?;
115                Criteria::Score(items.clone())
116            }
117            QuestionKind::Noul => {
118                if criteria.is_null() {
119                    Criteria::Noul {
120                        false_desc: None,
121                        true_desc: None,
122                    }
123                } else {
124                    let map = criteria.as_object().ok_or_else(|| {
125                        Error::Question(
126                            "Noul criteria must be a dictionary with false/true descriptions"
127                                .into(),
128                        )
129                    })?;
130                    Criteria::Noul {
131                        false_desc: map.get("false").cloned(),
132                        true_desc: map.get("true").cloned(),
133                    }
134                }
135            }
136        };
137
138        Ok(Self {
139            kind,
140            instructions,
141            criteria,
142        })
143    }
144
145    fn choice_criteria(criteria: &Value) -> Result<Criteria> {
146        match criteria {
147            // A list of unique labels becomes `dict.fromkeys(...)`: every
148            // description is null.
149            Value::Array(items) => {
150                let mut labels = Vec::with_capacity(items.len());
151                for item in items {
152                    let label = item
153                        .as_str()
154                        .ok_or_else(|| Error::Question("Choice labels must be strings".into()))?;
155                    labels.push(label.to_string());
156                }
157                let mut seen = std::collections::HashSet::with_capacity(labels.len());
158                if !labels.iter().all(|label| seen.insert(label.as_str())) {
159                    return Err(Error::Question("Choice labels must be unique".into()));
160                }
161                if labels.is_empty() {
162                    return Err(Error::Question(
163                        "Choice criteria must be a nonempty dictionary or list".into(),
164                    ));
165                }
166                Ok(Criteria::Choice(
167                    labels
168                        .into_iter()
169                        .map(|label| (label, Value::Null))
170                        .collect(),
171                ))
172            }
173            Value::Object(map) if !map.is_empty() => Ok(Criteria::Choice(
174                map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
175            )),
176            _ => Err(Error::Question(
177                "Choice criteria must be a nonempty dictionary or list".into(),
178            )),
179        }
180    }
181
182    /// Option texts in label-index order. Noul is always `[false, true]`.
183    pub fn render_options(&self) -> Vec<String> {
184        match &self.criteria {
185            Criteria::Choice(pairs) => pairs
186                .iter()
187                .map(|(label, description)| {
188                    if is_blank(description) {
189                        label.clone()
190                    } else {
191                        format!("{label}: {}", render_criterion(description))
192                    }
193                })
194                .collect(),
195            Criteria::Score(levels) => levels
196                .iter()
197                .enumerate()
198                .map(|(index, level)| format!("level {index}: {}", render_criterion(level)))
199                .collect(),
200            Criteria::Noul {
201                false_desc,
202                true_desc,
203            } => {
204                let render = |value: &Option<Value>, fallback: &str| match value {
205                    Some(inner) if !is_blank(inner) => render_criterion(inner),
206                    _ => fallback.to_string(),
207                };
208                vec![
209                    format!(
210                        "false: {}",
211                        render(false_desc, "no, the statement does not hold")
212                    ),
213                    format!("true: {}", render(true_desc, "yes, the statement holds")),
214                ]
215            }
216        }
217    }
218
219    /// Labels for a choice question, in order.
220    pub fn choice_labels(&self) -> Option<Vec<String>> {
221        match &self.criteria {
222            Criteria::Choice(pairs) => Some(pairs.iter().map(|(k, _)| k.clone()).collect()),
223            _ => None,
224        }
225    }
226
227    /// Rubric legend for a score question, rendered as upstream stores it.
228    pub fn score_legend(&self) -> Option<Map<String, Value>> {
229        match &self.criteria {
230            Criteria::Score(levels) => {
231                let mut legend = Map::new();
232                for (index, level) in levels.iter().enumerate() {
233                    legend.insert(index.to_string(), level.clone());
234                }
235                Some(legend)
236            }
237            _ => None,
238        }
239    }
240}
241
242/// Render a value the way a Python error message would show it.
243fn render_repr(value: &Value) -> String {
244    match value {
245        Value::String(text) => format!("{text:?}"),
246        Value::Null => "None".into(),
247        Value::Bool(true) => "True".into(),
248        Value::Bool(false) => "False".into(),
249        other => other.to_string(),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use serde_json::json;
257
258    fn q(value: Value) -> Question {
259        Question::from_value(&value).expect("valid question")
260    }
261
262    #[test]
263    fn choice_list_renders_bare_labels() {
264        let question = q(json!({
265            "type": "choice", "instructions": "Pick", "criteria": ["a", "b"]
266        }));
267        assert_eq!(question.render_options(), vec!["a", "b"]);
268    }
269
270    #[test]
271    fn choice_map_renders_descriptions_and_keeps_order() {
272        let question = q(json!({
273            "type": "choice",
274            "instructions": "Pick",
275            "criteria": {"billing": "refunds", "tech": "", "zero": 0}
276        }));
277        assert_eq!(
278            question.render_options(),
279            vec!["billing: refunds", "tech", "zero: 0"]
280        );
281    }
282
283    #[test]
284    fn structured_criterion_becomes_json() {
285        let question = q(json!({
286            "type": "choice",
287            "instructions": "Pick",
288            "criteria": {"a": {"desc": "x", "n": 1}}
289        }));
290        assert_eq!(
291            question.render_options(),
292            vec![r#"a: {"desc": "x", "n": 1}"#]
293        );
294    }
295
296    #[test]
297    fn score_levels_are_numbered() {
298        let question = q(json!({
299            "type": "score", "instructions": "How bad", "criteria": ["low", "high"]
300        }));
301        assert_eq!(
302            question.render_options(),
303            vec!["level 0: low", "level 1: high"]
304        );
305    }
306
307    #[test]
308    fn noul_defaults_and_overrides() {
309        let bare = q(json!({"type": "noul", "instructions": "True?"}));
310        assert_eq!(
311            bare.render_options(),
312            vec![
313                "false: no, the statement does not hold",
314                "true: yes, the statement holds"
315            ]
316        );
317        let described = q(json!({
318            "type": "noul",
319            "instructions": "True?",
320            "criteria": {"true": "it holds"}
321        }));
322        assert_eq!(
323            described.render_options(),
324            vec!["false: no, the statement does not hold", "true: it holds"]
325        );
326    }
327
328    #[test]
329    fn rejects_bad_definitions() {
330        assert!(Question::from_value(&json!({"type": "nope", "instructions": "x"})).is_err());
331        assert!(Question::from_value(&json!({"type": "choice"})).is_err());
332        assert!(Question::from_value(&json!({
333            "type": "choice", "instructions": "x", "criteria": ["a", "a"]
334        }))
335        .is_err());
336        assert!(Question::from_value(&json!({
337            "type": "score", "instructions": "x", "criteria": []
338        }))
339        .is_err());
340    }
341
342    #[test]
343    fn non_string_instructions_use_ascii_json() {
344        let question = q(json!({
345            "type": "noul", "instructions": {"ask": "h\u{e9}llo"}
346        }));
347        assert_eq!(question.instructions, "{\"ask\": \"h\\u00e9llo\"}");
348    }
349}