Skip to main content

ljos_cli/
jev.rs

1//! The prompt hook's judgments through Jev, TypeSafe's decision model, on
2//! TypeSafe's own API or OpenRouter's Decisions API: which candidate claims
3//! bear on a prompt, whether the prompt corrects the agent, and whether it
4//! puts a choice.
5//!
6//! One request answers all three: the prompt and the candidates are the
7//! state, and each judgment is a `noul` question, a probability that the
8//! statement is true. Opt-in per machine through `~/.config/ljos/jev.toml`,
9//! because the call sends the prompt and the candidate claims off the
10//! machine; with no file, no key, a failure or a spent budget, the hook
11//! keeps its local path.
12//!
13//! The same questions can go to another judge: `backend = "chat"` sends
14//! them as one JSON-mode chat request to any chat-completions endpoint (a
15//! hosted model, a local llama-server), and `backend = "command"` hands the
16//! request to an argv on stdin and reads the answers from its stdout, so a
17//! harness on the machine can be the judge. Both answer in the shape Jev
18//! does, so the parsers, the cache, the ledger and the callers are shared.
19//!
20//! Several judges can stand side by side: `[judges.NAME]` tables each name
21//! a backend, a model and a key, and `[route]` names which judges answer
22//! each decision (`prompt`, `ballot`, `audit`, `review`). A decision put to
23//! more than one judge is answered by their pool: probabilities by the
24//! weighted mean of their log-odds, choices by the normalised weighted
25//! geometric mean of their distributions, scores by the weighted mean. The
26//! top-level keys are the judge named `default`, which answers every
27//! decision no route names.
28//!
29//! These judges are fast and calibrated, and they do not reason. A
30//! decision they leave open goes to the personas: each with a runner of
31//! its own, in a session it keeps (see `persona_session`).
32
33use std::collections::BTreeMap;
34use std::path::PathBuf;
35use std::time::Duration;
36
37use serde_json::Value;
38
39/// Which judge answers the questions.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum Backend {
43    /// Jev over TypeSafe's API or OpenRouter's Decisions API.
44    #[default]
45    Jev,
46    /// One JSON-mode chat completion on a chat-completions endpoint;
47    /// `endpoint` is the base URL and `model` the model name there.
48    Chat,
49    /// The `command` argv, given the request on stdin, answers on stdout.
50    Command,
51}
52
53impl Backend {
54    /// The name the doctor row and the log carry.
55    #[must_use]
56    pub fn name(self) -> &'static str {
57        match self {
58            Self::Jev => "jev",
59            Self::Chat => "chat",
60            Self::Command => "command",
61        }
62    }
63
64    /// Whether the backend needs a key at all.
65    #[must_use]
66    pub fn needs_key(self) -> bool {
67        self == Self::Jev
68    }
69}
70
71/// One judge: where a decision is sent and how.
72#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
73pub struct Judge {
74    #[serde(default)]
75    pub backend: Backend,
76    #[serde(default)]
77    pub command: Option<Vec<String>>,
78    #[serde(default)]
79    pub key_file: Option<String>,
80    #[serde(default)]
81    pub key_cmd: Option<Vec<String>>,
82    /// An environment variable holding the key.
83    #[serde(default)]
84    pub key_env: Option<String>,
85    #[serde(default = "default_model")]
86    pub model: String,
87    #[serde(default = "default_endpoint")]
88    pub endpoint: String,
89    #[serde(default = "default_budget")]
90    pub budget_ms: u64,
91    #[serde(default = "default_price_in")]
92    pub usd_per_mtok_in: f64,
93    /// This judge's weight in a pool.
94    #[serde(default = "default_weight")]
95    pub weight: f64,
96}
97
98fn default_weight() -> f64 {
99    1.0
100}
101
102/// The decisions a route can name, and the log kind each is asked under.
103pub const DECISIONS: &[(&str, &str)] = &[
104    ("prompt", "hook"),
105    ("ballot", "ballot"),
106    ("audit", "stop-audit"),
107    ("review", "review"),
108];
109
110/// `~/.config/ljos/jev.toml`.
111#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
112pub struct Config {
113    /// Off unless set: the call sends the prompt and claims off the machine.
114    #[serde(default)]
115    pub enabled: bool,
116    /// Which judge answers: `jev` (the default), `chat` or `command`.
117    #[serde(default)]
118    pub backend: Backend,
119    /// The argv of the `command` backend. It reads the request JSON on
120    /// stdin and prints `{"answers": ...}` on stdout inside `budget_ms`.
121    #[serde(default)]
122    pub command: Option<Vec<String>>,
123    /// An environment variable holding the key.
124    #[serde(default)]
125    pub key_env: Option<String>,
126    /// Further judges by name, beside the top-level `default`.
127    #[serde(default)]
128    pub judges: BTreeMap<String, Judge>,
129    /// Which judges answer a decision: `prompt`, `ballot`, `audit` or
130    /// `review` to a list of judge names. A decision no route names goes to
131    /// `default`.
132    #[serde(default)]
133    pub route: BTreeMap<String, Vec<String>>,
134    /// A file holding the key, one line, mode 0600.
135    #[serde(default)]
136    pub key_file: Option<String>,
137    /// A command that prints the key on its first line, such as
138    /// `["pass", "show", "api/typesafe/jev"]`; asked once per login and held
139    /// in the runtime directory, mode 0600.
140    #[serde(default)]
141    pub key_cmd: Option<Vec<String>>,
142    /// The model: `jev-1.13.0` on TypeSafe's API, `typesafe/jev-1.13` on
143    /// OpenRouter's.
144    #[serde(default = "default_model")]
145    pub model: String,
146    /// How long the hook waits for the answer.
147    #[serde(default = "default_budget")]
148    pub budget_ms: u64,
149    /// TypeSafe's endpoint, or `https://openrouter.ai/api/alpha/decisions`.
150    #[serde(default = "default_endpoint")]
151    pub endpoint: String,
152    /// The month's spend, in US dollars, past which the hook stops asking.
153    #[serde(default = "default_monthly")]
154    pub monthly_usd: f64,
155    /// A prompt with fewer words is an acknowledgement ("yes", "keep
156    /// going"), with too little in it for a judgment to add anything.
157    #[serde(default = "default_min_words")]
158    pub min_words: usize,
159    /// Fewer candidates than this is nothing to choose between; the local
160    /// filters answer.
161    #[serde(default = "default_min_candidates")]
162    pub min_candidates: usize,
163    /// US dollars per million input tokens, for an API whose answer does
164    /// not carry its cost. Output is not charged.
165    #[serde(default = "default_price_in")]
166    pub usd_per_mtok_in: f64,
167    /// The probability at which a candidate counts as bearing on the
168    /// prompt; higher lets fewer off-topic claims through.
169    #[serde(default = "default_cut")]
170    pub bears_at: f64,
171    /// The probability at which the prompt counts as a correction or a
172    /// choice.
173    #[serde(default = "default_cut")]
174    pub cue_at: f64,
175    /// A persona ballot whose confidence is under this goes to a subagent
176    /// instead of being cast.
177    #[serde(default = "default_escalate")]
178    pub escalate_below: f64,
179    /// Days an answer is kept and given again for an identical request, at
180    /// no cost; 0 turns the cache off. Jev keeps no cache of its own.
181    #[serde(default = "default_cache_days")]
182    pub cache_days: u64,
183}
184
185fn default_model() -> String {
186    "jev-1.13.0".into()
187}
188fn default_budget() -> u64 {
189    2000
190}
191fn default_endpoint() -> String {
192    "https://api.typesafe.ai/v1/systemone".into()
193}
194fn default_monthly() -> f64 {
195    4.0
196}
197fn default_min_words() -> usize {
198    4
199}
200fn default_min_candidates() -> usize {
201    2
202}
203fn default_cache_days() -> u64 {
204    7
205}
206fn default_escalate() -> f64 {
207    0.8
208}
209fn default_cut() -> f64 {
210    0.5
211}
212fn default_price_in() -> f64 {
213    0.042
214}
215
216/// What a call cost: the API's own figure when it sends one (OpenRouter
217/// does), else the input tokens at the configured price.
218fn cost_of(body: &Value, usd_per_mtok_in: f64) -> f64 {
219    body["usage"]["cost"].as_f64().unwrap_or_else(|| {
220        body["usage"]["input_tokens"].as_f64().unwrap_or(0.0) * usd_per_mtok_in / 1e6
221    })
222}
223
224/// The longest prompt the state carries; a pasted log past it adds cost
225/// and no judgment.
226const PROMPT_CHARS: usize = 2000;
227
228fn config_path() -> PathBuf {
229    std::env::var_os("XDG_CONFIG_HOME")
230        .filter(|v| !v.is_empty())
231        .map(PathBuf::from)
232        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
233        .unwrap_or_else(|| PathBuf::from(".config"))
234        .join("ljos")
235        .join("jev.toml")
236}
237
238fn expand(path: &str) -> PathBuf {
239    match path.strip_prefix("~/") {
240        Some(rest) => std::env::var_os("HOME")
241            .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
242        None => PathBuf::from(path),
243    }
244}
245
246fn read_config() -> Option<Config> {
247    let text = std::fs::read_to_string(config_path()).ok()?;
248    toml::from_str(&text).ok()
249}
250
251/// Whether this machine turned Jev on, key or not. The hook's local path
252/// then skips the cross-encoder, which is what Jev stands in for.
253#[must_use]
254pub fn enabled() -> bool {
255    read_config().is_some_and(|c| c.enabled)
256}
257
258/// The key from the first line of what a key file or command holds. A
259/// `name: value` or `name=value` line gives its value.
260fn key_from(text: &str) -> Option<String> {
261    let line = text.lines().next()?.trim();
262    let value = line
263        .rsplit(|c: char| c == ':' || c == '=' || c.is_whitespace())
264        .next()
265        .unwrap_or(line)
266        .trim();
267    (!value.is_empty()).then(|| value.to_string())
268}
269
270fn key_cache(judge: &str) -> Option<PathBuf> {
271    let dir = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty())?;
272    let file = if judge == "default" {
273        "jev-key".to_string()
274    } else {
275        let safe: String = judge
276            .chars()
277            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
278            .collect();
279        format!("jev-key-{safe}")
280    };
281    Some(PathBuf::from(dir).join("ljos").join(file))
282}
283
284/// Run the key command once, with no terminal to prompt on and three
285/// seconds to answer, and hold what it printed for the rest of the login.
286fn key_by_command(judge: &str, argv: &[String]) -> Option<String> {
287    use std::io::Write;
288    use std::os::unix::fs::OpenOptionsExt;
289    let cache = key_cache(judge);
290    if let Some(key) = cache
291        .as_ref()
292        .and_then(|p| std::fs::read_to_string(p).ok())
293        .and_then(|t| key_from(&t))
294    {
295        return Some(key);
296    }
297    let (prog, args) = argv.split_first()?;
298    let out = std::process::Command::new("timeout")
299        .arg("3")
300        .arg(prog)
301        .args(args)
302        .stdin(std::process::Stdio::null())
303        .stderr(std::process::Stdio::null())
304        .output()
305        .ok()?;
306    if !out.status.success() {
307        return None;
308    }
309    let key = key_from(&String::from_utf8_lossy(&out.stdout))?;
310    if let Some(path) = cache {
311        let _ = std::fs::create_dir_all(path.parent()?);
312        if let Ok(mut f) = std::fs::OpenOptions::new()
313            .write(true)
314            .create(true)
315            .truncate(true)
316            .mode(0o600)
317            .open(&path)
318        {
319            let _ = writeln!(f, "{key}");
320        }
321    }
322    Some(key)
323}
324
325impl Config {
326    /// The judge the top-level keys describe.
327    #[must_use]
328    pub fn default_judge(&self) -> Judge {
329        Judge {
330            backend: self.backend,
331            command: self.command.clone(),
332            key_file: self.key_file.clone(),
333            key_cmd: self.key_cmd.clone(),
334            key_env: self.key_env.clone(),
335            model: self.model.clone(),
336            endpoint: self.endpoint.clone(),
337            budget_ms: self.budget_ms,
338            usd_per_mtok_in: self.usd_per_mtok_in,
339            weight: 1.0,
340        }
341    }
342
343    /// The judge called `name`; `default` is the top-level one.
344    #[must_use]
345    pub fn judge(&self, name: &str) -> Option<Judge> {
346        if name == "default" {
347            Some(self.default_judge())
348        } else {
349            self.judges.get(name).cloned()
350        }
351    }
352
353    /// The names that answer `decision`, as the route gives them.
354    #[must_use]
355    pub fn route_of(&self, decision: &str) -> Vec<String> {
356        self.route
357            .get(decision)
358            .filter(|r| !r.is_empty())
359            .cloned()
360            .unwrap_or_else(|| vec!["default".to_string()])
361    }
362}
363
364/// The judge's key: a command, a file or an environment variable; empty
365/// for a backend that needs none. `None` when the source gives nothing.
366fn judge_key(name: &str, j: &Judge) -> Option<String> {
367    if let Some(argv) = &j.key_cmd {
368        return key_by_command(name, argv);
369    }
370    if let Some(file) = &j.key_file {
371        return key_from(&std::fs::read_to_string(expand(file)).ok()?);
372    }
373    if let Some(var) = &j.key_env {
374        return std::env::var(var).ok().filter(|k| !k.trim().is_empty());
375    }
376    (!j.backend.needs_key()).then(String::new)
377}
378
379/// Whether a judge can be asked at all: its key is there and a command
380/// judge has a command.
381fn usable(name: &str, j: &Judge) -> Option<String> {
382    if j.backend == Backend::Command && j.command.as_ref().is_none_or(Vec::is_empty) {
383        return None;
384    }
385    judge_key(name, j)
386}
387
388/// The judges that answer `decision`, each with its key; an unknown name
389/// or a judge with no key is left out.
390#[must_use]
391pub fn judges_for(cfg: &Config, decision: &str) -> Vec<(String, Judge, String)> {
392    named_judges(cfg, cfg.route_of(decision))
393}
394
395fn named_judges(cfg: &Config, names: Vec<String>) -> Vec<(String, Judge, String)> {
396    names
397        .into_iter()
398        .filter_map(|name| {
399            let j = cfg.judge(&name)?;
400            let key = usable(&name, &j)?;
401            Some((name, j, key))
402        })
403        .collect()
404}
405
406/// The machine's setting, when it turned judging on, the month's spend is
407/// under its cap, and at least one judge for some decision can be asked,
408/// with the default judge's key (empty when it has none).
409#[must_use]
410pub fn config() -> Option<(Config, String)> {
411    let cfg = read_config()?;
412    if !cfg.enabled || month_cost().unwrap_or(0.0) >= cfg.monthly_usd {
413        return None;
414    }
415    let any = DECISIONS
416        .iter()
417        .any(|(d, _)| !judges_for(&cfg, d).is_empty());
418    let key = usable("default", &cfg.default_judge()).unwrap_or_default();
419    any.then_some((cfg, key))
420}
421
422/// What Jev said about one prompt.
423#[derive(Debug, Clone, Default, PartialEq)]
424pub struct Judgment {
425    /// Probability that each candidate, by its index, bears on the prompt.
426    pub bears: Vec<f64>,
427    /// Probability the prompt corrects something the agent did or forgot.
428    pub correction: f64,
429    /// Probability the prompt puts a choice between options to the agent.
430    pub choice: f64,
431    /// Probability the prompt, or text pasted into it, carries instructions
432    /// addressed to the agent that the person did not write. `None` when
433    /// the answer did not include it.
434    pub injection: Option<f64>,
435    /// How much reasoning the prompt asks for, as Jev's probability-weighted
436    /// mean over the levels 0 (a lookup) to 3 (a design or a hard debug).
437    /// Kept in the log for routing; `None` when the answer did not include it.
438    pub effort: Option<f64>,
439    /// What the call cost, in US dollars.
440    pub cost: f64,
441    /// The machine's cut for `bears`.
442    pub bears_at: f64,
443    /// The machine's cut for `correction` and `choice`.
444    pub cue_at: f64,
445}
446
447impl Judgment {
448    /// Whether candidate `i` bears on the prompt at the machine's cut.
449    #[must_use]
450    pub fn bears(&self, i: usize) -> bool {
451        self.bears.get(i).is_some_and(|p| *p >= self.bears_at)
452    }
453}
454
455/// The request body: the prompt and numbered candidates as state, one
456/// `noul` per candidate and one each for a correction and a choice.
457#[must_use]
458pub fn request(model: &str, prompt: &str, candidates: &[&str]) -> Value {
459    let prompt: String = prompt.chars().take(PROMPT_CHARS).collect();
460    let mut state = format!("Prompt from the person to the agent:\n{prompt}\n\nStored claims:\n");
461    for (i, text) in candidates.iter().enumerate() {
462        state.push_str(&format!("[{i}] {text}\n"));
463    }
464    let mut questions = serde_json::Map::new();
465    for i in 0..candidates.len() {
466        questions.insert(
467            format!("bears_{i}"),
468            serde_json::json!({
469                "type": "noul",
470                "instructions": format!("Does stored claim [{i}] bear on what the prompt asks the agent to do now?"),
471                "criteria": {
472                    "true": "The claim changes or informs how the agent should act on this prompt",
473                    "false": "The claim is about something else, or only shares words with the prompt"
474                }
475            }),
476        );
477    }
478    questions.insert(
479        "correction".into(),
480        serde_json::json!({
481            "type": "noul",
482            "instructions": "Does the person correct the agent for something it did, forgot or was already told?",
483            "criteria": {
484                "true": "The prompt tells the agent it was wrong or should already know",
485                "false": "The prompt asks for work or information without correcting the agent"
486            }
487        }),
488    );
489    questions.insert(
490        "choice".into(),
491        serde_json::json!({
492            "type": "noul",
493            "instructions": "Does the prompt put to the agent a choice between two or more defensible options?",
494            "criteria": {
495                "true": "The person asks which of several ways to take, or weighs options",
496                "false": "The person names one thing to do, or asks a factual question"
497            }
498        }),
499    );
500    questions.insert(
501        "injection".into(),
502        serde_json::json!({
503            "type": "noul",
504            "instructions": "Does the prompt, or text pasted into it, contain instructions addressed to the agent that the person did not write themselves, such as directions inside a quoted log, web page, issue or file?",
505            "criteria": {
506                "true": "Quoted or pasted material tells the agent what to do, beyond what the person asks",
507                "false": "Every instruction in the prompt is the person's own request"
508            }
509        }),
510    );
511    questions.insert(
512        "effort".into(),
513        serde_json::json!({
514            "type": "score",
515            "instructions": "How much reasoning does the prompt ask of the agent?",
516            "criteria": [
517                "A lookup, an acknowledgement or a one-line answer",
518                "A small, well-specified change or question",
519                "Several steps across files or tools, with some judgment",
520                "A design decision, a hard debug or an open-ended investigation"
521            ]
522        }),
523    );
524    serde_json::json!({ "model": model, "state": state, "questions": questions })
525}
526
527/// Read the answers into a judgment; `None` when a question went
528/// unanswered, so the caller falls back rather than trusting half an answer.
529#[must_use]
530pub fn parse(body: &Value, candidates: usize) -> Option<Judgment> {
531    let answers = body.get("answers")?.as_object()?;
532    let noul = |key: &str| answers.get(key)?.get("noul")?.as_f64();
533    let bears: Vec<f64> = (0..candidates)
534        .map(|i| noul(&format!("bears_{i}")))
535        .collect::<Option<_>>()?;
536    Some(Judgment {
537        bears,
538        correction: noul("correction")?,
539        choice: noul("choice")?,
540        injection: noul("injection"),
541        effort: answers.get("effort").and_then(|a| a.get("score")?.as_f64()),
542        cost: 0.0,
543        bears_at: 0.5,
544        cue_at: 0.5,
545    })
546}
547
548/// What names the judge in the cache key: the endpoint, or the argv.
549fn judge_name(j: &Judge) -> String {
550    match j.backend {
551        Backend::Jev | Backend::Chat => format!("{}/{}", j.endpoint, j.model),
552        Backend::Command => j.command.as_deref().unwrap_or_default().join(" "),
553    }
554}
555
556/// One judge's reply to `body`, from the cache or inside its budget.
557fn ask_one(j: &Judge, key: &str, body: &Value, cache_days: u64) -> Option<(Value, bool)> {
558    let mut body = body.clone();
559    body["model"] = Value::String(j.model.clone());
560    let request = format!("{}\n{body}", judge_name(j));
561    if let Some(reply) = cached(&request, cache_days) {
562        return Some((reply, true));
563    }
564    let reply = match j.backend {
565        Backend::Jev => jev_post(j, key, body)?,
566        Backend::Chat => chat_post(j, key, &body)?,
567        Backend::Command => command_post(j, &body)?,
568    };
569    reply.get("answers")?;
570    if cache_days > 0 {
571        keep(&request, &reply);
572    }
573    Some((reply, false))
574}
575
576/// Ask every judge the route names for `kind` at once, each inside its
577/// own budget, and pool what came back; record the cost and log each
578/// judge's answers beside the pool. `None` when no judge answered.
579fn post(cfg: &Config, body: Value, kind: &str, about: Value) -> Option<Value> {
580    let decision = DECISIONS
581        .iter()
582        .find(|(_, k)| *k == kind)
583        .map_or(kind, |(d, _)| *d);
584    let judges = judges_for(cfg, decision);
585    if judges.is_empty() {
586        return None;
587    }
588    let replies = ask_all(&judges, &body, cfg.cache_days);
589    finish_post(&body, kind, about, replies)
590}
591
592type Reply = (String, f64, Value, bool, f64);
593
594/// Ask each judge at once, each inside its own budget; the ones that
595/// answered, with their weight, reply, whether it was cached and its cost.
596fn ask_all(judges: &[(String, Judge, String)], body: &Value, cache_days: u64) -> Vec<Reply> {
597    std::thread::scope(|scope| {
598        let handles: Vec<_> = judges
599            .iter()
600            .map(|(name, j, key)| {
601                scope.spawn(move || {
602                    ask_one(j, key, body, cache_days).map(|(reply, hit)| {
603                        let cost = if hit {
604                            0.0
605                        } else {
606                            cost_of(&reply, j.usd_per_mtok_in)
607                        };
608                        (name.clone(), j.weight, reply, hit, cost)
609                    })
610                })
611            })
612            .collect();
613        handles
614            .into_iter()
615            .filter_map(|h| h.join().ok().flatten())
616            .collect()
617    })
618}
619
620/// Pool the replies, record the cost and log every judge's answer and why.
621fn finish_post(body: &Value, kind: &str, about: Value, replies: Vec<Reply>) -> Option<Value> {
622    let body = body.clone();
623    if replies.is_empty() {
624        return None;
625    }
626    let cost: f64 = replies.iter().map(|r| r.4).sum();
627    if replies.iter().all(|r| r.3) {
628        count("cached");
629    } else {
630        record_cost(cost);
631    }
632    let weighted: Vec<(f64, Value)> = replies
633        .iter()
634        .map(|(_, w, reply, _, _)| (*w, reply["answers"].clone()))
635        .collect();
636    let answers = if replies.len() == 1 {
637        replies[0].2["answers"].clone()
638    } else {
639        pool(&body, &weighted)
640    };
641    let per: serde_json::Map<String, Value> = replies
642        .iter()
643        .map(|(name, _, reply, _, _)| (name.clone(), reply["answers"].clone()))
644        .collect();
645    let why: serde_json::Map<String, Value> = replies
646        .iter()
647        .filter_map(|(name, _, reply, _, _)| {
648            reply["why"]
649                .as_str()
650                .map(|w| (name.clone(), Value::String(w.to_string())))
651        })
652        .collect();
653    log(&serde_json::json!({
654        "ts": crate::now_utc(),
655        "kind": kind,
656        "judges": replies.iter().map(|r| r.0.clone()).collect::<Vec<_>>(),
657        "about": about,
658        "answers": answers,
659        "per_judge": per,
660        "why": why,
661        "cost": cost,
662    }));
663    Some(serde_json::json!({
664        "answers": answers,
665        "usage": {"cost": cost},
666    }))
667}
668
669/// One question's answers pooled across judges, weighted. A question no
670/// judge answered stays missing, so the parser refuses the pool as it
671/// refuses a partial reply.
672#[must_use]
673pub fn pool(body: &Value, replies: &[(f64, Value)]) -> Value {
674    const EPS: f64 = 0.01;
675    let logit = |p: f64| {
676        let p = p.clamp(EPS, 1.0 - EPS);
677        (p / (1.0 - p)).ln()
678    };
679    let mut out = serde_json::Map::new();
680    let Some(questions) = body["questions"].as_object() else {
681        return Value::Object(out);
682    };
683    for (name, q) in questions {
684        let given: Vec<(f64, &Value)> = replies
685            .iter()
686            .filter_map(|(w, a)| a.get(name).map(|x| (*w, x)))
687            .collect();
688        let total: f64 = given.iter().map(|g| g.0).sum();
689        if given.is_empty() || total <= 0.0 {
690            continue;
691        }
692        match q["type"].as_str().unwrap_or("noul") {
693            "score" => {
694                let v: Vec<(f64, f64)> = given
695                    .iter()
696                    .filter_map(|(w, a)| Some((*w, a["score"].as_f64()?)))
697                    .collect();
698                let t: f64 = v.iter().map(|x| x.0).sum();
699                if t > 0.0 {
700                    let s = v.iter().map(|(w, x)| w * x).sum::<f64>() / t;
701                    out.insert(
702                        name.clone(),
703                        serde_json::json!({"type": "score", "score": s}),
704                    );
705                }
706            }
707            "choice" => {
708                let keys: Vec<String> = q["criteria"]
709                    .as_object()
710                    .map(|m| m.keys().cloned().collect())
711                    .unwrap_or_default();
712                let mut logp: BTreeMap<String, f64> = BTreeMap::new();
713                let mut t = 0.0;
714                for (w, a) in &given {
715                    let Some(probs) = a["probabilities"].as_object() else {
716                        continue;
717                    };
718                    t += w;
719                    for k in &keys {
720                        let p = probs.get(k).and_then(Value::as_f64).unwrap_or(0.0).max(EPS);
721                        *logp.entry(k.clone()).or_default() += w * p.ln();
722                    }
723                }
724                if t <= 0.0 || logp.is_empty() {
725                    continue;
726                }
727                let raw: BTreeMap<String, f64> =
728                    logp.into_iter().map(|(k, l)| (k, (l / t).exp())).collect();
729                let z: f64 = raw.values().sum();
730                let probs: serde_json::Map<String, Value> = raw
731                    .iter()
732                    .map(|(k, p)| (k.clone(), Value::from(p / z)))
733                    .collect();
734                let choice = raw
735                    .iter()
736                    .max_by(|a, b| a.1.total_cmp(b.1))
737                    .map(|(k, _)| k.clone())
738                    .unwrap_or_default();
739                let confidence = concentration(&probs).unwrap_or(1.0);
740                out.insert(
741                    name.clone(),
742                    serde_json::json!({"type": "choice", "choice": choice, "confidence": confidence, "probabilities": probs}),
743                );
744            }
745            _ => {
746                let v: Vec<(f64, f64)> = given
747                    .iter()
748                    .filter_map(|(w, a)| Some((*w, a["noul"].as_f64()?)))
749                    .collect();
750                let t: f64 = v.iter().map(|x| x.0).sum();
751                if t > 0.0 {
752                    let l = v.iter().map(|(w, p)| w * logit(*p)).sum::<f64>() / t;
753                    let p = 1.0 / (1.0 + (-l).exp());
754                    out.insert(name.clone(), serde_json::json!({"type": "noul", "noul": p}));
755                }
756            }
757        }
758    }
759    Value::Object(out)
760}
761
762/// The request as Jev takes it: the body as is, the key as a bearer.
763fn jev_post(cfg: &Judge, key: &str, body: Value) -> Option<Value> {
764    ureq::post(&cfg.endpoint)
765        .timeout(Duration::from_millis(cfg.budget_ms))
766        .set("Authorization", &format!("Bearer {key}"))
767        .set("Content-Type", "application/json")
768        .send_json(body)
769        .ok()?
770        .into_json()
771        .ok()
772}
773
774/// What a chat model is told about the answer shape, so its reply reads
775/// as Jev's does.
776const CHAT_SYSTEM: &str = "You judge questions about a state and answer with one JSON object and nothing else: \
777{\"answers\": {<question name>: <answer>, ...}}, one answer per question, under the question's name. \
778A question of type \"noul\" takes {\"noul\": p}: p is the probability, from 0 to 1, that the statement in its \
779instructions is true, judged by its criteria. A question of type \"choice\" takes {\"choice\": <one key of its \
780criteria>, \"confidence\": p, \"probabilities\": {<key>: p, ...}} over every key, summing to 1. \
781Answer every question. Calibrate: 0.5 means you do not know.";
782
783/// A Jev request as one chat completion: the state and the questions in
784/// the user turn, the answer shape in the system turn, JSON mode on.
785#[must_use]
786pub fn chat_request(model: &str, body: &Value) -> Value {
787    let state = body["state"].as_str().unwrap_or("");
788    let questions = serde_json::to_string_pretty(&body["questions"]).unwrap_or_default();
789    serde_json::json!({
790        "model": model,
791        "temperature": 0,
792        "response_format": {"type": "json_object"},
793        "messages": [
794            {"role": "system", "content": CHAT_SYSTEM},
795            {"role": "user", "content": format!("State:\n{state}\n\nQuestions:\n{questions}\n")}
796        ]
797    })
798}
799
800/// The JSON object in a chat reply's content, with a code fence stripped.
801fn content_json(reply: &Value) -> Option<Value> {
802    text_json(reply["choices"][0]["message"]["content"].as_str()?)
803}
804
805/// The first JSON object in `text`, a code fence stripped.
806#[must_use]
807pub fn text_json(text: &str) -> Option<Value> {
808    let text = text.trim();
809    let text = text
810        .strip_prefix("```json")
811        .or_else(|| text.strip_prefix("```"))
812        .and_then(|t| t.strip_suffix("```"))
813        .map_or(text, str::trim);
814    serde_json::from_str(text).ok().or_else(|| {
815        let start = text.find('{')?;
816        let end = text.rfind('}')?;
817        serde_json::from_str(&text[start..=end]).ok()
818    })
819}
820
821/// The answers a chat model gave, in Jev's shape: a bare number or a
822/// boolean under a `noul` question becomes `{"noul": p}`, and a `choice`
823/// answer gets the confidence and probabilities it left out. A question
824/// with no answer stays missing, so the parser refuses the reply.
825#[must_use]
826pub fn chat_answers(body: &Value, content: &Value) -> Value {
827    let given = content.get("answers").unwrap_or(content);
828    let mut answers = serde_json::Map::new();
829    let Some(questions) = body["questions"].as_object() else {
830        return Value::Object(answers);
831    };
832    for (name, q) in questions {
833        let Some(a) = given.get(name) else { continue };
834        let kind = q["type"].as_str().unwrap_or("noul");
835        let fixed = if kind == "score" {
836            let Some(score) = a["score"].as_f64().or_else(|| a.as_f64()) else {
837                continue;
838            };
839            let levels = q["criteria"].as_array().map_or(0, Vec::len);
840            let top = levels.saturating_sub(1) as f64;
841            serde_json::json!({"type": "score", "score": score.clamp(0.0, top.max(0.0))})
842        } else if kind == "choice" {
843            let Some(choice) = a["choice"].as_str().or_else(|| a.as_str()) else {
844                continue;
845            };
846            let mut probs: serde_json::Map<String, Value> =
847                a["probabilities"].as_object().cloned().unwrap_or_default();
848            // Jev's confidence measures how concentrated the distribution
849            // is, not the chosen option's probability; a reply without it
850            // gets one minus the normalised entropy of its probabilities.
851            let confidence = a["confidence"]
852                .as_f64()
853                .or_else(|| concentration(&probs))
854                .unwrap_or(1.0);
855            if probs.is_empty() {
856                probs.insert(choice.to_string(), Value::from(confidence));
857            }
858            serde_json::json!({
859                "type": "choice",
860                "choice": choice,
861                "confidence": confidence,
862                "probabilities": probs,
863            })
864        } else {
865            let p = a["noul"]
866                .as_f64()
867                .or_else(|| a.as_f64())
868                .or_else(|| a.as_bool().map(|b| if b { 1.0 } else { 0.0 }));
869            let Some(p) = p else { continue };
870            serde_json::json!({"type": "noul", "noul": p.clamp(0.0, 1.0)})
871        };
872        answers.insert(name.clone(), fixed);
873    }
874    Value::Object(answers)
875}
876
877/// One minus the normalised Shannon entropy of a distribution: 1 when all
878/// the mass is on one option, 0 when it is spread evenly. `None` for fewer
879/// than two options, where concentration says nothing.
880#[must_use]
881pub fn concentration(probs: &serde_json::Map<String, Value>) -> Option<f64> {
882    let p: Vec<f64> = probs.values().filter_map(Value::as_f64).collect();
883    if p.len() < 2 {
884        return None;
885    }
886    let total: f64 = p.iter().sum();
887    if total <= 0.0 {
888        return None;
889    }
890    let entropy: f64 = p
891        .iter()
892        .map(|x| x / total)
893        .filter(|x| *x > 0.0)
894        .map(|x| -x * x.ln())
895        .sum();
896    Some((1.0 - entropy / (p.len() as f64).ln()).clamp(0.0, 1.0))
897}
898
899/// One chat completion at `{endpoint}/chat/completions`, read back into
900/// Jev's shape with the prompt tokens as the usage.
901fn chat_post(cfg: &Judge, key: &str, body: &Value) -> Option<Value> {
902    let url = format!("{}/chat/completions", cfg.endpoint.trim_end_matches('/'));
903    let mut req = ureq::post(&url)
904        .timeout(Duration::from_millis(cfg.budget_ms))
905        .set("Content-Type", "application/json");
906    if !key.is_empty() {
907        req = req.set("Authorization", &format!("Bearer {key}"));
908    }
909    let reply: Value = req
910        .send_json(chat_request(&cfg.model, body))
911        .ok()?
912        .into_json()
913        .ok()?;
914    let content = content_json(&reply)?;
915    Some(serde_json::json!({
916        "answers": chat_answers(body, &content),
917        "usage": {"input_tokens": reply["usage"]["prompt_tokens"].as_f64().unwrap_or(0.0)},
918    }))
919}
920
921/// The command backend: the request on stdin, `{"answers": ...}` on
922/// stdout, inside the budget under `timeout`, as the key command runs.
923fn command_post(cfg: &Judge, body: &Value) -> Option<Value> {
924    use std::io::Write;
925    let argv = cfg.command.as_deref()?;
926    let (prog, args) = argv.split_first()?;
927    let secs = (cfg.budget_ms.div_ceil(1000)).max(1);
928    let mut child = std::process::Command::new("timeout")
929        .arg(secs.to_string())
930        .arg(prog)
931        .args(args)
932        .env("LJOS_JUDGE", "1")
933        .stdin(std::process::Stdio::piped())
934        .stdout(std::process::Stdio::piped())
935        .stderr(std::process::Stdio::null())
936        .spawn()
937        .ok()?;
938    child
939        .stdin
940        .take()?
941        .write_all(body.to_string().as_bytes())
942        .ok()?;
943    let out = child.wait_with_output().ok()?;
944    if !out.status.success() {
945        return None;
946    }
947    let content: Value = serde_json::from_slice(&out.stdout).ok()?;
948    let answers = chat_answers(body, &content);
949    (!answers.as_object()?.is_empty()).then(|| serde_json::json!({"answers": answers}))
950}
951
952/// Ask Jev about one prompt, inside the configured budget.
953#[must_use]
954pub fn judge(prompt: &str, candidates: &[&str]) -> Option<Judgment> {
955    let (cfg, _) = config()?;
956    let body = request(&cfg.model, prompt, candidates);
957    let reply = post(&cfg, body, "hook", Value::Null)?;
958    let mut judged = parse(&reply, candidates.len())?;
959    judged.cost = cost_of(&reply, cfg.usd_per_mtok_in);
960    judged.bears_at = cfg.bears_at;
961    judged.cue_at = cfg.cue_at;
962    Some(judged)
963}
964
965/// A persona's ballot as Jev answered it: the choice with its confidence
966/// and the probability of every option, and its forecast of the share each
967/// option gets from the rest of the panel.
968#[derive(Debug, Clone, PartialEq)]
969pub struct Ballot {
970    pub choice: String,
971    pub confidence: f64,
972    pub probabilities: BTreeMap<String, f64>,
973    pub forecast: BTreeMap<String, f64>,
974    /// The machine's cut under which the ballot goes to a subagent.
975    pub escalate_below: f64,
976}
977
978impl Ballot {
979    /// Whether Jev is too unsure for its answer to stand as the ballot.
980    #[must_use]
981    pub fn escalates(&self) -> bool {
982        self.confidence < self.escalate_below
983    }
984}
985
986/// The ballot request: the persona's brief as state, one `choice` for its
987/// own vote and one for what the rest of the panel will pick.
988#[must_use]
989pub fn ballot_request(model: &str, brief: &str, options: &[String]) -> Value {
990    let criteria = |verb: &str| -> Value {
991        options
992            .iter()
993            .map(|o| (o.clone(), Value::String(format!("{verb} {o}"))))
994            .collect::<serde_json::Map<_, _>>()
995            .into()
996    };
997    serde_json::json!({
998        "model": model,
999        "state": brief,
1000        "questions": {
1001            "ballot": {
1002                "type": "choice",
1003                "instructions": "You are the persona the state describes. Which option do you vote for, from your own view and what you know?",
1004                "criteria": criteria("vote for"),
1005            },
1006            "forecast": {
1007                "type": "choice",
1008                "instructions": "Which option will most of the other reviewers on this panel vote for?",
1009                "criteria": criteria("most others pick"),
1010            },
1011        }
1012    })
1013}
1014
1015/// Read a ballot answer; `None` when either question went unanswered or
1016/// the choice is not one of the options.
1017#[must_use]
1018pub fn parse_ballot(body: &Value, options: &[String]) -> Option<Ballot> {
1019    let answers = body.get("answers")?;
1020    let probs = |key: &str| -> Option<BTreeMap<String, f64>> {
1021        let map = answers.get(key)?.get("probabilities")?.as_object()?;
1022        Some(
1023            map.iter()
1024                .filter_map(|(k, v)| Some((k.clone(), v.as_f64()?)))
1025                .collect(),
1026        )
1027    };
1028    let ballot = answers.get("ballot")?;
1029    let choice = ballot.get("choice")?.as_str()?.to_string();
1030    if !options.contains(&choice) {
1031        return None;
1032    }
1033    Some(Ballot {
1034        confidence: ballot.get("confidence")?.as_f64()?,
1035        probabilities: probs("ballot")?,
1036        forecast: probs("forecast")?,
1037        choice,
1038        escalate_below: 0.8,
1039    })
1040}
1041
1042/// Ask Jev for a persona's ballot on an issue.
1043#[must_use]
1044pub fn ballot(persona: &str, issue: &str, brief: &str, options: &[String]) -> Option<Ballot> {
1045    let (cfg, _) = config()?;
1046    let body = ballot_request(&cfg.model, brief, options);
1047    let about = serde_json::json!({"issue": issue, "persona": persona, "options": options});
1048    let reply = post(&cfg, body, "ballot", about)?;
1049    let mut b = parse_ballot(&reply, options)?;
1050    b.escalate_below = cfg.escalate_below;
1051    Some(b)
1052}
1053
1054/// `$XDG_CACHE_HOME/ljos/jev`, one file per request.
1055fn cache_dir() -> Option<PathBuf> {
1056    Some(
1057        std::env::var_os("XDG_CACHE_HOME")
1058            .filter(|v| !v.is_empty())
1059            .map(PathBuf::from)
1060            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?
1061            .join("ljos")
1062            .join("jev"),
1063    )
1064}
1065
1066/// The file an identical request lands in. The hash only names the file;
1067/// the file holds the whole request, and a hit must match it exactly.
1068fn cache_file(request: &str) -> Option<PathBuf> {
1069    use std::hash::{Hash, Hasher};
1070    let mut h = std::collections::hash_map::DefaultHasher::new();
1071    request.hash(&mut h);
1072    Some(cache_dir()?.join(format!("{:016x}.json", h.finish())))
1073}
1074
1075/// The answer to an identical request made within `days`.
1076fn cached(request: &str, days: u64) -> Option<Value> {
1077    if days == 0 {
1078        return None;
1079    }
1080    let path = cache_file(request)?;
1081    let age = std::fs::metadata(&path)
1082        .ok()?
1083        .modified()
1084        .ok()?
1085        .elapsed()
1086        .ok()?;
1087    if age > Duration::from_secs(days * 86_400) {
1088        let _ = std::fs::remove_file(&path);
1089        return None;
1090    }
1091    let entry: Value = serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
1092    (entry["request"].as_str() == Some(request)).then(|| entry["reply"].clone())
1093}
1094
1095fn keep(request: &str, reply: &Value) {
1096    let Some(path) = cache_file(request) else {
1097        return;
1098    };
1099    if let Some(dir) = path.parent() {
1100        let _ = std::fs::create_dir_all(dir);
1101    }
1102    let entry = serde_json::json!({"request": request, "reply": reply});
1103    let _ = std::fs::write(path, entry.to_string());
1104}
1105
1106/// Add one to this month's tally named `what` (`calls`, `cached`).
1107fn count(what: &str) {
1108    let Some(dir) = state_dir() else { return };
1109    let _ = std::fs::create_dir_all(&dir);
1110    let path = dir.join("jev-cost.toml");
1111    let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
1112        .ok()
1113        .and_then(|t| toml::from_str(&t).ok())
1114        .unwrap_or_default();
1115    *totals
1116        .entry(format!("{}-{what}", this_month()))
1117        .or_default() += 1.0;
1118    if let Ok(text) = toml::to_string(&totals) {
1119        let _ = std::fs::write(path, text);
1120    }
1121}
1122
1123/// The stop audit's cuts. A stop is held back only on a near-certain
1124/// answer: the model is asked about the agent's own words, and a false
1125/// block costs the person a turn.
1126pub const AUDIT_CLAIM_AT: f64 = 0.9;
1127/// The test run shown counts as red at or under this.
1128pub const AUDIT_RED_BELOW: f64 = 0.1;
1129/// The final message counts as deferring asked work at or over this.
1130pub const AUDIT_DEFER_AT: f64 = 0.9;
1131
1132/// What Jev said about an agent about to stop.
1133#[derive(Debug, Clone, Copy, PartialEq)]
1134pub struct Audit {
1135    /// The final message claims the work is done, passing or ready.
1136    pub claims_complete: f64,
1137    /// The last test output shown passes with no failure.
1138    pub tests_green: f64,
1139    /// The final message puts part of the asked work off, or out of scope.
1140    pub deferral: f64,
1141}
1142
1143/// The audit request: the turn as state, three nouls.
1144#[must_use]
1145pub fn audit_request(model: &str, state: &str) -> Value {
1146    serde_json::json!({
1147        "model": model,
1148        "state": state,
1149        "questions": {
1150            "claims_complete": {
1151                "type": "noul",
1152                "instructions": "Does the agent's final message claim the asked work is done, complete, passing, green or ready?",
1153                "criteria": {
1154                    "true": "It says the work is finished or the tests pass",
1155                    "false": "It reports progress, a failure, a question or what is still open"
1156                }
1157            },
1158            "tests_green": {
1159                "type": "noul",
1160                "instructions": "Does the most recent test output in the state pass, with no failed, errored or crashed test?",
1161                "criteria": {
1162                    "true": "The latest run reports every test passing",
1163                    "false": "The latest run reports a failure, an error, a crash or a build that did not finish"
1164                }
1165            },
1166            "deferral": {
1167                "type": "noul",
1168                "instructions": "Does the final message put part of what the person asked off to later, or call it out of scope, without naming something outside the agent's control that blocks it?",
1169                "criteria": {
1170                    "true": "It leaves asked work for a later change, session or person, with no external block",
1171                    "false": "It finishes the asked work, or names a real block such as a missing credential or a failing external service"
1172                }
1173            }
1174        }
1175    })
1176}
1177
1178/// Read the audit; `None` on a partial answer.
1179#[must_use]
1180pub fn parse_audit(body: &Value) -> Option<Audit> {
1181    let a = body.get("answers")?;
1182    let noul = |k: &str| a.get(k)?.get("noul")?.as_f64();
1183    Some(Audit {
1184        claims_complete: noul("claims_complete")?,
1185        tests_green: noul("tests_green")?,
1186        deferral: noul("deferral")?,
1187    })
1188}
1189
1190/// Ask Jev about a turn that is about to end.
1191#[must_use]
1192pub fn audit(state: &str) -> Option<Audit> {
1193    let (cfg, _) = config()?;
1194    let body = audit_request(&cfg.model, state);
1195    let reply = post(&cfg, body, "stop-audit", Value::Null)?;
1196    parse_audit(&reply)
1197}
1198
1199/// The probability at or over which a reviewed claim is graded recalled.
1200pub const REVIEW_HOLDS_AT: f64 = 0.9;
1201/// At or under this a reviewed claim is reported as contradicted, for the
1202/// agent to supersede or withdraw; a judge does not lapse it.
1203pub const REVIEW_FAILS_AT: f64 = 0.1;
1204
1205/// The review request: the claim and the newer claims about the same
1206/// thing as state, one noul on whether it still holds.
1207#[must_use]
1208pub fn review_request(model: &str, claim: &str, newer: &[&str]) -> Value {
1209    let mut state = format!(
1210        "Stored claim under review:\n{claim}\n\nNewer stored claims on the same subject:\n"
1211    );
1212    if newer.is_empty() {
1213        state.push_str("(none)\n");
1214    }
1215    for (i, t) in newer.iter().enumerate() {
1216        state.push_str(&format!("[{i}] {t}\n"));
1217    }
1218    serde_json::json!({
1219        "model": model,
1220        "state": state,
1221        "questions": {
1222            "holds": {
1223                "type": "noul",
1224                "instructions": "Does the claim under review still hold, given the newer claims? With no newer claim, does it read as a durable fact or rule rather than a passing observation?",
1225                "criteria": {
1226                    "true": "Nothing newer contradicts or replaces it, and it states something that stays true",
1227                    "false": "A newer claim contradicts, corrects or replaces it, or it described a state that has passed"
1228                }
1229            }
1230        }
1231    })
1232}
1233
1234/// Ask the review judges whether a claim still holds.
1235#[must_use]
1236pub fn review(id: &str, claim: &str, newer: &[&str]) -> Option<f64> {
1237    let (cfg, _) = config()?;
1238    let body = review_request(&cfg.model, claim, newer);
1239    let reply = post(&cfg, body, "review", serde_json::json!({"id": id}))?;
1240    reply["answers"]["holds"]["noul"].as_f64()
1241}
1242
1243/// Why a stop is held back, from the audit and whether a test ran in the
1244/// turn; `None` lets the agent stop.
1245#[must_use]
1246pub fn audit_reason(a: &Audit, test_ran: bool) -> Option<String> {
1247    if test_ran && a.claims_complete >= AUDIT_CLAIM_AT && a.tests_green <= AUDIT_RED_BELOW {
1248        return Some(
1249            "The final message says the work is done, and the last test run shown is red. \
1250             Say what still fails, or fix it, before stopping."
1251                .to_string(),
1252        );
1253    }
1254    if a.deferral >= AUDIT_DEFER_AT {
1255        return Some(
1256            "The final message leaves part of the asked work for later without naming what blocks it. \
1257             Do that part, or say in one sentence what outside the work blocks it."
1258                .to_string(),
1259        );
1260    }
1261    None
1262}
1263
1264fn state_dir() -> Option<PathBuf> {
1265    Some(
1266        std::env::var_os("XDG_STATE_HOME")
1267            .filter(|v| !v.is_empty())
1268            .map(PathBuf::from)
1269            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?
1270            .join("ljos"),
1271    )
1272}
1273
1274/// Every answer Jev gave, one JSON line each in the state directory, so
1275/// its probabilities can be scored once the outcomes are known.
1276fn log(entry: &Value) {
1277    use std::io::Write;
1278    let Some(dir) = state_dir() else { return };
1279    let _ = std::fs::create_dir_all(&dir);
1280    if let Ok(mut f) = std::fs::OpenOptions::new()
1281        .create(true)
1282        .append(true)
1283        .open(dir.join("jev-log.jsonl"))
1284    {
1285        let _ = writeln!(f, "{entry}");
1286    }
1287}
1288
1289/// Add a call's cost to this month's running total in the state directory,
1290/// so `ljos doctor` can say what Jev has cost.
1291fn record_cost(cost: f64) {
1292    let Some(dir) = state_dir() else { return };
1293    let _ = std::fs::create_dir_all(&dir);
1294    let month = crate::now_utc().chars().take(7).collect::<String>();
1295    let path = dir.join("jev-cost.toml");
1296    let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
1297        .ok()
1298        .and_then(|t| toml::from_str(&t).ok())
1299        .unwrap_or_default();
1300    *totals.entry(format!("{month}-calls")).or_default() += 1.0;
1301    *totals.entry(month).or_default() += cost;
1302    if let Ok(text) = toml::to_string(&totals) {
1303        let _ = std::fs::write(path, text);
1304    }
1305}
1306
1307fn month_totals() -> Option<BTreeMap<String, f64>> {
1308    let dir = state_dir()?;
1309    let text = std::fs::read_to_string(dir.join("jev-cost.toml")).ok()?;
1310    toml::from_str(&text).ok()
1311}
1312
1313fn this_month() -> String {
1314    crate::now_utc().chars().take(7).collect()
1315}
1316
1317/// This month's recorded Jev spend, in US dollars.
1318#[must_use]
1319pub fn month_cost() -> Option<f64> {
1320    month_totals()?.get(&this_month()).copied()
1321}
1322
1323/// This month's tally named `what`: `calls` made, `cached` answered from
1324/// the cache.
1325#[must_use]
1326pub fn month_count(what: &str) -> u64 {
1327    month_totals()
1328        .and_then(|t| t.get(&format!("{}-{what}", this_month())).copied())
1329        .unwrap_or(0.0) as u64
1330}
1331
1332/// How many calls this month made.
1333#[must_use]
1334pub fn month_calls() -> u64 {
1335    month_count("calls")
1336}
1337
1338/// The `jev` row in `ljos doctor`, only on a machine with a Jev file: off,
1339/// on without its key, or on with this month's spend. A setting that does
1340/// not parse is not ok, since the hook then keeps its local path silently.
1341#[must_use]
1342pub fn doctor_row() -> Option<crate::Habitat> {
1343    let text = std::fs::read_to_string(config_path()).ok()?;
1344    let (state, ok) = match toml::from_str::<Config>(&text) {
1345        Err(e) => (format!("{}: {e}", config_path().display()), false),
1346        Ok(cfg) if !cfg.enabled => ("off".to_string(), true),
1347        Ok(cfg) => {
1348            let spent = month_cost().unwrap_or(0.0);
1349            let routes: Vec<String> = DECISIONS
1350                .iter()
1351                .filter(|(d, _)| cfg.route.contains_key(*d))
1352                .map(|(d, _)| format!("{d}={}", cfg.route_of(d).join("+")))
1353                .collect();
1354            let head = format!(
1355                "{} {}{}  {} calls, {} cached  ${spent:.4} of ${:.2} this month",
1356                cfg.backend.name(),
1357                cfg.model,
1358                if routes.is_empty() {
1359                    String::new()
1360                } else {
1361                    format!("  {}", routes.join(" "))
1362                },
1363                month_calls(),
1364                month_count("cached"),
1365                cfg.monthly_usd
1366            );
1367            if spent >= cfg.monthly_usd {
1368                (format!("capped  {head}"), true)
1369            } else if config().is_none() {
1370                let why = if cfg.backend == Backend::Command {
1371                    "on, but no command is set"
1372                } else {
1373                    "on, but the key file or command gave no key"
1374                };
1375                (why.to_string(), false)
1376            } else {
1377                (format!("on  {head}"), true)
1378            }
1379        }
1380    };
1381    Some(crate::Habitat {
1382        name: "jev",
1383        state,
1384        ok,
1385    })
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391
1392    #[test]
1393    fn one_request_asks_about_every_candidate_and_both_cues() {
1394        let body = request("jev-1.13.0", "fix the ci", &["alpha claim", "beta claim"]);
1395        let q = body["questions"].as_object().unwrap();
1396        assert_eq!(
1397            q.len(),
1398            6,
1399            "two bears, correction, choice, injection, effort"
1400        );
1401        assert_eq!(q["bears_1"]["type"], "noul");
1402        assert_eq!(q["injection"]["type"], "noul");
1403        assert_eq!(q["effort"]["type"], "score");
1404        assert_eq!(q["effort"]["criteria"].as_array().unwrap().len(), 4);
1405        assert!(body["state"].as_str().unwrap().contains("[1] beta claim"));
1406    }
1407
1408    #[test]
1409    fn a_full_answer_is_read_and_a_partial_one_is_refused() {
1410        let full = serde_json::json!({
1411            "answers": {
1412                "bears_0": {"type": "noul", "noul": 0.9},
1413                "bears_1": {"type": "noul", "noul": 0.1},
1414                "correction": {"type": "noul", "noul": 0.2},
1415                "choice": {"type": "noul", "noul": 0.7}
1416            },
1417            "usage": {"input_tokens": 900, "output_tokens": 40, "cost": 0.0000378}
1418        });
1419        let j = parse(&full, 2).unwrap();
1420        assert_eq!(j.bears, vec![0.9, 0.1]);
1421        assert!(j.bears(0) && !j.bears(1));
1422        let strict = Judgment {
1423            bears_at: 0.95,
1424            ..j.clone()
1425        };
1426        assert!(!strict.bears(0), "a higher cut drops the 0.9");
1427        assert!((j.choice - 0.7).abs() < 1e-9);
1428        assert!(
1429            (cost_of(&full, 0.042) - 0.0000378).abs() < 1e-12,
1430            "the API's figure"
1431        );
1432        let direct = serde_json::json!({"usage": {"input_tokens": 1000, "output_tokens": 60}});
1433        assert!(
1434            (cost_of(&direct, 0.042) - 0.000042).abs() < 1e-12,
1435            "tokens at the price"
1436        );
1437        let partial = serde_json::json!({"answers": {"bears_0": {"noul": 0.9}}});
1438        assert!(parse(&partial, 2).is_none());
1439    }
1440
1441    #[test]
1442    fn jev_is_off_without_a_file_and_off_when_the_file_says_so() {
1443        let dir = tempfile::tempdir().unwrap();
1444        // Safety: the test sets and clears this for itself.
1445        unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
1446        assert!(config().is_none(), "no file, no call");
1447        std::fs::create_dir_all(dir.path().join("ljos")).unwrap();
1448        let key = dir.path().join("key");
1449        std::fs::write(&key, "sk-or-test\n").unwrap();
1450        std::fs::write(
1451            dir.path().join("ljos/jev.toml"),
1452            format!("enabled = false\nkey_file = \"{}\"\n", key.display()),
1453        )
1454        .unwrap();
1455        assert!(config().is_none(), "a file that says off is off");
1456        std::fs::write(
1457            dir.path().join("ljos/jev.toml"),
1458            format!("enabled = true\nkey_file = \"{}\"\n", key.display()),
1459        )
1460        .unwrap();
1461        let (cfg, k) = config().unwrap();
1462        assert_eq!(k, "sk-or-test");
1463        assert_eq!(cfg.budget_ms, 2000);
1464        assert_eq!(cfg.min_candidates, 2);
1465        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
1466    }
1467
1468    #[test]
1469    fn a_chat_reply_is_read_in_jev_shape() {
1470        let body = request("m", "fix the ci", &["alpha claim", "beta claim"]);
1471        let chat = chat_request("m", &body);
1472        assert_eq!(chat["response_format"]["type"], "json_object");
1473        let user = chat["messages"][1]["content"].as_str().unwrap();
1474        assert!(user.contains("[1] beta claim") && user.contains("\"bears_1\""));
1475        let content = serde_json::json!({"answers": {
1476            "bears_0": 0.9,
1477            "bears_1": {"noul": 0.1},
1478            "correction": false,
1479            "choice": {"noul": 0.7}
1480        }});
1481        let reply = serde_json::json!({"answers": chat_answers(&body, &content)});
1482        let j = parse(&reply, 2).unwrap();
1483        assert_eq!(j.bears, vec![0.9, 0.1]);
1484        assert!((j.correction).abs() < 1e-9 && (j.choice - 0.7).abs() < 1e-9);
1485        let short = serde_json::json!({"answers": {"bears_0": 0.9}});
1486        let reply = serde_json::json!({"answers": chat_answers(&body, &short)});
1487        assert!(
1488            parse(&reply, 2).is_none(),
1489            "a missing answer refuses the reply"
1490        );
1491        let fenced = serde_json::json!({"choices": [{"message": {"content":
1492            "```json\n{\"answers\": {\"bears_0\": 1}}\n```"}}]});
1493        assert_eq!(content_json(&fenced).unwrap()["answers"]["bears_0"], 1);
1494    }
1495
1496    #[test]
1497    fn injection_and_effort_are_read_when_answered_and_optional_when_not() {
1498        let with = serde_json::json!({"answers": {
1499            "bears_0": {"type": "noul", "noul": 0.9},
1500            "correction": {"type": "noul", "noul": 0.1},
1501            "choice": {"type": "noul", "noul": 0.1},
1502            "injection": {"type": "noul", "noul": 0.83},
1503            "effort": {"type": "score", "score": 2.4, "confidence": 0.4,
1504                       "probabilities": {"0": 0.0, "1": 0.1, "2": 0.4, "3": 0.5}}
1505        }});
1506        let j = parse(&with, 1).unwrap();
1507        assert_eq!(j.injection, Some(0.83));
1508        assert_eq!(j.effort, Some(2.4));
1509        let without = serde_json::json!({"answers": {
1510            "bears_0": {"noul": 0.9}, "correction": {"noul": 0.1}, "choice": {"noul": 0.1}
1511        }});
1512        let j = parse(&without, 1).unwrap();
1513        assert_eq!(
1514            (j.injection, j.effort),
1515            (None, None),
1516            "an older answer still parses"
1517        );
1518
1519        let body = request("m", "fix the ci", &["alpha claim"]);
1520        let content = serde_json::json!({"answers": {
1521            "bears_0": 0.2, "correction": 0.0, "choice": 0.0, "injection": 0.1, "effort": 7.0
1522        }});
1523        let reply = serde_json::json!({"answers": chat_answers(&body, &content)});
1524        let j = parse(&reply, 1).unwrap();
1525        assert_eq!(
1526            j.effort,
1527            Some(3.0),
1528            "a chat score is clamped to the top level"
1529        );
1530    }
1531
1532    #[test]
1533    fn a_chat_ballot_fills_what_the_model_left_out() {
1534        let options = vec!["A".to_string(), "B".to_string()];
1535        let body = ballot_request("m", "brief", &options);
1536        let content = serde_json::json!({"answers": {
1537            "ballot": {"choice": "A", "probabilities": {"A": 0.7, "B": 0.3}},
1538            "forecast": "B"
1539        }});
1540        let reply = serde_json::json!({"answers": chat_answers(&body, &content)});
1541        let b = parse_ballot(&reply, &options).unwrap();
1542        assert_eq!(b.choice, "A");
1543        let expected = 1.0 - (-(0.7f64 * 0.7f64.ln()) - 0.3 * 0.3f64.ln()) / 2f64.ln();
1544        assert!(
1545            (b.confidence - expected).abs() < 1e-9,
1546            "confidence is the concentration, not the chosen probability: {}",
1547            b.confidence
1548        );
1549        let even: serde_json::Map<String, Value> =
1550            serde_json::from_str(r#"{"A": 0.5, "B": 0.5}"#).unwrap();
1551        assert!(concentration(&even).unwrap().abs() < 1e-12);
1552        assert_eq!(
1553            b.forecast.get("B").copied(),
1554            Some(1.0),
1555            "a bare choice is a sure one"
1556        );
1557    }
1558
1559    #[test]
1560    fn the_command_backend_answers_from_stdout_and_needs_no_key() {
1561        let cfg: Config = toml::from_str(
1562            "enabled = true\nbackend = \"command\"\ncommand = [\"sh\", \"-c\", \
1563             \"cat >/dev/null; echo '{\\\"answers\\\": {\\\"bears_0\\\": 0.8, \\\"correction\\\": 0, \\\"choice\\\": 0.2}}'\"]\n",
1564        )
1565        .unwrap();
1566        assert_eq!(cfg.backend, Backend::Command);
1567        assert!(!cfg.backend.needs_key());
1568        let body = request("m", "fix the ci", &["alpha claim"]);
1569        let reply = command_post(&cfg.default_judge(), &body).unwrap();
1570        let j = parse(&reply, 1).unwrap();
1571        assert_eq!(j.bears, vec![0.8]);
1572        let silent: Config = toml::from_str(
1573            "enabled = true\nbackend = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null; echo {}\"]\n",
1574        )
1575        .unwrap();
1576        assert!(
1577            command_post(&silent.default_judge(), &body).is_none(),
1578            "no answer is a refusal"
1579        );
1580        let plain: Config = toml::from_str("enabled = true\n").unwrap();
1581        assert_eq!(plain.backend, Backend::Jev, "the default judge is Jev");
1582        assert!(plain.backend.needs_key());
1583    }
1584
1585    #[test]
1586    fn judges_are_named_and_routed_and_unknown_names_drop_out() {
1587        let cfg: Config = toml::from_str(concat!(
1588            "enabled = true\nbackend = \"command\"\ncommand = [\"true\"]\n",
1589            "[judges.local]\nbackend = \"command\"\ncommand = [\"true\"]\nweight = 2.0\n",
1590            "[judges.nokey]\nbackend = \"jev\"\n",
1591            "[route]\nballot = [\"default\", \"local\", \"nokey\", \"nobody\"]\n",
1592        ))
1593        .unwrap();
1594        assert_eq!(
1595            cfg.route_of("prompt"),
1596            ["default"],
1597            "an unrouted decision goes to default"
1598        );
1599        let names: Vec<String> = judges_for(&cfg, "ballot")
1600            .into_iter()
1601            .map(|j| j.0)
1602            .collect();
1603        assert_eq!(
1604            names,
1605            ["default", "local"],
1606            "a judge with no key and an unknown name drop out"
1607        );
1608        assert!((cfg.judge("local").unwrap().weight - 2.0).abs() < 1e-12);
1609    }
1610
1611    #[test]
1612    fn a_pool_averages_log_odds_and_multiplies_distributions() {
1613        let body = serde_json::json!({"questions": {
1614            "q": {"type": "noul"},
1615            "c": {"type": "choice", "criteria": {"A": "a", "B": "b"}},
1616            "s": {"type": "score", "criteria": ["0", "1", "2", "3"]},
1617            "missing": {"type": "noul"}
1618        }});
1619        let a = serde_json::json!({"q": {"noul": 0.9}, "c": {"choice": "A", "probabilities": {"A": 0.8, "B": 0.2}}, "s": {"score": 1.0}});
1620        let b = serde_json::json!({"q": {"noul": 0.1}, "c": {"choice": "B", "probabilities": {"A": 0.2, "B": 0.8}}, "s": {"score": 3.0}});
1621        let even = pool(&body, &[(1.0, a.clone()), (1.0, b.clone())]);
1622        assert!(
1623            (even["q"]["noul"].as_f64().unwrap() - 0.5).abs() < 1e-9,
1624            "opposed odds cancel"
1625        );
1626        assert!((even["c"]["probabilities"]["A"].as_f64().unwrap() - 0.5).abs() < 1e-9);
1627        assert!((even["s"]["score"].as_f64().unwrap() - 2.0).abs() < 1e-9);
1628        assert!(
1629            even.get("missing").is_none(),
1630            "no judge answered it, so the pool leaves it out"
1631        );
1632        let leaning = pool(&body, &[(3.0, a), (1.0, b)]);
1633        // (3 ln 9 - ln 9) / 4 = ln 3, so the pool is 3/4.
1634        assert!(
1635            (leaning["q"]["noul"].as_f64().unwrap() - 0.75).abs() < 1e-9,
1636            "weight moves the pool"
1637        );
1638        assert_eq!(leaning["c"]["choice"], "A");
1639        let agree = pool(
1640            &body,
1641            &[
1642                (1.0, serde_json::json!({"q": {"noul": 0.8}})),
1643                (1.0, serde_json::json!({"q": {"noul": 0.8}})),
1644            ],
1645        );
1646        assert!((agree["q"]["noul"].as_f64().unwrap() - 0.8).abs() < 1e-9);
1647    }
1648
1649    #[test]
1650    fn a_key_line_gives_its_value() {
1651        assert_eq!(key_from("sk-or-v1-abc\n").as_deref(), Some("sk-or-v1-abc"));
1652        assert_eq!(
1653            key_from("apikey: sk-or-v1-abc\nurl: x\n").as_deref(),
1654            Some("sk-or-v1-abc")
1655        );
1656        assert_eq!(
1657            key_from("apikey=sk-or-v1-abc").as_deref(),
1658            Some("sk-or-v1-abc")
1659        );
1660        assert_eq!(key_from("\n"), None);
1661    }
1662
1663    #[test]
1664    fn a_ballot_carries_its_confidence_and_forecast_and_escalates_under_the_cut() {
1665        let options = vec!["age".to_string(), "gpg".to_string()];
1666        let body = ballot_request("jev-1.13.0", "You are brio.", &options);
1667        assert_eq!(body["questions"]["ballot"]["type"], "choice");
1668        assert_eq!(
1669            body["questions"]["forecast"]["criteria"]["gpg"],
1670            "most others pick gpg"
1671        );
1672        let reply = serde_json::json!({"answers": {
1673            "ballot": {"type": "choice", "choice": "age", "confidence": 0.97,
1674                       "probabilities": {"age": 0.98, "gpg": 0.02}},
1675            "forecast": {"type": "choice", "choice": "age", "confidence": 0.95,
1676                         "probabilities": {"age": 0.97, "gpg": 0.03}}}});
1677        let b = parse_ballot(&reply, &options).unwrap();
1678        assert_eq!(b.choice, "age");
1679        assert!(!b.escalates(), "0.97 stands at the 0.8 cut");
1680        assert!((b.forecast["gpg"] - 0.03).abs() < 1e-9);
1681        let unsure = Ballot {
1682            confidence: 0.6,
1683            ..b.clone()
1684        };
1685        assert!(unsure.escalates(), "0.6 goes to a subagent");
1686        let off = serde_json::json!({"answers": {
1687            "ballot": {"choice": "rsa", "confidence": 0.9, "probabilities": {}},
1688            "forecast": {"choice": "age", "confidence": 0.9, "probabilities": {}}}});
1689        assert!(
1690            parse_ballot(&off, &options).is_none(),
1691            "a choice off the list is refused"
1692        );
1693    }
1694
1695    #[test]
1696    fn an_identical_request_is_answered_from_the_cache_and_only_that_one() {
1697        let dir = tempfile::tempdir().unwrap();
1698        // Safety: the test sets and clears this for itself.
1699        unsafe { std::env::set_var("XDG_CACHE_HOME", dir.path()) };
1700        let reply = serde_json::json!({"answers": {"x": {"noul": 0.9}}});
1701        assert!(cached("req-a", 7).is_none(), "nothing kept yet");
1702        keep("req-a", &reply);
1703        assert_eq!(cached("req-a", 7), Some(reply));
1704        assert!(cached("req-b", 7).is_none(), "another request misses");
1705        assert!(cached("req-a", 0).is_none(), "0 days is off");
1706        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
1707    }
1708
1709    #[test]
1710    fn a_stop_is_held_only_on_done_beside_red_or_an_open_deferral() {
1711        let a = |c: f64, g: f64, d: f64| Audit {
1712            claims_complete: c,
1713            tests_green: g,
1714            deferral: d,
1715        };
1716        assert!(
1717            audit_reason(&a(0.95, 0.05, 0.1), true).is_some(),
1718            "done beside red"
1719        );
1720        assert!(
1721            audit_reason(&a(0.95, 0.05, 0.1), false).is_none(),
1722            "no test ran, nothing to be red"
1723        );
1724        assert!(
1725            audit_reason(&a(0.95, 0.9, 0.1), true).is_none(),
1726            "done beside green"
1727        );
1728        assert!(
1729            audit_reason(&a(0.5, 0.05, 0.1), true).is_none(),
1730            "a red run reported as red"
1731        );
1732        assert!(
1733            audit_reason(&a(0.2, 0.9, 0.95), false).is_some(),
1734            "work put off"
1735        );
1736        let reply = serde_json::json!({"answers": {
1737            "claims_complete": {"noul": 0.9}, "tests_green": {"noul": 0.1}, "deferral": {"noul": 0.0}}});
1738        assert_eq!(parse_audit(&reply), Some(a(0.9, 0.1, 0.0)));
1739        assert_eq!(
1740            audit_request("m", "s")["questions"]
1741                .as_object()
1742                .unwrap()
1743                .len(),
1744            3
1745        );
1746    }
1747}