Drop7 Research
approaches/lifetime-objective/fast-reveal-sampling/gate.cppC++959 lines · 41.7 KB
// E-FAST-M6 equivalence, regression, determinism and timing gates
// (EX-20260823-fast-m6-reveal-sampling-port-be23e203).
//
// The comparator is the GENUINE native factored search: build.sh generates a
// copy of approaches/lifetime-objective/reveal-sampling/search.cpp that
// differs only in (a) the renamed entry point and (b) `thread_local` on its
// five inline diagnostic atomics, so that per-decision completed depth can be
// read back under game-level parallelism (each game runs wholly on one worker
// thread).  The diff is machine-checked to exactly those lines.
//
// Modes (one binary, disjoint probe/smoke seed blocks, no leased seed):
//   --mode grid     trace equivalence fast vs native on live probe games over
//                   d{3,4} x N{5,7} x M{1,2,6}: chosen column, work count and
//                   completed depth compared on every decision.
//   --mode replay   full replay of 3 retained C0 games (d3 N7 M6), fast
//                   engine driving; per-decision native comparison plus the
//                   retained final (score, moves) identity check; play-duty
//                   CPU/move for both engines.
//   --mode m1       M = 1 regression: FastFactoredSearch (memo on and off)
//                   vs the untouched fast::FastSearch, all six metric fields.
//   --mode subset   deterministic fast-only trace over all 12 grid points
//                   (smoke seeds) with per-decision memo-on/off identity and
//                   mirror-invariance checks; output contains no timing, so
//                   runs and thread counts must be byte-identical.
//   --mode cont     continuation duty (K=4, H=40, CRN tapes in the G0v2
//                   style) at d3 N7 M6: outcome identity fast vs native and
//                   realised CPU-s/move for both.

#include "reveal-sampling-noentry.cpp"

#include "../../../approaches/lifetime-objective/fast-reveal-sampling/fast-factored-search.hpp"

#include <sys/resource.h>
#include <time.h>

#include <algorithm>
#include <atomic>
#include <climits>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>

namespace drop7::fastm6gate {

namespace rs = drop7::lifetime::reveal;
using drop7::fastr::FastFactoredParameters;
using drop7::fastr::FastFactoredSearch;
using fast::FastSearchMetrics;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

inline double threadCpuSeconds() {
  timespec ts{};
  clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ts);
  return static_cast<double>(ts.tv_sec) + 1e-9 * static_cast<double>(ts.tv_nsec);
}

inline double processCpuSeconds() {
  rusage usage{};
  getrusage(RUSAGE_SELF, &usage);
  return static_cast<double>(usage.ru_utime.tv_sec) +
         1e-6 * static_cast<double>(usage.ru_utime.tv_usec) +
         static_cast<double>(usage.ru_stime.tv_sec) +
         1e-6 * static_cast<double>(usage.ru_stime.tv_usec);
}

constexpr std::uint64_t kFnvOffset = 1469598103934665603ull;
constexpr std::uint64_t kFnvPrime = 1099511628211ull;

inline void fnvAdd(std::uint64_t& hash, std::uint64_t value) {
  for (int byte = 0; byte < 8; ++byte) {
    hash ^= (value >> (8 * byte)) & 0xffu;
    hash *= kFnvPrime;
  }
}

inline bool anyLegal(const Board& board) {
  for (int column = 0; column < kBoardSize; ++column) {
    if (isLegal(board, column)) return true;
  }
  return false;
}

// ---------------------------------------------------------------------------
// The configuration grid.  M = 1 bounds are the retained arm bounds; M > 1
// bounds follow the run-arms.sh convention (worst-case work, auto cache)
// except the two depth-4 M = 6 points, whose worst case (3.9e9 / 3.0e10
// work per decision) is infeasible inside the 4 h gate wall: they run under
// a disclosed budget cap, which deliberately exercises the work-limit
// degradation and LRU-eviction paths of both engines.
// ---------------------------------------------------------------------------

struct GridSpec {
  std::string tag;
  int depth = 3;
  int discSamples = 5;
  int revealSamples = 1;
  std::uint64_t maximumWork = 0;
  std::size_t maximumCacheEntries = 0;
  std::string boundOrigin;
};

inline std::uint64_t worstWork(int depth, int n, int m) {
  return rs::worstCaseIterativeWork(
      static_cast<std::uint64_t>(kBoardSize) * static_cast<std::uint64_t>(n) *
          static_cast<std::uint64_t>(m),
      depth);
}

inline std::size_t autoCache(int depth, int n, int m) {
  const std::uint64_t needed = rs::worstCaseIterativeCacheEntries(
      static_cast<std::uint64_t>(kBoardSize) * static_cast<std::uint64_t>(n) *
          static_cast<std::uint64_t>(m),
      depth);
  return static_cast<std::size_t>(std::max<std::uint64_t>(60'000, needed + 1));
}

inline std::vector<GridSpec> gridSpecs() {
  std::vector<GridSpec> specs;
  specs.push_back({"d3-n5-m1", 3, 5, 1, 3'200'000, 60'000,
                   "frozen reference bound (runs/RUN-A525-reveal/d3-n5-m1.json)"});
  specs.push_back({"d3-n5-m2", 3, 5, 2, worstWork(3, 5, 2), autoCache(3, 5, 2),
                   "worst-case work, auto cache (run-arms.sh convention)"});
  specs.push_back({"d3-n5-m6", 3, 5, 6, worstWork(3, 5, 6), autoCache(3, 5, 6),
                   "worst-case work, auto cache"});
  specs.push_back({"d3-n7-m1", 3, 7, 1, 16'000'000, 60'000,
                   "retained d3-n7-m1.json bound"});
  specs.push_back({"d3-n7-m2", 3, 7, 2, worstWork(3, 7, 2), autoCache(3, 7, 2),
                   "worst-case work, auto cache"});
  specs.push_back({"d3-n7-m6", 3, 7, 6, 51'084'852, 87'025,
                   "C0 exact (runs/RUN-A525-reveal/d3-n7-m6.json)"});
  specs.push_back({"d4-n5-m1", 4, 5, 1, 3'200'000, 60'000,
                   "frozen fair-D4 bound"});
  specs.push_back({"d4-n5-m2", 4, 5, 2, worstWork(4, 5, 2), autoCache(4, 5, 2),
                   "worst-case work, auto cache"});
  specs.push_back({"d4-n5-m6", 4, 5, 6, 51'084'852, 100'000,
                   "budget cap (worst case 3.87e9 infeasible in the gate wall); "
                   "exercises work-limit degradation and LRU eviction"});
  specs.push_back({"d4-n7-m1", 4, 7, 1, 16'000'000, 60'000,
                   "retained fresh-s7.json bound"});
  specs.push_back({"d4-n7-m2", 4, 7, 2, 187'336'114, autoCache(4, 7, 2),
                   "retained d4-n7-m2 arm bound (worst-case work, auto cache)"});
  specs.push_back({"d4-n7-m6", 4, 7, 6, 100'000'000, 150'000,
                   "budget cap (worst case 2.99e10 infeasible in the gate wall); "
                   "exercises work-limit degradation and LRU eviction"});
  return specs;
}

inline rs::SearchParameters nativeParameters(const GridSpec& spec) {
  rs::SearchParameters parameters;
  parameters.depth = spec.depth;
  parameters.discSamples = spec.discSamples;
  parameters.revealSamples = spec.revealSamples;
  parameters.maximumWork = spec.maximumWork;
  parameters.maximumCacheEntries = spec.maximumCacheEntries;
  return parameters;
}

inline FastFactoredParameters fastParameters(const GridSpec& spec, bool memo) {
  FastFactoredParameters parameters;
  parameters.depth = spec.depth;
  parameters.chance_samples = spec.discSamples;
  parameters.reveal_samples = spec.revealSamples;
  parameters.maximum_work = spec.maximumWork;
  parameters.maximum_cache_entries = spec.maximumCacheEntries;
  parameters.use_leaf_memo = memo;
  return parameters;
}

// Reads the native decision's completed depth from the (thread_local)
// diagnostics; the caller must have called rs::resetDiagnostics() first.
inline int nativeCompletedDepth() {
  const int depth = rs::gMinCompletedDepth.load();
  return depth >= (1 << 29) ? 0 : depth;
}

std::mutex gLogMutex;

// ---------------------------------------------------------------------------
// Mode: grid
// ---------------------------------------------------------------------------

constexpr std::uint32_t kProbeBase = 0xa527'8000u;  // grid blocks of 0x20
constexpr std::uint32_t kProbeM1Base = 0xa527'8200u;  // m1-regression blocks
constexpr std::uint32_t kSmokeBase = 0xa51d'8000u;  // subset mode, 12 of 16

struct GridGameResult {
  std::uint64_t decisions = 0;
  std::uint64_t columnMismatches = 0;
  std::uint64_t workMismatches = 0;
  std::uint64_t depthMismatches = 0;
  std::uint64_t nativeWork = 0;
  std::uint64_t fastWork = 0;
  std::uint64_t workLimitedNative = 0;
  double nativeCpu = 0.0;
  double fastCpu = 0.0;
  std::uint64_t nativeHash = kFnvOffset;
  std::uint64_t fastHash = kFnvOffset;
  int minCompletedDepth = INT_MAX;
};

GridGameResult runGridGame(const GridSpec& spec, std::uint32_t seed,
                           int maximumMoves, int mismatchLogBudget) {
  GridGameResult result;
  rs::FactoredSearch native{nativeParameters(spec)};
  FastFactoredSearch fastSearch{fastParameters(spec, true)};
  State state = initialHeadlessState(seed);
  while (!state.game_over && state.moves_played < maximumMoves) {
    if (!anyLegal(state.board)) break;
    rs::resetDiagnostics();
    std::uint64_t nativeWork = 0;
    const double nativeStart = threadCpuSeconds();
    const int nativeAction = native.chooseAction(state, nativeWork);
    result.nativeCpu += threadCpuSeconds() - nativeStart;
    const int nativeDepth = nativeCompletedDepth();
    result.workLimitedNative += rs::gWorkLimitEvents.load();
    FastSearchMetrics metrics;
    const double fastStart = threadCpuSeconds();
    fastSearch.chooseAction(state, metrics);
    result.fastCpu += threadCpuSeconds() - fastStart;
    ++result.decisions;
    result.nativeWork += nativeWork;
    result.fastWork += metrics.work;
    result.minCompletedDepth = std::min(result.minCompletedDepth, nativeDepth);
    fnvAdd(result.nativeHash, static_cast<std::uint64_t>(
                                  static_cast<std::int64_t>(nativeAction)));
    fnvAdd(result.nativeHash, nativeWork);
    fnvAdd(result.nativeHash, static_cast<std::uint64_t>(nativeDepth));
    fnvAdd(result.fastHash, static_cast<std::uint64_t>(
                                static_cast<std::int64_t>(metrics.action)));
    fnvAdd(result.fastHash, metrics.work);
    fnvAdd(result.fastHash, static_cast<std::uint64_t>(metrics.completed_depth));
    const bool columnBad = metrics.action != nativeAction;
    const bool workBad = metrics.work != nativeWork;
    const bool depthBad = metrics.completed_depth != nativeDepth;
    if (columnBad) ++result.columnMismatches;
    if (workBad) ++result.workMismatches;
    if (depthBad) ++result.depthMismatches;
    if ((columnBad || workBad || depthBad) &&
        static_cast<int>(result.columnMismatches + result.workMismatches +
                         result.depthMismatches) <= mismatchLogBudget) {
      std::lock_guard<std::mutex> lock(gLogMutex);
      std::cerr << "MISMATCH " << spec.tag << " seed 0x" << std::hex << seed
                << std::dec << " move " << state.moves_played << ": native ("
                << nativeAction << ", " << nativeWork << ", " << nativeDepth
                << ") fast (" << metrics.action << ", " << metrics.work << ", "
                << metrics.completed_depth << ")\n";
    }
    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;
  }
  return result;
}

int runGridMode(int threads, int games, int maximumMoves) {
  const auto specs = gridSpecs();
  struct Task {
    std::size_t specIndex;
    int game;
  };
  std::vector<Task> tasks;
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    for (int game = 0; game < games; ++game) tasks.push_back({specIndex, game});
  }
  // Heavy points first so the tail of the schedule is short.
  std::stable_sort(tasks.begin(), tasks.end(), [&](const Task& a, const Task& b) {
    const auto cost = [&](const Task& task) {
      const GridSpec& spec = specs[task.specIndex];
      return spec.maximumWork * static_cast<std::uint64_t>(spec.depth);
    };
    return cost(a) > cost(b);
  });
  std::vector<GridGameResult> results(specs.size() * static_cast<std::size_t>(games));
  std::atomic<std::size_t> nextTask{0};
  const double wallStart =
      std::chrono::duration<double>(
          std::chrono::steady_clock::now().time_since_epoch())
          .count();
  std::vector<std::thread> pool;
  for (int worker = 0; worker < threads; ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t index = nextTask.fetch_add(1);
        if (index >= tasks.size()) return;
        const Task& task = tasks[index];
        const GridSpec& spec = specs[task.specIndex];
        const std::uint32_t seed = kProbeBase +
                                   static_cast<std::uint32_t>(task.specIndex) * 0x20u +
                                   static_cast<std::uint32_t>(task.game);
        results[task.specIndex * static_cast<std::size_t>(games) +
                static_cast<std::size_t>(task.game)] =
            runGridGame(spec, seed, maximumMoves, 8);
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  const double wallSeconds =
      std::chrono::duration<double>(
          std::chrono::steady_clock::now().time_since_epoch())
          .count() -
      wallStart;

  std::uint64_t totalDecisions = 0, totalMismatches = 0;
  std::cout << std::setprecision(10) << "{\"gate\": \"fastm6-grid-equivalence\", "
            << "\"probeSeedBaseHex\": \"0xa5278000\", \"gamesPerPoint\": "
            << games << ", \"maxMovesPerGame\": " << maximumMoves
            << ", \"points\": [";
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    const GridSpec& spec = specs[specIndex];
    GridGameResult sum;
    sum.minCompletedDepth = INT_MAX;
    std::uint64_t pointNativeHash = kFnvOffset;
    std::uint64_t pointFastHash = kFnvOffset;
    for (int game = 0; game < games; ++game) {
      const GridGameResult& r =
          results[specIndex * static_cast<std::size_t>(games) +
                  static_cast<std::size_t>(game)];
      sum.decisions += r.decisions;
      sum.columnMismatches += r.columnMismatches;
      sum.workMismatches += r.workMismatches;
      sum.depthMismatches += r.depthMismatches;
      sum.nativeWork += r.nativeWork;
      sum.fastWork += r.fastWork;
      sum.workLimitedNative += r.workLimitedNative;
      sum.nativeCpu += r.nativeCpu;
      sum.fastCpu += r.fastCpu;
      sum.minCompletedDepth = std::min(sum.minCompletedDepth, r.minCompletedDepth);
      fnvAdd(pointNativeHash, r.nativeHash);
      fnvAdd(pointFastHash, r.fastHash);
    }
    totalDecisions += sum.decisions;
    totalMismatches +=
        sum.columnMismatches + sum.workMismatches + sum.depthMismatches;
    std::cout << (specIndex == 0 ? "" : ", ") << "{\"tag\": \"" << spec.tag
              << "\", \"depth\": " << spec.depth << ", \"discSamples\": "
              << spec.discSamples << ", \"revealSamples\": " << spec.revealSamples
              << ", \"maximumWork\": " << spec.maximumWork
              << ", \"maximumCacheEntries\": " << spec.maximumCacheEntries
              << ", \"boundOrigin\": \"" << spec.boundOrigin << "\""
              << ", \"decisions\": " << sum.decisions
              << ", \"columnMismatches\": " << sum.columnMismatches
              << ", \"workMismatches\": " << sum.workMismatches
              << ", \"depthMismatches\": " << sum.depthMismatches
              << ", \"nativeWorkTotal\": " << sum.nativeWork
              << ", \"fastWorkTotal\": " << sum.fastWork
              << ", \"workLimitedNativeDecisions\": " << sum.workLimitedNative
              << ", \"minCompletedDepth\": "
              << (sum.minCompletedDepth == INT_MAX ? 0 : sum.minCompletedDepth)
              << ", \"nativeCpuSeconds\": " << sum.nativeCpu
              << ", \"fastCpuSeconds\": " << sum.fastCpu
              << ", \"nativeCpuSecondsPerMove\": "
              << (sum.decisions ? sum.nativeCpu / static_cast<double>(sum.decisions) : 0.0)
              << ", \"fastCpuSecondsPerMove\": "
              << (sum.decisions ? sum.fastCpu / static_cast<double>(sum.decisions) : 0.0)
              << ", \"speedup\": "
              << (sum.fastCpu > 0.0 ? sum.nativeCpu / sum.fastCpu : 0.0)
              << ", \"nativeTraceHash\": \"" << std::hex << pointNativeHash
              << "\", \"fastTraceHash\": \"" << pointFastHash << std::dec
              << "\", \"decisionsAtLeast500\": "
              << (sum.decisions >= 500 ? "true" : "false") << "}";
  }
  std::cout << "], \"totalDecisions\": " << totalDecisions
            << ", \"totalMismatches\": " << totalMismatches
            << ", \"threads\": " << threads << ", \"wallSeconds\": "
            << wallSeconds << ", \"processCpuSeconds\": " << processCpuSeconds()
            << ", \"pass\": " << (totalMismatches == 0 ? "true" : "false")
            << "}\n";
  bool enough = true;
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    GridGameResult sum;
    for (int game = 0; game < games; ++game) {
      sum.decisions += results[specIndex * static_cast<std::size_t>(games) +
                               static_cast<std::size_t>(game)].decisions;
    }
    if (sum.decisions < 500) enough = false;
  }
  return (totalMismatches == 0 && enough) ? 0 : 1;
}

// ---------------------------------------------------------------------------
// Mode: replay (retained C0 games; fast engine drives)
// ---------------------------------------------------------------------------

struct ReplayExpected {
  std::uint32_t seed;
  std::int64_t score;
  int moves;
};

// Retained finals from runs/RUN-A525-reveal/d3-n7-m6.json gamesDetail; the
// same three games G0v2 replayed exactly.
constexpr ReplayExpected kReplayExpected[] = {
    {0xa51d1005u, 320871, 95},
    {0xa51d1009u, 381355, 110},
    {0xa51d100au, 463094, 130},
};

int runReplayMode(int threads) {
  const GridSpec c0{"d3-n7-m6", 3, 7, 6, 51'084'852, 87'025, "C0 exact"};
  const int games = static_cast<int>(std::size(kReplayExpected));
  struct ReplayResult {
    std::uint64_t decisions = 0;
    std::uint64_t mismatches = 0;
    std::int64_t finalScore = 0;
    int finalMoves = 0;
    bool finalsMatch = false;
    double nativeCpu = 0.0;
    double fastCpu = 0.0;
    std::uint64_t work = 0;
  };
  std::vector<ReplayResult> results(static_cast<std::size_t>(games));
  std::atomic<int> nextGame{0};
  std::vector<std::thread> pool;
  const int workerCount = std::max(1, std::min(threads, games));
  for (int worker = 0; worker < workerCount; ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const int gameIndex = nextGame.fetch_add(1);
        if (gameIndex >= games) return;
        const ReplayExpected& expected =
            kReplayExpected[static_cast<std::size_t>(gameIndex)];
        ReplayResult& result = results[static_cast<std::size_t>(gameIndex)];
        rs::FactoredSearch native{nativeParameters(c0)};
        FastFactoredSearch fastSearch{fastParameters(c0, true)};
        State state = initialHeadlessState(expected.seed);
        while (!state.game_over && state.moves_played < 2000) {
          if (!anyLegal(state.board)) break;
          rs::resetDiagnostics();
          std::uint64_t nativeWork = 0;
          const double nativeStart = threadCpuSeconds();
          const int nativeAction = native.chooseAction(state, nativeWork);
          result.nativeCpu += threadCpuSeconds() - nativeStart;
          const int nativeDepth = nativeCompletedDepth();
          FastSearchMetrics metrics;
          const double fastStart = threadCpuSeconds();
          fastSearch.chooseAction(state, metrics);
          result.fastCpu += threadCpuSeconds() - fastStart;
          ++result.decisions;
          result.work += metrics.work;
          if (metrics.action != nativeAction || metrics.work != nativeWork ||
              metrics.completed_depth != nativeDepth) {
            ++result.mismatches;
            std::lock_guard<std::mutex> lock(gLogMutex);
            std::cerr << "MISMATCH replay seed 0x" << std::hex << expected.seed
                      << std::dec << " move " << state.moves_played
                      << ": native (" << nativeAction << ", " << nativeWork
                      << ", " << nativeDepth << ") fast (" << metrics.action
                      << ", " << metrics.work << ", "
                      << metrics.completed_depth << ")\n";
          }
          // The FAST engine drives, so the finals identity below proves the
          // port reproduces the retained C0 trajectory end to end.
          int column = metrics.action;
          if (column < 0 || !isLegal(state.board, column)) {
            column = centerFirstMove(state.board);
          }
          if (column < 0) break;
          MoveResult move;
          if (!playHeadlessMove(state, expected.seed, column, move)) break;
        }
        result.finalScore = state.score;
        result.finalMoves = state.moves_played;
        result.finalsMatch = state.score == expected.score &&
                             state.moves_played == expected.moves;
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::uint64_t decisions = 0, mismatches = 0;
  double nativeCpu = 0.0, fastCpu = 0.0;
  bool finalsOk = true;
  std::cout << std::setprecision(10)
            << "{\"gate\": \"fastm6-c0-replay\", \"config\": {\"depth\": 3, "
               "\"discSamples\": 7, \"revealSamples\": 6, \"maximumWork\": "
               "51084852, \"maximumCacheEntries\": 87025}, \"games\": [";
  for (int gameIndex = 0; gameIndex < games; ++gameIndex) {
    const ReplayExpected& expected =
        kReplayExpected[static_cast<std::size_t>(gameIndex)];
    const ReplayResult& result = results[static_cast<std::size_t>(gameIndex)];
    decisions += result.decisions;
    mismatches += result.mismatches;
    nativeCpu += result.nativeCpu;
    fastCpu += result.fastCpu;
    finalsOk = finalsOk && result.finalsMatch;
    std::cout << (gameIndex == 0 ? "" : ", ") << "{\"seedHex\": \"0x" << std::hex
              << expected.seed << std::dec << "\", \"decisions\": "
              << result.decisions << ", \"mismatches\": " << result.mismatches
              << ", \"finalScore\": " << result.finalScore
              << ", \"finalMoves\": " << result.finalMoves
              << ", \"expectedScore\": " << expected.score
              << ", \"expectedMoves\": " << expected.moves
              << ", \"finalsMatch\": " << (result.finalsMatch ? "true" : "false")
              << "}";
  }
  std::cout << "], \"decisions\": " << decisions
            << ", \"mismatches\": " << mismatches
            << ", \"playDuty\": {\"nativeCpuSecondsPerMove\": "
            << (decisions ? nativeCpu / static_cast<double>(decisions) : 0.0)
            << ", \"fastCpuSecondsPerMove\": "
            << (decisions ? fastCpu / static_cast<double>(decisions) : 0.0)
            << ", \"speedup\": " << (fastCpu > 0.0 ? nativeCpu / fastCpu : 0.0)
            << "}, \"finalsMatch\": " << (finalsOk ? "true" : "false")
            << ", \"pass\": "
            << ((mismatches == 0 && finalsOk) ? "true" : "false") << "}\n";
  return (mismatches == 0 && finalsOk) ? 0 : 1;
}

// ---------------------------------------------------------------------------
// Mode: m1 (regression vs the untouched fast search)
// ---------------------------------------------------------------------------

int runM1Mode(int threads, int games, int maximumMoves) {
  std::vector<GridSpec> specs;
  for (const GridSpec& spec : gridSpecs()) {
    if (spec.revealSamples == 1) specs.push_back(spec);
  }
  struct M1Result {
    std::uint64_t decisions = 0;
    std::uint64_t memoOnMismatches = 0;
    std::uint64_t memoOffMismatches = 0;
  };
  std::vector<M1Result> results(specs.size() * static_cast<std::size_t>(games));
  struct Task {
    std::size_t specIndex;
    int game;
  };
  std::vector<Task> tasks;
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    for (int game = 0; game < games; ++game) tasks.push_back({specIndex, game});
  }
  std::atomic<std::size_t> nextTask{0};
  std::vector<std::thread> pool;
  for (int worker = 0; worker < threads; ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t index = nextTask.fetch_add(1);
        if (index >= tasks.size()) return;
        const Task& task = tasks[index];
        const GridSpec& spec = specs[task.specIndex];
        M1Result& result =
            results[task.specIndex * static_cast<std::size_t>(games) +
                    static_cast<std::size_t>(task.game)];
        const std::uint32_t seed =
            kProbeM1Base + static_cast<std::uint32_t>(task.specIndex) * 0x20u +
            static_cast<std::uint32_t>(task.game);
        fast::FastSearchParameters baselineParameters;
        baselineParameters.depth = spec.depth;
        baselineParameters.chance_samples = spec.discSamples;
        baselineParameters.maximum_work = spec.maximumWork;
        baselineParameters.maximum_cache_entries = spec.maximumCacheEntries;
        fast::FastSearch baseline{baselineParameters};
        FastFactoredSearch memoOn{fastParameters(spec, true)};
        FastFactoredSearch memoOff{fastParameters(spec, false)};
        State state = initialHeadlessState(seed);
        while (!state.game_over && state.moves_played < maximumMoves) {
          if (!anyLegal(state.board)) break;
          FastSearchMetrics expected, onMetrics, offMetrics;
          baseline.chooseAction(state, expected);
          memoOn.chooseAction(state, onMetrics);
          memoOff.chooseAction(state, offMetrics);
          ++result.decisions;
          const auto identical = [](const FastSearchMetrics& a,
                                    const FastSearchMetrics& b) {
            return a.action == b.action && a.completed_depth == b.completed_depth &&
                   a.nodes == b.nodes && a.work == b.work &&
                   a.cache_hits == b.cache_hits &&
                   a.cache_entries == b.cache_entries;
          };
          if (!identical(expected, onMetrics)) ++result.memoOnMismatches;
          if (!identical(expected, offMetrics)) ++result.memoOffMismatches;
          int column = expected.action;
          if (column < 0 || !isLegal(state.board, column)) {
            column = centerFirstMove(state.board);
          }
          if (column < 0) break;
          MoveResult move;
          if (!playHeadlessMove(state, seed, column, move)) break;
        }
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::uint64_t totalDecisions = 0, totalMismatches = 0;
  std::cout << "{\"gate\": \"fastm6-m1-regression\", \"points\": [";
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    M1Result sum;
    for (int game = 0; game < games; ++game) {
      const M1Result& r =
          results[specIndex * static_cast<std::size_t>(games) +
                  static_cast<std::size_t>(game)];
      sum.decisions += r.decisions;
      sum.memoOnMismatches += r.memoOnMismatches;
      sum.memoOffMismatches += r.memoOffMismatches;
    }
    totalDecisions += sum.decisions;
    totalMismatches += sum.memoOnMismatches + sum.memoOffMismatches;
    std::cout << (specIndex == 0 ? "" : ", ") << "{\"tag\": \""
              << specs[specIndex].tag << "\", \"decisions\": " << sum.decisions
              << ", \"memoOnMetricMismatches\": " << sum.memoOnMismatches
              << ", \"memoOffMetricMismatches\": " << sum.memoOffMismatches
              << "}";
  }
  std::cout << "], \"metricFieldsCompared\": \"action, completed_depth, nodes, "
               "work, cache_hits, cache_entries\", \"totalDecisions\": "
            << totalDecisions << ", \"totalMismatches\": " << totalMismatches
            << ", \"pass\": " << (totalMismatches == 0 ? "true" : "false")
            << "}\n";
  return totalMismatches == 0 ? 0 : 1;
}

// ---------------------------------------------------------------------------
// Mode: subset (fast-only determinism + mirror + memo identity; no timing in
// the output, so byte identity across runs and thread counts is the check)
// ---------------------------------------------------------------------------

int runSubsetMode(int threads) {
  const auto specs = gridSpecs();
  struct SubsetResult {
    std::uint64_t decisions = 0;
    std::uint64_t memoMismatches = 0;
    std::uint64_t mirrorMismatches = 0;
    std::uint64_t mirrorSkippedSymmetric = 0;
    std::uint64_t traceHash = kFnvOffset;
  };
  std::vector<SubsetResult> results(specs.size());
  std::atomic<std::size_t> nextTask{0};
  std::vector<std::thread> pool;
  for (int worker = 0; worker < std::max(1, threads); ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t specIndex = nextTask.fetch_add(1);
        if (specIndex >= specs.size()) return;
        const GridSpec& spec = specs[specIndex];
        SubsetResult& result = results[specIndex];
        const std::uint32_t seed =
            kSmokeBase + static_cast<std::uint32_t>(specIndex);
        const int maximumMoves =
            (spec.depth == 4 && spec.revealSamples == 6) ? 6 : 12;
        FastFactoredSearch memoOn{fastParameters(spec, true)};
        FastFactoredSearch memoOff{fastParameters(spec, false)};
        FastFactoredSearch mirrorEngine{fastParameters(spec, true)};
        State state = initialHeadlessState(seed);
        while (!state.game_over && state.moves_played < maximumMoves) {
          if (!anyLegal(state.board)) break;
          FastSearchMetrics onMetrics, offMetrics, mirrorMetrics;
          memoOn.chooseAction(state, onMetrics);
          memoOff.chooseAction(state, offMetrics);
          State mirrored = state;
          mirrored.board = cfpi::detail::mirrorBoard(state.board);
          // A mirror-symmetric board maps to itself, so both orientations are
          // the same canonical state and the engine returns the same column,
          // not its mirror; finding-13's mirror gate (section 4C) excludes
          // symmetric boards for exactly this reason.
          const bool symmetric = mirrored.board == state.board;
          if (!symmetric) mirrorEngine.chooseAction(mirrored, mirrorMetrics);
          ++result.decisions;
          if (onMetrics.action != offMetrics.action ||
              onMetrics.work != offMetrics.work ||
              onMetrics.completed_depth != offMetrics.completed_depth ||
              onMetrics.nodes != offMetrics.nodes ||
              onMetrics.cache_hits != offMetrics.cache_hits ||
              onMetrics.cache_entries != offMetrics.cache_entries) {
            ++result.memoMismatches;
          }
          if (symmetric) {
            ++result.mirrorSkippedSymmetric;
          } else {
            const int expectedMirror =
                onMetrics.action < 0 ? onMetrics.action
                                     : kBoardSize - 1 - onMetrics.action;
            if (mirrorMetrics.action != expectedMirror ||
                mirrorMetrics.work != onMetrics.work ||
                mirrorMetrics.completed_depth != onMetrics.completed_depth) {
              ++result.mirrorMismatches;
            }
          }
          fnvAdd(result.traceHash, static_cast<std::uint64_t>(
                                       static_cast<std::int64_t>(onMetrics.action)));
          fnvAdd(result.traceHash, onMetrics.work);
          fnvAdd(result.traceHash,
                 static_cast<std::uint64_t>(onMetrics.completed_depth));
          int column = onMetrics.action;
          if (column < 0 || !isLegal(state.board, column)) {
            column = centerFirstMove(state.board);
          }
          if (column < 0) break;
          MoveResult move;
          if (!playHeadlessMove(state, seed, column, move)) break;
        }
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::uint64_t totalDecisions = 0, totalMismatches = 0;
  std::cout << "{\"gate\": \"fastm6-subset-determinism-mirror-memo\", "
               "\"smokeSeedBaseHex\": \"0xa51d8000\", \"points\": [";
  for (std::size_t specIndex = 0; specIndex < specs.size(); ++specIndex) {
    const SubsetResult& result = results[specIndex];
    totalDecisions += result.decisions;
    totalMismatches += result.memoMismatches + result.mirrorMismatches;
    std::cout << (specIndex == 0 ? "" : ", ") << "{\"tag\": \""
              << specs[specIndex].tag << "\", \"decisions\": "
              << result.decisions << ", \"memoOnOffMismatches\": "
              << result.memoMismatches << ", \"mirrorMismatches\": "
              << result.mirrorMismatches << ", \"mirrorSkippedSymmetric\": "
              << result.mirrorSkippedSymmetric << ", \"traceHash\": \""
              << std::hex << result.traceHash << std::dec << "\"}";
  }
  std::cout << "], \"totalDecisions\": " << totalDecisions
            << ", \"totalMismatches\": " << totalMismatches << ", \"pass\": "
            << (totalMismatches == 0 ? "true" : "false") << "}\n";
  return totalMismatches == 0 ? 0 : 1;
}

// ---------------------------------------------------------------------------
// Mode: cont (continuation duty timing + outcome identity, G0v2 rung-2 style)
// ---------------------------------------------------------------------------

constexpr std::uint32_t kTapeDomain = 0x50534f4cu;  // "PSOL" (panel2 value)
constexpr std::uint32_t kMoveDomain = 0x434f4e54u;  // "CONT" (panel2 value)

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 ContOutcome {
  int movesSurvived = 0;
  std::uint32_t clears = 0;
  std::uint32_t reveals = 0;
  int rises = 0;
  std::uint64_t columnHash = kFnvOffset;
  std::uint64_t work = 0;
  double cpuSeconds = 0.0;
};

template <typename Engine>
ContOutcome runContinuation(const State& canonicalRoot, std::uint32_t tapeSeed,
                            int horizon, Engine& engine) {
  ContOutcome outcome;
  State state = canonicalRoot;
  for (int ordinal = 0;; ++ordinal) {
    if (state.game_over || !anyLegal(state.board)) break;
    const double start = threadCpuSeconds();
    std::uint64_t work = 0;
    int column = engine.chooseAction(state, work);
    outcome.cpuSeconds += threadCpuSeconds() - start;
    outcome.work += work;
    if (column < 0 || !isLegal(state.board, column)) {
      column = centerFirstMove(state.board);
    }
    if (column < 0) break;
    fnvAdd(outcome.columnHash, static_cast<std::uint64_t>(column));
    Mulberry32 tape(moveSeedFor(tapeSeed, ordinal));
    MoveResult move;
    if (!cfpi::detail::playMoveSampled(state, column, tape, move)) break;
    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) ++outcome.rises;
    state = move.state;
    if (state.game_over) break;
    ++outcome.movesSurvived;
    if (outcome.movesSurvived >= horizon) break;
  }
  return outcome;
}

int runContMode(int threads) {
  const GridSpec c0{"d3-n7-m6", 3, 7, 6, 51'084'852, 87'025, "C0 exact"};
  // Roots: pre-move states at moves 40 and 80 of retained C0 game 0xa51d1005,
  // regenerated by the fast engine (whose replay-identity is proven by --mode
  // replay); already-read data, no new seed opened.
  const std::uint32_t rootSeed = 0xa51d1005u;
  const int captures[] = {40, 80};
  std::vector<State> roots;
  {
    FastFactoredSearch driver{fastParameters(c0, true)};
    State state = initialHeadlessState(rootSeed);
    while (!state.game_over && state.moves_played <= 80) {
      for (const int capture : captures) {
        if (state.moves_played == capture) {
          State root = state;
          root.score = 0;
          roots.push_back(root);
        }
      }
      if (!anyLegal(state.board)) break;
      std::uint64_t work = 0;
      int column = driver.chooseAction(state, work);
      if (column < 0 || !isLegal(state.board, column)) {
        column = centerFirstMove(state.board);
      }
      if (column < 0) break;
      MoveResult move;
      if (!playHeadlessMove(state, rootSeed, column, move)) break;
    }
  }
  if (roots.size() != std::size(captures)) {
    std::cerr << "cont: expected " << std::size(captures) << " roots, got "
              << roots.size() << "\n";
    return 1;
  }
  constexpr int kK = 4;
  constexpr int kHorizon = 40;
  struct Task {
    std::size_t root;
    int continuation;
    int engine;  // 0 fast, 1 native
  };
  std::vector<Task> tasks;
  for (std::size_t root = 0; root < roots.size(); ++root) {
    for (int continuation = 0; continuation < kK; ++continuation) {
      for (int engine = 0; engine < 2; ++engine) {
        tasks.push_back({root, continuation, engine});
      }
    }
  }
  std::vector<ContOutcome> outcomes(tasks.size());
  std::vector<State> canonicalRoots;
  std::vector<std::uint32_t> rootHashes;
  for (const State& root : roots) {
    bool mirrored = false;
    const State canonical = cfpi::detail::canonicalState(root, mirrored);
    canonicalRoots.push_back(canonical);
    rootHashes.push_back(publicRootHash(canonical));
  }
  std::atomic<std::size_t> nextTask{0};
  std::vector<std::thread> pool;
  for (int worker = 0; worker < std::max(1, threads); ++worker) {
    pool.emplace_back([&]() {
      for (;;) {
        const std::size_t index = nextTask.fetch_add(1);
        if (index >= tasks.size()) return;
        const Task& task = tasks[index];
        const std::uint32_t tapeSeed =
            tapeSeedFor(rootHashes[task.root], task.continuation);
        if (task.engine == 0) {
          FastFactoredSearch engine{fastParameters(c0, true)};
          outcomes[index] = runContinuation(canonicalRoots[task.root], tapeSeed,
                                            kHorizon, engine);
        } else {
          rs::FactoredSearch engine{nativeParameters(c0)};
          outcomes[index] = runContinuation(canonicalRoots[task.root], tapeSeed,
                                            kHorizon, engine);
        }
      }
    });
  }
  for (std::thread& thread : pool) thread.join();
  std::uint64_t mismatches = 0;
  std::uint64_t fastMoves = 0, nativeMoves = 0;
  double fastCpu = 0.0, nativeCpu = 0.0;
  std::cout << std::setprecision(10)
            << "{\"gate\": \"fastm6-continuation-duty\", \"rootGameSeedHex\": "
               "\"0xa51d1005\", \"rootMoves\": [40, 80], \"K\": " << kK
            << ", \"H\": " << kHorizon << ", \"continuations\": [";
  bool first = true;
  for (std::size_t index = 0; index < tasks.size(); index += 2) {
    const ContOutcome& fastOutcome = outcomes[index];
    const ContOutcome& nativeOutcome = outcomes[index + 1];
    const bool identical =
        fastOutcome.columnHash == nativeOutcome.columnHash &&
        fastOutcome.movesSurvived == nativeOutcome.movesSurvived &&
        fastOutcome.clears == nativeOutcome.clears &&
        fastOutcome.reveals == nativeOutcome.reveals &&
        fastOutcome.rises == nativeOutcome.rises &&
        fastOutcome.work == nativeOutcome.work;
    if (!identical) ++mismatches;
    const std::uint64_t moves =
        static_cast<std::uint64_t>(fastOutcome.movesSurvived) + 1;
    fastMoves += moves;
    nativeMoves += static_cast<std::uint64_t>(nativeOutcome.movesSurvived) + 1;
    fastCpu += fastOutcome.cpuSeconds;
    nativeCpu += nativeOutcome.cpuSeconds;
    std::cout << (first ? "" : ", ") << "{\"root\": " << tasks[index].root
              << ", \"continuation\": " << tasks[index].continuation
              << ", \"movesSurvived\": " << fastOutcome.movesSurvived
              << ", \"clears\": " << fastOutcome.clears << ", \"reveals\": "
              << fastOutcome.reveals << ", \"rises\": " << fastOutcome.rises
              << ", \"work\": " << fastOutcome.work
              << ", \"identicalToNative\": " << (identical ? "true" : "false")
              << "}";
    first = false;
  }
  std::cout << "], \"outcomeMismatches\": " << mismatches
            << ", \"continuationDuty\": {\"fastCpuSecondsPerMove\": "
            << (fastMoves ? fastCpu / static_cast<double>(fastMoves) : 0.0)
            << ", \"nativeCpuSecondsPerMove\": "
            << (nativeMoves ? nativeCpu / static_cast<double>(nativeMoves) : 0.0)
            << ", \"speedup\": " << (fastCpu > 0.0 ? nativeCpu / fastCpu : 0.0)
            << ", \"fastMoves\": " << fastMoves << ", \"nativeMoves\": "
            << nativeMoves << "}, \"pass\": "
            << (mismatches == 0 ? "true" : "false") << "}\n";
  return mismatches == 0 ? 0 : 1;
}

}  // namespace drop7::fastm6gate

int main(int argc, char** argv) {
  using namespace drop7::fastm6gate;
  std::string mode;
  int threads = 1;
  int games = 21;
  int maximumMoves = 25;
  try {
    for (int index = 1; index + 1 < argc; index += 2) {
      const std::string key = argv[index];
      const std::string value = argv[index + 1];
      if (key == "--mode") mode = value;
      else if (key == "--threads") threads = std::stoi(value);
      else if (key == "--games") games = std::stoi(value);
      else if (key == "--max-moves") maximumMoves = std::stoi(value);
      else throw std::invalid_argument("unknown option " + key);
    }
    if (mode == "grid") return runGridMode(threads, games, maximumMoves);
    if (mode == "replay") return runReplayMode(threads);
    if (mode == "m1") return runM1Mode(threads, games, maximumMoves);
    if (mode == "subset") return runSubsetMode(threads);
    if (mode == "cont") return runContMode(threads);
    std::cerr << "usage: gate --mode grid|replay|m1|subset|cont [--threads N]"
                 " [--games N] [--max-moves N]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "fastm6 gate failed: " << error.what() << '\n';
    return 1;
  }
}