Drop7 Research
approaches/lifetime-objective/flow-ceiling/flow-run.cppC++981 lines · 38.2 KB
// flow-run - receding-horizon clairvoyant play, and what it does to disc flow.
//
//   flow-run --self-test
//   flow-run --cross-check <suite.jsonl> [--limit N]
//   flow-run --policy <rh-points|rh-clears|fair-d4|lowest-column>
//            --games N --seed-start 0xa5230000 [--horizon 9] [--commit 1]
//            [--max-moves 300] [--threads 8] [--time-limit S] [--node-limit N]
//            [--jsonl out.jsonl]
//
// The question
// ------------
// `docs/exploratory/finding-01-score-is-survival.md` derives an exact
// conservation requirement: twelve discs (five placed, seven risen) enter a
// 49-cell board every five moves, so indefinite survival needs at least 2.400
// numbered clears and 1.400 covered reveals per move.  Fair depth-4 sustains
// 1.9489 / 1.0697 and dies.  Is 2.400 reachable by *any* line of play?
//
// What this measures, and what it does not
// ----------------------------------------
// A nine-move exact optimum cannot answer that, because nine moves is under two
// rise cycles: a nine-move line can build height it never has to pay for, so
// its flow rate is an over-estimate of anything sustainable.  This tool instead
// plays a *whole game* under receding-horizon clairvoyant control: at every
// move it cuts an exactly-solvable window out of a fixed future, solves it
// exactly, plays the first `--commit` moves of the optimal line, and repeats.
//
// The future is fixed once, at the start, as a `MasterTape`.  It is never
// redrawn when the policy deviates, so the planner is genuinely clairvoyant
// over its window and never gets a second draw.
//
//   * This is a LOWER bound on sustainable optimal flow.  A globally optimal
//     player is at least as good as one that re-plans every move with a
//     nine-move exact lookahead.
//   * It is NOT an upper bound, and it is NOT globally optimal.  A flow rate
//     measured here that falls short of 2.400 does not by itself prove 2.400 is
//     unreachable; it proves that nine-move exact lookahead does not reach it.
//   * `--horizon` sweeps the lookahead so the reader can see whether the
//     measured rate is still climbing with H or has flattened.
//
// Two objectives are provided because the score objective is not the flow
// objective.  `rh-points` maximizes score over the window, exactly what
// `scenario/solver.hpp` optimizes.  `rh-clears` maximizes numbered discs
// cleared over the window, which is the quantity the conservation law is about
// and is therefore the more direct probe of the ceiling.
//
// Public-information boundary: `rh-points` and `rh-clears` READ THE HIDDEN
// BOARD.  They are diagnostics and teachers, never deployable policies.
// `fair-d4` and `lowest-column` read only public state and are the controls.

#include "fair-only-depth4-noentry.cpp"

#include "fair-planner.hpp"
#include "flow-common.hpp"
#include "flow-solver.hpp"

#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

namespace {

using namespace drop7;
using namespace drop7::scenario;
using namespace drop7::flowceiling;
namespace d4 = drop7::fair_only_depth4;

// The exploratory leases assigned to this work.  Nothing outside them is
// opened.  Game seeds come from FLOW, so that every cohort in finding-06 and
// finding-07 plays the *same* master tapes and every comparison is paired.
// The fair planner's hidden-board sampler is a separate stream and draws from
// FLOW2, so sampling randomness can never collide with game randomness.
constexpr std::uint32_t kLeaseStart = 0xa523'0000u;      // SEEDLEASE-A52-FLOW
constexpr std::uint32_t kLeaseEnd = 0xa523'3fffu;
// SEEDLEASE-A52-FLOW3 is partitioned so a master-tape seed and a sampler seed
// can never collide: game tapes take 0xa5239000-0xa5239fff, samplers take
// 0xa523a000-0xa523bfff.
constexpr std::uint32_t kLease3TapeStart = 0xa523'9000u;
constexpr std::uint32_t kLease3TapeEnd = 0xa523'9fffu;
constexpr std::uint32_t kSamplerLeaseStart = 0xa523'4000u;  // ...-FLOW2
constexpr std::uint32_t kSamplerLeaseEnd = 0xa523'bfffu;    // ...-FLOW3 end
// FLOW2 (0xa5234000-0xa5237fff) carries the original K series; FLOW3
// (0xa5238000-0xa523bfff) carries everything introduced after it.  A new K in
// an existing series deliberately keeps its series' sampler base so the series
// stays a series.

// ---------------------------------------------------------------------------
// Choosers
// ---------------------------------------------------------------------------

// Frozen fair depth-4, public state only.
struct FairDepth4Chooser {
  int operator()(const State& state, const LatentBoard&, int, int) const {
    if (state.game_over) return -1;
    const d4::SearchDecision decision = d4::chooseDepth4Action(state);
    return decision.action;
  }
};

// Trivial public control from `generate.hpp`.
struct LowestColumnChooser {
  int operator()(const State& state, const LatentBoard&, int, int) const {
    return lowestColumnPolicyLocal(state.board);
  }
  static int lowestColumnPolicyLocal(const Board& board) {
    int best = -1;
    int best_height = kBoardSize + 1;
    for (int column = 0; column < kBoardSize; ++column) {
      if (!isLegal(board, column)) continue;
      int height = 0;
      for (int row = 0; row < kBoardSize; ++row) {
        if (board[indexOf(row, column)] != kEmpty) ++height;
      }
      if (height < best_height) {
        best_height = height;
        best = column;
      }
    }
    return best;
  }
};

// Receding-horizon clairvoyant control.  Privileged: reads `latent`.
struct RecedingChooser {
  const MasterTape* tape = nullptr;
  Objective objective = Objective::kPoints;
  int horizon = 9;
  int commit = 1;
  int min_horizon = 5;
  WindowLimits limits{};
  GameStat* stats = nullptr;

  std::vector<int> pending;
  std::size_t pending_at = 0;

  int operator()(const State& state, const LatentBoard& latent, int move,
                 int rises) {
    if (pending_at < pending.size()) return pending[pending_at++];
    pending.clear();
    pending_at = 0;

    for (int attempt_horizon = horizon; attempt_horizon >= min_horizon;
         attempt_horizon -= 2) {
      Scenario window;
      std::string reason;
      if (!cutWindow(*tape, state, latent, move, rises, attempt_horizon, window,
                     reason)) {
        std::fprintf(stderr, "window cut failed at move %d: %s\n", move,
                     reason.c_str());
        return -1;
      }
      const WindowResult solved = solveWindow(window, objective, limits);
      stats->solver_nodes += solved.nodes;
      stats->solve_seconds += solved.wall_seconds;
      if (!solved.complete) {
        ++stats->incomplete_windows;
        continue;
      }
      // Replay the whole optimal line through the engine and require that it
      // really earns the reported value.  Cheap, and it turns a solver bug into
      // a reported number rather than a silent wrong answer.
      if (!verifyLine(window, solved)) ++stats->pv_mismatches;
      if (solved.pv.empty()) return -1;
      const int take =
          std::min(static_cast<int>(solved.pv.size()), std::max(1, commit));
      pending.assign(solved.pv.begin(), solved.pv.begin() + take);
      return pending[pending_at++];
    }
    // Every horizon exhausted its budget.  Reported, not papered over.
    return -1;
  }

  bool verifyLine(const Scenario& window, const WindowResult& solved) const {
    auto engine = makeScenarioEngine(window);
    std::int64_t total = 0;
    for (int column : solved.pv) {
      if (engine.state().game_over) break;
      MoveResult result;
      if (!engine.play(column, result)) return false;
      total += moveValue(objective, result);
      if (engine.state().game_over) break;
    }
    return total == solved.value;
  }
};

// Receding-horizon planner that does not read the hidden board.  See
// `fair-planner.hpp` for what each configuration is and is not allowed to know.
struct FairRecedingChooser {
  const MasterTape* tape = nullptr;
  FairPlannerConfig config{};
  // Optional warm-up configuration.  Moves before `warm_moves` are decided with
  // `warm_config`; from `warm_moves` onward `config` takes over and only those
  // moves are measured.  Two arms sharing a warm-up reach a bit-identical state
  // before they diverge, which is what makes a matched-horizon comparison at a
  // realistic mid-game occupancy affordable.
  FairPlannerConfig warm_config{};
  int warm_moves = 0;
  std::uint32_t sampler_seed = kSamplerLeaseStart;
  GameStat* stats = nullptr;
  Mulberry32 random{kSamplerLeaseStart};

  void reset() { random = Mulberry32(sampler_seed); }

  int operator()(const State& state, const LatentBoard& hidden, int move,
                 int rises) {
    const FairPlannerConfig& active =
        move < warm_moves ? warm_config : config;
    const FairDecision decision = fairDecision(active, *tape, state, hidden,
                                               move, rises, random);
    stats->solver_nodes += decision.nodes;
    stats->solve_seconds += decision.wall_seconds;
    stats->incomplete_windows += decision.incomplete;
    return decision.column;
  }
};

// ---------------------------------------------------------------------------
// Aggregation and reporting
// ---------------------------------------------------------------------------

double median(std::vector<double> values) {
  if (values.empty()) return 0.0;
  std::sort(values.begin(), values.end());
  const std::size_t half = values.size() / 2;
  if (values.size() % 2 == 1) return values[half];
  return 0.5 * (values[half - 1] + values[half]);
}

double meanOf(const std::vector<double>& values) {
  if (values.empty()) return 0.0;
  double total = 0.0;
  for (double value : values) total += value;
  return total / static_cast<double>(values.size());
}

void report(const std::string& label, const std::vector<GameStat>& games) {
  std::printf("\n=== %s ===\n", label.c_str());
  if (games.empty()) {
    std::printf("no games\n");
    return;
  }
  std::int64_t moves = 0;
  std::int64_t cleared = 0;
  std::int64_t revealed = 0;
  std::int64_t score = 0;
  std::int64_t chain = 0;
  std::int64_t rise = 0;
  std::int64_t clear_points = 0;
  int rises = 0;
  int clear_moves = 0;
  int clear_awards = 0;
  int double_awards = 0;
  int fifth_drop = 0;
  int censored = 0;
  int identity = 0;
  int incomplete = 0;
  int mismatches = 0;
  int deepest = 0;
  double solve_seconds = 0.0;
  std::int64_t nodes = 0;
  std::array<std::int64_t, kMaxWaveDepth> depth_count{};
  std::array<std::int64_t, kMaxWaveDepth> depth_cleared{};
  std::vector<double> move_list;
  std::vector<double> score_list;
  std::vector<double> clears_list;
  std::vector<double> reveals_list;
  std::vector<double> slope_list;
  std::size_t max_cycles = 0;

  for (const GameStat& game : games) {
    moves += game.moves;
    cleared += game.cleared;
    revealed += game.revealed;
    score += game.score;
    chain += game.chain_points;
    rise += game.rise_points;
    clear_points += game.clear_points;
    rises += game.rises;
    clear_moves += game.clear_moves;
    clear_awards += game.clear_awards;
    double_awards += game.double_clear_awards;
    fifth_drop += game.fifth_drop_clears;
    if (game.censored) ++censored;
    identity += game.identity_violations;
    incomplete += game.incomplete_windows;
    mismatches += game.pv_mismatches;
    deepest = std::max(deepest, game.max_wave_depth);
    solve_seconds += game.solve_seconds;
    nodes += game.solver_nodes;
    for (int depth = 0; depth < kMaxWaveDepth; ++depth) {
      depth_count[static_cast<std::size_t>(depth)] +=
          game.wave_depth_count[static_cast<std::size_t>(depth)];
      depth_cleared[static_cast<std::size_t>(depth)] +=
          game.wave_depth_cleared[static_cast<std::size_t>(depth)];
    }
    move_list.push_back(game.moves);
    score_list.push_back(static_cast<double>(game.score));
    clears_list.push_back(game.clearsPerMove());
    reveals_list.push_back(game.revealsPerMove());
    slope_list.push_back(occupancySlope(game.cycle_occupancy, 1));
    max_cycles = std::max(max_cycles, game.cycle_occupancy.size());
  }

  std::printf("games %zu, censored (hit the move cap alive) %d\n", games.size(),
              censored);
  std::printf("moves: mean %.2f  median %.1f  min %.0f  max %.0f\n",
              meanOf(move_list), median(move_list),
              *std::min_element(move_list.begin(), move_list.end()),
              *std::max_element(move_list.begin(), move_list.end()));
  std::printf("score: mean %.1f  median %.1f\n", meanOf(score_list),
              median(score_list));
  const double move_total = static_cast<double>(moves);
  std::printf(
      "flow (pooled): clears/move %.4f of 2.4000 required (%.1f%%), "
      "reveals/move %.4f of 1.4000 required (%.1f%%)\n",
      cleared / move_total, 100.0 * (cleared / move_total) / 2.4,
      revealed / move_total, 100.0 * (revealed / move_total) / 1.4);
  std::printf("flow (per-game mean): clears/move %.4f  reveals/move %.4f\n",
              meanOf(clears_list), meanOf(reveals_list));
  std::printf("score composition: rise %.2f%%  boardClear %.2f%%  chain %.2f%%\n",
              score > 0 ? 100.0 * static_cast<double>(rise) / score : 0.0,
              score > 0 ? 100.0 * static_cast<double>(clear_points) / score : 0.0,
              score > 0 ? 100.0 * static_cast<double>(chain) / score : 0.0);
  std::printf(
      "rises %d, board-clear moves %d, 70k awards %d, double awards %d, "
      "fifth-drop clears %d\n",
      rises, clear_moves, clear_awards, double_awards, fifth_drop);
  std::printf(
      "checks: score-identity violations %d, incomplete windows %d, pv "
      "mismatches %d\n",
      identity, incomplete, mismatches);
  std::printf("solver: %lld nodes, %.1f s of window solving\n",
              static_cast<long long>(nodes), solve_seconds);

  std::int64_t waves = 0;
  for (std::int64_t count : depth_count) waves += count;
  std::printf("waves %lld, deepest %d\n", static_cast<long long>(waves),
              deepest);
  std::printf("depth  waves    share    discs\n");
  for (int depth = 1; depth < kMaxWaveDepth; ++depth) {
    const std::int64_t count = depth_count[static_cast<std::size_t>(depth)];
    if (count == 0) continue;
    std::printf("%5d  %6lld  %6.2f%%  %7lld\n", depth,
                static_cast<long long>(count),
                waves > 0 ? 100.0 * static_cast<double>(count) / waves : 0.0,
                static_cast<long long>(
                    depth_cleared[static_cast<std::size_t>(depth)]));
  }

  std::printf("occupancy after each rise (cells occupied of 49):\n");
  std::printf("cycle  games  mean occupied  mean covered\n");
  for (std::size_t cycle = 0; cycle < max_cycles; ++cycle) {
    double occupied = 0.0;
    double covered = 0.0;
    int count = 0;
    for (const GameStat& game : games) {
      if (cycle >= game.cycle_occupancy.size()) continue;
      occupied += game.cycle_occupancy[cycle];
      covered += game.cycle_covered[cycle];
      ++count;
    }
    if (count == 0) continue;
    std::printf("%5zu  %5d  %13.2f  %12.2f\n", cycle + 1, count,
                occupied / count, covered / count);
  }
  std::printf(
      "occupancy slope (cells per five-move cycle, from cycle 2): mean %.4f  "
      "median %.4f\n",
      meanOf(slope_list), median(slope_list));
}

// ---------------------------------------------------------------------------
// Run modes
// ---------------------------------------------------------------------------

struct Options {
  std::string policy = "rh-points";
  std::string jsonl_output;
  std::string cross_check;
  int games = 8;
  std::uint32_t seed_start = kLeaseStart;
  int horizon = 9;
  int commit = 1;
  int min_horizon = 5;
  int max_moves = 300;
  int threads = 8;
  int limit = 0;
  double time_limit = 60.0;
  std::int64_t node_limit = 0;
  int samples = 8;
  int sample_threads = 1;
  int warm_horizon = 0;
  int warm_samples = 0;
  int warm_moves = 0;
  bool latent_known = false;
  bool tape_known = false;
  std::uint32_t sampler_seed = kSamplerLeaseStart;
  bool self_test = false;
};

GameStat playOneGame(const Options& options, std::uint32_t seed) {
  const MasterTape tape = makeMasterTape(seed, options.max_moves);
  GameStat game;
  game.seed = seed;
  game.policy = options.policy;
  if (options.policy == "fair-d4") {
    FairDepth4Chooser chooser;
    playLongGame(tape, chooser, options.max_moves, game, nullptr);
  } else if (options.policy == "lowest-column") {
    LowestColumnChooser chooser;
    playLongGame(tape, chooser, options.max_moves, game, nullptr);
  } else if (options.policy == "fair-rh") {
    FairRecedingChooser chooser;
    chooser.tape = &tape;
    chooser.config.objective = Objective::kClears;
    chooser.config.horizon = options.horizon;
    chooser.config.samples = options.samples;
    chooser.config.latent_known = options.latent_known;
    chooser.config.tape_known = options.tape_known;
    chooser.config.sample_threads = options.sample_threads;
    chooser.config.limits.max_seconds = options.time_limit;
    chooser.config.limits.max_nodes = options.node_limit;
    chooser.warm_config = chooser.config;
    chooser.warm_moves = options.warm_moves;
    if (options.warm_horizon > 0) chooser.warm_config.horizon = options.warm_horizon;
    if (options.warm_samples > 0) chooser.warm_config.samples = options.warm_samples;
    // One independent sampler stream per game, inside SEEDLEASE-A52-FLOW2.
    chooser.sampler_seed = options.sampler_seed + (seed - options.seed_start);
    chooser.stats = &game;
    chooser.reset();
    game.measure_from = options.warm_moves;
    playLongGame(tape, chooser, options.max_moves, game, nullptr,
                 options.warm_moves);
  } else {
    RecedingChooser chooser;
    chooser.tape = &tape;
    chooser.objective = options.policy == "rh-clears" ? Objective::kClears
                                                      : Objective::kPoints;
    chooser.horizon = options.horizon;
    chooser.commit = options.commit;
    chooser.min_horizon = std::min(options.min_horizon, options.horizon);
    chooser.limits.max_seconds = options.time_limit;
    chooser.limits.max_nodes = options.node_limit;
    chooser.stats = &game;
    playLongGame(tape, chooser, options.max_moves, game, nullptr);
  }
  return game;
}

int runGames(const Options& options) {
  const std::uint32_t seed_end =
      options.seed_start + static_cast<std::uint32_t>(options.games) - 1;
  const bool in_flow =
      options.seed_start >= kLeaseStart && seed_end <= kLeaseEnd;
  const bool in_flow3_tapes =
      options.seed_start >= kLease3TapeStart && seed_end <= kLease3TapeEnd;
  if (!in_flow && !in_flow3_tapes) {
    std::cerr << "game seed range outside SEEDLEASE-A52-FLOW "
              << "(0xa5230000-0xa5233fff) and the FLOW3 tape partition "
              << "(0xa5239000-0xa5239fff)\n";
    return 2;
  }
  if (options.policy == "fair-rh" &&
      (options.sampler_seed < kSamplerLeaseStart ||
       options.sampler_seed + static_cast<std::uint32_t>(options.games) - 1 >
           kSamplerLeaseEnd)) {
    std::cerr << "sampler seed range outside SEEDLEASE-A52-FLOW2/FLOW3 ("
              << "0xa5234000-0xa523bfff)\n";
    return 2;
  }
  std::vector<GameStat> games(static_cast<std::size_t>(options.games));
  std::atomic<int> next{0};
  std::mutex log;
  const int threads = std::max(1, std::min(options.threads, options.games));
  std::vector<std::thread> pool;
  for (int worker = 0; worker < threads; ++worker) {
    pool.emplace_back([&] {
      for (;;) {
        const int index = next.fetch_add(1);
        if (index >= options.games) return;
        const std::uint32_t seed =
            options.seed_start + static_cast<std::uint32_t>(index);
        GameStat game = playOneGame(options, seed);
        games[static_cast<std::size_t>(index)] = game;
        std::lock_guard<std::mutex> guard(log);
        std::printf(
            "seed %08x moves %4d score %10lld clears/move %.4f reveals/move "
            "%.4f occ_end %2d %s\n",
            seed, game.moves, static_cast<long long>(game.score),
            game.clearsPerMove(), game.revealsPerMove(),
            game.move_occupancy.empty() ? 0 : game.move_occupancy.back(),
            game.censored ? "CENSORED" : "died");
        std::fflush(stdout);
      }
    });
  }
  for (std::thread& thread : pool) thread.join();

  if (!options.jsonl_output.empty()) {
    std::ofstream jsonl(options.jsonl_output);
    if (!jsonl) {
      std::cerr << "cannot write " << options.jsonl_output << "\n";
      return 2;
    }
    for (const GameStat& game : games) jsonl << gameStatJson(game) << "\n";
  }

  char label[256];
  if (options.policy == "fair-rh") {
    std::snprintf(label, sizeof(label),
                  "fair-rh  H=%d K=%d latentKnown=%d tapeKnown=%d cap=%d "
                  "warm=%d(H%d,K%d)  %d games",
                  options.horizon, options.samples, options.latent_known ? 1 : 0,
                  options.tape_known ? 1 : 0, options.max_moves,
                  options.warm_moves,
                  options.warm_horizon > 0 ? options.warm_horizon
                                           : options.horizon,
                  options.warm_samples > 0 ? options.warm_samples
                                           : options.samples,
                  options.games);
  } else {
    std::snprintf(label, sizeof(label), "%s  H=%d commit=%d cap=%d  %d games",
                  options.policy.c_str(), options.horizon, options.commit,
                  options.max_moves, options.games);
  }
  report(label, games);
  return 0;
}

// The correctness gate for `flow-solver.hpp`: with the points objective it must
// reproduce the frozen solver's optimum on every scenario of a suite.
int runCrossCheck(const Options& options) {
  std::vector<Scenario> scenarios;
  std::string error;
  if (!loadScenarioFile(options.cross_check, scenarios, error)) {
    std::cerr << error << "\n";
    return 2;
  }
  if (options.limit > 0 &&
      static_cast<int>(scenarios.size()) > options.limit) {
    scenarios.resize(static_cast<std::size_t>(options.limit));
  }
  std::atomic<int> next{0};
  std::atomic<int> mismatches{0};
  std::atomic<int> incomplete{0};
  std::mutex log;
  const int threads = std::max(1, options.threads);
  std::vector<std::thread> pool;
  for (int worker = 0; worker < threads; ++worker) {
    pool.emplace_back([&] {
      for (;;) {
        const int index = next.fetch_add(1);
        if (index >= static_cast<int>(scenarios.size())) return;
        const Scenario& scenario = scenarios[static_cast<std::size_t>(index)];
        SolveOptions frozen_options;
        frozen_options.threads = 1;
        const SolveResult frozen = solveScenario(scenario, frozen_options);
        WindowLimits limits;
        limits.max_seconds = 0.0;
        const WindowResult mine =
            solveWindow(scenario, Objective::kPoints, limits);
        if (!frozen.complete || !mine.complete) {
          incomplete.fetch_add(1);
          continue;
        }
        if (frozen.optimum != mine.value) {
          mismatches.fetch_add(1);
          std::lock_guard<std::mutex> guard(log);
          std::printf("MISMATCH %s frozen=%lld flow=%lld\n", scenario.id,
                      static_cast<long long>(frozen.optimum),
                      static_cast<long long>(mine.value));
        }
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::printf("cross-check: %zu scenarios, %d mismatches, %d incomplete\n",
              scenarios.size(), mismatches.load(), incomplete.load());
  return mismatches.load() == 0 ? 0 : 1;
}

bool checkMasterTapeDeterminism() {
  const MasterTape left = makeMasterTape(kLeaseStart, 40);
  const MasterTape right = makeMasterTape(kLeaseStart, 40);
  if (left.discs != right.discs) return false;
  if (left.start_latent != right.start_latent) return false;
  for (std::size_t index = 0; index < left.rises.size(); ++index) {
    if (left.rises[index] != right.rises[index]) return false;
  }
  return true;
}

// A window cut out of the long game must reproduce the long game exactly when
// played with the same columns: same boards, same score deltas, same waves.
bool checkWindowMatchesLongGame() {
  const MasterTape tape = makeMasterTape(kLeaseStart + 1u, 60);
  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises = 0;
  for (int move = 0; move < 24; ++move) {
    if (state.game_over) return true;
    Scenario window;
    std::string reason;
    if (!cutWindow(tape, state, latent, move, rises, 6, window, reason)) {
      std::printf("self-test: window cut failed: %s\n", reason.c_str());
      return false;
    }
    auto engine = makeScenarioEngine(window);
    State long_state = state;
    LatentBoard long_latent = latent;
    int long_rises = rises;
    for (int step = 0; step < 6; ++step) {
      if (long_state.game_over) break;
      const int column =
          LowestColumnChooser::lowestColumnPolicyLocal(long_state.board);
      if (column < 0) break;
      MoveResult window_result;
      if (!engine.play(column, window_result)) return false;
      MoveResult long_result;
      LatentBoard next_latent{};
      if (!playMasterMove(tape, long_state, long_latent, move + step,
                          long_rises, column, long_result, next_latent)) {
        return false;
      }
      if (window_result.state.board != long_result.state.board) return false;
      if (window_result.score_delta != long_result.score_delta) return false;
      if (window_result.waves.size() != long_result.waves.size()) return false;
      if (engine.latent() != next_latent) return false;
      if (long_result.level_advanced) ++long_rises;
      long_state = long_result.state;
      long_latent = next_latent;
    }
    const int column = LowestColumnChooser::lowestColumnPolicyLocal(state.board);
    if (column < 0) return true;
    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises, column, result,
                        next_latent)) {
      return false;
    }
    if (result.level_advanced) ++rises;
    state = result.state;
    latent = next_latent;
  }
  return true;
}

// The score identity of finding-01, checked move by move on a real game:
// delta = 17,000 x rise + 70,000 x clears + sum of wave points.
bool checkScoreIdentity() {
  Options options;
  options.policy = "lowest-column";
  options.max_moves = 200;
  const GameStat game = playOneGame(options, kLeaseStart + 2u);
  if (game.moves < 10) return false;
  if (game.identity_violations != 0) return false;
  return game.score ==
         game.rise_points + game.clear_points + game.chain_points;
}

// The clears objective must dominate the points objective on clears, and the
// points objective must dominate the clears objective on points.  Both are
// exact optima of their own objective, so anything else is a solver bug.
bool checkObjectiveDominance() {
  const MasterTape tape = makeMasterTape(kLeaseStart + 3u, 40);
  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises = 0;
  for (int move = 0; move < 8; ++move) {
    Scenario window;
    std::string reason;
    if (!cutWindow(tape, state, latent, move, rises, 6, window, reason)) {
      return false;
    }
    WindowLimits limits;
    const WindowResult points = solveWindow(window, Objective::kPoints, limits);
    const WindowResult clears = solveWindow(window, Objective::kClears, limits);
    if (!points.complete || !clears.complete) return false;

    const auto replay = [&window](const std::vector<int>& path,
                                  std::int64_t& score, std::int64_t& cleared) {
      auto engine = makeScenarioEngine(window);
      score = 0;
      cleared = 0;
      for (int column : path) {
        if (engine.state().game_over) break;
        MoveResult result;
        if (!engine.play(column, result)) break;
        score += result.score_delta;
        for (const Wave& wave : result.waves) cleared += wave.cleared;
      }
    };
    std::int64_t points_score = 0;
    std::int64_t points_cleared = 0;
    std::int64_t clears_score = 0;
    std::int64_t clears_cleared = 0;
    replay(points.pv, points_score, points_cleared);
    replay(clears.pv, clears_score, clears_cleared);
    if (points_score != points.value) return false;
    if (clears_cleared != clears.value) return false;
    if (clears_cleared < points_cleared) return false;
    if (points_score < clears_score) return false;

    const int column = LowestColumnChooser::lowestColumnPolicyLocal(state.board);
    if (column < 0) return true;
    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises, column, result,
                        next_latent)) {
      return false;
    }
    if (result.level_advanced) ++rises;
    state = result.state;
    latent = next_latent;
    if (state.game_over) return true;
  }
  return true;
}

// runRoot must agree with run(): the best root value is the window optimum.
// This validates the machinery the fair planner is built on against the same
// solver that `--cross-check` proves equal to the frozen exact solver.
bool checkRootValuesMatchOptimum() {
  const MasterTape tape = makeMasterTape(kLeaseStart + 4u, 60);
  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises = 0;
  for (int move = 0; move < 20; ++move) {
    if (state.game_over) return true;
    Scenario window;
    std::string reason;
    if (!cutWindow(tape, state, latent, move, rises, 6, window, reason)) {
      return false;
    }
    WindowLimits limits;
    for (Objective objective : {Objective::kPoints, Objective::kClears}) {
      const WindowResult whole = solveWindow(window, objective, limits);
      const RootResult root = solveWindowRoot(window, objective, limits);
      if (!whole.complete || !root.complete) return false;
      std::int64_t best = 0;
      bool any = false;
      for (int column = 0; column < kBoardSize; ++column) {
        if (!root.legal[static_cast<std::size_t>(column)]) continue;
        const std::int64_t value = root.value[static_cast<std::size_t>(column)];
        if (!any || value > best) {
          best = value;
          any = true;
        }
      }
      if (!any || best != whole.value) return false;
    }
    const int column = LowestColumnChooser::lowestColumnPolicyLocal(state.board);
    if (column < 0) return true;
    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises, column, result,
                        next_latent)) {
      return false;
    }
    if (result.level_advanced) ++rises;
    state = result.state;
    latent = next_latent;
  }
  return true;
}

// THE INFORMATION-BOUNDARY GATE.  With `latent_known = false` the decision must
// be a function of public state alone, so replacing every hidden value under
// every covered cell with a different legal value must not change the chosen
// column.  With `tape_known = false` the same must hold when the entire future
// disc tape and every future risen row are replaced.
bool checkFairPlannerIgnoresHiddenState() {
  const MasterTape tape = makeMasterTape(kLeaseStart + 5u, 80);
  // A second tape with the same start position but different hidden values and
  // a completely different future.
  MasterTape other = makeMasterTape(kLeaseStart + 6u, 80);
  other.start_board = tape.start_board;

  FairPlannerConfig config;
  config.objective = Objective::kClears;
  config.horizon = 5;
  config.samples = 3;
  config.latent_known = false;
  config.tape_known = false;

  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises = 0;
  int checked = 0;
  for (int move = 0; move < 30; ++move) {
    if (state.game_over) break;

    // Same public state, same sampler stream, two different hidden boards and
    // two different futures.
    LatentBoard fake{};
    for (int index = 0; index < kCellCount; ++index) {
      const std::uint8_t cell = state.board[index];
      if (cell == kSolid || cell == kCracked) {
        fake[index] = static_cast<std::uint8_t>(1 + (latent[index] % kBoardSize));
      }
    }
    Mulberry32 left(kSamplerLeaseStart + 0x20u);
    Mulberry32 right(kSamplerLeaseStart + 0x20u);
    const FairDecision a =
        fairDecision(config, tape, state, latent, move, rises, left);
    const FairDecision b =
        fairDecision(config, other, state, fake, move, rises, right);
    if (a.column != b.column) return false;
    if (a.column < 0) return false;
    ++checked;

    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises, a.column, result,
                        next_latent)) {
      return false;
    }
    if (result.level_advanced) ++rises;
    state = result.state;
    latent = next_latent;
  }
  return checked >= 20;
}

// The `latent_known` arm is allowed to read the hidden board, and must: with
// one sample and both privileges it has to reproduce the clairvoyant window
// optimum exactly.
bool checkPrivilegedArmMatchesClairvoyant() {
  const MasterTape tape = makeMasterTape(kLeaseStart + 7u, 60);
  FairPlannerConfig config;
  config.objective = Objective::kClears;
  config.horizon = 6;
  config.samples = 1;
  config.latent_known = true;
  config.tape_known = true;

  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises = 0;
  for (int move = 0; move < 20; ++move) {
    if (state.game_over) return true;
    Scenario window;
    std::string reason;
    if (!cutWindow(tape, state, latent, move, rises, config.horizon, window,
                   reason)) {
      return false;
    }
    WindowLimits limits;
    const WindowResult whole =
        solveWindow(window, Objective::kClears, limits);
    if (!whole.complete || whole.pv.empty()) return false;

    Mulberry32 unused(kSamplerLeaseStart + 0x40u);
    const FairDecision decision =
        fairDecision(config, tape, state, latent, move, rises, unused);
    if (decision.column < 0) return false;

    // The privileged arm need not pick the same column as the clairvoyant PV
    // when several columns tie, but the value it picked must equal the optimum.
    const RootResult root = solveWindowRoot(window, Objective::kClears, limits);
    if (!root.complete) return false;
    if (root.value[static_cast<std::size_t>(decision.column)] != whole.value) {
      return false;
    }

    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises, decision.column,
                        result, next_latent)) {
      return false;
    }
    if (result.level_advanced) ++rises;
    state = result.state;
    latent = next_latent;
  }
  return true;
}

int runSelfTest() {
  struct Check {
    const char* name;
    bool (*run)();
  };
  const Check checks[] = {
      {"master tape is deterministic in its seed", checkMasterTapeDeterminism},
      {"a cut window reproduces the long game move for move",
       checkWindowMatchesLongGame},
      {"per-move score identity holds over a whole game", checkScoreIdentity},
      {"each objective's optimum dominates on its own quantity",
       checkObjectiveDominance},
      {"root-move values agree with the window optimum",
       checkRootValuesMatchOptimum},
      {"INFORMATION BOUNDARY: the fair planner ignores hidden state",
       checkFairPlannerIgnoresHiddenState},
      {"the privileged arm reproduces the clairvoyant optimum",
       checkPrivilegedArmMatchesClairvoyant},
  };
  int failures = 0;
  for (const Check& check : checks) {
    const bool ok = check.run();
    std::printf("%-58s %s\n", check.name, ok ? "ok" : "FAILED");
    if (!ok) ++failures;
  }
  std::printf("self-test: %d failure(s)\n", failures);
  return failures == 0 ? 0 : 1;
}

}  // namespace

int main(int argc, char** argv) {
  Options options;
  for (int index = 1; index < argc; ++index) {
    const std::string flag = argv[index];
    if (flag == "--self-test") {
      options.self_test = true;
    } else if (flag == "--cross-check" && index + 1 < argc) {
      options.cross_check = argv[++index];
    } else if (flag == "--policy" && index + 1 < argc) {
      options.policy = argv[++index];
    } else if (flag == "--jsonl" && index + 1 < argc) {
      options.jsonl_output = argv[++index];
    } else if (flag == "--games" && index + 1 < argc) {
      options.games = std::atoi(argv[++index]);
    } else if (flag == "--seed-start" && index + 1 < argc) {
      options.seed_start = static_cast<std::uint32_t>(
          std::strtoul(argv[++index], nullptr, 0));
    } else if (flag == "--horizon" && index + 1 < argc) {
      options.horizon = std::atoi(argv[++index]);
    } else if (flag == "--commit" && index + 1 < argc) {
      options.commit = std::atoi(argv[++index]);
    } else if (flag == "--min-horizon" && index + 1 < argc) {
      options.min_horizon = std::atoi(argv[++index]);
    } else if (flag == "--max-moves" && index + 1 < argc) {
      options.max_moves = std::atoi(argv[++index]);
    } else if (flag == "--threads" && index + 1 < argc) {
      options.threads = std::atoi(argv[++index]);
    } else if (flag == "--limit" && index + 1 < argc) {
      options.limit = std::atoi(argv[++index]);
    } else if (flag == "--time-limit" && index + 1 < argc) {
      options.time_limit = std::atof(argv[++index]);
    } else if (flag == "--node-limit" && index + 1 < argc) {
      options.node_limit = std::atoll(argv[++index]);
    } else if (flag == "--samples" && index + 1 < argc) {
      options.samples = std::atoi(argv[++index]);
    } else if (flag == "--sample-threads" && index + 1 < argc) {
      options.sample_threads = std::atoi(argv[++index]);
    } else if (flag == "--warm-horizon" && index + 1 < argc) {
      options.warm_horizon = std::atoi(argv[++index]);
    } else if (flag == "--warm-samples" && index + 1 < argc) {
      options.warm_samples = std::atoi(argv[++index]);
    } else if (flag == "--warm-moves" && index + 1 < argc) {
      options.warm_moves = std::atoi(argv[++index]);
    } else if (flag == "--latent-known") {
      options.latent_known = true;
    } else if (flag == "--tape-known") {
      options.tape_known = true;
    } else if (flag == "--sampler-seed" && index + 1 < argc) {
      options.sampler_seed = static_cast<std::uint32_t>(
          std::strtoul(argv[++index], nullptr, 0));
    } else {
      std::cerr << "unknown argument " << flag << "\n";
      return 2;
    }
  }
  if (options.self_test) return runSelfTest();
  if (!options.cross_check.empty()) return runCrossCheck(options);
  if (options.policy != "rh-points" && options.policy != "rh-clears" &&
      options.policy != "fair-rh" && options.policy != "fair-d4" &&
      options.policy != "lowest-column") {
    std::cerr << "unknown policy " << options.policy << "\n";
    return 2;
  }
  return runGames(options);
}