README.mdpreviewREADME.mdsource125 lines · 6.9 KB · raw

Chapter 5: decisions, the one place Jev is asked anything

The bot of chapter 4 has one real decision. Every so often it scores all the goals it knows about (open this chest, go through that door, talk to that man) by roughly how far away they are, plus a penalty for each time a goal has already failed, and pursues the lowest. That is sensible and a bit short-sighted: the nearest pot always beats the dungeon two screens away.

A carried patch (0002, chapter 18) lets the host make that pick instead. The shim gathers the goals that are within reach and scored within a margin of the cheapest (at most six, upstream's own pick first) and hands them over. This crate says each one as a sentence, puts them to Jev as one Choice, and hands a pick back. Here is a real request, the golden copy a test holds the code to (the Castle Yard, two goals on offer):

{
  "model": "jev-1.13.0",
  "questions": {
    "next": {
      "type": "choice",
      "instructions": {
        "question": "Link is playing The Legend of Zelda: A Link to the Past, out to find every item and clear every dungeon. Which of these should he do next?",
        "focus": "Weigh what each is likely to gain - an item, a new area, progress toward a dungeon - against how far away it is and whether it has already failed."
      },
      "criteria": {
        "A": "Lift the pot in the north side of this screen, 6 steps west and 2 steps north of Link. It is about 62 steps away.",
        "B": "Open the unopened chest in the north-east corner of this screen, right beside Link. It is about 3 steps away."
      }
    }
  },
  "state": {
    "game": "The Legend of Zelda: A Link to the Past (open mode: Zelda is already rescued, the castle gate is open)",
    "link_is": "outdoors, in Castle Yard",
    "link_has": ["a sword", "the Pegasus Boots", "the Moon Pearl"],
    "hearts": 14.0,
    "heart_pieces_of_4": 2,
    "pendants": ["Wisdom"],
    "crystals": 2,
    "current_dungeon_small_keys": 1,
    "big_keys_held": ["Eastern Palace"],
    "progress_so_far": 9
  }
}

Jev answers with a probability for each option, and the pick is drawn from those. A clear favourite (70% or more) is simply taken; below that, the distribution is flattened a little (a floor of 0.05, then a softmax at temperature 2) and sampled, so a close call still explores.

Aside: Jev is never in charge of whether the game goes on. Every way a question can fail to happen (nothing worth asking, the same question asked a moment ago, the rate limiter saying "not now", the network erroring, this run's spending limit reached) ends the same way: upstream's own pick, and the bot plays on. With no chooser installed at all the hook is NULL and the bot is upstream's byte for byte.

Here is every path through Engine::ask, and the How each one is logged as:

flowchart TD
  start["a goal choice"] --> q{"two or more distinct options?"}
  q -- no --> nq["NoQuestion: upstream's pick, nothing spent"]
  q -- yes --> r{"same options asked within 1,800 frames?"}
  r -- yes --> re["Reused: the remembered answer, resampled"]
  r -- no --> lim{"this run's dollar limit reached?"}
  lim -- yes --> d1["Default: upstream's pick"]
  lim -- no --> g{"the shared spend guards admit it?"}
  g -- "not now" --> th["Throttled: upstream's pick, try again later"]
  g -- yes --> ask["ask Jev"]
  ask -- answered --> ok["Asked: Jev's pick, with cost and the exact request"]
  ask -- failed --> d2["Default: upstream's pick, and why"]

The guards are jev-http's (chapter 19 has where it comes from): a spend ledger on disk that every process on the machine shares, with a bucket of questions per minute and a breaker on dollars per hour. They clear by themselves; there is no lifetime cap, only the vendor's own "out of credit".

One kind today, a shape for more. The crate is built so that a second kind of question would be a new module, not a new code path. A kind owns five things (its Facts, how they become a Question, the typed Answer, the Fallback without Jev, and the Record it logs), and implements the Decision trait. Everything shared (the guards, the call, sampling, the reuse cache, the totals, the history log) lives once, in Engine.

Every decision is a line of jev.jsonl (the window's is run/zbanks-window/jev.jsonl). The window's Jev panel and the jev_history MCP tool read the last 200 back, and a restart reloads them, so the history survives the process.

Try it. nix develop -c buck2 test //packages/decisions:test runs offline: the sampling rule, the flattening, the request held byte-equal to a golden copy, the dungeon lore, and every older shape of log line still loading. To see one real round trip to Jev, run chapter 13's probe.

For the people who maintain it

In this folder

PathWhat
src/lib.rsThe Decision trait; Engine<D> (guards, the call, sample, the reuse cache, Totals, the history log, load_history); How, Record<A>, SampleRule; the shared Config; FAVOURITE_THRESHOLD (0.70); HISTORY_CAP (200); MODEL, pinned to jev-1.13.0.
src/goal_choice.rsThe first decision: Facts (the offers, where Link is, a Snapshot of inventory, hearts, pendants, crystals and keys, refreshed by Chooser::set_snapshot once a frame), Answer, the GoalChoice impl, DUNGEON_LORE (vanilla rewards appended to a sentence that already names a dungeon), and Chooser/Shared, which wire an Engine<GoalChoice> into zbanks::GoalChooser. Its Config adds margin (512), which goes straight to the C shim.
src/goal_choice/words.rsThe offered goals as options Jev can tell apart, from the bot's own records only: what, where (the screen by name and which third of it; steps east/west and north/south of Link on his screen; elsewhere, the compass point and the exit the bot's path leaves by), whether it is on the way to another option, failures so far, the path length. Goals that read the same are one option (offers); no two options share a sentence (tested).
BUCKThe rust_library and //packages/decisions:test.
let client = jev_http::Jev::from_env("native", decisions::model()).expect("key")?;
let mut chooser = decisions::goal_choice::Chooser::new(client, decisions::goal_choice::Config::default())?;
chooser.load_history(&log_path)?; // before log_to, so a restart keeps the panel's history
chooser.log_to(&log_path)?;
let chooser = decisions::goal_choice::Shared::new(chooser);
bot.set_goal_chooser(Some(Box::new(chooser.clone())), decisions::goal_choice::Config::default().margin);

Who uses it: apps/zbanks --jev (headless) and apps/native --jev (the window), and apps/jevprobe for decisions::model(). Measurements of cost and of how Jev's picks differ from upstream's: research/zbanks-alttp.md, "Jev at the goal choice".

← Previous: Chapter 4, zbanks/ · Up: packages · Next: Chapter 6, replay/ →