Drop7 Research
approaches/heuristic-search/exact-search/exact-root-ensemble.cppC++475 lines · 17.1 KB
#include "../../../src/core/native/public-behavior.hpp"

#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/resource.h>
#include <thread>
#include <utility>
#include <vector>

// A causal test of chance-estimation noise at the root. Every member is the
// same full-width exact-depth-three expectimax policy. Members differ only in
// their deterministic stratified chance salt, and every legal root value is
// averaged before selecting an action.
namespace drop7::exact_root_ensemble {

using Clock = std::chrono::steady_clock;

constexpr int kDepth = 3;
constexpr int kChanceSamples = 5;
constexpr std::uint64_t kMaximumWork = 1'000'000;
constexpr std::size_t kMaximumCacheEntries = 40'000;
constexpr int kMaximumMoves = 500;
constexpr std::array<std::uint32_t, 3> kPolicySeeds{{
    0xd707'5eedu,
    0x91e1'0da5u,
    0x6a09'e667u,
}};
constexpr std::uint32_t kScreenStart = 0x3d70'd000u;
constexpr std::uint32_t kConfirmationStart = 0x3d70'd100u;
constexpr std::uint32_t kTrainingEnd = 0x4d00'0000u;
constexpr std::array<int, kBoardSize> kColumnOrder{{3, 2, 4, 1, 5, 0, 6}};

std::mutex progress_mutex;

struct Decision {
  int action = -1;
  std::uint64_t work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
};

Decision chooseAction(const State& source, int members) {
  if (members < 1 || members > static_cast<int>(kPolicySeeds.size())) {
    throw std::invalid_argument("exact ensemble member count is invalid");
  }
  if (source.game_over) return {};

  bool mirrored = false;
  const State canonical = cfpi::detail::canonicalState(source, mirrored);
  std::array<double, kBoardSize> sums{};
  std::array<int, kBoardSize> counts{};
  sums.fill(0.0);
  Decision result;

  for (int member = 0; member < members; ++member) {
    cfpi::BehaviorOptions options;
    options.max_depth = kDepth;
    options.chance_samples = kChanceSamples;
    options.max_work = kMaximumWork;
    options.max_cache_entries = kMaximumCacheEntries;
    options.policy_seed = kPolicySeeds[member];
    cfpi::detail::SearchContext context(options);
    for (const int column : kColumnOrder) {
      if (!isLegal(canonical.board, column)) continue;
      const double value =
          cfpi::detail::evaluateAction(canonical, column, kDepth, context);
      if (!std::isfinite(value)) {
        throw std::runtime_error("exact ensemble produced non-finite root Q");
      }
      sums[column] += value;
      ++counts[column];
    }
    result.work += context.work;
    result.nodes += context.nodes;
    result.cache_hits += context.cache_hits;
  }

  int winner = -1;
  double winning_value = -std::numeric_limits<double>::infinity();
  for (const int column : kColumnOrder) {
    if (!isLegal(canonical.board, column)) continue;
    if (counts[column] != members) {
      throw std::logic_error("exact ensemble did not complete every legal root");
    }
    const double value = sums[column] / members;
    if (value > winning_value) {
      winning_value = value;
      winner = column;
    }
  }
  if (winner < 0) winner = centerFirstMove(canonical.board);
  result.action = mirrored ? kBoardSize - 1 - winner : winner;
  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) * 1024u;
#endif
}

struct Game {
  std::uint32_t seed = 0;
  std::int64_t score = 0;
  int moves = 0;
  int clears = 0;
  int reveals = 0;
  bool censored = false;
  std::uint64_t work = 0;
  std::uint64_t nodes = 0;
  std::uint64_t cache_hits = 0;
  double seconds = 0;
};

Game runGame(std::uint32_t seed, int members, std::string_view label) {
  const auto started = Clock::now();
  State state = initialHeadlessState(seed);
  Game result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    const Decision decision = chooseAction(state, members);
    if (!isLegal(state.board, decision.action)) {
      throw std::runtime_error("exact ensemble selected an illegal move");
    }
    result.work += decision.work;
    result.nodes += decision.nodes;
    result.cache_hits += decision.cache_hits;
    MoveResult move;
    if (!playHeadlessMove(state, seed, decision.action, move)) {
      throw std::runtime_error("exact ensemble transition failed");
    }
    for (const Wave& wave : move.waves) {
      result.clears += wave.cleared;
      result.reveals += wave.revealed;
    }
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  result.seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  {
    const std::lock_guard<std::mutex> lock(progress_mutex);
    std::cerr << label << " seed 0x" << std::hex << seed << std::dec << ' '
              << result.score << " (" << result.moves << " moves, work "
              << result.work << ")\n";
  }
  return result;
}

struct Summary {
  int games = 0;
  double mean_score = 0;
  double mean_moves = 0;
  double clears_per_move = 0;
  double reveals_per_move = 0;
  double work_per_move = 0;
  double nodes_per_move = 0;
  double cache_hits_per_move = 0;
  double aggregate_seconds = 0;
  int censored = 0;
  std::uint64_t peak_rss_bytes = 0;
};

Summary summarize(const std::vector<Game>& games) {
  if (games.empty()) throw std::invalid_argument("empty exact ensemble cohort");
  Summary result;
  result.games = static_cast<int>(games.size());
  double scores = 0;
  double moves = 0;
  double clears = 0;
  double reveals = 0;
  double work = 0;
  double nodes = 0;
  double cache_hits = 0;
  for (const Game& game : games) {
    scores += game.score;
    moves += game.moves;
    clears += game.clears;
    reveals += game.reveals;
    work += static_cast<double>(game.work);
    nodes += static_cast<double>(game.nodes);
    cache_hits += static_cast<double>(game.cache_hits);
    result.aggregate_seconds += game.seconds;
    result.censored += game.censored;
  }
  result.mean_score = scores / games.size();
  result.mean_moves = moves / games.size();
  result.clears_per_move = clears / moves;
  result.reveals_per_move = reveals / moves;
  result.work_per_move = work / moves;
  result.nodes_per_move = nodes / moves;
  result.cache_hits_per_move = cache_hits / moves;
  result.peak_rss_bytes = peakRssBytes();
  return result;
}

struct Paired {
  double mean_score_delta = 0;
  double mean_move_delta = 0;
  int wins = 0;
  int ties = 0;
  int losses = 0;
};

Paired paired(const std::vector<Game>& baseline,
              const std::vector<Game>& candidate) {
  if (baseline.size() != candidate.size() || baseline.empty()) {
    throw std::invalid_argument("invalid paired exact ensemble cohorts");
  }
  Paired result;
  for (std::size_t index = 0; index < baseline.size(); ++index) {
    const auto score_delta = candidate[index].score - baseline[index].score;
    const int move_delta = candidate[index].moves - baseline[index].moves;
    result.mean_score_delta += score_delta;
    result.mean_move_delta += move_delta;
    if (move_delta > 0) ++result.wins;
    else if (move_delta < 0) ++result.losses;
    else ++result.ties;
  }
  result.mean_score_delta /= baseline.size();
  result.mean_move_delta /= baseline.size();
  return result;
}

struct Cohort {
  std::vector<Game> baseline;
  std::vector<Game> candidate;
  double wall_seconds = 0;
};

Cohort runCohort(std::uint32_t start, int games, int threads,
                 std::string_view phase) {
  Cohort result;
  result.baseline.resize(games);
  result.candidate.resize(games);
  std::atomic<int> next{0};
  std::atomic<bool> failed{false};
  std::mutex error_mutex;
  std::string error_message;
  const auto started = Clock::now();
  std::vector<std::thread> workers;
  for (int worker = 0; worker < std::min(threads, games); ++worker) {
    workers.emplace_back([&]() {
      while (!failed.load(std::memory_order_relaxed)) {
        const int index = next.fetch_add(1, std::memory_order_relaxed);
        if (index >= games) break;
        try {
          const std::uint32_t seed = start + static_cast<std::uint32_t>(index);
          result.baseline[index] =
              runGame(seed, 1, std::string(phase) + "-exact-d3");
          result.candidate[index] =
              runGame(seed, 3, std::string(phase) + "-ensemble-3");
        } catch (const std::exception& error) {
          failed.store(true, std::memory_order_relaxed);
          const std::lock_guard<std::mutex> lock(error_mutex);
          error_message = error.what();
        }
      }
    });
  }
  for (std::thread& worker : workers) worker.join();
  if (failed.load()) {
    throw std::runtime_error("exact ensemble worker failed: " + error_message);
  }
  result.wall_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

void writeSummary(std::ostream& output, const Summary& summary) {
  output << "{\"games\":" << summary.games
         << ",\"meanScore\":" << summary.mean_score
         << ",\"meanMoves\":" << summary.mean_moves
         << ",\"clearsPerMove\":" << summary.clears_per_move
         << ",\"revealsPerMove\":" << summary.reveals_per_move
         << ",\"workPerMove\":" << summary.work_per_move
         << ",\"nodesPerMove\":" << summary.nodes_per_move
         << ",\"cacheHitsPerMove\":" << summary.cache_hits_per_move
         << ",\"aggregateSeconds\":" << summary.aggregate_seconds
         << ",\"censored\":" << summary.censored
         << ",\"peakRssBytes\":" << summary.peak_rss_bytes << '}';
}

void writeGames(std::ostream& output, const std::vector<Game>& games) {
  output << '[';
  for (std::size_t index = 0; index < games.size(); ++index) {
    if (index != 0) output << ',';
    output << "{\"seed\":" << games[index].seed
           << ",\"score\":" << games[index].score
           << ",\"moves\":" << games[index].moves
           << ",\"censored\":" << (games[index].censored ? "true" : "false")
           << '}';
  }
  output << ']';
}

void writeCohort(std::ostream& output, const Cohort& cohort) {
  const Summary baseline = summarize(cohort.baseline);
  const Summary candidate = summarize(cohort.candidate);
  const Paired comparison = paired(cohort.baseline, cohort.candidate);
  output << "{\"baseline\":";
  writeSummary(output, baseline);
  output << ",\"candidate\":";
  writeSummary(output, candidate);
  output << ",\"paired\":{\"meanScoreDelta\":"
         << comparison.mean_score_delta << ",\"meanMoveDelta\":"
         << comparison.mean_move_delta << ",\"wins\":" << comparison.wins
         << ",\"ties\":" << comparison.ties << ",\"losses\":"
         << comparison.losses << "},\"baselineGames\":";
  writeGames(output, cohort.baseline);
  output << ",\"candidateGames\":";
  writeGames(output, cohort.candidate);
  output << ",\"wallSeconds\":" << cohort.wall_seconds << '}';
}

bool selfTest(std::ostream& output) {
  State state;
  state.board = initialBoard();
  state.board[indexOf(5, 0)] = 3;
  state.board[indexOf(5, 1)] = 5;
  state.board[indexOf(5, 4)] = 4;
  state.next_disc = 6;
  state.moves_remaining = 3;
  const Decision first = chooseAction(state, 3);
  const Decision repeat = chooseAction(state, 3);
  cfpi::BehaviorOptions baseline_options;
  baseline_options.max_depth = kDepth;
  baseline_options.chance_samples = kChanceSamples;
  baseline_options.max_work = kMaximumWork;
  baseline_options.max_cache_entries = kMaximumCacheEntries;
  baseline_options.policy_seed = kPolicySeeds[0];
  const int baseline_action =
      cfpi::chooseBehaviorAction(state, baseline_options);
  const Decision direct_baseline = chooseAction(state, 1);
  State reflected = state;
  reflected.board = cfpi::detail::mirrorBoard(state.board);
  const Decision mirror = chooseAction(reflected, 3);
  State metadata = state;
  metadata.score = 999'999;
  metadata.level = 77;
  metadata.moves_played = 321;
  const Decision metadata_decision = chooseAction(metadata, 3);
  const bool deterministic = first.action == repeat.action &&
                             first.work == repeat.work &&
                             first.nodes == repeat.nodes;
  const bool reflection_safe =
      mirror.action == kBoardSize - 1 - first.action;
  const bool public_state_only = metadata_decision.action == first.action;
  const bool legal = isLegal(state.board, first.action);
  const bool bounded = first.work <= kPolicySeeds.size() * kMaximumWork;
  const bool canonical_score = kLevelBonus == 7'000;
  const bool baseline_parity = direct_baseline.action == baseline_action;
  const bool passed = deterministic && reflection_safe && public_state_only &&
                      legal && bounded && canonical_score && baseline_parity;
  output << "{\"passed\":" << (passed ? "true" : "false")
         << ",\"deterministic\":" << (deterministic ? "true" : "false")
         << ",\"reflectionSafe\":" << (reflection_safe ? "true" : "false")
         << ",\"publicStateOnly\":" << (public_state_only ? "true" : "false")
         << ",\"legal\":" << (legal ? "true" : "false")
         << ",\"bounded\":" << (bounded ? "true" : "false")
         << ",\"canonicalScore\":" << (canonical_score ? "true" : "false")
         << ",\"baselineParity\":" << (baseline_parity ? "true" : "false")
         << ",\"action\":" << first.action << ",\"work\":" << first.work
         << "}\n";
  return passed;
}

int run(int argc, char** argv) {
  int threads = 4;
  std::string output_path = "/tmp/drop7-exact-root-ensemble.json";
  for (int index = 1; index < argc; ++index) {
    const std::string_view argument(argv[index]);
    if (argument == "--threads" && index + 1 < argc) {
      threads = std::stoi(argv[++index]);
    } else if (argument == "--output" && index + 1 < argc) {
      output_path = argv[++index];
    } else {
      throw std::invalid_argument("unknown exact ensemble option");
    }
  }
  if (threads < 1 || threads > 64) {
    throw std::invalid_argument("exact ensemble threads must be from 1 to 64");
  }
  if (kConfirmationStart + 8u > kTrainingEnd) {
    throw std::logic_error("exact ensemble cohort leaves training partition");
  }

  const Cohort screen = runCohort(kScreenStart, 4, threads, "screen");
  const Summary screen_baseline = summarize(screen.baseline);
  const Summary screen_candidate = summarize(screen.candidate);
  const bool screen_passed =
      screen_candidate.mean_score > screen_baseline.mean_score &&
      screen_candidate.mean_moves > screen_baseline.mean_moves;

  Cohort confirmation;
  if (screen_passed) {
    confirmation =
        runCohort(kConfirmationStart, 8, threads, "confirmation");
  }

  std::ofstream output(output_path);
  if (!output) throw std::runtime_error("could not write exact ensemble artifact");
  output << std::fixed << std::setprecision(8)
         << "{\n  \"format\":\"drop7-exact-root-ensemble-v1\",\n"
         << "  \"canonicalLevelBonus\":" << kLevelBonus << ",\n"
         << "  \"publicStateOnly\":true,\n"
         << "  \"rootCompleteness\":\"all-legal-actions\",\n"
         << "  \"depth\":" << kDepth << ",\n"
         << "  \"chanceSamplesPerMember\":" << kChanceSamples << ",\n"
         << "  \"policySeeds\":[" << kPolicySeeds[0] << ','
         << kPolicySeeds[1] << ',' << kPolicySeeds[2] << "],\n"
         << "  \"maximumMoves\":" << kMaximumMoves << ",\n"
         << "  \"screenSeedStart\":" << kScreenStart << ",\n"
         << "  \"screen\":";
  writeCohort(output, screen);
  output << ",\n  \"screenPassed\":"
         << (screen_passed ? "true" : "false") << ",\n"
         << "  \"confirmation\":";
  if (screen_passed) writeCohort(output, confirmation);
  else output << "null";
  bool confirmed = false;
  if (screen_passed) {
    const Summary baseline = summarize(confirmation.baseline);
    const Summary candidate = summarize(confirmation.candidate);
    confirmed = candidate.mean_score > baseline.mean_score &&
                candidate.mean_moves > baseline.mean_moves;
  }
  output << ",\n  \"confirmed\":" << (confirmed ? "true" : "false")
         << ",\n  \"decision\":\""
         << (confirmed ? "advance" : "reject") << "\"\n}\n";
  std::cout << std::fixed << std::setprecision(3)
            << "EXACT_ROOT_ENSEMBLE {\"screenBaselineScore\":"
            << screen_baseline.mean_score << ",\"screenCandidateScore\":"
            << screen_candidate.mean_score << ",\"screenBaselineMoves\":"
            << screen_baseline.mean_moves << ",\"screenCandidateMoves\":"
            << screen_candidate.mean_moves << ",\"screenPassed\":"
            << (screen_passed ? "true" : "false")
            << ",\"confirmed\":" << (confirmed ? "true" : "false")
            << ",\"peakRssBytes\":" << peakRssBytes() << "}\n";
  return 0;
}

}  // namespace drop7::exact_root_ensemble

int main(int argc, char** argv) {
  try {
    if (argc == 2 && std::string_view(argv[1]) == "--self-test") {
      return drop7::exact_root_ensemble::selfTest(std::cout) ? EXIT_SUCCESS
                                                             : EXIT_FAILURE;
    }
    return drop7::exact_root_ensemble::run(argc, argv);
  } catch (const std::exception& error) {
    std::cerr << "drop7_exact_root_ensemble: " << error.what() << '\n';
    return EXIT_FAILURE;
  }
}