whiskers.git / web / worker / src / explain.rs
1//! The part of the page that says where Whiskers uses Jev, with a diagram of a turn and real
2//! request bodies. Every fact on it is read from the code that does the using (`site::jev`,
3//! `whiskers_core::journey`, `whiskers_judge`), so editing the guard changes the page, and the
4//! tests below hold it to that.
5
6use jev_ui::components::{icons, window};
7use jev_ui::flow::spine;
8use jev_ui::wire::{Bar, Panel, bars, exchange};
9use crate::pic::{Row, facts};
10use maud::{Markup, html};
11use site::config::Pic;
12use site::jev::{EXAMPLE_AGE, Example, examples, rows};
13use whiskers_core::journey::{JEV_USES, NOT_JEV, TURN};
14use whiskers_core::{Direction, RefusalKind, Verdict};
15use whiskers_judge::{MIN_SUITABLE, MODEL, Topic};
16
17/// One icon for each thing Jev is not used for, in `NOT_JEV`'s order; the array's length is its
18/// own, so a new entry does not compile until it has one.
19const NOT_JEV_ICONS: [Pic; NOT_JEV.len()] = [Pic::Claude, Pic::Microphone, Pic::Picture, Pic::Magnifier, Pic::Clock, Pic::Book];
20
21fn verdict_words(verdict: &Verdict) -> String {
22    match verdict {
23        Verdict::Allow => "Allowed.".to_owned(),
24        Verdict::Refuse { reason, kind: RefusalKind::NeedsAGrownUp } => format!("Refused ({reason}). She is sent to a grown-up and the parents' log shows it first."),
25        Verdict::Refuse { reason, kind: RefusalKind::OffLimits } => format!("Refused ({reason}). The fixed line is spoken instead."),
26    }
27}
28
29fn kind_words(kind: RefusalKind) -> &'static str {
30    match kind {
31        RefusalKind::NeedsAGrownUp => "sends her to a grown-up",
32        RefusalKind::OffLimits => "a fixed line steers elsewhere",
33    }
34}
35
36fn direction_words(direction: Direction) -> &'static str {
37    match direction {
38        Direction::FromChild => "what she said",
39        Direction::ToChild => "what the model wrote",
40    }
41}
42
43fn example(n: usize, e: &Example) -> Markup {
44    let head = format!("POST {}\nauthorization: Bearer [the service's key]\ncontent-type: application/json", jev_protocol::ENDPOINT);
45    html! {
46        div .case {
47            p { strong { (direction_words(e.direction)) ": " } "\u{201c}" (e.text) "\u{201d}" }
48            p .fine { (e.why) }
49            (exchange(&icons::TOOL, &format!("jev \u{b7} check {n} \u{b7} 2 questions"), "", n == 1, &[
50                Panel { label: "request", head: Some(&head), body: &e.request },
51                Panel { label: "response, written for this page", head: None, body: &e.response },
52            ]))
53            p .verdict { (verdict_words(&e.verdict)) }
54        }
55    }
56}
57
58pub fn section() -> Markup {
59    let examples = examples();
60    let diagram = rows();
61    let summary = "A turn, step by step: where Jev is asked, and every way a turn can end in a fixed line instead of an answer.";
62    let bar_labels: Vec<String> = examples.iter().map(|e| format!("{} ({})", e.text, e.topic.label())).collect();
63    let bar_rows: Vec<Bar<'_>> = examples
64        .iter()
65        .zip(&bar_labels)
66        .map(|(e, label)| Bar { label, p: e.suitable, won: matches!(e.verdict, Verdict::Allow) })
67        .collect();
68    html! {
69        div .stack {
70            section #jev aria-labelledby="jev-h" {
71                (window(Some(&icons::JEV), html! { h2 #jev-h { "Where Jev is used" } }, None, true, html! {
72                    p {
73                        "Jev is TypeSafe\u{2019}s System One model. It does not write: it answers typed questions with a probability. "
74                        "In Whiskers it is the guard, and only the guard. These are all the places the code asks it something."
75                    }
76                    ol .uses {
77                        @for u in JEV_USES {
78                            li {
79                                strong { (u.id) }
80                                p { (u.asks) }
81                                p .fine { "If Jev cannot answer: " (u.on_failure) "." }
82                            }
83                        }
84                    }
85                    p .fine { "A check that cannot be made is never an allow. A failed or throttled check is a refusal." }
86                }))
87            }
88            section aria-labelledby="turn-h" {
89                (window(Some(&icons::TOOL), html! { h2 #turn-h { "One turn, from voice to voice" } }, None, true, html! {
90                    p { "Pink steps ask Jev. Each box on the right is a way the turn can end early, what the child hears instead, and what the parents\u{2019} log shows." }
91                    (spine("One turn of Whiskers", summary, &diagram))
92                    p .fine {
93                        "This is drawn from the list the core keeps of its own steps (" code { "whiskers-core/src/journey.rs" } "), and a test runs the real turn into every one of the "
94                        (TURN.iter().map(|s| s.exits.len()).sum::<usize>()) " endings shown, so the drawing cannot show a step or an ending the code does not have."
95                    }
96                }))
97            }
98            section aria-labelledby="check-h" {
99                (window(Some(&icons::JEV), html! { h2 #check-h { "One check: two questions, one decision" } }, None, true, html! {
100                    p {
101                        "Every message, in either direction, is put to Jev as two typed questions in a single request. A yes-or-no: is it entirely suitable for a child of her age? "
102                        "And a choice: what is it mainly about? The first returns one probability, the second a probability for each of nine topics. The age is in the question; her name never is."
103                    }
104                    div .topics role="table" aria-label="The nine topics" {
105                        @for (topic, label, what) in Topic::ALL {
106                            div role="row" .topic {
107                                span role="cell" .t { (label) }
108                                span role="cell" { (what) }
109                                span role="cell" .fine {
110                                    @if topic == Topic::Ordinary { "allowed, if sure enough" }
111                                    @else { (kind_words(topic.kind())) }
112                                }
113                            }
114                        }
115                    }
116                    p {
117                        "The decision is a small pure function, and nothing else decides: a message is allowed only if its topic is " strong { "ordinary" }
118                        " and Jev is at least " strong { (MIN_SUITABLE) } " sure it is suitable (the dashed tick below). Anything else is refused. "
119                        "What she says about being hurt or unsafe, or being asked to keep a secret from her parents, is refused in a way that sends her to a grown-up; "
120                        "what the model writes that touches those topics is simply withheld."
121                    }
122                    (bars(&bar_rows, Some((MIN_SUITABLE, "allowed from here, if the topic is ordinary"))))
123                    p .fine { "The three examples below, on that rule. The topic order is part of the question: Jev is known to favour earlier options, so it is not changed casually." }
124                }))
125            }
126            section aria-labelledby="ex-h" {
127                (window(Some(&icons::TOOL), html! { h2 #ex-h { "Three checks, with the real requests" } }, None, true, html! {
128                    p {
129                        "Sentences written for this page, asked for a " (EXAMPLE_AGE) "-year-old. Each request below is the exact body the guard builds, for model " code { (MODEL) } ", printed with whitespace added and nothing else. "
130                        strong { "The responses are not recorded: " }
131                        "this page never calls Jev, so the answers were written by hand in the shape Jev returns, with figures chosen for the example. They were checked by the same verifier the guard uses, which refuses a response that does not answer exactly the questions asked, and the verdict is the guard\u{2019}s own."
132                    }
133                    @for (i, e) in examples.iter().enumerate() { (example(i + 1, e)) }
134                }))
135            }
136            section aria-labelledby="not-h" {
137                (window(Some(&icons::JEV), html! { h2 #not-h { "Where Jev is not used" } }, None, true, html! {
138                    (facts("Where Jev is not used", NOT_JEV.iter().zip(NOT_JEV_ICONS).map(|((what, why), icon)| Row { icon, title: html! { (what) }, text: html! { (why) } }).collect()))
139                    p .fine { "Jev, and the service that holds its key, can be down or throttled. The tablet never holds the key. In that case no message is allowed through: she hears a fixed, kind line, and the parents\u{2019} log records the failure." }
140                }))
141            }
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use jev_ui::wire::printed_json;
150
151    fn page() -> String {
152        section().into_string()
153    }
154
155    fn unescape(text: &str) -> String {
156        text.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'").replace("&amp;", "&")
157    }
158
159    #[test]
160    fn each_printed_request_and_response_is_the_body_the_code_holds() {
161        let html = page();
162        for (i, e) in examples().iter().enumerate() {
163            let block = html.split("class=\"case\"").nth(i + 1).expect("a block per example");
164            let block = block.split("class=\"case\"").next().unwrap();
165            // The two JSON panels of this block, in order: request, then response.
166            let mut jsons = block.split("<pre class=\"json\">").skip(1).map(|part| part.split("</pre>").next().unwrap());
167            let request = jsons.next().expect("a printed request");
168            let response = jsons.next().expect("a printed response");
169            let sent: serde_json::Value = serde_json::from_str(&e.request).unwrap();
170            assert_eq!(printed_json(request).expect("the request prints as JSON"), sent);
171            // Nothing but whitespace was added: the keys are in the order the code sent them.
172            assert_eq!(serde_json::to_string(&printed_json(request).unwrap()).unwrap(), e.request);
173            assert_eq!(serde_json::to_string(&printed_json(response).unwrap()).unwrap(), e.response);
174        }
175    }
176
177    #[test]
178    fn the_page_lists_the_guards_topics_and_threshold_and_cannot_list_others() {
179        let html = unescape(&page());
180        for (_, label, what) in Topic::ALL {
181            assert!(html.contains(label), "{label}");
182            assert!(html.contains(what), "{label}'s description");
183        }
184        assert_eq!(page().matches("class=\"topic\"").count(), Topic::ALL.len());
185        assert!(html.contains(&format!("at least <strong>{MIN_SUITABLE}</strong>")));
186        assert!(html.contains(&format!("left: {:.1}%", MIN_SUITABLE * 100.0)), "the tick is where the guard cuts");
187    }
188
189    #[test]
190    fn the_diagram_and_the_list_of_uses_are_the_codes() {
191        let html = page();
192        for step in TURN {
193            assert!(html.contains(&format!("data-id=\"{}\"", step.id)), "{}", step.id);
194        }
195        assert_eq!(html.matches("class=\"exit\"").count(), TURN.iter().map(|s| s.exits.len()).sum::<usize>());
196        assert_eq!(html.matches("class=\"uses\"").count(), 1);
197        for u in JEV_USES {
198            assert!(unescape(&html).contains(u.asks), "{}", u.id);
199        }
200        for (what, _) in NOT_JEV {
201            assert!(html.contains(what), "{what}");
202        }
203    }
204
205    #[test]
206    fn it_says_plainly_where_the_responses_came_from_and_what_a_failure_does() {
207        let html = unescape(&page());
208        assert!(html.contains("The responses are not recorded"));
209        assert!(html.contains("never calls Jev"));
210        assert!(html.contains("A check that cannot be made is never an allow"));
211        assert!(html.contains("she hears a fixed, kind line"));
212    }
213
214    #[test]
215    fn the_example_sentences_hold_no_name_no_address_and_nothing_graphic() {
216        // The sentences and their captions only: the request also carries the guard's own topic
217        // descriptions, which name what it looks for.
218        for e in examples() {
219            let said = format!("{} {}", e.text, e.why).to_lowercase();
220            for bad in ["http", "@", "street", "phone", "kill", "blood", "gun", "hurt", "die"] {
221                assert!(!said.contains(bad), "{bad}");
222            }
223        }
224    }
225}