JaffarPlus
High-performance best-first search optimizer for tool-assisted speedruns
Loading...
Searching...
No Matches
driver.hpp
Go to the documentation of this file.
1#pragma once
2
9#include "engine.hpp"
10#include "game.hpp"
11#include "runner.hpp"
12#include <chrono>
13#include <cstdlib>
14#include <fstream>
15#include <jaffarCommon/deserializers/contiguous.hpp>
16#include <jaffarCommon/file.hpp>
17#include <jaffarCommon/serializers/contiguous.hpp>
18#include <jaffarCommon/string.hpp>
19#include <limits>
20#include <vector>
21
22namespace jaffarPlus
23{
24
35class Driver final
36
37{
38public:
58
68 Driver(const std::string& configFilePath, const nlohmann::json& config) : _configFilePath(configFilePath)
69 {
70 // Getting job identifier from the system timer
71 auto currentTime = std::chrono::system_clock::now();
72 _jobId = std::chrono::duration_cast<std::chrono::seconds>(currentTime.time_since_epoch()).count();
73
74 // Mutable working copy of the root config; each recognized key is consumed (popped) below, so any
75 // leftover key at the end is unrecognized (a typo or unsupported option) and is reported by name.
76 auto configRemaining = config;
77
78 // Getting driver configuration
79 auto driverConfig = jaffarCommon::json::popObject(configRemaining, "Driver Configuration");
80
81 // Getting end win delay config
82 // Stop condition on wins: "Stop Frames After First Win" = N keeps the search expanding N more
83 // steps past the FIRST win before stopping. 0 = stop immediately; absent/-1 = never stop on
84 // wins (run to Max Steps). Pairs naturally with "Win State Collection" below to harvest every
85 // distinct ending inside the window (e.g. board reseeding: distinct frozen RNG = distinct
86 // next layout). The legacy boolean "End On First Win State" is accepted as an alias
87 // (true -> 0, false -> -1) so archived configs keep their exact semantics.
89 if (driverConfig.contains("End On First Win State")) _stopFramesAfterFirstWin = jaffarCommon::json::popBoolean(driverConfig, "End On First Win State") ? 0 : -1;
90 if (driverConfig.contains("Stop Frames After First Win"))
91 _stopFramesAfterFirstWin = (ssize_t)jaffarCommon::json::popNumber<size_t>(driverConfig, "Stop Frames After First Win");
92 // Optional: save EVERY win state's solution, deduplicated by a configurable tuple of game
93 // properties. Driver-owned config; the engine performs the capture at the moment each win
94 // state is produced (the worker runner still holds it live).
95 if (driverConfig.contains("Win State Collection"))
96 {
97 auto wc = jaffarCommon::json::popObject(driverConfig, "Win State Collection");
98 auto enabled = jaffarCommon::json::popBoolean(wc, "Enabled");
99 std::vector<std::string> props;
100 for (const auto& n : jaffarCommon::json::getArray<nlohmann::json>(wc, "Dedup Properties")) props.push_back(n.get<std::string>());
101 wc.erase("Dedup Properties");
102 auto prefix = jaffarCommon::json::popString(wc, "Path Prefix");
103 size_t maxF = wc.contains("Max Files") ? jaffarCommon::json::popNumber<size_t>(wc, "Max Files") : 1000;
104 jaffarCommon::json::checkEmpty(wc, "Driver Configuration > Win State Collection");
105 // The engine does not exist yet at parse time -- stash and apply after construction below
106 if (enabled)
107 {
108 _winCollectProps = props;
109 _winCollectPrefix = prefix;
110 _winCollectMax = maxF;
111 _winCollectArm = true;
112 }
113 }
114
115 // Getting maximum number of steps (zero is not established)
116 _maxSteps = jaffarCommon::json::popNumber<uint32_t>(driverConfig, "Max Steps");
117
118 // For testing purposes, the maximum number of steps can be overriden via environment variables
119 if (auto* value = std::getenv("JAFFAR_DRIVER_OVERRIDE_DRIVER_MAX_STEP")) _maxSteps = std::stoul(value);
120
121 // Getting intermediate result configuration
122 auto saveIntermediateResultsJs = jaffarCommon::json::popObject(driverConfig, "Save Intermediate Results");
123 _saveIntermediateResultsEnabled = jaffarCommon::json::popBoolean(saveIntermediateResultsJs, "Enabled");
124 _saveIntermediateFrequency = jaffarCommon::json::popNumber<float>(saveIntermediateResultsJs, "Frequency (s)");
125 _saveIntermediateBestSolutionPath = jaffarCommon::json::popString(saveIntermediateResultsJs, "Best Solution Path");
126 _saveIntermediateWorstSolutionPath = jaffarCommon::json::popString(saveIntermediateResultsJs, "Worst Solution Path");
127 jaffarCommon::json::checkEmpty(saveIntermediateResultsJs, "Driver Configuration > Save Intermediate Results");
128
129 // Optional reference reward floor: cancel the whole job if the BEST state's reward falls below a
130 // per-step reference reward trace (beyond tolerance). This is purely a driver-level stopping criterion
131 // and diagnostic -- it does NOT prune states (recoverable slower-then-faster lines are kept); it only
132 // detects the first step at which the leading edge falls behind the reference (e.g. a reference TAS) and
133 // stops, so the run isn't ground on once it can no longer keep pace. Reward must be monotone-comparable.
137 if (driverConfig.contains("Reference Reward Floor"))
138 {
139 auto refJs = jaffarCommon::json::popObject(driverConfig, "Reference Reward Floor");
140 _referenceFloorEnabled = jaffarCommon::json::popBoolean(refJs, "Enabled");
141 _referenceFloorTolerance = jaffarCommon::json::popNumber<float>(refJs, "Tolerance");
142 // Optional (default 0): compare best against the reference's reward G steps EARLIER. With a
143 // discrete-jump reward (waypoint boxes), a zero reward tolerance cancels at every box boundary
144 // on a frames-scale timing skew even when the frontier is at pace; a step grace bounds the
145 // allowed slack in TIME uniformly (<= G frames behind schedule anywhere) instead of in reward
146 // units (which a jump makes step-dependent).
147 _referenceFloorStepGrace = refJs.contains("Step Grace") ? jaffarCommon::json::popNumber<uint32_t>(refJs, "Step Grace") : 0;
148 // The reference reward trace can be supplied two ways:
149 // - "Solution File": a reference solution (a .sol input sequence). Its per-step floor-reward trace is
150 // computed at engine init by replaying it through THIS run's own runner/game (see initialize()), so the
151 // floor is always consistent with the live reward function -- no external jaffar-player --dumpReward pass.
152 // - "Path": a precomputed file with one reward value per line (legacy; kept for backward compatibility).
153 _referenceSolutionPath = refJs.contains("Solution File") ? jaffarCommon::json::popString(refJs, "Solution File") : std::string();
154 const auto refPath = refJs.contains("Path") ? jaffarCommon::json::popString(refJs, "Path") : std::string();
155 // Optional reference-lineage rebase: when the search root and the reference live on DIFFERENT
156 // lineages (e.g. sectioned solving where the search seeds from our own previous-section win but
157 // the floor must be the reference's own trajectory), the floor replay first reloads this raw
158 // state file and replays this emulator-level prefix sequence -- exactly like the emulator's own
159 // "Initial State/Sequence File Path" pair -- and only then replays "Solution File" recording the
160 // trace. Depth k of the trace is then the reference's reward k steps after ITS OWN section start,
161 // compared against our best at k steps after OUR root: synchronized in reward, not in state.
162 _referenceFloorInitialStatePath = refJs.contains("Initial State File Path") ? jaffarCommon::json::popString(refJs, "Initial State File Path") : std::string();
163 _referenceFloorInitialSequencePath = refJs.contains("Initial Sequence File Path") ? jaffarCommon::json::popString(refJs, "Initial Sequence File Path") : std::string();
164 // Optional (default false): also cancel the instant the reference reward falls BELOW the worst kept
165 // state -- i.e. the reference solution has been evicted from the frontier, so the winning line can no
166 // longer be reached no matter how good "best" looks. A stricter companion to the best-below check.
167 _cancelIfReferenceBelowWorst = refJs.contains("Cancel If Reference Below Worst") ? jaffarCommon::json::popBoolean(refJs, "Cancel If Reference Below Worst") : false;
168 // Optional margin for the below-worst check (default 0): cancel only when ref + margin < worst. Set this
169 // to the Reference Pinning bonus so the guard does not fire while the pinned reference state (whose
170 // stored reward includes that bonus) is in fact still alive in the frontier.
171 _referenceBelowWorstMargin = refJs.contains("Below Worst Margin") ? jaffarCommon::json::popNumber<float>(refJs, "Below Worst Margin") : 0.0f;
172 jaffarCommon::json::checkEmpty(refJs, "Driver Configuration > Reference Reward Floor");
173 if (_referenceFloorEnabled && _referenceSolutionPath.empty() && refPath.empty())
174 JAFFAR_THROW_LOGIC("[ERROR] 'Reference Reward Floor' is enabled but neither 'Solution File' nor 'Path' was provided\n");
175 // Legacy precomputed-trace file: load it now. (The "Solution File" form defers to initialize(), once the
176 // runner/game exist to replay it.) Files are runtime artifacts, so skip the read under --dryRun
177 // (JAFFAR_IS_DRY_RUN): validate the config shape but don't depend on the cwd-relative file being present.
178 _referenceTracePath = refPath;
179 if (_referenceFloorEnabled && _referenceSolutionPath.empty() && std::getenv("JAFFAR_IS_DRY_RUN") == nullptr)
180 {
181 std::ifstream f(refPath);
182 if (f.good() == false) JAFFAR_THROW_RUNTIME("[ERROR] Could not open 'Reference Reward Floor' > 'Path': '%s'\n", refPath.c_str());
183 float v;
184 while (f >> v) _referenceReward.push_back(v);
185 jaffarCommon::logger::log("[J+] Reference reward floor enabled: %lu steps loaded, tolerance %.4f\n", _referenceReward.size(), _referenceFloorTolerance);
186 }
187 }
188
189 jaffarCommon::json::checkEmpty(driverConfig, "Driver Configuration");
190
191 // Getting component configurations (consumed from the root so the root check below can flag strays)
192 auto emulatorConfig = jaffarCommon::json::popObject(configRemaining, "Emulator Configuration");
193 auto gameConfig = jaffarCommon::json::popObject(configRemaining, "Game Configuration");
194 auto runnerConfig = jaffarCommon::json::popObject(configRemaining, "Runner Configuration");
195 auto engineConfig = jaffarCommon::json::popObject(configRemaining, "Engine Configuration");
196
197 // Any remaining top-level key is unrecognized
198 jaffarCommon::json::checkEmpty(configRemaining, "configuration root");
199
200 // Creating runner from the configuration
201 _runner = jaffarPlus::Runner::getRunner(emulatorConfig, gameConfig, runnerConfig);
202
203 // Creating engine from the configuration
204 _engine = std::make_unique<Engine>(emulatorConfig, gameConfig, runnerConfig, engineConfig);
206 }
207
210
218 {
219 // Resetting step counter
220 _currentStep = 0;
221
222 // Resetting win state counter
223 _winStatesFound = 0;
224
225 // Resetting best states reward
226 _bestWinStateReward = -std::numeric_limits<float>::infinity();
227 _bestStateReward = -std::numeric_limits<float>::infinity();
228 _bestStateFloorReward = -std::numeric_limits<float>::infinity();
229
230 // Resetting worst state reward
231 _worstStateReward = std::numeric_limits<float>::infinity();
232
233 // Initializing runner
234 _runner->initialize();
235
236 // Initializing engine
237 _engine->initialize();
238
239 // Allocating space for the current best and worst states. These are standalone snapshots outside the
240 // NUMA slabs, so they hold the FULL self-contained state ([hot]+[history]), not just the hot slot.
241 _stateSize = _engine->getFullStateSize();
244
245 // If a reference SOLUTION file was provided for the reward floor, compute its per-step floor-reward trace
246 // now by replaying it through our own runner/game -- guaranteeing the floor is measured with the exact same
247 // reward function the search uses. Depth 0 (the initial post-initial-sequence state, i.e. the search root)
248 // is recorded first, then one reward per applied input, so _referenceReward[N] is the reference's floor
249 // reward at search depth N -- aligned with _bestStateFloorReward, which the run loop compares against it.
250 const bool referencePruneRequested = _engine->isReferencePruneRequested();
251 if ((_referenceFloorEnabled || referencePruneRequested) && _referenceSolutionPath.empty() == false && std::getenv("JAFFAR_IS_DRY_RUN") == nullptr)
252 {
253 // Load the reference solution (.sol files are null-separated input strings, as the player reads them)
254 std::string solutionString;
255 if (jaffarCommon::file::loadStringFromFile(solutionString, _referenceSolutionPath) == false)
256 JAFFAR_THROW_RUNTIME("[ERROR] Could not open 'Reference Reward Floor' > 'Solution File': '%s'\n", _referenceSolutionPath.c_str());
257 const auto referenceInputs = jaffarCommon::string::split(solutionString, '\0');
258
259 // Snapshot the runner's initial (post-initial-sequence) state so we can restore it after the replay. The
260 // engine already captured the root from this same state above, so the replay only borrows the runner.
261 std::vector<uint8_t> initialStateStorage(_runner->getStateSize());
262 {
263 jaffarCommon::serializer::Contiguous s(initialStateStorage.data(), initialStateStorage.size());
264 _runner->serializeState(s);
265 }
266
267 auto* game = _runner->getGame();
268 auto* emulator = game->getEmulator();
269 _referenceReward.clear();
270
271 // Reference-lineage rebase (see config parsing): put the emulator on the reference's OWN
272 // trajectory before replaying the slice. The raw state file is loaded with all state properties
273 // enabled (the same dance as emulator init -- state files carry the full property set), and the
274 // prefix replays at the EMULATOR level so the game module's lineage variables (route odometer,
275 // latches) stay at their root defaults, exactly as they do for the search root on its lineage.
276 if (_referenceFloorInitialStatePath.empty() == false)
277 {
278 std::string st;
279 if (jaffarCommon::file::loadStringFromFile(st, _referenceFloorInitialStatePath) == false)
280 JAFFAR_THROW_RUNTIME("[ERROR] Could not open 'Reference Reward Floor' > 'Initial State File Path': '%s'\n", _referenceFloorInitialStatePath.c_str());
281 emulator->enableAllStateProperties();
282 jaffarCommon::deserializer::Contiguous d(st.data(), st.size());
283 emulator->deserializeState(d);
284 emulator->reapplyDisabledStateProperties();
285 }
286 if (_referenceFloorInitialSequencePath.empty() == false)
287 {
288 std::string seqString;
289 if (jaffarCommon::file::loadStringFromFile(seqString, _referenceFloorInitialSequencePath) == false)
290 JAFFAR_THROW_RUNTIME("[ERROR] Could not open 'Reference Reward Floor' > 'Initial Sequence File Path': '%s'\n", _referenceFloorInitialSequencePath.c_str());
291 const auto prefixInputs = jaffarCommon::string::split(seqString, '\0');
292 for (const auto& inputString : prefixInputs)
293 if (inputString.empty() == false) emulator->advanceState(emulator->registerInput(inputString));
294 }
295
296 // Per-depth serialized reference states, captured for EXACT pinning: with a quantized (coarse)
297 // dedup hash, hash equality no longer identifies the reference lineage, so the engine byte-compares
298 // pin candidates against these exact states (hash match = cheap pre-filter only).
299 std::vector<std::vector<uint8_t>> referenceStates;
300 const auto captureState = [&]()
301 {
302 // Capture in CANONICAL (round-tripped) form: serialize, load back, serialize again. Search-side
303 // states are always post-round-trip, and a few emulator header bytes normalize on load -- a raw
304 // linear-replay capture differs in those and would fail the exact byte comparison.
305 std::vector<uint8_t> st(_runner->getStateSize());
306 {
307 jaffarCommon::serializer::Contiguous s(st.data(), st.size());
308 _runner->serializeState(s);
309 }
310 {
311 jaffarCommon::deserializer::Contiguous d(st.data(), st.size());
312 _runner->deserializeState(d);
313 }
314 {
315 jaffarCommon::serializer::Contiguous s(st.data(), st.size());
316 _runner->serializeState(s);
317 }
318 referenceStates.push_back(std::move(st));
319 };
320
321 // Depth 0: evaluate the initial state exactly as the engine does when it seeds the root
322 game->evaluateRules();
323 game->updateGameStateType();
324 game->updateReward();
325 _referenceReward.push_back(game->getFloorReward());
326 _referenceStateType.push_back((int)game->getStateType());
327 captureState();
328
329 // Replay each input, recording the floor reward at each resulting depth. Each step first
330 // round-trips the runner (serialize + load) so the replay follows the exact same
331 // load -> advance -> serialize path as the search; otherwise path-dependent residue bytes
332 // (CPU scratch, audio buffer tails) differ and exact pin verification can never match.
333 std::vector<uint8_t> rt(_runner->getStateSize());
334 for (const auto& inputString : referenceInputs)
335 {
336 if (inputString.empty()) continue;
337 {
338 jaffarCommon::serializer::Contiguous s(rt.data(), rt.size());
339 _runner->serializeState(s);
340 }
341 {
342 jaffarCommon::deserializer::Contiguous d(rt.data(), rt.size());
343 _runner->deserializeState(d);
344 }
345 _runner->advanceState(emulator->registerInput(inputString));
346 game->evaluateRules();
347 game->updateGameStateType();
348 game->updateReward();
349 _referenceReward.push_back(game->getFloorReward());
350 _referenceStateType.push_back((int)game->getStateType());
351 captureState();
352 }
353 std::vector<std::string> refInputStrings;
354 for (const auto& s : referenceInputs)
355 if (s.empty() == false) refInputStrings.push_back(s);
356 _engine->setReferenceStates(std::move(referenceStates), refInputStrings);
357
358 // Diagnostic (JAFFAR_DUMP_REF_TRACE=<file>): write the computed per-step floor-reward trace
359 // for offline analysis (e.g. reward-function monotonicity studies).
360 if (const char* tracePath = std::getenv("JAFFAR_DUMP_REF_TRACE"); tracePath != nullptr)
361 {
362 std::string out;
363 for (size_t i = 0; i < _referenceReward.size(); i++)
364 out += std::to_string(i) + "\t" + std::to_string(_referenceReward[i]) + "\t" + std::to_string(i < _referenceStateType.size() ? _referenceStateType[i] : -1) + "\n";
365 jaffarCommon::file::saveStringToFile(out, tracePath);
366 }
367
368 // Restore the runner to its initial state (and reset its step counter) for the search
369 _runner->setStepCount(0);
370 {
371 jaffarCommon::deserializer::Contiguous d(initialStateStorage.data(), initialStateStorage.size());
372 _runner->deserializeState(d);
373 }
374
375 jaffarCommon::logger::log("[J+] Reference reward floor computed from solution '%s': %lu steps, tolerance %.4f\n", _referenceSolutionPath.c_str(), _referenceReward.size(),
377 }
378
379 // Arm reference pruning in the engine now that the trace exists. The trace source is the
380 // driver's "Reference Reward Floor" (Solution File replayed above, or the legacy "Path" file --
381 // loaded here if the floor itself is disabled and only the engine-side prune needs it).
382 if (referencePruneRequested)
383 {
384 if (_referenceReward.empty() && _referenceTracePath.empty() == false && std::getenv("JAFFAR_IS_DRY_RUN") == nullptr)
385 {
386 std::ifstream f(_referenceTracePath);
387 if (f.good() == false) JAFFAR_THROW_RUNTIME("[ERROR] Could not open 'Reference Reward Floor' > 'Path': '%s'\n", _referenceTracePath.c_str());
388 float v;
389 while (f >> v) _referenceReward.push_back(v);
390 }
391 if (_referenceReward.empty() && std::getenv("JAFFAR_IS_DRY_RUN") == nullptr)
392 JAFFAR_THROW_LOGIC("[ERROR] 'Reference Reward Prune' is enabled but the 'Reference Reward Floor' section provides no trace ('Solution File' or 'Path')\n");
393 if (_referenceReward.empty() == false)
394 {
395 _engine->setReferencePruneTrace(_referenceReward);
396 jaffarCommon::logger::log("[J+] Reference pruning armed: %lu-step trace supplied to the engine\n", _referenceReward.size());
397 }
398 }
399 }
400
411 int run()
412 {
413 // Internal flag to indicate we are still running
414 _hasFinished = false;
415
416 // If using ncurses, initialize terminal now
417 jaffarCommon::logger::initializeTerminal();
418
419 // Storage for the exit
420 exitReason_t exitReason;
421
422 // Starting intermediate result saving thread
423 std::thread intermediateResultSaverThread;
424 if (_saveIntermediateResultsEnabled == true) intermediateResultSaverThread = std::thread([this]() { intermediateResultSaveLoop(); });
425
426 // Running engine until a termination point
427 while (true)
428 {
429 // If found winning state, report it now
430 if (_engine->isWinCollectionFull())
431 {
433 break;
434 }
435 if (_engine->getWinStatesFound() > 0 && _firstWinStep < 0) _firstWinStep = (ssize_t)_currentStep;
437 {
438 exitReason = exitReason_t::winStateFound;
439 break;
440 }
441
442 // If ran out of states, finish now
443 if (_engine->getStateCount() == 0)
444 {
445 exitReason = _engine->getWinStatesFound() > 0 ? exitReason_t::winStateFound : exitReason_t::outOfStates;
446 break;
447 }
448
449 // If maximum step established and reached, finish now
450 if (_maxSteps > 0 && _currentStep >= _maxSteps)
451 {
452 if (_winStatesFound > 0) exitReason = exitReason_t::winStateFound;
454 break;
455 }
456
457 // Updating best and worst states
460
461 // Reference frame-count cap: the win state is checked at the top of the loop, so if the floor is enabled and
462 // we have reached the reference's frame count here without winning, the run has spent the reference's entire
463 // frame budget -- it can no longer even match, let alone beat, the reference. Cancel.
465 {
466 jaffarCommon::logger::log("[J+] Reached reference frame count (%lu frames) at step %lu without winning -- can no longer beat the reference, cancelling.\n",
469 break;
470 }
471
472 // Reference reward floor: if the best leading edge has fallen below the reference at this step, the run
473 // can no longer keep pace with the reference -- cancel now (purely a stop signal; nothing was pruned).
474 // With Step Grace G, the comparison point is the reference G steps earlier (bounded time slack).
475 const size_t floorRefStep = (_currentStep > _referenceFloorStepGrace) ? _currentStep - _referenceFloorStepGrace : 0;
477 {
478 // Diagnostic (JAFFAR_FLOOR_AUDIT=1): on cancel, byte-compare the best state against the
479 // stored canonical reference state at this depth, under the volatile-residue mask.
480 if (false)
481 {
482 const auto& refStates = _engine->getRefStates();
483 const auto& mask = _engine->getRefVolatileMask();
484 if (floorRefStep < refStates.size() && refStates[floorRefStep].size() > 0)
485 {
486 _engine->getStateDb()->loadStateIntoRunner(*_runner, _bestStateStorage.data());
487 std::vector<uint8_t> st(_runner->getStateSize());
488 {
489 jaffarCommon::serializer::Contiguous ser(st.data(), st.size());
490 _runner->serializeState(ser);
491 }
492 {
493 jaffarCommon::deserializer::Contiguous des(st.data(), st.size());
494 _runner->deserializeState(des);
495 }
496 {
497 jaffarCommon::serializer::Contiguous ser(st.data(), st.size());
498 _runner->serializeState(ser);
499 }
500 const auto& ref = refStates[floorRefStep];
501 size_t total = 0, volat = 0;
502 std::string offs;
503 for (size_t i = 0; i < std::min(st.size(), ref.size()); i++)
504 if (st[i] != ref[i])
505 {
506 total++;
507 if (i < mask.size() && mask[i] != 0)
508 {
509 volat++;
510 continue;
511 }
512 if (offs.size() < 400) offs += " " + std::to_string(i) + "(" + std::to_string(ref[i]) + "->" + std::to_string(st[i]) + ")";
513 }
514 jaffarCommon::logger::log("[J+] FLOOR AUDIT step %lu: best-vs-ref diffs=%lu (volatile-masked=%lu, causal=%lu); causal offsets:%s\n", _currentStep, total, volat,
515 total - volat, offs.c_str());
516 jaffarCommon::logger::log("[J+] FLOOR AUDIT rewards: bestFloor=%.6f bestSearch=%.6f refFloor=%.6f\n", _bestStateFloorReward, _bestStateReward,
517 _referenceReward[floorRefStep]);
518 }
519 }
520 jaffarCommon::logger::log("[J+] Best (%.6f) fell below reference floor (%.6f, tol %.4f, grace %u steps) at step %lu by %.6f -- cancelling.\n", _bestStateFloorReward,
524 break;
525 }
526
527 // Reference-below-worst (opt-in): the reference reward has dropped below the WORST kept
528 // state -- the frontier band no longer contains reference-level states (frontier likely
529 // needs increasing). Warning only; surfaced as an addendum on the per-step reference log
530 // line (no standalone message per the logging policy).
533
534 // Input-history backing guard: the "Trie" strategy's shared node pool grows ~ live-states x depth
535 // toward a hard ceiling (getInputHistoryMaxMemoryBytes). Stop GRACEFULLY at a high-water mark -- or, as
536 // a backstop, if a worker already latched the pool exhausted -- so the search saves its best result and
537 // exits cleanly, instead of a worker hitting the ceiling mid-step and terminating the whole process.
538 // No-op for None/Raw (ceiling 0): their history lives in the StateDb slot, already bounded.
539 {
540 const size_t ihCeiling = _engine->getInputHistoryMaxMemoryBytes();
541 if (ihCeiling > 0)
542 {
543 const size_t ihNow = _engine->getInputHistoryApproxMemoryBytes();
544 const bool exhausted = _engine->isInputHistoryExhausted();
545 if (exhausted || ihNow >= (size_t)((double)ihCeiling * _inputHistoryCapacityWatermark))
546 {
547 const double GB = 1024.0 * 1024.0 * 1024.0;
548 jaffarCommon::logger::log("[J+] Input-history trie at %.1f / %.1f GB (%.0f%% of its hard ceiling)%s at step %lu -- stopping "
549 "gracefully. The Trie node pool grows ~ live-states x depth and cannot be enlarged past RAM; switch "
550 "Store Input History Type to \"Raw\" (bounded by 'State Database/Max Size (Mb)'), or lower the State "
551 "DB size so fewer live states slow the trie's growth.\n",
552 (double)ihNow / GB, (double)ihCeiling / GB, 100.0 * (double)ihNow / (double)ihCeiling, exhausted ? " (pool exhausted)" : "", _currentStep);
554 break;
555 }
556 }
557 }
558
559 // Storing manually saved solution, if required
561
562 // Printing information
563 printInfo();
564
565 // Running engine step
566 _engine->runStep();
567
568 // Summing amount of win states found
569 _winStatesFound = _engine->getWinStatesFound();
570
571 // Increasing step counter
572 _currentStep++;
573 }
574
575 // Setting finalized flag
576 _hasFinished = true;
577
578 // Waiting for saver thread
579 if (_saveIntermediateResultsEnabled == true) intermediateResultSaverThread.join();
580
581 // If using ncurses, terminate terminal now
582 jaffarCommon::logger::finalizeTerminal();
583
584 // Updating and storing best states
587
588 // Also flush any pending "Trigger Save Solution" request: with End-On-First-Win the loop breaks at
589 // the top before the winning step's storeManualSaveSolution() runs, so a win rule's save would be
590 // lost. The engine's manual-save request persists across steps, so writing it here captures it.
592
593 // Final report
594 printInfo();
595
596 // Otherwise return the reason why we stopped
597 return exitReason;
598 }
599
607 {
608 auto manualSaveSolution = _engine->getManualSaveSolution();
609
610 if (manualSaveSolution.path != "")
611 {
612 // Loading the saved state into the runner (its depth was recorded at capture; set it before
613 // deserializing so the trie can rebuild and the solution renders to the right length).
614 _runner->setStepCount(manualSaveSolution.stepCount);
615 _engine->getStateDb()->loadStateIntoRunner(*_runner, manualSaveSolution.stateData);
616
617 // Saving manually stored solution
618 std::string solutionData = _runner->getInputHistoryString();
619 jaffarCommon::file::saveStringToFile(solutionData, manualSaveSolution.path);
620 }
621 }
622
630 {
631 // Making sure the main thread is not currently writing
633
634 // Saving best solution and state
635 std::string jobSuffix = std::string(".") + std::to_string(_jobId);
636 std::string stepSuffix = std::string(".") + std::to_string(_currentStep);
637
638 // Saving files with standard name
639 if (_saveIntermediateBestSolutionPath != "") jaffarCommon::file::saveStringToFile(_bestSolutionStorage, _saveIntermediateBestSolutionPath);
640
641 // Saving files with a job suffix and step number
642 if (_saveIntermediateBestSolutionPath != "") jaffarCommon::file::saveStringToFile(_bestSolutionStorage, _saveIntermediateBestSolutionPath + jobSuffix + stepSuffix);
643
644 // Making sure the main thread is not currently writing
646 }
647
655 {
656 // Making sure the main thread is not currently writing
658
659 // Saving best solution and state
660 std::string jobSuffix = std::string(".") + std::to_string(_jobId);
661
662 // Saving best solution and state
664
665 // Saving best solution and state
666 if (_saveIntermediateWorstSolutionPath != "") jaffarCommon::file::saveStringToFile(_worstSolutionStorage, _saveIntermediateWorstSolutionPath + jobSuffix);
667
668 // Making sure the main thread is not currently writing
670 }
671
680 {
681 // Timer for saving to file
682 auto lastSaveTime = jaffarCommon::timing::now();
683
684 // Run loop while the driver is still running
685 while (_hasFinished == false)
686 {
687 // Sleeping for 100ms intervals to prevent excessive overheads
688 usleep(100000);
689
690 // Getting time elapsed since last save
691 auto currentTime = jaffarCommon::timing::now();
692 auto timeElapsedSinceLastSave = jaffarCommon::timing::timeDeltaSeconds(currentTime, lastSaveTime);
693
694 // Checking if we need to save best state
695 if (timeElapsedSinceLastSave > _saveIntermediateFrequency && _currentStep > 1)
696 {
697 // Saving worst and best state information
700
701 // Resetting timer
702 lastSaveTime = jaffarCommon::timing::now();
703 }
704 }
705 }
706
715 {
716 // If no states in database, there is nothing to update
717 if (_engine->getStateDb()->getStateCount() == 0) return;
718
719 // Making sure the intermediate result thread is not currently reading
721
722 // Getting worst state so far
723 auto worstState = _engine->getStateDb()->getWorstState();
724
725 // Saving worst state into the storage (gather hot slab slot + its cold history into the full buffer)
726 _engine->getStateDb()->captureSlotToBuffer(worstState, _worstStateStorage.data());
727
728 // The worst state belongs to the current frontier, so its depth is the current search step (the step
729 // counter is not stored per-state). Set it before loading so the solution renders to the right length.
730 _runner->setSearchStep(_currentStep);
731 _engine->getStateDb()->loadStateIntoRunner(*_runner, _worstStateStorage.data());
732
733 // Saving worst solution into storage
734 _worstSolutionStorage = _runner->getInputHistoryString();
735
736 // Updating worst state reward
737 _worstStateReward = _runner->getGame()->getReward();
738
739 // Making sure the intermediate result thread is not currently reading
741 }
742
753 {
754 // If no states in database and no win states, there is nothing to update
755 if (_engine->getStateDb()->getStateCount() == 0 && _winStatesFound == 0) return;
756
757 // Making sure the intermediate result thread is not currently reading
759
760 // If we haven't found any winning state, simply use the currently best state
761 if (_winStatesFound == 0)
762 {
763 // Getting best state so far
764 auto bestState = _engine->getStateDb()->getBestState();
765
766 // Saving best state into the storage (gather hot slab slot + its cold history into the full buffer)
767 if (bestState != nullptr) _engine->getStateDb()->captureSlotToBuffer(bestState, _bestStateStorage.data());
768 }
769
770 // If we have found a winning state in this step that improves on the current best, save it now
771 if (_engine->getWinStatesFound() > 0)
772 {
773 // Getting best win state (best reward) for the current step
774 auto winStateEntry = _engine->getStepBestWinState();
775
776 // If the reward if better than the current best, then make it the new best state
777 if (winStateEntry.reward > _bestWinStateReward)
778 {
779 // Saving new best
780 _bestWinStateReward = winStateEntry.reward;
781
782 // Saving win state into the storage (and remembering its depth for solution rendering)
783 memcpy(_bestStateStorage.data(), winStateEntry.stateData, _stateSize);
784 _bestWinStateStepCount = winStateEntry.stepCount;
785 }
786 }
787
788 // Set the runner's step counter to the best state's depth before loading (the counter is not stored
789 // per-state): a win's depth was recorded at capture; an ordinary best belongs to the current frontier.
790 if (_winStatesFound > 0)
791 _runner->setStepCount(_bestWinStateStepCount);
792 else
793 _runner->setSearchStep(_currentStep);
794 _bestStateStepCount = _runner->getStepCount(); // remembered so the printInfo reload uses the same depth
795 _engine->getStateDb()->loadStateIntoRunner(*_runner, _bestStateStorage.data());
796
797 // Updating best state reward
798 _bestStateReward = _runner->getGame()->getReward();
799 _bestStateFloorReward = _runner->getGame()->getFloorReward(); // un-biased position for the Reference Reward Floor (decoupled from the magnet)
800
801 // Diagnostic (JAFFAR_FLOOR_AUDIT=1): per-step best-vs-reference byte comparison under the
802 // volatile-residue mask -- localizes the step where the reference-follower leaves the frontier.
803 if (std::getenv("JAFFAR_FLOOR_AUDIT") != nullptr && _referenceFloorEnabled)
804 {
805 const auto& refStates = _engine->getRefStates();
806 const auto& mask = _engine->getRefVolatileMask();
807 const size_t depth = _currentStep;
808 if (depth < refStates.size() && refStates[depth].size() > 0)
809 {
810 std::vector<uint8_t> st(_runner->getStateSize());
811 {
812 jaffarCommon::serializer::Contiguous ser(st.data(), st.size());
813 _runner->serializeState(ser);
814 }
815 {
816 jaffarCommon::deserializer::Contiguous des(st.data(), st.size());
817 _runner->deserializeState(des);
818 }
819 {
820 jaffarCommon::serializer::Contiguous ser(st.data(), st.size());
821 _runner->serializeState(ser);
822 }
823 const auto& ref = refStates[depth];
824 size_t causal = 0;
825 std::string offs;
826 for (size_t i = 0; i < std::min(st.size(), ref.size()); i++)
827 if (st[i] != ref[i] && (i >= mask.size() || mask[i] == 0))
828 {
829 causal++;
830 if (offs.size() < 240) offs += " " + std::to_string(i) + "(" + std::to_string(ref[i]) + "->" + std::to_string(st[i]) + ")";
831 }
832 jaffarCommon::logger::log("[J+] FLOOR AUDIT step %lu: best-vs-ref causal diffs=%lu; bestFloor=%.6f refFloor=%.6f%s\n", depth, causal, _bestStateFloorReward,
833 depth < _referenceReward.size() ? _referenceReward[depth] : -1.0f, causal > 0 && causal < 12 ? offs.c_str() : "");
834 }
835 }
836
837 // Storing best solution
838 _bestSolutionStorage = _runner->getInputHistoryString();
839
840 // Making sure the intermediate result thread is not currently reading
842 }
843
852 {
853 // If using ncurses, clear terminal before printing the information for this step
854 jaffarCommon::logger::clearTerminal();
855
856 // Printing information
857 jaffarCommon::logger::log("[J+] Job Id: %lu\n", _jobId);
858 jaffarCommon::logger::log("[J+] Script File: '%s'\n", _configFilePath.c_str());
859 jaffarCommon::logger::log("[J+] Emulator Name: '%s'\n", _runner->getGame()->getEmulator()->getName().c_str());
860 jaffarCommon::logger::log("[J+] Game Name: '%s'\n", _runner->getGame()->getName().c_str());
861 jaffarCommon::logger::log("[J+] Current Step #: %lu", _currentStep);
862 if (_maxSteps > 0) jaffarCommon::logger::log(" (Max: %lu)", _maxSteps);
863 jaffarCommon::logger::log("\n");
864
865 if (_winStatesFound == 0)
866 jaffarCommon::logger::log("[J+] Current Reward (Best / Worst): %.6f / %.6f (Diff: %.6f)\n", _bestStateReward, _worstStateReward,
868
869 if (_winStatesFound > 0)
870 jaffarCommon::logger::log("[J+] Current Reward (Win / Worst): %.6f / %.6f (Diff: %.6f)\n", _bestStateReward, _worstStateReward,
872
873 // When a reference trace exists (floor cancel and/or engine-side pruning), show the reference
874 // reward at this step and how the best compares to it (positive = best ahead of the reference,
875 // negative = best behind), for easy human review.
876 if (_referenceReward.empty() == false)
877 {
878 if (_currentStep < _referenceReward.size())
879 {
880 // The cancel check compares against the reference Step-Grace steps EARLIER; print that
881 // graced margin too whenever grace is active, so the display always matches the check.
882 const size_t floorRefStep = _currentStep >= _referenceFloorStepGrace ? _currentStep - _referenceFloorStepGrace : 0;
883 const char* belowWorstTag = _referenceBelowWorstNow ? " [ref BELOW WORST -- frontier?]" : "";
885 jaffarCommon::logger::log(
886 "[J+] Reference Reward (Ref / Best-Ref): %.6f (Best-Ref %+.6f; graced check vs step %lu: %+.6f, floor tol %.4f) [step %lu / %lu ref steps]%s\n",
889 else
890 jaffarCommon::logger::log("[J+] Reference Reward (Ref / Best-Ref): %.6f (Best-Ref %+.6f, floor tol %.4f) [step %lu / %lu ref steps]%s\n",
892 _referenceReward.size(), belowWorstTag);
893 }
894 else
895 jaffarCommon::logger::log("[J+] Reference Reward (Ref / Best-Ref): (none: step %lu beyond reference trace of %lu steps)\n", _currentStep,
896 _referenceReward.size());
897
898 // Projected lead: the earliest reference step whose reward matches or exceeds the current
899 // best. Its gap to the current step estimates how many frames a win would save if the
900 // advantage held to the end (rough guide only -- reward is not a perfect progress clock).
901 {
902 size_t parityStep = _referenceReward.size();
903 for (size_t i = 0; i < _referenceReward.size(); i++)
905 {
906 parityStep = i;
907 break;
908 }
909 if (parityStep < _referenceReward.size())
910 jaffarCommon::logger::log("[J+] Reference Parity Step: %lu (projected frames saved: %+ld)\n", parityStep,
911 (int64_t)parityStep - (int64_t)_currentStep);
912 else
913 jaffarCommon::logger::log("[J+] Reference Parity Step: beyond trace end (best exceeds final reference reward; projected savings >= %+ld)\n",
914 (int64_t)_referenceReward.size() - (int64_t)_currentStep);
915 }
916 }
917
918 // Printing engine information
919 jaffarCommon::logger::log("[J+] Engine Information: \n");
920 _engine->printInfo();
921
922 // Loading best state into runner (same depth updateBestState() used, so the trie rebuilds correctly)
923 _runner->setStepCount(_bestStateStepCount);
924 _engine->getStateDb()->loadStateIntoRunner(*_runner, _bestStateStorage.data());
925
926 // Printing best state information to screen
927 jaffarCommon::logger::log("[J+] Runner Information (Best State): \n");
928 _runner->printInfo();
929 jaffarCommon::logger::log("[J+] Game Information (Best State): \n");
930 _runner->getGame()->printInfo();
931 jaffarCommon::logger::log("[J+] Emulator Information (Best State): \n");
932 _runner->getGame()->getEmulator()->printInfo();
933
934 // Division rule to separate different steps
935 jaffarCommon::logger::log("[J+] --------------------------------------------------------------\n");
936
937 // If using ncurses, refresh terminal now
938 jaffarCommon::logger::refreshTerminal();
939 }
940
947 static std::unique_ptr<Driver> getDriver(const std::string& configFilePath, const nlohmann::json& config)
948 {
949 // Creating new engine
950 auto d = std::make_unique<Driver>(configFilePath, config);
951
952 // Returning engine
953 return d;
954 }
955
957 size_t getCurrentStep() { return _currentStep; }
958
959private:
960 const std::string _configFilePath;
961
962 std::unique_ptr<Engine> _engine;
963
964 std::unique_ptr<Runner> _runner;
965
966 size_t _jobId;
967
968 size_t _maxSteps;
969
971
973
974 ssize_t _firstWinStep = -1;
975
976 bool _winCollectArm = false;
977
978 std::vector<std::string> _winCollectProps;
979
980 std::string _winCollectPrefix;
981
982 size_t _winCollectMax = 1000;
983
985
989
992
994
1001
1003 std::vector<float> _referenceReward;
1004 std::vector<int> _referenceStateType;
1008
1014
1015 std::string _bestStateStorage;
1016
1018
1020
1022
1023 size_t _stateSize;
1024
1025 __volatile__ bool _hasFinished;
1026
1028
1030
1032
1034
1036
1038};
1039
1040} // namespace jaffarPlus
Owns and runs the engine's step loop and reports how the run ended.
Definition driver.hpp:37
std::mutex _updateIntermediateResultMutex
Guards intermediate result storage between the main and saver threads.
Definition driver.hpp:1037
size_t _bestWinStateStepCount
Depth of the best win state (recorded at capture; the count is not stored per-state).
Definition driver.hpp:987
float _saveIntermediateFrequency
Minimum interval, in seconds, between intermediate result saves.
Definition driver.hpp:1031
bool _cancelIfReferenceBelowWorst
Opt-in: enable the below-worst check (surfaced as a log addendum, not a cancel).
Definition driver.hpp:998
std::string _bestSolutionStorage
Storage for the current best solution's input history.
Definition driver.hpp:1019
size_t _winStatesFound
Total number of win states found so far.
Definition driver.hpp:984
float _worstStateReward
Reward for the worst state found so far.
Definition driver.hpp:993
std::string _referenceSolutionPath
Optional reference solution (.sol) replayed at init to build _referenceReward.
Definition driver.hpp:1005
int run()
Runs the engine's step loop until a termination condition is met.
Definition driver.hpp:411
size_t getCurrentStep()
Returns the current step counter.
Definition driver.hpp:957
bool _winCollectArm
Whether "Win State Collection" was enabled in the config (applied to the engine post-construction)
Definition driver.hpp:976
exitReason_t
Reason the run loop terminated, returned by run.
Definition driver.hpp:41
@ referenceBelowWorst
The reference reward fell below the worst kept state (reference evicted from the frontier)
Definition driver.hpp:54
@ outOfStates
Engine ran out of states.
Definition driver.hpp:46
@ bestBelowReference
The best state's reward fell below the reference reward floor at this step.
Definition driver.hpp:50
@ exceededReferenceFrames
The run reached the reference's frame count without winning (can no longer beat it)
Definition driver.hpp:56
@ winStateFound
Found a win state.
Definition driver.hpp:42
@ winCollectionFull
Win-state collection reached its Max Files cap.
Definition driver.hpp:44
@ maximumStepReached
Maximum step reached.
Definition driver.hpp:48
@ inputHistoryNearCapacity
The shared input-history trie neared/hit its hard memory ceiling.
Definition driver.hpp:52
size_t _jobId
Job identifier (derived from system time) distinguishing intermediate values between jobs.
Definition driver.hpp:966
std::string _winCollectPrefix
Win-state collection: output path prefix.
Definition driver.hpp:980
std::string _bestStateStorage
Storage for the current best (win or otherwise) state.
Definition driver.hpp:1015
std::unique_ptr< Runner > _runner
Runner used for printing information and saving partial results.
Definition driver.hpp:964
float _bestStateFloorReward
Un-biased progress (position) reward of the best state; used for the Reference Reward Floor compariso...
Definition driver.hpp:991
float _referenceFloorTolerance
Allowed shortfall of best below the reference per step.
Definition driver.hpp:996
std::vector< float > _referenceReward
Per-step reference reward floor (index = step).
Definition driver.hpp:1003
uint32_t _referenceFloorStepGrace
Compare best against the reference this many steps earlier (bounded time slack for jumpy rewards).
Definition driver.hpp:997
std::string _referenceFloorInitialSequencePath
Optional emulator-level prefix sequence for the reference-lineage rebase.
Definition driver.hpp:1007
size_t _currentStep
Counter for the number of steps performed; the initial state counts as step zero.
Definition driver.hpp:970
void storeManualSaveSolution()
Saves a solution explicitly requested by the engine, if any.
Definition driver.hpp:606
~Driver()
Destroys the driver.
Definition driver.hpp:209
void updateWorstState()
Refreshes the tracked worst state, its solution, and its reward.
Definition driver.hpp:714
__volatile__ bool _hasFinished
Internal flag indicating the driver has finished.
Definition driver.hpp:1025
bool _referenceFloorEnabled
Whether the reference reward floor cancel is active.
Definition driver.hpp:995
static std::unique_ptr< Driver > getDriver(const std::string &configFilePath, const nlohmann::json &config)
Factory that constructs a driver from configuration.
Definition driver.hpp:947
float _bestStateReward
Ranking reward (magnet-biased) for the best state found so far; drives eviction/display.
Definition driver.hpp:990
void printInfo()
Prints the current state of execution to the logger.
Definition driver.hpp:851
ssize_t _firstWinStep
Step at which the first win was found.
Definition driver.hpp:974
std::string _saveIntermediateBestSolutionPath
Path to store the best solution found so far.
Definition driver.hpp:1033
size_t _maxSteps
Maximum number of steps (zero = not established).
Definition driver.hpp:968
void updateBestState()
Refreshes the tracked best state, its solution, and its reward.
Definition driver.hpp:752
ssize_t _stopFramesAfterFirstWin
Stop N steps after the first win (0 = immediately; -1 = never stop on wins)
Definition driver.hpp:972
std::vector< int > _referenceStateType
Per-step reference state type (normal/win/fail) recorded during the floor replay.
Definition driver.hpp:1004
std::vector< std::string > _winCollectProps
Win-state collection: dedup property names.
Definition driver.hpp:978
std::string _referenceFloorInitialStatePath
Optional raw state file rebasing the floor replay onto the reference's own lineage.
Definition driver.hpp:1006
std::unique_ptr< Engine > _engine
Pointer to the internal Jaffar engine.
Definition driver.hpp:962
float _referenceBelowWorstMargin
Margin added to the reference before the below-worst comparison (typically the pinning bonus).
Definition driver.hpp:999
void initialize()
Resets the execution back to the starting point.
Definition driver.hpp:217
void saveBestStateInformation()
Writes the current best solution to file, under the mutex.
Definition driver.hpp:629
std::string _worstSolutionStorage
Storage for the current worst solution's input history.
Definition driver.hpp:1021
bool _saveIntermediateResultsEnabled
Whether to store intermediate results at all.
Definition driver.hpp:1029
void saveWorstStateInformation()
Writes the current worst solution to file, under the mutex.
Definition driver.hpp:654
bool _referenceBelowWorstNow
Whether the reference is currently below the worst kept state (per-step log addendum).
Definition driver.hpp:1000
float _bestWinStateReward
Reward for the best win state found so far.
Definition driver.hpp:986
size_t _stateSize
Storage size of a runner state.
Definition driver.hpp:1023
Driver(const std::string &configFilePath, const nlohmann::json &config)
Constructs the driver and its engine/runner from the parsed configuration.
Definition driver.hpp:68
std::string _saveIntermediateWorstSolutionPath
Path to store the worst solution found so far.
Definition driver.hpp:1035
void intermediateResultSaveLoop()
Background loop that periodically saves best and worst solutions to file.
Definition driver.hpp:679
std::string _worstStateStorage
Storage for the current worst (win or otherwise) state.
Definition driver.hpp:1017
size_t _winCollectMax
Win-state collection: Max Files cap.
Definition driver.hpp:982
size_t _bestStateStepCount
Depth of the current best state, set by updateBestState() and reused by the printInfo reload.
Definition driver.hpp:988
std::string _referenceTracePath
Legacy precomputed-trace file path (loaded on demand if only the engine-side prune needs it)
Definition driver.hpp:1002
const std::string _configFilePath
Path to the config file, kept for reference.
Definition driver.hpp:960
double _inputHistoryCapacityWatermark
Fraction of the input-history trie's hard ceiling at which the run stops gracefully (high-water mark)...
Definition driver.hpp:1013
static std::unique_ptr< Runner > getRunner(const nlohmann::json &emulatorConfig, const nlohmann::json &gameConfig, const nlohmann::json &runnerConfig)
Creates a runner from the emulator, game and runner configurations.
Definition runner.hpp:527
Parallel breadth-first search engine that expands game states step by step, deduplicating via a hash ...
Abstract base for a JaffarPlus game: wraps an emulator, registers game properties,...
Drives a Game forward one input at a time, managing the allowed/candidate input sets,...