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;
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].
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.
Stop asking once this run has spent this much (the ledger's lifetime
cap still applies on top); None for no run limit.
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.
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.
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";
One request: the state, every question about it, and the keys to read their answers by.
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.
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.
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.
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.
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.
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.
The top (raw, unflattened) probability was at or above the threshold: its index was returned directly, with no roll.
282 Favourite,
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.
Choices made without asking because the guards were throttling.
300 pub throttled: u32,
Input tokens over every question asked.
Why [Engine::ask_guarded] did not send anything.
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).
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.
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.
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.
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.
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.
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).
The boundary itself: exactly at the threshold takes the favourite,
matching >= in decide_sample_rule, not >.