Drop7 Research
approaches/afterstate-learning/distributional-afterstate/generate-corpus.cppC++315 lines · 12.1 KB
// Corpus generator for the distributional afterstate ranker pilot.
//
// Harvests decision roots from complete fair-D1 games played on the leased
// public-development seed range, then labels every legal sibling of every
// root under aligned chance scenarios with a fixed public continuation
// (phase-greedy D1, kHorizon moves). Output is deterministic NDJSON sorted by
// (origin_seed, move_index, action, scenario) regardless of thread count.
//
// This tool reads game seeds to produce training data. It is not a policy and
// never runs at deployment. The model trained on this data sees only public
// afterstates.

#include <atomic>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <future>
#include <iostream>
#include <map>
#include <mutex>
#include <set>
#include <sstream>
#include <string>
#include <unordered_map>
#include <vector>

#include "../../../src/core/native/engine.hpp"
#include "../../../src/core/native/public-behavior.hpp"
#include "common.hpp"

namespace afterstate = drop7::afterstate;
using drop7::Board;
using drop7::MoveResult;
using drop7::State;

namespace {

struct Options {
  std::uint32_t seed_start = 0x5da7'0000u;
  int max_games = afterstate::kMaxHarvestGames;
  int max_roots = afterstate::kMaxRoots;
  int scenarios = afterstate::kScenarios;
  int threads = 8;
  double wall_seconds = 4.0 * 3600.0;
  std::string out_dir;
  std::string run_id;
  std::string fold_force;  // if set, every root gets this fold
  afterstate::Continuation continuation = afterstate::Continuation::kD1;
};

Options parseArgs(int argc, char** argv) {
  Options options;
  for (int i = 1; i < argc; ++i) {
    const std::string arg = argv[i];
    auto next = [&]() -> std::string {
      if (i + 1 >= argc) throw std::runtime_error("missing value for " + arg);
      return argv[++i];
    };
    if (arg == "--seed-start") {
      options.seed_start = static_cast<std::uint32_t>(std::stoul(next(), nullptr, 0));
    } else if (arg == "--games") {
      options.max_games = std::stoi(next());
    } else if (arg == "--roots") {
      options.max_roots = std::stoi(next());
    } else if (arg == "--scenarios") {
      options.scenarios = std::stoi(next());
      if (options.scenarios < 2) {
        throw std::runtime_error("--scenarios must be >= 2");
      }
    } else if (arg == "--fold-force") {
      options.fold_force = next();
    } else if (arg == "--continuation") {
      const std::string value = next();
      if (value == "d1") {
        options.continuation = afterstate::Continuation::kD1;
      } else if (value == "d2") {
        options.continuation = afterstate::Continuation::kD2;
      } else {
        throw std::runtime_error("unknown --continuation: " + value);
      }
    } else if (arg == "--threads") {
      options.threads = std::stoi(next());
    } else if (arg == "--wall-seconds") {
      options.wall_seconds = std::stod(next());
    } else if (arg == "--out") {
      options.out_dir = next();
    } else if (arg == "--run-id") {
      options.run_id = next();
    } else {
      throw std::runtime_error("unknown argument: " + arg);
    }
  }
  if (options.out_dir.empty()) throw std::runtime_error("--out is required");
  if (options.threads < 1) throw std::runtime_error("--threads must be >= 1");
  return options;
}

std::string boardString(const Board& board) {
  return drop7::serializeBoard(board);
}

// Plays one complete fair-D1 game and returns its decision roots in move
// order. Deterministic for a given seed.
std::vector<afterstate::RootRecord> harvestGame(std::uint32_t seed,
                                                int move_cap) {
  std::vector<afterstate::RootRecord> roots;
  State state = drop7::initialHeadlessState(seed);
  while (!state.game_over && state.moves_played < move_cap) {
    afterstate::RootRecord root;
    if (afterstate::makeRoot(state, seed, root)) roots.push_back(root);
    const int action = drop7::cfpi::choosePhaseGreedyAction(state, 1);
    if (action < 0) break;
    MoveResult move;
    if (!drop7::playHeadlessMove(state, seed, action, move)) break;
  }
  return roots;
}

std::string labelRow(const afterstate::RootRecord& root,
                     const afterstate::SiblingLabel& label) {
  std::ostringstream row;
  row << "{\"originSeed\":\"0x" << std::hex << root.origin_seed << std::dec
      << "\",\"moveIndex\":" << root.move_index
      << ",\"fold\":\"" << root.fold << "\""
      << ",\"rootBoard\":\"" << boardString(root.board) << "\""
      << ",\"rootNextDisc\":" << root.next_disc
      << ",\"rootMovesRemaining\":" << root.moves_remaining
      << ",\"mirrored\":" << (root.mirrored ? "true" : "false")
      << ",\"action\":" << label.action << ",\"scenario\":" << label.scenario
      << ",\"afterstateBoard\":\"" << boardString(label.afterstate) << "\""
      << ",\"afterstateNextDisc\":" << label.afterstate_next_disc
      << ",\"afterstateMovesRemaining\":" << label.afterstate_moves_remaining
      << ",\"terminal\":" << (label.terminal ? "true" : "false")
      << ",\"scoreGained\":" << label.score_gained
      << ",\"movesSurvived\":" << label.moves_survived
      << ",\"clears\":" << label.clears << ",\"reveals\":" << label.reveals
      << ",\"maxChain\":" << label.max_chain << "}";
  return row.str();
}

}  // namespace

int main(int argc, char** argv) {
  const Options options = parseArgs(argc, argv);
  const auto started = std::chrono::steady_clock::now();
  std::filesystem::create_directories(options.out_dir);

  constexpr int kMoveCap = 2'000;

  // Phase 1: harvest roots, games parallel in seed order chunks.
  std::vector<std::vector<afterstate::RootRecord>> per_game_roots(
      static_cast<std::size_t>(options.max_games));
  std::atomic<int> next_game{0};
  std::atomic<bool> stop{false};
  std::mutex log_mutex;

  auto harvest_worker = [&]() {
    for (;;) {
      const int game = next_game.fetch_add(1);
      if (game >= options.max_games || stop.load()) return;
      const auto elapsed =
          std::chrono::duration<double>(std::chrono::steady_clock::now() -
                                        started)
              .count();
      if (elapsed > options.wall_seconds) {
        stop.store(true);
        return;
      }
      const std::uint32_t seed = options.seed_start + static_cast<std::uint32_t>(game);
      per_game_roots[static_cast<std::size_t>(game)] = harvestGame(seed, kMoveCap);
      {
        std::lock_guard<std::mutex> lock(log_mutex);
        std::cerr << "harvested game " << game << " seed 0x" << std::hex << seed
                  << std::dec << " roots "
                  << per_game_roots[static_cast<std::size_t>(game)].size()
                  << "\n";
      }
    }
  };

  {
    std::vector<std::future<void>> workers;
    for (int t = 0; t < options.threads; ++t) {
      workers.push_back(std::async(std::launch::async, harvest_worker));
    }
    for (auto& worker : workers) worker.get();
  }

  // Merge in seed order; enforce whole-origin folds and drop exact duplicate
  // public roots whose first sighting was in a different fold.
  std::vector<afterstate::RootRecord> roots;
  std::unordered_map<std::string, std::string> root_fold;
  std::int64_t dropped_cross_fold = 0;
  for (int game = 0; game < options.max_games; ++game) {
    for (afterstate::RootRecord root :
         per_game_roots[static_cast<std::size_t>(game)]) {
      if (!options.fold_force.empty()) root.fold = options.fold_force;
      const std::string key = boardString(root.board) + ":" +
                              std::to_string(root.next_disc) + ":" +
                              std::to_string(root.moves_remaining);
      const auto [it, inserted] = root_fold.emplace(key, root.fold);
      if (!inserted && it->second != root.fold) {
        ++dropped_cross_fold;
        continue;
      }
      roots.push_back(std::move(root));
      if (static_cast<int>(roots.size()) >= options.max_roots) break;
    }
    if (static_cast<int>(roots.size()) >= options.max_roots) break;
  }

  // Phase 2: label roots in parallel; output stays in root order.
  std::vector<std::vector<afterstate::SiblingLabel>> per_root_labels(roots.size());
  std::atomic<std::size_t> next_root{0};
  auto label_worker = [&]() {
    for (;;) {
      const std::size_t index = next_root.fetch_add(1);
      if (index >= roots.size() || stop.load()) return;
      const auto elapsed =
          std::chrono::duration<double>(std::chrono::steady_clock::now() -
                                        started)
              .count();
      if (elapsed > options.wall_seconds) {
        stop.store(true);
        return;
      }
      per_root_labels[index] =
          afterstate::labelRoot(roots[index], options.scenarios,
                                options.continuation);
      if ((index + 1) % 500 == 0) {
        std::lock_guard<std::mutex> lock(log_mutex);
        std::cerr << "labeled " << (index + 1) << "/" << roots.size()
                  << " roots\n";
      }
    }
  };
  {
    std::vector<std::future<void>> workers;
    for (int t = 0; t < options.threads; ++t) {
      workers.push_back(std::async(std::launch::async, label_worker));
    }
    for (auto& worker : workers) worker.get();
  }

  const bool complete = !stop.load() &&
                        next_root.load() >= roots.size() &&
                        next_game.load() >= options.max_games;

  // Phase 3: write deterministic output.
  const std::string corpus_path = options.out_dir + "/corpus.ndjson";
  std::ofstream corpus(corpus_path);
  std::ofstream root_index(options.out_dir + "/roots.tsv");
  root_index << "root_uid\tfold\torigin_seed\tmove_index\tboard\tnext_disc\t"
                "moves_remaining\tlegal_actions\n";
  std::int64_t rows = 0;
  std::map<std::string, std::int64_t> fold_roots;
  for (std::size_t index = 0; index < roots.size(); ++index) {
    if (per_root_labels[index].empty()) continue;  // stopped mid-root
    const afterstate::RootRecord& root = roots[index];
    ++fold_roots[root.fold];
    const std::string board = boardString(root.board);
    root_index << board << ":" << root.next_disc << ":" << root.moves_remaining
               << "\t" << root.fold << "\t0x" << std::hex << root.origin_seed
               << std::dec << "\t" << root.move_index << "\t" << board << "\t"
               << root.next_disc << "\t" << root.moves_remaining << "\t";
    for (std::size_t a = 0; a < root.legal_actions.size(); ++a) {
      root_index << (a == 0 ? "" : ",") << root.legal_actions[a];
    }
    root_index << "\n";
    for (const auto& label : per_root_labels[index]) {
      corpus << labelRow(root, label) << "\n";
      ++rows;
    }
  }
  corpus.close();
  root_index.close();

  std::ofstream manifest(options.out_dir + "/manifest.json");
  const double elapsed =
      std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
          .count();
  manifest << "{\n"
           << "  \"format\": \"drop7-afterstate-corpus-v1\",\n"
           << "  \"runId\": \"" << options.run_id << "\",\n"
           << "  \"seedStartHex\": \"0x" << std::hex << options.seed_start
           << std::dec << "\",\n"
           << "  \"gamesScheduled\": " << options.max_games << ",\n"
           << "  \"rootsLabeled\": " << per_root_labels.size() << ",\n"
           << "  \"rows\": " << rows << ",\n"
           << "  \"scenarios\": " << options.scenarios << ",\n"
           << "  \"horizon\": " << afterstate::kHorizon << ",\n"
           << "  \"continuationPolicy\": \""
           << (options.continuation == afterstate::Continuation::kD2
                   ? "fair-d2-s5"
                   : "phase-greedy-d1")
           << "\",\n"
           << "  \"harvestPolicy\": \"phase-greedy-d1\",\n"
           << "  \"droppedCrossFoldDuplicateRoots\": " << dropped_cross_fold
           << ",\n"
           << "  \"complete\": " << (complete ? "true" : "false") << ",\n"
           << "  \"wallSeconds\": " << elapsed << ",\n"
           << "  \"foldRoots\": {";
  bool first = true;
  for (const auto& [fold, count] : fold_roots) {
    manifest << (first ? "" : ",") << "\n    \"" << fold << "\": " << count;
    first = false;
  }
  manifest << "\n  }\n}\n";

  std::cout << "corpus rows " << rows << " roots " << roots.size()
            << " complete " << (complete ? "true" : "false") << " wall "
            << elapsed << "s\n";
  return complete ? 0 : 2;
}