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;