1use std::collections::BTreeMap;
14use std::path::PathBuf;
15use std::time::Duration;
16
17use serde_json::Value;
18
19#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
21pub struct Config {
22 #[serde(default)]
24 pub enabled: bool,
25 #[serde(default)]
27 pub key_file: Option<String>,
28 #[serde(default)]
32 pub key_cmd: Option<Vec<String>>,
33 #[serde(default = "default_model")]
36 pub model: String,
37 #[serde(default = "default_budget")]
39 pub budget_ms: u64,
40 #[serde(default = "default_endpoint")]
42 pub endpoint: String,
43 #[serde(default = "default_monthly")]
45 pub monthly_usd: f64,
46 #[serde(default = "default_min_words")]
49 pub min_words: usize,
50 #[serde(default = "default_min_candidates")]
53 pub min_candidates: usize,
54 #[serde(default = "default_price_in")]
57 pub usd_per_mtok_in: f64,
58 #[serde(default = "default_cut")]
61 pub bears_at: f64,
62 #[serde(default = "default_cut")]
65 pub cue_at: f64,
66 #[serde(default = "default_escalate")]
69 pub escalate_below: f64,
70 #[serde(default = "default_cache_days")]
73 pub cache_days: u64,
74}
75
76fn default_model() -> String {
77 "jev-1.13.0".into()
78}
79fn default_budget() -> u64 {
80 2000
81}
82fn default_endpoint() -> String {
83 "https://api.typesafe.ai/v1/systemone".into()
84}
85fn default_monthly() -> f64 {
86 4.0
87}
88fn default_min_words() -> usize {
89 4
90}
91fn default_min_candidates() -> usize {
92 2
93}
94fn default_cache_days() -> u64 {
95 7
96}
97fn default_escalate() -> f64 {
98 0.8
99}
100fn default_cut() -> f64 {
101 0.5
102}
103fn default_price_in() -> f64 {
104 0.042
105}
106
107fn cost_of(body: &Value, usd_per_mtok_in: f64) -> f64 {
110 body["usage"]["cost"].as_f64().unwrap_or_else(|| {
111 body["usage"]["input_tokens"].as_f64().unwrap_or(0.0) * usd_per_mtok_in / 1e6
112 })
113}
114
115const PROMPT_CHARS: usize = 2000;
118
119fn config_path() -> PathBuf {
120 std::env::var_os("XDG_CONFIG_HOME")
121 .filter(|v| !v.is_empty())
122 .map(PathBuf::from)
123 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
124 .unwrap_or_else(|| PathBuf::from(".config"))
125 .join("ljos")
126 .join("jev.toml")
127}
128
129fn expand(path: &str) -> PathBuf {
130 match path.strip_prefix("~/") {
131 Some(rest) => std::env::var_os("HOME")
132 .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
133 None => PathBuf::from(path),
134 }
135}
136
137fn read_config() -> Option<Config> {
138 let text = std::fs::read_to_string(config_path()).ok()?;
139 toml::from_str(&text).ok()
140}
141
142#[must_use]
145pub fn enabled() -> bool {
146 read_config().is_some_and(|c| c.enabled)
147}
148
149fn key_from(text: &str) -> Option<String> {
152 let line = text.lines().next()?.trim();
153 let value = line
154 .rsplit(|c: char| c == ':' || c == '=' || c.is_whitespace())
155 .next()
156 .unwrap_or(line)
157 .trim();
158 (!value.is_empty()).then(|| value.to_string())
159}
160
161fn key_cache() -> Option<PathBuf> {
162 let dir = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty())?;
163 Some(PathBuf::from(dir).join("ljos").join("jev-key"))
164}
165
166fn key_by_command(argv: &[String]) -> Option<String> {
169 use std::io::Write;
170 use std::os::unix::fs::OpenOptionsExt;
171 let cache = key_cache();
172 if let Some(key) = cache
173 .as_ref()
174 .and_then(|p| std::fs::read_to_string(p).ok())
175 .and_then(|t| key_from(&t))
176 {
177 return Some(key);
178 }
179 let (prog, args) = argv.split_first()?;
180 let out = std::process::Command::new("timeout")
181 .arg("3")
182 .arg(prog)
183 .args(args)
184 .stdin(std::process::Stdio::null())
185 .stderr(std::process::Stdio::null())
186 .output()
187 .ok()?;
188 if !out.status.success() {
189 return None;
190 }
191 let key = key_from(&String::from_utf8_lossy(&out.stdout))?;
192 if let Some(path) = cache {
193 let _ = std::fs::create_dir_all(path.parent()?);
194 if let Ok(mut f) = std::fs::OpenOptions::new()
195 .write(true)
196 .create(true)
197 .truncate(true)
198 .mode(0o600)
199 .open(&path)
200 {
201 let _ = writeln!(f, "{key}");
202 }
203 }
204 Some(key)
205}
206
207#[must_use]
210pub fn config() -> Option<(Config, String)> {
211 let cfg = read_config()?;
212 if !cfg.enabled || month_cost().unwrap_or(0.0) >= cfg.monthly_usd {
213 return None;
214 }
215 let key = match (&cfg.key_cmd, &cfg.key_file) {
216 (Some(argv), _) => key_by_command(argv)?,
217 (None, Some(file)) => key_from(&std::fs::read_to_string(expand(file)).ok()?)?,
218 (None, None) => return None,
219 };
220 Some((cfg, key))
221}
222
223#[derive(Debug, Clone, Default, PartialEq)]
225pub struct Judgment {
226 pub bears: Vec<f64>,
228 pub correction: f64,
230 pub choice: f64,
232 pub cost: f64,
234 pub bears_at: f64,
236 pub cue_at: f64,
238}
239
240impl Judgment {
241 #[must_use]
243 pub fn bears(&self, i: usize) -> bool {
244 self.bears.get(i).is_some_and(|p| *p >= self.bears_at)
245 }
246}
247
248#[must_use]
251pub fn request(model: &str, prompt: &str, candidates: &[&str]) -> Value {
252 let prompt: String = prompt.chars().take(PROMPT_CHARS).collect();
253 let mut state = format!("Prompt from the person to the agent:\n{prompt}\n\nStored claims:\n");
254 for (i, text) in candidates.iter().enumerate() {
255 state.push_str(&format!("[{i}] {text}\n"));
256 }
257 let mut questions = serde_json::Map::new();
258 for i in 0..candidates.len() {
259 questions.insert(
260 format!("bears_{i}"),
261 serde_json::json!({
262 "type": "noul",
263 "instructions": format!("Does stored claim [{i}] bear on what the prompt asks the agent to do now?"),
264 "criteria": {
265 "true": "The claim changes or informs how the agent should act on this prompt",
266 "false": "The claim is about something else, or only shares words with the prompt"
267 }
268 }),
269 );
270 }
271 questions.insert(
272 "correction".into(),
273 serde_json::json!({
274 "type": "noul",
275 "instructions": "Does the person correct the agent for something it did, forgot or was already told?",
276 "criteria": {
277 "true": "The prompt tells the agent it was wrong or should already know",
278 "false": "The prompt asks for work or information without correcting the agent"
279 }
280 }),
281 );
282 questions.insert(
283 "choice".into(),
284 serde_json::json!({
285 "type": "noul",
286 "instructions": "Does the prompt put to the agent a choice between two or more defensible options?",
287 "criteria": {
288 "true": "The person asks which of several ways to take, or weighs options",
289 "false": "The person names one thing to do, or asks a factual question"
290 }
291 }),
292 );
293 serde_json::json!({ "model": model, "state": state, "questions": questions })
294}
295
296#[must_use]
299pub fn parse(body: &Value, candidates: usize) -> Option<Judgment> {
300 let answers = body.get("answers")?.as_object()?;
301 let noul = |key: &str| answers.get(key)?.get("noul")?.as_f64();
302 let bears: Vec<f64> = (0..candidates)
303 .map(|i| noul(&format!("bears_{i}")))
304 .collect::<Option<_>>()?;
305 Some(Judgment {
306 bears,
307 correction: noul("correction")?,
308 choice: noul("choice")?,
309 cost: 0.0,
310 bears_at: 0.5,
311 cue_at: 0.5,
312 })
313}
314
315fn post(cfg: &Config, key: &str, body: Value, kind: &str, about: Value) -> Option<Value> {
318 let request = format!("{}\n{body}", cfg.endpoint);
319 if let Some(reply) = cached(&request, cfg.cache_days) {
320 count("cached");
321 return Some(reply);
322 }
323 let reply: Value = ureq::post(&cfg.endpoint)
324 .timeout(Duration::from_millis(cfg.budget_ms))
325 .set("Authorization", &format!("Bearer {key}"))
326 .set("Content-Type", "application/json")
327 .send_json(body)
328 .ok()?
329 .into_json()
330 .ok()?;
331 let answers = reply.get("answers")?.clone();
332 let cost = cost_of(&reply, cfg.usd_per_mtok_in);
333 record_cost(cost);
334 if cfg.cache_days > 0 {
335 keep(&request, &reply);
336 }
337 log(&serde_json::json!({
338 "ts": crate::now_utc(),
339 "kind": kind,
340 "model": cfg.model,
341 "about": about,
342 "answers": answers,
343 "cost": cost,
344 }));
345 Some(reply)
346}
347
348#[must_use]
350pub fn judge(prompt: &str, candidates: &[&str]) -> Option<Judgment> {
351 let (cfg, key) = config()?;
352 let body = request(&cfg.model, prompt, candidates);
353 let reply = post(&cfg, &key, body, "hook", Value::Null)?;
354 let mut judged = parse(&reply, candidates.len())?;
355 judged.cost = cost_of(&reply, cfg.usd_per_mtok_in);
356 judged.bears_at = cfg.bears_at;
357 judged.cue_at = cfg.cue_at;
358 Some(judged)
359}
360
361#[derive(Debug, Clone, PartialEq)]
365pub struct Ballot {
366 pub choice: String,
367 pub confidence: f64,
368 pub probabilities: BTreeMap<String, f64>,
369 pub forecast: BTreeMap<String, f64>,
370 pub escalate_below: f64,
372}
373
374impl Ballot {
375 #[must_use]
377 pub fn escalates(&self) -> bool {
378 self.confidence < self.escalate_below
379 }
380}
381
382#[must_use]
385pub fn ballot_request(model: &str, brief: &str, options: &[String]) -> Value {
386 let criteria = |verb: &str| -> Value {
387 options
388 .iter()
389 .map(|o| (o.clone(), Value::String(format!("{verb} {o}"))))
390 .collect::<serde_json::Map<_, _>>()
391 .into()
392 };
393 serde_json::json!({
394 "model": model,
395 "state": brief,
396 "questions": {
397 "ballot": {
398 "type": "choice",
399 "instructions": "You are the persona the state describes. Which option do you vote for, from your own view and what you know?",
400 "criteria": criteria("vote for"),
401 },
402 "forecast": {
403 "type": "choice",
404 "instructions": "Which option will most of the other reviewers on this panel vote for?",
405 "criteria": criteria("most others pick"),
406 },
407 }
408 })
409}
410
411#[must_use]
414pub fn parse_ballot(body: &Value, options: &[String]) -> Option<Ballot> {
415 let answers = body.get("answers")?;
416 let probs = |key: &str| -> Option<BTreeMap<String, f64>> {
417 let map = answers.get(key)?.get("probabilities")?.as_object()?;
418 Some(
419 map.iter()
420 .filter_map(|(k, v)| Some((k.clone(), v.as_f64()?)))
421 .collect(),
422 )
423 };
424 let ballot = answers.get("ballot")?;
425 let choice = ballot.get("choice")?.as_str()?.to_string();
426 if !options.contains(&choice) {
427 return None;
428 }
429 Some(Ballot {
430 confidence: ballot.get("confidence")?.as_f64()?,
431 probabilities: probs("ballot")?,
432 forecast: probs("forecast")?,
433 choice,
434 escalate_below: 0.8,
435 })
436}
437
438#[must_use]
440pub fn ballot(persona: &str, issue: &str, brief: &str, options: &[String]) -> Option<Ballot> {
441 let (cfg, key) = config()?;
442 let body = ballot_request(&cfg.model, brief, options);
443 let about = serde_json::json!({"issue": issue, "persona": persona, "options": options});
444 let reply = post(&cfg, &key, body, "ballot", about)?;
445 let mut b = parse_ballot(&reply, options)?;
446 b.escalate_below = cfg.escalate_below;
447 Some(b)
448}
449
450fn cache_dir() -> Option<PathBuf> {
452 Some(
453 std::env::var_os("XDG_CACHE_HOME")
454 .filter(|v| !v.is_empty())
455 .map(PathBuf::from)
456 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?
457 .join("ljos")
458 .join("jev"),
459 )
460}
461
462fn cache_file(request: &str) -> Option<PathBuf> {
465 use std::hash::{Hash, Hasher};
466 let mut h = std::collections::hash_map::DefaultHasher::new();
467 request.hash(&mut h);
468 Some(cache_dir()?.join(format!("{:016x}.json", h.finish())))
469}
470
471fn cached(request: &str, days: u64) -> Option<Value> {
473 if days == 0 {
474 return None;
475 }
476 let path = cache_file(request)?;
477 let age = std::fs::metadata(&path)
478 .ok()?
479 .modified()
480 .ok()?
481 .elapsed()
482 .ok()?;
483 if age > Duration::from_secs(days * 86_400) {
484 let _ = std::fs::remove_file(&path);
485 return None;
486 }
487 let entry: Value = serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
488 (entry["request"].as_str() == Some(request)).then(|| entry["reply"].clone())
489}
490
491fn keep(request: &str, reply: &Value) {
492 let Some(path) = cache_file(request) else {
493 return;
494 };
495 if let Some(dir) = path.parent() {
496 let _ = std::fs::create_dir_all(dir);
497 }
498 let entry = serde_json::json!({"request": request, "reply": reply});
499 let _ = std::fs::write(path, entry.to_string());
500}
501
502fn count(what: &str) {
504 let Some(dir) = state_dir() else { return };
505 let _ = std::fs::create_dir_all(&dir);
506 let path = dir.join("jev-cost.toml");
507 let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
508 .ok()
509 .and_then(|t| toml::from_str(&t).ok())
510 .unwrap_or_default();
511 *totals
512 .entry(format!("{}-{what}", this_month()))
513 .or_default() += 1.0;
514 if let Ok(text) = toml::to_string(&totals) {
515 let _ = std::fs::write(path, text);
516 }
517}
518
519pub const AUDIT_CLAIM_AT: f64 = 0.9;
523pub const AUDIT_RED_BELOW: f64 = 0.1;
525pub const AUDIT_DEFER_AT: f64 = 0.9;
527
528#[derive(Debug, Clone, Copy, PartialEq)]
530pub struct Audit {
531 pub claims_complete: f64,
533 pub tests_green: f64,
535 pub deferral: f64,
537}
538
539#[must_use]
541pub fn audit_request(model: &str, state: &str) -> Value {
542 serde_json::json!({
543 "model": model,
544 "state": state,
545 "questions": {
546 "claims_complete": {
547 "type": "noul",
548 "instructions": "Does the agent's final message claim the asked work is done, complete, passing, green or ready?",
549 "criteria": {
550 "true": "It says the work is finished or the tests pass",
551 "false": "It reports progress, a failure, a question or what is still open"
552 }
553 },
554 "tests_green": {
555 "type": "noul",
556 "instructions": "Does the most recent test output in the state pass, with no failed, errored or crashed test?",
557 "criteria": {
558 "true": "The latest run reports every test passing",
559 "false": "The latest run reports a failure, an error, a crash or a build that did not finish"
560 }
561 },
562 "deferral": {
563 "type": "noul",
564 "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?",
565 "criteria": {
566 "true": "It leaves asked work for a later change, session or person, with no external block",
567 "false": "It finishes the asked work, or names a real block such as a missing credential or a failing external service"
568 }
569 }
570 }
571 })
572}
573
574#[must_use]
576pub fn parse_audit(body: &Value) -> Option<Audit> {
577 let a = body.get("answers")?;
578 let noul = |k: &str| a.get(k)?.get("noul")?.as_f64();
579 Some(Audit {
580 claims_complete: noul("claims_complete")?,
581 tests_green: noul("tests_green")?,
582 deferral: noul("deferral")?,
583 })
584}
585
586#[must_use]
588pub fn audit(state: &str) -> Option<Audit> {
589 let (cfg, key) = config()?;
590 let body = audit_request(&cfg.model, state);
591 let reply = post(&cfg, &key, body, "stop-audit", Value::Null)?;
592 parse_audit(&reply)
593}
594
595#[must_use]
598pub fn audit_reason(a: &Audit, test_ran: bool) -> Option<String> {
599 if test_ran && a.claims_complete >= AUDIT_CLAIM_AT && a.tests_green <= AUDIT_RED_BELOW {
600 return Some(
601 "The final message says the work is done, and the last test run shown is red. \
602 Say what still fails, or fix it, before stopping."
603 .to_string(),
604 );
605 }
606 if a.deferral >= AUDIT_DEFER_AT {
607 return Some(
608 "The final message leaves part of the asked work for later without naming what blocks it. \
609 Do that part, or say in one sentence what outside the work blocks it."
610 .to_string(),
611 );
612 }
613 None
614}
615
616fn state_dir() -> Option<PathBuf> {
617 Some(
618 std::env::var_os("XDG_STATE_HOME")
619 .filter(|v| !v.is_empty())
620 .map(PathBuf::from)
621 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?
622 .join("ljos"),
623 )
624}
625
626fn log(entry: &Value) {
629 use std::io::Write;
630 let Some(dir) = state_dir() else { return };
631 let _ = std::fs::create_dir_all(&dir);
632 if let Ok(mut f) = std::fs::OpenOptions::new()
633 .create(true)
634 .append(true)
635 .open(dir.join("jev-log.jsonl"))
636 {
637 let _ = writeln!(f, "{entry}");
638 }
639}
640
641fn record_cost(cost: f64) {
644 let Some(dir) = state_dir() else { return };
645 let _ = std::fs::create_dir_all(&dir);
646 let month = crate::now_utc().chars().take(7).collect::<String>();
647 let path = dir.join("jev-cost.toml");
648 let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
649 .ok()
650 .and_then(|t| toml::from_str(&t).ok())
651 .unwrap_or_default();
652 *totals.entry(format!("{month}-calls")).or_default() += 1.0;
653 *totals.entry(month).or_default() += cost;
654 if let Ok(text) = toml::to_string(&totals) {
655 let _ = std::fs::write(path, text);
656 }
657}
658
659fn month_totals() -> Option<BTreeMap<String, f64>> {
660 let dir = state_dir()?;
661 let text = std::fs::read_to_string(dir.join("jev-cost.toml")).ok()?;
662 toml::from_str(&text).ok()
663}
664
665fn this_month() -> String {
666 crate::now_utc().chars().take(7).collect()
667}
668
669#[must_use]
671pub fn month_cost() -> Option<f64> {
672 month_totals()?.get(&this_month()).copied()
673}
674
675#[must_use]
678pub fn month_count(what: &str) -> u64 {
679 month_totals()
680 .and_then(|t| t.get(&format!("{}-{what}", this_month())).copied())
681 .unwrap_or(0.0) as u64
682}
683
684#[must_use]
686pub fn month_calls() -> u64 {
687 month_count("calls")
688}
689
690#[must_use]
694pub fn doctor_row() -> Option<crate::Habitat> {
695 let text = std::fs::read_to_string(config_path()).ok()?;
696 let (state, ok) = match toml::from_str::<Config>(&text) {
697 Err(e) => (format!("{}: {e}", config_path().display()), false),
698 Ok(cfg) if !cfg.enabled => ("off".to_string(), true),
699 Ok(cfg) => {
700 let spent = month_cost().unwrap_or(0.0);
701 let head = format!(
702 "{} {} calls, {} cached ${spent:.4} of ${:.2} this month",
703 cfg.model,
704 month_calls(),
705 month_count("cached"),
706 cfg.monthly_usd
707 );
708 if spent >= cfg.monthly_usd {
709 (format!("capped {head}"), true)
710 } else if config().is_none() {
711 (
712 "on, but the key file or command gave no key".to_string(),
713 false,
714 )
715 } else {
716 (format!("on {head}"), true)
717 }
718 }
719 };
720 Some(crate::Habitat {
721 name: "jev",
722 state,
723 ok,
724 })
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730
731 #[test]
732 fn one_request_asks_about_every_candidate_and_both_cues() {
733 let body = request("jev-1.13.0", "fix the ci", &["alpha claim", "beta claim"]);
734 let q = body["questions"].as_object().unwrap();
735 assert_eq!(q.len(), 4);
736 assert_eq!(q["bears_1"]["type"], "noul");
737 assert!(body["state"].as_str().unwrap().contains("[1] beta claim"));
738 }
739
740 #[test]
741 fn a_full_answer_is_read_and_a_partial_one_is_refused() {
742 let full = serde_json::json!({
743 "answers": {
744 "bears_0": {"type": "noul", "noul": 0.9},
745 "bears_1": {"type": "noul", "noul": 0.1},
746 "correction": {"type": "noul", "noul": 0.2},
747 "choice": {"type": "noul", "noul": 0.7}
748 },
749 "usage": {"input_tokens": 900, "output_tokens": 40, "cost": 0.0000378}
750 });
751 let j = parse(&full, 2).unwrap();
752 assert_eq!(j.bears, vec![0.9, 0.1]);
753 assert!(j.bears(0) && !j.bears(1));
754 let strict = Judgment {
755 bears_at: 0.95,
756 ..j.clone()
757 };
758 assert!(!strict.bears(0), "a higher cut drops the 0.9");
759 assert!((j.choice - 0.7).abs() < 1e-9);
760 assert!(
761 (cost_of(&full, 0.042) - 0.0000378).abs() < 1e-12,
762 "the API's figure"
763 );
764 let direct = serde_json::json!({"usage": {"input_tokens": 1000, "output_tokens": 60}});
765 assert!(
766 (cost_of(&direct, 0.042) - 0.000042).abs() < 1e-12,
767 "tokens at the price"
768 );
769 let partial = serde_json::json!({"answers": {"bears_0": {"noul": 0.9}}});
770 assert!(parse(&partial, 2).is_none());
771 }
772
773 #[test]
774 fn jev_is_off_without_a_file_and_off_when_the_file_says_so() {
775 let dir = tempfile::tempdir().unwrap();
776 unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
778 assert!(config().is_none(), "no file, no call");
779 std::fs::create_dir_all(dir.path().join("ljos")).unwrap();
780 let key = dir.path().join("key");
781 std::fs::write(&key, "sk-or-test\n").unwrap();
782 std::fs::write(
783 dir.path().join("ljos/jev.toml"),
784 format!("enabled = false\nkey_file = \"{}\"\n", key.display()),
785 )
786 .unwrap();
787 assert!(config().is_none(), "a file that says off is off");
788 std::fs::write(
789 dir.path().join("ljos/jev.toml"),
790 format!("enabled = true\nkey_file = \"{}\"\n", key.display()),
791 )
792 .unwrap();
793 let (cfg, k) = config().unwrap();
794 assert_eq!(k, "sk-or-test");
795 assert_eq!(cfg.budget_ms, 2000);
796 assert_eq!(cfg.min_candidates, 2);
797 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
798 }
799
800 #[test]
801 fn a_key_line_gives_its_value() {
802 assert_eq!(key_from("sk-or-v1-abc\n").as_deref(), Some("sk-or-v1-abc"));
803 assert_eq!(
804 key_from("apikey: sk-or-v1-abc\nurl: x\n").as_deref(),
805 Some("sk-or-v1-abc")
806 );
807 assert_eq!(
808 key_from("apikey=sk-or-v1-abc").as_deref(),
809 Some("sk-or-v1-abc")
810 );
811 assert_eq!(key_from("\n"), None);
812 }
813
814 #[test]
815 fn a_ballot_carries_its_confidence_and_forecast_and_escalates_under_the_cut() {
816 let options = vec!["age".to_string(), "gpg".to_string()];
817 let body = ballot_request("jev-1.13.0", "You are brio.", &options);
818 assert_eq!(body["questions"]["ballot"]["type"], "choice");
819 assert_eq!(
820 body["questions"]["forecast"]["criteria"]["gpg"],
821 "most others pick gpg"
822 );
823 let reply = serde_json::json!({"answers": {
824 "ballot": {"type": "choice", "choice": "age", "confidence": 0.97,
825 "probabilities": {"age": 0.98, "gpg": 0.02}},
826 "forecast": {"type": "choice", "choice": "age", "confidence": 0.95,
827 "probabilities": {"age": 0.97, "gpg": 0.03}}}});
828 let b = parse_ballot(&reply, &options).unwrap();
829 assert_eq!(b.choice, "age");
830 assert!(!b.escalates(), "0.97 stands at the 0.8 cut");
831 assert!((b.forecast["gpg"] - 0.03).abs() < 1e-9);
832 let unsure = Ballot {
833 confidence: 0.6,
834 ..b.clone()
835 };
836 assert!(unsure.escalates(), "0.6 goes to a subagent");
837 let off = serde_json::json!({"answers": {
838 "ballot": {"choice": "rsa", "confidence": 0.9, "probabilities": {}},
839 "forecast": {"choice": "age", "confidence": 0.9, "probabilities": {}}}});
840 assert!(
841 parse_ballot(&off, &options).is_none(),
842 "a choice off the list is refused"
843 );
844 }
845
846 #[test]
847 fn an_identical_request_is_answered_from_the_cache_and_only_that_one() {
848 let dir = tempfile::tempdir().unwrap();
849 unsafe { std::env::set_var("XDG_CACHE_HOME", dir.path()) };
851 let reply = serde_json::json!({"answers": {"x": {"noul": 0.9}}});
852 assert!(cached("req-a", 7).is_none(), "nothing kept yet");
853 keep("req-a", &reply);
854 assert_eq!(cached("req-a", 7), Some(reply));
855 assert!(cached("req-b", 7).is_none(), "another request misses");
856 assert!(cached("req-a", 0).is_none(), "0 days is off");
857 unsafe { std::env::remove_var("XDG_CACHE_HOME") };
858 }
859
860 #[test]
861 fn a_stop_is_held_only_on_done_beside_red_or_an_open_deferral() {
862 let a = |c: f64, g: f64, d: f64| Audit {
863 claims_complete: c,
864 tests_green: g,
865 deferral: d,
866 };
867 assert!(
868 audit_reason(&a(0.95, 0.05, 0.1), true).is_some(),
869 "done beside red"
870 );
871 assert!(
872 audit_reason(&a(0.95, 0.05, 0.1), false).is_none(),
873 "no test ran, nothing to be red"
874 );
875 assert!(
876 audit_reason(&a(0.95, 0.9, 0.1), true).is_none(),
877 "done beside green"
878 );
879 assert!(
880 audit_reason(&a(0.5, 0.05, 0.1), true).is_none(),
881 "a red run reported as red"
882 );
883 assert!(
884 audit_reason(&a(0.2, 0.9, 0.95), false).is_some(),
885 "work put off"
886 );
887 let reply = serde_json::json!({"answers": {
888 "claims_complete": {"noul": 0.9}, "tests_green": {"noul": 0.1}, "deferral": {"noul": 0.0}}});
889 assert_eq!(parse_audit(&reply), Some(a(0.9, 0.1, 0.0)));
890 assert_eq!(
891 audit_request("m", "s")["questions"]
892 .as_object()
893 .unwrap()
894 .len(),
895 3
896 );
897 }
898}