Skip to main content

jev/rules/
mod.rs

1//! Fuzzy rules over a reply: what `-r rules.toml` reads. Jev's answers are degrees from 0 to 1 — a
2//! Noul's probability of yes, each Score level's and each Choice option's probability — and the
3//! rules combine them with AND, OR, NOT and hedges into a score per outcome, which counts as a yes
4//! at or over a threshold.
5//!
6//! ```toml
7//! [logic]                    # optional
8//! and = "min"                # min | product | lukasiewicz
9//! or  = "max"                # max | probsum | bounded
10//!
11//! [decide]                   # optional
12//! threshold = 0.5
13//!
14//! [terms]                    # the names rules use for the answers
15//! hot     = "temp.Hot"       # a Score's level, by its text
16//! stormy  = "sky.storm"      # a Choice's option
17//! raining = "raining"        # a Noul, by its id alone
18//!
19//! [[rule]]
20//! if     = "raining AND NOT SOMEWHAT hot"
21//! then   = "raincoat"
22//! weight = 1.0               # optional; the rule's score is multiplied by it
23//! ```
24//!
25//! Operators are uppercase (`AND`, `OR`, `NOT`, and the hedges `VERY`, `SOMEWHAT`, `EXTREMELY`,
26//! `INDEED`) and terms are lowercase, so neither can be taken for the other. Hedges and `NOT` bind
27//! tightest, then `AND`, then `OR`, and parentheses group. Rules with the same `then` are joined by
28//! the file's OR.
29//!
30//! A rules file is checked against the questions before anything is asked ([`Rules::parse`]), so a
31//! term naming a level the question doesn't have costs no call.
32
33use 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/// How AND joins two degrees.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum And {
51    /// The smaller: safe when the answers are related, as answers about one state usually are.
52    #[default]
53    Min,
54    /// Their product, for independent conditions: every doubt lowers the result.
55    Product,
56    /// `max(0, a + b − 1)`: strict, true only when both clearly are.
57    Lukasiewicz,
58}
59
60impl And {
61    /// How it is worked out, for the graph.
62    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/// How OR joins two degrees, in a rule and between rules with the same `then`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum Or {
83    /// The larger: the strongest reason decides.
84    #[default]
85    Max,
86    /// `a + b − ab`: independent reasons reinforce each other.
87    Probsum,
88    /// `min(1, a + b)`: reasons add up, to at most 1.
89    Bounded,
90}
91
92impl Or {
93    /// How it is worked out, for the graph.
94    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/// The file's `[logic]`: which AND and which OR.
112#[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/// A word that changes how strongly a term holds.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123enum Hedge {
124    /// `a²`: only a strong answer stays strong.
125    Very,
126    /// `√a`: a weak answer still counts.
127    Somewhat,
128    /// `a³`.
129    Extremely,
130    /// Pushes a degree away from 0.5, toward yes or no.
131    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    /// Its name and how it is worked out, for the graph.
146    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
166/// Every operator, for the messages that name them.
167const OPERATORS: &str = "AND, OR, NOT, VERY, SOMEWHAT, EXTREMELY, INDEED";
168
169/// A rule's `if`, parsed.
170#[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    /// The degree to which it holds, with each term's from `degree`.
181    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    /// The terms it uses, in the order written.
192    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
234/// How many words and brackets one `if` may have.
235const MOST_TOKENS: usize = 256;
236
237/// A recursive descent over the tokens: OR of ANDs of hedged or negated terms and groups.
238struct 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        // Parsing and evaluating recurse once per level of the tree, and a tree is never deeper
250        // than its words and brackets are many. Bounding them keeps a rule from a file or a model
251        // from running the stack out, which would end the process rather than return an error.
252        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
331/// Whether `name` can be a term: a lowercase word of letters, digits and `_`, so that it can never
332/// be taken for an operator.
333fn 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/// What a term reads from the reply: one level of a Score, one option of a Choice, or a Noul's yes.
340/// It keeps every label of its question as well, which the graph draws.
341#[derive(Debug, Clone, PartialEq)]
342struct Target {
343    id: String,
344    kind: Kind,
345    /// The Score's levels, the Choice's options, or `no` and `yes` for a Noul.
346    labels: Vec<String>,
347    /// Which of `labels` the term is.
348    selected: usize,
349}
350
351/// Which type of question a term reads.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum Kind {
354    Noul,
355    Score,
356    Choice,
357}
358
359impl Target {
360    /// Which question `target` names in `questions`, and what of it. `target` is the question id
361    /// alone for a Noul, or the id, a `.`, and a level's text or an option's name.
362    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        // An id may have a `.` of its own, so each `.` is tried until one ends a question's id: the
378        // last first, so that with both `weather` and `weather.temp` asked, `weather.temp.Hot` is
379        // the level of `weather.temp`.
380        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                    // Levels are numbered in a reply as u8s, so a level past 255 can't be read back.
387                    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    /// The degree the reply gives it. An answer missing from the reply, or of another type, is an
406    /// error rather than a zero: a zero would read as a confident no.
407    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    /// The error for a reply whose answer gives no probability for this term's level or option.
423    fn missing(&self) -> crate::Error {
424        crate::Error::MissingProbability { id: self.id.clone(), label: self.labels[self.selected].clone() }
425    }
426}
427
428/// A Score's levels as the graph labels them: the text, or the number of a structured level.
429fn 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
440/// A Score's levels, as a term names them. A structured level has no text to be named by.
441fn 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
456/// `id.a`, `id.b`, … for a message.
457fn 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/// A rules file, as written.
462#[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/// One rule, checked.
513#[derive(Debug, Clone, PartialEq)]
514struct Rule {
515    /// The `if` as written, which the outcome shows.
516    text: String,
517    when: Expr,
518    then: Then,
519    weight: f64,
520}
521
522/// What a rule concludes: an item, scored and then decided at the threshold, or one fuzzy set of an
523/// output, clipped at the rule's score and merged into a crisp value.
524#[derive(Debug, Clone, PartialEq)]
525enum Then {
526    Item(String),
527    /// `OUTPUT IS SET`, by position in [`Rules::outputs`] and in that output's sets.
528    Output {
529        output: usize,
530        set: usize,
531    },
532}
533
534/// A rules file, parsed and checked against the questions it will read.
535#[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    /// The rules in `text` (TOML), for a reply to `questions`. Every term is checked against the
546    /// questions here, before anything is asked; the error says what is wrong and where.
547    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            // An operator written in lowercase reads as a term, and the parser would then trip over
575            // whatever follows it; saying what it is helps more than where it tripped.
576            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    /// The outcome `reply` gives: every `then`, in the order the file first names it, with its
608    /// score and the rules behind it.
609    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    /// Each rule's score for `reply`: what its `if` came to, times its weight.
648    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    /// The sets output `at` is concluded in, each with the score its rule clips it at.
654    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/// What the rules made of a reply.
667#[derive(Debug, Clone, PartialEq, Serialize)]
668pub struct Outcome {
669    /// The score at or over which an item is a yes.
670    pub threshold: f64,
671    /// Every item a `then` names, in the order the file first names it.
672    pub items: Vec<Item>,
673    /// Every `[output]`, with the crisp value its sets' clipped shapes merge into.
674    #[serde(skip_serializing_if = "Vec::is_empty")]
675    pub outputs: Vec<OutputValue>,
676}
677
678/// An output's crisp value: the centroid of its sets, each clipped at the score its rules give it.
679#[derive(Debug, Clone, PartialEq, Serialize)]
680pub struct OutputValue {
681    pub output: String,
682    /// `None` when no rule concluding it scored above zero, so there is no shape to take a centre of.
683    pub value: Option<f64>,
684    /// Every set, in order along the output's range.
685    pub sets: Vec<SetScore>,
686}
687
688/// One set of an output: the score its rules clip it at (joined by the file's OR), and those rules.
689#[derive(Debug, Clone, PartialEq, Serialize)]
690pub struct SetScore {
691    pub set: String,
692    pub score: f64,
693    pub rules: Vec<Fired>,
694}
695
696/// One `then`: its score, whether that reaches the threshold, and the rules that gave it.
697#[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/// One rule's part in an item's score.
706#[derive(Debug, Clone, PartialEq, Serialize)]
707pub struct Fired {
708    /// The rule's `if`, as written.
709    #[serde(rename = "if")]
710    pub when: String,
711    pub weight: f64,
712    /// What the `if` came to, times the weight.
713    pub score: f64,
714}
715
716impl Outcome {
717    /// One line per item, `name  score  yes`, then the threshold, then a line per output with its
718    /// value and each set's score. Ends with a newline.
719    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    /// The value to two places, or `-` when no rule fired.
739    pub fn value_text(&self) -> String {
740        self.value.map_or_else(|| "-".to_owned(), |value| format!("{value:.2}"))
741    }
742
743    /// `drops 0.00, liter 0.80, gallon 0.10`.
744    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    /// The weather from the examples: mostly mild, fairly humid, probably raining.
765    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    /// The score `if` gives, under `logic`.
798    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        // A hedge or NOT binds to the term after it, not to the rest of the rule.
838        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        // cold OR (raining AND humid) = max(0.1, 0.6)
845        assert!(close(score("", "cold OR raining AND humid"), 0.6));
846        // (cold OR raining) AND humid = min(0.8, 0.6), and (cold OR hot) AND raining = min(0.2, 0.8)
847        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        // In the order the file first names them.
873        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        // max(0.1, 0.6 × 0.5)
877        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        // A term the file doesn't define, and the likeliest reason for one.
921        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        // Exact: the level's own text.
945        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        // Checked even when no rule uses it: a typo there is still a typo.
953        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        // Deep enough to overflow the stack if it were parsed: it must be refused before that.
991        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        // And a rule of a sensible length is fine.
997        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}