lmjtfy.git / apps / lmjtfy / src / view.rs
view.rsannotatedview.rssource1315 lines · 51.2 KB · raw
1//! The page and the transcript, as HTML. Pure: a `View` in, markup out.
2
3use crate::diagram;
4
5pub mod code;
6pub mod toast;
7
8use std::time::Duration;
9
10use archive::{Answer, Detail, Entry, Home, Stats, when};
11use ask::{Judged, Outcome, Prepared, Sent};
12use jev_protocol::{ChoiceAnswer, ScoreAnswer};
13use llm::{Draft, ToolCall};
14use maud::{DOCTYPE, Markup, PreEscaped, html};
15use rules::{Fact, Kind, Known, Network};
16use serde_json::Value;
17
18const CSS: &str = include_str!("page.css");
19const JS: &str = include_str!("page.js");
20// The nearest open faces to typesafe.ai's own: Inter Tight for its grotesk,
21// VT323 for its pixel terminal face, and JetBrains Mono, which it also uses.
22const FONTS: &str = "https://fonts.googleapis.com/css2?family=Inter+Tight:wght@500&family=JetBrains+Mono:wght@400;500&family=VT323&display=swap";
23
24/// Pixel icons for the window title bars, 12 by 12, `#` for ink.
25mod icons {
26    pub const ASK: [&str; 12] = [
27        "............", "..#####.....", ".#.....#....", ".#.....#....", ".#.....#....", ".#.....#....",
28        "..#####.....", ".......##...", "........##..", ".........##.", "..........#.", "............",
29    ];
30    pub const JEV: [&str; 12] = [
31        ".....##.....", "....#.##....", "...#.#.##...", "..#.#.#.##..", ".#.#.#.#.##.", "#.#.#.#.####",
32        "####.#.#.#.#", ".##.#.#.#.#.", "..##.#.#.#..", "...##.#.#...", "....##.#....", ".....##.....",
33    ];
34    pub const LLM: [&str; 12] = [
35        "............", ".#########..", ".#.......#..", ".#.##.##.#..", ".#.......#..", ".#.#####.#..",
36        ".#.......#..", ".#########..", "...##.......", "..##........", ".#..........", "............",
37    ];
38    pub const TOOL: [&str; 12] = [
39        "............", ".......###..", "......##....", "......##..#.", "......#####.", ".....####...",
40        "....###.....", "...###......", "..###.......", ".###........", ".##.........", "............",
41    ];
42}
43
44/// A pixel icon as inline SVG: one square per `#`.
45fn icon(rows: &[&str; 12], class: &str) -> Markup {
46    html! {
47        svg class={ "icon " (class) } viewBox="0 0 12 12" aria-hidden="true" {
48            @for (y, row) in rows.iter().enumerate() {
49                @for (x, cell) in row.bytes().enumerate() {
50                    @if cell == b'#' { rect x=(x) y=(y) width="1" height="1" fill="currentColor" {} }
51                }
52            }
53        }
54    }
55}
56
57/// One request to Jev, before and after its response. It may ask several
58/// questions.
59pub struct JevCall {
60    /// What the request is for: `facts` or `answers`.
61    pub id: &'static str,
62    /// Each question's id and Jev type, in the order asked.
63    pub parts: Vec<(String, &'static str)>,
64    /// The request body as sent.
65    pub request: String,
66    /// `None` while Jev is still answering.
67    pub outcome: Option<Outcome>,
68    /// Which of the request's kept responses this is, once one is.
69    pub kept: Option<Versions>,
70}
71
72/// A call's place among the responses its request got: version `version` of
73/// `versions`, and its name for the page's ↻ and ◀ ▶ (`archive::call_id`).
74#[derive(Clone, Debug, PartialEq)]
75pub struct Versions {
76    pub id: String,
77    pub version: u32,
78    pub versions: u32,
79}
80
81impl JevCall {
82    pub fn pending(prepared: &Prepared) -> Self {
83        JevCall {
84            id: prepared.id,
85            parts: prepared.parts.iter().map(|part| (part.id.clone(), part.kind)).collect(),
86            request: prepared.request.clone(),
87            outcome: None,
88            kept: None,
89        }
90    }
91
92    /// The answer to the question called `id`, if Jev gave one.
93    pub fn judged(&self, id: &str) -> Option<&Judged> {
94        let index = self.parts.iter().position(|(part, _)| part == id)?;
95        match &self.outcome {
96            Some(Outcome::Answered { judged, .. }) => judged.get(index),
97            _ => None,
98        }
99    }
100}
101
102/// The call to the LLM that writes Jev's questions.
103pub struct LlmCall {
104    pub model: &'static str,
105    /// The request body as sent.
106    pub request: String,
107    /// `None` while the LLM is still writing.
108    pub outcome: Option<LlmOutcome>,
109    /// Which of the request's kept responses this is, once one is.
110    pub kept: Option<Versions>,
111}
112
113pub struct LlmOutcome {
114    /// The reply as it came, or the error in its place.
115    pub body: String,
116    /// What the reply cost when it was sent.
117    pub neurons: f64,
118    pub took: Duration,
119    /// Tool calls the LLM made past the cap, which were not run.
120    pub dropped: usize,
121    /// Whether this ask sent the request, or was given the reply kept from
122    /// the first time.
123    pub sent: Sent,
124}
125
126/// One tool call the LLM made.
127pub struct Tool {
128    /// Its question's id in the request to Jev: `q1`, `q2`, ...
129    pub id: String,
130    pub call: ToolCall,
131    /// Why Jev's own protocol refused a call whose shape was right.
132    pub refused: Option<String>,
133}
134
135impl Tool {
136    /// The question to send to Jev, if the call is one.
137    pub fn draft(&self) -> Option<&Draft> {
138        self.call.draft.as_ref().ok().filter(|_| self.refused.is_none())
139    }
140
141    /// Why this call was not sent to Jev, if it was not.
142    fn problem(&self) -> Option<&str> {
143        match &self.call.draft {
144            Err(error) => Some(error),
145            Ok(_) => self.refused.as_deref(),
146        }
147    }
148}
149
150/// How the exchange ended.
151pub enum Ending {
152    /// Nothing was typed; nothing was sent.
153    Empty,
154    /// The gate said this is not something Jev can judge.
155    NotAQuestion { p_yes: f64 },
156    /// Jev answered: the input itself, read one way or two, or what the LLM
157    /// wrote. Which, is in the rules' notes.
158    Answered,
159    /// This visitor has asked too much in the last minute. Nothing was sent.
160    Slow,
161    /// The LLM replied, and nothing in the reply was a question Jev answered.
162    NoQuestion,
163    /// A call failed, or could not be built.
164    Failed { error: String },
165    /// The Worker has no API key.
166    Offline,
167    /// Today's shared budget for `what` is used up.
168    Spent { what: &'static str },
169    /// The page is looking at an old version of a call, and the call after
170    /// it was never sent: nothing is sent while looking, so it waits for ↻.
171    NotKept { id: String },
172}
173
174/// One answer in the transcript.
175struct Shown<'a> {
176    /// The question it answers: the input, or what the LLM wrote.
177    question: &'a str,
178    judged: &'a Judged,
179    /// How the input was read, when it was read more than one way.
180    read_as: Option<&'static str>,
181    /// A how-likely: the answer is the probability itself, not a yes or no.
182    chance: bool,
183}
184
185impl Shown<'_> {
186    /// The answer as it is kept for the feed and for link previews.
187    fn told(&self) -> Answer {
188        let detail = match self.judged {
189            Judged::Noul(p) if self.chance => Detail::Chance { p: *p },
190            Judged::Noul(p_yes) => Detail::Noul { p_yes: *p_yes },
191            Judged::Choice(answer) => {
192                let mut options = answer.probabilities.clone();
193                options.sort_by(|a, b| b.1.total_cmp(&a.1));
194                Detail::Choice { confidence: answer.confidence, options }
195            }
196            Judged::Score(answer) => Detail::Score {
197                score: answer.score,
198                confidence: answer.confidence,
199                levels: answer.legend.iter().cloned().zip(answer.probabilities.iter().copied()).collect(),
200            },
201        };
202        Answer { headline: self.headline(), detail }
203    }
204
205    /// The answer in a few words: what the transcript prints large.
206    fn headline(&self) -> String {
207        match self.judged {
208            Judged::Noul(p) if self.chance => format!("{}.", percent(*p)),
209            judged => headline(judged),
210        }
211    }
212}
213
214/// Everything the transcript shows.
215pub struct View {
216    pub input: String,
217    /// What is known about the input so far: what the rules run on.
218    pub known: Known,
219    /// The rules that decided a step, in order (indices into the network's
220    /// terminals).
221    pub fired: Vec<usize>,
222    /// The request for the facts the rules wanted.
223    pub facts: Option<JevCall>,
224    pub llm: Option<LlmCall>,
225    pub tools: Vec<Tool>,
226    /// The request that asks Jev every question the LLM wrote.
227    pub judging: Option<JevCall>,
228    /// `None` while calls are still being made.
229    pub ending: Option<Ending>,
230    /// Which version of each call the page asked for (`$pins`).
231    pub pins: archive::Pins,
232}
233
234impl View {
235    pub fn new(input: String) -> Self {
236        View {
237            input,
238            known: Known::default(),
239            fired: Vec::new(),
240            facts: None,
241            llm: None,
242            tools: Vec::new(),
243            judging: None,
244            ending: None,
245            pins: archive::Pins::default(),
246        }
247    }
248
249    /// `$pins` once this view is shown: every call shown at a version other
250    /// than its newest, so the page's next ↻ or ◀ ▶ keeps them as they are.
251    pub fn pinned(&self) -> String {
252        let kept = [self.facts.as_ref().and_then(|call| call.kept.as_ref()), self.llm.as_ref().and_then(|call| call.kept.as_ref()), self.judging.as_ref().and_then(|call| call.kept.as_ref())];
253        let pins: Vec<String> = kept.into_iter().flatten().filter(|kept| kept.version < kept.versions).map(|kept| format!("{}:{}", kept.id, kept.version)).collect();
254        pins.join(";")
255    }
256
257    /// How many of this view's calls were sent for it, and how many were
258    /// answered from what was kept.
259    pub fn calls(&self) -> (u32, u32) {
260        let jev = [&self.facts, &self.judging].into_iter().flatten().filter_map(|call| match &call.outcome {
261            Some(Outcome::Answered { sent, .. }) => Some(*sent),
262            _ => None,
263        });
264        let llm = self.llm.as_ref().and_then(|call| call.outcome.as_ref()).map(|outcome| outcome.sent);
265        let (mut sent, mut kept) = (0, 0);
266        for call in jev.chain(llm) {
267            match call {
268                Sent::Now => sent += 1,
269                Sent::Before { .. } => kept += 1,
270            }
271        }
272        (sent, kept)
273    }
274
275    fn fact(&self, fact: Fact) -> Option<&Judged> {
276        self.facts.as_ref()?.judged(ask::question_id(fact))
277    }
278
279    fn judged(&self, tool: &Tool) -> Option<&Judged> {
280        self.judging.as_ref()?.judged(&tool.id)
281    }
282
283    /// The kind Jev thinks the input most likely is.
284    pub fn reading(&self) -> Option<Kind> {
285        match self.fact(Fact::Reads(Kind::Noul))? {
286            Judged::Choice(answer) => ask::readings(answer).first().copied(),
287            _ => None,
288        }
289    }
290
291    /// What to print for a fact on the diagram: Jev's own number for a fact
292    /// it answered, a count for the others.
293    fn shown(&self, fact: Fact) -> Option<String> {
294        match fact {
295            Fact::Drafted => Some(self.tools.iter().filter(|tool| tool.draft().is_some()).count().to_string()),
296            Fact::Judged => Some(self.tools.iter().filter(|tool| self.judged(tool).is_some()).count().to_string()),
297            Fact::Reads(kind) => match self.fact(fact)? {
298                Judged::Choice(answer) => ask::kind_probability(answer, kind).map(probability),
299                _ => None,
300            },
301            _ => match self.fact(fact)? {
302                Judged::Noul(p_yes) => Some(probability(*p_yes)),
303                Judged::Score(answer) => Some(format!("{:.1}", answer.score)),
304                Judged::Choice(_) => None,
305            },
306        }
307    }
308
309    /// Whether Jev answered any question the LLM wrote.
310    pub fn any_judged(&self) -> bool {
311        self.tools.iter().any(|tool| self.judged(tool).is_some())
312    }
313
314    /// The answers the rules say to show, in rule order: the input itself as
315    /// Jev answered it with the facts (read as a yes-or-no, as a how-much, or
316    /// both when Jev was split), then what the LLM wrote.
317    fn results(&self) -> Vec<Shown<'_>> {
318        let mut results = Vec::new();
319        for fact in Network::lmjtfy().shows(&self.known) {
320            if fact == Fact::Judged {
321                results.extend(self.tools.iter().filter_map(|tool| {
322                    let question = tool.call.draft.as_ref().ok()?.instructions();
323                    Some(Shown { question, judged: self.judged(tool)?, read_as: None, chance: false })
324                }));
325            } else if let Some(judged) = self.fact(fact) {
326                let read_as = match fact {
327                    Fact::Yes => "read as a yes or no",
328                    Fact::Chance => "read as a how likely",
329                    _ => "read as a how much",
330                };
331                results.push(Shown { question: &self.input, judged, read_as: Some(read_as), chance: fact == Fact::Chance });
332            }
333        }
334        // The reading is worth saying only when there is another beside it.
335        if results.len() == 1 {
336            results[0].read_as = None;
337        }
338        results
339    }
340
341    /// Whether there is any answer to show.
342    pub fn answered(&self) -> bool {
343        !self.results().is_empty()
344    }
345
346    /// What Jev said to each question it answered, as it is kept for the
347    /// feed and for link previews.
348    pub fn said(&self) -> Vec<Answer> {
349        self.results().iter().map(Shown::told).collect()
350    }
351}
352
353/// What is used of the budgets every visitor shares, and their limits.
354pub struct Shared {
355    pub status: budget::Status,
356    pub neurons_per_day: f64,
357    pub jev_dollars_per_day: f64,
358}
359
360/// A whole number with thousands separators.
361fn grouped(n: f64) -> String {
362    let digits = format!("{:.0}", n.max(0.0));
363    let mut out = String::new();
364    for (i, digit) in digits.chars().enumerate() {
365        if i > 0 && (digits.len() - i) % 3 == 0 {
366            out.push(',');
367        }
368        out.push(digit);
369    }
370    out
371}
372
373/// The shared budgets, as a window everyone sees: what is left today, whose
374/// count it is, and that it is one pot. With no figures to show, it says so.
375pub fn budget(shared: Option<Shared>) -> Markup {
376    html! {
377        div #budget .win {
378            div .bar-title { (icon(&icons::JEV, "")) "Shared budget" span .meta { "resets 00:00 UTC" } }
379            div .body {
380                @if let Some(shared) = shared {
381                    @let left = (shared.neurons_per_day - shared.status.neurons).max(0.0);
382                    @let jev_left = (shared.jev_dollars_per_day - shared.status.jev_dollars).max(0.0);
383                    div .bars {
384                        div .bar.won {
385                            span .label { "LLM · free neurons left today" }
386                            span .pct { (grouped(left)) " of " (grouped(shared.neurons_per_day)) }
387                            span .track { span .thumb style=(at(left / shared.neurons_per_day)) {} }
388                        }
389                        div .bar.won {
390                            span .label { "Jev · dollars left today" }
391                            span .pct { (format!("${jev_left:.4} of ${:.2}", shared.jev_dollars_per_day)) }
392                            span .track { span .thumb style=(at(jev_left / shared.jev_dollars_per_day)) {} }
393                        }
394                    }
395                    p .fine {
396                        "Everyone who opens this page spends from the same two pots. When one is empty, "
397                        "the site stops answering until it resets. "
398                        @match shared.status.counted {
399                            budget::Counted::Account => "The neuron figure is Cloudflare's own count for the whole account.",
400                            budget::Counted::Own => "The neuron figure is this site's own count; Cloudflare's could not be read.",
401                        }
402                    }
403                } @else {
404                    p .fine { "The shared budget could not be read just now." }
405                }
406            }
407        }
408    }
409}
410
411/// A line of a feed. A line of "asked lately" has an id from its question,
412/// so the line a question already has is the one a live update moves to the
413/// top; "most asked" repeats some of those questions, and has none.
414fn entry(entry: &Entry, id: Option<String>) -> Markup {
415    html! {
416        li .entry id=[id] {
417            a href={ "/?q=" (url_encode(&entry.input)) } title=(when(entry.asked_ms)) { (entry.input) }
418            span .said-s {
419                (entry.headlines())
420                @if entry.times > 1 { span .times { " ×" (entry.times) } }
421            }
422        }
423    }
424}
425
426fn lately_entry(asked: &Entry) -> Markup {
427    entry(asked, Some(entry_id(&asked.input)))
428}
429
430/// The id of a question's line in the feed: FNV-1a of the question, so it
431/// is the same on every page and for every render.
432fn entry_id(input: &str) -> String {
433    let hash = input.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3));
434    format!("q-{hash:016x}")
435}
436
437/// The end of "asked lately". A full page may have more after it, so when it
438/// scrolls into view it asks `/feed` for the page after the last line shown,
439/// once: the answer replaces it, with a new end that asks again. A short
440/// page is the end of the feed, and asks nothing.
441fn more(shown: &[Entry]) -> Markup {
442    let after = (shown.len() == archive::FEED as usize).then(|| shown.last()).flatten();
443    html! {
444        @if let Some(last) = after {
445            li #more "data-on-intersect__once"={
446                "@get('/feed?ms=" (last.asked_ms) "&q=" (url_encode(&last.input)) "')"
447            } { span .meta { "more…" } }
448        } @else {
449            li #more hidden {}
450        }
451    }
452}
453
454/// The page of the feed after a scroll: its lines, which go before the end,
455/// and the new end.
456pub fn older(entries: &[Entry]) -> (Markup, Markup) {
457    (html! { @for asked in entries { (lately_entry(asked)) } }, more(entries))
458}
459
460/// A question just asked, for the top of every open page's feed.
461pub fn lately_top(entry: &Entry) -> Markup {
462    html! { ol hidden "data-prepend"="lately" { (lately_entry(entry)) } }
463}
464
465/// The most asked questions, replaced whole when they change.
466pub fn most(most: &[Entry]) -> Markup {
467    html! {
468        div #most {
469            @if !most.is_empty() {
470                span .label-s { "most asked" }
471                ol .feed { @for asked in most { (entry(asked, None)) } }
472            }
473        }
474    }
475}
476
477/// What people asked and what Jev said: lately, and most often. Each is a
478/// link that asks it again, which sends nothing: every call it needs is
479/// kept. Nothing about who asked is shown, and a question Jev judged unfit to show is not listed. "Asked
480/// lately" scrolls on for as long as there is feed (`more`). The window is
481/// there even when the feed is empty, and hidden by the style sheet until a
482/// line arrives, so a live update has somewhere to put the first one.
483pub fn feed(home: Option<&Home>) -> Markup {
484    let lately = home.map(|home| home.lately.as_slice()).unwrap_or_default();
485    let most_asked = home.map(|home| home.most.as_slice()).unwrap_or_default();
486    html! {
487        div #feed .win {
488            div .bar-title { (icon(&icons::ASK, "")) "Asked lately" span .meta { "by nobody in particular" } }
489            div .body {
490                ol #lately .feed { @for asked in lately { (lately_entry(asked)) } (more(lately)) }
491                (most(most_asked))
492            }
493        }
494    }
495}
496
497fn plural(n: u32, one: &str, many: &str) -> String {
498    format!("{} {}", grouped(f64::from(n)), if n == 1 { one } else { many })
499}
500
501/// The archive in numbers: how much Jev settled alone, and how many
502/// requests were never sent because their response was kept.
503pub fn tally(stats: Option<&Stats>) -> Markup {
504    let stats = stats.filter(|stats| stats.questions > 0);
505    html! {
506        div #tally .win[stats.is_some()] {
507            @if let Some(stats) = stats {
508                div .bar-title { (icon(&icons::JEV, "")) "So far" }
509                div .body {
510                    ul .tally {
511                        li {
512                            b { (plural(stats.questions, "question", "questions")) }
513                            " answered, asked " (plural(stats.asks, "time", "times")) "."
514                        }
515                        @if let Some(percent) = stats.no_llm_percent() {
516                            li { b { (percent) "%" } " of them Jev answered alone, with no LLM." }
517                        }
518                        li {
519                            b { (plural(stats.sent, "request", "requests")) }
520                            " sent to Jev and the LLM. "
521                            b { (grouped(f64::from(stats.kept))) }
522                            " more were not sent, because the same request had been answered before."
523                        }
524                        @if stats.clones + stats.pulls > 0 {
525                            li {
526                                "The code was " a href=(CODE) { "cloned" } " "
527                                b { (plural(stats.clones, "time", "times")) } " and pulled "
528                                b { (plural(stats.pulls, "time", "times")) } "."
529                            }
530                        }
531                    }
532                }
533            }
534        }
535    }
536}
537
538/// A short mark of an answer, for the address of its picture: whoever shows
539/// previews keeps a picture by its address, so the address has to change
540/// when the answer does (a question asked for the first time, most of all).
541fn mark(text: &str) -> String {
542    // FNV-1a.
543    let hash = text.bytes().fold(0x811c_9dc5_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193));
544    format!("{hash:08x}")
545}
546
547/// What a link to the page unfurls as, in Discord and wherever else reads
548/// Open Graph tags. The home page says what the site is. A `?q=` link shows
549/// the question, and Jev's answer when it has been asked before: a preview
550/// is built from what is kept and never asks anything. `origin` is where
551/// the site is, for the picture's address.
552fn preview(typed: &str, said: Option<&Entry>, origin: &str) -> Markup {
553    // Each answer with its numbers: the probability, the confidence, the
554    // likeliest options. That is half of what Jev says.
555    let answer = said.filter(|entry| !entry.answers.is_empty()).map(|entry| {
556        let lines: Vec<String> = entry.answers.iter().map(Answer::line).collect();
557        lines.join("  /  ")
558    });
559    let (title, description, picture) = if typed.is_empty() {
560        (
561            "Let Me Jev That For You".to_owned(),
562            "Ask Jev anything. It answers yes, no, which one or how much, with a probability, and shows its work."
563                .to_owned(),
564            format!("{origin}/card.png"),
565        )
566    } else {
567        let description = match &answer {
568            Some(answer) => format!("Jev: {answer}"),
569            None => "Let me Jev that for you.".to_owned(),
570        };
571        let picture =
572            format!("{origin}/card.png?q={}&a={}", url_encode(typed), mark(answer.as_deref().unwrap_or_default()));
573        (format!("Q: {typed}"), description, picture)
574    };
575    html! {
576        title { (title) }
577        meta name="description" content=(description);
578        meta property="og:type" content="website";
579        meta property="og:site_name" content="LMJTFY · Let Me Jev That For You";
580        meta property="og:title" content=(title);
581        meta property="og:description" content=(description);
582        meta property="og:image" content=(picture);
583        meta property="og:image:type" content="image/png";
584        meta property="og:image:width" content=(card::WIDTH);
585        meta property="og:image:height" content=(card::HEIGHT);
586        // The picture is shown large, under the text.
587        meta name="twitter:card" content="summary_large_image";
588        // The bar down the side of the embed: the page's pink.
589        meta name="theme-color" content="#f386a1";
590    }
591}
592
593/// The start of every page: what it unfurls as, the type and the look.
594fn head(preview: Markup) -> Markup {
595    html! {
596        head {
597            meta charset="utf-8";
598            meta name="viewport" content="width=device-width, initial-scale=1";
599            // The build this page came from, for `/live` to compare (page.js).
600            meta name="lmjtfy-build" content=(crate::BUILD);
601            (preview)
602            link rel="preconnect" href="https://fonts.googleapis.com";
603            link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
604            link rel="stylesheet" href=(FONTS);
605            style { (PreEscaped(CSS)) }
606        }
607    }
608}
609
610fn nav() -> Markup {
611    html! {
612        nav {
613            a href="/" { "LMJTFY" }
614            // Filled in over `/live` (page.js): pages open now, and a toast
615            // for each question asked and each clone while this one is open.
616            span .online-wrap tabindex="0" {
617                span #online .online hidden {}
618                (places(&[]))
619            }
620            div #toasts aria-live="polite" {}
621            a .dark href=(CODE) { "Code" }
622            a .dark href="https://docs.typesafe.ai" { "What is Jev?" }
623        }
624    }
625}
626
627/// Whether Jev was right, as the people who asked think: a vote each, one per
628/// browser, taken back by pressing it again. `rating` is `None` when there
629/// is nothing to vote on yet; `can` is false for a browser with no id.
630pub fn rating(rating: Option<&archive::Rating>, can: bool) -> Markup {
631    let button = |vote: archive::Vote, glyph: &str, n: u32, label: &str| {
632        let on = rating.and_then(|rating| rating.mine) == Some(vote);
633        let name = match vote {
634            archive::Vote::Up => "up",
635            archive::Vote::Down => "down",
636        };
637        html! {
638            button .vote .on[on] type="button" disabled[!can] aria-pressed=(on) title=(label)
639                "data-on:click"={ "$vote = '" (name) "'; @post('/rate')" } {
640                span .glyph aria-hidden="true" { (glyph) } " " (n)
641            }
642        }
643    };
644    html! {
645        div #rating .rating {
646            @if let Some(shown) = rating {
647                span .label-s { "Was Jev right?" }
648                (button(archive::Vote::Up, "👍", shown.up, "Jev was right"))
649                (button(archive::Vote::Down, "👎", shown.down, "Jev was wrong"))
650                @if !can { span .meta { "Voting needs this site's cookie: reload the page." } }
651            }
652        }
653    }
654}
655
656/// Where the open pages are, for the drop-down under "N online": a flag and
657/// a place a line, most first. Replaced whole over `/live` when a page comes
658/// or goes.
659pub fn places(open: &[(archive::Place, u32)]) -> Markup {
660    html! {
661        ul #places .places {
662            @for (place, n) in open {
663                li {
664                    span .flag aria-hidden="true" { (place.flag().unwrap_or_else(|| "·".to_owned())) }
665                    span .where { (place.label()) }
666                    @if *n > 1 { span .times { "×" (n) } }
667                }
668            }
669        }
670    }
671}
672
673/// Where the code is cloned from, and its landing page: the same address.
674const CODE: &str = "/lmjtfy.git";
675
676/// The foot of the home page and the code page: what the site is, and on
677/// the home page, how to get its code (the code page has it at the top).
678fn footer(clone: Option<&str>) -> Markup {
679    html! {
680        footer {
681            div .wrap {
682                div .about {
683                    (icon(&icons::JEV, "big"))
684                    span {
685                        "Jev answers typed questions with calibrated probabilities, not text. "
686                        "Rules decide what to ask it, and an LLM writes the options when a question needs them. "
687                        a href="/rules" { "See the rules." } " "
688                        "Not affiliated with TypeSafe AI."
689                    }
690                }
691                @if let Some(origin) = clone {
692                    (clone_line(&crate::clone::command(origin), html! { a href=(CODE) { "Get the code:" } }))
693                }
694            }
695        }
696    }
697}
698
699/// A clone command with a copy button.
700fn clone_line(command: &str, label: Markup) -> Markup {
701    html! {
702        div .share.clone {
703            span { (label) }
704            code #clone { (command) }
705            button .ghost type="button" data-copy="clone" { "Copy" }
706        }
707    }
708}
709
710/// The rules with facts set by hand (`playground.rs`): every test is a link.
711pub fn playground(
712    network: &Network,
713    known: &Known,
714    fired: &[usize],
715    says: &str,
716    link: impl Fn(diagram::Clicked) -> String,
717) -> Markup {
718    let title = "The rules · LMJTFY";
719    let about = "The rules that decide what happens to a question, as a Rete network you can set the facts of.";
720    html! {
721        (DOCTYPE)
722        html lang="en" {
723            (head(html! {
724                title { (title) }
725                meta name="description" content=(about);
726                meta property="og:title" content=(title);
727                meta property="og:description" content=(about);
728                meta name="theme-color" content="#f386a1";
729            }))
730            body {
731                div .sheet {
732                    div .wrap {
733                        (nav())
734                        div .stack {
735                            div .win {
736                                div .bar-title {
737                                    (icon(&icons::TOOL, "")) "rules · rete"
738                                    span .meta {
739                                        @if fired.is_empty() { "nothing decided yet" } @else { (diagram::sequence(network, fired)) }
740                                    }
741                                }
742                                div .body {
743                                    (diagram::rete(network, known, fired, |_| None, |clicked| Some(link(clicked))))
744                                    p .says { (says) }
745                                    p .fine {
746                                        "Click a test to say its fact is so, or a fact to step through what it can be. "
747                                        "Nothing is asked of Jev or the LLM here: the facts are whatever you set. "
748                                        a href="/rules" { "Start over." }
749                                    }
750                                    p .fine {
751                                        "On a real question, Jev gives every fact in the first frame in one "
752                                        "request, and the rules take it from there. Filled is what a rule that "
753                                        "holds stands on, outlined is known and not needed, dashed failed."
754                                    }
755                                }
756                            }
757                        }
758                    }
759                }
760                // The online count and the toasts, as on every page.
761                script { (PreEscaped(JS)) }
762            }
763        }
764    }
765}
766
767/// `said` is the kept answer to `typed`, if there is one, for the preview.
768pub fn page(typed: &str, said: Option<&Entry>, origin: &str, shared: Option<Shared>, home: Option<&Home>) -> Markup {
769    html! {
770        (DOCTYPE)
771        html lang="en" {
772            (head(html! {
773                (preview(typed, said, origin))
774                script type="module" src="/datastar.js" {}
775            }))
776            body data-signals="{q: '', pins: ''}" {
777                div .sheet {
778                    div .wrap {
779                        (nav())
780                        div .headline {
781                            p .eyebrow { "Let me ........................ Jev that for you" }
782                            h1 { "Let Me Jev That For You" }
783                        }
784                        div .stack {
785                            div .win {
786                                div .bar-title { (icon(&icons::ASK, "")) "Ask" }
787                                div .body {
788                                    form #ask data-type=(typed) data-on:submit__prevent="@post('/ask')" data-indicator:asking {
789                                        // Jev answers in about a fifth of a second, so the
790                                        // gate runs on every pause in the typing.
791                                        input #q name="q" data-bind:q "data-on:input__debounce.300ms"="@post('/gate')"
792                                            autocomplete="off" required maxlength=(ask::MAX_INPUT_CHARS)
793                                            placeholder="Ask Jev anything" aria-label="Your question";
794                                        button type="submit" data-attr:disabled="$asking" { "Ask Jev" }
795                                    }
796                                    (live(None))
797                                }
798                            }
799                            (budget(shared))
800                            (feed(home))
801                            (tally(home.map(|home| &home.stats)))
802                        }
803                    }
804                }
805                (resting())
806                (footer(Some(origin)))
807                script { (PreEscaped(JS)) }
808            }
809        }
810    }
811}
812
813/// What the facts say about the text so far, for the line under the box.
814pub struct Glance {
815    /// Jev's probability that it can judge the text.
816    pub answerable: f64,
817    pub kind: Option<Kind>,
818}
819
820fn kind_words(kind: Kind) -> &'static str {
821    match kind {
822        Kind::Noul => "a yes or no",
823        Kind::Choice => "a pick",
824        Kind::Score => "a how much",
825        Kind::Chance => "a how likely",
826    }
827}
828
829/// The line under the box: what Jev makes of the text so far.
830pub fn live(glance: Option<Glance>) -> Markup {
831    html! {
832        p #live .live aria-live="polite" {
833            @if let Some(glance) = glance {
834                @if ask::is_yes(glance.answerable) {
835                    span .dot.yes {} "Jev can judge this"
836                    @if let Some(kind) = glance.kind { " · " (kind_words(kind)) }
837                } @else {
838                    span .dot {} "Jev can't judge this yet"
839                }
840                span .meta { " · " (probability(glance.answerable)) }
841            }
842        }
843    }
844}
845
846/// The transcript before anything is asked.
847fn resting() -> Markup {
848    html! { div #transcript {} }
849}
850
851/// The transcript as it stands. Sent whole each time; Datastar morphs it in by id.
852pub fn transcript(view: &View) -> Markup {
853    let results = view.results();
854    let said = view.ending.is_some() || !results.is_empty();
855    html! {
856        div #transcript {
857            div .sheet {
858                div .wrap {
859                    p .step { (step(view)) }
860                    div .win.white {
861                        div .bar-title { (icon(&icons::JEV, "")) "Jev" }
862                        div .body {
863                            div .row { span .who { "Q:" } span .q { (view.input) } }
864                            @if said {
865                                div .row { span .who { "Jev:" } div .said {
866                                    @for shown in &results { (result(shown)) }
867                                    @if let Some(ending) = &view.ending { (ended(ending)) }
868                                } }
869                            }
870                            @if matches!(view.ending, Some(Ending::Answered)) {
871                                // Filled in once the answer is kept (`/rate`).
872                                div #rating .rating {}
873                            }
874                            @if matches!(view.ending, Some(Ending::Answered | Ending::NotAQuestion { .. })) {
875                                (share(&view.input))
876                            }
877                        }
878                    }
879                }
880            }
881            @if view.facts.is_some() {
882                div .band {
883                    div .wrap.stack {
884                        (rules_card(view))
885                        @if let Some(facts) = &view.facts { (jev_card(facts)) }
886                        @if let Some(llm) = &view.llm { (llm_card(llm)) }
887                        @for tool in &view.tools { (tool_card(tool, view.judging.is_some())) }
888                        @if let Some(judging) = &view.judging { (jev_card(judging)) }
889                    }
890                }
891            }
892        }
893    }
894}
895
896fn step(view: &View) -> &'static str {
897    match &view.ending {
898        Some(Ending::Empty) => "Type a question first",
899        Some(Ending::Answered | Ending::NotAQuestion { .. }) => "Was that so hard?",
900        Some(_) => "Nothing came back",
901        None if view.judging.is_some() => "Step 3: asking Jev",
902        None if view.llm.is_some() => "Step 2: an LLM is writing Jev's question",
903        None if view.facts.is_some() => "Step 1: asking Jev what kind of question that is",
904        None => "Getting Jev",
905    }
906}
907
908/// The rules as a Rete network, with what is known about this query marked
909/// on it (`diagram.rs` says what each mark means).
910fn rules_card(view: &View) -> Markup {
911    let network = Network::lmjtfy();
912    html! {
913        details .win open {
914            summary .bar-title {
915                span .fold {} (icon(&icons::TOOL, "")) "rules · rete"
916                span .meta {
917                    // No rule decides a step when Jev's first answers settle
918                    // it: then the rules that hold are the story.
919                    @let holding = network.holding(&view.known);
920                    @if !view.fired.is_empty() {
921                        (diagram::sequence(&network, &view.fired))
922                    } @else if holding.is_empty() {
923                        "waiting on facts"
924                    } @else {
925                        (holding.join(" · "))
926                    }
927                }
928            }
929            div .body {
930                (diagram::rete(&network, &view.known, &view.fired, |fact| view.shown(fact), |_| None))
931                p .fine {
932                    "Facts on the left, with what Jev said; rules on the right, numbered in the order they "
933                    "decided; the tests that join them between. Filled is what a rule that fired stood on, "
934                    "outlined was learned and not needed, dashed failed. A line from a rule back to a fact "
935                    "is a call that teaches it. "
936                    a href="/rules" { "Try the rules yourself." }
937                }
938            }
939        }
940    }
941}
942
943/// What Jev said to one question.
944fn result(shown: &Shown<'_>) -> Markup {
945    html! {
946        div .result {
947            p .asked {
948                (shown.question)
949                @if let Some(read_as) = shown.read_as { span .read-as { " · " (read_as) } }
950            }
951            @match shown.judged {
952                // The probability is the answer; the note says it is a Noul,
953                // and how to read the number.
954                Judged::Noul(_) if shown.chance => {
955                    p .answer { (shown.headline()) }
956                    p .verdict { (shown.told().note().unwrap_or_default()) }
957                },
958                Judged::Noul(p_yes) => {
959                    p .answer { (headline(shown.judged)) }
960                    p .verdict { "Noul · p(yes) = " (probability(*p_yes)) }
961                },
962                Judged::Choice(answer) => (choice(answer)),
963                Judged::Score(answer) => (score(answer)),
964            }
965        }
966    }
967}
968
969/// A `Noul` as a word: flat when Jev is sure, a question when it is not.
970fn yes_or_no(p_yes: f64) -> &'static str {
971    match (ask::is_yes(p_yes), ask::is_sure(p_yes)) {
972        (true, true) => "Yes.",
973        (true, false) => "Yes?",
974        (false, true) => "No.",
975        (false, false) => "No?",
976    }
977}
978
979/// The level a `Score` fell nearest, and the highest level there is.
980fn nearest(answer: &ScoreAnswer) -> (usize, usize) {
981    let top = answer.probabilities.len().saturating_sub(1);
982    ((answer.score.round().max(0.0) as usize).min(top), top)
983}
984
985/// An answer in a few words: what the transcript prints large, and what the
986/// feed prints beside the question.
987fn headline(judged: &Judged) -> String {
988    match judged {
989        Judged::Noul(p_yes) => yes_or_no(*p_yes).to_owned(),
990        Judged::Choice(answer) => format!("{}.", answer.choice),
991        Judged::Score(answer) => {
992            format!("{}.", answer.legend.get(nearest(answer).0).map(String::as_str).unwrap_or_default())
993        }
994    }
995}
996
997/// Where the square sits on its rule, from a probability.
998fn at(p: f64) -> String {
999    format!("left: {:.1}%", (p * 100.0).clamp(0.0, 100.0))
1000}
1001
1002fn percent(p: f64) -> String {
1003    format!("{:.0}%", p * 100.0)
1004}
1005
1006/// Every option with its probability, most likely first.
1007fn choice(answer: &ChoiceAnswer) -> Markup {
1008    let mut ranked: Vec<&(String, f64)> = answer.probabilities.iter().collect();
1009    ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
1010    html! {
1011        p .answer.pick { (headline(&Judged::Choice(answer.clone()))) }
1012        div .bars {
1013            @for (label, p) in ranked {
1014                div .bar.won[*label == answer.choice] {
1015                    span .label { (label) }
1016                    span .pct { (percent(*p)) }
1017                    span .track { span .thumb style=(at(*p)) {} }
1018                }
1019            }
1020        }
1021        p .verdict { "Choice · confidence " (probability(answer.confidence)) }
1022    }
1023}
1024
1025/// Every level with its probability, lowest first, and where the score fell.
1026fn score(answer: &ScoreAnswer) -> Markup {
1027    let (nearest, top) = nearest(answer);
1028    html! {
1029        p .answer.pick {
1030            (headline(&Judged::Score(answer.clone())))
1031            span .of { " " (format!("{:.1}", answer.score)) " / " (top) }
1032        }
1033        div .bars {
1034            @for (level, p) in answer.probabilities.iter().enumerate() {
1035                div .bar.won[level == nearest] {
1036                    span .label { (level) " · " (answer.legend.get(level).map(String::as_str).unwrap_or_default()) }
1037                    span .pct { (percent(*p)) }
1038                    span .track { span .thumb style=(at(*p)) {} }
1039                }
1040            }
1041        }
1042        p .verdict { "Score · 0 to " (top) " · confidence " (probability(answer.confidence)) }
1043    }
1044}
1045
1046fn ended(ending: &Ending) -> Markup {
1047    match ending {
1048        Ending::Answered => html! {},
1049        Ending::Slow => html! {
1050            p .answer.quiet { "Slow down." }
1051            p .why { "That is a lot of questions in one minute. Nothing was sent. Try again in a minute." }
1052        },
1053        Ending::NotAQuestion { p_yes } => html! {
1054            p .answer { (yes_or_no(*p_yes)) }
1055            p .why { "That's not a question I can answer. Ask me yes or no, which one, or how much." }
1056            p .verdict { "p(I can answer this) = " (probability(*p_yes)) }
1057        },
1058        Ending::NoQuestion => html! {
1059            p .answer.quiet { "Jev wasn't asked anything." }
1060            p .why { "The LLM didn't write a question Jev can take. Its reply is below." }
1061        },
1062        Ending::Failed { error } => html! {
1063            p .answer.quiet { "Jev didn't answer." }
1064            p .why { (error) }
1065        },
1066        Ending::Offline => html! {
1067            p .answer.quiet { "Jev is offline." }
1068            p .why { "This server has no TypeSafe API key, so nothing was sent." }
1069        },
1070        Ending::NotKept { id } => html! {
1071            p .answer.quiet { "Not asked yet." }
1072            p .why {
1073                "This version leads to a question Jev was never asked. Looking back costs nothing, so it was not sent. "
1074                (again(id, "↻ Ask it"))
1075            }
1076        },
1077        Ending::Spent { what } => html! {
1078            p .answer.quiet { "Out of budget for today." }
1079            p .why { "Everyone shares one daily budget for " (what) ", and it is used up. It resets at 00:00 UTC." }
1080        },
1081        Ending::Empty => html! { p .answer.quiet { "Ask me something." } },
1082    }
1083}
1084
1085/// The LLM's call: what it was sent and what it wrote.
1086fn llm_card(made: &LlmCall) -> Markup {
1087    html! {
1088        details .win {
1089            summary .bar-title {
1090                span .fold {} (icon(&icons::LLM, "")) "llm · " (made.model)
1091                span .meta {
1092                    @match &made.outcome {
1093                        None => "writing Jev's question",
1094                        Some(LlmOutcome { sent: Sent::Before { at_ms }, .. }) => (kept(*at_ms)),
1095                        Some(outcome) => (format!("{} ms · {:.1} neurons", outcome.took.as_millis(), outcome.neurons)),
1096                    }
1097                }
1098            }
1099            div .body {
1100                (versions(made.kept.as_ref()))
1101                div .wire {
1102                    span .label-s { "request" }
1103                    (json(&made.request))
1104                }
1105                @if let Some(outcome) = &made.outcome {
1106                    div .wire {
1107                        span .label-s { "response" }
1108                        (json(&outcome.body))
1109                        @if outcome.dropped > 0 {
1110                            p .note { (outcome.dropped) " more tool calls were over the limit and were not run." }
1111                        }
1112                    }
1113                }
1114            }
1115        }
1116    }
1117}
1118
1119/// One tool call the LLM made: what it wrote, and whether it went to Jev.
1120/// `sent` is whether a request to Jev was made at all.
1121fn tool_card(tool: &Tool, sent: bool) -> Markup {
1122    html! {
1123        details .win open[tool.problem().is_some()] {
1124            summary .bar-title {
1125                span .fold {} (icon(&icons::TOOL, "")) "tool call · " (tool.call.name)
1126                span .meta {
1127                    @if tool.problem().is_some() { "not sent" } @else if sent { "sent to Jev as " (tool.id) } @else { (tool.id) }
1128                }
1129            }
1130            div .body {
1131                div .wire {
1132                    span .label-s { "the LLM's tool call" }
1133                    pre { (tool.call.name) }
1134                    (json(&tool.call.arguments))
1135                    @if let Some(problem) = tool.problem() {
1136                        p .note { "Not sent to Jev: " (problem) }
1137                    }
1138                }
1139            }
1140        }
1141    }
1142}
1143
1144/// A request to Jev: the body as sent and the response as it arrived.
1145/// ↻ for the call `id`: send its request again, and work the rest out from
1146/// the answer.
1147fn again(id: &str, label: &str) -> Markup {
1148    html! {
1149        button .again type="button" title="Send this request again and keep the answer as its newest"
1150            "data-on:click"={ "$pins = ($pins || '') + ';" (id) ":fresh'; @post('/ask')" } { (label) }
1151    }
1152}
1153
1154/// ◀ version n of m ▶ and ↻, for a call that has an answer.
1155fn versions(kept: Option<&Versions>) -> Markup {
1156    let Some(kept) = kept else { return html! {} };
1157    let step = |to: u32, glyph: &str, label: &str| {
1158        html! {
1159            button .step type="button" title=(label)
1160                "data-on:click"={ "$pins = ($pins || '') + ';" (kept.id) ":" (to) "'; @post('/ask')" } { (glyph) }
1161        }
1162    };
1163    html! {
1164        div .versions {
1165            @if kept.version > 1 { (step(kept.version - 1, "◀", "The answer before")) }
1166            span .label-s { "answer " (kept.version) " of " (kept.versions) }
1167            @if kept.version < kept.versions { (step(kept.version + 1, "▶", "The answer after")) }
1168            (again(&kept.id, "↻ Ask again"))
1169        }
1170    }
1171}
1172
1173fn jev_card(made: &JevCall) -> Markup {
1174    let questions = if made.parts.len() == 1 { "1 question".to_owned() } else { format!("{} questions", made.parts.len()) };
1175    html! {
1176        details .win open {
1177            summary .bar-title {
1178                span .fold {} (icon(&icons::TOOL, "")) "jev · " (made.id) " · " (questions)
1179                span .meta { (meta(made.outcome.as_ref())) }
1180            }
1181            div .body {
1182                (versions(made.kept.as_ref()))
1183                div .wire {
1184                    span .label-s { "request" }
1185                    pre {
1186                        "POST " (jev_protocol::ENDPOINT) "\n"
1187                        "authorization: Bearer [the server's key]\n"
1188                        "content-type: application/json"
1189                    }
1190                    (json(&made.request))
1191                }
1192                @match &made.outcome {
1193                    Some(Outcome::Answered { body, .. }) => div .wire {
1194                        span .label-s { "response" }
1195                        (json(body))
1196                    },
1197                    Some(Outcome::Failed { error, .. }) => div .wire {
1198                        span .label-s { "no response" }
1199                        pre { (error) }
1200                    },
1201                    None => {},
1202                }
1203            }
1204        }
1205    }
1206}
1207
1208/// A JSON body, indented and coloured for reading. Only whitespace is added:
1209/// keys keep their order and every value is printed as it was sent. Text that
1210/// is not JSON is shown as it is.
1211fn json(text: &str) -> Markup {
1212    match serde_json::from_str::<Value>(text) {
1213        Ok(value) => html! { pre .json { (node(&value, 0)) } },
1214        Err(_) => html! { pre { (text) } },
1215    }
1216}
1217
1218fn node(value: &Value, depth: usize) -> Markup {
1219    let indent = "  ".repeat(depth + 1);
1220    let close = "  ".repeat(depth);
1221    match value {
1222        Value::Object(map) if !map.is_empty() => html! {
1223            "{\n"
1224            @for (i, (key, child)) in map.iter().enumerate() {
1225                (indent) span .k { (quoted(key)) } ": " (node(child, depth + 1))
1226                @if i + 1 < map.len() { "," } "\n"
1227            }
1228            (close) "}"
1229        },
1230        Value::Array(items) if !items.is_empty() => html! {
1231            "[\n"
1232            @for (i, child) in items.iter().enumerate() {
1233                (indent) (node(child, depth + 1))
1234                @if i + 1 < items.len() { "," } "\n"
1235            }
1236            (close) "]"
1237        },
1238        Value::String(text) => html! { span .s { (quoted(text)) } },
1239        // Numbers, booleans, null, and the empty object and array.
1240        other => html! { span .n { (other.to_string()) } },
1241    }
1242}
1243
1244/// A string as JSON writes it, quotes and escapes included.
1245fn quoted(text: &str) -> String {
1246    Value::String(text.to_owned()).to_string()
1247}
1248
1249/// A call's title bar when nothing was sent: the response is the one kept
1250/// from the first time this exact request was made.
1251fn kept(at_ms: f64) -> String {
1252    format!("not sent · answered {}", when(at_ms))
1253}
1254
1255fn meta(outcome: Option<&Outcome>) -> String {
1256    match outcome {
1257        None => "waiting for Jev".to_owned(),
1258        Some(Outcome::Answered { sent: Sent::Before { at_ms }, request_id, .. }) => {
1259            let mut parts = vec![kept(*at_ms)];
1260            parts.extend(request_id.clone());
1261            parts.join(" · ")
1262        }
1263        Some(Outcome::Answered { request_id, attempts, usage, took, .. }) => {
1264            let mut parts = vec![
1265                format!("{} ms", took.as_millis()),
1266                if *attempts == 1 { "1 attempt".to_owned() } else { format!("{attempts} attempts") },
1267                format!("{} tokens in", usage.input_tokens),
1268                format!("${:.6}", usage.dollars()),
1269            ];
1270            parts.extend(request_id.clone());
1271            parts.join(" · ")
1272        }
1273        Some(Outcome::Failed { request_id, took, .. }) => {
1274            let mut parts = vec!["failed".to_owned(), format!("{} ms", took.as_millis())];
1275            parts.extend(request_id.clone());
1276            parts.join(" · ")
1277        }
1278    }
1279}
1280
1281fn share(input: &str) -> Markup {
1282    let query: String = url_encode(input);
1283    html! {
1284        div .share {
1285            span { "Send it to them:" }
1286            a #link href={ "/?q=" (query) } { "/?q=" (query) }
1287            button .ghost type="button" data-copy="link" { "Copy link" }
1288        }
1289    }
1290}
1291
1292/// Percent-encodes everything but unreserved characters, for a query value.
1293/// `text` percent-encoded, for a URL or a header.
1294pub fn url_encoded(text: &str) -> String {
1295    url_encode(text)
1296}
1297
1298fn url_encode(text: &str) -> String {
1299    let mut out = String::with_capacity(text.len());
1300    for byte in text.bytes() {
1301        match byte {
1302            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char),
1303            b' ' => out.push('+'),
1304            _ => out.push_str(&format!("%{byte:02X}")),
1305        }
1306    }
1307    out
1308}
1309
1310fn probability(p: f64) -> String {
1311    format!("{p:.2}")
1312}
1313
1314#[cfg(test)]
1315mod tests;