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.check(reply)?;
122 self.terms.iter().map(|(name, target)| Ok((name.clone(), target.degree(reply)?))).collect()
123 }
124
125 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 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 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 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 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 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 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
311pub(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
320fn 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 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 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 assert_eq!(svg.matches("<text").count(), svg.matches("</text>").count());
441 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 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 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}