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