1use crate::error::{LayaError, Result};
7use serde_json::Value;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum QType {
13 Choice,
14 Score,
15 Noul,
16}
17
18impl QType {
19 pub fn index(self) -> usize {
20 match self {
21 QType::Choice => 0,
22 QType::Score => 1,
23 QType::Noul => 2,
24 }
25 }
26
27 pub fn name(self) -> &'static str {
28 match self {
29 QType::Choice => "choice",
30 QType::Score => "score",
31 QType::Noul => "noul",
32 }
33 }
34
35 pub fn from_name(s: &str) -> Option<QType> {
36 match s {
37 "choice" => Some(QType::Choice),
38 "score" => Some(QType::Score),
39 "noul" => Some(QType::Noul),
40 _ => None,
41 }
42 }
43
44 pub fn from_index(i: usize) -> Option<QType> {
45 match i {
46 0 => Some(QType::Choice),
47 1 => Some(QType::Score),
48 2 => Some(QType::Noul),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
58pub struct InternalQ {
59 pub t: QType,
60 pub ins: String,
61 pub crit: Value,
62 pub labels: Option<Value>,
63 pub option_order: Option<Vec<usize>>,
67}
68
69pub trait Tokenizer {
72 fn cls_id(&self) -> u32;
73 fn sep_id(&self) -> u32;
74 fn mask_id(&self) -> u32;
75 fn pad_id(&self) -> u32;
76 fn mask_token(&self) -> &str;
77 fn encode(&self, text: &str) -> Vec<u32>;
78}
79
80const DEFAULT_NOUL_FALSE: &str = "false";
81const DEFAULT_NOUL_TRUE: &str = "true";
82
83pub fn py_json(v: &Value) -> String {
89 match v {
90 Value::Null => "null".to_string(),
91 Value::Bool(b) => {
92 if *b {
93 "true".to_string()
94 } else {
95 "false".to_string()
96 }
97 }
98 Value::Number(n) => n.to_string(),
99 Value::String(_) => serde_json::to_string(v).unwrap_or_else(|_| "\"\"".to_string()),
102 Value::Array(a) => {
103 let parts: Vec<String> = a.iter().map(py_json).collect();
104 format!("[{}]", parts.join(", "))
105 }
106 Value::Object(m) => {
107 let parts: Vec<String> = m
108 .iter()
109 .map(|(k, val)| {
110 let key = serde_json::to_string(&Value::String(k.clone()))
111 .unwrap_or_else(|_| "\"\"".to_string());
112 format!("{}: {}", key, py_json(val))
113 })
114 .collect();
115 format!("{{{}}}", parts.join(", "))
116 }
117 }
118}
119
120pub fn serialize_state(state: &Value) -> String {
122 match state {
123 Value::String(s) => s.clone(),
124 other => py_json(other),
125 }
126}
127
128pub fn render_criterion(v: &Value) -> String {
130 match v {
131 Value::String(s) => s.clone(),
132 other => py_json(other),
133 }
134}
135
136fn is_empty_criterion(v: Option<&Value>) -> bool {
138 match v {
139 None => true,
140 Some(Value::Null) => true,
141 Some(Value::String(s)) if s.is_empty() => true,
142 _ => false,
143 }
144}
145
146pub fn resolve_noul_labels(labels: Option<&Value>) -> Result<(String, String)> {
148 let err = || {
149 LayaError::InvalidQuestion(
150 "noul labels must map exactly 'false' and 'true' to distinct non-empty strings"
151 .to_string(),
152 )
153 };
154 let (f, t) = match labels {
155 None | Some(Value::Null) => {
156 return Ok((
157 DEFAULT_NOUL_FALSE.to_string(),
158 DEFAULT_NOUL_TRUE.to_string(),
159 ))
160 }
161 Some(Value::Object(m)) => {
162 let keys: std::collections::BTreeSet<&str> = m.keys().map(|s| s.as_str()).collect();
163 let want: std::collections::BTreeSet<&str> = ["false", "true"].into_iter().collect();
164 if keys != want {
165 return Err(err());
166 }
167 (m.get("false"), m.get("true"))
168 }
169 _ => return Err(err()),
170 };
171 let (f, t) = match (f, t) {
172 (Some(Value::String(f)), Some(Value::String(t))) => (f, t),
173 _ => return Err(err()),
174 };
175 let f = f.trim();
176 let t = t.trim();
177 if f.is_empty() || t.is_empty() || f == t {
178 return Err(err());
179 }
180 Ok((f.to_string(), t.to_string()))
181}
182
183pub fn render_options(q: &InternalQ) -> Result<Vec<String>> {
185 if q.t != QType::Noul && q.labels.is_some() {
186 return Err(LayaError::InvalidQuestion(
187 "labels is only supported for noul questions".to_string(),
188 ));
189 }
190 match q.t {
191 QType::Choice => {
192 let obj = q.crit.as_object().ok_or_else(|| {
193 LayaError::InvalidQuestion("choice criteria must be an object".to_string())
194 })?;
195 Ok(obj
196 .iter()
197 .map(|(k, v)| {
198 if is_empty_criterion(Some(v)) {
199 k.clone()
200 } else {
201 format!("{}: {}", k, render_criterion(v))
202 }
203 })
204 .collect())
205 }
206 QType::Score => {
207 let arr = q.crit.as_array().ok_or_else(|| {
208 LayaError::InvalidQuestion("score criteria must be a list".to_string())
209 })?;
210 Ok(arr
211 .iter()
212 .enumerate()
213 .map(|(i, c)| format!("level {}: {}", i, render_criterion(c)))
214 .collect())
215 }
216 QType::Noul => {
217 let (false_label, true_label) = resolve_noul_labels(q.labels.as_ref())?;
218 let empty = serde_json::Map::new();
219 let crit = q.crit.as_object().unwrap_or(&empty);
220 let false_crit = crit.get("false");
221 let true_crit = crit.get("true");
222 let false_text = if is_empty_criterion(false_crit) {
223 "no, the statement does not hold".to_string()
224 } else {
225 render_criterion(false_crit.unwrap())
226 };
227 let true_text = if is_empty_criterion(true_crit) {
228 "yes, the statement holds".to_string()
229 } else {
230 render_criterion(true_crit.unwrap())
231 };
232 Ok(vec![
233 format!("{}: {}", false_label, false_text),
234 format!("{}: {}", true_label, true_text),
235 ])
236 }
237 }
238}
239
240pub fn build_sequence<T: Tokenizer + ?Sized>(
245 tok: &T,
246 state: &Value,
247 q: &InternalQ,
248 max_len: usize,
249 head_max_len: usize,
250 option_order: Option<&[usize]>,
251 truncate_left: bool,
252) -> Result<(Vec<u32>, Vec<usize>)> {
253 let mask_tok = tok.mask_token();
254 let opts = render_options(q)?;
255 let order: Vec<usize> = match option_order {
256 Some(o) => o.to_vec(),
257 None => (0..opts.len()).collect(),
258 };
259
260 let ins = q.ins.replace(mask_tok, " ");
261 let head_text = format!("{} question: {}", q.t.name(), ins);
262 let mut head_ids = tok.encode(&head_text);
263
264 let mut opt_ids: Vec<Vec<u32>> = Vec::with_capacity(order.len());
265 for &i in &order {
266 let mut o = vec![tok.mask_id()];
267 let enc = tok.encode(&format!(" {}", opts[i].replace(mask_tok, " ")));
268 o.extend(enc.into_iter().take(48));
269 opt_ids.push(o);
270 }
271
272 let sum_opt = |v: &[Vec<u32>]| -> usize { v.iter().map(|o| o.len()).sum() };
273 let mut opt_budget: isize = head_max_len as isize - sum_opt(&opt_ids) as isize;
274 if opt_budget < 16 {
275 let denom = std::cmp::max(1, opt_ids.len()) as isize;
276 let per = std::cmp::max(4, (head_max_len as isize - 16) / denom) as usize;
277 for o in opt_ids.iter_mut() {
278 o.truncate(per);
279 }
280 opt_budget = head_max_len as isize - sum_opt(&opt_ids) as isize;
281 }
282 let head_keep = opt_budget.max(8) as usize;
283 head_ids.truncate(head_keep.min(head_ids.len()));
284
285 let mut ids: Vec<u32> = Vec::new();
286 ids.push(tok.cls_id());
287 ids.extend_from_slice(&head_ids);
288 ids.push(tok.sep_id());
289
290 let mut markers: Vec<usize> = Vec::with_capacity(opt_ids.len());
291 for o in &opt_ids {
292 markers.push(ids.len());
293 ids.extend_from_slice(o);
294 }
295 ids.push(tok.sep_id());
296
297 let room = (max_len as isize - ids.len() as isize - 1).max(0) as usize;
298 let st_all = tok.encode(&serialize_state(state).replace(mask_tok, " "));
299 let st: Vec<u32> = if truncate_left {
300 let start = st_all.len().saturating_sub(room);
301 st_all[start..].to_vec()
302 } else {
303 st_all.into_iter().take(room).collect()
304 };
305 ids.extend_from_slice(&st);
306 ids.push(tok.sep_id());
307 ids.truncate(max_len);
308
309 let markers = markers.into_iter().filter(|&m| m < max_len).collect();
310 Ok((ids, markers))
311}
312
313pub fn round_half_even(x: f64, ndigits: i32) -> f64 {
315 if !x.is_finite() {
316 return x;
317 }
318 let factor = 10f64.powi(ndigits);
319 let scaled = x * factor;
320 let floor = scaled.floor();
321 let diff = scaled - floor;
322 let rounded = if (diff - 0.5).abs() < 1e-9 {
323 if (floor as i64) % 2 == 0 {
325 floor
326 } else {
327 floor + 1.0
328 }
329 } else {
330 scaled.round()
331 };
332 rounded / factor
333}
334
335pub fn round4(x: f64) -> f64 {
337 round_half_even(x, 4)
338}
339
340pub fn softmax(z: &[f64]) -> Vec<f64> {
342 if z.is_empty() {
343 return Vec::new();
344 }
345 let m = z.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
346 let exps: Vec<f64> = z.iter().map(|&v| (v - m).exp()).collect();
347 let sum: f64 = exps.iter().sum();
348 exps.into_iter().map(|v| v / sum).collect()
349}
350
351pub fn answer_confidence(p: &[f64], k: usize) -> f64 {
358 if k < 1 {
359 return 1.0;
360 }
361 let p = &p[..k.min(p.len())];
362 let max = p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
363 max.clamp(0.0, 1.0)
364}
365
366pub fn unpermute_probs(p: &[f64], option_order: Option<&[usize]>) -> Vec<f64> {
374 match option_order {
375 Some(order) if order.len() == p.len() => {
376 let mut canonical = vec![0.0; p.len()];
377 for (slot, &opt) in order.iter().enumerate() {
378 canonical[opt] = p[slot];
379 }
380 canonical
381 }
382 _ => p.to_vec(),
383 }
384}
385
386pub fn confidence_from_probs(p: &[f64], k: usize) -> f64 {
389 if k < 2 {
390 return 1.0;
391 }
392 let p = &p[..k.min(p.len())];
393 let ent: f64 = -p.iter().map(|&v| v * v.clamp(1e-12, 1.0).ln()).sum::<f64>();
394 (1.0 - ent / (k as f64).ln()).clamp(0.0, 1.0)
395}
396
397pub fn jev_confidence(answer: &Value) -> Option<f64> {
416 let kind = answer.get("type").and_then(|v| v.as_str())?;
417 let probs = answer.get("probabilities").and_then(|v| v.as_object())?;
418 if probs.is_empty() || !matches!(kind, "choice" | "score") {
419 return None;
420 }
421 if probs.values().any(|v| v.is_boolean()) {
424 return None;
425 }
426 let p: Vec<f64> = if kind == "score" {
427 let mut keyed: Vec<(i64, f64)> = Vec::with_capacity(probs.len());
430 for (key, value) in probs {
431 let idx = key.parse::<i64>().ok()?;
432 keyed.push((idx, value.as_f64()?));
433 }
434 keyed.sort_by_key(|&(idx, _)| idx);
435 keyed.into_iter().map(|(_, v)| v).collect()
436 } else {
437 probs.values().map(|v| v.as_f64()).collect::<Option<_>>()?
438 };
439 if !p.iter().all(|v| v.is_finite() && *v >= 0.0) {
440 return None;
441 }
442 let n = p.len();
443 if n == 1 {
444 return Some(1.0);
445 }
446 let total: f64 = p.iter().sum();
447 let p: Vec<f64> = if total != 0.0 {
448 p.iter().map(|&v| v / total).collect()
449 } else {
450 vec![1.0 / n as f64; n]
451 };
452 if kind == "choice" {
453 let max = p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
454 return Some((max - 1.0 / n as f64) / (1.0 - 1.0 / n as f64));
455 }
456 let mode = (0..n)
458 .max_by(|&a, &b| p[a].partial_cmp(&p[b]).unwrap())
459 .unwrap();
460 let spread: f64 = p
461 .iter()
462 .enumerate()
463 .map(|(i, &v)| v * (i as f64 - mode as f64).abs())
464 .sum();
465 let center = (n as f64 - 1.0) / 2.0;
466 let uniform_spread: f64 = (0..n).map(|i| (i as f64 - center).abs()).sum::<f64>() / n as f64;
467 Some((1.0 - spread / uniform_spread).max(0.0))
468}
469
470pub fn drop_stale_option_order(
479 qid: &str,
480 question: &mut Value,
481 n_options: usize,
482 warned: &mut std::collections::HashSet<String>,
483) -> bool {
484 let order = match question
485 .as_object_mut()
486 .and_then(|m| m.remove("option_order"))
487 {
488 Some(o) => o,
489 None => return false,
490 };
491 if warned.insert(qid.to_string()) {
492 let had = match &order {
493 Value::Array(a) => format!("{} slots", a.len()),
494 other => py_json(other),
495 };
496 eprintln!(
497 "laya.shortlist: question {:?}: 'option_order' ({}) was dropped because the label set \
498 was narrowed to {}; the order no longer fits the kept options and remapping it onto \
499 them would move options between slots.",
500 qid, had, n_options
501 );
502 }
503 true
504}
505
506pub const TEMP_MIN: f64 = 0.5;
507pub const TEMP_MAX: f64 = 5.0;
508
509pub fn clamp_temperature(t: &Value) -> f64 {
511 let f = match t {
512 Value::Number(n) => n.as_f64(),
513 Value::String(s) => s.trim().parse::<f64>().ok(),
514 _ => None,
515 };
516 match f {
517 Some(x) if x.is_finite() => x.clamp(TEMP_MIN, TEMP_MAX),
518 _ => 1.0,
519 }
520}
521
522pub fn temp_bucket(qtype: usize, k: usize) -> String {
524 let size = if k <= 2 {
525 "2"
526 } else if k <= 5 {
527 "3-5"
528 } else if k <= 10 {
529 "6-10"
530 } else {
531 "11+"
532 };
533 let name = QType::from_index(qtype)
534 .map(|q| q.name())
535 .unwrap_or("choice");
536 format!("{}:{}", name, size)
537}
538
539#[derive(Debug, Clone)]
541pub struct Item {
542 pub ids: Vec<u32>,
543 pub markers: Vec<usize>,
544 pub qtype: usize,
545}
546
547#[derive(Debug, Clone)]
549pub struct CollatedBatch {
550 pub input_ids: Vec<Vec<u32>>,
551 pub attention_mask: Vec<Vec<u32>>,
552 pub marker_pos: Vec<Vec<usize>>,
553 pub marker_mask: Vec<Vec<bool>>,
554 pub qtype: Vec<usize>,
555}
556
557pub fn collate_items(groups: &[Vec<Item>], _pad_id: u32) -> Option<CollatedBatch> {
560 let items: Vec<&Item> = groups.iter().flatten().collect();
561 if items.is_empty() {
562 return None;
563 }
564 let l = items.iter().map(|it| it.ids.len()).max().unwrap();
565 let kmax = items.iter().map(|it| it.markers.len()).max().unwrap();
566
567 let mut input_ids = Vec::with_capacity(items.len());
568 let mut attention_mask = Vec::with_capacity(items.len());
569 let mut marker_pos = Vec::with_capacity(items.len());
570 let mut marker_mask = Vec::with_capacity(items.len());
571 let mut qtype = Vec::with_capacity(items.len());
572
573 for it in &items {
574 let mut ids = it.ids.clone();
575 let mut att = vec![1u32; ids.len()];
576 ids.resize(l, _pad_id);
577 att.resize(l, 0);
578 input_ids.push(ids);
579 attention_mask.push(att);
580
581 let k = it.markers.len();
582 let mut mpos: Vec<usize> = it.markers.clone();
583 mpos.resize(kmax, 0);
584 let mut mmask: Vec<bool> = vec![true; k];
585 mmask.resize(kmax, false);
586 marker_pos.push(mpos);
587 marker_mask.push(mmask);
588
589 qtype.push(it.qtype);
590 }
591
592 Some(CollatedBatch {
593 input_ids,
594 attention_mask,
595 marker_pos,
596 marker_mask,
597 qtype,
598 })
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use serde_json::json;
605
606 struct Fake;
608 impl Tokenizer for Fake {
609 fn cls_id(&self) -> u32 {
610 1
611 }
612 fn sep_id(&self) -> u32 {
613 2
614 }
615 fn mask_id(&self) -> u32 {
616 3
617 }
618 fn pad_id(&self) -> u32 {
619 0
620 }
621 fn mask_token(&self) -> &str {
622 "[MASK]"
623 }
624 fn encode(&self, text: &str) -> Vec<u32> {
625 text.split_whitespace()
626 .map(|w| 100 + (w.len() as u32))
627 .collect()
628 }
629 }
630
631 #[test]
632 fn py_json_matches_python_spacing() {
633 let v = json!({"a": 1, "b": "x"});
635 assert_eq!(py_json(&v), r#"{"a": 1, "b": "x"}"#);
636 assert_eq!(py_json(&json!([1, 2, 3])), "[1, 2, 3]");
637 assert_eq!(py_json(&json!("café")), "\"café\""); assert_eq!(py_json(&json!(true)), "true");
639 assert_eq!(py_json(&json!(null)), "null");
640 }
641
642 #[test]
643 fn serialize_state_passes_strings_through() {
644 assert_eq!(serialize_state(&json!("hello")), "hello");
645 assert_eq!(serialize_state(&json!({"k": "v"})), r#"{"k": "v"}"#);
646 }
647
648 #[test]
649 fn answer_confidence_is_max_prob() {
650 assert_eq!(answer_confidence(&[0.2, 0.7, 0.1], 3), 0.7);
652 assert_eq!(answer_confidence(&[0.6, 0.4], 2), 0.6);
653 assert_eq!(answer_confidence(&[0.9], 1), 0.9);
654 assert_eq!(answer_confidence(&[], 0), 1.0);
655 assert_eq!(answer_confidence(&[0.3, 0.3, 0.99], 2), 0.3);
657 }
658
659 #[test]
660 fn round4_half_to_even() {
661 assert_eq!(round4(0.12345), 0.1234); assert_eq!(round4(0.12355), 0.1236);
663 assert_eq!(round4(1.0), 1.0);
664 }
665
666 #[test]
667 fn render_options_per_type() {
668 let choice = InternalQ {
669 t: QType::Choice,
670 ins: String::new(),
671 crit: json!({"a": "first", "b": null, "c": ""}),
672 labels: None,
673 option_order: None,
674 };
675 assert_eq!(render_options(&choice).unwrap(), vec!["a: first", "b", "c"]);
676
677 let score = InternalQ {
678 t: QType::Score,
679 ins: String::new(),
680 crit: json!(["low", "high"]),
681 labels: None,
682 option_order: None,
683 };
684 assert_eq!(
685 render_options(&score).unwrap(),
686 vec!["level 0: low", "level 1: high"]
687 );
688
689 let noul = InternalQ {
690 t: QType::Noul,
691 ins: String::new(),
692 crit: json!({"true": "yes it does", "false": "no"}),
693 labels: None,
694 option_order: None,
695 };
696 assert_eq!(
697 render_options(&noul).unwrap(),
698 vec!["false: no", "true: yes it does"]
699 );
700
701 let noul_default = InternalQ {
702 t: QType::Noul,
703 ins: String::new(),
704 crit: Value::Null,
705 labels: None,
706 option_order: None,
707 };
708 assert_eq!(
709 render_options(&noul_default).unwrap(),
710 vec![
711 "false: no, the statement does not hold",
712 "true: yes, the statement holds"
713 ]
714 );
715 }
716
717 #[test]
718 fn temp_bucket_sizes() {
719 assert_eq!(temp_bucket(0, 2), "choice:2");
720 assert_eq!(temp_bucket(1, 4), "score:3-5");
721 assert_eq!(temp_bucket(2, 8), "noul:6-10");
722 assert_eq!(temp_bucket(0, 20), "choice:11+");
723 }
724
725 #[test]
726 fn build_sequence_structure() {
727 let q = InternalQ {
728 t: QType::Choice,
729 ins: "pick one".to_string(),
730 crit: json!({"a": null, "b": null}),
731 labels: None,
732 option_order: None,
733 };
734 let (ids, markers) =
735 build_sequence(&Fake, &json!("some state text"), &q, 512, 192, None, false).unwrap();
736 assert_eq!(ids[0], 1); assert_eq!(*ids.last().unwrap(), 2); assert_eq!(markers.len(), 2); for &m in &markers {
741 assert_eq!(ids[m], 3);
742 }
743 }
744
745 #[test]
746 fn confidence_bounds() {
747 assert_eq!(confidence_from_probs(&[1.0], 1), 1.0); let uniform = confidence_from_probs(&[0.5, 0.5], 2);
749 assert!(uniform.abs() < 1e-9); let certain = confidence_from_probs(&[1.0, 0.0], 2);
751 assert!((certain - 1.0).abs() < 1e-9);
752 }
753
754 #[test]
755 fn unpermute_inverts_slot_order() {
756 let p = [0.7, 0.2, 0.1];
759 let order = [2usize, 0, 1];
760 assert_eq!(unpermute_probs(&p, Some(&order)), vec![0.2, 0.1, 0.7]);
761 assert_eq!(unpermute_probs(&p, None), vec![0.7, 0.2, 0.1]);
763 assert_eq!(unpermute_probs(&p, Some(&[0usize, 1])), vec![0.7, 0.2, 0.1]);
764 }
765
766 #[test]
767 fn build_sequence_honours_option_order() {
768 let q = InternalQ {
772 t: QType::Choice,
773 ins: "pick one".to_string(),
774 crit: json!({"aa": null, "b": null, "ccc": null}),
775 labels: None,
776 option_order: Some(vec![2, 1, 0]),
777 };
778 let (ids, markers) = build_sequence(
779 &Fake,
780 &json!("state"),
781 &q,
782 512,
783 192,
784 Some(&[2, 1, 0]),
785 false,
786 )
787 .unwrap();
788 assert_eq!(markers.len(), 3);
789 assert_eq!(ids[markers[0] + 1], 103);
792 assert_eq!(ids[markers[2] + 1], 102);
793 }
794
795 fn choice_answer(p: &[f64]) -> Value {
796 let probs: serde_json::Map<String, Value> = p
797 .iter()
798 .enumerate()
799 .map(|(i, &v)| (format!("o{i}"), json!(v)))
800 .collect();
801 json!({ "type": "choice", "probabilities": probs })
802 }
803
804 fn score_answer(p: &[f64]) -> Value {
805 let probs: serde_json::Map<String, Value> = p
806 .iter()
807 .enumerate()
808 .map(|(i, &v)| (i.to_string(), json!(v)))
809 .collect();
810 json!({ "type": "score", "probabilities": probs })
811 }
812
813 #[test]
814 fn jev_confidence_matches_typesafe_vectors() {
815 let cases: &[(Value, f64)] = &[
817 (choice_answer(&[0.5, 0.5]), 0.0),
818 (choice_answer(&[0.2, 0.2]), 0.0), (choice_answer(&[0.0, 0.0]), 0.0), (choice_answer(&[0.82, 0.18]), 0.64),
821 (choice_answer(&[1.0]), 1.0),
822 (choice_answer(&[0.88, 0.12]), 0.76),
823 (score_answer(&[0.2, 0.2, 0.2, 0.2, 0.2]), 0.0),
824 (score_answer(&[0.04, 0.04, 0.04, 0.04, 0.04]), 0.0),
825 (score_answer(&[0.01, 0.02, 0.07, 0.3, 0.6]), 0.55),
826 (score_answer(&[1.0]), 1.0),
827 (score_answer(&[0.0, 0.95, 0.05]), 0.925),
828 ];
829 for (answer, want) in cases {
830 let got = jev_confidence(answer).expect("a usable confidence");
831 assert!(
832 (got - want).abs() < 1e-9,
833 "answer {answer:?}: got {got}, want {want}"
834 );
835 }
836 }
837
838 #[test]
839 fn jev_confidence_reads_score_levels_in_numeric_order() {
840 let mut probs = serde_json::Map::new();
842 for i in 0..=10 {
843 probs.insert(i.to_string(), json!(0.0));
844 }
845 probs.insert("10".to_string(), json!(1.0));
846 let answer = json!({ "type": "score", "probabilities": probs });
847 assert_eq!(jev_confidence(&answer), Some(1.0));
848 }
849
850 #[test]
851 fn jev_confidence_differs_from_entropy() {
852 let p = [0.7, 0.1, 0.1, 0.1];
854 let jev = jev_confidence(&choice_answer(&p)).unwrap();
855 assert!((jev - 0.6).abs() < 1e-9, "jev {jev}");
856 assert!(confidence_from_probs(&p, 4) < 0.35);
857 }
858
859 #[test]
860 fn jev_confidence_none_for_unusable_answers() {
861 let none_cases = [
862 json!({ "type": "noul", "noul": 0.9 }),
863 json!({ "type": "weird", "probabilities": { "a": 1.0 } }),
864 json!({ "type": "choice" }),
865 json!({ "type": "choice", "probabilities": {} }),
866 json!({ "type": "choice", "probabilities": { "o0": 0.5, "o1": "high" } }),
867 json!({ "type": "choice", "probabilities": { "o0": true, "o1": false } }),
868 json!({ "type": "score", "probabilities": { "low": 0.5, "high": 0.5 } }),
869 json!({ "type": "choice", "probabilities": { "o0": 1.5, "o1": -0.5 } }),
872 json!({ "type": "score", "probabilities": { "0": 0.5, "1": -0.1, "2": 0.6 } }),
873 ];
874 for answer in none_cases {
875 assert_eq!(jev_confidence(&answer), None, "answer {answer:?}");
876 }
877 }
878
879 #[test]
880 fn drop_stale_option_order_drops_and_dedupes() {
881 use std::collections::HashSet;
882 let mut warned = HashSet::new();
883 let mut q =
884 json!({ "type": "choice", "criteria": ["a", "b"], "option_order": [3, 2, 1, 0] });
885 assert!(drop_stale_option_order("intent", &mut q, 2, &mut warned));
886 assert!(q.get("option_order").is_none(), "stale order removed");
887 assert_eq!(
888 q.get("criteria"),
889 Some(&json!(["a", "b"])),
890 "criteria untouched"
891 );
892 assert!(warned.contains("intent"));
893 let mut plain = json!({ "type": "choice", "criteria": ["a", "b"] });
895 assert!(!drop_stale_option_order(
896 "intent",
897 &mut plain,
898 2,
899 &mut warned
900 ));
901 assert_eq!(warned.len(), 1, "no new warning for an order-free question");
902 }
903}