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