Skip to main content

jev/rules/
graph.rs

1//! The rules drawn as a picture, laid out as a fuzzy rule base is: a row per rule with its premises
2//! (the questions' levels, the term's own in bold, cut at the degree Jev gave it), the operators that
3//! combine them, and its conclusion (an item's bar, or an output's set clipped at the rule's score),
4//! then the final column (each item's score against the threshold, and each output's merged shape
5//! with its centre).
6//!
7//! Without a reply it is the structure alone, which costs no call; with one, every part carries its
8//! number. [`Rules::graph_text`] draws it for a terminal, [`Rules::graph_svg`] as an SVG image.
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12
13use super::{Expr, Kind, Rules, Then};
14use crate::DecisionResponse;
15
16/// One node of a rule's `if`, with what it came to when there is a reply.
17#[derive(Debug, Clone, PartialEq)]
18pub(super) struct Node {
19    /// `AND`, `OR`, `NOT`, a hedge, or the term's name.
20    pub(super) label: String,
21    /// How it is worked out: `min`, `1 − x`, `x²`; empty for a term.
22    pub(super) how: &'static str,
23    pub(super) value: Option<f64>,
24    pub(super) children: Vec<Node>,
25    /// The term, for a leaf.
26    pub(super) term: Option<String>,
27}
28
29impl Node {
30    fn of(expr: &Expr, rules: &Rules, degrees: Option<&BTreeMap<String, f64>>) -> Node {
31        let unary = |label: &str, how, inner: &Expr, apply: &dyn Fn(f64) -> f64| {
32            let child = Node::of(inner, rules, degrees);
33            Node { label: label.to_owned(), how, value: child.value.map(apply), children: vec![child], term: None }
34        };
35        match expr {
36            Expr::Term(term) => Node {
37                label: term.clone(),
38                how: "",
39                value: degrees.map(|degrees| degrees[term]),
40                children: Vec::new(),
41                term: Some(term.clone()),
42            },
43            Expr::Not(inner) => unary("NOT", "1 − x", inner, &|x| 1.0 - x),
44            Expr::Hedge(hedge, inner) => {
45                let (label, how) = hedge.describe();
46                unary(label, how, inner, &|x| hedge.apply(x))
47            }
48            Expr::And(..) | Expr::Or(..) => {
49                // `a AND b AND c` parses as ((a AND b) AND c); it is drawn as one AND of three.
50                let mut operands = Vec::new();
51                flatten(expr, &mut operands);
52                let children: Vec<Node> = operands.iter().map(|operand| Node::of(operand, rules, degrees)).collect();
53                let (label, how) = match expr {
54                    Expr::And(..) => ("AND", rules.logic.and.describe()),
55                    _ => ("OR", rules.logic.or.describe()),
56                };
57                let values: Option<Vec<f64>> = children.iter().map(|child| child.value).collect();
58                let value = values.map(|values| {
59                    values[1..].iter().fold(values[0], |joined, &value| match expr {
60                        Expr::And(..) => rules.logic.and.apply(joined, value),
61                        _ => rules.logic.or.apply(joined, value),
62                    })
63                });
64                Node { label: label.to_owned(), how, value, children, term: None }
65            }
66        }
67    }
68
69    /// The terms under it, in the order written.
70    pub(super) fn terms(&self, out: &mut Vec<String>) {
71        match &self.term {
72            Some(term) => out.push(term.clone()),
73            None => self.children.iter().for_each(|child| child.terms(out)),
74        }
75    }
76}
77
78/// The operands of a run of the same operator, left to right.
79fn flatten<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) {
80    let (a, b) = match expr {
81        Expr::And(a, b) | Expr::Or(a, b) => (a, b),
82        other => return out.push(other),
83    };
84    for side in [a, b] {
85        let same = matches!((expr, side.as_ref()), (Expr::And(..), Expr::And(..)) | (Expr::Or(..), Expr::Or(..)));
86        if same {
87            flatten(side, out);
88        } else {
89            out.push(side);
90        }
91    }
92}
93
94/// A rule laid out for drawing.
95pub(super) struct Row {
96    pub(super) number: usize,
97    pub(super) text: String,
98    pub(super) then: Then,
99    pub(super) weight: f64,
100    pub(super) tree: Node,
101    /// The rule's score: the tree's value times the weight.
102    pub(super) score: Option<f64>,
103}
104
105impl Rules {
106    /// Every rule laid out for drawing, with numbers when there is a reply.
107    pub(super) fn rows(&self, degrees: Option<&BTreeMap<String, f64>>) -> Vec<Row> {
108        self.rules
109            .iter()
110            .enumerate()
111            .map(|(at, rule)| {
112                let tree = Node::of(&rule.when, self, degrees);
113                let score = tree.value.map(|value| value * rule.weight);
114                Row { number: at + 1, text: rule.text.clone(), then: rule.then.clone(), weight: rule.weight, tree, score }
115            })
116            .collect()
117    }
118
119    /// Every term's degree in `reply`.
120    pub(super) fn degrees(&self, reply: &DecisionResponse) -> crate::Result<BTreeMap<String, f64>> {
121        self.terms.iter().map(|(name, target)| Ok((name.clone(), target.degree(reply)?))).collect()
122    }
123
124    /// What a rule concludes, as written.
125    pub(super) fn then_text(&self, then: &Then) -> String {
126        match then {
127            Then::Item(name) => name.clone(),
128            Then::Output { output, set } => format!("{} IS {}", self.outputs[*output].name, self.outputs[*output].sets[*set].name),
129        }
130    }
131
132    /// The rules for a terminal: each rule as a tree of its operators down to its terms, what it
133    /// concludes, and then the final scores and each output's merged shape. With `reply`, every node
134    /// carries its number; without it, this is the structure alone. Ends with a newline.
135    pub fn graph_text(&self, reply: Option<&DecisionResponse>) -> crate::Result<String> {
136        let degrees = reply.map(|reply| self.degrees(reply)).transpose()?;
137        let outcome = reply.map(|reply| self.evaluate(reply)).transpose()?;
138        let rows = self.rows(degrees.as_ref());
139        let mut out = String::new();
140        for row in &rows {
141            let _ = writeln!(out, "R{}  {}  ⇒  {}", row.number, row.text, self.then_text(&row.then));
142            self.tree_lines(&row.tree, reply, "    ", "    ", &mut out);
143            let weight = if row.weight < 1.0 { format!(" (× weight {})", row.weight) } else { String::new() };
144            let _ = match (&row.then, row.score) {
145                (Then::Item(name), Some(score)) => writeln!(out, "    ⇒ {name}  {score:.2}{weight}  {}", bar(score, 20)),
146                (Then::Item(name), None) => writeln!(out, "    ⇒ {name}{weight}"),
147                (then @ Then::Output { .. }, Some(score)) => {
148                    writeln!(out, "    ⇒ {}, clipped at {score:.2}{weight}", self.then_text(then))
149                }
150                (then @ Then::Output { .. }, None) => writeln!(out, "    ⇒ {}, clipped at the rule's score{weight}", self.then_text(then)),
151            };
152            out.push('\n');
153        }
154
155        let items: Vec<String> = self.item_names();
156        if !items.is_empty() || !self.outputs.is_empty() {
157            out.push_str("Final\n");
158        }
159        let width = items.iter().map(|item| item.chars().count()).max().unwrap_or(0);
160        for name in &items {
161            let by: Vec<String> = rows
162                .iter()
163                .filter(|row| matches!(&row.then, Then::Item(item) if item == name))
164                .map(|row| format!("R{}", row.number))
165                .collect();
166            let _ = match outcome.as_ref().and_then(|outcome| outcome.items.iter().find(|item| &item.item == name)) {
167                Some(item) => {
168                    writeln!(out, "  {name:width$}  {:.2}  {}{}", item.score, bar(item.score, 20), if item.yes { "  yes" } else { "" })
169                }
170                None => writeln!(out, "  {name:width$}  from {}", by.join(" OR ")),
171            };
172        }
173        if !items.is_empty() {
174            let _ = writeln!(out, "  threshold {:.2}", self.threshold);
175        }
176        for (at, output) in self.outputs.iter().enumerate() {
177            if at > 0 || !items.is_empty() {
178                out.push('\n');
179            }
180            let scores: Option<Vec<f64>> = rows.iter().map(|row| row.score).collect();
181            let clipped = scores.as_ref().map(|scores| self.clipped(at, scores));
182            let value = clipped.as_ref().and_then(|clipped| output.centroid(clipped, self.logic.or));
183            let _ = match (&clipped, value) {
184                (Some(_), Some(value)) => writeln!(out, "  {} = {value:.2}", output.name),
185                (Some(_), None) => writeln!(out, "  {} = -  (no rule fired)", output.name),
186                (None, _) => writeln!(out, "  {}  [{}, {}]", output.name, output.range.0, output.range.1),
187            };
188            self.plot(at, clipped.as_deref(), value, &mut out);
189        }
190        Ok(out)
191    }
192
193    /// Every item a `then` names, in the order the file first names it.
194    pub(super) fn item_names(&self) -> Vec<String> {
195        let mut items: Vec<String> = Vec::new();
196        for rule in &self.rules {
197            if let Then::Item(name) = &rule.then {
198                if !items.contains(name) {
199                    items.push(name.clone());
200                }
201            }
202        }
203        items
204    }
205
206    /// `node` and what is under it, as a tree drawn with box lines.
207    fn tree_lines(&self, node: &Node, reply: Option<&DecisionResponse>, first: &str, rest: &str, out: &mut String) {
208        let how = if node.how.is_empty() { String::new() } else { format!(" ({})", node.how) };
209        let value = node.value.map(|value| format!("  {value:.2}")).unwrap_or_default();
210        let source = match &node.term {
211            Some(term) => format!("  ← {}", self.source(term, reply)),
212            None => String::new(),
213        };
214        let _ = writeln!(out, "{first}{}{how}{value}{source}", node.label);
215        for (at, child) in node.children.iter().enumerate() {
216            let last = at + 1 == node.children.len();
217            let (branch, under) = if last { ("└─ ", "   ") } else { ("├─ ", "│  ") };
218            self.tree_lines(child, reply, &format!("{rest}{branch}"), &format!("{rest}{under}"), out);
219        }
220    }
221
222    /// Where a term's degree comes from, and with a reply every level's or option's probability,
223    /// the term's own marked: `temp: Cold 0.04 · Mild 0.95 · [Hot 0.01]`.
224    fn source(&self, term: &str, reply: Option<&DecisionResponse>) -> String {
225        let target = &self.terms[term];
226        let probabilities = reply.and_then(|reply| target.probabilities(reply));
227        let labels: Vec<String> = target
228            .labels
229            .iter()
230            .enumerate()
231            .map(|(at, label)| {
232                let shown = match &probabilities {
233                    Some(probabilities) => format!("{label} {:.2}", probabilities[at]),
234                    None => label.clone(),
235                };
236                if at == target.selected {
237                    format!("[{shown}]")
238                } else {
239                    shown
240                }
241            })
242            .collect();
243        format!("{}: {}", target.id, labels.join(" · "))
244    }
245
246    /// An output's range as a plot: the merged shape filled in blocks, every set's outline shaded
247    /// behind it, the sets' names under their peaks, and `↑` at the centre.
248    fn plot(&self, at: usize, clipped: Option<&[(usize, f64)]>, value: Option<f64>, out: &mut String) {
249        const WIDTH: usize = 60;
250        const HEIGHT: usize = 5;
251        const EIGHTHS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
252        let output = &self.outputs[at];
253        let xs: Vec<f64> = output.samples(WIDTH).collect();
254        let outline: Vec<f64> = xs.iter().map(|x| output.sets.iter().map(|set| set.membership(*x)).fold(0.0, f64::max)).collect();
255        let merged: Vec<f64> = xs.iter().map(|x| clipped.map_or(0.0, |clipped| output.merged(clipped, self.logic.or, *x))).collect();
256        for line in (0..HEIGHT).rev() {
257            let axis = match line {
258                l if l == HEIGHT - 1 => "  1.0 ┤",
259                _ => "      │",
260            };
261            let mut text = axis.to_owned();
262            for (&shape, &merged) in outline.iter().zip(&merged) {
263                let fill = ((merged * HEIGHT as f64 - line as f64) * 8.0).round().clamp(0.0, 8.0) as usize;
264                let cell = if fill > 0 {
265                    EIGHTHS[fill]
266                } else if shape * HEIGHT as f64 - line as f64 >= 0.5 {
267                    '░'
268                } else {
269                    ' '
270                };
271                text.push(cell);
272            }
273            let _ = writeln!(out, "{}", text.trim_end());
274        }
275        let column_of = |x: f64| (((x - output.range.0) / (output.range.1 - output.range.0)) * (WIDTH - 1) as f64).round() as usize;
276        let mut axis: Vec<char> = "─".repeat(WIDTH).chars().collect();
277        if let Some(value) = value {
278            axis[column_of(value).min(WIDTH - 1)] = '┬';
279        }
280        let _ = writeln!(out, "  0.0 └{}", axis.into_iter().collect::<String>());
281        // The range's ends and the centre, then the sets' names, each placed where there is room.
282        let mut under = vec![' '; WIDTH + 12];
283        let place = |line: &mut Vec<char>, column: usize, text: &str| {
284            let start = column.min(line.len().saturating_sub(text.chars().count()));
285            if line[start..(start + text.chars().count()).min(line.len())].iter().all(|cell| *cell == ' ') {
286                for (offset, character) in text.chars().enumerate() {
287                    if let Some(cell) = line.get_mut(start + offset) {
288                        *cell = character;
289                    }
290                }
291            }
292        };
293        if let Some(value) = value {
294            place(&mut under, column_of(value), &format!("↑ {value:.2}"));
295        }
296        place(&mut under, 0, &trim_number(output.range.0));
297        let high = trim_number(output.range.1);
298        place(&mut under, WIDTH - high.chars().count(), &high);
299        let _ = writeln!(out, "       {}", under.into_iter().collect::<String>().trim_end());
300        let mut names = vec![' '; WIDTH + 12];
301        for set in &output.sets {
302            let column = column_of(set.middle()).saturating_sub(set.name.chars().count() / 2);
303            place(&mut names, column, &set.name);
304        }
305        let _ = writeln!(out, "       {}", names.into_iter().collect::<String>().trim_end());
306    }
307}
308
309/// A number without a needless `.0`.
310pub(super) fn trim_number(number: f64) -> String {
311    if number.fract() == 0.0 {
312        format!("{number:.0}")
313    } else {
314        format!("{number}")
315    }
316}
317
318/// `score` as a bar `width` cells long, to an eighth of a cell.
319fn bar(score: f64, width: usize) -> String {
320    const EIGHTHS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
321    let eighths = (score.clamp(0.0, 1.0) * width as f64 * 8.0).round() as usize;
322    format!("{}{}", "█".repeat(eighths / 8), EIGHTHS[eighths % 8])
323}
324
325impl super::Target {
326    /// Every level's or option's probability in `reply`, in the order of `labels`. For a Noul, no
327    /// and yes.
328    pub(super) fn probabilities(&self, reply: &DecisionResponse) -> Option<Vec<f64>> {
329        match self.kind {
330            Kind::Noul => reply.noul(&self.id).ok().map(|yes| vec![1.0 - yes, yes]),
331            Kind::Score => {
332                let answer = reply.score(&self.id).ok()?;
333                Some(
334                    (0..self.labels.len())
335                        .map(|at| u8::try_from(at).ok().and_then(|at| answer.probabilities.get(&at)).copied().unwrap_or(0.0))
336                        .collect(),
337                )
338            }
339            Kind::Choice => {
340                let answer = reply.choice(&self.id).ok()?;
341                Some(self.labels.iter().map(|label| answer.probabilities.get(label).copied().unwrap_or(0.0)).collect())
342            }
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use serde_json::json;
350
351    use super::super::Rules;
352    use crate::{DecisionResponse, Question};
353
354    const RULES: &str = r#"
355        [terms]
356        hot = "temp.Hot"
357        raining = "raining"
358        regular = "rain.Regular"
359
360        [output.water]
361        range = [0, 10]
362        little = [0, 0, 2, 5]
363        lots = [5, 8, 10, 10]
364
365        [[rule]]
366        if = "raining AND NOT VERY hot"
367        then = "raincoat"
368
369        [[rule]]
370        if = "regular"
371        then = "water IS little"
372    "#;
373
374    fn rules() -> Rules {
375        let questions = [
376            ("temp".to_owned(), Question::score("How warm?", ["Cold", "Mild", "Hot"])),
377            ("raining".to_owned(), Question::noul("Raining?")),
378            ("rain".to_owned(), Question::score("How much rain?", ["Scarce", "Regular"])),
379        ];
380        Rules::parse(RULES, questions.iter().map(|(id, question)| (id.as_str(), question))).unwrap()
381    }
382
383    fn reply() -> DecisionResponse {
384        serde_json::from_value(json!({
385            "model": "typesafe/jev-1.13-20260917",
386            "answers": {
387                "temp": {"type": "score", "score": 1.4, "confidence": 0.5, "probabilities": {"0": 0.0, "1": 0.6, "2": 0.4}},
388                "raining": {"type": "noul", "noul": 0.9},
389                "rain": {"type": "score", "score": 0.8, "confidence": 0.6, "probabilities": {"0": 0.2, "1": 0.8}},
390            },
391            "usage": {"input_tokens": 1, "output_tokens": 1},
392        }))
393        .unwrap()
394    }
395
396    #[test]
397    fn draws_the_structure_without_a_reply() {
398        let text = rules().graph_text(None).unwrap();
399        let expected = "\
400R1  raining AND NOT VERY hot  ⇒  raincoat
401    AND (min)
402    ├─ raining  ← raining: no · [yes]
403    └─ NOT (1 − x)
404       └─ VERY (x²)
405          └─ hot  ← temp: Cold · Mild · [Hot]
406    ⇒ raincoat
407";
408        assert!(text.starts_with(expected), "{text}");
409        assert!(text.contains("R2  regular  ⇒  water IS little\n"), "{text}");
410        assert!(text.contains("  raincoat  from R1\n  threshold 0.50\n"), "{text}");
411        assert!(text.contains("  water  [0, 10]\n"), "{text}");
412    }
413
414    #[test]
415    fn draws_every_number_with_a_reply() {
416        let text = rules().graph_text(Some(&reply())).unwrap();
417        // VERY 0.4 = 0.16, NOT that = 0.84, AND with 0.9 = 0.84.
418        assert!(text.contains("    AND (min)  0.84\n"), "{text}");
419        assert!(text.contains("       └─ VERY (x²)  0.16\n"), "{text}");
420        assert!(text.contains("hot  0.40  ← temp: Cold 0.00 · Mild 0.60 · [Hot 0.40]"), "{text}");
421        assert!(text.contains("    ⇒ water IS little, clipped at 0.80\n"), "{text}");
422        assert!(text.contains("  raincoat  0.84  ████████████████▊  yes\n"), "{text}");
423        let value = rules().evaluate(&reply()).unwrap().outputs[0].value.unwrap();
424        assert!(text.contains(&format!("  water = {value:.2}\n")), "{text}");
425        assert!(text.contains(&format!("↑ {value:.2}")), "{text}");
426    }
427
428    #[test]
429    fn draws_an_svg_with_a_row_per_rule() {
430        let svg = rules().graph_svg(Some(&reply())).unwrap();
431        assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
432        assert!(svg.trim_end().ends_with("</svg>"));
433        for text in
434            ["Premises", "Conclusions", "Final", ">R1<", ">R2<", "if raining AND NOT VERY hot  ⇒  raincoat", "items (threshold 0.50)"]
435        {
436            assert!(svg.contains(text), "{text} is missing");
437        }
438        let value = rules().evaluate(&reply()).unwrap().outputs[0].value.unwrap();
439        assert!(svg.contains(&format!("water = {value:.2}")));
440        // Every element that opens is closed.
441        assert_eq!(svg.matches("<text").count(), svg.matches("</text>").count());
442        // The structure alone draws no numbers and says why.
443        let structure = rules().graph_svg(None).unwrap();
444        assert!(structure.contains("Structure only") && !structure.contains("water ="));
445    }
446
447    #[test]
448    fn a_missing_answer_is_an_error() {
449        let mut reply = reply();
450        reply.answers.remove("raining");
451        assert!(rules().graph_text(Some(&reply)).is_err());
452        assert!(rules().graph_svg(Some(&reply)).is_err());
453    }
454}