lmjtfy.git / packages / ask / src / lib.rs
lib.rsannotatedlib.rssource463 lines · 18.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/// 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;