1use std::collections::BTreeMap;
11use std::fmt::Write as _;
12
13use super::{Expr, Kind, Rules, Then};
14use crate::DecisionResponse;
15
16#[derive(Debug, Clone, PartialEq)]
18pub(super) struct Node {
19 pub(super) label: String,
21 pub(super) how: &'static str,
23 pub(super) value: Option<f64>,
24 pub(super) children: Vec<Node>,
25 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 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 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
78fn 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
94pub(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 pub(super) score: Option<f64>,
103}
104
105impl Rules {
106 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 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 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 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 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 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 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 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 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
309pub(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
318fn 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 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 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 assert_eq!(svg.matches("<text").count(), svg.matches("</text>").count());
442 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}