lib.rsannotatedlib.rssource692 lines · 31.3 KB · raw

One module per kind of question this project puts to Jev, tied together by [Decision] and the one engine that runs any of them ([Engine::ask]).

A decision module owns exactly five things: Facts (a typed struct holding everything a call needs, built from game and bot state by one constructor - the game-state reading lives there and nowhere else), Question (how the facts render into the request sent to Jev), Answer (the typed result, decoded from Jev's reply), Fallback (what happens without Jev: upstream's own pick, and why - used when there is nothing worth asking, when a throttle or a failure means nothing was asked, or when the run's own spending limit is reached), and Record (the serialized form written to the shared history log, which the log itself makes generic - see [Record]).

Everything a decision kind SHARES lives here instead, in [Engine]: the budget and rate guards (jev_http's ledger, underneath), the call itself, the history log's append and load, the "ask again within the window" reuse cache, and the replay hook - a replay recorder reads the same [Record] this writes to run/zbanks-window/jev.jsonl and can answer from it instead of asking again, which is exactly why Record's shape is the one thing every decision kind agrees on.

See this crate's CLAUDE.md for the one rule this exists to enforce: a new Jev call is a new module implementing [Decision], never an ad-hoc request built somewhere else.

28pub mod goal_choice;
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;

How many decisions [Engine::history] keeps live in memory - for the panel and the jev_history MCP tool. The on-disk log (log_to) is unbounded; a run that plays for days must not grow this in memory without limit. Of the same order as mcp::CALLS_KEPT for the same reason. The full history past this bound is never lost - it stays in the log file on disk - only what is held live is bounded.

46pub const HISTORY_CAP: usize = 200;

How every decision kind samples and reuses an answer, and stops asking. A kind-specific number (a goal-choice margin, say) lives on that kind's own Config, alongside one of these - see [goal_choice::Config].

51#[derive(Clone, Copy, Debug)]
52pub struct Config {

A set of options asked about within this many frames is not asked again; the remembered answer is reused instead ([Decision::reuse]).

55    pub reask_frames: u32,

_sample_goal's two transforms on a Choice's probabilities: an additive floor, then softmax flattening at this temperature (digest: floor 0.05, T 2.0). A decision whose Answer carries no distribution to sample (a Score, say) has no reason to call [flatten] at all.

60    pub floor: f64,
61    pub temperature: f64,

Stop asking once this run has spent this much (the ledger's lifetime cap still applies on top); None for no run limit.

64    pub run_dollars: Option<f64>,
65}
67impl Default for Config {
68    fn default() -> Self {
69        Self { reask_frames: 1800, floor: 0.05, temperature: 2.0, run_dollars: None }
70    }
71}

A raw (unflattened) top probability at or above this takes the argmax directly ([SampleRule::Favourite]); below it, [Engine::sample] keeps rolling ([SampleRule::Sampled]) so a close call still explores.

76pub const FAVOURITE_THRESHOLD: f64 = 0.70;

The threshold decision [Engine::sample] makes, pulled out as a pure function of probabilities alone (no RNG, no Engine) so it is unit-testable without constructing one. Returns which rule fires, and, for [SampleRule::Favourite], the argmax index directly - None for [SampleRule::Sampled], since that branch still needs a real roll over 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}

_sample_goal's distribution: normalise, add floor to each and renormalise, then flatten with log(p)/temperature through a softmax. A decision whose Answer carries a Choice's probabilities calls this from its own [Decision::decode]/[Decision::reuse] (via the sample 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}

One kind of question this project puts to Jev. See the module doc for what each item is for.

120pub trait Decision: Sized {

Everything this call needs. Built once, from game and bot state, by a constructor that lives beside this trait's impl - the game-state reading lives there and nowhere else.

124    type Facts;

The typed result: decoded from Jev's reply by [Self::decode], or built by [Self::fallback]. What the panel and the log show.

127    type Answer: Clone + Serialize + DeserializeOwned;

What is kept to answer the SAME question again within the reuse window, without asking. Not always the full Answer: a Choice's probabilities are kept per underlying option identity, not per the sentence built for one particular call, so a later call whose options happen to be grouped slightly differently can still look each one up - see [goal_choice::GoalChoice::reuse].

134    type Memory: Clone;

Identifies "the same question" for the reuse cache. Two calls with equal keys within [Config::reask_frames] share one [Self::Memory].

137    type Key: PartialEq + Clone;

The typed keys [Self::question] got back from Questions, which [Self::decode] reads the verified answers through.

140    type Asked;

This decision's name in the shared log and panel.

143    const KIND: &'static str;
145    fn key(facts: &Self::Facts) -> Self::Key;

Render the facts into the question sent to Jev, or None when there is nothing worth asking (one option, an empty rubric): [Engine::ask] then skips the network and the guards entirely and calls [Self::fallback] instead, recorded as [How::NoQuestion].

151    fn question(facts: &Self::Facts) -> Option<Ask<Self::Asked>>;

Turn Jev's reply into this decision's answer, and what to remember of it for [Self::reuse]. sample is the engine's own RNG ([Engine::sample]), offered for an Answer that is itself a distribution to pick from, as a Choice's is; a decision that never 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>;

What happens without Jev: upstream's own pick, and the reason. Used whenever [Self::question] returns None, for the run's own spending limit, and for the answer half of a throttle or a failure (whose own reason overrides this one in the log - see [How]).

169    fn fallback(facts: &Self::Facts) -> (Self::Answer, String);

Answer the same question again from what [Self::decode] chose to remember of it last time, against THIS call's facts (which may have regrouped the same underlying options slightly differently).

174    fn reuse(facts: &Self::Facts, memory: &Self::Memory, sample: &mut dyn FnMut(&[f64]) -> usize) -> Self::Answer;

One line for the trace log (eprintln!, never the persisted [Record]): "picked 2 of 5", say.

178    fn summary(answer: &Self::Answer) -> String;

Rewrite a JSON line from the shared history log into the CURRENT shape, before [Engine::load_history] parses it as a Record<Self::Answer> - the hook for schema evolution. An older line is not a different decision kind, so it must not be treated the way an unparseable line is (skipped, load_history's own doc comment): a field renamed, added, or reshaped since the line was written is migrated here, in one place, rather than the loader silently discarding everything written under an earlier shape. Default: unchanged, for a decision kind whose Answer/How never changed shape since its first line.

190    fn migrate(value: serde_json::Value) -> serde_json::Value {
191        value
192    }
193}

The model every question this project asks goes to. Pinned, so the thresholds tuned against it (the 0.70 argmax line) stay tuned: an alias moves under you. jev-latest named this release on 2026-10-01 (docs.typesafe.ai/models), so pinning it changed no answer.

199pub const MODEL: &str = "jev-1.13.0";
201pub fn model() -> jev_protocol::ModelId {
202    jev_protocol::ModelId::pinned(MODEL).expect("MODEL is a versioned id")
203}

One request: the state, every question about it, and the keys to read their answers by.

207pub struct Ask<K> {
208    pub state: Json,
209    pub questions: Questions,
210    pub keys: K,
211}

How a decision was made - shared by every decision kind, because the question of whether and why Jev was asked does not depend on what was asked about.

216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum How {

Jev was asked; what it cost, and the request exactly as sent (a panel's expanded view pretty-prints this; the log keeps it structured rather than pre-formatted, so it stays greppable with jq).

223    Asked { dollars: f64, input_tokens: u64, millis: u128, prompt: serde_json::Value },

[Decision::question] returned None: nothing was asked, and nothing spent.

226    NoQuestion { why: String },

The same question was asked at frame; that answer was reused ([Decision::reuse]) instead of asking again.

229    Reused { frame: u32 },

Upstream's pick, because the shared question bucket or the hour breaker said not now (jev_http::Failure::Throttled). Clears by itself; nothing to do but keep playing.

233    Throttled { why: String, retry_in_s: f64 },

Upstream's pick, because asking failed, was not allowed, or this run's own spending limit was reached.

236    Default { why: String },
237}

Every line ever written before kind existed was a goal choice - the only decision kind this project had until this crate's second one is built - so that is the one sound default for a line an older binary wrote.

243fn default_kind() -> String {
244    "goal_choice".to_owned()
245}

One decision, in the shape the shared history log keeps and the panel shows - what every decision kind agrees on (frame, kind, how) alongside whatever that kind's own Answer carries, flattened into the same JSON object so an old log line (written before kind existed) is 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,

Which of [Engine::sample]'s two rules picked the answer - [None] for a record that never sampled at all (How::NoQuestion, How::Throttled, How::Default: upstream's own pick, no roll). #[serde(default)] so a line from before this field existed still loads.

265    #[serde(default)]
266    pub sample_rule: Option<SampleRule>,
267}

Which of [Engine::sample]'s two rules picked an answer from a probability distribution. A probability clearly ahead of the rest ([FAVOURITE_THRESHOLD] or higher) is just taken - rolling dice on an answer already settled wastes a trip on the goal it would have picked anyway (frame 68593: a 10% option sampled against a clear favourite; frame 77784: a 19% option sampled). Below that threshold, a real sample over the (floor/temperature-flattened) distribution keeps exploring - the case exploration exists for.

277#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum SampleRule {

The top (raw, unflattened) probability was at or above the threshold: its index was returned directly, with no roll.

282    Favourite,

Nothing stood out: a real sample over the flattened distribution.

284    Sampled,
285}

Running totals, for "questions per minute" and "dollars per game-minute". A count that depends on knowing what "upstream's own pick" means for a particular Answer shape (goal-choice's overrides) is not here - it is 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,

Choices where [Decision::question] returned None.

297    pub no_question: u32,
298    pub defaults: u32,

Choices made without asking because the guards were throttling.

300    pub throttled: u32,

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}
308struct Remembered<D: Decision> {
309    frame: u32,
310    key: D::Key,
311    memory: D::Memory,
312}

Why [Engine::ask_guarded] did not send anything.

315enum NotAsked {
316    Throttled { why: String, retry_in: std::time::Duration },
317    Failed(String),
318}
320enum AskFailure {
321    Jev(jev_http::Failure),
322    Reply(&'static str),
323}

How often [Engine::guards] re-reads the ledger.

326const GUARDS_EVERY: std::time::Duration = std::time::Duration::from_secs(1);

Runs one kind of [Decision]: the guards, the call, the reuse cache, and the history log - everything a decision kind does not own itself. One of these per decision kind per process (each opens its own jev_http::Jev client and ledger handle; sharing one client's connection pool across two decision kinds in the same process is not needed while there is only 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,

The most recent decisions, oldest first, capped at [HISTORY_CAP]: what [Engine::history] returns for a panel and an MCP tool. Seeded from the on-disk log at startup ([Engine::load_history]) so a window restart - which rebuilds this engine from nothing - does not lose the panel's history; grown by [Engine::record] after that.

347    history: VecDeque<Record<D::Answer>>,
348    log: Option<std::fs::File>,

While the guards are throttling, when they said they would clear: until then nothing is asked, and the ledger is not even read.

351    throttled_until: Option<Instant>,

The shared guards as last read, and when.

353    guards: Option<(Instant, Result<jev_http::Status, String>)>,

Which rule [Self::sample]'s most recent call used - read (and cleared) by whichever ask/ask-adjacent path just called D::decode/D::reuse (which call sample exactly once, synchronously, so there is never more than one pending value to read). None until the first sample of this engine's life.

359    last_sample_rule: Option<SampleRule>,
360}
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    }

What the shared guards say - every process's questions, not only 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    }

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    }

Seed [Self::history] from path's existing JSONL log, keeping only the last [HISTORY_CAP] decisions - called once at startup, before [Self::log_to] starts appending to the same path, so a window restart does not lose the panel's history even though this engine itself is rebuilt from nothing. No such file yet is not an error, the first window of a fresh ROM.

Every line is run through [Decision::migrate] before it is parsed as a Record<D::Answer>, so a line written under an EARLIER shape of this decision's Answer/How is not just skipped (incident 2026-09-22: 1,659 real lines, almost the whole log, silently read as zero history because the loader only ever understood the newest shape). What still cannot be parsed after migrating - a line from some other decision kind, or a torn write from a process killed mid-append - is skipped rather than failing the whole read, the same 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    }

The most recent decisions, oldest first, bounded at [HISTORY_CAP]. The log this was seeded from and keeps growing (log_to) holds every 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    }
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    }

Sample an index from probabilities. A raw top probability at or above [FAVOURITE_THRESHOLD] is taken directly, with no roll ([SampleRule::Favourite]) - rolling dice on an answer already this settled only risks the same wasted trip a real sample exists to avoid for a close call. Otherwise, this engine's own floor/temperature ([flatten]) flattens the distribution and a real sample is drawn ([SampleRule::Sampled]). Either way, which rule fired is left in self.last_sample_rule for the caller to pick up (Engine::build's callers, immediately after D::decode/ D::reuse calls this). The sample closure a Decision's 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    }
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    }

Ask, or answer without asking, this decision's question for facts. Recorded and logged the same way whichever it was: totals updated, a one-line trace to stderr, and the whole Record appended to the log (log_to) and kept in history, so a caller need not do any of that itself - this IS the "new Jev call = a new module implementing 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    }
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    }

Frame 68593: a 10% option sampled against a clear favourite. A top probability at or above the threshold is taken directly - no roll, 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    }

Frame 77784: a 19% option was sampled - nothing here stood out enough to skip the roll, so the rule says to keep sampling (the actual draw is Engine::sample's own RNG, not this pure function's 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    }

The boundary itself: exactly at the threshold takes the favourite, 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}