Drop7 Research
approaches/d4-long-outcome/long-outcome/curriculum-long-outcome-nnue.cppC++2,220 lines · 85.8 KB
#define DROP7_FAIR_ONLY_HORIZON_LIBRARY
#include "../../fair-expectimax/reference/fair-only-horizon.cpp"
#undef DROP7_FAIR_ONLY_HORIZON_LIBRARY

#include <bit>
#include <cstring>
#include <filesystem>
#include <numbers>
#include <optional>
#include <sstream>
#include <type_traits>
#include <unordered_set>

// Runs an artifact-only long-outcome diagnostic.  The corpus split, stochastic
// tape, horizon, balanced return, sparse architecture, optimizer, and
// acceptance gates are fixed before generating any horizon-100 label.  This
// translation unit has no gameplay runner and accepts no gameplay seed.
namespace drop7::curriculum_long_outcome_nnue {

namespace fair = drop7::fair_only_horizon;
namespace detail = drop7::cfpi::detail;
using Clock = std::chrono::steady_clock;

constexpr int kCorpusStates = 4'096;
constexpr int kTrainingStates = 3'072;
constexpr int kHeldoutStates = 1'024;
constexpr int kScenarios = 7;
constexpr int kHorizon = 100;
constexpr int kEventsPerStep = 128;
constexpr int kDefaultThreads = 8;
constexpr int kBoardTokens = 10;
constexpr int kBoardInputs = kCellCount * kBoardTokens;
constexpr int kNextInputs = kBoardSize;
constexpr int kPhaseInputs = kMovesPerLevel;
constexpr int kActionInputs = kBoardSize;
constexpr int kAggregateInputs = 32;
constexpr int kInputs =
    kBoardInputs + kNextInputs + kPhaseInputs + kActionInputs +
    kAggregateInputs;
constexpr int kActiveInputs = kCellCount + 3 + kAggregateInputs;
constexpr int kHidden = 96;
constexpr int kHeads = 5;
constexpr int kEpochs = 36;
constexpr int kBatchRoots = 32;
constexpr double kLearningRate = 0.0015;
constexpr double kFinalLearningRateRatio = 0.15;
constexpr double kWeightDecay = 0.00002;
constexpr double kPairwiseLossWeight = 0.06;
constexpr double kPairwiseTemperature = 0.15;
constexpr std::array<double, kHeads> kHeadLossWeights{{
    1.0, 0.25, 0.15, 0.15, 0.15,
}};

constexpr double kBalancedSurvivalWeight = 0.45;
constexpr double kBalancedScoreWeight = 0.30;
constexpr double kBalancedClearsWeight = 0.15;
constexpr double kBalancedRevealsWeight = 0.10;
constexpr double kScoreScale = 350'000.0;
constexpr double kClearsScale = 220.0;
constexpr double kRevealsScale = 120.0;

constexpr double kGateTop1Gain = 0.05;
constexpr double kGatePairwiseGain = 0.03;
constexpr double kGateRegretRatio = 0.90;
constexpr double kGateAbsoluteTop1 = 0.45;
constexpr double kGateAbsolutePairwise = 0.65;

constexpr double kWallLimitSeconds = 45.0 * 60.0;
constexpr std::uint64_t kRssLimitBytes = 256ull * 1024ull * 1024ull;
constexpr std::uint64_t kCheckpointLimitBytes = 1ull * 1024ull * 1024ull;
constexpr std::uintmax_t kExpectedCorpusBytes = 4'864'041;
constexpr std::string_view kExpectedCorpusSha256 =
    "c963ac242994e7d18020fd7369954be2f4015d7f6c972f6d5fffe79c371db226";
constexpr std::uint64_t kExpectedDatasetFingerprint =
    0xc649'f123'fc0c'c4b9ull;
constexpr std::uint64_t kSplitDomain = 0x4355'5252'5350'4c54ull;
constexpr std::uint64_t kHashHalfDomain = 0x4355'5252'4841'4c46ull;
constexpr std::uint64_t kChanceKeyDomain = 0x434c'4f4e'4754'4150ull;
constexpr std::uint32_t kRevealDomain = 0x434c'5256u;   // "CLRV"
constexpr std::uint32_t kVisibleDomain = 0x434c'5653u;  // "CLVS"
constexpr std::uint32_t kInitializationDomain = 0x434c'4e4eu;

static_assert(kLevelBonus == 17'000);
static_assert(kCorpusStates == kTrainingStates + kHeldoutStates);
static_assert(kScenarios == kBoardSize && kHorizon == 100);
static_assert(kEventsPerStep > kCellCount + kBoardSize);
static_assert(kInputs == 541 && kActiveInputs == 84);
static_assert(kHidden == 96 && kHeads == 5 && kEpochs == 36);
static_assert(kBalancedSurvivalWeight + kBalancedScoreWeight +
                      kBalancedClearsWeight + kBalancedRevealsWeight ==
                  1.0);
static_assert(kGateTop1Gain == 0.05 && kGatePairwiseGain == 0.03);
static_assert(kGateRegretRatio == 0.90 && kGateAbsoluteTop1 == 0.45 &&
              kGateAbsolutePairwise == 0.65);
static_assert(kRevealDomain != kVisibleDomain);
static_assert((kInputs * kHidden + kHidden + kHeads * kHidden + kHeads) *
                      sizeof(float) <
                  kCheckpointLimitBytes);

constexpr std::array<int, kBoardSize> kActionOrder{{3, 2, 4, 1, 5, 0, 6}};

struct Options {
  std::string states = "/tmp/drop7-oracle-curriculum-states.jsonl";
  std::string output = "/tmp/drop7-curriculum-long-outcome-nnue.json";
  std::string labels =
      "/tmp/drop7-curriculum-long-outcome-labels.jsonl";
  std::string checkpoint =
      "/tmp/drop7-curriculum-long-outcome-nnue.bin";
  int threads = kDefaultThreads;
};

Options parseOptions(int argc, char** argv, int begin) {
  Options result;
  for (int index = begin; index < argc; index += 2) {
    if (index + 1 >= argc) throw std::invalid_argument("missing option value");
    const std::string flag = argv[index];
    if (flag == "--states") {
      result.states = argv[index + 1];
    } else if (flag == "--output") {
      result.output = argv[index + 1];
    } else if (flag == "--labels") {
      result.labels = argv[index + 1];
    } else if (flag == "--checkpoint") {
      result.checkpoint = argv[index + 1];
    } else if (flag == "--threads") {
      result.threads = std::stoi(argv[index + 1]);
      if (result.threads < 1 || result.threads > 16) {
        throw std::invalid_argument("threads must be in [1,16]");
      }
    } else {
      throw std::invalid_argument("unknown option " + flag);
    }
  }
  return result;
}

std::uint64_t peakRssBytes() {
  rusage usage{};
  if (getrusage(RUSAGE_SELF, &usage) != 0) return 0;
#if defined(__APPLE__)
  return static_cast<std::uint64_t>(usage.ru_maxrss);
#else
  return static_cast<std::uint64_t>(usage.ru_maxrss) * 1024ull;
#endif
}

void enforceRssLimit() {
  if (peakRssBytes() > kRssLimitBytes) {
    throw std::runtime_error("curriculum NNUE exceeded 256 MiB RSS cap");
  }
}

struct Deadline {
  Clock::time_point started = Clock::now();

  double elapsedSeconds() const {
    return std::chrono::duration<double>(Clock::now() - started).count();
  }

  void check() const {
    if (elapsedSeconds() > kWallLimitSeconds) {
      throw std::runtime_error("curriculum NNUE exceeded 45 minute wall cap");
    }
  }
};

struct PublicState {
  Board board{};
  std::uint8_t next_disc = 1;
  std::uint8_t moves_remaining = kMovesPerLevel;

  bool operator==(const PublicState&) const = default;
};

State materialize(const PublicState& source) {
  State result;
  result.board = source.board;
  result.next_disc = source.next_disc;
  result.moves_remaining = source.moves_remaining;
  result.score = 0;
  result.level = 1;
  result.moves_played = 0;
  result.game_over = false;
  return result;
}

PublicState publicState(const State& source) {
  if (source.game_over || source.next_disc < 1 ||
      source.next_disc > kBoardSize || source.moves_remaining < 1 ||
      source.moves_remaining > kMovesPerLevel) {
    throw std::invalid_argument("invalid public restart state");
  }
  for (const std::uint8_t cell : source.board) {
    if (cell > kCracked) throw std::invalid_argument("invalid board token");
  }
  return {source.board, source.next_disc,
          static_cast<std::uint8_t>(source.moves_remaining)};
}

PublicState mirror(const PublicState& source) {
  PublicState result = source;
  result.board = detail::mirrorBoard(source.board);
  return result;
}

PublicState canonicalState(const PublicState& source, bool& mirrored) {
  mirrored = detail::mirroredRepresentationIsSmaller(source.board);
  return mirrored ? mirror(source) : source;
}

struct StateAction {
  PublicState state{};
  int action = -1;
};

StateAction canonicalStateAction(const PublicState& source, int action) {
  if (action < 0 || action >= kBoardSize) {
    throw std::invalid_argument("invalid state-action column");
  }
  const PublicState reflected = mirror(source);
  const int reflected_action = kBoardSize - 1 - action;
  if (reflected.board < source.board ||
      (reflected.board == source.board && reflected_action < action)) {
    return {reflected, reflected_action};
  }
  return {source, action};
}

std::string publicKey(const PublicState& source) {
  bool ignored = false;
  const PublicState state = canonicalState(source, ignored);
  std::string result;
  result.reserve(kCellCount + 2);
  for (const std::uint8_t cell : state.board) {
    result.push_back(static_cast<char>(cell));
  }
  result.push_back(static_cast<char>(state.next_disc));
  result.push_back(static_cast<char>(state.moves_remaining));
  return result;
}

std::uint64_t mix64(std::uint64_t value) {
  value ^= value >> 30u;
  value *= 0xbf58'476d'1ce4'e5b9ull;
  value ^= value >> 27u;
  value *= 0x94d0'49bb'1331'11ebull;
  return value ^ (value >> 31u);
}

std::uint64_t publicHash(const PublicState& source) {
  bool ignored = false;
  const PublicState state = canonicalState(source, ignored);
  std::uint64_t hash = 0xcbf2'9ce4'8422'2325ull;
  for (const std::uint8_t cell : state.board) {
    hash ^= static_cast<std::uint64_t>(cell + 1u);
    hash *= 0x0000'0100'0000'01b3ull;
  }
  hash ^= state.next_disc;
  hash *= 0x0000'0100'0000'01b3ull;
  hash ^= static_cast<std::uint64_t>(state.moves_remaining + 1u);
  return mix64(hash);
}

std::uint32_t publicChanceKey(const PublicState& source) {
  const std::uint64_t value = mix64(publicHash(source) ^ kChanceKeyDomain);
  return mix32(static_cast<std::uint32_t>(value) ^
               static_cast<std::uint32_t>(value >> 32u));
}

constexpr std::array<std::uint32_t, 64> kSha256Constants{{
    0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u,
    0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u,
    0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u,
    0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u,
    0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu,
    0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
    0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u,
    0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u,
    0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u,
    0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u,
    0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u,
    0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
    0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u,
    0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u,
    0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u,
    0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u,
}};

std::string sha256(std::string_view source) {
  std::vector<std::uint8_t> message(source.begin(), source.end());
  const std::uint64_t bit_length =
      static_cast<std::uint64_t>(message.size()) * 8u;
  message.push_back(0x80u);
  while (message.size() % 64 != 56) message.push_back(0u);
  for (int byte = 7; byte >= 0; --byte) {
    message.push_back(
        static_cast<std::uint8_t>((bit_length >> (byte * 8)) & 0xffu));
  }
  std::array<std::uint32_t, 8> hash{{
      0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au,
      0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u,
  }};
  for (std::size_t offset = 0; offset < message.size(); offset += 64) {
    std::array<std::uint32_t, 64> words{};
    for (int word = 0; word < 16; ++word) {
      const std::size_t begin = offset + static_cast<std::size_t>(word * 4);
      words[word] = (static_cast<std::uint32_t>(message[begin]) << 24) |
                    (static_cast<std::uint32_t>(message[begin + 1]) << 16) |
                    (static_cast<std::uint32_t>(message[begin + 2]) << 8) |
                    static_cast<std::uint32_t>(message[begin + 3]);
    }
    for (int word = 16; word < 64; ++word) {
      const std::uint32_t s0 = std::rotr(words[word - 15], 7) ^
                               std::rotr(words[word - 15], 18) ^
                               (words[word - 15] >> 3);
      const std::uint32_t s1 = std::rotr(words[word - 2], 17) ^
                               std::rotr(words[word - 2], 19) ^
                               (words[word - 2] >> 10);
      words[word] = words[word - 16] + s0 + words[word - 7] + s1;
    }
    std::uint32_t a = hash[0];
    std::uint32_t b = hash[1];
    std::uint32_t c = hash[2];
    std::uint32_t d = hash[3];
    std::uint32_t e = hash[4];
    std::uint32_t f = hash[5];
    std::uint32_t g = hash[6];
    std::uint32_t h = hash[7];
    for (int round = 0; round < 64; ++round) {
      const std::uint32_t upper =
          std::rotr(e, 6) ^ std::rotr(e, 11) ^ std::rotr(e, 25);
      const std::uint32_t choose = (e & f) ^ (~e & g);
      const std::uint32_t first =
          h + upper + choose + kSha256Constants[round] + words[round];
      const std::uint32_t lower =
          std::rotr(a, 2) ^ std::rotr(a, 13) ^ std::rotr(a, 22);
      const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
      const std::uint32_t second = lower + majority;
      h = g;
      g = f;
      f = e;
      e = d + first;
      d = c;
      c = b;
      b = a;
      a = first + second;
    }
    hash[0] += a;
    hash[1] += b;
    hash[2] += c;
    hash[3] += d;
    hash[4] += e;
    hash[5] += f;
    hash[6] += g;
    hash[7] += h;
  }
  constexpr char digits[] = "0123456789abcdef";
  std::string result;
  result.reserve(64);
  for (const std::uint32_t value : hash) {
    for (int nibble = 7; nibble >= 0; --nibble) {
      result.push_back(digits[(value >> (nibble * 4)) & 0x0fu]);
    }
  }
  return result;
}

std::string readWholeFile(const std::string& path) {
  std::ifstream input(path, std::ios::binary | std::ios::ate);
  if (!input) throw std::runtime_error("could not read curriculum artifact");
  const std::streampos end = input.tellg();
  if (end < 0) throw std::runtime_error("invalid curriculum artifact size");
  std::string result(static_cast<std::size_t>(end), '\0');
  input.seekg(0);
  input.read(result.data(), static_cast<std::streamsize>(result.size()));
  if (!input) throw std::runtime_error("curriculum artifact read failed");
  return result;
}

std::size_t valueOffset(const std::string& line, std::string_view key) {
  const std::string needle = "\"" + std::string(key) + "\":";
  const std::size_t found = line.find(needle);
  if (found == std::string::npos) {
    throw std::runtime_error("curriculum field missing: " +
                             std::string(key));
  }
  return found + needle.size();
}

std::string stringField(const std::string& line, std::string_view key) {
  std::size_t begin = valueOffset(line, key);
  if (begin >= line.size() || line[begin] != '"') {
    throw std::runtime_error("curriculum string field malformed");
  }
  ++begin;
  const std::size_t end = line.find('"', begin);
  if (end == std::string::npos) {
    throw std::runtime_error("curriculum string field unterminated");
  }
  return line.substr(begin, end - begin);
}

double numberField(const std::string& line, std::string_view key) {
  const std::size_t begin = valueOffset(line, key);
  char* end = nullptr;
  const double value = std::strtod(line.c_str() + begin, &end);
  if (end == line.c_str() + begin || !std::isfinite(value)) {
    throw std::runtime_error("curriculum numeric field malformed");
  }
  return value;
}

int integerField(const std::string& line, std::string_view key) {
  const double value = numberField(line, key);
  if (value != std::floor(value) || value < std::numeric_limits<int>::min() ||
      value > std::numeric_limits<int>::max()) {
    throw std::runtime_error("curriculum integer field malformed");
  }
  return static_cast<int>(value);
}

std::uint64_t parseHex64(std::string_view source) {
  if (source.size() != 18 || source.substr(0, 2) != "0x") {
    throw std::runtime_error("curriculum public hash malformed");
  }
  std::uint64_t result = 0;
  for (const char digit : source.substr(2)) {
    result <<= 4u;
    if (digit >= '0' && digit <= '9') result |= digit - '0';
    else if (digit >= 'a' && digit <= 'f') result |= digit - 'a' + 10;
    else throw std::runtime_error("curriculum public hash digit malformed");
  }
  return result;
}

enum class FlowBand : std::uint8_t {
  kBlocked,
  kClosed,
  kRecovering,
  kFlowing,
};

struct FlowAggregates {
  int occupancy = 0;
  int maximum_height = 0;
  int covers = 0;
  int legal_columns = 0;
  double mean_moves = 0.0;
  double survival = 0.0;
  double mean_score = 0.0;
  double clears_per_move = 0.0;
  double reveals_per_move = 0.0;
  double mean_maximum_chain = 0.0;
  FlowBand band = FlowBand::kBlocked;
};

struct CorpusRecord {
  PublicState state{};
  std::uint64_t public_hash = 0;
  FlowAggregates flow{};
  int hash_half = 0;
};

FlowBand parseFlowBand(const std::string& value) {
  if (value == "blocked") return FlowBand::kBlocked;
  if (value == "closed") return FlowBand::kClosed;
  if (value == "recovering") return FlowBand::kRecovering;
  if (value == "flowing") return FlowBand::kFlowing;
  throw std::runtime_error("unknown curriculum flow band");
}

CorpusRecord parseRecord(const std::string& line) {
  if (stringField(line, "format") != "drop7-public-restart-v1" ||
      line.find("\"independentRestartValidated\":true") ==
          std::string::npos ||
      line.find("\"sourceSeed\"") != std::string::npos ||
      line.find("\"futureTape\"") != std::string::npos ||
      line.find("\"history\"") != std::string::npos) {
    throw std::runtime_error("curriculum public-boundary schema mismatch");
  }
  CorpusRecord result;
  const std::string board = stringField(line, "board");
  if (board.size() != kCellCount) {
    throw std::runtime_error("curriculum board length mismatch");
  }
  for (int index = 0; index < kCellCount; ++index) {
    if (board[index] < '0' || board[index] > '9') {
      throw std::runtime_error("curriculum board token malformed");
    }
    result.state.board[index] =
        static_cast<std::uint8_t>(board[index] - '0');
    if (result.state.board[index] > kCracked) {
      throw std::runtime_error("curriculum board token out of range");
    }
  }
  result.state.next_disc =
      static_cast<std::uint8_t>(integerField(line, "nextDisc"));
  result.state.moves_remaining =
      static_cast<std::uint8_t>(integerField(line, "movesRemaining"));
  if (result.state.next_disc < 1 || result.state.next_disc > kBoardSize ||
      result.state.moves_remaining < 1 ||
      result.state.moves_remaining > kMovesPerLevel) {
    throw std::runtime_error("curriculum visible state out of range");
  }
  result.public_hash = parseHex64(stringField(line, "publicHash"));
  if (result.public_hash != publicHash(result.state) ||
      detail::mirroredRepresentationIsSmaller(result.state.board)) {
    throw std::runtime_error("curriculum hash/canonicalization mismatch");
  }
  result.flow.occupancy = integerField(line, "occupancy");
  result.flow.maximum_height = integerField(line, "maximumHeight");
  result.flow.covers = integerField(line, "covers");
  result.flow.legal_columns = integerField(line, "legalColumns");
  result.flow.mean_moves = numberField(line, "meanMoves");
  result.flow.survival = numberField(line, "survivalRate");
  result.flow.mean_score = numberField(line, "meanScoreDelta");
  result.flow.clears_per_move = numberField(line, "clearsPerMove");
  result.flow.reveals_per_move = numberField(line, "revealsPerMove");
  result.flow.mean_maximum_chain = numberField(line, "meanMaximumChain");
  result.flow.band = parseFlowBand(stringField(line, "flowBand"));
  result.hash_half = static_cast<int>(
      mix64(result.public_hash ^ kHashHalfDomain) & 1ull);

  const auto heights = detail::columnHeights(result.state.board);
  const int occupied = static_cast<int>(std::count_if(
      result.state.board.begin(), result.state.board.end(),
      [](std::uint8_t cell) { return cell != kEmpty; }));
  const int covers = static_cast<int>(std::count_if(
      result.state.board.begin(), result.state.board.end(),
      [](std::uint8_t cell) { return cell == kSolid || cell == kCracked; }));
  int legal_count = 0;
  legalColumns(result.state.board, legal_count);
  if (result.flow.occupancy != occupied || result.flow.covers != covers ||
      result.flow.maximum_height !=
          *std::max_element(heights.begin(), heights.end()) ||
      result.flow.legal_columns != legal_count || legal_count < 1 ||
      result.flow.mean_moves < 0.0 || result.flow.mean_moves > 25.0 ||
      result.flow.survival < 0.0 || result.flow.survival > 1.0 ||
      result.flow.mean_score < 0.0 || result.flow.clears_per_move < 0.0 ||
      result.flow.reveals_per_move < 0.0 ||
      result.flow.mean_maximum_chain < 0.0) {
    throw std::runtime_error("curriculum flow/shape aggregate mismatch");
  }
  const FlowBand expected =
      result.flow.survival < 0.25
          ? FlowBand::kBlocked
          : (result.flow.reveals_per_move < 0.25
                 ? FlowBand::kClosed
                 : (result.flow.reveals_per_move < 0.60
                        ? FlowBand::kRecovering
                        : FlowBand::kFlowing));
  if (result.flow.band != expected) {
    throw std::runtime_error("curriculum flow band mismatch");
  }
  return result;
}

struct Corpus {
  std::vector<CorpusRecord> training;
  std::vector<CorpusRecord> heldout;
  std::array<int, 2> training_halves{};
  std::array<int, 2> heldout_halves{};
};

Corpus loadCorpus(const std::string& path) {
  std::error_code error;
  if (std::filesystem::file_size(path, error) != kExpectedCorpusBytes ||
      error) {
    throw std::runtime_error("curriculum byte-count checksum mismatch");
  }
  const std::string source = readWholeFile(path);
  if (sha256(source) != kExpectedCorpusSha256) {
    throw std::runtime_error("curriculum SHA-256 checksum mismatch");
  }
  std::vector<CorpusRecord> records;
  records.reserve(kCorpusStates);
  std::unordered_set<std::string> unique;
  std::istringstream input(source);
  std::string line;
  while (std::getline(input, line)) {
    if (line.empty()) continue;
    CorpusRecord record = parseRecord(line);
    if (!unique.insert(publicKey(record.state)).second) {
      throw std::runtime_error("curriculum duplicate public state");
    }
    records.push_back(std::move(record));
  }
  if (records.size() != kCorpusStates || unique.size() != kCorpusStates) {
    throw std::runtime_error("curriculum record-count mismatch");
  }
  std::sort(records.begin(), records.end(),
            [](const CorpusRecord& first, const CorpusRecord& second) {
              const std::uint64_t first_rank =
                  mix64(first.public_hash ^ kSplitDomain);
              const std::uint64_t second_rank =
                  mix64(second.public_hash ^ kSplitDomain);
              if (first_rank != second_rank) return first_rank < second_rank;
              return first.public_hash < second.public_hash;
            });
  Corpus result;
  result.training.reserve(kTrainingStates);
  result.heldout.reserve(kHeldoutStates);
  for (int index = 0; index < kCorpusStates; ++index) {
    CorpusRecord record = std::move(records[index]);
    if (index < kTrainingStates) {
      ++result.training_halves[record.hash_half];
      result.training.push_back(std::move(record));
    } else {
      ++result.heldout_halves[record.hash_half];
      result.heldout.push_back(std::move(record));
    }
  }
  if (result.training.size() != kTrainingStates ||
      result.heldout.size() != kHeldoutStates ||
      result.training_halves[0] == 0 || result.training_halves[1] == 0 ||
      result.heldout_halves[0] == 0 || result.heldout_halves[1] == 0) {
    throw std::runtime_error("curriculum deterministic hash split failed");
  }
  return result;
}

struct PublicRandom {
  std::uint32_t key = 0;
  int scenario = 0;
  int step = 0;
  int event = 0;

  std::uint8_t nextDisc() {
    if (event >= kEventsPerStep) {
      throw std::runtime_error("public chance event slice exhausted");
    }
    const int event_index = step * kEventsPerStep + event++;
    const double unit = detail::stratifiedUnit(
        key, scenario, kScenarios, kRevealDomain, event_index);
    return static_cast<std::uint8_t>(
        std::floor(unit * static_cast<double>(kBoardSize)) + 1.0);
  }
};

std::uint8_t visibleDisc(std::uint32_t key, int scenario, int step) {
  const double unit = detail::stratifiedUnit(
      key, scenario, kScenarios, kVisibleDomain, step);
  return static_cast<std::uint8_t>(
      std::floor(unit * static_cast<double>(kBoardSize)) + 1.0);
}

struct PublicTransition {
  PublicState state{};
  Board terminal_board{};
  bool terminal = false;
  std::int64_t score = 0;
  int clears = 0;
  int reveals = 0;
  int waves = 0;
  int maximum_chain = 0;

  bool operator==(const PublicTransition&) const = default;
};

PublicTransition playPublicMove(const PublicState& source, int action,
                                std::uint32_t root_key, int scenario,
                                int step) {
  if (scenario < 0 || scenario >= kScenarios || step < 0 ||
      step >= kHorizon) {
    throw std::invalid_argument("invalid public chance coordinate");
  }
  const StateAction canonical = canonicalStateAction(source, action);
  if (!isLegal(canonical.state.board, canonical.action)) {
    throw std::invalid_argument("public chance action is illegal");
  }
  State state = materialize(canonical.state);
  PublicRandom random{root_key, scenario, step, 0};
  MoveResult move;
  if (!detail::playMoveSampled(state, canonical.action, random, move)) {
    throw std::runtime_error("public chance transition failed");
  }
  PublicTransition result;
  result.terminal = move.state.game_over;
  result.terminal_board = move.state.board;
  result.score = move.score_delta;
  result.waves = static_cast<int>(move.waves.size());
  for (const Wave& wave : move.waves) {
    result.clears += wave.cleared;
    result.reveals += wave.revealed;
    result.maximum_chain = std::max(result.maximum_chain, wave.depth);
  }
  if (!result.terminal) {
    move.state.score = 0;
    move.state.level = 1;
    move.state.moves_played = 0;
    move.state.next_disc = visibleDisc(root_key, scenario, step);
    bool ignored = false;
    result.state = canonicalState(publicState(move.state), ignored);
  }
  return result;
}

using PublicMove = PublicTransition (*)(const PublicState&, int,
                                        std::uint32_t, int, int);
static_assert(std::is_same_v<decltype(&playPublicMove), PublicMove>);
static_assert(!std::is_invocable_v<PublicMove, const State&, int,
                                   std::uint32_t, int, int>);

struct D1Evaluation {
  int action = -1;
  std::array<double, kBoardSize> values{};
  std::uint64_t work = 0;
  std::uint64_t nodes = 0;
};

D1Evaluation evaluateD1(const PublicState& source) {
  bool mirrored = false;
  const PublicState canonical = canonicalState(source, mirrored);
  fair::SearchContext context;
  const fair::RootEvaluation root =
      fair::rootDecision(materialize(canonical), 1, context);
  int legal = 0;
  int finite = 0;
  for (int action = 0; action < kBoardSize; ++action) {
    legal += isLegal(canonical.board, action);
    finite += std::isfinite(root.values[action]);
  }
  if (root.action < 0 || legal != finite || context.work > 70 ||
      !context.cache.empty()) {
    throw std::runtime_error("exact public fair-D1 failed");
  }
  D1Evaluation result;
  result.action = mirrored ? kBoardSize - 1 - root.action : root.action;
  result.values.fill(-std::numeric_limits<double>::infinity());
  for (int action = 0; action < kBoardSize; ++action) {
    const int source_action = mirrored ? kBoardSize - 1 - action : action;
    result.values[source_action] = root.values[action];
  }
  result.work = context.work;
  result.nodes = context.nodes;
  return result;
}

struct Shape {
  int occupancy = 0;
  int maximum_height = 0;
  int covers = 0;
  int legal_columns = 0;
};

Shape shape(const Board& board) {
  Shape result;
  const auto heights = detail::columnHeights(board);
  result.maximum_height = *std::max_element(heights.begin(), heights.end());
  for (const std::uint8_t cell : board) {
    result.occupancy += cell != kEmpty;
    result.covers += cell == kSolid || cell == kCracked;
  }
  legalColumns(board, result.legal_columns);
  return result;
}

detail::PhaseFeatures boardCertificate(const Board& board,
                                       std::uint8_t next_disc,
                                       std::uint8_t moves_remaining) {
  PublicState state{board, next_disc, moves_remaining};
  return detail::extractPhaseFeatures(materialize(state));
}

double clogDebt(const detail::PhaseFeatures& value) {
  return 8.0 * value.adjacent_ones + 12.0 * value.triple_twos +
         4.0 * value.dead_low_numbers;
}

struct ScenarioLabel {
  int moves = 0;
  std::int64_t score = 0;
  int clears = 0;
  int reveals = 0;
  int maximum_chain = 0;
  bool survived = false;

  bool operator==(const ScenarioLabel&) const = default;
};

struct ActionCertificate {
  double terminal_rate = 0.0;
  double mean_score = 0.0;
  double mean_clears = 0.0;
  double mean_reveals = 0.0;
  double mean_waves = 0.0;
  double mean_maximum_chain = 0.0;
  double post_occupancy = 0.0;
  double post_height = 0.0;
  double post_covers = 0.0;
  double post_legal_columns = 0.0;
  double direct_delta = 0.0;
  double latent_delta = 0.0;
  double clog_improvement = 0.0;

  bool operator==(const ActionCertificate&) const = default;
};

struct ActionLabel {
  bool legal = false;
  std::array<ScenarioLabel, kScenarios> scenarios{};
  ActionCertificate certificate{};
  double survival = 0.0;
  double mean_score = 0.0;
  double mean_clears = 0.0;
  double mean_reveals = 0.0;
  double balanced_return = 0.0;
  double normalized_target = 0.0;
  double normalized_d1_q = 0.0;
  std::array<double, kHeads> targets{};
};

struct RootLabel {
  CorpusRecord record{};
  std::array<ActionLabel, kBoardSize> actions{};
  int labeled_action = -1;
  int d1_action = -1;
  std::uint64_t transitions = 0;
  std::uint64_t d1_calls = 0;
  std::uint64_t d1_work = 0;
  std::uint64_t d1_nodes = 0;
  double seconds = 0.0;
};

double positiveTanh(double value, double scale) {
  return std::tanh(std::max(0.0, value) / scale);
}

double balancedReturn(double survival, double score, double clears,
                      double reveals) {
  return kBalancedSurvivalWeight * survival +
         kBalancedScoreWeight * positiveTanh(score, kScoreScale) +
         kBalancedClearsWeight * positiveTanh(clears, kClearsScale) +
         kBalancedRevealsWeight * positiveTanh(reveals, kRevealsScale);
}

void observeCertificate(const PublicState& root,
                        const detail::PhaseFeatures& before,
                        const PublicTransition& transition,
                        ActionCertificate& certificate) {
  const Board& post_board = transition.terminal ? transition.terminal_board
                                                 : transition.state.board;
  const std::uint8_t post_next =
      transition.terminal ? root.next_disc : transition.state.next_disc;
  const std::uint8_t post_phase =
      transition.terminal ? std::uint8_t{1}
                          : transition.state.moves_remaining;
  const Shape post_shape = shape(post_board);
  const detail::PhaseFeatures after =
      boardCertificate(post_board, post_next, post_phase);
  constexpr double inverse = 1.0 / static_cast<double>(kScenarios);
  certificate.terminal_rate += transition.terminal ? inverse : 0.0;
  certificate.mean_score += transition.score * inverse;
  certificate.mean_clears += transition.clears * inverse;
  certificate.mean_reveals += transition.reveals * inverse;
  certificate.mean_waves += transition.waves * inverse;
  certificate.mean_maximum_chain += transition.maximum_chain * inverse;
  certificate.post_occupancy += post_shape.occupancy * inverse;
  certificate.post_height += post_shape.maximum_height * inverse;
  certificate.post_covers += post_shape.covers * inverse;
  certificate.post_legal_columns += post_shape.legal_columns * inverse;
  certificate.direct_delta +=
      (after.direct_potential - before.direct_potential) * inverse;
  certificate.latent_delta +=
      (after.latent_chain_potential - before.latent_chain_potential) *
      inverse;
  certificate.clog_improvement +=
      (clogDebt(before) - clogDebt(after)) * inverse;
}

RootLabel labelRoot(const CorpusRecord& source, const Deadline& deadline) {
  const auto started = Clock::now();
  RootLabel result;
  result.record = source;
  bool ignored = false;
  result.record.state = canonicalState(source.state, ignored);
  const std::uint32_t root_key = publicChanceKey(result.record.state);
  const D1Evaluation root_d1 = evaluateD1(result.record.state);
  result.d1_action = root_d1.action;
  result.d1_work += root_d1.work;
  result.d1_nodes += root_d1.nodes;
  ++result.d1_calls;
  const detail::PhaseFeatures before =
      detail::extractPhaseFeatures(materialize(result.record.state));

  for (const int forced_action : kActionOrder) {
    if (!isLegal(result.record.state.board, forced_action)) continue;
    ActionLabel& action = result.actions[forced_action];
    action.legal = true;
    for (int scenario = 0; scenario < kScenarios; ++scenario) {
      PublicState state = result.record.state;
      ScenarioLabel& outcome = action.scenarios[scenario];
      bool terminal = false;
      for (int step = 0; step < kHorizon; ++step) {
        deadline.check();
        int selected = forced_action;
        if (step > 0) {
          const D1Evaluation continuation = evaluateD1(state);
          selected = continuation.action;
          result.d1_work += continuation.work;
          result.d1_nodes += continuation.nodes;
          ++result.d1_calls;
        }
        if (!isLegal(state.board, selected)) {
          throw std::runtime_error("fair-D1 continuation selected illegal action");
        }
        const PublicTransition transition =
            playPublicMove(state, selected, root_key, scenario, step);
        ++result.transitions;
        ++outcome.moves;
        outcome.score += transition.score;
        outcome.clears += transition.clears;
        outcome.reveals += transition.reveals;
        outcome.maximum_chain =
            std::max(outcome.maximum_chain, transition.maximum_chain);
        if (step == 0) {
          observeCertificate(result.record.state, before, transition,
                             action.certificate);
        }
        if (transition.terminal) {
          terminal = true;
          break;
        }
        state = transition.state;
      }
      outcome.survived = !terminal && outcome.moves == kHorizon;
      constexpr double inverse = 1.0 / static_cast<double>(kScenarios);
      action.survival += outcome.survived ? inverse : 0.0;
      action.mean_score += outcome.score * inverse;
      action.mean_clears += outcome.clears * inverse;
      action.mean_reveals += outcome.reveals * inverse;
    }
    action.balanced_return =
        balancedReturn(action.survival, action.mean_score,
                       action.mean_clears, action.mean_reveals);
  }

  double target_minimum = std::numeric_limits<double>::infinity();
  double target_maximum = -std::numeric_limits<double>::infinity();
  double d1_minimum = std::numeric_limits<double>::infinity();
  double d1_maximum = -std::numeric_limits<double>::infinity();
  for (int action = 0; action < kBoardSize; ++action) {
    if (!result.actions[action].legal) continue;
    target_minimum =
        std::min(target_minimum, result.actions[action].balanced_return);
    target_maximum =
        std::max(target_maximum, result.actions[action].balanced_return);
    d1_minimum = std::min(d1_minimum, root_d1.values[action]);
    d1_maximum = std::max(d1_maximum, root_d1.values[action]);
  }
  const double target_range = target_maximum - target_minimum;
  const double d1_range = d1_maximum - d1_minimum;
  double best = -std::numeric_limits<double>::infinity();
  for (const int action_index : kActionOrder) {
    ActionLabel& action = result.actions[action_index];
    if (!action.legal) continue;
    action.normalized_target =
        target_range > 1.0e-12
            ? (action.balanced_return - target_minimum) / target_range
            : 0.5;
    action.normalized_d1_q =
        d1_range > 1.0e-12
            ? (root_d1.values[action_index] - d1_minimum) / d1_range
            : 0.5;
    action.targets = {{
        action.normalized_target - action.normalized_d1_q,
        action.survival,
        positiveTanh(action.mean_score, kScoreScale),
        positiveTanh(action.mean_clears, kClearsScale),
        positiveTanh(action.mean_reveals, kRevealsScale),
    }};
    if (action.balanced_return > best) {
      best = action.balanced_return;
      result.labeled_action = action_index;
    }
  }
  if (result.labeled_action < 0 || result.d1_action < 0 ||
      result.transitions >
          static_cast<std::uint64_t>(kBoardSize * kScenarios * kHorizon) ||
      result.d1_work > result.d1_calls * 70) {
    throw std::runtime_error("long-outcome root labeling invariant failed");
  }
  result.seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

struct LabelCost {
  std::uint64_t roots = 0;
  std::uint64_t legal_actions = 0;
  std::uint64_t transitions = 0;
  std::uint64_t d1_calls = 0;
  std::uint64_t d1_work = 0;
  std::uint64_t d1_nodes = 0;
  double aggregate_root_seconds = 0.0;
  double wall_seconds = 0.0;
};

struct LabeledRange {
  std::vector<RootLabel> roots;
  LabelCost cost{};
};

void addRootCost(const RootLabel& root, LabelCost& cost) {
  ++cost.roots;
  for (const ActionLabel& action : root.actions) {
    cost.legal_actions += action.legal;
  }
  cost.transitions += root.transitions;
  cost.d1_calls += root.d1_calls;
  cost.d1_work += root.d1_work;
  cost.d1_nodes += root.d1_nodes;
  cost.aggregate_root_seconds += root.seconds;
}

LabeledRange labelRange(const std::vector<CorpusRecord>& source,
                        std::string_view name, int threads,
                        const Deadline& deadline) {
  const auto started = Clock::now();
  LabeledRange result;
  result.roots.resize(source.size());
  std::atomic<std::size_t> next{0};
  std::atomic<std::size_t> completed{0};
  std::mutex exception_mutex;
  std::exception_ptr exception;
  std::vector<std::future<void>> workers;
  const int worker_count =
      std::max(1, std::min(threads, static_cast<int>(source.size())));
  workers.reserve(static_cast<std::size_t>(worker_count));
  for (int worker = 0; worker < worker_count; ++worker) {
    workers.push_back(std::async(std::launch::async, [&] {
      try {
        for (;;) {
          const std::size_t index = next.fetch_add(1);
          if (index >= source.size()) return;
          result.roots[index] = labelRoot(source[index], deadline);
          if ((index & 31u) == 0u) enforceRssLimit();
          const std::size_t count = completed.fetch_add(1) + 1;
          if (count % 64 == 0 || count == source.size()) {
            static std::mutex progress_mutex;
            const std::lock_guard<std::mutex> lock(progress_mutex);
            std::cerr << "curriculum-long-label " << name << ' ' << count
                      << '/' << source.size() << '\n';
          }
        }
      } catch (...) {
        const std::lock_guard<std::mutex> lock(exception_mutex);
        if (exception == nullptr) exception = std::current_exception();
        next.store(source.size());
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  if (exception != nullptr) std::rethrow_exception(exception);
  for (const RootLabel& root : result.roots) addRootCost(root, result.cost);
  result.cost.wall_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  if (result.cost.roots != source.size() ||
      result.cost.legal_actions < result.cost.roots ||
      result.cost.d1_work > result.cost.d1_calls * 70) {
    throw std::runtime_error("long-outcome labeled range incomplete");
  }
  deadline.check();
  enforceRssLimit();
  return result;
}

struct SparseFeature {
  std::uint16_t index = 0;
  float value = 0.0f;

  bool operator==(const SparseFeature&) const = default;
};

struct SparseInput {
  std::array<SparseFeature, kActiveInputs> values{};
  int count = 0;

  void add(int index, double value) {
    if (index < 0 || index >= kInputs || count >= kActiveInputs ||
        !std::isfinite(value)) {
      throw std::runtime_error("sparse NNUE input invariant failed");
    }
    values[count++] = {static_cast<std::uint16_t>(index),
                       static_cast<float>(value)};
  }

  bool operator==(const SparseInput&) const = default;
};

double signedTanh(double value, double scale) {
  return std::tanh(value / scale);
}

SparseInput makeInput(const CorpusRecord& record,
                      const ActionLabel& action_label, int action) {
  const StateAction tuple = canonicalStateAction(record.state, action);
  SparseInput result;
  for (int cell = 0; cell < kCellCount; ++cell) {
    const int token = tuple.state.board[cell];
    if (token < 0 || token >= kBoardTokens) {
      throw std::runtime_error("NNUE board token out of range");
    }
    result.add(cell * kBoardTokens + token, 1.0);
  }
  constexpr int next_base = kBoardInputs;
  constexpr int phase_base = next_base + kNextInputs;
  constexpr int action_base = phase_base + kPhaseInputs;
  constexpr int aggregate_base = action_base + kActionInputs;
  result.add(next_base + tuple.state.next_disc - 1, 1.0);
  result.add(phase_base + tuple.state.moves_remaining - 1, 1.0);
  result.add(action_base + tuple.action, 1.0);

  const detail::PhaseFeatures certificate =
      detail::extractPhaseFeatures(materialize(tuple.state));
  const FlowAggregates& flow = record.flow;
  const ActionCertificate& action_value = action_label.certificate;
  const std::array<double, kAggregateInputs> aggregates{{
      flow.mean_moves / 25.0,
      flow.survival,
      positiveTanh(flow.mean_score, 90'000.0),
      positiveTanh(flow.clears_per_move, 3.0),
      positiveTanh(flow.reveals_per_move, 2.0),
      std::tanh(flow.mean_maximum_chain / 10.0),
      static_cast<double>(flow.band) / 3.0,
      static_cast<double>(flow.occupancy) / kCellCount,
      static_cast<double>(flow.maximum_height) / kBoardSize,
      static_cast<double>(flow.covers) / kCellCount,
      static_cast<double>(flow.legal_columns) / kBoardSize,
      std::tanh(certificate.direct_potential / 12.0),
      std::tanh(certificate.latent_chain_potential / 12.0),
      std::tanh(certificate.cracked_exposure / 12.0),
      std::tanh(certificate.solid_exposure / 12.0),
      std::tanh(certificate.adjacent_ones / 6.0),
      std::tanh(certificate.triple_twos / 6.0),
      std::tanh(certificate.dead_low_numbers / 12.0),
      std::tanh(certificate.quiet_build_options / 7.0),
      std::tanh(certificate.trigger_readiness / 12.0),
      action_value.terminal_rate,
      positiveTanh(action_value.mean_score, 30'000.0),
      positiveTanh(action_value.mean_clears, 12.0),
      positiveTanh(action_value.mean_reveals, 8.0),
      positiveTanh(action_value.mean_waves, 8.0),
      positiveTanh(action_value.mean_maximum_chain, 8.0),
      action_value.post_occupancy / kCellCount,
      action_value.post_height / kBoardSize,
      action_value.post_covers / kCellCount,
      signedTanh(action_value.direct_delta, 6.0),
      signedTanh(action_value.latent_delta, 6.0),
      signedTanh(action_value.clog_improvement, 6.0),
  }};
  for (int index = 0; index < kAggregateInputs; ++index) {
    result.add(aggregate_base + index, aggregates[index]);
  }
  if (result.count != kActiveInputs) {
    throw std::runtime_error("sparse NNUE active-input count changed");
  }
  return result;
}

struct PreparedAction {
  bool legal = false;
  SparseInput input{};
};

struct PreparedRoot {
  const RootLabel* label = nullptr;
  std::array<PreparedAction, kBoardSize> actions{};
};

std::vector<PreparedRoot> prepare(const std::vector<RootLabel>& labels) {
  std::vector<PreparedRoot> result(labels.size());
  for (std::size_t root = 0; root < labels.size(); ++root) {
    result[root].label = &labels[root];
    for (int action = 0; action < kBoardSize; ++action) {
      if (!labels[root].actions[action].legal) continue;
      result[root].actions[action].legal = true;
      result[root].actions[action].input = makeInput(
          labels[root].record, labels[root].actions[action], action);
    }
  }
  return result;
}

struct Network {
  std::vector<float> input_weights;
  std::array<float, kHidden> hidden_bias{};
  std::array<float, kHeads * kHidden> head_weights{};
  std::array<float, kHeads> head_bias{};

  Network()
      : input_weights(static_cast<std::size_t>(kInputs * kHidden)) {}
};

double randomSigned(std::uint32_t& state) {
  state = mix32(state + 0x9e37'79b9u);
  const double unit =
      static_cast<double>(state >> 8u) / static_cast<double>(1u << 24u);
  return 2.0 * unit - 1.0;
}

Network initializeNetwork() {
  Network result;
  std::uint32_t random = kInitializationDomain;
  for (float& value : result.input_weights) {
    value = static_cast<float>(0.025 * randomSigned(random));
  }
  result.hidden_bias.fill(0.025f);
  for (float& value : result.head_weights) {
    value = static_cast<float>(0.05 * randomSigned(random));
  }
  return result;
}

struct Forward {
  std::array<double, kHidden> z{};
  std::array<double, kHidden> hidden{};
  std::array<double, kHeads> heads{};
};

Forward forward(const Network& network, const SparseInput& input) {
  Forward result;
  for (int hidden = 0; hidden < kHidden; ++hidden) {
    result.z[hidden] = network.hidden_bias[hidden];
  }
  for (int feature = 0; feature < input.count; ++feature) {
    const int index = input.values[feature].index;
    const double value = input.values[feature].value;
    const float* weights =
        network.input_weights.data() + index * kHidden;
    for (int hidden = 0; hidden < kHidden; ++hidden) {
      result.z[hidden] += weights[hidden] * value;
    }
  }
  for (int hidden = 0; hidden < kHidden; ++hidden) {
    result.hidden[hidden] = std::clamp(result.z[hidden], 0.0, 1.0);
  }
  for (int head = 0; head < kHeads; ++head) {
    result.heads[head] = network.head_bias[head];
    for (int hidden = 0; hidden < kHidden; ++hidden) {
      result.heads[head] +=
          network.head_weights[head * kHidden + hidden] *
          result.hidden[hidden];
    }
  }
  return result;
}

struct Gradient {
  std::vector<double> input_weights;
  std::array<double, kHidden> hidden_bias{};
  std::array<double, kHeads * kHidden> head_weights{};
  std::array<double, kHeads> head_bias{};

  Gradient()
      : input_weights(static_cast<std::size_t>(kInputs * kHidden)) {}
};

void backpropagate(const Network& network, const SparseInput& input,
                   const Forward& computed,
                   const std::array<double, kHeads>& head_gradient,
                   Gradient& gradient) {
  std::array<double, kHidden> hidden_gradient{};
  for (int head = 0; head < kHeads; ++head) {
    gradient.head_bias[head] += head_gradient[head];
    for (int hidden = 0; hidden < kHidden; ++hidden) {
      gradient.head_weights[head * kHidden + hidden] +=
          head_gradient[head] * computed.hidden[hidden];
      hidden_gradient[hidden] +=
          head_gradient[head] *
          network.head_weights[head * kHidden + hidden];
    }
  }
  for (int hidden = 0; hidden < kHidden; ++hidden) {
    if (computed.z[hidden] <= 0.0 || computed.z[hidden] >= 1.0) {
      hidden_gradient[hidden] = 0.0;
    }
    gradient.hidden_bias[hidden] += hidden_gradient[hidden];
  }
  for (int feature = 0; feature < input.count; ++feature) {
    const int index = input.values[feature].index;
    const double value = input.values[feature].value;
    double* weights = gradient.input_weights.data() + index * kHidden;
    for (int hidden = 0; hidden < kHidden; ++hidden) {
      weights[hidden] += hidden_gradient[hidden] * value;
    }
  }
}

struct AdamState {
  Gradient first{};
  Gradient second{};
  std::uint64_t step = 0;
};

void updateParameter(float& parameter, double gradient, double& first,
                     double& second, std::uint64_t step,
                     double learning_rate) {
  first = 0.9 * first + 0.1 * gradient;
  second = 0.999 * second + 0.001 * gradient * gradient;
  const double corrected_first = first / (1.0 - std::pow(0.9, step));
  const double corrected_second = second / (1.0 - std::pow(0.999, step));
  parameter -= static_cast<float>(
      learning_rate * corrected_first /
      (std::sqrt(corrected_second) + 1.0e-8));
}

void applyAdam(Network& network, Gradient& gradient, AdamState& adam,
               std::uint64_t rows, std::uint64_t update,
               std::uint64_t total_updates) {
  if (rows == 0 || update < 1 || update > total_updates) {
    throw std::runtime_error("invalid Adam batch");
  }
  ++adam.step;
  const double progress =
      static_cast<double>(update - 1) /
      static_cast<double>(std::max<std::uint64_t>(1, total_updates - 1));
  const double cosine = 0.5 * (1.0 + std::cos(std::numbers::pi * progress));
  const double learning_rate =
      kLearningRate *
      (kFinalLearningRateRatio + (1.0 - kFinalLearningRateRatio) * cosine);
  const double inverse = 1.0 / static_cast<double>(rows);
  for (std::size_t index = 0; index < network.input_weights.size(); ++index) {
    const double value = gradient.input_weights[index] * inverse +
                         kWeightDecay * network.input_weights[index];
    updateParameter(network.input_weights[index], value,
                    adam.first.input_weights[index],
                    adam.second.input_weights[index], adam.step,
                    learning_rate);
  }
  for (int hidden = 0; hidden < kHidden; ++hidden) {
    updateParameter(network.hidden_bias[hidden],
                    gradient.hidden_bias[hidden] * inverse,
                    adam.first.hidden_bias[hidden],
                    adam.second.hidden_bias[hidden], adam.step,
                    learning_rate);
  }
  for (int index = 0; index < kHeads * kHidden; ++index) {
    const double value = gradient.head_weights[index] * inverse +
                         kWeightDecay * network.head_weights[index];
    updateParameter(network.head_weights[index], value,
                    adam.first.head_weights[index],
                    adam.second.head_weights[index], adam.step,
                    learning_rate);
  }
  for (int head = 0; head < kHeads; ++head) {
    updateParameter(network.head_bias[head],
                    gradient.head_bias[head] * inverse,
                    adam.first.head_bias[head],
                    adam.second.head_bias[head], adam.step,
                    learning_rate);
  }
}

void shuffleIndices(std::vector<std::size_t>& values, int epoch) {
  std::uint32_t random =
      mix32(kInitializationDomain ^ static_cast<std::uint32_t>(epoch));
  for (std::size_t end = values.size(); end > 1; --end) {
    random = mix32(random + 0x9e37'79b9u);
    const std::size_t other = random % end;
    std::swap(values[end - 1], values[other]);
  }
}

struct TrainingSummary {
  Network network{};
  std::uint64_t updates = 0;
  std::uint64_t rows = 0;
  std::uint64_t pairs = 0;
  double seconds = 0.0;
};

TrainingSummary train(const std::vector<PreparedRoot>& roots,
                      const Deadline& deadline) {
  if (roots.size() != kTrainingStates) {
    throw std::runtime_error("NNUE training split size changed");
  }
  const auto started = Clock::now();
  TrainingSummary result;
  result.network = initializeNetwork();
  AdamState adam;
  std::vector<std::size_t> order(roots.size());
  std::iota(order.begin(), order.end(), std::size_t{0});
  const std::uint64_t batches_per_epoch =
      (roots.size() + kBatchRoots - 1) / kBatchRoots;
  const std::uint64_t total_updates = kEpochs * batches_per_epoch;
  for (int epoch = 0; epoch < kEpochs; ++epoch) {
    shuffleIndices(order, epoch);
    for (std::size_t begin = 0; begin < roots.size(); begin += kBatchRoots) {
      deadline.check();
      Gradient gradient;
      std::uint64_t rows = 0;
      std::uint64_t pairs = 0;
      const std::size_t end =
          std::min(roots.size(), begin + static_cast<std::size_t>(kBatchRoots));
      for (std::size_t offset = begin; offset < end; ++offset) {
        const PreparedRoot& root = roots[order[offset]];
        std::array<Forward, kBoardSize> computed{};
        std::array<std::array<double, kHeads>, kBoardSize> head_gradient{};
        int legal = 0;
        for (int action = 0; action < kBoardSize; ++action) {
          if (!root.actions[action].legal) continue;
          ++legal;
          ++rows;
          computed[action] =
              forward(result.network, root.actions[action].input);
          const auto& target = root.label->actions[action].targets;
          for (int head = 0; head < kHeads; ++head) {
            head_gradient[action][head] =
                2.0 * kHeadLossWeights[head] *
                (computed[action].heads[head] - target[head]);
          }
        }
        int root_pairs = 0;
        for (int first = 0; first < kBoardSize; ++first) {
          if (!root.actions[first].legal) continue;
          for (int second = first + 1; second < kBoardSize; ++second) {
            if (!root.actions[second].legal) continue;
            const double first_target =
                root.label->actions[first].normalized_target;
            const double second_target =
                root.label->actions[second].normalized_target;
            root_pairs += std::abs(first_target - second_target) > 1.0e-12;
          }
        }
        pairs += root_pairs;
        if (root_pairs > 0) {
          const double pair_scale =
              kPairwiseLossWeight * legal / root_pairs;
          for (int first = 0; first < kBoardSize; ++first) {
            if (!root.actions[first].legal) continue;
            for (int second = first + 1; second < kBoardSize; ++second) {
              if (!root.actions[second].legal) continue;
              const double first_target =
                  root.label->actions[first].normalized_target;
              const double second_target =
                  root.label->actions[second].normalized_target;
              if (std::abs(first_target - second_target) <= 1.0e-12) continue;
              const int better = first_target > second_target ? first : second;
              const int worse = first_target > second_target ? second : first;
              const double better_score =
                  root.label->actions[better].normalized_d1_q +
                  computed[better].heads[0];
              const double worse_score =
                  root.label->actions[worse].normalized_d1_q +
                  computed[worse].heads[0];
              const double scaled = std::clamp(
                  (better_score - worse_score) / kPairwiseTemperature,
                  -40.0, 40.0);
              const double derivative =
                  -pair_scale /
                  (kPairwiseTemperature * (1.0 + std::exp(scaled)));
              head_gradient[better][0] += derivative;
              head_gradient[worse][0] -= derivative;
            }
          }
        }
        for (int action = 0; action < kBoardSize; ++action) {
          if (!root.actions[action].legal) continue;
          backpropagate(result.network, root.actions[action].input,
                        computed[action], head_gradient[action], gradient);
        }
      }
      ++result.updates;
      result.rows += rows;
      result.pairs += pairs;
      applyAdam(result.network, gradient, adam, rows, result.updates,
                total_updates);
    }
    enforceRssLimit();
    std::cerr << "curriculum-nnue epoch " << epoch + 1 << '/' << kEpochs
              << '\n';
  }
  if (result.updates != total_updates) {
    throw std::runtime_error("NNUE Adam update schedule incomplete");
  }
  result.seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

struct Ranking {
  std::uint64_t roots = 0;
  std::uint64_t top1 = 0;
  std::uint64_t pairs = 0;
  double pairwise_credit = 0.0;
  double normalized_regret = 0.0;
};

double top1Rate(const Ranking& value) {
  return value.roots > 0
             ? static_cast<double>(value.top1) / value.roots
             : 0.0;
}

double pairwiseRate(const Ranking& value) {
  return value.pairs > 0 ? value.pairwise_credit / value.pairs : 0.0;
}

double regret(const Ranking& value) {
  return value.roots > 0 ? value.normalized_regret / value.roots : 0.0;
}

int selectAction(const RootLabel& root,
                 const std::array<double, kBoardSize>& scores) {
  int selected = -1;
  double best = -std::numeric_limits<double>::infinity();
  for (const int action : kActionOrder) {
    if (!root.actions[action].legal) continue;
    if (scores[action] > best) {
      best = scores[action];
      selected = action;
    }
  }
  return selected;
}

void observeRanking(const RootLabel& root,
                    const std::array<double, kBoardSize>& scores,
                    Ranking& result) {
  const int selected = selectAction(root, scores);
  if (selected < 0) throw std::runtime_error("ranking has no legal action");
  ++result.roots;
  double best = -std::numeric_limits<double>::infinity();
  double minimum = std::numeric_limits<double>::infinity();
  for (int action = 0; action < kBoardSize; ++action) {
    if (!root.actions[action].legal) continue;
    best = std::max(best, root.actions[action].normalized_target);
    minimum = std::min(minimum, root.actions[action].normalized_target);
  }
  result.top1 +=
      best - root.actions[selected].normalized_target <= 1.0e-12;
  const double range = best - minimum;
  if (range > 1.0e-12) {
    result.normalized_regret +=
        (best - root.actions[selected].normalized_target) / range;
  }
  for (int first = 0; first < kBoardSize; ++first) {
    if (!root.actions[first].legal) continue;
    for (int second = first + 1; second < kBoardSize; ++second) {
      if (!root.actions[second].legal) continue;
      const double target_difference =
          root.actions[first].normalized_target -
          root.actions[second].normalized_target;
      if (std::abs(target_difference) <= 1.0e-12) continue;
      ++result.pairs;
      const double prediction_difference = scores[first] - scores[second];
      if (std::abs(prediction_difference) <= 1.0e-12) {
        result.pairwise_credit += 0.5;
      } else {
        result.pairwise_credit +=
            (prediction_difference > 0.0) == (target_difference > 0.0);
      }
    }
  }
}

struct Evaluation {
  Ranking baseline{};
  Ranking candidate{};
  std::array<Ranking, 2> baseline_halves{};
  std::array<Ranking, 2> candidate_halves{};
  std::array<double, kHeads> squared_error{};
  std::uint64_t rows = 0;
};

Evaluation evaluate(const Network& network,
                    const std::vector<PreparedRoot>& roots) {
  Evaluation result;
  for (const PreparedRoot& prepared : roots) {
    const RootLabel& root = *prepared.label;
    std::array<double, kBoardSize> baseline{};
    std::array<double, kBoardSize> candidate{};
    baseline.fill(-std::numeric_limits<double>::infinity());
    candidate.fill(-std::numeric_limits<double>::infinity());
    for (int action = 0; action < kBoardSize; ++action) {
      if (!prepared.actions[action].legal) continue;
      const Forward computed = forward(network, prepared.actions[action].input);
      baseline[action] = root.actions[action].normalized_d1_q;
      candidate[action] = baseline[action] + computed.heads[0];
      ++result.rows;
      for (int head = 0; head < kHeads; ++head) {
        const double error =
            computed.heads[head] - root.actions[action].targets[head];
        result.squared_error[head] += error * error;
      }
    }
    observeRanking(root, baseline, result.baseline);
    observeRanking(root, candidate, result.candidate);
    observeRanking(root, baseline,
                   result.baseline_halves[root.record.hash_half]);
    observeRanking(root, candidate,
                   result.candidate_halves[root.record.hash_half]);
  }
  return result;
}

bool nonregressed(const Ranking& candidate, const Ranking& baseline) {
  return top1Rate(candidate) + 1.0e-12 >= top1Rate(baseline) &&
         pairwiseRate(candidate) + 1.0e-12 >= pairwiseRate(baseline) &&
         regret(candidate) <= regret(baseline) + 1.0e-12;
}

struct Gate {
  bool top1_gain = false;
  bool pairwise_gain = false;
  bool regret_gain = false;
  bool absolute_top1 = false;
  bool absolute_pairwise = false;
  std::array<bool, 2> half_nonregression{};
  bool passed = false;
};

Gate applyGate(const Evaluation& heldout) {
  Gate result;
  result.top1_gain =
      top1Rate(heldout.candidate) >=
      top1Rate(heldout.baseline) + kGateTop1Gain;
  result.pairwise_gain =
      pairwiseRate(heldout.candidate) >=
      pairwiseRate(heldout.baseline) + kGatePairwiseGain;
  result.regret_gain =
      regret(heldout.candidate) <=
      kGateRegretRatio * regret(heldout.baseline);
  result.absolute_top1 =
      top1Rate(heldout.candidate) >= kGateAbsoluteTop1;
  result.absolute_pairwise =
      pairwiseRate(heldout.candidate) >= kGateAbsolutePairwise;
  for (int half = 0; half < 2; ++half) {
    result.half_nonregression[half] = nonregressed(
        heldout.candidate_halves[half], heldout.baseline_halves[half]);
  }
  result.passed = result.top1_gain && result.pairwise_gain &&
                  result.regret_gain && result.absolute_top1 &&
                  result.absolute_pairwise &&
                  result.half_nonregression[0] &&
                  result.half_nonregression[1];
  return result;
}

std::string jsonEscape(std::string_view source) {
  std::string result;
  for (const char character : source) {
    if (character == '\\' || character == '"') result.push_back('\\');
    result.push_back(character);
  }
  return result;
}

std::string hex64(std::uint64_t value) {
  std::ostringstream output;
  output << "0x" << std::hex << std::setw(16) << std::setfill('0') << value;
  return output.str();
}

void writeBoard(std::ostream& output, const Board& board) {
  for (const std::uint8_t cell : board) {
    output << static_cast<char>('0' + cell);
  }
}

void writeLabelRoot(std::ostream& output, const RootLabel& root,
                    std::string_view split) {
  output << std::setprecision(12) << "{\"split\":\"" << split
         << "\",\"publicHash\":\"" << hex64(root.record.public_hash)
         << "\",\"hashHalf\":" << root.record.hash_half
         << ",\"state\":{\"board\":\"";
  writeBoard(output, root.record.state.board);
  output << "\",\"nextDisc\":"
         << static_cast<int>(root.record.state.next_disc)
         << ",\"movesRemaining\":"
         << static_cast<int>(root.record.state.moves_remaining)
         << "},\"d1Action\":" << root.d1_action
         << ",\"optimalAction\":" << root.labeled_action
         << ",\"actions\":[";
  for (int action = 0; action < kBoardSize; ++action) {
    if (action != 0) output << ',';
    const ActionLabel& value = root.actions[action];
    if (!value.legal) {
      output << "null";
      continue;
    }
    output << "{\"action\":" << action
           << ",\"survival\":" << value.survival
           << ",\"cumulativeCorrectedScore\":" << value.mean_score
           << ",\"clears\":" << value.mean_clears
           << ",\"reveals\":" << value.mean_reveals
           << ",\"balancedReturn\":" << value.balanced_return
           << ",\"normalizedTarget\":" << value.normalized_target
           << ",\"normalizedD1Q\":" << value.normalized_d1_q
           << ",\"certificate\":{\"terminalRate\":"
           << value.certificate.terminal_rate
           << ",\"meanScore\":" << value.certificate.mean_score
           << ",\"meanClears\":" << value.certificate.mean_clears
           << ",\"meanReveals\":" << value.certificate.mean_reveals
           << ",\"meanWaves\":" << value.certificate.mean_waves
           << ",\"meanMaximumChain\":"
           << value.certificate.mean_maximum_chain
           << ",\"postOccupancy\":"
           << value.certificate.post_occupancy
           << ",\"postMaximumHeight\":"
           << value.certificate.post_height
           << ",\"postCovers\":" << value.certificate.post_covers
           << ",\"postLegalColumns\":"
           << value.certificate.post_legal_columns
           << ",\"directDelta\":" << value.certificate.direct_delta
           << ",\"latentDelta\":" << value.certificate.latent_delta
           << ",\"clogImprovement\":"
           << value.certificate.clog_improvement << "},\"scenarios\":[";
    for (int scenario = 0; scenario < kScenarios; ++scenario) {
      if (scenario != 0) output << ',';
      const ScenarioLabel& item = value.scenarios[scenario];
      output << "{\"moves\":" << item.moves
             << ",\"cumulativeCorrectedScore\":" << item.score
             << ",\"clears\":" << item.clears
             << ",\"reveals\":" << item.reveals
             << ",\"maximumChain\":" << item.maximum_chain
             << ",\"survived\":"
             << (item.survived ? "true" : "false") << '}';
    }
    output << "]}";
  }
  output << "],\"transitions\":" << root.transitions
         << ",\"d1Calls\":" << root.d1_calls
         << ",\"d1Work\":" << root.d1_work
         << ",\"d1Nodes\":" << root.d1_nodes
         << ",\"seconds\":" << root.seconds << "}\n";
}

void writeLabels(const Options& options, const LabeledRange& training,
                 const LabeledRange& heldout) {
  std::ofstream output(options.labels, std::ios::trunc);
  if (!output) throw std::runtime_error("could not write NNUE labels");
  output << std::setprecision(12)
         << "{\"type\":\"metadata\",\"format\":\"drop7-curriculum-public-long-outcomes-v1\""
         << ",\"sourceSha256\":\"" << kExpectedCorpusSha256 << "\""
         << ",\"sourceFingerprint\":\""
         << hex64(kExpectedDatasetFingerprint) << "\""
         << ",\"newGameplaySeeds\":0,\"publicStatesOnly\":true"
         << ",\"splitBeforeLabels\":true,\"trainingStates\":"
         << training.roots.size() << ",\"heldoutStates\":"
         << heldout.roots.size() << ",\"scenarios\":" << kScenarios
         << ",\"horizon\":" << kHorizon
         << ",\"continuation\":\"exact fair-D1\""
         << ",\"returnWeights\":{\"survival\":"
         << kBalancedSurvivalWeight << ",\"score\":"
         << kBalancedScoreWeight << ",\"clears\":"
         << kBalancedClearsWeight << ",\"reveals\":"
         << kBalancedRevealsWeight << "}}\n";
  for (const RootLabel& root : training.roots) {
    writeLabelRoot(output, root, "training");
  }
  for (const RootLabel& root : heldout.roots) {
    writeLabelRoot(output, root, "heldout");
  }
  output.close();
  if (!output) throw std::runtime_error("failed finishing NNUE labels");
}

template <typename Value>
void writeBinary(std::ostream& output, const Value& value) {
  static_assert(std::is_trivially_copyable_v<Value>);
  output.write(reinterpret_cast<const char*>(&value), sizeof(value));
}

template <typename Value>
void readBinary(std::istream& input, Value& value) {
  static_assert(std::is_trivially_copyable_v<Value>);
  input.read(reinterpret_cast<char*>(&value), sizeof(value));
}

void writeCheckpoint(const std::string& path, const Network& network) {
  std::ofstream output(path, std::ios::binary | std::ios::trunc);
  if (!output) throw std::runtime_error("could not write NNUE checkpoint");
  constexpr std::array<char, 8> magic{{'D', '7', 'C', 'L', 'N', 'N', '1', '\0'}};
  output.write(magic.data(), magic.size());
  constexpr std::uint32_t version = 1;
  constexpr std::uint32_t inputs = kInputs;
  constexpr std::uint32_t hidden = kHidden;
  constexpr std::uint32_t heads = kHeads;
  constexpr std::uint32_t level_bonus = kLevelBonus;
  writeBinary(output, version);
  writeBinary(output, inputs);
  writeBinary(output, hidden);
  writeBinary(output, heads);
  writeBinary(output, level_bonus);
  writeBinary(output, kExpectedDatasetFingerprint);
  output.write(kExpectedCorpusSha256.data(), kExpectedCorpusSha256.size());
  output.write(reinterpret_cast<const char*>(network.input_weights.data()),
               static_cast<std::streamsize>(network.input_weights.size() *
                                            sizeof(float)));
  output.write(reinterpret_cast<const char*>(network.hidden_bias.data()),
               sizeof(network.hidden_bias));
  output.write(reinterpret_cast<const char*>(network.head_weights.data()),
               sizeof(network.head_weights));
  output.write(reinterpret_cast<const char*>(network.head_bias.data()),
               sizeof(network.head_bias));
  output.close();
  if (!output) throw std::runtime_error("failed finishing NNUE checkpoint");
  std::error_code error;
  const std::uintmax_t bytes = std::filesystem::file_size(path, error);
  if (error || bytes > kCheckpointLimitBytes) {
    throw std::runtime_error("NNUE checkpoint exceeded 1 MiB cap");
  }
}

Network readCheckpoint(const std::string& path) {
  std::ifstream input(path, std::ios::binary);
  if (!input) throw std::runtime_error("could not read NNUE checkpoint");
  std::array<char, 8> magic{};
  input.read(magic.data(), magic.size());
  constexpr std::array<char, 8> expected{{'D', '7', 'C', 'L', 'N', 'N', '1', '\0'}};
  std::uint32_t version = 0;
  std::uint32_t inputs = 0;
  std::uint32_t hidden = 0;
  std::uint32_t heads = 0;
  std::uint32_t level_bonus = 0;
  std::uint64_t fingerprint = 0;
  readBinary(input, version);
  readBinary(input, inputs);
  readBinary(input, hidden);
  readBinary(input, heads);
  readBinary(input, level_bonus);
  readBinary(input, fingerprint);
  std::array<char, 64> checksum{};
  input.read(checksum.data(), checksum.size());
  if (magic != expected || version != 1 || inputs != kInputs ||
      hidden != kHidden || heads != kHeads || level_bonus != kLevelBonus ||
      fingerprint != kExpectedDatasetFingerprint ||
      std::string_view(checksum.data(), checksum.size()) !=
          kExpectedCorpusSha256) {
    throw std::runtime_error("NNUE checkpoint header mismatch");
  }
  Network result;
  input.read(reinterpret_cast<char*>(result.input_weights.data()),
             static_cast<std::streamsize>(result.input_weights.size() *
                                          sizeof(float)));
  input.read(reinterpret_cast<char*>(result.hidden_bias.data()),
             sizeof(result.hidden_bias));
  input.read(reinterpret_cast<char*>(result.head_weights.data()),
             sizeof(result.head_weights));
  input.read(reinterpret_cast<char*>(result.head_bias.data()),
             sizeof(result.head_bias));
  if (!input) throw std::runtime_error("NNUE checkpoint truncated");
  char trailing = 0;
  if (input.read(&trailing, 1)) {
    throw std::runtime_error("NNUE checkpoint has trailing data");
  }
  return result;
}

void writeRanking(std::ostream& output, const Ranking& value) {
  output << std::setprecision(12) << "{\"roots\":" << value.roots
         << ",\"top1\":" << top1Rate(value)
         << ",\"pairs\":" << value.pairs
         << ",\"pairwise\":" << pairwiseRate(value)
         << ",\"normalizedRegret\":" << regret(value) << '}';
}

void writeCost(std::ostream& output, const LabelCost& value) {
  output << std::setprecision(12) << "{\"roots\":" << value.roots
         << ",\"legalActions\":" << value.legal_actions
         << ",\"transitions\":" << value.transitions
         << ",\"d1Calls\":" << value.d1_calls
         << ",\"d1Work\":" << value.d1_work
         << ",\"d1Nodes\":" << value.d1_nodes
         << ",\"aggregateRootSeconds\":"
         << value.aggregate_root_seconds << ",\"wallSeconds\":"
         << value.wall_seconds << '}';
}

void writeEvaluation(std::ostream& output, const Evaluation& value) {
  output << "{\"d1\":";
  writeRanking(output, value.baseline);
  output << ",\"nnue\":";
  writeRanking(output, value.candidate);
  output << ",\"hashHalves\":[";
  for (int half = 0; half < 2; ++half) {
    if (half != 0) output << ',';
    output << "{\"half\":" << half << ",\"d1\":";
    writeRanking(output, value.baseline_halves[half]);
    output << ",\"nnue\":";
    writeRanking(output, value.candidate_halves[half]);
    output << '}';
  }
  output << "],\"headRmse\":[";
  for (int head = 0; head < kHeads; ++head) {
    if (head != 0) output << ',';
    output << (value.rows > 0
                   ? std::sqrt(value.squared_error[head] / value.rows)
                   : 0.0);
  }
  output << "]}";
}

struct LabelMoments {
  std::uint64_t actions = 0;
  double survival = 0.0;
  double score = 0.0;
  double clears = 0.0;
  double reveals = 0.0;
  double balanced = 0.0;
};

LabelMoments labelMoments(const LabeledRange& range) {
  LabelMoments result;
  for (const RootLabel& root : range.roots) {
    for (const ActionLabel& action : root.actions) {
      if (!action.legal) continue;
      ++result.actions;
      result.survival += action.survival;
      result.score += action.mean_score;
      result.clears += action.mean_clears;
      result.reveals += action.mean_reveals;
      result.balanced += action.balanced_return;
    }
  }
  if (result.actions > 0) {
    const double inverse = 1.0 / result.actions;
    result.survival *= inverse;
    result.score *= inverse;
    result.clears *= inverse;
    result.reveals *= inverse;
    result.balanced *= inverse;
  }
  return result;
}

void writeArtifact(const Options& options, const Corpus& corpus,
                   const LabeledRange& training_labels,
                   const LabeledRange& heldout_labels,
                   const TrainingSummary& training,
                   const Evaluation& fitting,
                   const Evaluation& heldout, const Gate& gate,
                   std::string_view label_sha, std::string_view checkpoint_sha,
                   std::uintmax_t checkpoint_bytes, double total_seconds) {
  std::ofstream output(options.output, std::ios::trunc);
  if (!output) throw std::runtime_error("could not write NNUE artifact");
  const LabelMoments training_moments = labelMoments(training_labels);
  const LabelMoments heldout_moments = labelMoments(heldout_labels);
  output << std::setprecision(12)
         << "{\n  \"format\":\"drop7-curriculum-long-outcome-nnue-v1\",\n"
         << "  \"purpose\":\"artifact-only diagnostic; potential rollout/value initializer only; no gameplay claim\",\n"
         << "  \"source\":{\"path\":\"" << jsonEscape(options.states)
         << "\",\"bytes\":" << kExpectedCorpusBytes
         << ",\"sha256\":\"" << kExpectedCorpusSha256
         << "\",\"datasetFingerprint\":\""
         << hex64(kExpectedDatasetFingerprint)
         << "\",\"publicStates\":" << kCorpusStates
         << ",\"checksumValidated\":true,\"newGameplaySeeds\":0},\n"
         << "  \"split\":{\"method\":\"rank mix64(canonicalPublicHash xor frozen domain) before labels\",\"training\":"
         << corpus.training.size() << ",\"heldout\":"
         << corpus.heldout.size() << ",\"trainingHashHalves\":["
         << corpus.training_halves[0] << ',' << corpus.training_halves[1]
         << "],\"heldoutHashHalves\":[" << corpus.heldout_halves[0]
         << ',' << corpus.heldout_halves[1] << "]},\n"
         << "  \"labels\":{\"path\":\"" << jsonEscape(options.labels)
         << "\",\"sha256\":\"" << label_sha
         << "\",\"scenarios\":" << kScenarios
         << ",\"horizon\":" << kHorizon
         << ",\"rootAction\":\"forced\",\"continuation\":\"exact public fair-D1\",\"commonEventIndexedChance\":true,\"correctedLevelBonus\":"
         << kLevelBonus << ",\"balancedReturn\":{\"survival\":"
         << kBalancedSurvivalWeight << ",\"cumulativeScore\":"
         << kBalancedScoreWeight << ",\"clears\":"
         << kBalancedClearsWeight << ",\"reveals\":"
         << kBalancedRevealsWeight << "},\"trainingMeans\":{\"actions\":"
         << training_moments.actions << ",\"survival\":"
         << training_moments.survival << ",\"score\":"
         << training_moments.score << ",\"clears\":"
         << training_moments.clears << ",\"reveals\":"
         << training_moments.reveals << ",\"balanced\":"
         << training_moments.balanced
         << "},\"heldoutMeans\":{\"actions\":"
         << heldout_moments.actions << ",\"survival\":"
         << heldout_moments.survival << ",\"score\":"
         << heldout_moments.score << ",\"clears\":"
         << heldout_moments.clears << ",\"reveals\":"
         << heldout_moments.reveals << ",\"balanced\":"
         << heldout_moments.balanced << "},\"trainingCost\":";
  writeCost(output, training_labels.cost);
  output << ",\"heldoutCost\":";
  writeCost(output, heldout_labels.cost);
  output << "},\n  \"model\":{\"architecture\":\"sparse exact-tuple-reflection 541x96 clipped-ReLU, five heads\",\"boardOneHot\":490,\"nextDiscOneHot\":7,\"risePhaseOneHot\":5,\"actionOneHot\":7,\"certificateFlowAggregates\":32,\"anchor\":\"within-root normalized exact D1 Q\",\"heads\":[\"balancedReturnResidual\",\"survival\",\"cumulativeScore\",\"clears\",\"reveals\"],\"epochs\":"
         << kEpochs << ",\"batchRoots\":" << kBatchRoots
         << ",\"learningRate\":" << kLearningRate
         << ",\"finalLearningRateRatio\":"
         << kFinalLearningRateRatio << ",\"weightDecay\":"
         << kWeightDecay << ",\"pairwiseLossWeight\":"
         << kPairwiseLossWeight << ",\"updates\":" << training.updates
         << ",\"rows\":" << training.rows << ",\"pairs\":"
         << training.pairs << ",\"seconds\":" << training.seconds
         << ",\"checkpoint\":\"" << jsonEscape(options.checkpoint)
         << "\",\"checkpointBytes\":" << checkpoint_bytes
         << ",\"checkpointSha256\":\"" << checkpoint_sha
         << "\"},\n  \"fitting\":";
  writeEvaluation(output, fitting);
  output << ",\n  \"heldout\":";
  writeEvaluation(output, heldout);
  output << ",\n  \"gates\":{\"top1GainRequired\":" << kGateTop1Gain
         << ",\"pairwiseGainRequired\":" << kGatePairwiseGain
         << ",\"regretRatioRequired\":" << kGateRegretRatio
         << ",\"absoluteTop1Required\":" << kGateAbsoluteTop1
         << ",\"absolutePairwiseRequired\":"
         << kGateAbsolutePairwise << ",\"top1Gain\":"
         << (gate.top1_gain ? "true" : "false")
         << ",\"pairwiseGain\":"
         << (gate.pairwise_gain ? "true" : "false")
         << ",\"regretGain\":"
         << (gate.regret_gain ? "true" : "false")
         << ",\"absoluteTop1\":"
         << (gate.absolute_top1 ? "true" : "false")
         << ",\"absolutePairwise\":"
         << (gate.absolute_pairwise ? "true" : "false")
         << ",\"hashHalfNonregression\":["
         << (gate.half_nonregression[0] ? "true" : "false") << ','
         << (gate.half_nonregression[1] ? "true" : "false")
         << "],\"passed\":" << (gate.passed ? "true" : "false")
         << "},\n  \"initializerEligible\":"
         << (gate.passed ? "true" : "false")
         << ",\n  \"gameplayPerformed\":false,\n  \"gameplayClaim\":false,\n"
         << "  \"resourceCaps\":{\"wallSeconds\":" << kWallLimitSeconds
         << ",\"rssBytes\":" << kRssLimitBytes
         << ",\"checkpointBytes\":" << kCheckpointLimitBytes
         << "},\n  \"totalSeconds\":" << total_seconds
         << ",\n  \"peakRssBytes\":" << peakRssBytes() << "\n}\n";
  output.close();
  if (!output) throw std::runtime_error("failed finishing NNUE artifact");
}

void expect(bool condition, std::string_view message) {
  if (!condition) throw std::runtime_error(std::string(message));
}

bool selfTest(const Options& options, std::ostream& output) {
  expect(sha256("abc") ==
             "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
         "SHA-256 known vector failed");
  const Corpus corpus = loadCorpus(options.states);
  expect(corpus.training.size() == kTrainingStates &&
             corpus.heldout.size() == kHeldoutStates,
         "checksum/split test failed");
  const CorpusRecord& fixture = corpus.training.front();
  State metadata = materialize(fixture.state);
  metadata.score = 9'876'543;
  metadata.level = 87;
  metadata.moves_played = 999;
  expect(publicState(metadata) == fixture.state,
         "hidden metadata crossed public boundary");

  const std::uint32_t key = publicChanceKey(fixture.state);
  for (const std::uint32_t domain : {kRevealDomain, kVisibleDomain}) {
    for (const int event : {0, 1, 127, 128, 12'799}) {
      std::array<int, kScenarios> strata{};
      for (int scenario = 0; scenario < kScenarios; ++scenario) {
        const double unit = detail::stratifiedUnit(
            key, scenario, kScenarios, domain, event);
        const int stratum =
            static_cast<int>(std::floor(unit * kScenarios));
        expect(stratum >= 0 && stratum < kScenarios,
               "chance stratum out of range");
        ++strata[stratum];
      }
      for (const int count : strata) {
        expect(count == 1, "chance event is not exactly stratified");
      }
    }
  }
  int action = -1;
  for (const int candidate : kActionOrder) {
    if (isLegal(fixture.state.board, candidate)) {
      action = candidate;
      break;
    }
  }
  expect(action >= 0, "fixture has no legal action");
  const PublicTransition first =
      playPublicMove(fixture.state, action, key, 0, 0);
  const PublicTransition repeated =
      playPublicMove(fixture.state, action, key, 0, 0);
  const PublicState reflected_state = mirror(fixture.state);
  const PublicTransition reflected = playPublicMove(
      reflected_state, kBoardSize - 1 - action,
      publicChanceKey(reflected_state), 0, 0);
  expect(first == repeated && first == reflected,
         "chance determinism/reflection failed");
  const D1Evaluation d1 = evaluateD1(fixture.state);
  const D1Evaluation reflected_d1 = evaluateD1(reflected_state);
  expect(d1.action == kBoardSize - 1 - reflected_d1.action &&
             isLegal(fixture.state.board, d1.action),
         "fair-D1 reflection failed");

  ActionLabel dummy;
  dummy.legal = true;
  CorpusRecord reflected_record = fixture;
  reflected_record.state = reflected_state;
  const SparseInput direct_input = makeInput(fixture, dummy, action);
  const SparseInput reflected_input = makeInput(
      reflected_record, dummy, kBoardSize - 1 - action);
  const Network network = initializeNetwork();
  expect(direct_input == reflected_input &&
             forward(network, direct_input).heads ==
                 forward(network, reflected_input).heads,
         "NNUE exact reflection failed");
  expect(balancedReturn(1.0, 0.0, 0.0, 0.0) ==
             kBalancedSurvivalWeight &&
             balancedReturn(0.0, 0.0, 0.0, 0.0) == 0.0,
         "balanced return freeze failed");
  expect((kInputs * kHidden + kHidden + kHeads * kHidden + kHeads) *
                 sizeof(float) <
             kCheckpointLimitBytes &&
             peakRssBytes() < kRssLimitBytes,
         "model resource certificate failed");
  enforceRssLimit();
  output << "CURRICULUM_LONG_OUTCOME_NNUE_SELF_TEST {\"passed\":true,"
         << "\"corpusSha256\":\"" << kExpectedCorpusSha256 << "\","
         << "\"states\":" << kCorpusStates
         << ",\"training\":" << kTrainingStates
         << ",\"heldout\":" << kHeldoutStates
         << ",\"metadataBlind\":true,\"chanceExact\":true,"
         << "\"reflectionExact\":true,\"checkpointUnder1MiB\":true,"
         << "\"peakRssBytes\":" << peakRssBytes() << "}\n";
  return true;
}

int run(const Options& options, std::ostream& output) {
  const Deadline deadline;
  const Corpus corpus = loadCorpus(options.states);
  const LabeledRange training_labels =
      labelRange(corpus.training, "training", options.threads, deadline);
  const LabeledRange heldout_labels =
      labelRange(corpus.heldout, "heldout", options.threads, deadline);
  writeLabels(options, training_labels, heldout_labels);
  const std::string label_sha = sha256(readWholeFile(options.labels));
  const std::vector<PreparedRoot> training = prepare(training_labels.roots);
  const std::vector<PreparedRoot> heldout = prepare(heldout_labels.roots);
  const TrainingSummary trained = train(training, deadline);
  const Evaluation fitting_evaluation = evaluate(trained.network, training);
  const Evaluation heldout_evaluation = evaluate(trained.network, heldout);
  const Gate gate = applyGate(heldout_evaluation);
  writeCheckpoint(options.checkpoint, trained.network);
  const Network reloaded = readCheckpoint(options.checkpoint);
  const PreparedRoot& check_root = heldout.front();
  int check_action = -1;
  for (const int action : kActionOrder) {
    if (check_root.actions[action].legal) {
      check_action = action;
      break;
    }
  }
  if (check_action < 0 ||
      forward(trained.network, check_root.actions[check_action].input).heads !=
          forward(reloaded, check_root.actions[check_action].input).heads) {
    throw std::runtime_error("NNUE checkpoint round-trip mismatch");
  }
  const std::string checkpoint_sha =
      sha256(readWholeFile(options.checkpoint));
  std::error_code error;
  const std::uintmax_t checkpoint_bytes =
      std::filesystem::file_size(options.checkpoint, error);
  if (error || checkpoint_bytes > kCheckpointLimitBytes) {
    throw std::runtime_error("NNUE checkpoint resource validation failed");
  }
  deadline.check();
  enforceRssLimit();
  const double total_seconds = deadline.elapsedSeconds();
  writeArtifact(options, corpus, training_labels, heldout_labels, trained,
                fitting_evaluation, heldout_evaluation, gate, label_sha,
                checkpoint_sha, checkpoint_bytes, total_seconds);
  output << std::fixed << std::setprecision(6)
         << "CURRICULUM_LONG_OUTCOME_NNUE_RESULT {\"d1Top1\":"
         << top1Rate(heldout_evaluation.baseline)
         << ",\"nnueTop1\":" << top1Rate(heldout_evaluation.candidate)
         << ",\"d1Pairwise\":"
         << pairwiseRate(heldout_evaluation.baseline)
         << ",\"nnuePairwise\":"
         << pairwiseRate(heldout_evaluation.candidate)
         << ",\"d1Regret\":" << regret(heldout_evaluation.baseline)
         << ",\"nnueRegret\":" << regret(heldout_evaluation.candidate)
         << ",\"hashHalvesNonregress\":["
         << (gate.half_nonregression[0] ? "true" : "false") << ','
         << (gate.half_nonregression[1] ? "true" : "false")
         << "],\"passed\":" << (gate.passed ? "true" : "false")
         << ",\"initializerEligible\":"
         << (gate.passed ? "true" : "false")
         << ",\"gameplayPerformed\":false,\"checkpointBytes\":"
         << checkpoint_bytes << ",\"checkpointSha256\":\""
         << checkpoint_sha << "\",\"totalSeconds\":" << total_seconds
         << ",\"peakRssBytes\":" << peakRssBytes()
         << ",\"artifact\":\"" << jsonEscape(options.output) << "\"}\n";
  return gate.passed ? EXIT_SUCCESS : 2;
}

}  // namespace drop7::curriculum_long_outcome_nnue

#ifndef DROP7_CURRICULUM_LONG_OUTCOME_NNUE_LIBRARY
int main(int argc, char** argv) {
  try {
    if (argc >= 2 && std::string_view(argv[1]) == "--self-test") {
      const auto options =
          drop7::curriculum_long_outcome_nnue::parseOptions(argc, argv, 2);
      return drop7::curriculum_long_outcome_nnue::selfTest(options, std::cout)
                 ? EXIT_SUCCESS
                 : EXIT_FAILURE;
    }
    if (argc >= 2 && std::string_view(argv[1]) == "--run") {
      const auto options =
          drop7::curriculum_long_outcome_nnue::parseOptions(argc, argv, 2);
      return drop7::curriculum_long_outcome_nnue::run(options, std::cout);
    }
    std::cerr << "usage: drop7_curriculum_long_outcome_nnue --self-test | "
                 "--run [--states PATH] [--output PATH] [--labels PATH] "
                 "[--checkpoint PATH] [--threads N]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "drop7_curriculum_long_outcome_nnue: " << error.what()
              << '\n';
    return EXIT_FAILURE;
  }
}
#endif