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