1use std::collections::BTreeMap;
41use std::fmt::Write as _;
42
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45
46use crate::{DecisionResponse, Options, Question};
47
48mod graph;
49mod output;
50mod svg;
51
52use output::Output;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum And {
58 #[default]
60 Min,
61 Product,
64 Lukasiewicz,
66}
67
68impl And {
69 fn describe(self) -> &'static str {
71 match self {
72 And::Min => "min",
73 And::Product => "a × b",
74 And::Lukasiewicz => "max(0, a + b − 1)",
75 }
76 }
77
78 fn apply(self, a: f64, b: f64) -> f64 {
79 match self {
80 And::Min => a.min(b),
81 And::Product => a * b,
82 And::Lukasiewicz => (a + b - 1.0).max(0.0),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum Or {
91 #[default]
93 Max,
94 Probsum,
97 Bounded,
100}
101
102impl Or {
103 fn describe(self) -> &'static str {
105 match self {
106 Or::Max => "max",
107 Or::Probsum => "a + b − ab",
108 Or::Bounded => "min(1, a + b)",
109 }
110 }
111
112 fn apply(self, a: f64, b: f64) -> f64 {
113 match self {
114 Or::Max => a.max(b),
115 Or::Probsum => a + b - a * b,
116 Or::Bounded => (a + b).min(1.0),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Logic {
125 #[serde(default)]
126 pub and: And,
127 #[serde(default)]
128 pub or: Or,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum Hedge {
136 Very,
138 Somewhat,
140 Extremely,
142 Indeed,
144}
145
146impl Hedge {
147 fn named(word: &str) -> Option<Hedge> {
148 Some(match word {
149 "VERY" => Hedge::Very,
150 "SOMEWHAT" => Hedge::Somewhat,
151 "EXTREMELY" => Hedge::Extremely,
152 "INDEED" => Hedge::Indeed,
153 _ => return None,
154 })
155 }
156
157 fn describe(self) -> (&'static str, &'static str) {
159 match self {
160 Hedge::Very => ("VERY", "x²"),
161 Hedge::Somewhat => ("SOMEWHAT", "√x"),
162 Hedge::Extremely => ("EXTREMELY", "x³"),
163 Hedge::Indeed => ("INDEED", "toward 0 or 1"),
164 }
165 }
166
167 fn apply(self, a: f64) -> f64 {
168 match self {
169 Hedge::Very => a * a,
170 Hedge::Somewhat => a.sqrt(),
171 Hedge::Extremely => a * a * a,
172 Hedge::Indeed if a <= 0.5 => 2.0 * a * a,
173 Hedge::Indeed => 1.0 - 2.0 * (1.0 - a) * (1.0 - a),
174 }
175 }
176}
177
178const OPERATORS: &str = "AND, OR, NOT, VERY, SOMEWHAT, EXTREMELY, INDEED";
180
181#[derive(Debug, Clone, PartialEq)]
183enum Expr {
184 Term(String),
185 Not(Box<Expr>),
186 Hedge(Hedge, Box<Expr>),
187 And(Box<Expr>, Box<Expr>),
188 Or(Box<Expr>, Box<Expr>),
189}
190
191impl Expr {
192 fn eval(&self, logic: Logic, degree: &mut impl FnMut(&str) -> crate::Result<f64>) -> crate::Result<f64> {
194 Ok(match self {
195 Expr::Term(term) => degree(term)?,
196 Expr::Not(inner) => 1.0 - inner.eval(logic, degree)?,
197 Expr::Hedge(hedge, inner) => hedge.apply(inner.eval(logic, degree)?),
198 Expr::And(a, b) => logic.and.apply(a.eval(logic, degree)?, b.eval(logic, degree)?),
199 Expr::Or(a, b) => logic.or.apply(a.eval(logic, degree)?, b.eval(logic, degree)?),
200 })
201 }
202
203 fn terms<'a>(&'a self, out: &mut Vec<&'a str>) {
205 match self {
206 Expr::Term(term) => out.push(term),
207 Expr::Not(inner) | Expr::Hedge(_, inner) => inner.terms(out),
208 Expr::And(a, b) | Expr::Or(a, b) => {
209 a.terms(out);
210 b.terms(out);
211 }
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq)]
217enum Token {
218 Word(String),
219 Open,
220 Close,
221}
222
223fn tokens(text: &str) -> Vec<Token> {
224 let mut tokens = Vec::new();
225 let mut word = String::new();
226 for character in text.chars() {
227 if character.is_whitespace() || character == '(' || character == ')' {
228 if !word.is_empty() {
229 tokens.push(Token::Word(std::mem::take(&mut word)));
230 }
231 match character {
232 '(' => tokens.push(Token::Open),
233 ')' => tokens.push(Token::Close),
234 _ => {}
235 }
236 } else {
237 word.push(character);
238 }
239 }
240 if !word.is_empty() {
241 tokens.push(Token::Word(word));
242 }
243 tokens
244}
245
246const MOST_TOKENS: usize = 256;
248
249struct Parser {
251 tokens: Vec<Token>,
252 at: usize,
253}
254
255impl Parser {
256 fn parse(text: &str) -> Result<Expr, String> {
257 let mut parser = Parser { tokens: tokens(text), at: 0 };
258 if parser.tokens.is_empty() {
259 return Err("the `if` is empty".to_owned());
260 }
261 if parser.tokens.len() > MOST_TOKENS {
265 return Err(format!(
266 "it is {} words and brackets long, over the {MOST_TOKENS} a rule may have; split it into several rules with the same `then`",
267 parser.tokens.len()
268 ));
269 }
270 let expr = parser.or()?;
271 match parser.tokens.get(parser.at) {
272 None => Ok(expr),
273 Some(Token::Close) => Err("a `)` with no `(` before it".to_owned()),
274 Some(Token::Open) => Err("a `(` where AND or OR should be".to_owned()),
275 Some(Token::Word(word)) if word.chars().any(|character| character.is_uppercase()) => {
276 Err(format!("`{word}` isn't an operator: operators are {OPERATORS}"))
277 }
278 Some(Token::Word(word)) => Err(format!("`{word}` where AND or OR should be")),
279 }
280 }
281
282 fn eat(&mut self, keyword: &str) -> bool {
283 let found = matches!(self.tokens.get(self.at), Some(Token::Word(word)) if word == keyword);
284 if found {
285 self.at += 1;
286 }
287 found
288 }
289
290 fn or(&mut self) -> Result<Expr, String> {
291 let mut left = self.and()?;
292 while self.eat("OR") {
293 left = Expr::Or(Box::new(left), Box::new(self.and()?));
294 }
295 Ok(left)
296 }
297
298 fn and(&mut self) -> Result<Expr, String> {
299 let mut left = self.unary()?;
300 while self.eat("AND") {
301 left = Expr::And(Box::new(left), Box::new(self.unary()?));
302 }
303 Ok(left)
304 }
305
306 fn unary(&mut self) -> Result<Expr, String> {
307 if self.eat("NOT") {
308 return Ok(Expr::Not(Box::new(self.unary()?)));
309 }
310 if let Some(Token::Word(word)) = self.tokens.get(self.at) {
311 if let Some(hedge) = Hedge::named(word) {
312 self.at += 1;
313 return Ok(Expr::Hedge(hedge, Box::new(self.unary()?)));
314 }
315 }
316 self.atom()
317 }
318
319 fn atom(&mut self) -> Result<Expr, String> {
320 let token = self.tokens.get(self.at).cloned();
321 self.at += 1;
322 match token {
323 None => Err("it ends where a term should be".to_owned()),
324 Some(Token::Close) => Err("a `)` where a term should be".to_owned()),
325 Some(Token::Open) => {
326 let inner = self.or()?;
327 if self.tokens.get(self.at) != Some(&Token::Close) {
328 return Err("a `(` that isn't closed".to_owned());
329 }
330 self.at += 1;
331 Ok(inner)
332 }
333 Some(Token::Word(word)) if word == "AND" || word == "OR" => Err(format!("`{word}` where a term should be")),
334 Some(Token::Word(word)) if is_term_name(&word) => Ok(Expr::Term(word)),
335 Some(Token::Word(word)) => Err(format!(
336 "`{word}` is neither an operator nor a term: operators are uppercase ({OPERATORS}), and terms are \
337 lowercase names from [terms]"
338 )),
339 }
340 }
341}
342
343fn is_term_name(name: &str) -> bool {
346 let mut characters = name.chars();
347 characters.next().is_some_and(|first| first.is_ascii_lowercase() || first == '_')
348 && characters.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_')
349}
350
351#[derive(Debug, Clone, PartialEq)]
354struct Target {
355 id: String,
356 kind: Kind,
357 labels: Vec<String>,
359 selected: usize,
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365enum Kind {
366 Noul,
367 Score,
368 Choice,
369}
370
371impl Target {
372 fn resolve(target: &str, questions: &BTreeMap<&str, &Question>) -> Result<Target, String> {
375 let target = target.trim();
376 if let Some(question) = questions.get(target) {
377 return match question {
378 Question::Noul { .. } => {
379 Ok(Target { id: target.to_owned(), kind: Kind::Noul, labels: vec!["no".to_owned(), "yes".to_owned()], selected: 1 })
380 }
381 Question::Score { criteria, .. } => {
382 Err(format!("`{target}` is a score: name one of its levels, as {}", listed(target, &level_names(criteria))))
383 }
384 Question::Choice { criteria, .. } => {
385 Err(format!("`{target}` is a choice: name one of its options, as {}", listed(target, &option_names(criteria))))
386 }
387 };
388 }
389 for (at, _) in target.rmatch_indices('.') {
393 let (id, name) = (&target[..at], &target[at + 1..]);
394 let Some(question) = questions.get(id) else { continue };
395 return match question {
396 Question::Noul { .. } => Err(format!("`{id}` is a noul, which has no levels or options: write `{id}` alone")),
397 Question::Score { criteria, .. } => {
398 match criteria.iter().position(|level| level.as_str() == Some(name)).filter(|at| u8::try_from(*at).is_ok()) {
400 Some(selected) => Ok(Target { id: id.to_owned(), kind: Kind::Score, labels: level_labels(criteria), selected }),
401 None => Err(format!("`{id}` has no level `{name}`; its levels are {}", listed(id, &level_names(criteria)))),
402 }
403 }
404 Question::Choice { criteria, .. } => {
405 let options = option_names(criteria);
406 match options.iter().position(|option| option == name) {
407 Some(selected) => Ok(Target { id: id.to_owned(), kind: Kind::Choice, labels: options, selected }),
408 None => Err(format!("`{id}` has no option `{name}`; its options are {}", listed(id, &options))),
409 }
410 }
411 };
412 }
413 let asked: Vec<&str> = questions.keys().copied().collect();
414 Err(format!("no question `{}`; the questions are {}", target.split('.').next().unwrap_or(target), asked.join(", ")))
415 }
416
417 fn degree(&self, reply: &DecisionResponse) -> crate::Result<f64> {
420 Ok(match self.kind {
421 Kind::Noul => reply.noul(&self.id)?,
422 Kind::Score => {
423 let level = u8::try_from(self.selected).unwrap_or(u8::MAX);
424 reply.score(&self.id)?.probabilities.get(&level).copied().ok_or_else(|| self.missing())?
425 }
426 Kind::Choice => {
427 reply.choice(&self.id)?.probabilities.get(&self.labels[self.selected]).copied().ok_or_else(|| self.missing())?
428 }
429 })
430 }
431}
432
433impl Target {
434 fn check(&self, reply: &DecisionResponse) -> crate::Result<()> {
438 let bad = |why: String| crate::Error::BadAnswer { id: self.id.clone(), why };
439 match self.kind {
440 Kind::Noul => {
441 reply.noul(&self.id)?;
442 }
443 Kind::Score => {
444 if let Some(level) = reply.score(&self.id)?.probabilities.keys().find(|level| usize::from(**level) >= self.labels.len()) {
445 return Err(bad(format!(
446 "it gives a probability for level {level}, and the question's levels are 0 to {}",
447 self.labels.len() - 1
448 )));
449 }
450 }
451 Kind::Choice => {
452 if let Some(option) = reply.choice(&self.id)?.probabilities.keys().find(|option| !self.labels.contains(option)) {
453 return Err(bad(format!("it gives a probability for `{option}`, which isn't one of the question's options")));
454 }
455 }
456 }
457 reply.answer(&self.id)?.check().map_err(bad)
458 }
459
460 fn missing(&self) -> crate::Error {
462 crate::Error::MissingProbability { id: self.id.clone(), label: self.labels[self.selected].clone() }
463 }
464}
465
466fn level_labels(levels: &[Value]) -> Vec<String> {
468 levels
469 .iter()
470 .enumerate()
471 .map(|(at, level)| match level {
472 Value::String(text) => text.clone(),
473 _ => format!("level {at}"),
474 })
475 .collect()
476}
477
478fn level_names(levels: &[Value]) -> Vec<String> {
480 levels
481 .iter()
482 .enumerate()
483 .map(|(at, level)| match level {
484 Value::String(text) => text.clone(),
485 _ => format!("(level {at} is structured, so no term can name it)"),
486 })
487 .collect()
488}
489
490fn option_names(Options(options): &Options) -> Vec<String> {
491 options.iter().map(|(name, _)| name.clone()).collect()
492}
493
494fn listed(id: &str, names: &[String]) -> String {
496 names.iter().map(|name| if name.starts_with('(') { name.clone() } else { format!("`{id}.{name}`") }).collect::<Vec<_>>().join(", ")
497}
498
499#[derive(Deserialize)]
501#[serde(deny_unknown_fields)]
502struct File {
503 #[serde(default)]
504 logic: Logic,
505 #[serde(default)]
506 decide: Decide,
507 #[serde(default)]
508 terms: BTreeMap<String, String>,
509 #[serde(default, rename = "output")]
510 outputs: BTreeMap<String, output::OutputFile>,
511 #[serde(default, rename = "rule")]
512 rules: Vec<RuleFile>,
513}
514
515#[derive(Deserialize)]
516#[serde(deny_unknown_fields)]
517struct Decide {
518 #[serde(default = "Decide::half")]
519 threshold: f64,
520}
521
522impl Decide {
523 fn half() -> f64 {
524 0.5
525 }
526}
527
528impl Default for Decide {
529 fn default() -> Decide {
530 Decide { threshold: Decide::half() }
531 }
532}
533
534#[derive(Deserialize)]
535#[serde(deny_unknown_fields)]
536struct RuleFile {
537 #[serde(rename = "if")]
538 when: String,
539 then: String,
540 #[serde(default = "RuleFile::full")]
541 weight: f64,
542}
543
544impl RuleFile {
545 fn full() -> f64 {
546 1.0
547 }
548}
549
550#[derive(Debug, Clone, PartialEq)]
552struct Rule {
553 text: String,
555 when: Expr,
556 then: Then,
557 weight: f64,
558}
559
560#[derive(Debug, Clone, PartialEq)]
563enum Then {
564 Item(String),
565 Output {
567 output: usize,
568 set: usize,
569 },
570}
571
572#[derive(Debug, Clone, PartialEq)]
574pub struct Rules {
575 logic: Logic,
576 threshold: f64,
577 terms: BTreeMap<String, Target>,
578 outputs: Vec<Output>,
579 rules: Vec<Rule>,
580}
581
582impl Rules {
583 pub fn parse<'a>(text: &str, questions: impl IntoIterator<Item = (&'a str, &'a Question)>) -> Result<Rules, String> {
586 let file: File = toml::from_str(text).map_err(|error| error.to_string().trim_end().to_owned())?;
587 if !(0.0..=1.0).contains(&file.decide.threshold) {
588 return Err(format!("[decide] threshold is {}; it goes from 0 to 1", file.decide.threshold));
589 }
590 let questions: BTreeMap<&str, &Question> = questions.into_iter().collect();
591 let mut terms = BTreeMap::new();
592 for (name, target) in &file.terms {
593 if !is_term_name(name) {
594 return Err(format!(
595 "[terms] `{name}`: a term is a lowercase word of letters, digits and `_`, so it can't be taken for an \
596 operator ({OPERATORS})"
597 ));
598 }
599 terms.insert(name.clone(), Target::resolve(target, &questions).map_err(|why| format!("[terms] {name}: {why}"))?);
600 }
601 let mut outputs = Vec::with_capacity(file.outputs.len());
602 for (name, output) in file.outputs {
603 outputs.push(Output::parse(name, output)?);
604 }
605 if file.rules.is_empty() {
606 return Err("no rules: add a [[rule]] with an `if` and a `then`".to_owned());
607 }
608 let mut rules = Vec::with_capacity(file.rules.len());
609 for (at, rule) in file.rules.into_iter().enumerate() {
610 let text = rule.when.trim().to_owned();
611 let wrong = |why: &str| format!("rule {} (`{text}`): {why}", at + 1);
612 for token in tokens(&text) {
615 let Token::Word(word) = token else { continue };
616 let upper = word.to_ascii_uppercase();
617 let what = match upper.as_str() {
618 "AND" | "OR" | "NOT" => "operator",
619 _ if Hedge::named(&upper).is_some() => "hedge",
620 _ => continue,
621 };
622 if word != upper && !terms.contains_key(&word) {
623 return Err(wrong(&format!("`{word}` isn't in [terms]; the {what} is written {upper}")));
624 }
625 }
626 let when = Parser::parse(&text).map_err(|why| wrong(&why))?;
627 let mut used = Vec::new();
628 when.terms(&mut used);
629 if let Some(missing) = used.iter().find(|term| !terms.contains_key(**term)) {
630 let named = match terms.is_empty() {
631 true => "none".to_owned(),
632 false => terms.keys().cloned().collect::<Vec<_>>().join(", "),
633 };
634 return Err(wrong(&format!("`{missing}` isn't in [terms], which names {named}")));
635 }
636 let then = Then::parse(&rule.then, &outputs).map_err(|why| wrong(&why))?;
637 if !(0.0..=1.0).contains(&rule.weight) {
638 return Err(wrong(&format!("the weight is {}; it goes from 0 to 1", rule.weight)));
639 }
640 rules.push(Rule { text, when, then, weight: rule.weight });
641 }
642 Ok(Rules { logic: file.logic, threshold: file.decide.threshold, terms, outputs, rules })
643 }
644
645 pub fn evaluate(&self, reply: &DecisionResponse) -> crate::Result<Outcome> {
648 self.check(reply)?;
649 let scores = self.scores(reply)?;
650 let mut items: Vec<Item> = Vec::new();
651 let mut outputs: Vec<OutputValue> = self
652 .outputs
653 .iter()
654 .map(|output| OutputValue {
655 output: output.name.clone(),
656 value: None,
657 sets: output.sets.iter().map(|set| SetScore { set: set.name.clone(), score: 0.0, rules: Vec::new() }).collect(),
658 })
659 .collect();
660 for (rule, &score) in self.rules.iter().zip(&scores) {
661 let fired = Fired { when: rule.text.clone(), weight: rule.weight, score };
662 match &rule.then {
663 Then::Item(name) => match items.iter_mut().find(|item| &item.item == name) {
664 Some(item) => {
665 item.score = self.logic.or.apply(item.score, score);
666 item.rules.push(fired);
667 }
668 None => items.push(Item { item: name.clone(), score, yes: false, rules: vec![fired] }),
669 },
670 Then::Output { output, set } => {
671 let set = &mut outputs[*output].sets[*set];
672 set.score = self.logic.or.apply(set.score, score);
673 set.rules.push(fired);
674 }
675 }
676 }
677 for item in &mut items {
678 item.yes = item.score >= self.threshold;
679 }
680 for (at, value) in outputs.iter_mut().enumerate() {
681 value.value = self.outputs[at].centroid(&self.set_scores(at, &scores), self.logic.or);
682 }
683 Ok(Outcome { threshold: self.threshold, items, outputs })
684 }
685
686 fn check(&self, reply: &DecisionResponse) -> crate::Result<()> {
688 let mut checked: Vec<&str> = Vec::new();
689 for target in self.terms.values() {
690 if !checked.contains(&target.id.as_str()) {
691 target.check(reply)?;
692 checked.push(&target.id);
693 }
694 }
695 Ok(())
696 }
697
698 fn scores(&self, reply: &DecisionResponse) -> crate::Result<Vec<f64>> {
700 let mut degree = |term: &str| self.terms[term].degree(reply);
701 self.rules.iter().map(|rule| Ok(rule.when.eval(self.logic, &mut degree)? * rule.weight)).collect()
702 }
703
704 fn set_scores(&self, at: usize, scores: &[f64]) -> Vec<f64> {
708 let mut sets = vec![0.0; self.outputs[at].sets.len()];
709 for (rule, &score) in self.rules.iter().zip(scores) {
710 if let Then::Output { output, set } = rule.then {
711 if output == at {
712 sets[set] = self.logic.or.apply(sets[set], score);
713 }
714 }
715 }
716 sets
717 }
718}
719
720#[derive(Debug, Clone, PartialEq, Serialize)]
722pub struct Outcome {
723 pub threshold: f64,
725 pub items: Vec<Item>,
727 #[serde(skip_serializing_if = "Vec::is_empty")]
729 pub outputs: Vec<OutputValue>,
730}
731
732#[derive(Debug, Clone, PartialEq, Serialize)]
736pub struct OutputValue {
737 pub output: String,
738 pub value: Option<f64>,
740 pub sets: Vec<SetScore>,
742}
743
744#[derive(Debug, Clone, PartialEq, Serialize)]
746pub struct SetScore {
747 pub set: String,
748 pub score: f64,
749 pub rules: Vec<Fired>,
750}
751
752#[derive(Debug, Clone, PartialEq, Serialize)]
754pub struct Item {
755 pub item: String,
756 pub score: f64,
757 pub yes: bool,
758 pub rules: Vec<Fired>,
759}
760
761#[derive(Debug, Clone, PartialEq, Serialize)]
763pub struct Fired {
764 #[serde(rename = "if")]
766 pub when: String,
767 pub weight: f64,
768 pub score: f64,
770}
771
772impl Outcome {
773 pub fn text(&self) -> String {
776 let names = self.items.iter().map(|item| &item.item).chain(self.outputs.iter().map(|output| &output.output));
777 let width = names.map(|name| name.chars().count()).max().unwrap_or(0);
778 let mut out = String::new();
779 for item in &self.items {
780 let yes = if item.yes { " yes" } else { "" };
781 let _ = writeln!(out, "{:width$} {:.2}{yes}", item.item, item.score);
782 }
783 if !self.items.is_empty() {
784 let _ = writeln!(out, "threshold {:.2}", self.threshold);
785 }
786 for output in &self.outputs {
787 let _ = writeln!(out, "{:width$} {} ({})", output.output, output.value_text(), output.sets_text());
788 }
789 out
790 }
791}
792
793impl OutputValue {
794 pub fn value_text(&self) -> String {
796 self.value.map_or_else(|| "-".to_owned(), |value| format!("{value:.2}"))
797 }
798
799 pub fn sets_text(&self) -> String {
801 self.sets.iter().map(|set| format!("{} {:.2}", set.set, set.score)).collect::<Vec<_>>().join(", ")
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use serde_json::json;
808
809 use super::*;
810
811 fn questions() -> Vec<(String, Question)> {
812 vec![
813 ("temp".to_owned(), Question::score("How warm is it?", ["Cold", "Mild", "Hot"])),
814 ("humidity".to_owned(), Question::score("How humid is it?", ["Dry", "Normal", "Humid"])),
815 ("raining".to_owned(), Question::noul("Is it raining?")),
816 ("sky".to_owned(), Question::choice("What is the sky like?", [("clear", ""), ("cloudy", ""), ("storm", "")])),
817 ]
818 }
819
820 fn reply() -> DecisionResponse {
822 serde_json::from_value(json!({
823 "model": "typesafe/jev-1.13-20260917",
824 "answers": {
825 "temp": {"type": "score", "score": 1.1, "confidence": 0.7,
826 "probabilities": {"0": 0.1, "1": 0.7, "2": 0.2}, "legend": {"0": "Cold", "1": "Mild", "2": "Hot"}},
827 "humidity": {"type": "score", "score": 1.5, "confidence": 0.6,
828 "probabilities": {"0": 0.1, "1": 0.3, "2": 0.6}, "legend": {"0": "Dry", "1": "Normal", "2": "Humid"}},
829 "raining": {"type": "noul", "noul": 0.8},
830 "sky": {"type": "choice", "choice": "cloudy", "confidence": 0.5,
831 "probabilities": {"clear": 0.1, "cloudy": 0.6, "storm": 0.3}},
832 },
833 "usage": {"input_tokens": 400, "output_tokens": 70},
834 }))
835 .unwrap()
836 }
837
838 const TERMS: &str = r#"
839 [terms]
840 cold = "temp.Cold"
841 mild = "temp.Mild"
842 hot = "temp.Hot"
843 humid = "humidity.Humid"
844 raining = "raining"
845 storm = "sky.storm"
846 "#;
847
848 fn rules(rules: &str) -> Result<Rules, String> {
849 let questions = questions();
850 Rules::parse(&format!("{TERMS}\n{rules}"), questions.iter().map(|(id, question)| (id.as_str(), question)))
851 }
852
853 fn score(logic: &str, when: &str) -> f64 {
855 let rules = rules(&format!("{logic}\n[[rule]]\nif = \"{when}\"\nthen = \"x\"")).unwrap();
856 rules.evaluate(&reply()).unwrap().items[0].score
857 }
858
859 fn close(a: f64, b: f64) -> bool {
860 (a - b).abs() < 1e-9
861 }
862
863 #[test]
864 fn reads_each_kind_of_term() {
865 assert!(close(score("", "mild"), 0.7));
866 assert!(close(score("", "raining"), 0.8));
867 assert!(close(score("", "storm"), 0.3));
868 }
869
870 #[test]
871 fn combines_with_min_max_and_one_minus() {
872 assert!(close(score("", "raining AND humid"), 0.6));
873 assert!(close(score("", "raining OR humid"), 0.8));
874 assert!(close(score("", "NOT raining"), 0.2));
875 assert!(close(score("", "raining AND NOT hot"), 0.8));
876 }
877
878 #[test]
879 fn takes_the_files_and_and_or() {
880 assert!(close(score("[logic]\nand = \"product\"", "raining AND humid"), 0.48));
881 assert!(close(score("[logic]\nand = \"lukasiewicz\"", "raining AND humid"), 0.4));
882 assert!(close(score("[logic]\nor = \"probsum\"", "raining OR humid"), 0.92));
883 assert!(close(score("[logic]\nor = \"bounded\"", "raining OR humid"), 1.0));
884 }
885
886 #[test]
887 fn applies_hedges() {
888 assert!(close(score("", "VERY mild"), 0.49));
889 assert!(close(score("", "EXTREMELY mild"), 0.343));
890 assert!(close(score("", "SOMEWHAT mild"), 0.7f64.sqrt()));
891 assert!(close(score("", "INDEED mild"), 0.82));
892 assert!(close(score("", "INDEED cold"), 0.02));
893 assert!(close(score("", "NOT VERY mild"), 0.51));
895 assert!(close(score("", "VERY mild AND raining"), 0.49));
896 }
897
898 #[test]
899 fn and_binds_tighter_than_or_and_parentheses_group() {
900 assert!(close(score("", "cold OR raining AND humid"), 0.6));
902 assert!(close(score("", "(cold OR raining) AND humid"), 0.6));
904 assert!(close(score("", "(cold OR hot) AND raining"), 0.2));
905 assert!(close(score("", "VERY (raining AND humid)"), 0.36));
906 }
907
908 #[test]
909 fn weights_rules_and_joins_the_same_then_by_or() {
910 let rules = rules(
911 r#"
912 [[rule]]
913 if = "cold"
914 then = "coat"
915
916 [[rule]]
917 if = "raining AND NOT hot"
918 then = "raincoat"
919
920 [[rule]]
921 if = "raining AND humid"
922 then = "coat"
923 weight = 0.5
924 "#,
925 )
926 .unwrap();
927 let outcome = rules.evaluate(&reply()).unwrap();
928 let items: Vec<(&str, f64, bool)> = outcome.items.iter().map(|item| (item.item.as_str(), item.score, item.yes)).collect();
930 assert_eq!(items.len(), 2);
931 assert_eq!(items[0].0, "coat");
932 assert!(close(items[0].1, 0.3));
934 assert!(!items[0].2);
935 assert_eq!(items[1].0, "raincoat");
936 assert!(close(items[1].1, 0.8) && items[1].2);
937 assert_eq!(outcome.items[0].rules.len(), 2);
938 assert_eq!(outcome.items[0].rules[1].when, "raining AND humid");
939 assert_eq!(outcome.text(), "coat 0.30\nraincoat 0.80 yes\nthreshold 0.50\n");
940 }
941
942 #[test]
943 fn decides_at_the_files_threshold() {
944 let outcome =
945 rules("[decide]\nthreshold = 0.25\n[[rule]]\nif = \"storm\"\nthen = \"stay in\"").unwrap().evaluate(&reply()).unwrap();
946 assert!(outcome.items[0].yes);
947 assert_eq!(outcome.threshold, 0.25);
948 }
949
950 #[test]
951 fn prints_the_outcome_as_json() {
952 let outcome = rules("[[rule]]\nif = \"raining\"\nthen = \"umbrella\"").unwrap().evaluate(&reply()).unwrap();
953 assert_eq!(
954 serde_json::to_value(&outcome).unwrap(),
955 json!({"threshold": 0.5, "items": [
956 {"item": "umbrella", "score": 0.8, "yes": true, "rules": [{"if": "raining", "weight": 1.0, "score": 0.8}]}
957 ]})
958 );
959 }
960
961 fn error(rules_text: &str) -> String {
962 rules(rules_text).unwrap_err()
963 }
964
965 #[test]
966 fn says_what_is_wrong_with_a_rule() {
967 let rule = |when: &str| error(&format!("[[rule]]\nif = \"{when}\"\nthen = \"x\""));
968 assert!(rule("").contains("empty"), "{}", rule(""));
969 assert!(rule("raining AND").contains("ends where a term should be"), "{}", rule("raining AND"));
970 assert!(rule("(raining AND hot").contains("isn't closed"));
971 assert!(rule("raining)").contains("`)` with no `(`"));
972 assert!(rule("raining hot").contains("`hot` where AND or OR should be"));
973 assert!(rule("raining XOR hot").contains("`XOR` isn't an operator: operators are AND, OR"));
974 assert!(rule("Hot").contains("`Hot` is neither"));
975 assert!(rule("AND hot").contains("`AND` where a term should be"));
976 let wet = rule("wet");
978 assert!(wet.contains("rule 1 (`wet`): `wet` isn't in [terms]") && wet.contains("raining"), "{wet}");
979 assert!(rule("raining and hot").contains("the operator is written AND"));
980 assert!(rule("very hot").contains("the hedge is written VERY"));
981 }
982
983 #[test]
984 fn says_what_is_wrong_with_the_file() {
985 assert!(error("").contains("no rules"));
986 assert!(error("[[rule]]\nif = \"hot\"\nthen = \" \"").contains("`then` is empty"));
987 assert!(error("[[rule]]\nif = \"hot\"\nthen = \"x\"\nweight = 2.0").contains("weight is 2"));
988 assert!(error("[decide]\nthreshold = 1.5\n[[rule]]\nif = \"hot\"\nthen = \"x\"").contains("threshold is 1.5"));
989 assert!(error("[logic]\nand = \"average\"\n[[rule]]\nif = \"hot\"\nthen = \"x\"").contains("average"));
990 assert!(error("[[rule]]\nif = \"hot\"\nthen = \"x\"\nelse = \"y\"").contains("else"));
991 }
992
993 #[test]
994 fn checks_every_term_against_the_questions() {
995 let questions = questions();
996 let parse = |terms: &str| {
997 Rules::parse(&format!("[terms]\n{terms}\n[[rule]]\nif = \"t\"\nthen = \"x\""), questions.iter().map(|(id, q)| (id.as_str(), q)))
998 };
999 assert!(parse("t = \"temp.Hot\"").is_ok());
1000 let hot = parse("t = \"temp.hot\"").unwrap_err();
1002 assert!(hot.contains("[terms] t: `temp` has no level `hot`; its levels are `temp.Cold`, `temp.Mild`, `temp.Hot`"), "{hot}");
1003 assert!(parse("t = \"sky.Storm\"").unwrap_err().contains("its options are `sky.clear`, `sky.cloudy`, `sky.storm`"));
1004 assert!(parse("t = \"temp\"").unwrap_err().contains("`temp` is a score: name one of its levels"));
1005 assert!(parse("t = \"sky\"").unwrap_err().contains("`sky` is a choice"));
1006 assert!(parse("t = \"raining.yes\"").unwrap_err().contains("write `raining` alone"));
1007 assert!(parse("t = \"wind.Strong\"").unwrap_err().contains("no question `wind`; the questions are"));
1008 let unused = Rules::parse(
1010 "[terms]\nt = \"temp.Hot\"\nu = \"temp.Warm\"\n[[rule]]\nif = \"t\"\nthen = \"x\"",
1011 questions.iter().map(|(id, q)| (id.as_str(), q)),
1012 );
1013 assert!(unused.unwrap_err().contains("[terms] u"));
1014 assert!(parse("T = \"temp.Hot\"").unwrap_err().contains("[terms] `T`: a term is a lowercase word"));
1015 }
1016
1017 #[test]
1018 fn a_question_id_may_have_a_dot() {
1019 let questions = [("weather.temp".to_owned(), Question::score("How warm?", ["Cold", "Hot"]))];
1020 let parsed = Rules::parse(
1021 "[terms]\nhot = \"weather.temp.Hot\"\n[[rule]]\nif = \"hot\"\nthen = \"x\"",
1022 questions.iter().map(|(id, q)| (id.as_str(), q)),
1023 );
1024 let hot = &parsed.unwrap().terms["hot"];
1025 assert_eq!((hot.id.as_str(), hot.kind, hot.selected), ("weather.temp", Kind::Score, 1));
1026 }
1027
1028 #[test]
1029 fn a_longer_question_id_wins_over_its_prefix() {
1030 let questions = [
1031 ("weather".to_owned(), Question::score("How is it?", ["Fine", "Bad"])),
1032 ("weather.temp".to_owned(), Question::score("How warm?", ["Cold", "Hot"])),
1033 ];
1034 let parsed = Rules::parse(
1035 "[terms]\nhot = \"weather.temp.Hot\"\nbad = \"weather.Bad\"\n[[rule]]\nif = \"hot OR bad\"\nthen = \"x\"",
1036 questions.iter().map(|(id, q)| (id.as_str(), q)),
1037 )
1038 .unwrap();
1039 let (hot, bad) = (&parsed.terms["hot"], &parsed.terms["bad"]);
1040 assert_eq!((hot.id.as_str(), hot.selected), ("weather.temp", 1));
1041 assert_eq!((bad.id.as_str(), bad.selected), ("weather", 1));
1042 }
1043
1044 #[test]
1045 fn refuses_a_rule_too_deep_to_evaluate() {
1046 let deep = format!("{}raining{}", "NOT (".repeat(100_000), ")".repeat(100_000));
1048 let long = rule_error(&deep);
1049 assert!(long.contains("over the 256 a rule may have"), "{long}");
1050 let chain = vec!["raining"; 200].join(" AND ");
1051 assert!(rule_error(&chain).contains("over the 256"));
1052 assert!(rules(&format!("[[rule]]\nif = \"{}\"\nthen = \"x\"", vec!["raining"; 100].join(" AND "))).is_ok());
1054 }
1055
1056 fn rule_error(when: &str) -> String {
1057 error(&format!("[[rule]]\nif = \"{when}\"\nthen = \"x\""))
1058 }
1059
1060 #[test]
1061 fn a_missing_probability_is_an_error_not_a_zero() {
1062 let rules = rules("[[rule]]\nif = \"hot OR storm\"\nthen = \"x\"").unwrap();
1063 let mut level_gone = reply();
1064 let crate::Answer::Score(temp) = level_gone.answers.get_mut("temp").unwrap() else { unreachable!() };
1065 temp.probabilities.remove(&2);
1067 temp.probabilities.insert(1, 0.9);
1068 assert_eq!(rules.evaluate(&level_gone), Err(crate::Error::MissingProbability { id: "temp".to_owned(), label: "Hot".to_owned() }));
1069 let mut option_gone = reply();
1070 let crate::Answer::Choice(sky) = option_gone.answers.get_mut("sky").unwrap() else { unreachable!() };
1071 sky.probabilities.remove("storm");
1072 sky.probabilities.insert("cloudy".to_owned(), 0.9);
1073 assert_eq!(rules.evaluate(&option_gone).unwrap_err().to_string(), "question `sky` gives no probability for `storm`");
1074 }
1075
1076 #[test]
1077 fn an_answer_that_isnt_probabilities_is_an_error_not_clamped() {
1078 let rules = rules("[[rule]]\nif = \"NOT raining OR hot OR storm\"\nthen = \"x\"").unwrap();
1079 let why = |change: &dyn Fn(&mut DecisionResponse)| {
1080 let mut reply = reply();
1081 change(&mut reply);
1082 match rules.evaluate(&reply) {
1083 Err(error @ crate::Error::BadAnswer { .. }) => error.to_string(),
1084 other => panic!("{other:?}"),
1085 }
1086 };
1087 let noul = |value: f64| {
1088 move |reply: &mut DecisionResponse| {
1089 reply.answers.insert("raining".to_owned(), crate::Answer::Noul(crate::NoulAnswer { noul: value })).map(drop).unwrap_or(())
1090 }
1091 };
1092 assert_eq!(
1094 why(&noul(1.2)),
1095 "the answer to question `raining` can't be read: the probability of yes is 1.2, which isn't a probability from 0 to 1"
1096 );
1097 assert!(why(&noul(f64::NAN)).contains("NaN"));
1098 let temp = |levels: [(u8, f64); 3]| {
1099 move |reply: &mut DecisionResponse| {
1100 let crate::Answer::Score(temp) = reply.answers.get_mut("temp").unwrap() else { unreachable!() };
1101 temp.probabilities = levels.into();
1102 }
1103 };
1104 assert!(why(&temp([(0, 0.1), (1, 0.9), (2, 0.2)])).contains("its probabilities add up to 1.200, not 1"));
1105 assert!(why(&temp([(0, 0.9), (1, -0.1), (2, 0.2)])).contains("level 1's probability is -0.1"));
1106 assert!(why(&temp([(0, 0.1), (1, 0.9), (3, 0.0)])).contains("level 3, and the question's levels are 0 to 2"));
1107 assert!(why(&|reply: &mut DecisionResponse| {
1108 let crate::Answer::Choice(sky) = reply.answers.get_mut("sky").unwrap() else { unreachable!() };
1109 sky.probabilities.insert("hail".to_owned(), 0.0);
1110 })
1111 .contains("`hail`, which isn't one of the question's options"));
1112 let mut rounded = reply();
1114 let crate::Answer::Choice(sky) = rounded.answers.get_mut("sky").unwrap() else { unreachable!() };
1115 sky.probabilities = [("clear", 0.33), ("cloudy", 0.33), ("storm", 0.33)].map(|(option, p)| (option.to_owned(), p)).into();
1116 assert!(rules.evaluate(&rounded).is_ok());
1117 let mut bad = reply();
1119 noul(1.2)(&mut bad);
1120 assert!(rules.graph_text(Some(&bad)).is_err() && rules.graph_svg(Some(&bad)).is_err());
1121 }
1122
1123 #[test]
1124 fn a_missing_answer_is_an_error_not_a_zero() {
1125 let rules = rules("[[rule]]\nif = \"raining\"\nthen = \"umbrella\"").unwrap();
1126 let mut reply = reply();
1127 reply.answers.remove("raining");
1128 assert_eq!(rules.evaluate(&reply), Err(crate::Error::MissingAnswer("raining".to_owned())));
1129 }
1130}