lmjtfy.git / apps / lmjtfy / src / diagram.rs
1//! The rules as a picture: the Rete network, left to right, with what is
2//! known about this query marked on it. Pure: a network and what is known
3//! in, SVG out.
4//!
5//! Columns: the facts, the tests on them (alpha nodes), the joins of tests
6//! (one column per depth), and the rules. A rule's first test is its own
7//! first join, so only joins of two or more tests are drawn as nodes.
8//!
9//! What the picture has to say, beyond the wiring:
10//! - which facts came in one request (the frames around the facts);
11//! - what Jev actually answered (the number on each fact);
12//! - what, of everything known, a rule that holds stands on (filled), and
13//!   what was learned and not needed (outlined);
14//! - the order the rules decided in (the number on a rule);
15//! - that a rule's effect teaches a fact (the line from a rule back round
16//!   to the fact, which is the loop the engine runs).
17
18use std::collections::BTreeSet;
19
20use maud::{Markup, html};
21use rules::{Effect, End, Fact, Known, Network, Note, Source, State, Test, Then, Want};
22
23const ROW: i32 = 28;
24const TOP: i32 = 32;
25const BOX_H: i32 = 18;
26const FACT_X: i32 = 0;
27const FACT_W: i32 = 150;
28const ALPHA_X: i32 = 184;
29const ALPHA_W: i32 = 74;
30const JOIN_X: i32 = 294;
31const JOIN_STEP: i32 = 40;
32const JOIN_R: i32 = 8;
33const RULE_W: i32 = 262;
34/// Room round the network for the lines that run from a rule back to the
35/// fact it teaches: one lane per such fact.
36const GUTTER: i32 = 30;
37const LANE: i32 = 9;
38/// A frame's room above its first fact, for its caption, and round its sides.
39const CAPTION: i32 = 15;
40const PAD: i32 = 5;
41
42fn class(state: State, used: bool) -> &'static str {
43    match (state, used) {
44        (State::Holds, true) => "holds",
45        // Known to hold, and no rule that holds needed it.
46        (State::Holds, false) => "spare",
47        (State::Fails, _) => "fails",
48        (State::Waits, _) => "waits",
49    }
50}
51
52fn then(then: Then) -> &'static str {
53    match then {
54        Then::End(End::NotAQuestion) => "say no",
55        Then::Note(Note::Show(Fact::Yes)) => "show Jev's yes or no",
56        Then::Note(Note::Show(Fact::Degree)) => "show Jev's degree",
57        Then::Note(Note::Show(Fact::Chance)) => "show Jev's probability",
58        Then::Note(Note::Show(_)) => "show them",
59        Then::Note(Note::List) => "put it on the feed",
60        Then::Do(Effect::Draft(Want::Options)) => "ask the LLM for options",
61        Then::Do(Effect::Draft(Want::Scale)) => "ask the LLM for a scale",
62        Then::Do(Effect::Draft(Want::Split)) => "ask the LLM to split it",
63        Then::Do(Effect::Judge) => "ask Jev",
64    }
65}
66
67/// Something on the diagram that can be clicked, in the playground.
68#[derive(Clone, Copy)]
69pub enum Clicked {
70    Fact(Fact),
71    Test(Test),
72}
73
74/// The first free row at or after `y` in a column, so two nodes that want
75/// the same place do not sit on each other.
76fn place(taken: &mut BTreeSet<i32>, mut y: i32) -> i32 {
77    while !taken.insert(y) {
78        y += ROW / 2;
79    }
80    y
81}
82
83/// The facts that arrive together, and what brings them: every Jev fact in
84/// one request (some share a question), and each effect's fact by its own
85/// call.
86fn frames() -> Vec<(String, Vec<Fact>)> {
87    let jev: Vec<Fact> = Fact::ALL.into_iter().filter(|fact| fact.source() == Source::Jev).collect();
88    let mut questions: Vec<&str> = jev.iter().map(|fact| ask::question_id(*fact)).collect();
89    questions.dedup();
90    let mut frames = vec![(format!("jev · {} questions, one request", questions.len()), jev)];
91    for fact in Fact::ALL {
92        match fact.source() {
93            Source::Jev => {}
94            Source::Llm => frames.push(("llm · 1 call".to_owned(), vec![fact])),
95            Source::JevAgain => frames.push(("jev · 1 request".to_owned(), vec![fact])),
96        }
97    }
98    frames
99}
100
101/// The network with `known` marked on it. `fired` is the rules that decided
102/// a step, in order, as indices into the network's terminals. `shown` is
103/// what to print for a known fact: Jev's own number, where there is one.
104/// `link` is where a test leads when it is clicked, for the playground.
105pub fn rete(
106    network: &Network,
107    known: &Known,
108    fired: &[usize],
109    shown: impl Fn(Fact) -> Option<String>,
110    link: impl Fn(Clicked) -> Option<String>,
111) -> Markup {
112    let linked = |clicked: Clicked, node: Markup| match link(clicked) {
113        Some(href) => html! { a href=(href) { (node) } },
114        None => node,
115    };
116    // Alphas in fact order, so a fact's tests sit together beside it.
117    let mut order: Vec<usize> = (0..network.alphas.len()).collect();
118    order.sort_by_key(|&alpha| network.alphas[alpha].fact());
119    let mut alpha_y = vec![0; network.alphas.len()];
120    for (row, &alpha) in order.iter().enumerate() {
121        alpha_y[alpha] = TOP + row as i32 * ROW;
122    }
123    let fact_y = |fact: Fact| {
124        let ys: Vec<i32> =
125            (0..network.alphas.len()).filter(|&a| network.alphas[a].fact() == fact).map(|a| alpha_y[a]).collect();
126        (!ys.is_empty()).then(|| ys.iter().sum::<i32>() / ys.len() as i32)
127    };
128
129    let depth = network.joins.iter().map(|join| join.depth).max().unwrap_or(1);
130    let join_x = |d: usize| JOIN_X + (d as i32 - 2) * JOIN_STEP;
131    let rule_x = join_x(depth) + JOIN_STEP;
132    let width = rule_x + RULE_W;
133    let height = TOP + network.alphas.len() as i32 * ROW - ROW / 2;
134
135    // Where each join's output leaves from: an alpha's right edge for a
136    // rule's first test, the join's own node otherwise.
137    let mut columns: Vec<BTreeSet<i32>> = vec![BTreeSet::new(); depth + 1];
138    let mut out: Vec<(i32, i32)> = Vec::with_capacity(network.joins.len());
139    for join in &network.joins {
140        out.push(if join.depth == 1 {
141            (ALPHA_X + ALPHA_W, alpha_y[join.alpha])
142        } else {
143            (join_x(join.depth), place(&mut columns[join.depth], alpha_y[join.alpha]))
144        });
145    }
146    let mut rule_rows = BTreeSet::new();
147    let rule_y: Vec<i32> = network.terminals.iter().map(|t| place(&mut rule_rows, out[t.join].1)).collect();
148
149    let (used_joins, used_alphas) = network.used(known);
150    let alpha_class = |alpha: usize| class(network.alpha(alpha, known), used_alphas[alpha]);
151    let join_class = |join: usize| class(network.join(join, known), used_joins[join]);
152    let fact_class = |fact: Fact| match known.get(fact) {
153        None => "waits",
154        Some(_) if (0..network.alphas.len()).any(|a| network.alphas[a].fact() == fact && used_alphas[a]) => "holds",
155        Some(_) => "spare",
156    };
157
158    // Each effect's fact has a lane: out to the right of its rules, under
159    // the network, and up the left to the fact.
160    let taught: Vec<Fact> = Fact::ALL.into_iter().filter(|fact| fact.source() != Source::Jev).collect();
161    let lane = |fact: Fact| taught.iter().position(|taught| *taught == fact).map(|lane| 10 + lane as i32 * LANE);
162
163    html! {
164        div .rete {
165            svg viewBox={ (-GUTTER) " 0 " (width + 2 * GUTTER) " " (height + GUTTER) } role="img"
166                aria-label="The rules as a Rete network" {
167                // Frames and lines first, so the nodes are drawn over them.
168                @for (caption, facts) in frames() {
169                    @let ys: Vec<i32> = facts.iter().filter_map(|fact| fact_y(*fact)).collect();
170                    @if let (Some(top), Some(bottom)) = (ys.iter().min(), ys.iter().max()) {
171                        @let top = top - BOX_H / 2 - CAPTION;
172                        g .frame {
173                            rect x=(FACT_X - PAD) y=(top) width=(FACT_W + 2 * PAD)
174                                height=(bottom + BOX_H / 2 + PAD - top) {}
175                            text x=(FACT_X) y=(top + 11) { (caption) }
176                        }
177                    }
178                }
179                @for (alpha, test) in network.alphas.iter().enumerate() {
180                    @if let Some(y) = fact_y(test.fact()) {
181                        line class=(alpha_class(alpha)) x1=(FACT_X + FACT_W + PAD) y1=(y) x2=(ALPHA_X) y2=(alpha_y[alpha]) {}
182                    }
183                }
184                // The lines into a join that a fired rule stands on are drawn
185                // last, over the ones beside them: several joins share the
186                // line that brings what came before.
187                @for last in [false, true] {
188                    @for (index, join) in network.joins.iter().enumerate() {
189                        @if let (Some(left), true) = (join.left, used_joins[index] == last) {
190                            @let (x, y) = out[index];
191                            @let (from_x, from_y) = out[left];
192                            @let from_x = if network.joins[left].depth == 1 { from_x } else { from_x + JOIN_R };
193                            @let edge = if from_y < y { y - JOIN_R } else { y + JOIN_R };
194                            // What came before arrives from above or below;
195                            // the test joined here arrives from the left.
196                            path class=(class(network.join(left, known), last))
197                                d={ "M" (from_x) " " (from_y) " H" (x) " V" (edge) } {}
198                            line class=(class(network.alpha(join.alpha, known), last))
199                                x1=(ALPHA_X + ALPHA_W) y1=(alpha_y[join.alpha]) x2=(x - JOIN_R) y2=(y) {}
200                        }
201                    }
202                }
203                @for (terminal, y) in network.terminals.iter().zip(&rule_y) {
204                    @let (x, from_y) = out[terminal.join];
205                    @let x = if network.joins[terminal.join].depth == 1 { x } else { x + JOIN_R };
206                    line class=(join_class(terminal.join)) x1=(x) y1=(from_y) x2=(rule_x) y2=(*y) {}
207                    @if let Then::Do(effect) = terminal.then {
208                        @let fact = effect.teaches();
209                        @if let (Some(to), Some(lane)) = (fact_y(fact), lane(fact)) {
210                            @let tip = FACT_X - PAD;
211                            g class={ "back " (join_class(terminal.join)) } {
212                                path d={
213                                    "M" (width) " " (*y) " H" (width + lane) " V" (height + lane)
214                                    " H" (-lane - PAD) " V" (to) " H" (tip - 5)
215                                } {}
216                                polygon points={ (tip) "," (to) " " (tip - 6) "," (to - 3) " " (tip - 6) "," (to + 3) } {}
217                            }
218                        }
219                    }
220                }
221
222                @for fact in Fact::ALL {
223                    @if let Some(y) = fact_y(fact) {
224                        (linked(Clicked::Fact(fact), html! {
225                            g class={ "fact " (fact_class(fact)) } {
226                                rect x=(FACT_X) y=(y - BOX_H / 2) width=(FACT_W) height=(BOX_H) {}
227                                text x=(FACT_X + 6) y=(y + 4) { (fact.name()) }
228                                @if let Some(value) = known.get(fact) {
229                                    text x=(FACT_X + FACT_W - 6) y=(y + 4) text-anchor="end" {
230                                        (shown(fact).unwrap_or_else(|| value.label().to_owned()))
231                                    }
232                                }
233                            }
234                        }))
235                    }
236                }
237                @for (alpha, test) in network.alphas.iter().enumerate() {
238                    (linked(Clicked::Test(*test), html! {
239                        g class={ "alpha " (alpha_class(alpha)) } {
240                            rect x=(ALPHA_X) y=(alpha_y[alpha] - BOX_H / 2) width=(ALPHA_W) height=(BOX_H) {}
241                            text x=(ALPHA_X + 6) y=(alpha_y[alpha] + 4) { (test.label()) }
242                        }
243                    }))
244                }
245                @for (index, join) in network.joins.iter().enumerate() {
246                    @if join.depth > 1 {
247                        g class={ "join " (join_class(index)) } {
248                            circle cx=(out[index].0) cy=(out[index].1) r=(JOIN_R) {}
249                            text x=(out[index].0) y=(out[index].1 + 4) text-anchor="middle" { "&" }
250                        }
251                    }
252                }
253                @for (index, (terminal, y)) in network.terminals.iter().zip(&rule_y).enumerate() {
254                    @let nth = fired.iter().position(|fired| *fired == index);
255                    g class={ "rule " (join_class(terminal.join)) @if nth.is_some() { " fired" } } {
256                        rect x=(rule_x) y=(y - BOX_H / 2) width=(RULE_W) height=(BOX_H) {}
257                        text x=(rule_x + 6) y=(y + 4) { (terminal.name) " → " (then(terminal.then)) }
258                        @if let Some(nth) = nth {
259                            rect .nth x=(width - BOX_H) y=(y - BOX_H / 2) width=(BOX_H) height=(BOX_H) {}
260                            text .nth x=(width - BOX_H / 2) y=(y + 4) text-anchor="middle" { (nth + 1) }
261                        }
262                    }
263                }
264            }
265        }
266    }
267}
268
269/// The rules that decided a step, in order, numbered, for the title bar.
270pub fn sequence(network: &Network, fired: &[usize]) -> String {
271    let steps: Vec<String> =
272        fired.iter().enumerate().map(|(nth, rule)| format!("{} {}", nth + 1, network.terminals[*rule].name)).collect();
273    steps.join(" · ")
274}
275
276/// The rules on their own, as an SVG file: `/rules.svg`, for the READMEs to
277/// show the network the site really runs. Nothing is known, so every node
278/// waits. The page's diagram styles come with it, since an image is drawn
279/// without the page's stylesheet.
280pub fn standalone(network: &Network) -> String {
281    let drawn = rete(network, &Known::default(), &[], |_| None, |_| None).into_string();
282    let svg = drawn.trim_start_matches(r#"<div class="rete">"#).trim_end_matches("</div>");
283    let mut style = String::from(
284        "svg { --ink: #1e1e1e; --paper: #fefefe; --pink: #f386a1; --mono: \"JetBrains Mono\", ui-monospace, monospace; \
285         background: #fefefe; font: 11px var(--mono); }\n",
286    );
287    // The page's `.rete` rules, aimed at this file's own elements.
288    for rule in include_str!("page.css").lines().filter(|line| line.starts_with(".rete ") && !line.starts_with(".rete svg")) {
289        style.push_str(&rule.replace(".rete ", ""));
290        style.push('\n');
291    }
292    svg.replacen("<svg ", &format!(r#"<svg xmlns="http://www.w3.org/2000/svg" "#), 1).replacen(
293        r#"aria-label="The rules as a Rete network">"#,
294        &format!(r#"aria-label="The rules as a Rete network"><style>{style}</style>"#),
295        1,
296    )
297}
298
299#[cfg(test)]
300mod tests {
301    #[test]
302    fn the_rules_stand_alone_as_an_svg_file() {
303        let svg = super::standalone(&rules::Network::lmjtfy());
304        assert!(svg.starts_with(r#"<svg xmlns="http://www.w3.org/2000/svg" "#), "{}", &svg[..80]);
305        assert!(svg.ends_with("</svg>"));
306        assert!(svg.contains("<style>") && svg.contains("g.holds rect"), "the page's styles come with it");
307        assert!(!svg.contains(".rete"), "its selectors are its own");
308        assert!(!svg.contains("<a "), "nothing in it links");
309    }
310
311    use rules::{Kind, Next, Value};
312
313    use super::*;
314
315    fn none(_: Fact) -> Option<String> {
316        None
317    }
318
319    fn unlinked(_: Clicked) -> Option<String> {
320        None
321    }
322
323    /// What Jev's one request teaches, with `reads` the kinds that hold.
324    fn facts(reads: &[Kind], scale: bool) -> Known {
325        let mut known = Known::default();
326        known.learn(Fact::Answerable, Value::Bool(true));
327        known.learn(Fact::Several, Value::Bool(false));
328        known.learn(Fact::Scale, Value::Bool(scale));
329        known.learn(Fact::Yes, Value::Given);
330        known.learn(Fact::Degree, Value::Given);
331        known.learn(Fact::Chance, Value::Given);
332        known.learn(Fact::Fit, Value::Bool(true));
333        for kind in Kind::ALL {
334            known.learn(Fact::Reads(kind), Value::Bool(reads.contains(&kind)));
335        }
336        known
337    }
338
339    /// Does what the engine says until the query ends, as the Worker does.
340    fn run(network: &Network, known: &mut Known) -> Vec<usize> {
341        let mut fired = Vec::new();
342        loop {
343            let (by, next) = network.decide(known);
344            fired.extend(by);
345            match next {
346                Next::Do(effect) => known.learn(effect.teaches(), Value::Bool(true)),
347                _ => return fired,
348            }
349        }
350    }
351
352    #[test]
353    fn nothing_known_draws_every_node_waiting() {
354        let network = Network::lmjtfy();
355        let svg = rete(&network, &Known::default(), &[], none, unlinked).into_string();
356        assert_eq!(svg.matches(r#"<g class="alpha waits">"#).count(), network.alphas.len());
357        assert_eq!(svg.matches(r#"<g class="rule waits">"#).count(), network.terminals.len());
358        assert_eq!(svg.matches(r#"<g class="fact waits">"#).count(), Fact::ALL.len());
359        assert!(!svg.contains("holds") && !svg.contains("spare") && !svg.contains("fired"));
360    }
361
362    #[test]
363    fn the_jev_facts_are_framed_as_one_request() {
364        let svg = rete(&Network::lmjtfy(), &Known::default(), &[], none, unlinked).into_string();
365        // Eleven facts from eight questions: the four readings are one.
366        assert!(svg.contains("jev · 8 questions, one request"), "{svg}");
367        assert!(svg.contains("llm · 1 call"));
368        assert_eq!(svg.matches(r#"<g class="frame">"#).count(), 3);
369    }
370
371    #[test]
372    fn a_yes_or_no_question_lights_its_rule_and_fails_the_drafting_ones() {
373        let network = Network::lmjtfy();
374        let mut all = facts(&[Kind::Noul], false);
375        let fired = run(&network, &mut all);
376        let svg = rete(&network, &all, &fired, none, unlinked).into_string();
377        // Nothing decided a step: the answer came with the facts.
378        assert!(fired.is_empty());
379        // "answer yes or no" and "list it" hold.
380        assert_eq!(svg.matches(r#"<g class="rule holds">"#).count(), 2);
381        assert!(svg.contains("answer yes or no → show Jev"));
382        // several and scale were learned, and no rule that holds needed... several is needed (= no).
383        assert!(svg.contains(r#"<g class="fact spare">"#));
384    }
385
386    #[test]
387    fn a_pick_runs_three_steps_and_they_are_numbered() {
388        let network = Network::lmjtfy();
389        let mut all = facts(&[Kind::Choice], false);
390        let fired = run(&network, &mut all);
391        let svg = rete(&network, &all, &fired, none, unlinked).into_string();
392        assert_eq!(sequence(&network, &fired), "1 draft options · 2 judge the drafts");
393        assert_eq!(svg.matches(r#" fired">"#).count(), 2);
394        assert!(svg.contains(r#"<text class="nth""#));
395        // yes and degree were asked for, and no rule that holds used them.
396        assert!(svg.matches(r#"<g class="fact spare">"#).count() >= 2);
397    }
398
399    #[test]
400    fn facts_and_tests_are_links_when_they_are_given_one() {
401        let network = Network::lmjtfy();
402        let link = |clicked: Clicked| {
403            Some(match clicked {
404                Clicked::Fact(fact) => format!("/fact?{}", fact.name()),
405                Clicked::Test(test) => format!("/test?{}", test.fact().name()),
406            })
407        };
408        let svg = rete(&network, &Known::default(), &[], none, link).into_string();
409        assert_eq!(svg.matches(r#"<a href="/test?"#).count(), network.alphas.len());
410        assert_eq!(svg.matches(r#"<a href="/fact?"#).count(), Fact::ALL.len());
411    }
412
413    #[test]
414    fn a_fact_shows_jevs_number_when_there_is_one() {
415        let mut all = Known::default();
416        all.learn(Fact::Answerable, Value::Bool(true));
417        let shown = |fact: Fact| (fact == Fact::Answerable).then(|| "0.97".to_owned());
418        let svg = rete(&Network::lmjtfy(), &all, &[], shown, unlinked).into_string();
419        assert!(svg.contains(">0.97</text>"));
420        assert!(svg.contains(">answerable</text>"));
421    }
422
423    #[test]
424    fn every_rule_with_an_effect_has_a_line_back_to_the_fact_it_teaches() {
425        let network = Network::lmjtfy();
426        let svg = rete(&network, &Known::default(), &[], none, unlinked).into_string();
427        let effects = network.terminals.iter().filter(|t| matches!(t.then, Then::Do(_))).count();
428        assert_eq!(effects, 4);
429        assert_eq!(svg.matches(r#"<g class="back "#).count(), effects);
430    }
431
432    #[test]
433    fn no_two_nodes_in_a_column_share_a_place() {
434        let mut taken = BTreeSet::new();
435        assert_eq!(place(&mut taken, 20), 20);
436        assert_eq!(place(&mut taken, 20), 34);
437        assert_eq!(place(&mut taken, 20), 48);
438    }
439}