1//! One module per kind of question this project puts to Jev, tied together 2//! by [`Decision`] and the one engine that runs any of them 3//! ([`Engine::ask`]). 4//! 5//! A decision module owns exactly five things: **Facts** (a typed struct 6//! holding everything a call needs, built from game and bot state by one 7//! constructor - the game-state reading lives there and nowhere else), 8//! **Question** (how the facts render into the request sent to Jev), 9//! **Answer** (the typed result, decoded from Jev's reply), **Fallback** 10//! (what happens without Jev: upstream's own pick, and why - used when 11//! there is nothing worth asking, when a throttle or a failure means 12//! nothing was asked, or when the run's own spending limit is reached), and 13//! **Record** (the serialized form written to the shared history log, 14//! which the log itself makes generic - see [`Record`]). 15//! 16//! Everything a decision kind SHARES lives here instead, in [`Engine`]: the 17//! budget and rate guards (`jev_http`'s ledger, underneath), the call 18//! itself, the history log's append and load, the "ask again within the 19//! window" reuse cache, and the replay hook - a replay recorder reads the 20//! same [`Record`] this writes to `run/zbanks-window/jev.jsonl` and can 21//! answer from it instead of asking again, which is exactly why `Record`'s 22//! shape is the one thing every decision kind agrees on. 23//! 24//! See this crate's CLAUDE.md for the one rule this exists to enforce: a 25//! new Jev call is a new module implementing [`Decision`], never an ad-hoc 26//! request built somewhere else. 27 28pub mod goal_choice; 29 30use std::collections::VecDeque; 31use std::io::{BufRead, Write}; 32use std::path::Path; 33use std::time::Instant; 34 35use jev_protocol::{Json, Questions, Response}; 36use serde::de::DeserializeOwned; 37use serde::{Deserialize, Serialize}; 38use serde_json::json; 39 40/// How many decisions [`Engine::history`] keeps live in memory - for the 41/// panel and the `jev_history` MCP tool. The on-disk log (`log_to`) is 42/// unbounded; a run that plays for days must not grow this in memory 43/// without limit. Of the same order as `mcp::CALLS_KEPT` for the same 44/// reason. The full history past this bound is never lost - it stays in 45/// the log file on disk - only what is held live is bounded. 46pub const HISTORY_CAP: usize = 200; 47 48/// How every decision kind samples and reuses an answer, and stops asking. 49/// A kind-specific number (a goal-choice margin, say) lives on that kind's 50/// own `Config`, alongside one of these - see [`goal_choice::Config`]. 51#[derive(Clone, Copy, Debug)] 52pub struct Config { 53 /// A set of options asked about within this many frames is not asked 54 /// again; the remembered answer is reused instead ([`Decision::reuse`]). 55 pub reask_frames: u32, 56 /// `_sample_goal`'s two transforms on a Choice's probabilities: an 57 /// additive floor, then softmax flattening at this temperature (digest: 58 /// floor 0.05, T 2.0). A decision whose `Answer` carries no distribution 59 /// to sample (a Score, say) has no reason to call [`flatten`] at all. 60 pub floor: f64, 61 pub temperature: f64, 62 /// Stop asking once this run has spent this much (the ledger's lifetime 63 /// cap still applies on top); `None` for no run limit. 64 pub run_dollars: Option<f64>, 65} 66 67impl Default for Config { 68 fn default() -> Self { 69 Self { reask_frames: 1800, floor: 0.05, temperature: 2.0, run_dollars: None } 70 } 71} 72 73/// A raw (unflattened) top probability at or above this takes the argmax 74/// directly ([`SampleRule::Favourite`]); below it, [`Engine::sample`] keeps 75/// rolling ([`SampleRule::Sampled`]) so a close call still explores. 76pub const FAVOURITE_THRESHOLD: f64 = 0.70; 77 78/// The threshold decision [`Engine::sample`] makes, pulled out as a pure 79/// function of `probabilities` alone (no RNG, no `Engine`) so it is 80/// unit-testable without constructing one. Returns which rule fires, and, 81/// for [`SampleRule::Favourite`], the argmax index directly - `None` for 82/// [`SampleRule::Sampled`], since that branch still needs a real roll over 83/// the flattened distribution, done by the caller. 84fn decide_sample_rule(probabilities: &[f64]) -> (SampleRule, Option<usize>) { 85 let top = probabilities.iter().copied().fold(f64::NEG_INFINITY, f64::max); 86 if top >= FAVOURITE_THRESHOLD { 87 let argmax = probabilities.iter().enumerate().max_by(|(_, a), (_, b)| a.total_cmp(b)).map_or(0, |(i, _)| i); 88 (SampleRule::Favourite, Some(argmax)) 89 } else { 90 (SampleRule::Sampled, None) 91 } 92} 93 94/// `_sample_goal`'s distribution: normalise, add `floor` to each and 95/// renormalise, then flatten with `log(p)/temperature` through a softmax. 96/// A decision whose `Answer` carries a Choice's probabilities calls this 97/// from its own [`Decision::decode`]/[`Decision::reuse`] (via the `sample` 98/// closure [`Engine::ask`] hands them, see [`Engine::sample`]). 99pub fn flatten(probabilities: &[f64], floor: f64, temperature: f64) -> Vec<f64> { 100 let clamp: Vec<f64> = probabilities.iter().map(|p| p.max(0.0)).collect(); 101 let total: f64 = clamp.iter().sum(); 102 let n = clamp.len() as f64; 103 let mut p: Vec<f64> = 104 if total > 0.0 { clamp.iter().map(|x| x / total).collect() } else { vec![1.0 / n; clamp.len()] }; 105 let floor = floor.max(0.0); 106 let total: f64 = p.iter().map(|x| x + floor).sum(); 107 p = p.iter().map(|x| (x + floor) / total).collect(); 108 if temperature > 0.0 && (temperature - 1.0).abs() > f64::EPSILON { 109 let logits: Vec<f64> = p.iter().map(|x| (x + 1e-12).ln() / temperature).collect(); 110 let max = logits.iter().copied().fold(f64::NEG_INFINITY, f64::max); 111 let exp: Vec<f64> = logits.iter().map(|l| (l - max).exp()).collect(); 112 let total: f64 = exp.iter().sum(); 113 p = exp.iter().map(|e| e / total).collect(); 114 } 115 p 116} 117 118/// One kind of question this project puts to Jev. See the module doc for 119/// what each item is for. 120pub trait Decision: Sized { 121 /// Everything this call needs. Built once, from game and bot state, by 122 /// a constructor that lives beside this trait's impl - the game-state 123 /// reading lives there and nowhere else. 124 type Facts; 125 /// The typed result: decoded from Jev's reply by [`Self::decode`], or 126 /// built by [`Self::fallback`]. What the panel and the log show. 127 type Answer: Clone + Serialize + DeserializeOwned; 128 /// What is kept to answer the SAME question again within the reuse 129 /// window, without asking. Not always the full `Answer`: a Choice's 130 /// probabilities are kept per underlying option identity, not per the 131 /// sentence built for one particular call, so a later call whose 132 /// options happen to be grouped slightly differently can still look 133 /// each one up - see [`goal_choice::GoalChoice::reuse`]. 134 type Memory: Clone; 135 /// Identifies "the same question" for the reuse cache. Two calls with 136 /// equal keys within [`Config::reask_frames`] share one [`Self::Memory`]. 137 type Key: PartialEq + Clone; 138 /// The typed keys [`Self::question`] got back from `Questions`, which 139 /// [`Self::decode`] reads the verified answers through. 140 type Asked; 141 142 /// This decision's name in the shared log and panel. 143 const KIND: &'static str; 144 145 fn key(facts: &Self::Facts) -> Self::Key; 146 147 /// Render the facts into the question sent to Jev, or `None` when there 148 /// is nothing worth asking (one option, an empty rubric): [`Engine::ask`] 149 /// then skips the network and the guards entirely and calls 150 /// [`Self::fallback`] instead, recorded as [`How::NoQuestion`]. 151 fn question(facts: &Self::Facts) -> Option<Ask<Self::Asked>>; 152 153 /// Turn Jev's reply into this decision's answer, and what to remember of 154 /// it for [`Self::reuse`]. `sample` is the engine's own RNG 155 /// ([`Engine::sample`]), offered for an `Answer` that is itself a 156 /// distribution to pick from, as a Choice's is; a decision that never 157 /// samples is free to ignore it. 158 fn decode( 159 facts: &Self::Facts, 160 response: &Response, 161 asked: &Self::Asked, 162 sample: &mut dyn FnMut(&[f64]) -> usize, 163 ) -> Result<(Self::Answer, Self::Memory), &'static str>; 164 165 /// What happens without Jev: upstream's own pick, and the reason. Used 166 /// whenever [`Self::question`] returns `None`, for the run's own 167 /// spending limit, and for the answer half of a throttle or a failure 168 /// (whose own reason overrides this one in the log - see [`How`]). 169 fn fallback(facts: &Self::Facts) -> (Self::Answer, String); 170 171 /// Answer the same question again from what [`Self::decode`] chose to 172 /// remember of it last time, against THIS call's facts (which may have 173 /// regrouped the same underlying options slightly differently). 174 fn reuse(facts: &Self::Facts, memory: &Self::Memory, sample: &mut dyn FnMut(&[f64]) -> usize) -> Self::Answer; 175 176 /// One line for the trace log (`eprintln!`, never the persisted 177 /// [`Record`]): "picked 2 of 5", say. 178 fn summary(answer: &Self::Answer) -> String; 179 180 /// Rewrite a JSON line from the shared history log into the CURRENT 181 /// shape, before [`Engine::load_history`] parses it as a 182 /// `Record<Self::Answer>` - the hook for schema evolution. An older 183 /// line is not a different decision kind, so it must not be treated 184 /// the way an unparseable line is (skipped, `load_history`'s own doc 185 /// comment): a field renamed, added, or reshaped since the line was 186 /// written is migrated here, in one place, rather than the loader 187 /// silently discarding everything written under an earlier shape. 188 /// Default: unchanged, for a decision kind whose `Answer`/`How` never 189 /// changed shape since its first line. 190 fn migrate(value: serde_json::Value) -> serde_json::Value { 191 value 192 } 193} 194 195/// The model every question this project asks goes to. Pinned, so the 196/// thresholds tuned against it (the 0.70 argmax line) stay tuned: an alias 197/// moves under you. `jev-latest` named this release on 2026-10-01 198/// (docs.typesafe.ai/models), so pinning it changed no answer. 199pub const MODEL: &str = "jev-1.13.0"; 200 201pub fn model() -> jev_protocol::ModelId { 202 jev_protocol::ModelId::pinned(MODEL).expect("MODEL is a versioned id") 203} 204 205/// One request: the state, every question about it, and the keys to read 206/// their answers by. 207pub struct Ask<K> { 208 pub state: Json, 209 pub questions: Questions, 210 pub keys: K, 211} 212 213/// How a decision was made - shared by every decision kind, because the 214/// question of whether and why Jev was asked does not depend on what was 215/// asked about. 216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 217#[serde(rename_all = "snake_case")] 218pub enum How { 219 /// Jev was asked; what it cost, and the request exactly as sent (a 220 /// panel's expanded view pretty-prints this; the log keeps it 221 /// structured rather than pre-formatted, so it stays greppable with 222 /// `jq`). 223 Asked { dollars: f64, input_tokens: u64, millis: u128, prompt: serde_json::Value }, 224 /// [`Decision::question`] returned `None`: nothing was asked, and 225 /// nothing spent. 226 NoQuestion { why: String }, 227 /// The same question was asked at `frame`; that answer was reused 228 /// ([`Decision::reuse`]) instead of asking again. 229 Reused { frame: u32 }, 230 /// Upstream's pick, because the shared question bucket or the hour 231 /// breaker said not now (`jev_http::Failure::Throttled`). Clears by 232 /// itself; nothing to do but keep playing. 233 Throttled { why: String, retry_in_s: f64 }, 234 /// Upstream's pick, because asking failed, was not allowed, or this 235 /// run's own spending limit was reached. 236 Default { why: String }, 237} 238 239/// Every line ever written before `kind` existed was a goal choice - the 240/// only decision kind this project had until this crate's second one is 241/// built - so that is the one sound default for a line an older binary 242/// wrote. 243fn default_kind() -> String { 244 "goal_choice".to_owned() 245} 246 247/// One decision, in the shape the shared history log keeps and the panel 248/// shows - what every decision kind agrees on (`frame`, `kind`, `how`) 249/// alongside whatever that kind's own `Answer` carries, flattened into the 250/// same JSON object so an old log line (written before `kind` existed) is 251/// still exactly `Answer`'s own fields plus `how`. 252#[derive(Clone, Debug, Serialize, Deserialize)] 253pub struct Record<A> { 254 pub frame: u32, 255 #[serde(default = "default_kind")] 256 pub kind: String, 257 #[serde(flatten)] 258 pub answer: A, 259 pub how: How, 260 /// Which of [`Engine::sample`]'s two rules picked the answer - 261 /// [`None`] for a record that never sampled at all (`How::NoQuestion`, 262 /// `How::Throttled`, `How::Default`: upstream's own pick, no roll). 263 /// `#[serde(default)]` so a line from before this field existed still 264 /// loads. 265 #[serde(default)] 266 pub sample_rule: Option<SampleRule>, 267} 268 269/// Which of [`Engine::sample`]'s two rules picked an answer from a 270/// probability distribution. A probability clearly ahead of the rest 271/// ([`FAVOURITE_THRESHOLD`] or higher) is just taken - rolling dice 272/// on an answer already settled wastes a trip on the goal it would have 273/// picked anyway (frame 68593: a 10% option sampled against a clear 274/// favourite; frame 77784: a 19% option sampled). Below that threshold, a 275/// real sample over the (floor/temperature-flattened) distribution keeps 276/// exploring - the case exploration exists for. 277#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 278#[serde(rename_all = "snake_case")] 279pub enum SampleRule { 280 /// The top (raw, unflattened) probability was at or above the 281 /// threshold: its index was returned directly, with no roll. 282 Favourite, 283 /// Nothing stood out: a real sample over the flattened distribution. 284 Sampled, 285} 286 287/// Running totals, for "questions per minute" and "dollars per game-minute". 288/// A count that depends on knowing what "upstream's own pick" means for a 289/// particular `Answer` shape (goal-choice's `overrides`) is not here - it is 290/// that decision kind's own bookkeeping, kept beside its `Engine`. 291#[derive(Clone, Debug, Default)] 292pub struct Totals { 293 pub choices: u32, 294 pub questions: u32, 295 pub reused: u32, 296 /// Choices where [`Decision::question`] returned `None`. 297 pub no_question: u32, 298 pub defaults: u32, 299 /// Choices made without asking because the guards were throttling. 300 pub throttled: u32, 301 /// Input tokens over every question asked. 302 pub input_tokens: u64, 303 pub dollars: f64, 304 pub first_frame: Option<u32>, 305 pub last_frame: u32, 306} 307 308struct Remembered<D: Decision> { 309 frame: u32, 310 key: D::Key, 311 memory: D::Memory, 312} 313 314/// Why [`Engine::ask_guarded`] did not send anything. 315enum NotAsked { 316 Throttled { why: String, retry_in: std::time::Duration }, 317 Failed(String), 318} 319 320enum AskFailure { 321 Jev(jev_http::Failure), 322 Reply(&'static str), 323} 324 325/// How often [`Engine::guards`] re-reads the ledger. 326const GUARDS_EVERY: std::time::Duration = std::time::Duration::from_secs(1); 327 328/// Runs one kind of [`Decision`]: the guards, the call, the reuse cache, and 329/// the history log - everything a decision kind does not own itself. One of 330/// these per decision kind per process (each opens its own `jev_http::Jev` 331/// client and ledger handle; sharing one client's connection pool across 332/// two decision kinds in the same process is not needed while there is only 333/// one kind, and is future work if a second one arrives). 334pub struct Engine<D: Decision> { 335 jev: jev_http::Jev, 336 runtime: tokio::runtime::Runtime, 337 config: Config, 338 rng: u64, 339 remembered: VecDeque<Remembered<D>>, 340 pub last: Option<Record<D::Answer>>, 341 pub totals: Totals, 342 /// The most recent decisions, oldest first, capped at [`HISTORY_CAP`]: 343 /// what [`Engine::history`] returns for a panel and an MCP tool. Seeded 344 /// from the on-disk log at startup ([`Engine::load_history`]) so a 345 /// window restart - which rebuilds this engine from nothing - does not 346 /// lose the panel's history; grown by [`Engine::record`] after that. 347 history: VecDeque<Record<D::Answer>>, 348 log: Option<std::fs::File>, 349 /// While the guards are throttling, when they said they would clear: 350 /// until then nothing is asked, and the ledger is not even read. 351 throttled_until: Option<Instant>, 352 /// The shared guards as last read, and when. 353 guards: Option<(Instant, Result<jev_http::Status, String>)>, 354 /// Which rule [`Self::sample`]'s most recent call used - read (and 355 /// cleared) by whichever `ask`/ask-adjacent path just called 356 /// `D::decode`/`D::reuse` (which call `sample` exactly once, 357 /// synchronously, so there is never more than one pending value to 358 /// read). `None` until the first sample of this engine's life. 359 last_sample_rule: Option<SampleRule>, 360} 361 362impl<D: Decision> Engine<D> { 363 pub fn new(jev: jev_http::Jev, config: Config) -> Result<Self, String> { 364 let runtime = tokio::runtime::Builder::new_current_thread() 365 .enable_all() 366 .build() 367 .map_err(|e| format!("building the tokio runtime: {e}"))?; 368 let seed = std::time::SystemTime::now() 369 .duration_since(std::time::UNIX_EPOCH) 370 .map_or(0x9E37_79B9_7F4A_7C15, |d| d.as_nanos() as u64) 371 | 1; 372 Ok(Self { 373 jev, 374 runtime, 375 config, 376 rng: seed, 377 remembered: VecDeque::new(), 378 last: None, 379 totals: Totals::default(), 380 history: VecDeque::new(), 381 log: None, 382 throttled_until: None, 383 guards: None, 384 last_sample_rule: None, 385 }) 386 } 387 388 /// What the shared guards say - every process's questions, not only 389 /// this one's - re-read from the ledger at most once a second. 390 pub fn guards(&mut self) -> Result<jev_http::Status, String> { 391 let stale = self.guards.as_ref().is_none_or(|(at, _)| at.elapsed() >= GUARDS_EVERY); 392 if stale { 393 self.guards = Some((Instant::now(), self.jev.status())); 394 } 395 self.guards.as_ref().map_or_else(|| Err("unread".to_owned()), |(_, status)| status.clone()) 396 } 397 398 /// Append every decision, as a JSON line, to `path`. 399 pub fn log_to(&mut self, path: &Path) -> Result<(), String> { 400 let file = std::fs::OpenOptions::new() 401 .create(true) 402 .append(true) 403 .open(path) 404 .map_err(|e| format!("{}: {e}", path.display()))?; 405 self.log = Some(file); 406 Ok(()) 407 } 408 409 /// Seed [`Self::history`] from `path`'s existing JSONL log, keeping only 410 /// the last [`HISTORY_CAP`] decisions - called once at startup, before 411 /// [`Self::log_to`] starts appending to the same path, so a window 412 /// restart does not lose the panel's history even though this engine 413 /// itself is rebuilt from nothing. No such file yet is not an error, the 414 /// first window of a fresh ROM. 415 /// 416 /// Every line is run through [`Decision::migrate`] before it is parsed 417 /// as a `Record<D::Answer>`, so a line written under an EARLIER shape of 418 /// this decision's `Answer`/`How` is not just skipped (incident 419 /// 2026-09-22: 1,659 real lines, almost the whole log, silently read as 420 /// zero history because the loader only ever understood the newest 421 /// shape). What still cannot be parsed after migrating - a line from 422 /// some other decision kind, or a torn write from a process killed 423 /// mid-append - is skipped rather than failing the whole read, the same 424 /// as the spend ledger does (`jev_http::ledger`'s `read_lines`). 425 pub fn load_history(&mut self, path: &Path) -> Result<usize, String> { 426 let file = match std::fs::File::open(path) { 427 Ok(file) => file, 428 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), 429 Err(e) => return Err(format!("{}: {e}", path.display())), 430 }; 431 for line in std::io::BufReader::new(file).lines() { 432 let line = line.map_err(|e| format!("{}: {e}", path.display()))?; 433 if line.trim().is_empty() { 434 continue; 435 } 436 let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else { continue }; 437 let value = D::migrate(value); 438 let Ok(record) = serde_json::from_value::<Record<D::Answer>>(value) else { continue }; 439 if record.kind != D::KIND { 440 continue; 441 } 442 if self.history.len() == HISTORY_CAP { 443 self.history.pop_front(); 444 } 445 self.history.push_back(record); 446 } 447 Ok(self.history.len()) 448 } 449 450 /// The most recent decisions, oldest first, bounded at [`HISTORY_CAP`]. 451 /// The log this was seeded from and keeps growing (`log_to`) holds every 452 /// decision ever made, unbounded, if more than this is ever needed. 453 pub fn history(&self) -> impl DoubleEndedIterator<Item = &Record<D::Answer>> { 454 self.history.iter() 455 } 456 457 pub fn config(&self) -> Config { 458 self.config 459 } 460 461 fn uniform(&mut self) -> f64 { 462 // xorshift64*: sampling needs no more than this. 463 self.rng ^= self.rng >> 12; 464 self.rng ^= self.rng << 25; 465 self.rng ^= self.rng >> 27; 466 (self.rng.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 467 } 468 469 /// Sample an index from `probabilities`. A raw top probability at or 470 /// above [`FAVOURITE_THRESHOLD`] is taken directly, with no roll 471 /// ([`SampleRule::Favourite`]) - rolling dice on an answer already this 472 /// settled only risks the same wasted trip a real sample exists to 473 /// avoid for a close call. Otherwise, this engine's own 474 /// `floor`/`temperature` ([`flatten`]) flattens the distribution and a 475 /// real sample is drawn ([`SampleRule::Sampled`]). Either way, which 476 /// rule fired is left in `self.last_sample_rule` for the caller to pick 477 /// up (`Engine::build`'s callers, immediately after `D::decode`/ 478 /// `D::reuse` calls this). The `sample` closure a `Decision`'s 479 /// `decode`/`reuse` is handed is exactly this method. 480 pub fn sample(&mut self, probabilities: &[f64]) -> usize { 481 let (rule, favourite) = decide_sample_rule(probabilities); 482 self.last_sample_rule = Some(rule); 483 if let Some(i) = favourite { 484 return i; 485 } 486 let flattened = flatten(probabilities, self.config.floor, self.config.temperature); 487 let mut u = self.uniform(); 488 for (i, p) in flattened.iter().enumerate() { 489 if u < *p { 490 return i; 491 } 492 u -= p; 493 } 494 flattened.len() - 1 495 } 496 497 fn ask_once( 498 &mut self, 499 ask: &Ask<D::Asked>, 500 facts: &D::Facts, 501 ) -> Result<(D::Answer, D::Memory, f64, u64, u128, serde_json::Value), AskFailure> { 502 // Captured before the request goes out, not reconstructed after: a 503 // failure below (a malformed reply, say) must still be able to show 504 // what was actually sent - the exact bytes, as jev-protocol builds them. 505 let prompt = jev_protocol::request_bytes(self.jev.model(), &ask.state, &ask.questions) 506 .ok() 507 .and_then(|bytes| serde_json::from_slice(&bytes).ok()) 508 .unwrap_or(serde_json::Value::Null); 509 let started = Instant::now(); 510 let why = format!("{} decision", D::KIND); 511 let answered = self 512 .runtime 513 .block_on(self.jev.ask(&ask.state, &ask.questions, &why)) 514 .map_err(AskFailure::Jev)?; 515 let (answer, memory) = { 516 // `sample` borrows `self` mutably; nothing else here still 517 // borrows it by the time `decode` runs, since `answered` above 518 // is already an owned value. 519 let mut sample = |p: &[f64]| self.sample(p); 520 D::decode(facts, &answered.response, &ask.keys, &mut sample).map_err(AskFailure::Reply)? 521 }; 522 let usage = answered.response.usage(); 523 Ok((answer, memory, usage.dollars(), usage.input_tokens, started.elapsed().as_millis(), prompt)) 524 } 525 526 fn ask_guarded( 527 &mut self, 528 ask: &Ask<D::Asked>, 529 facts: &D::Facts, 530 ) -> Result<(D::Answer, D::Memory, f64, u64, u128, serde_json::Value), NotAsked> { 531 if let Some(until) = self.throttled_until { 532 let now = Instant::now(); 533 if now < until { 534 return Err(NotAsked::Throttled { why: "the guards said to wait".to_owned(), retry_in: until - now }); 535 } 536 self.throttled_until = None; 537 } 538 self.ask_once(ask, facts).map_err(|failure| match failure { 539 AskFailure::Jev(jev_http::Failure::Throttled { why, retry_in }) => { 540 self.throttled_until = Some(Instant::now() + retry_in); 541 self.guards = None; 542 NotAsked::Throttled { why, retry_in } 543 } 544 AskFailure::Jev(other) => NotAsked::Failed(format!("{other}")), 545 AskFailure::Reply(why) => NotAsked::Failed(why.to_owned()), 546 }) 547 } 548 549 fn build(&mut self, frame: u32, answer: D::Answer, how: How, sampled: bool) -> Record<D::Answer> { 550 let sample_rule = if sampled { self.last_sample_rule.take() } else { None }; 551 Record { frame, kind: D::KIND.to_owned(), answer, how, sample_rule } 552 } 553 554 /// Ask, or answer without asking, this decision's question for `facts`. 555 /// Recorded and logged the same way whichever it was: totals updated, a 556 /// one-line trace to stderr, and the whole `Record` appended to the log 557 /// (`log_to`) and kept in `history`, so a caller need not do any of that 558 /// itself - this IS the "new Jev call = a new module implementing 559 /// `Decision`, never an ad-hoc request built somewhere else" promise. 560 pub fn ask(&mut self, frame: u32, facts: &D::Facts) -> Record<D::Answer> { 561 while self.remembered.front().is_some_and(|r| frame.saturating_sub(r.frame) > self.config.reask_frames) { 562 self.remembered.pop_front(); 563 } 564 let record = match D::question(facts) { 565 None => { 566 let (answer, why) = D::fallback(facts); 567 self.build(frame, answer, How::NoQuestion { why }, false) 568 } 569 Some(ask) => { 570 let key = D::key(facts); 571 if let Some(found) = self.remembered.iter().rev().find(|r| r.key == key) { 572 let asked_at = found.frame; 573 let memory = found.memory.clone(); 574 let answer = D::reuse(facts, &memory, &mut |p| self.sample(p)); 575 self.build(frame, answer, How::Reused { frame: asked_at }, true) 576 } else if let Some(limit) = self.config.run_dollars.filter(|limit| self.totals.dollars >= *limit) { 577 let (answer, _) = D::fallback(facts); 578 self.build(frame, answer, How::Default { why: format!("this run's ${limit:.2} is spent") }, false) 579 } else { 580 match self.ask_guarded(&ask, facts) { 581 Ok((answer, memory, dollars, input_tokens, millis, prompt)) => { 582 self.remembered.push_back(Remembered { frame, key, memory }); 583 self.build(frame, answer, How::Asked { dollars, input_tokens, millis, prompt }, true) 584 } 585 Err(NotAsked::Throttled { why, retry_in }) => { 586 let (answer, _) = D::fallback(facts); 587 self.build(frame, answer, How::Throttled { why, retry_in_s: retry_in.as_secs_f64() }, false) 588 } 589 Err(NotAsked::Failed(why)) => { 590 let (answer, _) = D::fallback(facts); 591 self.build(frame, answer, How::Default { why }, false) 592 } 593 } 594 } 595 } 596 }; 597 self.record(&record); 598 self.last = Some(record.clone()); 599 record 600 } 601 602 fn record(&mut self, record: &Record<D::Answer>) { 603 let t = &mut self.totals; 604 t.choices += 1; 605 t.first_frame.get_or_insert(record.frame); 606 t.last_frame = record.frame; 607 match &record.how { 608 How::Asked { dollars, input_tokens, .. } => { 609 t.questions += 1; 610 t.dollars += dollars; 611 t.input_tokens += input_tokens; 612 } 613 How::Reused { .. } => t.reused += 1, 614 How::NoQuestion { .. } => t.no_question += 1, 615 How::Throttled { .. } => t.throttled += 1, 616 How::Default { .. } => t.defaults += 1, 617 } 618 // Short and to stderr (`run/native.log`): the full request that 619 // `How::Asked` carries would make every choice's line there as big 620 // as the question sent, drowning the one-line-per-choice trace this 621 // was for. 622 let short_how = match &record.how { 623 How::Asked { dollars, millis, .. } => format!("asked, {millis} ms, ${dollars:.6}"), 624 How::Reused { frame } => format!("reused frame {frame}"), 625 How::NoQuestion { why } => format!("no question: {why}"), 626 How::Throttled { why, .. } => format!("throttled: {why}"), 627 How::Default { why } => format!("default: {why}"), 628 }; 629 eprintln!("jev {}: frame {} {} ({short_how})", D::KIND, record.frame, D::summary(&record.answer)); 630 // The persisted line is the whole `Record`, serialized directly - 631 // its own shape IS the log's schema, so there is exactly one place 632 // that says what a line means (`load_history` deserializes the same 633 // type straight back, and this is also what feeds `self.history`). 634 let line = serde_json::to_value(record).unwrap_or_else(|e| json!({"error": e.to_string()})); 635 if let Some(log) = &mut self.log { 636 let _ = writeln!(log, "{line}"); 637 } 638 if self.history.len() == HISTORY_CAP { 639 self.history.pop_front(); 640 } 641 self.history.push_back(record.clone()); 642 } 643} 644 645#[cfg(test)] 646mod tests { 647 use super::*; 648 649 #[test] 650 fn flattening_keeps_every_option_alive_and_sums_to_one() { 651 let p = flatten(&[0.99, 0.01, 0.0], 0.05, 2.0); 652 assert!((p.iter().sum::<f64>() - 1.0).abs() < 1e-9); 653 assert!(p.iter().all(|x| *x > 0.05), "{p:?}"); 654 assert!(p[0] < 0.9 && p[0] > p[1], "{p:?}"); 655 } 656 657 #[test] 658 fn no_answer_is_even_odds() { 659 let p = flatten(&[0.0, 0.0], 0.05, 2.0); 660 assert!((p[0] - 0.5).abs() < 1e-9); 661 } 662 663 /// Frame 68593: a 10% option sampled against a clear favourite. A top 664 /// probability at or above the threshold is taken directly - no roll, 665 /// and the index returned is the argmax, not whichever a draw landed on. 666 #[test] 667 fn a_clear_favourite_is_taken_directly_not_rolled_for() { 668 let (rule, favourite) = decide_sample_rule(&[0.10, 0.75, 0.15]); 669 assert_eq!(rule, SampleRule::Favourite); 670 assert_eq!(favourite, Some(1)); 671 } 672 673 /// Frame 77784: a 19% option was sampled - nothing here stood out 674 /// enough to skip the roll, so the rule says to keep sampling (the 675 /// actual draw is `Engine::sample`'s own RNG, not this pure function's 676 /// job to cover). 677 #[test] 678 fn a_close_call_keeps_sampling_so_exploration_survives() { 679 let (rule, favourite) = decide_sample_rule(&[0.35, 0.34, 0.19, 0.12]); 680 assert_eq!(rule, SampleRule::Sampled); 681 assert_eq!(favourite, None); 682 } 683 684 /// The boundary itself: exactly at the threshold takes the favourite, 685 /// matching `>=` in `decide_sample_rule`, not `>`. 686 #[test] 687 fn exactly_at_the_threshold_counts_as_a_favourite() { 688 let (rule, favourite) = decide_sample_rule(&[0.70, 0.30]); 689 assert_eq!(rule, SampleRule::Favourite); 690 assert_eq!(favourite, Some(0)); 691 } 692}