1//! What lmjtfy asks Jev, and the record each call leaves for the page. 2//! 3//! There are two requests. The first asks for facts about the input, as many 4//! as the rules want at once ([`rules::Fact`]): can Jev judge it, what kind 5//! of question is it, is it several, does it need a scale written for it, is 6//! it fit to show publicly, and the answer if it is a yes-or-no question, a 7//! how-much one, or a how-likely one. The second asks the questions the LLM wrote ([`llm::Draft`]), 8//! all in one request. Every question is asked about the same state, the 9//! visitor's input. 10//! 11//! A call is prepared before it is sent, so the page can show the request 12//! while Jev is still answering it, and [`send`] returns the response. 13//! Nothing here does I/O: it runs on whatever `Transport` and `Runtime` the 14//! `Client` was built with. 15#![forbid(unsafe_code)] 16 17use std::time::Duration; 18 19use jev_client::{Client, Observer, Runtime, Transport}; 20use jev_protocol::{ 21 Choice, ChoiceAnswer, Json, Key, ModelId, Noul, NoulAnswer, ProtocolError, Question, Questions, Score, 22 Response, ScoreAnswer, Usage, request_bytes, worst_case_dollars, 23}; 24use llm::Draft; 25use rules::{Fact, Kind, Value}; 26use serde::{Deserialize, Serialize}; 27 28/// The longest input sent on. A search box, not a document. 29pub const MAX_INPUT_CHARS: usize = 500; 30 31/// At or above this, a `Noul` reads as yes. 32pub const THRESHOLD: f64 = 0.5; 33 34/// Whether a `Noul`'s probability reads as yes. 35pub fn is_yes(p_yes: f64) -> bool { 36 p_yes >= THRESHOLD 37} 38 39/// A `Noul` is sure when the likelier answer has at least this much. Below 40/// it the page ends the answer with a question mark instead of a full stop 41/// (the user, 2026-10-02: "is AI a bit stupid?" came back 0.55, "he's not 42/// that confident"). 43pub const SURE: f64 = 0.7; 44 45/// The `confidence` a `Score` needs to be stated flatly. A first guess, like 46/// `SURE`: below it Jev says the levels are ambiguous (TypeSafe's docs), and 47/// the headline asks instead of stating. 48pub const CONFIDENT: f64 = 0.5; 49 50/// Whether a `Score`'s confidence is high enough to state its level flatly. 51pub fn is_confident(confidence: f64) -> bool { 52 confidence >= CONFIDENT 53} 54 55/// Whether a `Noul`'s probability is far enough from even to state flatly. 56pub fn is_sure(p_yes: f64) -> bool { 57 p_yes.max(1.0 - p_yes) >= SURE 58} 59 60/// A probability in a word or two, for a how-likely answer: the number is 61/// the answer, and this is how to read it. 62pub fn likelihood(p: f64) -> &'static str { 63 match p { 64 p if p < 0.1 => "very unlikely", 65 p if p < 0.4 => "unlikely", 66 p if p <= 0.6 => "a toss-up", 67 p if p <= 0.9 => "likely", 68 _ => "very likely", 69 } 70} 71 72/// The visitor's text as Jev sees it, trimmed and capped. 73pub fn clean(input: &str) -> String { 74 input.trim().chars().take(MAX_INPUT_CHARS).collect() 75} 76 77/// The labels of the `kind` question's options, and the [`Kind`] each means. 78const KINDS: [(&str, Kind, &str); 4] = [ 79 ("yes_or_no", Kind::Noul, "It asks whether something is so. A yes or a no answers it."), 80 ( 81 "pick_one", 82 Kind::Choice, 83 "It asks which, who or what: the best or right one among several possibilities. Naming one answers it.", 84 ), 85 ( 86 "how_much", 87 Kind::Score, 88 "It asks for a degree, an amount or a rating. A position on a scale answers it.", 89 ), 90 ( 91 "how_likely", 92 Kind::Chance, 93 "It asks for the chance, the odds or the probability of something. A percentage answers it.", 94 ), 95]; 96 97/// Jev's runner-up reading is taken too when it has at least this much: Jev 98/// is split, and both readings are answered (the user, 2026-10-02: at 90% 99/// do that one; at 40 / 40 / 20, do the top two). 100pub const ALSO: f64 = 0.3; 101 102/// Jev's probability that the input is this kind of question. 103pub fn kind_probability(answer: &ChoiceAnswer, kind: Kind) -> Option<f64> { 104 let (label, ..) = KINDS.iter().find(|(_, known, _)| *known == kind)?; 105 answer.probabilities.iter().find(|(option, _)| option == label).map(|(_, p)| *p) 106} 107 108/// The kinds the input is read as, likeliest first: the top one, and the 109/// runner-up if Jev gave it at least [`ALSO`]. 110pub fn readings(answer: &ChoiceAnswer) -> Vec<Kind> { 111 let mut ranked: Vec<(Kind, f64)> = 112 Kind::ALL.into_iter().filter_map(|kind| Some((kind, kind_probability(answer, kind)?))).collect(); 113 ranked.sort_by(|a, b| b.1.total_cmp(&a.1)); 114 ranked.iter().enumerate().filter(|(rank, (_, p))| *rank == 0 || (*rank == 1 && *p >= ALSO)).map(|(_, (kind, _))| *kind).collect() 115} 116 117/// The id of the question that teaches a fact. The three readings are one 118/// question, answered with a probability for each. 119pub fn question_id(fact: Fact) -> &'static str { 120 match fact { 121 Fact::Reads(_) => "kind", 122 fact => fact.name(), 123 } 124} 125 126/// The scale a how-much question is answered on when nobody writes one for 127/// it, lowest first. Fixed, so Jev can be asked without an LLM (the user, 128/// 2026-10-02, knowing it gives up levels written for the question). 129pub const DEGREES: [&str; 5] = ["Not at all", "Slightly", "Moderately", "Very", "Extremely"]; 130 131/// The question that teaches a fact. Only facts whose source is Jev have one. 132fn fact_question(fact: Fact) -> Result<Question, ProtocolError> { 133 Ok(match fact { 134 Fact::Answerable => Question::Noul( 135 Noul::new(Json::text("Is the input a question you can answer with your types?")) 136 .yes_means(Json::text( 137 "The input asks something that can be judged: a yes-or-no question, a question with a best \ 138 answer among options, or a question of degree.", 139 )) 140 .no_means(Json::text( 141 "The input is anything else: a greeting, a statement, a command, or a request to write, \ 142 explain, summarise or do something.", 143 )), 144 ), 145 Fact::Reads(_) => Question::Choice(Choice::new( 146 Json::text("What kind of question is the input?"), 147 KINDS.iter().map(|(label, _, means)| ((*label).to_owned(), Some(Json::text(means)))), 148 )?), 149 Fact::Several => Question::Noul( 150 Noul::new(Json::text("Does the input ask more than one separate question?")) 151 .yes_means(Json::text("It asks two or more separate questions, each needing its own answer.")) 152 .no_means(Json::text("It asks one question, even if that question mentions several things.")), 153 ), 154 Fact::Yes => Question::Noul( 155 Noul::new(Json::text("Answer the question in the input.")) 156 .yes_means(Json::text("The answer to the question in the input is yes.")) 157 .no_means(Json::text("The answer to the question in the input is no.")), 158 ), 159 Fact::Degree => Question::Score(Score::new( 160 Json::text("Answer the question in the input as a degree: how much, how good, how strong, how likely."), 161 DEGREES.iter().map(|level| Json::text(level)), 162 )?), 163 Fact::Scale => Question::Noul( 164 Noul::new(Json::text("Would the input be answered badly on a general scale from 'Not at all' to 'Extremely'?")) 165 .yes_means(Json::text( 166 "Yes. What it asks about has its own units, ranges, grades or named levels, and a good \ 167 answer would use them.", 168 )) 169 .no_means(Json::text( 170 "No. A general scale answers it well enough, or it is not a how-much question at all.", 171 )), 172 ), 173 Fact::Chance => Question::Noul( 174 Noul::new(Json::text("Is the thing the input asks the chances of so?")) 175 .yes_means(Json::text("It is so, or it will happen.")) 176 .no_means(Json::text("It is not so, or it will not happen.")), 177 ), 178 Fact::Fit => Question::Noul( 179 Noul::new(Json::text("Is the input fit to show to strangers on a public page?")) 180 .yes_means(Json::text("It is harmless to show anyone.")) 181 .no_means(Json::text( 182 "It has a slur, harassment, a threat, sexual content, or a private person's name or \ 183 personal details.", 184 )), 185 ), 186 Fact::Whole => Question::Noul( 187 Noul::new(Json::text("Is the input finished, or was it cut off part way through being typed?")) 188 .yes_means(Json::text( 189 "Finished. Its last word is a whole word, and nothing it needs is missing after it. A \ 190 greeting, a statement or a few words can be finished; it need not be a question.", 191 )) 192 .no_means(Json::text( 193 "Cut off. It ends in part of a word, or on a word that needs more after it, such as a, \ 194 the, to, of, or, and, is.", 195 )), 196 ), 197 Fact::Drafted | Fact::Judged => { 198 return Err(ProtocolError::Invalid(format!("{} is not a fact Jev is asked for", fact.name()))); 199 } 200 }) 201} 202 203fn drafted_question(draft: &Draft) -> Result<Question, ProtocolError> { 204 Ok(match draft { 205 Draft::Noul { instructions, yes_means, no_means } => Question::Noul( 206 Noul::new(Json::text(instructions)).yes_means(Json::text(yes_means)).no_means(Json::text(no_means)), 207 ), 208 Draft::Choice { instructions, options } => Question::Choice(Choice::new( 209 Json::text(instructions), 210 options.iter().map(|option| (option.label.clone(), Some(Json::text(&option.description)))), 211 )?), 212 Draft::Score { instructions, levels } => { 213 Question::Score(Score::new(Json::text(instructions), levels.iter().map(|level| Json::text(level)))?) 214 } 215 }) 216} 217 218/// Whether Jev's own protocol takes a question the LLM wrote. One that it 219/// refuses is left out of the request, and the page says why. 220pub fn check(draft: &Draft) -> Result<(), ProtocolError> { 221 drafted_question(draft).map(|_| ()) 222} 223 224/// The answer's key, typed by the question it belongs to. 225#[derive(Clone, Copy)] 226enum Asked { 227 Noul(Key<NoulAnswer>), 228 Choice(Key<ChoiceAnswer>), 229 Score(Key<ScoreAnswer>), 230} 231 232/// One question in a request. 233pub struct Part { 234 /// The question's id in the request and on the page. 235 pub id: String, 236 /// The Jev type asked: `noul`, `choice` or `score`. 237 pub kind: &'static str, 238 asked: Asked, 239} 240 241/// A call that is ready to send: one request, one or more questions. 242pub struct Prepared { 243 /// What the call is for, on the page: `facts` or `answers`. 244 pub id: &'static str, 245 /// The questions, in the order they are asked. 246 pub parts: Vec<Part>, 247 /// The request body exactly as it will be sent. 248 pub request: String, 249 /// The most this call can cost, every retry billed 250 /// (`jev_protocol::worst_case_dollars`): what a budget holds before it 251 /// is sent. 252 pub worst_case_dollars: f64, 253 state: Json, 254 questions: Questions, 255} 256 257fn prepare( 258 model: &ModelId, 259 id: &'static str, 260 input: &str, 261 asked: impl IntoIterator<Item = (String, Question)>, 262) -> Result<Prepared, ProtocolError> { 263 let state = Json::canonical(&serde_json::json!({ "input": input }).to_string())?; 264 let mut questions = Questions::new(); 265 let mut parts = Vec::new(); 266 for (id, question) in asked { 267 let (kind, asked) = match question { 268 Question::Noul(noul) => ("noul", Asked::Noul(questions.noul(&id, noul)?)), 269 Question::Choice(choice) => ("choice", Asked::Choice(questions.choice(&id, choice)?)), 270 Question::Score(score) => ("score", Asked::Score(questions.score(&id, score)?)), 271 }; 272 parts.push(Part { id, kind, asked }); 273 } 274 let request = String::from_utf8(request_bytes(model, &state, &questions)?) 275 .map_err(|e| ProtocolError::Invalid(e.to_string()))?; 276 let worst_case_dollars = worst_case_dollars(model, &state, &questions); 277 Ok(Prepared { id, parts, request, worst_case_dollars, state, questions }) 278} 279 280/// What to ask Jev about an input, in a form that can be sent to the 281/// archive: it prepares the same request from this as the Worker did. 282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 283pub enum Wanted { 284 /// The questions that teach these facts, in one request. 285 Facts(Vec<Fact>), 286 /// The questions the LLM wrote, in one request, each with its id. 287 Drafted(Vec<(String, Draft)>), 288} 289 290/// The request for `wanted` about `input` (already [`clean`]). 291pub fn wanted(model: &ModelId, input: &str, wanted: &Wanted) -> Result<Prepared, ProtocolError> { 292 match wanted { 293 Wanted::Facts(facts) => { 294 // Several facts can share a question; it is asked once. 295 let mut asked: Vec<(String, Question)> = Vec::new(); 296 for fact in facts { 297 let id = question_id(*fact); 298 if asked.iter().all(|(known, _)| known != id) { 299 asked.push((id.to_owned(), fact_question(*fact)?)); 300 } 301 } 302 prepare(model, "facts", input, asked) 303 } 304 Wanted::Drafted(drafts) => { 305 let asked: Result<Vec<_>, _> = 306 drafts.iter().map(|(id, draft)| Ok((id.clone(), drafted_question(draft)?))).collect(); 307 prepare(model, "answers", input, asked?) 308 } 309 } 310} 311 312/// What the answer to a fact's question ([`question_id`]) makes the fact. 313/// `None` when the answer is not of the type that question is. 314pub fn learned(fact: Fact, judged: &Judged) -> Option<Value> { 315 match (fact, judged) { 316 (Fact::Answerable | Fact::Several | Fact::Scale | Fact::Fit | Fact::Whole, Judged::Noul(p_yes)) => { 317 Some(Value::Bool(is_yes(*p_yes))) 318 } 319 (Fact::Yes | Fact::Chance, Judged::Noul(_)) | (Fact::Degree, Judged::Score(_)) => Some(Value::Given), 320 (Fact::Reads(kind), Judged::Choice(answer)) => Some(Value::Bool(readings(answer).contains(&kind))), 321 _ => None, 322 } 323} 324 325/// Jev's answer, by type. 326#[derive(Clone, Debug, PartialEq)] 327pub enum Judged { 328 /// Probability that the answer is yes. 329 Noul(f64), 330 Choice(ChoiceAnswer), 331 Score(ScoreAnswer), 332} 333 334/// Whether this ask put the request on the wire. 335#[derive(Clone, Copy, Debug, PartialEq)] 336pub enum Sent { 337 /// It did, just now. 338 Now, 339 /// It did not: the same request was answered at `at_ms` (Unix 340 /// milliseconds), and this is that response. 341 Before { at_ms: f64 }, 342} 343 344/// What came back. 345#[derive(Debug)] 346pub enum Outcome { 347 Answered { 348 /// One answer per question, in the order they were asked. 349 judged: Vec<Judged>, 350 sent: Sent, 351 /// The response body exactly as it arrived. 352 body: String, 353 request_id: Option<String>, 354 attempts: u32, 355 usage: Usage, 356 /// The whole round trip, retries and waits included. 357 took: Duration, 358 }, 359 Failed { 360 error: String, 361 request_id: Option<String>, 362 took: Duration, 363 /// What a budget should count the failure as: nothing when it 364 /// certainly cost nothing, the call's worst case otherwise. 365 dollars: f64, 366 }, 367} 368 369impl Outcome { 370 /// What the call cost, or may have. 371 pub fn dollars(&self) -> f64 { 372 match self { 373 Outcome::Answered { sent: Sent::Before { .. }, .. } => 0.0, 374 Outcome::Answered { usage, .. } => usage.dollars(), 375 Outcome::Failed { dollars, .. } => *dollars, 376 } 377 } 378} 379 380impl Prepared { 381 /// The state and the questions, for a client that takes them and not the 382 /// body: `jev-http`'s, which the eval asks through. 383 pub fn asking(&self) -> (&Json, &Questions) { 384 (&self.state, &self.questions) 385 } 386} 387 388/// The answers in a response, one per question of `prepared`, in order. 389pub fn judged(prepared: &Prepared, response: &Response) -> Vec<Judged> { 390 prepared 391 .parts 392 .iter() 393 .map(|part| match part.asked { 394 Asked::Noul(key) => Judged::Noul(response.get(key).noul), 395 Asked::Choice(key) => Judged::Choice(response.get(key).clone()), 396 Asked::Score(key) => Judged::Score(response.get(key).clone()), 397 }) 398 .collect() 399} 400 401/// Sends a prepared call. `runtime` is the clock the round trip is timed on. 402pub async fn send<T: Transport, R: Runtime, O: Observer>( 403 client: &Client<T, R, O>, 404 runtime: &impl Runtime, 405 prepared: &Prepared, 406) -> Outcome { 407 let started = runtime.now(); 408 let asked = client.ask(&prepared.state, &prepared.questions).await; 409 let took = runtime.now() - started; 410 match asked { 411 Ok(answered) => Outcome::Answered { 412 judged: judged(prepared, &answered.response), 413 sent: Sent::Now, 414 body: String::from_utf8_lossy(&answered.body).into_owned(), 415 request_id: answered.request_id, 416 attempts: answered.attempts, 417 usage: answered.response.usage(), 418 took, 419 }, 420 Err(error) => Outcome::Failed { 421 request_id: error.request_id().map(str::to_owned), 422 dollars: if error.nothing_billed() { 0.0 } else { prepared.worst_case_dollars }, 423 error: error.to_string(), 424 took, 425 }, 426 } 427} 428 429/// A response body as it arrived for a call, with what the call recorded 430/// beside it. 431pub struct Kept<'a> { 432 pub body: &'a str, 433 pub request_id: Option<String>, 434 pub attempts: u32, 435 pub took: Duration, 436 pub sent: Sent, 437} 438 439/// Reads a kept response as the answer to `prepared`: the same check of the 440/// body against the questions that `send` makes, with nothing sent. A body 441/// that does not answer these questions is a failure, not an answer. 442pub fn read(model: &ModelId, prepared: &Prepared, kept: Kept<'_>) -> Outcome { 443 match Response::parse(model, &prepared.questions, kept.body.as_bytes()) { 444 Ok(response) => Outcome::Answered { 445 judged: judged(prepared, &response), 446 sent: kept.sent, 447 body: kept.body.to_owned(), 448 request_id: kept.request_id, 449 attempts: kept.attempts, 450 usage: response.usage(), 451 took: kept.took, 452 }, 453 Err(error) => Outcome::Failed { 454 error: format!("the kept response does not answer this request: {error}"), 455 request_id: kept.request_id, 456 took: kept.took, 457 dollars: 0.0, 458 }, 459 } 460} 461 462#[cfg(test)] 463mod tests;