lib.rsannotatedlib.rssource692 lines · 31.3 KB · raw
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}