1#![doc = include_str!("../README.md")]
29#![forbid(unsafe_code)]
30
31use async_trait::async_trait;
32use bytes::Bytes;
33use cratefield_core::{
34 Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile, Clock,
35 DEFAULT_MAX_STATE_CHARS, HttpClient, HttpError, HttpPolicy, MAX_RESPONSE_BYTES, Question,
36 retry_after, validate_questions,
37};
38use http::header::{AUTHORIZATION, CONTENT_TYPE};
39use http::{Request, StatusCode};
40use std::collections::BTreeMap;
41use std::fmt;
42use std::sync::Arc;
43use std::time::Duration;
44
45pub const DEFAULT_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
49
50pub const DEFAULT_MODEL: &str = "jev-1.13.0";
56
57pub const MAX_CHOICE_OPTIONS: usize = 255;
60
61pub const MAX_SCORE_LEVELS: usize = 10;
64
65const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
69
70const OVERLOADED: u16 = 529;
73
74pub struct TypeSafe {
77 http: Arc<dyn HttpClient>,
78 clock: Arc<dyn Clock>,
82 api_key: Option<String>,
84 model: String,
85 endpoint: String,
86}
87
88impl fmt::Debug for TypeSafe {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.debug_struct("TypeSafe")
97 .field("endpoint", &self.endpoint)
98 .field("model", &self.model)
99 .field("api_key_configured", &self.configured_key().is_some())
100 .finish_non_exhaustive()
103 }
104}
105
106impl TypeSafe {
107 pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>, api_key: Option<String>) -> Self {
112 Self {
113 http,
114 clock,
115 api_key,
116 model: DEFAULT_MODEL.to_owned(),
117 endpoint: DEFAULT_ENDPOINT.to_owned(),
118 }
119 }
120
121 #[must_use]
126 pub fn with_model(mut self, model: impl Into<String>) -> Self {
127 self.model = model.into();
128 self
129 }
130
131 #[must_use]
134 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
135 self.endpoint = endpoint.into();
136 self
137 }
138
139 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Option<Self> {
147 let api_key = std::env::var("TYPESAFE_API_KEY").ok()?;
148 let model = model_from_env(std::env::var("TYPESAFE_MODEL").ok());
149 Some(Self::new(http, clock, Some(api_key)).with_model(model))
150 }
151
152 fn configured_key(&self) -> Option<&str> {
155 self.api_key
156 .as_deref()
157 .map(str::trim)
158 .filter(|key| !key.is_empty())
159 }
160}
161
162fn model_from_env(value: Option<String>) -> String {
167 match value {
168 Some(model) if !model.trim().is_empty() => model.trim().to_owned(),
169 _ => DEFAULT_MODEL.to_owned(),
170 }
171}
172
173#[derive(serde::Serialize)]
178struct SystemOneRequest<'a> {
179 state: &'a str,
180 model: &'a str,
181 questions: BTreeMap<&'a str, WireQuestion<'a>>,
182}
183
184#[derive(serde::Serialize)]
189struct WireQuestion<'a> {
190 #[serde(rename = "type")]
191 kind: &'static str,
192 instructions: &'a str,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 criteria: Option<WireCriteria<'a>>,
195}
196
197#[derive(serde::Serialize)]
203#[serde(untagged)]
204enum WireCriteria<'a> {
205 Options(BTreeMap<&'a str, &'a str>),
206 Levels(Vec<&'a str>),
207}
208
209fn build_request<'a>(
213 state: &'a str,
214 model: &'a str,
215 questions: &'a BTreeMap<String, Question>,
216) -> SystemOneRequest<'a> {
217 SystemOneRequest {
218 state,
219 model,
220 questions: questions
221 .iter()
222 .map(|(id, question)| (id.as_str(), wire_question(question)))
223 .collect(),
224 }
225}
226
227fn wire_question(question: &Question) -> WireQuestion<'_> {
228 match question {
229 Question::Choice {
230 instructions,
231 criteria,
232 } => WireQuestion {
233 kind: question.kind(),
234 instructions,
235 criteria: Some(WireCriteria::Options(
236 criteria
237 .iter()
238 .map(|(name, meaning)| (name.as_str(), meaning.as_str()))
239 .collect(),
240 )),
241 },
242 Question::Score {
243 instructions,
244 levels,
245 } => WireQuestion {
246 kind: question.kind(),
247 instructions,
248 criteria: Some(WireCriteria::Levels(
249 levels.iter().map(|(_, meaning)| meaning.as_str()).collect(),
250 )),
251 },
252 Question::Noul { instructions } => WireQuestion {
253 kind: question.kind(),
254 instructions,
255 criteria: None,
256 },
257 }
258}
259
260fn check_vendor_limits(questions: &BTreeMap<String, Question>) -> Result<(), ClassifierError> {
268 for (id, question) in questions {
269 match question {
270 Question::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => {
271 return Err(ClassifierError::Rejected(format!(
272 "choice question {id:?} carries {} options, over the {MAX_CHOICE_OPTIONS} the vendor accepts",
273 criteria.len()
274 )));
275 }
276 Question::Score { levels, .. } if levels.len() > MAX_SCORE_LEVELS => {
277 return Err(ClassifierError::Rejected(format!(
278 "score question {id:?} carries {} levels, over the {MAX_SCORE_LEVELS} the vendor accepts",
279 levels.len()
280 )));
281 }
282 _ => {}
283 }
284 }
285 Ok(())
286}
287
288#[derive(serde::Deserialize)]
291struct SystemOneResponse {
292 #[serde(default)]
293 model: String,
294 answers: BTreeMap<String, WireAnswer>,
295 #[serde(default)]
296 usage: Option<WireUsage>,
297}
298
299#[derive(serde::Deserialize, Default)]
300struct WireUsage {
301 #[serde(default)]
302 input_tokens: u32,
303 #[serde(default)]
304 output_tokens: u32,
305}
306
307#[derive(serde::Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case")]
317enum WireAnswer {
318 Noul { noul: f32 },
321 Choice {
322 choice: String,
323 probabilities: BTreeMap<String, f32>,
324 confidence: f32,
325 },
326 Score {
327 score: f32,
328 probabilities: BTreeMap<String, f32>,
329 confidence: f32,
330 },
331}
332
333fn answer_kind(answer: &WireAnswer) -> &'static str {
335 match answer {
336 WireAnswer::Noul { .. } => "noul",
337 WireAnswer::Choice { .. } => "choice",
338 WireAnswer::Score { .. } => "score",
339 }
340}
341
342fn reject(message: String) -> ClassifierError {
347 tracing::warn!(
348 provider = "typesafe",
349 "a typesafe answer does not match the question it answers"
350 );
351 ClassifierError::Rejected(message)
352}
353
354fn map_answers(
363 answers: BTreeMap<String, WireAnswer>,
364 questions: &BTreeMap<String, Question>,
365 model: &str,
366) -> Result<BTreeMap<String, Answer>, ClassifierError> {
367 let mut mapped = BTreeMap::new();
368 for (id, answer) in answers {
369 let Some(question) = questions.get(&id) else {
370 return Err(reject(format!(
371 "typesafe answered question id {id:?}, which was not asked"
372 )));
373 };
374 let built = match (question, &answer) {
375 (Question::Noul { .. }, WireAnswer::Noul { noul }) => noul_answer(&id, *noul)?,
376 (
377 Question::Choice { criteria, .. },
378 WireAnswer::Choice {
379 choice,
380 probabilities,
381 confidence,
382 },
383 ) => choice_answer(&id, choice, probabilities, *confidence, criteria)?,
384 (
385 Question::Score { levels, .. },
386 WireAnswer::Score {
387 score,
388 probabilities,
389 confidence,
390 },
391 ) => score_answer(&id, *score, probabilities, *confidence, levels)?,
392 _ => {
393 return Err(reject(format!(
394 "typesafe answered {} question {id:?} with a {} answer",
395 question.kind(),
396 answer_kind(&answer),
397 )));
398 }
399 };
400 mapped.insert(id, built.with_model(model));
401 }
402 if mapped.len() != questions.len() {
403 let missing: Vec<_> = questions
404 .keys()
405 .filter(|id| !mapped.contains_key(*id))
406 .cloned()
407 .collect();
408 return Err(reject(format!(
409 "typesafe did not answer the question(s) {missing:?}"
410 )));
411 }
412 Ok(mapped)
413}
414
415fn noul_answer(id: &str, noul: f32) -> Result<Answer, ClassifierError> {
422 if !noul.is_finite() || !(0.0..=1.0).contains(&noul) {
423 return Err(reject(format!(
424 "typesafe answered noul question {id:?} with {noul}, outside [0, 1]"
425 )));
426 }
427 let verdict = noul >= 0.5;
428 let probabilities =
429 BTreeMap::from([("true".to_owned(), noul), ("false".to_owned(), 1.0 - noul)]);
430 Ok(Answer::noul(verdict, probabilities))
431}
432
433fn choice_answer(
437 id: &str,
438 choice: &str,
439 probabilities: &BTreeMap<String, f32>,
440 confidence: f32,
441 criteria: &BTreeMap<String, String>,
442) -> Result<Answer, ClassifierError> {
443 let checked = checked_probabilities(id, probabilities, criteria.keys().map(String::as_str))?;
444 if !criteria.contains_key(choice) {
445 return Err(reject(format!(
446 "typesafe answered choice question {id:?} with {choice:?}, which is not one of its criteria"
447 )));
448 }
449 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
450 return Err(reject(format!(
451 "typesafe answered choice question {id:?} with confidence {confidence}, outside [0, 1]"
452 )));
453 }
454 Ok(Answer::new(
460 AnswerValue::Choice(choice.to_owned()),
461 checked,
462 confidence,
463 ))
464}
465
466fn score_answer(
471 id: &str,
472 score: f32,
473 probabilities: &BTreeMap<String, f32>,
474 confidence: f32,
475 levels: &[(String, String)],
476) -> Result<Answer, ClassifierError> {
477 #[allow(clippy::cast_precision_loss)] let top = (levels.len() - 1) as f32;
485 if !score.is_finite()
486 || !(-SCORE_RANGE_TOLERANCE..=top + SCORE_RANGE_TOLERANCE).contains(&score)
487 {
488 return Err(reject(format!(
489 "typesafe scored question {id:?} at {score}, off its 0..={top} scale"
490 )));
491 }
492 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
493 return Err(reject(format!(
494 "typesafe answered score question {id:?} with confidence {confidence}, outside [0, 1]"
495 )));
496 }
497 let mut checked = BTreeMap::new();
501 for (index, probability) in probabilities {
502 let Ok(level) = index.parse::<usize>() else {
503 return Err(reject(format!(
504 "typesafe answered score question {id:?} under {index:?}, which is not a level index"
505 )));
506 };
507 let Some((name, _)) = levels.get(level) else {
508 return Err(reject(format!(
509 "typesafe answered score question {id:?} at level {index}, which it does not have"
510 )));
511 };
512 if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
513 return Err(reject(format!(
514 "typesafe answered question {id:?} with probability {probability} for level {index}, outside [0, 1]"
515 )));
516 }
517 checked.insert(name.clone(), *probability);
518 }
519 let value = score_on_level_scale(score, levels);
520 Ok(Answer::new(AnswerValue::Score(value), checked, confidence))
525}
526
527fn checked_probabilities<'a>(
532 id: &str,
533 probabilities: &BTreeMap<String, f32>,
534 criteria: impl Iterator<Item = &'a str>,
535) -> Result<BTreeMap<String, f32>, ClassifierError> {
536 let offered: Vec<&str> = criteria.collect();
537 let mut checked = BTreeMap::new();
538 for (label, probability) in probabilities {
539 if !offered.contains(&label.as_str()) {
540 return Err(reject(format!(
541 "typesafe answered question {id:?} with label {label:?}, which it does not offer"
542 )));
543 }
544 if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
545 return Err(reject(format!(
546 "typesafe answered question {id:?} with probability {probability} for {label:?}, outside [0, 1]"
547 )));
548 }
549 checked.insert(label.clone(), *probability);
550 }
551 Ok(checked)
552}
553
554const SCORE_RANGE_TOLERANCE: f32 = 1.0e-3;
558
559fn score_on_level_scale(score: f32, levels: &[(String, String)]) -> f32 {
567 let parsed: Option<Vec<f32>> = levels
568 .iter()
569 .map(|(name, _)| name.parse::<f32>().ok().filter(|value| value.is_finite()))
570 .collect();
571 let Some(names) = parsed else {
572 return score;
573 };
574 if names.len() < 2 {
575 return names.first().copied().unwrap_or(score);
576 }
577 #[allow(clippy::cast_precision_loss)] let top = (names.len() - 1) as f32;
579 let score = score.clamp(0.0, top);
580 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
582 let base = (score.floor() as usize).min(names.len() - 2);
583 #[allow(clippy::cast_precision_loss)] let fraction = score - base as f32;
585 names[base] + fraction * (names[base + 1] - names[base])
586}
587
588#[async_trait]
589impl Classifier for TypeSafe {
590 fn profile(&self) -> ClassifierProfile {
591 ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS)
595 }
596
597 async fn ask(
598 &self,
599 state: &str,
600 questions: &BTreeMap<String, Question>,
601 ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
602 let Some(api_key) = self.configured_key() else {
603 tracing::info!(
604 provider = "typesafe",
605 outcome = "not_configured",
606 "classifier outcome"
607 );
608 return Err(ClassifierError::NotConfigured);
609 };
610
611 validate_questions(questions)?;
614 check_vendor_limits(questions)?;
615
616 let profile = self.profile();
617 let (state, dropped) = profile.truncate(state);
618 if dropped {
619 tracing::warn!(
623 limit = profile.max_state_chars,
624 "typesafe classifier truncated the state; the answers see only the first `limit` chars"
625 );
626 }
627
628 let payload = build_request(state, &self.model, questions);
629 let body = serde_json::to_vec(&payload)
630 .map_err(|err| ClassifierError::Transport(err.to_string()))?;
631 let mut request = Request::builder()
632 .method(http::Method::POST)
633 .uri(&self.endpoint)
634 .header(CONTENT_TYPE, "application/json")
635 .header(AUTHORIZATION, format!("Bearer {api_key}"))
636 .body(Bytes::from(body))
637 .map_err(|err| ClassifierError::Transport(err.to_string()))?;
638 request.extensions_mut().insert(HttpPolicy {
641 timeout: REQUEST_TIMEOUT,
642 max_response_bytes: MAX_RESPONSE_BYTES,
643 });
644
645 let response = self.http.send(request).await.map_err(|err: HttpError| {
646 tracing::warn!(
647 provider = "typesafe",
648 outcome = "transport",
649 "typesafe transport failure"
650 );
651 ClassifierError::Transport(err.to_string())
652 })?;
653
654 let status = response.status();
655 let retry_after = retry_after(response.headers(), self.clock.as_ref());
658 let text = String::from_utf8_lossy(response.body()).to_string();
659
660 if status == StatusCode::TOO_MANY_REQUESTS
664 || status.as_u16() == OVERLOADED
665 || status.is_server_error()
666 {
667 tracing::warn!(
668 provider = "typesafe",
669 code = status.as_u16(),
670 outcome = "transient",
671 "classifier outcome"
672 );
673 return Err(ClassifierError::Transient { retry_after });
674 }
675 if !status.is_success() {
676 tracing::warn!(
680 provider = "typesafe",
681 code = status.as_u16(),
682 outcome = "rejected",
683 "classifier outcome"
684 );
685 return Err(ClassifierError::Rejected(text));
686 }
687
688 let parsed: SystemOneResponse = match serde_json::from_str(&text) {
689 Ok(parsed) => parsed,
690 Err(err) => {
691 tracing::warn!(
692 provider = "typesafe",
693 outcome = "transport",
694 "a typesafe success body did not survive the hop as an answer set"
695 );
696 return Err(ClassifierError::Transport(err.to_string()));
697 }
698 };
699 let resolved_model = if parsed.model.is_empty() {
703 self.model.clone()
704 } else {
705 parsed.model
706 };
707 let answers = map_answers(parsed.answers, questions, &resolved_model)?;
708 let usage = parsed.usage.unwrap_or_default();
709 tracing::debug!(
710 provider = "typesafe",
711 outcome = "answered",
712 model = %resolved_model,
713 questions = answers.len(),
714 input_tokens = usage.input_tokens,
715 output_tokens = usage.output_tokens,
716 "classifier outcome"
717 );
718 Ok(answers)
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 fn good_set() -> BTreeMap<String, Question> {
727 BTreeMap::from([
728 (
729 "topic".to_owned(),
730 Question::Choice {
731 instructions: "Which topic?".to_owned(),
732 criteria: BTreeMap::from([
733 ("billing".to_owned(), "money, invoices".to_owned()),
734 ("bugs".to_owned(), "something is broken".to_owned()),
735 ]),
736 },
737 ),
738 (
739 "angry".to_owned(),
740 Question::Noul {
741 instructions: "Is the writer angry?".to_owned(),
742 },
743 ),
744 ])
745 }
746
747 fn vendor_answer(id: &str, answer: WireAnswer) -> (String, WireAnswer) {
748 (id.to_owned(), answer)
749 }
750
751 fn map(
752 answers: Vec<(String, WireAnswer)>,
753 questions: &BTreeMap<String, Question>,
754 ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
755 map_answers(answers.into_iter().collect(), questions, DEFAULT_MODEL)
756 }
757
758 #[test]
759 fn an_answer_for_an_id_that_was_not_asked_is_refused() {
760 let answers = vec![vendor_answer("never-asked", WireAnswer::Noul { noul: 1.0 })];
761 assert!(matches!(
762 map(answers, &good_set()),
763 Err(ClassifierError::Rejected(_))
764 ));
765 }
766
767 #[test]
768 fn a_label_outside_its_question_is_refused() {
769 let answers = vec![vendor_answer(
771 "topic",
772 WireAnswer::Choice {
773 choice: "shipping".to_owned(),
774 probabilities: BTreeMap::new(),
775 confidence: 1.0,
776 },
777 )];
778 assert!(matches!(
779 map(answers, &good_set()),
780 Err(ClassifierError::Rejected(_))
781 ));
782 let answers = vec![vendor_answer(
784 "topic",
785 WireAnswer::Choice {
786 choice: "bugs".to_owned(),
787 probabilities: BTreeMap::from([("shipping".to_owned(), 1.0_f32)]),
788 confidence: 1.0,
789 },
790 )];
791 assert!(matches!(
792 map(answers, &good_set()),
793 Err(ClassifierError::Rejected(_))
794 ));
795 }
796
797 #[test]
798 fn a_number_outside_the_unit_interval_is_refused() {
799 let answers = vec![vendor_answer(
800 "topic",
801 WireAnswer::Choice {
802 choice: "bugs".to_owned(),
803 probabilities: BTreeMap::from([("bugs".to_owned(), 1.5_f32)]),
804 confidence: 0.5,
805 },
806 )];
807 assert!(matches!(
808 map(answers, &good_set()),
809 Err(ClassifierError::Rejected(_))
810 ));
811 let answers = vec![vendor_answer(
812 "topic",
813 WireAnswer::Choice {
814 choice: "bugs".to_owned(),
815 probabilities: BTreeMap::from([("bugs".to_owned(), -0.1_f32)]),
816 confidence: 0.5,
817 },
818 )];
819 assert!(matches!(
820 map(answers, &good_set()),
821 Err(ClassifierError::Rejected(_))
822 ));
823 let answers = vec![vendor_answer(
826 "topic",
827 WireAnswer::Choice {
828 choice: "bugs".to_owned(),
829 probabilities: BTreeMap::from([("bugs".to_owned(), 0.5_f32)]),
830 confidence: f32::INFINITY,
831 },
832 )];
833 assert!(matches!(
834 map(answers, &good_set()),
835 Err(ClassifierError::Rejected(_))
836 ));
837 }
838
839 #[test]
840 fn a_noul_is_derived_from_its_one_number() {
841 let noul_only = BTreeMap::from([(
842 "angry".to_owned(),
843 Question::Noul {
844 instructions: "Is the writer angry?".to_owned(),
845 },
846 )]);
847 let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.8 })];
849 let mapped = map(answers, &noul_only).expect("maps");
850 let angry = &mapped["angry"];
851 assert_eq!(angry.value, AnswerValue::Noul(true));
852 assert!((angry.probabilities["true"] - 0.8).abs() < f32::EPSILON);
853 assert!((angry.probabilities["false"] - 0.2).abs() < f32::EPSILON);
854 assert!((angry.confidence - 0.8).abs() < f32::EPSILON, "{angry:?}");
855
856 let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.3 })];
858 let mapped = map(answers, &noul_only).expect("maps");
859 let angry = &mapped["angry"];
860 assert_eq!(angry.value, AnswerValue::Noul(false));
861 assert!((angry.confidence - 0.7).abs() < f32::EPSILON, "{angry:?}");
862 }
863
864 #[test]
865 fn a_noul_outside_the_unit_interval_is_refused() {
866 let noul_only = BTreeMap::from([(
867 "angry".to_owned(),
868 Question::Noul {
869 instructions: "Is the writer angry?".to_owned(),
870 },
871 )]);
872 for noul in [-0.1_f32, 1.1, f32::NAN] {
873 let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul })];
874 assert!(
875 matches!(map(answers, &noul_only), Err(ClassifierError::Rejected(_))),
876 "noul {noul} refused"
877 );
878 }
879 }
880
881 #[test]
882 fn a_score_re_keys_to_the_level_names_and_interpolates_the_value() {
883 let words = BTreeMap::from([(
887 "frustration".to_owned(),
888 Question::Score {
889 instructions: "How frustrated?".to_owned(),
890 levels: vec![
891 ("Calm".to_owned(), "Calm".to_owned()),
892 ("Frustrated".to_owned(), "Frustrated".to_owned()),
893 ("Very angry".to_owned(), "Very angry".to_owned()),
894 ],
895 },
896 )]);
897 let answers = vec![vendor_answer(
898 "frustration",
899 WireAnswer::Score {
900 score: 1.05,
901 probabilities: BTreeMap::from([
902 ("0".to_owned(), 0.0_f32),
903 ("1".to_owned(), 0.95_f32),
904 ("2".to_owned(), 0.05_f32),
905 ]),
906 confidence: 0.92,
907 },
908 )];
909 let mapped = map(answers, &words).expect("maps");
910 let frustration = &mapped["frustration"];
911 assert!(
912 (value_score(frustration) - 1.05).abs() < f32::EPSILON,
913 "{frustration:?}"
914 );
915 assert!((frustration.probabilities["Frustrated"] - 0.95).abs() < f32::EPSILON);
916 assert!((frustration.probabilities["Very angry"] - 0.05).abs() < f32::EPSILON);
917 assert!((frustration.confidence - 0.92).abs() < f32::EPSILON);
918
919 let numeric = BTreeMap::from([(
923 "severity".to_owned(),
924 Question::Score {
925 instructions: "How severe?".to_owned(),
926 levels: vec![
927 ("1".to_owned(), "a typo".to_owned()),
928 ("5".to_owned(), "data loss".to_owned()),
929 ],
930 },
931 )]);
932 let answers = vec![vendor_answer(
933 "severity",
934 WireAnswer::Score {
935 score: 0.7,
936 probabilities: BTreeMap::from([
937 ("0".to_owned(), 0.3_f32),
938 ("1".to_owned(), 0.7_f32),
939 ]),
940 confidence: 0.7,
941 },
942 )];
943 let mapped = map(answers, &numeric).expect("maps");
944 let severity = &mapped["severity"];
945 assert!((value_score(severity) - 3.8).abs() < 1.0e-5, "{severity:?}");
946 assert!((severity.probabilities["1"] - 0.3).abs() < f32::EPSILON);
947 assert!((severity.probabilities["5"] - 0.7).abs() < f32::EPSILON);
948
949 let answers = vec![vendor_answer(
952 "severity",
953 WireAnswer::Score {
954 score: 1.0,
955 probabilities: BTreeMap::from([
956 ("0".to_owned(), 0.2_f32),
957 ("1".to_owned(), 0.8_f32),
958 ]),
959 confidence: 0.8,
960 },
961 )];
962 let mapped = map(answers, &numeric).expect("maps");
963 assert!((value_score(&mapped["severity"]) - 5.0).abs() < f32::EPSILON);
964 }
965
966 #[test]
967 fn a_level_named_like_a_float_special_is_not_a_number_axis() {
968 let words = BTreeMap::from([(
971 "odd".to_owned(),
972 Question::Score {
973 instructions: "How odd?".to_owned(),
974 levels: vec![
975 ("inf".to_owned(), "not much".to_owned()),
976 ("NaN".to_owned(), "very".to_owned()),
977 ],
978 },
979 )]);
980 let answers = vec![vendor_answer(
981 "odd",
982 WireAnswer::Score {
983 score: 0.5,
984 probabilities: BTreeMap::from([
985 ("0".to_owned(), 0.5_f32),
986 ("1".to_owned(), 0.5_f32),
987 ]),
988 confidence: 0.5,
989 },
990 )];
991 let mapped = map(answers, &words).expect("maps");
992 assert!((value_score(&mapped["odd"]) - 0.5).abs() < f32::EPSILON);
993 }
994
995 #[test]
996 fn a_score_off_its_scale_or_over_its_levels_is_refused() {
997 let numeric = BTreeMap::from([(
998 "severity".to_owned(),
999 Question::Score {
1000 instructions: "How severe?".to_owned(),
1001 levels: vec![
1002 ("1".to_owned(), "a typo".to_owned()),
1003 ("5".to_owned(), "data loss".to_owned()),
1004 ],
1005 },
1006 )]);
1007 let scored = |score: f32, probabilities: BTreeMap<String, f32>| {
1008 vec![vendor_answer(
1009 "severity",
1010 WireAnswer::Score {
1011 score,
1012 probabilities,
1013 confidence: 0.7,
1014 },
1015 )]
1016 };
1017 assert!(matches!(
1019 map(
1020 scored(1.5, BTreeMap::from([("0".to_owned(), 1.0_f32)])),
1021 &numeric
1022 ),
1023 Err(ClassifierError::Rejected(_))
1024 ));
1025 assert!(matches!(
1027 map(
1028 scored(0.5, BTreeMap::from([("7".to_owned(), 1.0_f32)])),
1029 &numeric
1030 ),
1031 Err(ClassifierError::Rejected(_))
1032 ));
1033 assert!(matches!(
1035 map(
1036 scored(0.5, BTreeMap::from([("data loss".to_owned(), 1.0_f32)])),
1037 &numeric
1038 ),
1039 Err(ClassifierError::Rejected(_))
1040 ));
1041 }
1042
1043 #[test]
1044 fn typesafe_model_is_honoured_only_when_set_and_non_blank() {
1045 assert_eq!(model_from_env(None), DEFAULT_MODEL);
1046 assert_eq!(model_from_env(Some(String::new())), DEFAULT_MODEL);
1047 assert_eq!(model_from_env(Some(" ".to_owned())), DEFAULT_MODEL);
1048 assert_eq!(model_from_env(Some("jev-latest".to_owned())), "jev-latest");
1049 assert_eq!(
1051 model_from_env(Some(" jev-preview ".to_owned())),
1052 "jev-preview"
1053 );
1054 }
1055
1056 fn value_score(answer: &Answer) -> f32 {
1058 match answer.value {
1059 AnswerValue::Score(score) => score,
1060 ref other => panic!("not a score: {other:?}"),
1061 }
1062 }
1063}