Drop7 Research
approaches/lifetime-objective/sibling-corpus/generate.cppC++2,008 lines · 84.8 KB
// Generates a training corpus for a public-state afterstate evaluator whose
// target is SURVIVAL, not score.
//
// Rationale (see docs/exploratory/finding-01-score-is-survival.md): measured
// over 64 fair-D4 games, score correlates with lifetime at r = 0.9995 and 94.3%
// of all points are the flat row-rise bonus.  Predicting remaining lifetime is
// therefore predicting score, but with no heavy tail, no 17,000-point
// quantization, and one label per move instead of one per game.
//
// Two products are written:
//
//   *.states  fixed-width records for every visited public state, labelled with
//             the exact remaining lifetime of that game.  Cheap: one label per
//             move, no branching, no rollout-policy bias.
//
//   *.panel   for a sampled subset of roots, the resolved afterstate of EVERY
//             legal column under the same environment tape (common random
//             numbers are automatic here because the engine derives reveal
//             randomness from (gameSeed, movesPlayed), which is identical
//             across siblings).  This is the offline all-sibling ranking panel
//             the benchmark contract requires, and it is what previous learned
//             leaves in this repository lacked.
//
// Behaviour diversity, not per-root branching, is what gives the evaluator
// coverage of actions the reference would never choose: the generator mixes
// search depths and injects epsilon-random legal deviations, recording which
// moves were deviations so they can be included or excluded by arm.

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

#include "../../../approaches/lifetime-objective/common/harness.hpp"
#include "../fast-engine/fast-search.hpp"

#include <sys/resource.h>

#include <memory>

#include <cstdio>
#include <cstring>
#include <exception>
#include <limits>
#include <list>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>

namespace drop7::corpus {

namespace frozen = drop7::fair_only_horizon;
using drop7::lifetime::occupiedCells;
using drop7::lifetime::topOccupiedRow;

#pragma pack(push, 1)
struct StateRecord {
  std::uint8_t board[kCellCount];  // pre-move public board, values 0..9
  std::uint8_t nextDisc;           // 1..7
  std::uint8_t movesRemaining;     // 1..5 until the next rise
  std::uint8_t legalMask;          // bit c set when column c is legal
  std::uint8_t chosenColumn;
  std::uint16_t movesToDeath;      // label: moves this game still had left
  std::uint16_t risesToDeath;      // label: rises this game still had left
  std::uint8_t clearsThisMove;
  std::uint8_t revealsThisMove;
  std::uint8_t behaviorDepth;
  std::uint8_t explored;           // 1 when the move was an epsilon deviation
  std::uint8_t censoredGame;       // 1 when the source game hit the move cap
  std::uint8_t occupiedCells;
  std::uint16_t moveIndex;
  std::uint32_t gameSeed;
  std::uint8_t padding[3];
};

struct PanelRecord {
  std::uint8_t board[kCellCount];   // pre-move public board
  std::uint8_t nextDisc;
  std::uint8_t movesRemaining;
  std::uint8_t legalMask;
  std::uint8_t chosenColumn;        // what the behaviour policy actually played
  std::uint8_t referenceColumn;     // what unmodified fair D4 would play
  // Per column: the resolved afterstate and its immediate effects.  Illegal
  // columns are written with survived = 0 and a zeroed board.
  std::uint8_t afterBoard[kBoardSize][kCellCount];
  std::uint8_t survived[kBoardSize];        // 0 when the move ends the game
  std::uint8_t afterClears[kBoardSize];
  std::uint8_t afterReveals[kBoardSize];
  std::uint8_t afterMaxDepth[kBoardSize];
  std::uint16_t afterScoreDelta[kBoardSize];
  std::uint16_t moveIndex;
  std::uint32_t gameSeed;
};
#pragma pack(pop)

static_assert(sizeof(StateRecord) == 72, "StateRecord must stay 72 bytes");

// ---------------------------------------------------------------------------
// Parameterized fair search, identical in semantics to the frozen reference at
// its default parameters (proved by the --parity gate in
// approaches/lifetime-objective/risk-calibration).
// ---------------------------------------------------------------------------
class WorkLimitReached : public std::exception {};

struct CacheEntry {
  double value = 0.0;
  std::list<std::string>::iterator order;
};

struct SearchContext {
  std::unordered_map<std::string, CacheEntry> cache;
  std::list<std::string> order;
  std::uint64_t work = 0;
};

class Search {
 public:
  Search(int depth, double terminalUtility, std::uint64_t maximumWork)
      : depth_(depth), terminalUtility_(terminalUtility), maximumWork_(maximumWork) {}

  int chooseAction(const State& source, std::uint64_t& work) {
    if (source.game_over) return -1;
    bool mirrored = false;
    const State canonical = cfpi::detail::canonicalState(source, mirrored);
    SearchContext context;
    int action = -1;
    for (int depth = 1; depth <= depth_; ++depth) {
      try {
        const int candidate = rootDecision(canonical, depth, context);
        if (candidate < 0) break;
        action = candidate;
      } catch (const WorkLimitReached&) {
        break;
      }
    }
    if (action < 0) action = centerFirstMove(canonical.board);
    work += context.work;
    return mirrored && action >= 0 ? kBoardSize - 1 - action : action;
  }

 private:
  void checkBudget(const SearchContext& context) const {
    if (context.work >= maximumWork_) throw WorkLimitReached{};
  }

  void cacheValue(SearchContext& context, std::string key, double value) const {
    const auto prior = context.cache.find(key);
    if (prior != context.cache.end()) {
      context.order.erase(prior->second.order);
      context.cache.erase(prior);
    }
    while (context.cache.size() >= 60'000) {
      const std::string& oldest = context.order.front();
      context.cache.erase(oldest);
      context.order.pop_front();
    }
    context.order.push_back(std::move(key));
    const auto order = std::prev(context.order.end());
    context.cache.emplace(*order, CacheEntry{value, order});
  }

  double evaluateAction(const State& state, int column, int depth,
                        SearchContext& context) const {
    const std::uint32_t stateSeed =
        cfpi::detail::scenarioSeedForState(state, frozen::kPolicySeed, depth);
    double value = 0.0;
    for (int sample = 0; sample < frozen::kChanceSamples; ++sample) {
      checkBudget(context);
      cfpi::detail::StratifiedRandom random{stateSeed, sample,
                                            frozen::kChanceSamples, 0};
      MoveResult move;
      const bool played =
          cfpi::detail::playMoveSampled(state, column, random, move);
      ++context.work;
      if (!played) {
        value += terminalUtility_;
        continue;
      }
      const double scoreDelta = static_cast<double>(move.score_delta);
      if (move.state.game_over) {
        value += scoreDelta + terminalUtility_;
        continue;
      }
      move.state.score = 0;
      move.state.next_disc = cfpi::detail::sampledNextDisc(
          stateSeed, sample, frozen::kChanceSamples);
      bool ignored = false;
      const State next = cfpi::detail::canonicalState(move.state, ignored);
      value += scoreDelta + bestFutureValue(next, depth - 1, context);
    }
    return value / frozen::kChanceSamples;
  }

  double bestFutureValue(const State& state, int depth,
                         SearchContext& context) const {
    checkBudget(context);
    if (state.game_over) return terminalUtility_;
    if (depth == 0) {
      ++context.work;
      return frozen::fairLeaf(state);
    }
    const std::string key = cfpi::detail::dynamicStateKey(state, depth);
    const auto cached = context.cache.find(key);
    if (cached != context.cache.end()) {
      const double value = cached->second.value;
      context.order.splice(context.order.end(), context.order,
                           cached->second.order);
      return value;
    }
    double best = -std::numeric_limits<double>::infinity();
    for (const int column : cfpi::detail::kColumnOrder) {
      if (!isLegal(state.board, column)) continue;
      best = std::max(best, evaluateAction(state, column, depth, context));
    }
    if (!std::isfinite(best)) best = terminalUtility_;
    cacheValue(context, key, best);
    return best;
  }

  int rootDecision(const State& canonical, int depth,
                   SearchContext& context) const {
    int action = -1;
    double bestValue = -std::numeric_limits<double>::infinity();
    for (const int column : cfpi::detail::kColumnOrder) {
      if (!isLegal(canonical.board, column)) continue;
      const double value = evaluateAction(canonical, column, depth, context);
      if (value > bestValue) {
        bestValue = value;
        action = column;
      }
    }
    return action;
  }

  int depth_;
  double terminalUtility_;
  std::uint64_t maximumWork_;
};

// ---------------------------------------------------------------------------

struct Options {
  std::uint32_t seedStart = 0xa51d'4000u;
  int games = 256;
  int maximumMoves = 2000;
  int threads = 32;
  int depth = 2;
  double terminalUtility = frozen::kTerminalUtility;
  double epsilon = 0.05;         // probability of a uniform legal deviation
  int panelStride = 0;           // 0 disables the all-sibling panel
  std::string statesPath;
  std::string panelPath;
  std::string summaryPath;
  // --- PanelRecordV2 mode (P-SOL-v1 section 3; EX-20260823-sol-corpus-...).
  // Every field below is inert unless --panel2 or --panel2-mirror-check is
  // given; the v1 output paths above are byte-identical with these defaults.
  std::string panel2Prefix;            // enables panel2 mode; one file/engine
  std::string contEngines = "d1";      // comma list of d1|d2|d3n7m6
  int panel2K = 6;                     // continuations per sibling (1..30)
  int panel2Horizon = 48;              // continuation horizon H in moves
  int panel2Roots = 1;                 // roots kept per game (0 = all grid)
  int panel2Start = 8;                 // first grid move index
  int behaviourDiscSamples = 5;        // behaviour engine N (FactoredSearch)
  int behaviourRevealSamples = 1;      // behaviour engine M
  std::uint64_t behaviourMaxWork = 3'200'000;
  std::size_t behaviourMaxCache = 60'000;
  int mirrorCheck = 0;                 // >0: mirror-invariance gate, N roots
  std::string leaseLabel = "unspecified";
  std::string dataRole = "unspecified";
  // --- G0 ladder modes (EX-...-v2).  Inert unless one of these is given.
  int makeSyntheticRoots = 0;   // >0: write N seed-free synthetic roots
  std::string rootsOut;         // output path for a roots file
  std::string replaySpec;       // "seedHex:move:move,..." C0 replay harvest
  std::string ladderRoots;      // roots file to run the continuation plan on
  int ladderLimit = 0;          // 0 = all roots in the file
  int crnParity = 0;            // >0: H=1 tape-parity gate over N roots
  int fastParity = 0;           // >0: fast-vs-native decision parity gate
};

std::uint8_t legalMaskOf(const Board& board) {
  std::uint8_t mask = 0;
  for (int column = 0; column < kBoardSize; ++column) {
    if (isLegal(board, column)) mask |= static_cast<std::uint8_t>(1u << column);
  }
  return mask;
}

struct GameOutput {
  std::vector<StateRecord> states;
  std::vector<PanelRecord> panels;
  int moves = 0;
  std::int64_t score = 0;
  bool censored = false;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
};

GameOutput generateGame(std::uint32_t seed, const Options& options,
                        Search& search) {
  GameOutput output;
  State state = initialHeadlessState(seed);
  // Exploration randomness is a policy-side domain derived only from the policy
  // seed and the game index, never from environment randomness.
  Mulberry32 explore(mix32(seed ^ 0x6f75'7421u) ^ frozen::kPolicySeed);
  std::vector<int> riseIndexOfMove;

  while (!state.game_over && state.moves_played < options.maximumMoves) {
    const Board preBoard = state.board;
    const std::uint8_t mask = legalMaskOf(preBoard);
    if (mask == 0) break;

    std::uint64_t work = 0;
    int column = search.chooseAction(state, work);
    bool explored = false;
    if (options.epsilon > 0.0 && explore.nextUnit() < options.epsilon) {
      int legalCount = 0;
      const auto legal = legalColumns(preBoard, legalCount);
      if (legalCount > 0) {
        const auto pick = static_cast<std::size_t>(
            (static_cast<std::uint64_t>(explore.nextBits()) *
             static_cast<std::uint64_t>(legalCount)) >> 32);
        const int deviation = legal[pick];
        if (deviation != column) {
          column = deviation;
          explored = true;
        }
      }
    }
    if (column < 0 || !isLegal(preBoard, column)) column = centerFirstMove(preBoard);
    if (column < 0) break;

    // All-sibling panel: resolve every legal column under the same tape.
    if (options.panelStride > 0 && state.moves_played % options.panelStride == 0) {
      PanelRecord panel{};
      std::memcpy(panel.board, preBoard.data(), kCellCount);
      panel.nextDisc = state.next_disc;
      panel.movesRemaining = static_cast<std::uint8_t>(state.moves_remaining);
      panel.legalMask = mask;
      panel.chosenColumn = static_cast<std::uint8_t>(column);
      panel.referenceColumn = static_cast<std::uint8_t>(
          drop7::fair_only_depth4::chooseDepth4Action(state).action);
      panel.moveIndex = static_cast<std::uint16_t>(state.moves_played);
      panel.gameSeed = seed;
      for (int sibling = 0; sibling < kBoardSize; ++sibling) {
        if (!isLegal(preBoard, sibling)) continue;
        State probe = state;
        MoveResult move;
        if (!playHeadlessMove(probe, seed, sibling, move)) continue;
        std::memcpy(panel.afterBoard[sibling], probe.board.data(), kCellCount);
        panel.survived[sibling] = probe.game_over ? 0 : 1;
        int clears = 0, reveals = 0, maxDepth = 0;
        for (const Wave& wave : move.waves) {
          clears += wave.cleared;
          reveals += wave.revealed;
          maxDepth = std::max(maxDepth, wave.depth);
        }
        panel.afterClears[sibling] = static_cast<std::uint8_t>(std::min(clears, 255));
        panel.afterReveals[sibling] = static_cast<std::uint8_t>(std::min(reveals, 255));
        panel.afterMaxDepth[sibling] = static_cast<std::uint8_t>(std::min(maxDepth, 255));
        panel.afterScoreDelta[sibling] = static_cast<std::uint16_t>(
            std::min<std::int64_t>(move.score_delta, 65535));
      }
      output.panels.push_back(panel);
    }

    StateRecord record{};
    std::memcpy(record.board, preBoard.data(), kCellCount);
    record.nextDisc = state.next_disc;
    record.movesRemaining = static_cast<std::uint8_t>(state.moves_remaining);
    record.legalMask = mask;
    record.chosenColumn = static_cast<std::uint8_t>(column);
    record.behaviorDepth = static_cast<std::uint8_t>(options.depth);
    record.explored = explored ? 1 : 0;
    record.occupiedCells = static_cast<std::uint8_t>(occupiedCells(preBoard));
    record.moveIndex = static_cast<std::uint16_t>(state.moves_played);
    record.gameSeed = seed;

    MoveResult move;
    if (!playHeadlessMove(state, seed, column, move)) break;

    int clears = 0, reveals = 0;
    for (const Wave& wave : move.waves) {
      clears += wave.cleared;
      reveals += wave.revealed;
    }
    record.clearsThisMove = static_cast<std::uint8_t>(std::min(clears, 255));
    record.revealsThisMove = static_cast<std::uint8_t>(std::min(reveals, 255));
    output.clears += static_cast<std::uint64_t>(clears);
    output.reveals += static_cast<std::uint64_t>(reveals);
    output.states.push_back(record);
    riseIndexOfMove.push_back(move.level_advanced ? 1 : 0);
    output.moves += 1;
  }

  output.score = state.score;
  output.censored = !state.game_over && output.moves >= options.maximumMoves;

  // Backfill the survival labels now that the game's true length is known.
  int risesAfter = 0;
  for (int index = output.moves - 1; index >= 0; --index) {
    StateRecord& record = output.states[static_cast<std::size_t>(index)];
    record.movesToDeath =
        static_cast<std::uint16_t>(output.moves - index);
    record.risesToDeath = static_cast<std::uint16_t>(risesAfter + riseIndexOfMove[static_cast<std::size_t>(index)]);
    risesAfter = record.risesToDeath;
    record.censoredGame = output.censored ? 1 : 0;
  }
  return output;
}

// ===========================================================================
// PanelRecordV2 (--panel2): sibling panels with K CRN continuation outcomes.
//
// Protocol: EX-20260823-sol-corpus-and-offline-gate-4d3d86e4 (P-SOL-v1,
// runs/RUN-20260823T191900Z-b9f8f80d/kimi-k3-main-design.md section 3).
// Everything in namespace panel2 is reachable only through --panel2 /
// --panel2-mirror-check; the v1 record layouts and code paths above are
// untouched.
//
// Layout (992 bytes per root, fixed stride; little-endian scalars):
//   header, 96 B:
//     u32 version = 0x0200; u32 recordId (sequential in seed order);
//     u32 originSeed (split bookkeeping only, never a feature);
//     u16 moveIndex; u8 rootNextDisc; u8 rootMovesToRise; u8 legalMask;
//     u8 chosenColumn; u8 referenceColumn (fair-D4 argmax, computed only when
//     recordId % 16 == 0, else 255); u8 engineId (0=D1, 1=D2, 2=D3 N7M6);
//     u8 K; u8 H; u8 panelFlags (bit0 reference computed, bit1 epsilon-root);
//     u8 rootBoard[49]; 24 B zero pad.
//   per sibling, 128 B x 7 (illegal columns keep their slot, fully zeroed):
//     u8 afterBoard[49]; u8 afterNextDisc; u8 afterMovesToRise; u8 survived;
//     u8 legal; u8 afterClears; u8 afterReveals; u8 afterMaxDepth;
//     i32 afterScoreDelta; u8 contLifetime[K]; u8 contDeathRise[K];
//     u32 contClearsTotal; u32 contRevealsTotal; zero pad to 128.
//   The two u32 continuation totals (summed numbered clears / cover reveals
//   over all K continuations, sibling move included) are an implementation
//   completion of the design's per-sibling table, which left the tail as pad;
//   they are not a protocol change.  K <= 30 as a consequence.
//
// Continuation semantics:
//   * One-step fields (afterBoard .. afterScoreDelta, survived) are resolved
//     under the true environment tape via playHeadlessMove, exactly as the v1
//     panel does (CRN across siblings is automatic there).
//   * Continuations are computed in the CANONICAL orientation of the public
//     root.  The CRN tape seed for continuation j is a pure function of the
//     canonical public root (board, next disc, moves-to-rise) and j, under
//     dedicated domain constants -- never of the origin seed.  Mirror
//     invariance of the labels is therefore exact by construction, and a
//     recordId regenerates byte-identical labels.
//   * Tape T_{r,j} is shared by all 7 siblings of root r and by every
//     continuation engine; alignment is by continuation move ordinal (each
//     ordinal reseeds its own Mulberry32 stream, so cascade length cannot
//     desynchronize siblings).
//   * contLifetime = moves survived from the root, the sibling placement
//     counting as the first move; 0 if that placement is immediately
//     terminal; capped at H.  contDeathRise = 0 when alive at H (censored),
//     else min(12, rises completed at death + 1).
// ===========================================================================

namespace panel2 {

constexpr std::uint32_t kVersion = 0x0200;
constexpr int kHeaderBytes = 96;
constexpr int kSiblingBytes = 128;
constexpr int kRecordBytes = kHeaderBytes + kBoardSize * kSiblingBytes;
static_assert(kRecordBytes == 992, "PanelRecordV2 must stay 992 bytes");
constexpr int kMaxK = 30;
constexpr std::uint32_t kTapeDomain = 0x50534f4cu;  // "PSOL"
constexpr std::uint32_t kMoveDomain = 0x434f4e54u;  // "CONT"

struct EngineSpec {
  std::uint8_t id = 0;  // 0=D1, 1=D2, 2=D3 N7M6
  int depth = 1;
  int discSamples = 5;
  int revealSamples = 1;
  std::uint64_t maximumWork = 3'200'000;
  std::size_t maximumCacheEntries = 60'000;
  std::string name;
};

// Continuation-engine identities are frozen to the configurations already on
// record: D1/D2 are the parameterized fair search at the frozen bounds
// (identical to FactoredSearch at N=5, M=1 -- gate B of the reveal-sampling
// CHECK), and D3 N7M6 is the C0 arm exactly as retained in
// runs/RUN-A525-reveal/d3-n7-m6.json (depth 3, N=7, M=6,
// maximumWork 51,084,852, maximumCacheEntries 87,025).
// worstCaseWork(3,7)+1 with b = 7 columns x 7 strata = 49 (the run.cpp
// iterative-deepening bound formula; at (4,7) it reproduces the recorded
// 11,892,399): the fast-d3s7 search always completes depth 3.
constexpr std::uint64_t kFastD3S7MaxWork = 242'698;

inline EngineSpec engineSpecByName(const std::string& name) {
  if (name == "d1") return {0, 1, 5, 1, 3'200'000, 60'000, "d1"};
  if (name == "d2") return {1, 2, 5, 1, 3'200'000, 60'000, "d2"};
  if (name == "d3n7m6") return {2, 3, 7, 6, 51'084'852, 87'025, "d3n7m6"};
  // EX-20260823-sol-corpus-and-offline-gate-v2: the fast memo-free engine
  // (finding-13, proven action/work-identical to the parameterized fair
  // search) at depth 3, seven disc strata, M = 1.
  if (name == "fastd3s7") return {3, 3, 7, 1, kFastD3S7MaxWork, 60'000, "fastd3s7"};
  // The native single-knob d3 s7 M1 search at the same bounds, for the
  // fast-vs-native parity gate only (never a corpus engine).
  if (name == "d3s7native") return {4, 3, 7, 1, kFastD3S7MaxWork, 60'000, "d3s7native"};
  throw std::invalid_argument("unknown continuation engine " + name);
}

inline std::vector<EngineSpec> parseEngineList(const std::string& list) {
  std::vector<EngineSpec> engines;
  std::string current;
  std::stringstream stream(list);
  while (std::getline(stream, current, ',')) {
    if (!current.empty()) engines.push_back(engineSpecByName(current));
  }
  if (engines.empty()) throw std::invalid_argument("--cont-engines is empty");
  return engines;
}

// ---------------------------------------------------------------------------
// Factored-chance fair search.  Faithful copy of
// approaches/lifetime-objective/reveal-sampling/search.cpp
// (drop7::lifetime::reveal::FactoredSearch, the C0 arms' engine), with the
// global bound diagnostics removed; the search arithmetic, iterative
// deepening, cache policy, and scenario indexing are line-identical.  At
// M = 1 the scenario indexing collapses to the single-knob parameterized
// search (proved by that program's gate B), so d1/d2 below are the frozen
// fair search at depths 1 and 2.
// ---------------------------------------------------------------------------

struct FactoredParameters {
  int depth = 4;
  int discSamples = frozen::kChanceSamples;
  int revealSamples = 1;
  double terminalUtility = frozen::kTerminalUtility;
  std::uint64_t maximumWork = 3'200'000;
  std::size_t maximumCacheEntries = 60'000;
};

inline FactoredParameters parametersFor(const EngineSpec& spec) {
  FactoredParameters parameters;
  parameters.depth = spec.depth;
  parameters.discSamples = spec.discSamples;
  parameters.revealSamples = spec.revealSamples;
  parameters.maximumWork = spec.maximumWork;
  parameters.maximumCacheEntries = spec.maximumCacheEntries;
  return parameters;
}

class FactoredSearch {
 public:
  explicit FactoredSearch(FactoredParameters parameters)
      : parameters_(parameters) {}

  int chooseAction(const State& source, std::uint64_t& work) {
    if (source.game_over) return -1;
    bool mirrored = false;
    const State canonical = cfpi::detail::canonicalState(source, mirrored);
    SearchContext context;
    int action = -1;
    for (int depth = 1; depth <= parameters_.depth; ++depth) {
      try {
        const int candidate = rootDecision(canonical, depth, context);
        if (candidate < 0) break;
        action = candidate;
      } catch (const WorkLimitReached&) {
        break;
      }
    }
    if (action < 0) action = centerFirstMove(canonical.board);
    work += context.work;
    return mirrored && action >= 0 ? kBoardSize - 1 - action : action;
  }

 private:
  void checkBudget(const SearchContext& context) const {
    if (context.work >= parameters_.maximumWork) throw WorkLimitReached{};
  }

  void cacheValue(SearchContext& context, std::string key, double value) const {
    const auto prior = context.cache.find(key);
    if (prior != context.cache.end()) {
      context.order.erase(prior->second.order);
      context.cache.erase(prior);
    }
    while (context.cache.size() >= parameters_.maximumCacheEntries) {
      const std::string& oldest = context.order.front();
      context.cache.erase(oldest);
      context.order.pop_front();
    }
    context.order.push_back(std::move(key));
    const auto order = std::prev(context.order.end());
    context.cache.emplace(*order, CacheEntry{value, order});
  }

  double evaluateAction(const State& state, int column, int depth,
                        SearchContext& context) const {
    const std::uint32_t stateSeed = cfpi::detail::scenarioSeedForState(
        state, frozen::kPolicySeed, depth);
    const int discSamples = parameters_.discSamples;
    const int revealSamples = parameters_.revealSamples;
    const int total = discSamples * revealSamples;
    double value = 0.0;
    for (int disc = 0; disc < discSamples; ++disc) {
      for (int rev = 0; rev < revealSamples; ++rev) {
        checkBudget(context);
        const int scenario = rev * discSamples + disc;
        cfpi::detail::StratifiedRandom random{stateSeed, scenario, total, 0};
        MoveResult move;
        const bool played =
            cfpi::detail::playMoveSampled(state, column, random, move);
        ++context.work;
        if (!played) {
          value += parameters_.terminalUtility;
          continue;
        }
        const double scoreDelta = static_cast<double>(move.score_delta);
        if (move.state.game_over) {
          value += scoreDelta + parameters_.terminalUtility;
          continue;
        }
        move.state.score = 0;
        move.state.next_disc =
            cfpi::detail::sampledNextDisc(stateSeed, disc, discSamples);
        bool ignored = false;
        const State next = cfpi::detail::canonicalState(move.state, ignored);
        value += scoreDelta + bestFutureValue(next, depth - 1, context);
      }
    }
    return value / static_cast<double>(total);
  }

  double bestFutureValue(const State& state, int depth,
                         SearchContext& context) const {
    checkBudget(context);
    if (state.game_over) return parameters_.terminalUtility;
    if (depth == 0) {
      ++context.work;
      return frozen::fairLeaf(state);
    }
    const std::string key = cfpi::detail::dynamicStateKey(state, depth);
    const auto cached = context.cache.find(key);
    if (cached != context.cache.end()) {
      const double value = cached->second.value;
      context.order.splice(context.order.end(), context.order,
                           cached->second.order);
      return value;
    }
    double best = -std::numeric_limits<double>::infinity();
    for (const int column : cfpi::detail::kColumnOrder) {
      if (!isLegal(state.board, column)) continue;
      best = std::max(best, evaluateAction(state, column, depth, context));
    }
    if (!std::isfinite(best)) best = parameters_.terminalUtility;
    cacheValue(context, key, best);
    return best;
  }

  int rootDecision(const State& canonical, int depth,
                   SearchContext& context) const {
    int action = -1;
    double bestValue = -std::numeric_limits<double>::infinity();
    for (const int column : cfpi::detail::kColumnOrder) {
      if (!isLegal(canonical.board, column)) continue;
      const double value = evaluateAction(canonical, column, depth, context);
      if (value > bestValue) {
        bestValue = value;
        action = column;
      }
    }
    return action;
  }

  FactoredParameters parameters_;
};

// ---------------------------------------------------------------------------
// CRN continuation tapes.  Seeds are pure functions of the canonical public
// root and the continuation index, under dedicated domains; the origin seed
// never enters (SL-20260823T215000Z-a5216000, rngAlgorithm note).
// ---------------------------------------------------------------------------

inline std::uint32_t publicRootHash(const State& canonicalRoot) {
  std::uint32_t hash = 0x811c'9dc5u;
  for (std::uint8_t cell : canonicalRoot.board) {
    hash ^= static_cast<std::uint32_t>(cell + 1u);
    hash *= 0x0100'0193u;
  }
  hash ^= static_cast<std::uint32_t>(canonicalRoot.next_disc);
  hash *= 0x0100'0193u;
  hash ^= static_cast<std::uint32_t>(canonicalRoot.moves_remaining);
  hash *= 0x0100'0193u;
  return hash;
}

inline std::uint32_t tapeSeedFor(std::uint32_t rootHash, int continuation) {
  return mix32(rootHash ^ kTapeDomain ^
               ((static_cast<std::uint32_t>(continuation) + 1u) *
                0x9e37'79b9u));
}

inline std::uint32_t moveSeedFor(std::uint32_t tapeSeed, int ordinal) {
  return mix32(tapeSeed ^ kMoveDomain ^
               ((static_cast<std::uint32_t>(ordinal) + 1u) * 0x85eb'ca6bu));
}

struct ContinuationOutcome {
  std::uint8_t lifetime = 0;   // moves survived, sibling move first, cap H
  std::uint8_t deathRise = 0;  // 0 = censored at H; else rise bin 1..12
  std::uint32_t clears = 0;    // numbered clears over the continuation
  std::uint32_t reveals = 0;   // cover reveals over the continuation
  std::uint64_t movesPlayed = 0;
};

// Continuation mover: FactoredSearch for the native engines, fast::FastSearch
// for engineId 3.  Both expose chooseAction(state, work&); the environment
// stepping below is engine-independent (CRN tape playMoveSampled), so engines
// differ only in the columns they choose from move 2 onward.
class ContinuationMover {
 public:
  explicit ContinuationMover(const EngineSpec& spec) {
    if (spec.id == 3) {
      fast::FastSearchParameters parameters;
      parameters.depth = spec.depth;
      parameters.chance_samples = spec.discSamples;
      parameters.maximum_work = spec.maximumWork;
      parameters.maximum_cache_entries = spec.maximumCacheEntries;
      fast_ = std::make_unique<fast::FastSearch>(parameters);
    } else {
      factored_ = std::make_unique<FactoredSearch>(parametersFor(spec));
    }
  }
  int chooseAction(const State& state, std::uint64_t& work) {
    return fast_ ? fast_->chooseAction(state, work)
                 : factored_->chooseAction(state, work);
  }

 private:
  std::unique_ptr<FactoredSearch> factored_;
  std::unique_ptr<fast::FastSearch> fast_;
};

inline ContinuationOutcome runContinuation(const State& canonicalRoot,
                                           int canonicalColumn,
                                           std::uint32_t tapeSeed, int horizon,
                                           const EngineSpec& spec) {
  ContinuationMover engine{spec};
  ContinuationOutcome outcome;
  State state = canonicalRoot;
  int column = canonicalColumn;
  int survivedMoves = 0;
  int rises = 0;
  for (int ordinal = 0;; ++ordinal) {
    Mulberry32 tape(moveSeedFor(tapeSeed, ordinal));
    MoveResult move;
    if (!cfpi::detail::playMoveSampled(state, column, tape, move)) break;
    ++outcome.movesPlayed;
    for (const Wave& wave : move.waves) {
      outcome.clears += static_cast<std::uint32_t>(wave.cleared);
      outcome.reveals += static_cast<std::uint32_t>(wave.revealed);
    }
    if (move.level_advanced) ++rises;
    state = move.state;
    if (state.game_over) break;
    ++survivedMoves;
    if (survivedMoves >= horizon) {
      outcome.lifetime = static_cast<std::uint8_t>(horizon);
      outcome.deathRise = 0;  // censored
      return outcome;
    }
    std::uint64_t work = 0;
    column = engine.chooseAction(state, work);
    if (column < 0 || !isLegal(state.board, column)) {
      column = centerFirstMove(state.board);
    }
    if (column < 0) break;
  }
  outcome.lifetime = static_cast<std::uint8_t>(std::min(survivedMoves, horizon));
  outcome.deathRise = static_cast<std::uint8_t>(std::min(rises + 1, 12));
  return outcome;
}

// ---------------------------------------------------------------------------
// Behaviour game with root staging.  Mirrors generateGame (same epsilon
// stream, same StateRecord labels) but plays through a FactoredSearch
// behaviour engine and stages full pre-move roots on the panel grid
// (moveIndex >= panel2Start, every panelStride-th move) for continuation
// labelling.
// ---------------------------------------------------------------------------

struct RootStaging {
  State rootState{};       // pre-move behaviour state, original orientation
  State canonicalRoot{};   // canonical public root used for continuations
  std::uint32_t rootHash = 0;
  bool mirroredRoot = false;
  std::uint8_t legalMask = 0;
  std::uint8_t chosenColumn = 0;
  std::uint8_t explored = 0;
  std::uint16_t moveIndex = 0;
  // One-step sibling resolution under the true environment tape (v1
  // semantics); illegal siblings stay zeroed.
  std::uint8_t afterBoard[kBoardSize][kCellCount] = {};
  std::uint8_t afterNextDisc[kBoardSize] = {};
  std::uint8_t afterMovesToRise[kBoardSize] = {};
  std::uint8_t survived[kBoardSize] = {};
  std::uint8_t afterClears[kBoardSize] = {};
  std::uint8_t afterReveals[kBoardSize] = {};
  std::uint8_t afterMaxDepth[kBoardSize] = {};
  std::int32_t afterScoreDelta[kBoardSize] = {};
};

struct GameStaging {
  std::vector<StateRecord> states;
  std::vector<RootStaging> roots;
  int moves = 0;
  std::int64_t score = 0;
  bool censored = false;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
};

inline State publicOnlyCanonicalRoot(const State& rootState, bool& mirrored) {
  State canonical = cfpi::detail::canonicalState(rootState, mirrored);
  // Continuation labels must be a pure function of the public root: the
  // board, the next disc, and the moves until the next rise.  Score is
  // already zeroed by canonicalState; level and move count are bookkeeping
  // with no effect on dynamics, and are normalized so no privileged history
  // can reach the tape or the engines.
  canonical.score = 0;
  canonical.level = 1;
  canonical.moves_played = 0;
  canonical.game_over = false;
  return canonical;
}

inline GameStaging generateGamePanel2(std::uint32_t seed,
                                      const Options& options,
                                      FactoredSearch& behaviour) {
  GameStaging output;
  State state = initialHeadlessState(seed);
  Mulberry32 explore(mix32(seed ^ 0x6f75'7421u) ^ frozen::kPolicySeed);
  std::vector<int> riseIndexOfMove;

  while (!state.game_over && state.moves_played < options.maximumMoves) {
    const Board preBoard = state.board;
    const std::uint8_t mask = legalMaskOf(preBoard);
    if (mask == 0) break;

    std::uint64_t work = 0;
    int column = behaviour.chooseAction(state, work);
    bool explored = false;
    if (options.epsilon > 0.0 && explore.nextUnit() < options.epsilon) {
      int legalCount = 0;
      const auto legal = legalColumns(preBoard, legalCount);
      if (legalCount > 0) {
        const auto pick = static_cast<std::size_t>(
            (static_cast<std::uint64_t>(explore.nextBits()) *
             static_cast<std::uint64_t>(legalCount)) >> 32);
        const int deviation = legal[pick];
        if (deviation != column) {
          column = deviation;
          explored = true;
        }
      }
    }
    if (column < 0 || !isLegal(preBoard, column)) column = centerFirstMove(preBoard);
    if (column < 0) break;

    const bool onGrid =
        state.moves_played >= options.panel2Start &&
        (state.moves_played - options.panel2Start) % options.panelStride == 0;
    if (onGrid) {
      RootStaging root;
      root.rootState = state;
      root.legalMask = mask;
      root.chosenColumn = static_cast<std::uint8_t>(column);
      root.explored = explored ? 1 : 0;
      root.moveIndex = static_cast<std::uint16_t>(state.moves_played);
      root.canonicalRoot = publicOnlyCanonicalRoot(state, root.mirroredRoot);
      root.rootHash = publicRootHash(root.canonicalRoot);
      for (int sibling = 0; sibling < kBoardSize; ++sibling) {
        if (!isLegal(preBoard, sibling)) continue;
        State probe = state;
        MoveResult move;
        if (!playHeadlessMove(probe, seed, sibling, move)) continue;
        std::memcpy(root.afterBoard[sibling], probe.board.data(), kCellCount);
        root.afterNextDisc[sibling] = probe.next_disc;
        root.afterMovesToRise[sibling] =
            static_cast<std::uint8_t>(probe.moves_remaining);
        root.survived[sibling] = probe.game_over ? 0 : 1;
        int clears = 0, reveals = 0, maxDepth = 0;
        for (const Wave& wave : move.waves) {
          clears += wave.cleared;
          reveals += wave.revealed;
          maxDepth = std::max(maxDepth, wave.depth);
        }
        root.afterClears[sibling] =
            static_cast<std::uint8_t>(std::min(clears, 255));
        root.afterReveals[sibling] =
            static_cast<std::uint8_t>(std::min(reveals, 255));
        root.afterMaxDepth[sibling] =
            static_cast<std::uint8_t>(std::min(maxDepth, 255));
        root.afterScoreDelta[sibling] = static_cast<std::int32_t>(
            std::min<std::int64_t>(move.score_delta,
                                   std::numeric_limits<std::int32_t>::max()));
      }
      output.roots.push_back(std::move(root));
    }

    StateRecord record{};
    std::memcpy(record.board, preBoard.data(), kCellCount);
    record.nextDisc = state.next_disc;
    record.movesRemaining = static_cast<std::uint8_t>(state.moves_remaining);
    record.legalMask = mask;
    record.chosenColumn = static_cast<std::uint8_t>(column);
    record.behaviorDepth = static_cast<std::uint8_t>(options.depth);
    record.explored = explored ? 1 : 0;
    record.occupiedCells = static_cast<std::uint8_t>(occupiedCells(preBoard));
    record.moveIndex = static_cast<std::uint16_t>(state.moves_played);
    record.gameSeed = seed;

    MoveResult move;
    if (!playHeadlessMove(state, seed, column, move)) break;

    int clears = 0, reveals = 0;
    for (const Wave& wave : move.waves) {
      clears += wave.cleared;
      reveals += wave.revealed;
    }
    record.clearsThisMove = static_cast<std::uint8_t>(std::min(clears, 255));
    record.revealsThisMove = static_cast<std::uint8_t>(std::min(reveals, 255));
    output.clears += static_cast<std::uint64_t>(clears);
    output.reveals += static_cast<std::uint64_t>(reveals);
    output.states.push_back(record);
    riseIndexOfMove.push_back(move.level_advanced ? 1 : 0);
    output.moves += 1;
  }

  output.score = state.score;
  output.censored = !state.game_over && output.moves >= options.maximumMoves;

  int risesAfter = 0;
  for (int index = output.moves - 1; index >= 0; --index) {
    StateRecord& record = output.states[static_cast<std::size_t>(index)];
    record.movesToDeath = static_cast<std::uint16_t>(output.moves - index);
    record.risesToDeath = static_cast<std::uint16_t>(
        risesAfter + riseIndexOfMove[static_cast<std::size_t>(index)]);
    risesAfter = record.risesToDeath;
    record.censoredGame = output.censored ? 1 : 0;
  }

  // Disclosed root-selection rule: keep panel2Roots roots per game, evenly
  // spaced over the game's grid roots (index floor((i + 0.5) * n / R)); at
  // R = 1 this is the median grid root.  0 keeps every grid root.
  if (options.panel2Roots > 0 &&
      output.roots.size() > static_cast<std::size_t>(options.panel2Roots)) {
    const std::size_t n = output.roots.size();
    const std::size_t r = static_cast<std::size_t>(options.panel2Roots);
    std::vector<RootStaging> kept;
    kept.reserve(r);
    for (std::size_t i = 0; i < r; ++i) {
      kept.push_back(std::move(output.roots[(2 * i + 1) * n / (2 * r)]));
    }
    output.roots = std::move(kept);
  }
  return output;
}

// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------

template <typename Scalar>
inline void putScalar(std::uint8_t* out, Scalar value) {
  std::memcpy(out, &value, sizeof(Scalar));  // little-endian host assumed
}

inline void serializeRecord(std::uint8_t* out, const RootStaging& root,
                            std::uint32_t recordId, std::uint32_t originSeed,
                            const EngineSpec& spec, int k, int horizon,
                            std::uint8_t referenceColumn,
                            std::uint8_t panelFlags,
                            const ContinuationOutcome* outcomes /* [7][K] */) {
  std::memset(out, 0, kRecordBytes);
  putScalar<std::uint32_t>(out + 0, kVersion);
  putScalar<std::uint32_t>(out + 4, recordId);
  putScalar<std::uint32_t>(out + 8, originSeed);
  putScalar<std::uint16_t>(out + 12, root.moveIndex);
  out[14] = root.rootState.next_disc;
  out[15] = static_cast<std::uint8_t>(root.rootState.moves_remaining);
  out[16] = root.legalMask;
  out[17] = root.chosenColumn;
  out[18] = referenceColumn;
  out[19] = spec.id;
  out[20] = static_cast<std::uint8_t>(k);
  out[21] = static_cast<std::uint8_t>(horizon);
  out[22] = panelFlags;
  std::memcpy(out + 23, root.rootState.board.data(), kCellCount);
  for (int sibling = 0; sibling < kBoardSize; ++sibling) {
    std::uint8_t* slot = out + kHeaderBytes + sibling * kSiblingBytes;
    const bool legal = (root.legalMask >> sibling) & 1u;
    if (!legal) continue;  // illegal columns keep their slot, zeroed
    std::memcpy(slot, root.afterBoard[sibling], kCellCount);
    slot[49] = root.afterNextDisc[sibling];
    slot[50] = root.afterMovesToRise[sibling];
    slot[51] = root.survived[sibling];
    slot[52] = 1;  // legal
    slot[53] = root.afterClears[sibling];
    slot[54] = root.afterReveals[sibling];
    slot[55] = root.afterMaxDepth[sibling];
    putScalar<std::int32_t>(slot + 56, root.afterScoreDelta[sibling]);
    std::uint32_t clearsTotal = 0, revealsTotal = 0;
    for (int j = 0; j < k; ++j) {
      const ContinuationOutcome& outcome = outcomes[sibling * k + j];
      slot[60 + j] = outcome.lifetime;
      slot[60 + k + j] = outcome.deathRise;
      clearsTotal += outcome.clears;
      revealsTotal += outcome.reveals;
    }
    putScalar<std::uint32_t>(slot + 60 + 2 * k, clearsTotal);
    putScalar<std::uint32_t>(slot + 64 + 2 * k, revealsTotal);
  }
}

// ---------------------------------------------------------------------------
// Continuation task pool, shared by the corpus writer and the mirror gate.
// Outcomes are stored by (root, engine, sibling, continuation) index, so the
// result is deterministic for any thread count.
// ---------------------------------------------------------------------------

struct ContinuationPlan {
  const std::vector<const RootStaging*>* roots = nullptr;
  const std::vector<EngineSpec>* engines = nullptr;
  int k = 0;
  int horizon = 0;
  std::vector<ContinuationOutcome> outcomes;  // [root][engine][7][K]
  std::vector<std::uint64_t> movesPerEngine;

  std::size_t indexOf(std::size_t rootIndex, std::size_t engineIndex,
                      int sibling, int continuation) const {
    return ((rootIndex * engines->size() + engineIndex) * kBoardSize +
            static_cast<std::size_t>(sibling)) * static_cast<std::size_t>(k) +
           static_cast<std::size_t>(continuation);
  }
};

inline void runContinuations(ContinuationPlan& plan, int threads) {
  struct Task {
    std::uint32_t rootIndex;
    std::uint16_t engineIndex;
    std::uint8_t sibling;
    std::uint8_t continuation;
  };
  std::vector<Task> tasks;
  for (std::size_t rootIndex = 0; rootIndex < plan.roots->size(); ++rootIndex) {
    const RootStaging& root = *(*plan.roots)[rootIndex];
    for (std::size_t engineIndex = 0; engineIndex < plan.engines->size();
         ++engineIndex) {
      for (int sibling = 0; sibling < kBoardSize; ++sibling) {
        if (!((root.legalMask >> sibling) & 1u)) continue;
        for (int j = 0; j < plan.k; ++j) {
          tasks.push_back({static_cast<std::uint32_t>(rootIndex),
                           static_cast<std::uint16_t>(engineIndex),
                           static_cast<std::uint8_t>(sibling),
                           static_cast<std::uint8_t>(j)});
        }
      }
    }
  }
  plan.outcomes.assign(
      plan.roots->size() * plan.engines->size() * kBoardSize *
          static_cast<std::size_t>(plan.k),
      ContinuationOutcome{});
  std::vector<std::atomic<std::uint64_t>> engineMoves(plan.engines->size());
  for (auto& counter : engineMoves) counter.store(0);

  std::atomic<std::size_t> nextTask{0};
  const int workerCount =
      std::max(1, std::min<int>(threads, static_cast<int>(tasks.size())));
  std::vector<std::thread> pool;
  for (int worker = 0; worker < workerCount; ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t taskIndex = nextTask.fetch_add(1);
        if (taskIndex >= tasks.size()) return;
        const Task& task = tasks[taskIndex];
        const RootStaging& root = *(*plan.roots)[task.rootIndex];
        const EngineSpec& spec = (*plan.engines)[task.engineIndex];
        const int canonicalColumn =
            root.mirroredRoot ? kBoardSize - 1 - task.sibling : task.sibling;
        const std::uint32_t tapeSeed =
            tapeSeedFor(root.rootHash, task.continuation);
        const ContinuationOutcome outcome =
            runContinuation(root.canonicalRoot, canonicalColumn, tapeSeed,
                            plan.horizon, spec);
        plan.outcomes[plan.indexOf(task.rootIndex, task.engineIndex,
                                   task.sibling, task.continuation)] = outcome;
        engineMoves[task.engineIndex].fetch_add(outcome.movesPlayed);
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  plan.movesPerEngine.clear();
  for (auto& counter : engineMoves) plan.movesPerEngine.push_back(counter.load());
}

}  // namespace panel2

// ===========================================================================
// G0 ladder harness (EX-20260823-sol-corpus-and-offline-gate-v2).
//
// Everything in namespace ladder is reachable only through the --make-*,
// --ladder-roots, --fast-parity and --crn-parity options; the v1 and panel2
// code paths above are untouched.
//
// Root pools are stored as JSON lines:
//   {"tag": 17, "kind": "synthetic", "moveIndex": 42,
//    "board": "<49 digits 0-9>", "nextDisc": 4, "movesRemaining": 5}
//
// Synthetic pool rule (disclosed): for pool index i, the generation tape is
// tapeSeed = mix32(kPoolTapeDomain ^ (i+1)*0x9e3779b9); the game starts from
// the empty board with next_disc drawn from Mulberry32(mix32(tapeSeed ^
// kPoolDiscDomain)) and moves_remaining 5, is played forward by the
// fast-d3s7 reference with environment randomness
// Mulberry32(mix32(tapeSeed ^ kPoolMoveDomain ^ (ordinal+1)*0x85ebca6b))
// per move, and the root is the pre-move state at move
// 8 + mix32(tapeSeed ^ kPoolDepthDomain) % 53 (8..60).  If the game dies
// first, the last pre-move state with >= 2 legal columns and >= 8 moves
// played is kept; if none exists the index is skipped and counted.  No
// engine seed is consumed anywhere on this path.
// ===========================================================================

double processCpuSeconds();

namespace ladder {

constexpr std::uint32_t kPoolTapeDomain = 0x524f'4f54u;   // "ROOT"
constexpr std::uint32_t kPoolMoveDomain = 0x504f'4f4cu;   // "POOL"
constexpr std::uint32_t kPoolDiscDomain = 0x4449'5343u;   // "DISC"
constexpr std::uint32_t kPoolDepthDomain = 0x4445'5054u;  // "DEPT"

struct PoolRoot {
  std::uint32_t tag = 0;
  std::string kind;
  std::uint16_t moveIndex = 0;
  State state{};
};

inline int popcount8(std::uint8_t mask) {
  int bits = 0;
  for (int i = 0; i < 8; ++i) bits += (mask >> i) & 1;
  return bits;
}

inline void writeRoots(const std::string& path,
                       const std::vector<PoolRoot>& roots) {
  std::ofstream file(path);
  if (!file) throw std::runtime_error("cannot open " + path);
  for (const PoolRoot& root : roots) {
    file << "{\"tag\": " << root.tag << ", \"kind\": \"" << root.kind
         << "\", \"moveIndex\": " << root.moveIndex << ", \"board\": \"";
    for (std::uint8_t cell : root.state.board)
      file << static_cast<char>('0' + cell);
    file << "\", \"nextDisc\": " << static_cast<int>(root.state.next_disc)
         << ", \"movesRemaining\": " << root.state.moves_remaining << "}\n";
  }
}

inline std::string jsonField(const std::string& line, const std::string& key) {
  const std::string needle = "\"" + key + "\": ";
  const std::size_t at = line.find(needle);
  if (at == std::string::npos)
    throw std::runtime_error("roots file missing field " + key);
  std::size_t start = at + needle.size();
  bool quoted = line[start] == '"';
  if (quoted) ++start;
  std::size_t end = start;
  while (end < line.size() &&
         (quoted ? line[end] != '"'
                 : (line[end] != ',' && line[end] != '}')))
    ++end;
  return line.substr(start, end - start);
}

inline std::vector<PoolRoot> readRoots(const std::string& path, int limit) {
  std::ifstream file(path);
  if (!file) throw std::runtime_error("cannot open " + path);
  std::vector<PoolRoot> roots;
  std::string line;
  while (std::getline(file, line)) {
    if (line.empty()) continue;
    PoolRoot root;
    root.tag = static_cast<std::uint32_t>(std::stoul(jsonField(line, "tag")));
    root.kind = jsonField(line, "kind");
    root.moveIndex =
        static_cast<std::uint16_t>(std::stoul(jsonField(line, "moveIndex")));
    const std::string board = jsonField(line, "board");
    if (board.size() != static_cast<std::size_t>(kCellCount))
      throw std::runtime_error("bad board length in roots file");
    for (int cell = 0; cell < kCellCount; ++cell) {
      root.state.board[static_cast<std::size_t>(cell)] =
          static_cast<std::uint8_t>(board[static_cast<std::size_t>(cell)] - '0');
    }
    root.state.next_disc =
        static_cast<std::uint8_t>(std::stoi(jsonField(line, "nextDisc")));
    root.state.moves_remaining = std::stoi(jsonField(line, "movesRemaining"));
    roots.push_back(std::move(root));
    if (limit > 0 && static_cast<int>(roots.size()) >= limit) break;
  }
  return roots;
}

// Play one synthetic pool game; returns true and fills the root when a state
// with >= 2 legal columns at >= 8 moves played exists on the trajectory.
inline bool makeSyntheticRoot(std::uint32_t index, PoolRoot& out) {
  const std::uint32_t tapeSeed =
      mix32(kPoolTapeDomain ^ ((index + 1u) * 0x9e37'79b9u));
  const int targetDepth =
      8 + static_cast<int>(mix32(tapeSeed ^ kPoolDepthDomain) % 53u);
  State state{};
  {
    Mulberry32 disc(mix32(tapeSeed ^ kPoolDiscDomain));
    state.next_disc = disc.nextDisc();
  }
  panel2::ContinuationMover mover{panel2::engineSpecByName("fastd3s7")};
  State lastGood{};
  bool haveGood = false;
  for (int ordinal = 0; ordinal <= targetDepth; ++ordinal) {
    if (state.game_over) break;
    const std::uint8_t mask = legalMaskOf(state.board);
    if (mask == 0) break;
    if (popcount8(mask) >= 2 && state.moves_played >= 8) {
      lastGood = state;
      haveGood = true;
      if (state.moves_played == targetDepth) break;
    }
    std::uint64_t work = 0;
    int column = mover.chooseAction(state, work);
    if (column < 0 || !isLegal(state.board, column))
      column = centerFirstMove(state.board);
    if (column < 0) break;
    Mulberry32 tape(mix32(tapeSeed ^ kPoolMoveDomain ^
                          ((static_cast<std::uint32_t>(ordinal) + 1u) *
                           0x85eb'ca6bu)));
    MoveResult move;
    if (!cfpi::detail::playMoveSampled(state, column, tape, move)) break;
    state = move.state;
  }
  if (!haveGood) return false;
  out.tag = index;
  out.kind = "synthetic";
  out.moveIndex = static_cast<std::uint16_t>(lastGood.moves_played);
  lastGood.score = 0;
  out.state = lastGood;
  return true;
}

inline int runMakeSyntheticRoots(const Options& options) {
  const int wanted = options.makeSyntheticRoots;
  const std::uint32_t budget = static_cast<std::uint32_t>(wanted) * 2u;
  std::vector<PoolRoot> candidates(budget);
  std::vector<std::uint8_t> valid(budget, 0);
  std::atomic<std::uint32_t> nextIndex{0};
  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 std::uint32_t index = nextIndex.fetch_add(1);
        if (index >= budget) return;
        valid[index] = makeSyntheticRoot(index, candidates[index]) ? 1 : 0;
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::vector<PoolRoot> roots;
  int skipped = 0;
  for (std::uint32_t index = 0;
       index < budget && static_cast<int>(roots.size()) < wanted; ++index) {
    if (valid[index]) roots.push_back(candidates[index]);
    else ++skipped;
  }
  if (static_cast<int>(roots.size()) < wanted)
    throw std::runtime_error("synthetic pool exhausted its 2x index budget");
  writeRoots(options.rootsOut, roots);
  std::cout << "{\"mode\": \"make-synthetic-roots\", \"roots\": "
            << roots.size() << ", \"skippedIndices\": " << skipped
            << ", \"out\": \"" << options.rootsOut << "\"}\n";
  return 0;
}

// Replay retained C0 games (already-read cohort; data status unchanged) with
// the exact C0 engine to harvest realistic roots.  --replay-spec is
// "seedHex:move:move[,...]"; every game is replayed to its end and the final
// (moves, score) printed so the caller can assert identity with the retained
// artifact.
inline int runReplayRoots(const Options& options) {
  struct Item {
    std::uint32_t seed;
    std::vector<int> captures;
  };
  std::vector<Item> items;
  {
    std::stringstream stream(options.replaySpec);
    std::string part;
    while (std::getline(stream, part, ',')) {
      if (part.empty()) continue;
      std::stringstream inner(part);
      std::string token;
      Item item{};
      int field = 0;
      while (std::getline(inner, token, ':')) {
        if (field == 0)
          item.seed = static_cast<std::uint32_t>(std::stoul(token, nullptr, 0));
        else
          item.captures.push_back(std::stoi(token));
        ++field;
      }
      items.push_back(std::move(item));
    }
  }
  std::vector<std::vector<PoolRoot>> captured(items.size());
  std::vector<std::string> finals(items.size());
  std::atomic<std::size_t> nextItem{0};
  const int threads =
      std::max(1, std::min<int>(options.threads, static_cast<int>(items.size())));
  std::vector<std::thread> pool;
  for (int worker = 0; worker < threads; ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t itemIndex = nextItem.fetch_add(1);
        if (itemIndex >= items.size()) return;
        const Item& item = items[itemIndex];
        panel2::ContinuationMover mover{panel2::engineSpecByName("d3n7m6")};
        State state = initialHeadlessState(item.seed);
        while (!state.game_over && state.moves_played < options.maximumMoves) {
          if (legalMaskOf(state.board) == 0) break;
          for (const int capture : item.captures) {
            if (state.moves_played == capture) {
              PoolRoot root;
              root.tag = item.seed;
              root.kind = "c0";
              root.moveIndex = static_cast<std::uint16_t>(capture);
              root.state = state;
              root.state.score = 0;
              captured[itemIndex].push_back(std::move(root));
            }
          }
          std::uint64_t work = 0;
          int column = mover.chooseAction(state, work);
          if (column < 0 || !isLegal(state.board, column))
            column = centerFirstMove(state.board);
          if (column < 0) break;
          MoveResult move;
          if (!playHeadlessMove(state, item.seed, column, move)) break;
        }
        std::ostringstream summary;
        summary << "{\"seedHex\": \"0x" << std::hex << item.seed << std::dec
                << "\", \"moves\": " << state.moves_played
                << ", \"score\": " << state.score << "}";
        finals[itemIndex] = summary.str();
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::vector<PoolRoot> roots;
  for (const auto& perItem : captured)
    for (const PoolRoot& root : perItem) roots.push_back(root);
  writeRoots(options.rootsOut, roots);
  std::cout << "{\"mode\": \"replay-roots\", \"roots\": " << roots.size()
            << ", \"finals\": [";
  for (std::size_t index = 0; index < finals.size(); ++index)
    std::cout << (index == 0 ? "" : ", ") << finals[index];
  std::cout << "], \"out\": \"" << options.rootsOut << "\"}\n";
  return 0;
}

inline panel2::RootStaging stagingFor(const PoolRoot& root) {
  panel2::RootStaging staging;
  staging.rootState = root.state;
  staging.legalMask = legalMaskOf(root.state.board);
  staging.chosenColumn = 255;
  staging.moveIndex = root.moveIndex;
  staging.canonicalRoot =
      panel2::publicOnlyCanonicalRoot(staging.rootState, staging.mirroredRoot);
  staging.rootHash = panel2::publicRootHash(staging.canonicalRoot);
  return staging;
}

// Run the continuation plan over a roots file and write one panel2 file per
// engine (ladder.py consumes them).  With --crn-parity N > 0 this instead
// runs every engine at horizon 1 over the first N roots: no engine decision
// is ever taken before the horizon, so the outcome arrays must be
// byte-identical across engines -- the CRN tape-parity gate.
inline int runLadder(const Options& options) {
  const auto engines = panel2::parseEngineList(options.contEngines);
  const auto poolRoots = readRoots(options.ladderRoots, options.ladderLimit);
  std::vector<panel2::RootStaging> staged;
  staged.reserve(poolRoots.size());
  for (const PoolRoot& root : poolRoots) staged.push_back(stagingFor(root));
  std::vector<const panel2::RootStaging*> roots;
  for (const auto& staging : staged) roots.push_back(&staging);

  const bool parityMode = options.crnParity > 0;
  if (parityMode && static_cast<int>(roots.size()) > options.crnParity)
    roots.resize(static_cast<std::size_t>(options.crnParity));

  panel2::ContinuationPlan plan;
  plan.roots = &roots;
  plan.engines = &engines;
  plan.k = options.panel2K;
  plan.horizon = parityMode ? 1 : options.panel2Horizon;
  const auto started = std::chrono::steady_clock::now();
  const double cpuAtStart = processCpuSeconds();
  panel2::runContinuations(plan, options.threads);
  const double wall =
      std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
          .count();
  const double cpu = processCpuSeconds() - cpuAtStart;

  if (parityMode) {
    std::uint64_t comparisons = 0, mismatches = 0;
    for (std::size_t rootIndex = 0; rootIndex < roots.size(); ++rootIndex) {
      for (int sibling = 0; sibling < kBoardSize; ++sibling) {
        if (!((roots[rootIndex]->legalMask >> sibling) & 1u)) continue;
        for (int j = 0; j < plan.k; ++j) {
          const auto& reference = plan.outcomes[plan.indexOf(rootIndex, 0, sibling, j)];
          for (std::size_t engineIndex = 1; engineIndex < engines.size();
               ++engineIndex) {
            const auto& other =
                plan.outcomes[plan.indexOf(rootIndex, engineIndex, sibling, j)];
            ++comparisons;
            if (reference.lifetime != other.lifetime ||
                reference.deathRise != other.deathRise ||
                reference.clears != other.clears ||
                reference.reveals != other.reveals)
              ++mismatches;
          }
        }
      }
    }
    std::cout << "{\"gate\": \"panel2-crn-tape-parity\", \"roots\": "
              << roots.size() << ", \"engines\": \"" << options.contEngines
              << "\", \"k\": " << plan.k << ", \"comparisons\": " << comparisons
              << ", \"mismatches\": " << mismatches
              << ", \"pass\": " << (mismatches == 0 ? "true" : "false")
              << "}\n";
    return mismatches == 0 ? 0 : 1;
  }

  std::vector<std::uint8_t> buffer(panel2::kRecordBytes);
  for (std::size_t engineIndex = 0; engineIndex < engines.size();
       ++engineIndex) {
    const std::string path = options.panel2Prefix + "." +
                             engines[engineIndex].name + ".panel2";
    std::FILE* file = std::fopen(path.c_str(), "wb");
    if (file == nullptr) throw std::runtime_error("cannot open " + path);
    for (std::size_t rootIndex = 0; rootIndex < roots.size(); ++rootIndex) {
      const std::size_t base = plan.indexOf(rootIndex, engineIndex, 0, 0);
      panel2::serializeRecord(buffer.data(), *roots[rootIndex],
                              static_cast<std::uint32_t>(rootIndex),
                              poolRoots[rootIndex].tag, engines[engineIndex],
                              plan.k, plan.horizon, 255, 0,
                              plan.outcomes.data() + base);
      std::fwrite(buffer.data(), 1, buffer.size(), file);
    }
    std::fclose(file);
  }
  std::cout << std::setprecision(10)
            << "{\"mode\": \"ladder\", \"roots\": " << roots.size()
            << ", \"engines\": \"" << options.contEngines
            << "\", \"k\": " << plan.k << ", \"horizon\": " << plan.horizon
            << ", \"movesPerEngine\": {";
  for (std::size_t engineIndex = 0; engineIndex < engines.size();
       ++engineIndex) {
    std::cout << (engineIndex == 0 ? "" : ", ") << "\""
              << engines[engineIndex].name
              << "\": " << plan.movesPerEngine[engineIndex];
  }
  std::cout << "}, \"wallSeconds\": " << wall << ", \"cpuSeconds\": " << cpu
            << ", \"threads\": " << options.threads << "}\n";
  return 0;
}

// Fast-vs-native parity gate: identical decisions from fast::FastSearch and
// the native single-knob d3 s7 M1 search on live probe games (smoke seeds
// only).  Work counts are compared and reported; the gate criterion is
// decision identity.
inline int runFastParityGate(const Options& options) {
  panel2::ContinuationMover nativeMover{panel2::engineSpecByName("d3s7native")};
  panel2::ContinuationMover fastMover{panel2::engineSpecByName("fastd3s7")};
  std::uint64_t decisions = 0, actionMismatches = 0, workMismatches = 0;
  std::uint64_t nativeWorkTotal = 0, fastWorkTotal = 0;
  for (std::uint32_t seed = options.seedStart;
       decisions < static_cast<std::uint64_t>(options.fastParity); ++seed) {
    State state = initialHeadlessState(seed);
    while (!state.game_over &&
           decisions < static_cast<std::uint64_t>(options.fastParity)) {
      if (legalMaskOf(state.board) == 0) break;
      std::uint64_t nativeWork = 0, fastWork = 0;
      const int nativeAction = nativeMover.chooseAction(state, nativeWork);
      const int fastAction = fastMover.chooseAction(state, fastWork);
      ++decisions;
      nativeWorkTotal += nativeWork;
      fastWorkTotal += fastWork;
      if (nativeAction != fastAction) ++actionMismatches;
      if (nativeWork != fastWork) ++workMismatches;
      int column = nativeAction;
      if (column < 0 || !isLegal(state.board, column))
        column = centerFirstMove(state.board);
      if (column < 0) break;
      MoveResult move;
      if (!playHeadlessMove(state, seed, column, move)) break;
    }
  }
  std::cout << "{\"gate\": \"fast-d3s7-vs-native-parity\", \"decisions\": "
            << decisions << ", \"actionMismatches\": " << actionMismatches
            << ", \"workMismatches\": " << workMismatches
            << ", \"nativeWorkTotal\": " << nativeWorkTotal
            << ", \"fastWorkTotal\": " << fastWorkTotal
            << ", \"seedStartHex\": \"0x" << std::hex << options.seedStart
            << std::dec << "\", \"pass\": "
            << (actionMismatches == 0 ? "true" : "false") << "}\n";
  return actionMismatches == 0 ? 0 : 1;
}

}  // namespace ladder

Options parseOptions(int argc, char** argv) {
  Options options;
  for (int index = 1; index < argc; index += 2) {
    if (index + 1 >= argc) throw std::invalid_argument("missing value");
    const std::string key = argv[index];
    const std::string value = argv[index + 1];
    if (key == "--seed-start") options.seedStart = static_cast<std::uint32_t>(std::stoul(value, nullptr, 0));
    else if (key == "--games") options.games = std::stoi(value);
    else if (key == "--max-moves") options.maximumMoves = std::stoi(value);
    else if (key == "--threads") options.threads = std::stoi(value);
    else if (key == "--depth") options.depth = std::stoi(value);
    else if (key == "--terminal-utility") options.terminalUtility = std::stod(value);
    else if (key == "--epsilon") options.epsilon = std::stod(value);
    else if (key == "--panel-stride") options.panelStride = std::stoi(value);
    else if (key == "--states") options.statesPath = value;
    else if (key == "--panel") options.panelPath = value;
    else if (key == "--summary") options.summaryPath = value;
    else if (key == "--panel2") options.panel2Prefix = value;
    else if (key == "--cont-engines") options.contEngines = value;
    else if (key == "--panel2-k") options.panel2K = std::stoi(value);
    else if (key == "--panel2-horizon") options.panel2Horizon = std::stoi(value);
    else if (key == "--panel2-roots") options.panel2Roots = std::stoi(value);
    else if (key == "--panel2-start") options.panel2Start = std::stoi(value);
    else if (key == "--behaviour-disc-samples") options.behaviourDiscSamples = std::stoi(value);
    else if (key == "--behaviour-reveal-samples") options.behaviourRevealSamples = std::stoi(value);
    else if (key == "--behaviour-max-work") options.behaviourMaxWork = std::stoull(value);
    else if (key == "--behaviour-max-cache") options.behaviourMaxCache = static_cast<std::size_t>(std::stoull(value));
    else if (key == "--panel2-mirror-check") options.mirrorCheck = std::stoi(value);
    else if (key == "--lease-label") options.leaseLabel = value;
    else if (key == "--data-role") options.dataRole = value;
    else if (key == "--make-synthetic-roots") options.makeSyntheticRoots = std::stoi(value);
    else if (key == "--roots-out") options.rootsOut = value;
    else if (key == "--replay-spec") options.replaySpec = value;
    else if (key == "--ladder-roots") options.ladderRoots = value;
    else if (key == "--ladder-limit") options.ladderLimit = std::stoi(value);
    else if (key == "--crn-parity") options.crnParity = std::stoi(value);
    else if (key == "--fast-parity") options.fastParity = std::stoi(value);
    else throw std::invalid_argument("unknown option " + key);
  }
  return options;
}

// ---------------------------------------------------------------------------
// panel2 drivers (reachable only through --panel2 / --panel2-mirror-check).
// ---------------------------------------------------------------------------

double processCpuSeconds() {
  struct rusage usage {};
  if (getrusage(RUSAGE_SELF, &usage) != 0) return 0.0;
  const auto seconds = [](const timeval& tv) {
    return static_cast<double>(tv.tv_sec) +
           static_cast<double>(tv.tv_usec) / 1e6;
  };
  return seconds(usage.ru_utime) + seconds(usage.ru_stime);
}

panel2::FactoredParameters behaviourParameters(const Options& options) {
  panel2::FactoredParameters parameters;
  parameters.depth = options.depth;
  parameters.discSamples = options.behaviourDiscSamples;
  parameters.revealSamples = options.behaviourRevealSamples;
  parameters.terminalUtility = options.terminalUtility;
  parameters.maximumWork = options.behaviourMaxWork;
  parameters.maximumCacheEntries = options.behaviourMaxCache;
  return parameters;
}

std::vector<panel2::GameStaging> playBehaviourGames(const Options& options) {
  std::vector<panel2::GameStaging> games(
      static_cast<std::size_t>(options.games));
  std::atomic<int> nextIndex{0};
  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([&]() {
      panel2::FactoredSearch behaviour{behaviourParameters(options)};
      for (;;) {
        const int index = nextIndex.fetch_add(1);
        if (index >= options.games) return;
        const std::uint32_t seed =
            options.seedStart + static_cast<std::uint32_t>(index);
        games[static_cast<std::size_t>(index)] =
            panel2::generateGamePanel2(seed, options, behaviour);
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  return games;
}

int runMirrorCheck(const Options& options) {
  const auto engines = panel2::parseEngineList(options.contEngines);
  const auto games = playBehaviourGames(options);

  // First N staged roots in seed order.
  std::vector<const panel2::RootStaging*> roots;
  std::vector<panel2::RootStaging> mirroredRoots;
  for (const auto& game : games) {
    for (const auto& root : game.roots) {
      if (static_cast<int>(roots.size()) >= options.mirrorCheck) break;
      roots.push_back(&root);
    }
  }
  mirroredRoots.reserve(roots.size());
  for (const panel2::RootStaging* original : roots) {
    panel2::RootStaging mirrored;
    mirrored.rootState = original->rootState;
    mirrored.rootState.board =
        cfpi::detail::mirrorBoard(original->rootState.board);
    std::uint8_t mask = 0;
    for (int column = 0; column < kBoardSize; ++column) {
      if ((original->legalMask >> column) & 1u) {
        mask |= static_cast<std::uint8_t>(1u << (kBoardSize - 1 - column));
      }
    }
    mirrored.legalMask = mask;
    mirrored.moveIndex = original->moveIndex;
    mirrored.canonicalRoot =
        panel2::publicOnlyCanonicalRoot(mirrored.rootState,
                                        mirrored.mirroredRoot);
    mirrored.rootHash = panel2::publicRootHash(mirrored.canonicalRoot);
    mirroredRoots.push_back(mirrored);
  }
  std::vector<const panel2::RootStaging*> mirroredPointers;
  for (const auto& root : mirroredRoots) mirroredPointers.push_back(&root);

  panel2::ContinuationPlan originalPlan;
  originalPlan.roots = &roots;
  originalPlan.engines = &engines;
  originalPlan.k = options.panel2K;
  originalPlan.horizon = options.panel2Horizon;
  panel2::runContinuations(originalPlan, options.threads);

  panel2::ContinuationPlan mirroredPlan;
  mirroredPlan.roots = &mirroredPointers;
  mirroredPlan.engines = &engines;
  mirroredPlan.k = options.panel2K;
  mirroredPlan.horizon = options.panel2Horizon;
  panel2::runContinuations(mirroredPlan, options.threads);

  std::uint64_t comparisons = 0, mismatches = 0;
  int maxLifetimeDiff = 0, maxDeathRiseDiff = 0;
  for (std::size_t rootIndex = 0; rootIndex < roots.size(); ++rootIndex) {
    for (std::size_t engineIndex = 0; engineIndex < engines.size();
         ++engineIndex) {
      for (int sibling = 0; sibling < kBoardSize; ++sibling) {
        if (!((roots[rootIndex]->legalMask >> sibling) & 1u)) continue;
        const int mirroredSibling = kBoardSize - 1 - sibling;
        for (int j = 0; j < options.panel2K; ++j) {
          const auto& a = originalPlan.outcomes[originalPlan.indexOf(
              rootIndex, engineIndex, sibling, j)];
          const auto& b = mirroredPlan.outcomes[mirroredPlan.indexOf(
              rootIndex, engineIndex, mirroredSibling, j)];
          ++comparisons;
          const int lifetimeDiff =
              std::abs(static_cast<int>(a.lifetime) - static_cast<int>(b.lifetime));
          const int deathRiseDiff =
              std::abs(static_cast<int>(a.deathRise) - static_cast<int>(b.deathRise));
          maxLifetimeDiff = std::max(maxLifetimeDiff, lifetimeDiff);
          maxDeathRiseDiff = std::max(maxDeathRiseDiff, deathRiseDiff);
          if (lifetimeDiff != 0 || deathRiseDiff != 0 ||
              a.clears != b.clears || a.reveals != b.reveals) {
            ++mismatches;
          }
        }
      }
    }
  }
  std::cout << "{\"gate\": \"panel2-mirror-invariance\", \"roots\": "
            << roots.size() << ", \"engines\": \"" << options.contEngines
            << "\", \"k\": " << options.panel2K << ", \"horizon\": "
            << options.panel2Horizon << ", \"comparisons\": " << comparisons
            << ", \"mismatches\": " << mismatches
            << ", \"maxLifetimeDiff\": " << maxLifetimeDiff
            << ", \"maxDeathRiseDiff\": " << maxDeathRiseDiff
            << ", \"pass\": " << (mismatches == 0 ? "true" : "false")
            << "}\n";
  return mismatches == 0 ? 0 : 1;
}

int runPanel2(const Options& options) {
  const double cpuAtStart = processCpuSeconds();
  const auto startedBehaviour = std::chrono::steady_clock::now();
  const auto engines = panel2::parseEngineList(options.contEngines);
  const auto games = playBehaviourGames(options);
  const auto startedContinuations = std::chrono::steady_clock::now();

  std::vector<const panel2::RootStaging*> roots;
  std::vector<std::uint32_t> rootSeeds;
  for (std::size_t gameIndex = 0; gameIndex < games.size(); ++gameIndex) {
    for (const auto& root : games[gameIndex].roots) {
      roots.push_back(&root);
      rootSeeds.push_back(options.seedStart +
                          static_cast<std::uint32_t>(gameIndex));
    }
  }

  panel2::ContinuationPlan plan;
  plan.roots = &roots;
  plan.engines = &engines;
  plan.k = options.panel2K;
  plan.horizon = options.panel2Horizon;
  panel2::runContinuations(plan, options.threads);
  const auto startedReference = std::chrono::steady_clock::now();

  // referenceColumn: fair-D4 argmax on every 16th record (recordId is the
  // root's index in seed order, so this is deterministic for any thread
  // count); 255 elsewhere.
  std::vector<std::uint8_t> referenceColumn(roots.size(), 255);
  {
    std::vector<std::size_t> targets;
    for (std::size_t rootIndex = 0; rootIndex < roots.size(); rootIndex += 16) {
      targets.push_back(rootIndex);
    }
    std::atomic<std::size_t> nextTarget{0};
    const int threads = std::max(
        1, std::min<int>(options.threads, static_cast<int>(targets.size())));
    std::vector<std::thread> pool;
    for (int worker = 0; worker < threads; ++worker) {
      pool.emplace_back([&]() {
        for (;;) {
          const std::size_t index = nextTarget.fetch_add(1);
          if (index >= targets.size()) return;
          const std::size_t rootIndex = targets[index];
          referenceColumn[rootIndex] = static_cast<std::uint8_t>(
              drop7::fair_only_depth4::chooseDepth4Action(
                  roots[rootIndex]->rootState).action);
        }
      });
    }
    for (std::thread& thread : pool) thread.join();
  }
  const auto startedWrite = std::chrono::steady_clock::now();

  // States file, seed order (deterministic for any thread count).
  std::FILE* statesFile = std::fopen(options.statesPath.c_str(), "wb");
  if (statesFile == nullptr) {
    throw std::runtime_error("cannot open " + options.statesPath);
  }
  std::uint64_t totalStates = 0, totalMoves = 0;
  std::uint64_t totalClears = 0, totalReveals = 0;
  long long totalScore = 0;
  int censoredGames = 0;
  for (const auto& game : games) {
    if (!game.states.empty()) {
      std::fwrite(game.states.data(), sizeof(StateRecord), game.states.size(),
                  statesFile);
    }
    totalStates += game.states.size();
    totalMoves += static_cast<std::uint64_t>(game.moves);
    totalClears += game.clears;
    totalReveals += game.reveals;
    totalScore += game.score;
    if (game.censored) ++censoredGames;
  }
  std::fclose(statesFile);

  // One panel2 file per continuation engine; identical roots, identical CRN
  // tapes, engine-specific labels.
  std::vector<std::string> panelPaths;
  std::vector<std::uint8_t> buffer(panel2::kRecordBytes);
  for (std::size_t engineIndex = 0; engineIndex < engines.size();
       ++engineIndex) {
    const std::string path =
        options.panel2Prefix + "." + engines[engineIndex].name + ".panel2";
    std::FILE* panelFile = std::fopen(path.c_str(), "wb");
    if (panelFile == nullptr) throw std::runtime_error("cannot open " + path);
    for (std::size_t rootIndex = 0; rootIndex < roots.size(); ++rootIndex) {
      const panel2::RootStaging& root = *roots[rootIndex];
      std::uint8_t panelFlags = 0;
      if (referenceColumn[rootIndex] != 255) panelFlags |= 1u;
      if (root.explored) panelFlags |= 2u;
      const std::size_t base =
          plan.indexOf(rootIndex, engineIndex, 0, 0);
      panel2::serializeRecord(buffer.data(), root,
                              static_cast<std::uint32_t>(rootIndex),
                              rootSeeds[rootIndex], engines[engineIndex],
                              options.panel2K, options.panel2Horizon,
                              referenceColumn[rootIndex], panelFlags,
                              plan.outcomes.data() + base);
      std::fwrite(buffer.data(), 1, buffer.size(), panelFile);
    }
    std::fclose(panelFile);
    panelPaths.push_back(path);
  }

  const auto finished = std::chrono::steady_clock::now();
  const auto wallOf = [](auto from, auto to) {
    return std::chrono::duration<double>(to - from).count();
  };
  std::ostringstream summary;
  summary << std::setprecision(10) << "{\n"
          << "  \"format\": \"drop7-panel2-summary-v1\",\n"
          << "  \"seedLease\": \"" << options.leaseLabel << "\",\n"
          << "  \"dataRole\": \"" << options.dataRole << "\",\n"
          << "  \"seedStartHex\": \"0x" << std::hex << options.seedStart
          << std::dec << "\",\n"
          << "  \"games\": " << options.games << ",\n"
          << "  \"behaviour\": {\"depth\": " << options.depth
          << ", \"discSamples\": " << options.behaviourDiscSamples
          << ", \"revealSamples\": " << options.behaviourRevealSamples
          << ", \"maximumWork\": " << options.behaviourMaxWork
          << ", \"epsilon\": " << options.epsilon << "},\n"
          << "  \"contEngines\": \"" << options.contEngines << "\",\n"
          << "  \"k\": " << options.panel2K << ",\n"
          << "  \"horizon\": " << options.panel2Horizon << ",\n"
          << "  \"panelStride\": " << options.panelStride << ",\n"
          << "  \"panel2Start\": " << options.panel2Start << ",\n"
          << "  \"panel2RootsPerGame\": " << options.panel2Roots << ",\n"
          << "  \"panel2RecordBytes\": " << panel2::kRecordBytes << ",\n"
          << "  \"panel2Records\": " << roots.size() << ",\n"
          << "  \"stateRecords\": " << totalStates << ",\n"
          << "  \"meanMoves\": "
          << static_cast<double>(totalMoves) /
                 static_cast<double>(std::max(1, options.games)) << ",\n"
          << "  \"meanScore\": "
          << static_cast<double>(totalScore) /
                 static_cast<double>(std::max(1, options.games)) << ",\n"
          << "  \"censoredGames\": " << censoredGames << ",\n"
          << "  \"clearsPerMove\": "
          << static_cast<double>(totalClears) /
                 static_cast<double>(std::max<std::uint64_t>(1, totalMoves))
          << ",\n"
          << "  \"revealsPerMove\": "
          << static_cast<double>(totalReveals) /
                 static_cast<double>(std::max<std::uint64_t>(1, totalMoves))
          << ",\n"
          << "  \"continuationMovesPerEngine\": {";
  for (std::size_t engineIndex = 0; engineIndex < engines.size();
       ++engineIndex) {
    summary << (engineIndex == 0 ? "" : ", ") << "\""
            << engines[engineIndex].name << "\": "
            << plan.movesPerEngine[engineIndex];
  }
  summary << "},\n"
          << "  \"behaviourWallSeconds\": "
          << wallOf(startedBehaviour, startedContinuations) << ",\n"
          << "  \"continuationWallSeconds\": "
          << wallOf(startedContinuations, startedReference) << ",\n"
          << "  \"referenceWallSeconds\": "
          << wallOf(startedReference, startedWrite) << ",\n"
          << "  \"totalWallSeconds\": "
          << wallOf(startedBehaviour, finished) << ",\n"
          << "  \"cpuSeconds\": " << processCpuSeconds() - cpuAtStart << ",\n"
          << "  \"threads\": " << options.threads << "\n}\n";
  std::cout << summary.str();
  if (!options.summaryPath.empty()) {
    std::ofstream file(options.summaryPath);
    file << summary.str();
  }
  return 0;
}

}  // namespace drop7::corpus

int main(int argc, char** argv) {
  using namespace drop7;
  using namespace drop7::corpus;
  try {
    const Options options = parseOptions(argc, argv);
    // G0 ladder modes (EX-20260823-sol-corpus-and-offline-gate-v2).
    if (options.makeSyntheticRoots > 0) {
      if (options.rootsOut.empty()) throw std::invalid_argument("--roots-out is required");
      return ladder::runMakeSyntheticRoots(options);
    }
    if (!options.replaySpec.empty()) {
      if (options.rootsOut.empty()) throw std::invalid_argument("--roots-out is required");
      return ladder::runReplayRoots(options);
    }
    if (options.fastParity > 0) return ladder::runFastParityGate(options);
    if (!options.ladderRoots.empty()) {
      if (options.panel2K < 1 || options.panel2K > panel2::kMaxK)
        throw std::invalid_argument("--panel2-k must be 1..30");
      if (options.crnParity == 0 && options.panel2Prefix.empty())
        throw std::invalid_argument("--panel2 prefix is required for ladder output");
      return ladder::runLadder(options);
    }
    if (!options.panel2Prefix.empty() || options.mirrorCheck > 0) {
      // PanelRecordV2 mode (P-SOL-v1).  The v1 body below is untouched.
      if (options.panel2K < 1 || options.panel2K > panel2::kMaxK) {
        throw std::invalid_argument("--panel2-k must be 1..30");
      }
      if (options.panel2Horizon < 1 || options.panel2Horizon > 255) {
        throw std::invalid_argument("--panel2-horizon must be 1..255");
      }
      if (options.panelStride <= 0) {
        throw std::invalid_argument("--panel-stride must be > 0 with --panel2");
      }
      if (options.mirrorCheck > 0) return runMirrorCheck(options);
      if (options.statesPath.empty()) {
        throw std::invalid_argument("--states is required");
      }
      return runPanel2(options);
    }
    if (options.statesPath.empty()) throw std::invalid_argument("--states is required");

    std::FILE* statesFile = std::fopen(options.statesPath.c_str(), "wb");
    if (statesFile == nullptr) throw std::runtime_error("cannot open " + options.statesPath);
    std::FILE* panelFile = nullptr;
    if (!options.panelPath.empty()) {
      panelFile = std::fopen(options.panelPath.c_str(), "wb");
      if (panelFile == nullptr) throw std::runtime_error("cannot open " + options.panelPath);
    }

    std::mutex writeMutex;
    std::atomic<int> nextIndex{0};
    std::atomic<std::uint64_t> totalStates{0}, totalPanels{0}, totalMoves{0};
    std::atomic<std::uint64_t> totalClears{0}, totalReveals{0};
    std::atomic<long long> totalScore{0};
    std::atomic<int> censoredGames{0}, finished{0};
    const auto started = std::chrono::steady_clock::now();

    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([&]() {
        Search search(options.depth, options.terminalUtility, 3'200'000);
        for (;;) {
          const int index = nextIndex.fetch_add(1);
          if (index >= options.games) return;
          const std::uint32_t seed = options.seedStart + static_cast<std::uint32_t>(index);
          GameOutput output = generateGame(seed, options, search);
          {
            const std::lock_guard<std::mutex> lock(writeMutex);
            if (!output.states.empty()) {
              std::fwrite(output.states.data(), sizeof(StateRecord),
                          output.states.size(), statesFile);
            }
            if (panelFile != nullptr && !output.panels.empty()) {
              std::fwrite(output.panels.data(), sizeof(PanelRecord),
                          output.panels.size(), panelFile);
            }
          }
          totalStates += output.states.size();
          totalPanels += output.panels.size();
          totalMoves += static_cast<std::uint64_t>(output.moves);
          totalClears += output.clears;
          totalReveals += output.reveals;
          totalScore += output.score;
          if (output.censored) censoredGames.fetch_add(1);
          const int done = finished.fetch_add(1) + 1;
          if (done % 64 == 0 || done == options.games) {
            std::cerr << "[" << done << "/" << options.games << "] states "
                      << totalStates.load() << " moves " << totalMoves.load() << "\n";
          }
        }
      });
    }
    for (std::thread& thread : pool) thread.join();
    std::fclose(statesFile);
    if (panelFile != nullptr) std::fclose(panelFile);

    const double wall = std::chrono::duration<double>(
                            std::chrono::steady_clock::now() - started).count();
    std::ostringstream summary;
    summary << std::setprecision(10) << "{\n"
            << "  \"format\": \"drop7-corpus-v1\",\n"
            << "  \"seedLease\": \"SEEDLEASE-A51D\",\n"
            << "  \"dataRole\": \"exploratory-training\",\n"
            << "  \"seedStartHex\": \"0x" << std::hex << options.seedStart << std::dec << "\",\n"
            << "  \"games\": " << options.games << ",\n"
            << "  \"behaviorDepth\": " << options.depth << ",\n"
            << "  \"epsilon\": " << options.epsilon << ",\n"
            << "  \"panelStride\": " << options.panelStride << ",\n"
            << "  \"stateRecordBytes\": " << sizeof(StateRecord) << ",\n"
            << "  \"panelRecordBytes\": " << sizeof(PanelRecord) << ",\n"
            << "  \"stateRecords\": " << totalStates.load() << ",\n"
            << "  \"panelRecords\": " << totalPanels.load() << ",\n"
            << "  \"meanMoves\": " << static_cast<double>(totalMoves.load()) / options.games << ",\n"
            << "  \"meanScore\": " << static_cast<double>(totalScore.load()) / options.games << ",\n"
            << "  \"clearsPerMove\": " << static_cast<double>(totalClears.load()) / static_cast<double>(totalMoves.load()) << ",\n"
            << "  \"revealsPerMove\": " << static_cast<double>(totalReveals.load()) / static_cast<double>(totalMoves.load()) << ",\n"
            << "  \"censoredGames\": " << censoredGames.load() << ",\n"
            << "  \"wallSeconds\": " << wall << ",\n"
            << "  \"movesPerSecond\": " << static_cast<double>(totalMoves.load()) / wall << "\n}\n";
    std::cout << summary.str();
    if (!options.summaryPath.empty()) {
      std::ofstream file(options.summaryPath);
      file << summary.str();
    }
    return 0;
  } catch (const std::exception& error) {
    std::cerr << "generate failed: " << error.what() << '\n';
    return 1;
  }
}