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//! (each question's levels or options and their probabilities, the term's own marked), 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.check(reply)?;
122        self.terms.iter().map(|(name, target)| Ok((name.clone(), target.degree(reply)?))).collect()
123    }
124
125    /// What a rule concludes, as written.
126    pub(super) fn then_text(&self, then: &Then) -> String {
127        match then {
128            Then::Item(name) => name.clone(),
129            Then::Output { output, set } => format!("{} IS {}", self.outputs[*output].name, self.outputs[*output].sets[*set].name),
130        }
131    }
132
133    /// The rules for a terminal: each rule as a tree of its operators down to its terms, what it
134    /// concludes, and then the final scores and each output's merged shape. With `reply`, every node
135    /// carries its number; without it, this is the structure alone. Ends with a newline.
136    pub fn graph_text(&self, reply: Option<&DecisionResponse>) -> crate::Result<String> {
137        let degrees = reply.map(|reply| self.degrees(reply)).transpose()?;
138        let outcome = reply.map(|reply| self.evaluate(reply)).transpose()?;
139        let rows = self.rows(degrees.as_ref());
140        let mut out = String::new();
141        for row in &rows {
142            let _ = writeln!(out, "R{}  {}  ⇒  {}", row.number, row.text, self.then_text(&row.then));
143            self.tree_lines(&row.tree, reply, "    ", "    ", &mut out);
144            let weight = if row.weight < 1.0 { format!(" (× weight {})", row.weight) } else { String::new() };
145            let _ = match (&row.then, row.score) {
146                (Then::Item(name), Some(score)) => writeln!(out, "    ⇒ {name}  {score:.2}{weight}  {}", bar(score, 20)),
147                (Then::Item(name), None) => writeln!(out, "    ⇒ {name}{weight}"),
148                (then @ Then::Output { .. }, Some(score)) => {
149                    writeln!(out, "    ⇒ {}, clipped at {score:.2}{weight}", self.then_text(then))
150                }
151                (then @ Then::Output { .. }, None) => writeln!(out, "    ⇒ {}, clipped at the rule's score{weight}", self.then_text(then)),
152            };
153            out.push('\n');
154        }
155
156        let items: Vec<String> = self.item_names();
157        if !items.is_empty() || !self.outputs.is_empty() {
158            out.push_str("Final\n");
159        }
160        let width = items.iter().map(|item| item.chars().count()).max().unwrap_or(0);
161        for name in &items {
162            let by: Vec<String> = rows
163                .iter()
164                .filter(|row| matches!(&row.then, Then::Item(item) if item == name))
165                .map(|row| format!("R{}", row.number))
166                .collect();
167            let _ = match outcome.as_ref().and_then(|outcome| outcome.items.iter().find(|item| &item.item == name)) {
168                Some(item) => {
169                    writeln!(out, "  {name:width$}  {:.2}  {}{}", item.score, bar(item.score, 20), if item.yes { "  yes" } else { "" })
170                }
171                None => writeln!(out, "  {name:width$}  from {}", by.join(" OR ")),
172            };
173        }
174        if !items.is_empty() {
175            let _ = writeln!(out, "  threshold {:.2}", self.threshold);
176        }
177        for (at, output) in self.outputs.iter().enumerate() {
178            if at > 0 || !items.is_empty() {
179                out.push('\n');
180            }
181            let scores: Option<Vec<f64>> = rows.iter().map(|row| row.score).collect();
182            let clipped = scores.as_ref().map(|scores| self.set_scores(at, scores));
183            let value = clipped.as_ref().and_then(|clipped| output.centroid(clipped, self.logic.or));
184            let _ = match (&clipped, value) {
185                (Some(_), Some(value)) => writeln!(out, "  {} = {value:.2}", output.name),
186                (Some(_), None) => writeln!(out, "  {} = -  (no rule fired)", output.name),
187                (None, _) => writeln!(out, "  {}  [{}, {}]", output.name, output.range.0, output.range.1),
188            };
189            self.plot(at, clipped.as_deref(), value, &mut out);
190        }
191        Ok(out)
192    }
193
194    /// Every item a `then` names, in the order the file first names it.
195    pub(super) fn item_names(&self) -> Vec<String> {
196        let mut items: Vec<String> = Vec::new();
197        for rule in &self.rules {
198            if let Then::Item(name) = &rule.then {
199                if !items.contains(name) {
200                    items.push(name.clone());
201                }
202            }
203        }
204        items
205    }
206
207    /// `node` and what is under it, as a tree drawn with box lines.
208    fn tree_lines(&self, node: &Node, reply: Option<&DecisionResponse>, first: &str, rest: &str, out: &mut String) {
209        let how = if node.how.is_empty() { String::new() } else { format!(" ({})", node.how) };
210        let value = node.value.map(|value| format!("  {value:.2}")).unwrap_or_default();
211        let source = match &node.term {
212            Some(term) => format!("  ← {}", self.source(term, reply)),
213            None => String::new(),
214        };
215        let _ = writeln!(out, "{first}{}{how}{value}{source}", node.label);
216        for (at, child) in node.children.iter().enumerate() {
217            let last = at + 1 == node.children.len();
218            let (branch, under) = if last { ("└─ ", "   ") } else { ("├─ ", "│  ") };
219            self.tree_lines(child, reply, &format!("{rest}{branch}"), &format!("{rest}{under}"), out);
220        }
221    }
222
223    /// Where a term's degree comes from, and with a reply every level's or option's probability,
224    /// the term's own marked: `temp: Cold 0.04 · Mild 0.95 · [Hot 0.01]`.
225    fn source(&self, term: &str, reply: Option<&DecisionResponse>) -> String {
226        let target = &self.terms[term];
227        let probabilities = reply.and_then(|reply| target.probabilities(reply));
228        let labels: Vec<String> = target
229            .labels
230            .iter()
231            .enumerate()
232            .map(|(at, label)| {
233                let shown = match probabilities.as_ref().map(|probabilities| probabilities[at]) {
234                    Some(Some(probability)) => format!("{label} {probability:.2}"),
235                    Some(None) => format!("{label} missing"),
236                    None => label.clone(),
237                };
238                if at == target.selected {
239                    format!("[{shown}]")
240                } else {
241                    shown
242                }
243            })
244            .collect();
245        format!("{}: {}", target.id, labels.join(" · "))
246    }
247
248    /// An output's range as a plot: the merged shape filled in blocks, every set's outline shaded
249    /// behind it, the sets' names under their peaks, and `↑` at the centre.
250    fn plot(&self, at: usize, clipped: Option<&[f64]>, value: Option<f64>, out: &mut String) {
251        const WIDTH: usize = 60;
252        const HEIGHT: usize = 5;
253        const EIGHTHS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
254        let output = &self.outputs[at];
255        let xs: Vec<f64> = output.samples(WIDTH).collect();
256        let outline: Vec<f64> = xs.iter().map(|x| output.sets.iter().map(|set| set.membership(*x)).fold(0.0, f64::max)).collect();
257        let merged: Vec<f64> = xs.iter().map(|x| clipped.map_or(0.0, |clipped| output.merged(clipped, self.logic.or, *x))).collect();
258        for line in (0..HEIGHT).rev() {
259            let axis = match line {
260                l if l == HEIGHT - 1 => "  1.0 ┤",
261                _ => "      │",
262            };
263            let mut text = axis.to_owned();
264            for (&shape, &merged) in outline.iter().zip(&merged) {
265                let fill = ((merged * HEIGHT as f64 - line as f64) * 8.0).round().clamp(0.0, 8.0) as usize;
266                let cell = if fill > 0 {
267                    EIGHTHS[fill]
268                } else if shape * HEIGHT as f64 - line as f64 >= 0.5 {
269                    '░'
270                } else {
271                    ' '
272                };
273                text.push(cell);
274            }
275            let _ = writeln!(out, "{}", text.trim_end());
276        }
277        let column_of = |x: f64| (((x - output.range.0) / (output.range.1 - output.range.0)) * (WIDTH - 1) as f64).round() as usize;
278        let mut axis: Vec<char> = "─".repeat(WIDTH).chars().collect();
279        if let Some(value) = value {
280            axis[column_of(value).min(WIDTH - 1)] = '┬';
281        }
282        let _ = writeln!(out, "  0.0 └{}", axis.into_iter().collect::<String>());
283        // The range's ends and the centre, then the sets' names, each placed where there is room.
284        let mut under = vec![' '; WIDTH + 12];
285        let place = |line: &mut Vec<char>, column: usize, text: &str| {
286            let start = column.min(line.len().saturating_sub(text.chars().count()));
287            if line[start..(start + text.chars().count()).min(line.len())].iter().all(|cell| *cell == ' ') {
288                for (offset, character) in text.chars().enumerate() {
289                    if let Some(cell) = line.get_mut(start + offset) {
290                        *cell = character;
291                    }
292                }
293            }
294        };
295        if let Some(value) = value {
296            place(&mut under, column_of(value), &format!("↑ {value:.2}"));
297        }
298        place(&mut under, 0, &trim_number(output.range.0));
299        let high = trim_number(output.range.1);
300        place(&mut under, WIDTH - high.chars().count(), &high);
301        let _ = writeln!(out, "       {}", under.into_iter().collect::<String>().trim_end());
302        let mut names = vec![' '; WIDTH + 12];
303        for set in &output.sets {
304            let column = column_of(set.middle()).saturating_sub(set.name.chars().count() / 2);
305            place(&mut names, column, &set.name);
306        }
307        let _ = writeln!(out, "       {}", names.into_iter().collect::<String>().trim_end());
308    }
309}
310
311/// A number without a needless `.0`.
312pub(super) fn trim_number(number: f64) -> String {
313    if number.fract() == 0.0 {
314        format!("{number:.0}")
315    } else {
316        format!("{number}")
317    }
318}
319
320/// `score` as a bar `width` cells long, to an eighth of a cell.
321fn bar(score: f64, width: usize) -> String {
322    const EIGHTHS: [&str; 8] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
323    let eighths = (score.clamp(0.0, 1.0) * width as f64 * 8.0).round() as usize;
324    format!("{}{}", "█".repeat(eighths / 8), EIGHTHS[eighths % 8])
325}
326
327impl super::Target {
328    /// Every level's or option's probability in `reply`, in the order of `labels`, and `None` for
329    /// one the reply leaves out: a drawing that showed it as 0 would make a gap look like a
330    /// confident no. For a Noul, no and yes.
331    pub(super) fn probabilities(&self, reply: &DecisionResponse) -> Option<Vec<Option<f64>>> {
332        match self.kind {
333            Kind::Noul => reply.noul(&self.id).ok().map(|yes| vec![Some(1.0 - yes), Some(yes)]),
334            Kind::Score => {
335                let answer = reply.score(&self.id).ok()?;
336                Some((0..self.labels.len()).map(|at| u8::try_from(at).ok().and_then(|at| answer.probabilities.get(&at)).copied()).collect())
337            }
338            Kind::Choice => {
339                let answer = reply.choice(&self.id).ok()?;
340                Some(self.labels.iter().map(|label| answer.probabilities.get(label).copied()).collect())
341            }
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use serde_json::json;
349
350    use super::super::Rules;
351    use crate::{DecisionResponse, Question};
352
353    const RULES: &str = r#"
354        [terms]
355        hot = "temp.Hot"
356        raining = "raining"
357        regular = "rain.Regular"
358
359        [output.water]
360        range = [0, 10]
361        little = [0, 0, 2, 5]
362        lots = [5, 8, 10, 10]
363
364        [[rule]]
365        if = "raining AND NOT VERY hot"
366        then = "raincoat"
367
368        [[rule]]
369        if = "regular"
370        then = "water IS little"
371    "#;
372
373    fn rules() -> Rules {
374        let questions = [
375            ("temp".to_owned(), Question::score("How warm?", ["Cold", "Mild", "Hot"])),
376            ("raining".to_owned(), Question::noul("Raining?")),
377            ("rain".to_owned(), Question::score("How much rain?", ["Scarce", "Regular"])),
378        ];
379        Rules::parse(RULES, questions.iter().map(|(id, question)| (id.as_str(), question))).unwrap()
380    }
381
382    fn reply() -> DecisionResponse {
383        serde_json::from_value(json!({
384            "model": "typesafe/jev-1.13-20260917",
385            "answers": {
386                "temp": {"type": "score", "score": 1.4, "confidence": 0.5, "probabilities": {"0": 0.0, "1": 0.6, "2": 0.4}},
387                "raining": {"type": "noul", "noul": 0.9},
388                "rain": {"type": "score", "score": 0.8, "confidence": 0.6, "probabilities": {"0": 0.2, "1": 0.8}},
389            },
390            "usage": {"input_tokens": 1, "output_tokens": 1},
391        }))
392        .unwrap()
393    }
394
395    #[test]
396    fn draws_the_structure_without_a_reply() {
397        let text = rules().graph_text(None).unwrap();
398        let expected = "\
399R1  raining AND NOT VERY hot  ⇒  raincoat
400    AND (min)
401    ├─ raining  ← raining: no · [yes]
402    └─ NOT (1 − x)
403       └─ VERY (x²)
404          └─ hot  ← temp: Cold · Mild · [Hot]
405    ⇒ raincoat
406";
407        assert!(text.starts_with(expected), "{text}");
408        assert!(text.contains("R2  regular  ⇒  water IS little\n"), "{text}");
409        assert!(text.contains("  raincoat  from R1\n  threshold 0.50\n"), "{text}");
410        assert!(text.contains("  water  [0, 10]\n"), "{text}");
411    }
412
413    #[test]
414    fn draws_every_number_with_a_reply() {
415        let text = rules().graph_text(Some(&reply())).unwrap();
416        // VERY 0.4 = 0.16, NOT that = 0.84, AND with 0.9 = 0.84.
417        assert!(text.contains("    AND (min)  0.84\n"), "{text}");
418        assert!(text.contains("       └─ VERY (x²)  0.16\n"), "{text}");
419        assert!(text.contains("hot  0.40  ← temp: Cold 0.00 · Mild 0.60 · [Hot 0.40]"), "{text}");
420        assert!(text.contains("    ⇒ water IS little, clipped at 0.80\n"), "{text}");
421        assert!(text.contains("  raincoat  0.84  ████████████████▊  yes\n"), "{text}");
422        let value = rules().evaluate(&reply()).unwrap().outputs[0].value.unwrap();
423        assert!(text.contains(&format!("  water = {value:.2}\n")), "{text}");
424        assert!(text.contains(&format!("↑ {value:.2}")), "{text}");
425    }
426
427    #[test]
428    fn draws_an_svg_with_a_row_per_rule() {
429        let svg = rules().graph_svg(Some(&reply())).unwrap();
430        assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
431        assert!(svg.trim_end().ends_with("</svg>"));
432        for text in
433            ["Premises", "Conclusions", "Final", ">R1<", ">R2<", "if raining AND NOT VERY hot  ⇒  raincoat", "items (threshold 0.50)"]
434        {
435            assert!(svg.contains(text), "{text} is missing");
436        }
437        let value = rules().evaluate(&reply()).unwrap().outputs[0].value.unwrap();
438        assert!(svg.contains(&format!("water = {value:.2}")));
439        // Every element that opens is closed.
440        assert_eq!(svg.matches("<text").count(), svg.matches("</text>").count());
441        // The structure alone draws no numbers and says why.
442        let structure = rules().graph_svg(None).unwrap();
443        assert!(structure.contains("Structure only") && !structure.contains("water ="));
444    }
445
446    #[test]
447    fn draws_a_missing_probability_as_missing() {
448        // `rain` gives nothing for Scarce; the term reads Regular, so the rules still evaluate.
449        let mut reply = reply();
450        let crate::Answer::Score(rain) = reply.answers.get_mut("rain").unwrap() else { unreachable!() };
451        rain.probabilities = [(1, 1.0)].into();
452        let text = rules().graph_text(Some(&reply)).unwrap();
453        assert!(text.contains("rain: Scarce missing · [Regular 1.00]"), "{text}");
454        let svg = rules().graph_svg(Some(&reply)).unwrap();
455        assert!(svg.contains(">missing<"));
456        // A Score's expected level is its own marker, not a reading of the levels.
457        assert!(svg.contains(">expected 1.40<"), "the temp marker is missing");
458    }
459
460    #[test]
461    fn a_missing_answer_is_an_error() {
462        let mut reply = reply();
463        reply.answers.remove("raining");
464        assert!(rules().graph_text(Some(&reply)).is_err());
465        assert!(rules().graph_svg(Some(&reply)).is_err());
466    }
467}