Drop7 Research
approaches/fair-expectimax/vertical-ladder/fair-vertical-ladder-depth4.cppC++551 lines · 20.5 KB
#define DROP7_D2_VERTICAL_LADDER_PROBE_LIBRARY
#include "d2-vertical-ladder-probe.cpp"
#undef DROP7_D2_VERTICAL_LADDER_PROBE_LIBRARY

#include <atomic>
#include <fstream>
#include <future>
#include <mutex>

// Uses a fixed vertical-ladder coefficient of 500 in depth-four search. Nothing
// is retuned here: the leaf is the reference fair leaf plus 500 times the
// conservative vertical-ladder
// energy, and every legal action/chance branch is completed at depth four.
namespace drop7::fair_vertical_ladder_depth4 {

namespace ladder = drop7::d2_vertical_ladder_probe;
namespace d4 = drop7::fair_only_depth4;
namespace fair = drop7::fair_only_horizon;
using Clock = std::chrono::steady_clock;

constexpr int kDepth = 4;
constexpr int kChanceSamples = d4::kChanceSamples;
constexpr double kEnergyWeight = 500.0;
constexpr double kTerminalUtility = fair::kTerminalUtility;
constexpr std::uint32_t kTransferStart = 0x3d9f'0000u;
constexpr int kTransferGames = 8;
constexpr int kMaximumMoves = 1'000;
constexpr int kParallelism = 4;
constexpr std::uint64_t kMaximumWork = 3'100'000;
constexpr std::uint64_t kMaximumRssBytes = 128u * 1024u * 1024u;

constexpr std::uint64_t power(std::uint64_t base, int exponent) {
  std::uint64_t result = 1;
  for (int index = 0; index < exponent; ++index) result *= base;
  return result;
}

constexpr std::uint64_t completeWork(int depth) {
  constexpr std::uint64_t branches = kBoardSize * kChanceSamples;
  std::uint64_t transitions = 0;
  for (int ply = 1; ply <= depth; ++ply) transitions += power(branches, ply);
  return transitions + power(branches, depth);
}

constexpr std::uint64_t kWorstCaseWork = completeWork(kDepth);
static_assert(kWorstCaseWork == 3'045'385);
static_assert(kMaximumWork > kWorstCaseWork);
static_assert(kDepth == d4::kCandidateDepth && kChanceSamples == 5);
static_assert(kTransferStart + kTransferGames < 0x3e00'0000u);
static_assert((kTransferStart >> 24u) == 0x3du);

constexpr std::array<int, kBoardSize + 1> kColumnOffsets{{
    0, 1, 10, 91, 820, 7'381, 66'430, 597'871,
}};
constexpr int kColumnStateCount = 5'380'840;
constexpr std::size_t kColumnCacheBytes =
    static_cast<std::size_t>(kColumnStateCount) * sizeof(float);
static_assert(kColumnOffsets.back() + 4'782'969 == kColumnStateCount);
static_assert(kColumnCacheBytes == 21'523'360);

std::mutex progress_mutex;

int columnToken(std::uint8_t cell) {
  if (cell >= 1 && cell <= 7) return static_cast<int>(cell) - 1;
  if (cell == kSolid) return 7;
  if (cell == kCracked) return 8;
  throw std::invalid_argument("invalid occupied ladder token");
}

int columnStateIndex(const Board& board, int column) {
  int height = 0;
  int code = 0;
  for (int row = kBoardSize - 1; row >= 0; --row) {
    const std::uint8_t cell = board[indexOf(row, column)];
    if (cell == kEmpty) continue;
    code = code * 9 + columnToken(cell);
    ++height;
  }
  return kColumnOffsets[height] + code;
}

double cachedColumnEnergy(const Board& board, int column) {
  static std::vector<float> cache(kColumnStateCount, -1.0f);
  const int index = columnStateIndex(board, column);
  std::atomic_ref<float> slot(cache[static_cast<std::size_t>(index)]);
  const float prior = slot.load(std::memory_order_relaxed);
  if (prior >= 0.0f) return prior;
  const float computed = static_cast<float>(
      ladder::verticalLadderColumnFeatures(board, column).energy);
  slot.store(computed, std::memory_order_relaxed);
  return computed;
}

double fastVerticalLadderEnergy(const Board& board) {
  double result = 0.0;
  for (int column = 0; column < kBoardSize; ++column) {
    result += cachedColumnEnergy(board, column);
  }
  return result;
}

struct SearchContext {
  std::uint64_t work = 0;
  std::uint64_t nodes = 0;
};

double leafValue(const State& state, double energy_weight,
                 SearchContext& context) {
  ++context.work;
  const double base = fair::fairLeaf(state);
  if (energy_weight == 0.0 || state.game_over) return base;
  const double result = base + energy_weight *
                                   fastVerticalLadderEnergy(state.board);
  if (!std::isfinite(result)) {
    throw std::runtime_error("vertical ladder D4 leaf was non-finite");
  }
  return result;
}

double bestFutureValue(const State& state, int depth, double energy_weight,
                       SearchContext& context);

struct ActionValue {
  double value = 0.0;
};

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

double bestFutureValue(const State& state, int depth, double energy_weight,
                       SearchContext& context) {
  ++context.nodes;
  if (state.game_over) return kTerminalUtility;
  if (depth == 0) return leafValue(state, energy_weight, context);
  double best = -std::numeric_limits<double>::infinity();
  for (const int column : cfpi::detail::kColumnOrder) {
    if (!isLegal(state.board, column)) continue;
    best = std::max(
        best,
        evaluateAction(state, column, depth, energy_weight, context).value);
  }
  return std::isfinite(best) ? best : kTerminalUtility;
}

struct Root {
  int action = -1;
  std::array<double, kBoardSize> values{};
  SearchContext context{};
};

Root rootDecision(const State& source, double energy_weight) {
  bool mirrored = false;
  const State state = cfpi::detail::canonicalState(source, mirrored);
  Root result;
  result.values.fill(-std::numeric_limits<double>::infinity());
  double best = -std::numeric_limits<double>::infinity();
  int canonical_action = -1;
  for (const int column : cfpi::detail::kColumnOrder) {
    if (!isLegal(state.board, column)) continue;
    const double value =
        evaluateAction(state, column, kDepth, energy_weight, result.context)
            .value;
    result.values[column] = value;
    if (value > best) {
      best = value;
      canonical_action = column;
    }
  }
  if (canonical_action < 0 || result.context.work > kMaximumWork) {
    throw std::runtime_error("vertical ladder D4 did not complete");
  }
  result.action = mirrored ? kBoardSize - 1 - canonical_action
                           : canonical_action;
  if (mirrored) {
    const auto canonical_values = result.values;
    for (int column = 0; column < kBoardSize; ++column) {
      result.values[kBoardSize - 1 - column] = canonical_values[column];
    }
  }
  return result;
}

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

Game runGame(std::uint32_t seed, bool candidate) {
  const auto started = Clock::now();
  State state = initialHeadlessState(seed);
  Game result;
  result.seed = seed;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    int action = -1;
    if (candidate) {
      const Root root = rootDecision(state, kEnergyWeight);
      action = root.action;
      result.work += root.context.work;
    } else {
      const d4::SearchDecision decision = d4::chooseDepth4Action(state);
      if (!decision.complete || decision.completed_depth != kDepth) {
        throw std::runtime_error("stock D4 transfer baseline incomplete");
      }
      action = decision.action;
      result.work += decision.work;
    }
    if (!isLegal(state.board, action)) {
      throw std::runtime_error("vertical ladder D4 returned illegal action");
    }
    MoveResult move;
    if (!playHeadlessMove(state, seed, action, move)) {
      throw std::runtime_error("vertical ladder D4 transition failed");
    }
    for (const Wave& wave : move.waves) {
      result.clears += static_cast<std::uint64_t>(wave.cleared);
      result.reveals += static_cast<std::uint64_t>(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();
  return result;
}

struct Cohort {
  std::array<std::vector<Game>, 2> games;
  double wall_seconds = 0.0;
};

Cohort runCohort() {
  const auto started = Clock::now();
  Cohort result;
  for (auto& values : result.games) values.resize(kTransferGames);
  std::atomic<int> next{0};
  std::vector<std::future<void>> workers;
  constexpr int jobs = 2 * kTransferGames;
  for (int worker = 0; worker < kParallelism; ++worker) {
    workers.push_back(std::async(std::launch::async, [&] {
      for (;;) {
        const int job = next.fetch_add(1);
        if (job >= jobs) return;
        const int game = job / 2;
        const int policy = job % 2;
        const std::uint32_t seed =
            kTransferStart + static_cast<std::uint32_t>(game);
        result.games[policy][game] = runGame(seed, policy == 1);
        const std::lock_guard<std::mutex> lock(progress_mutex);
        const Game& value = result.games[policy][game];
        std::cerr << "ladder-d4 " << (policy == 0 ? "stock" : "candidate")
                  << " seed 0x" << std::hex << seed << std::dec << ' '
                  << value.score << '/' << value.moves << " in "
                  << value.seconds << "s\n";
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  result.wall_seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  return result;
}

struct Summary {
  double mean_score = 0.0;
  double mean_moves = 0.0;
  double lower_quartile_score = 0.0;
  double lower_quartile_moves = 0.0;
  double clears_per_move = 0.0;
  double reveals_per_move = 0.0;
  double work_per_move = 0.0;
  int censored = 0;
};

double quantile(std::vector<double> values, double probability) {
  if (values.empty()) return 0.0;
  std::sort(values.begin(), values.end());
  const double position = probability * static_cast<double>(values.size() - 1);
  const std::size_t lower = static_cast<std::size_t>(std::floor(position));
  const std::size_t upper = static_cast<std::size_t>(std::ceil(position));
  const double fraction = position - static_cast<double>(lower);
  return values[lower] * (1.0 - fraction) + values[upper] * fraction;
}

Summary summarize(const std::vector<Game>& games) {
  Summary result;
  std::vector<double> scores;
  std::vector<double> moves;
  std::uint64_t total_moves = 0;
  for (const Game& game : games) {
    result.mean_score += static_cast<double>(game.score) / games.size();
    result.mean_moves += static_cast<double>(game.moves) / games.size();
    result.censored += game.censored;
    result.clears_per_move += game.clears;
    result.reveals_per_move += game.reveals;
    result.work_per_move += game.work;
    total_moves += static_cast<std::uint64_t>(game.moves);
    scores.push_back(static_cast<double>(game.score));
    moves.push_back(static_cast<double>(game.moves));
  }
  result.lower_quartile_score = quantile(scores, 0.25);
  result.lower_quartile_moves = quantile(moves, 0.25);
  if (total_moves > 0) {
    result.clears_per_move /= static_cast<double>(total_moves);
    result.reveals_per_move /= static_cast<double>(total_moves);
    result.work_per_move /= static_cast<double>(total_moves);
  }
  return result;
}

struct Difference {
  double mean_score = 0.0;
  double mean_moves = 0.0;
  double lower95_score = 0.0;
  double lower95_moves = 0.0;
  double first_half_score = 0.0;
  double first_half_moves = 0.0;
  double second_half_score = 0.0;
  double second_half_moves = 0.0;
  int score_wins = 0;
  int move_wins = 0;
};

double pairedLower95(const std::vector<double>& values) {
  const double mean =
      std::accumulate(values.begin(), values.end(), 0.0) / values.size();
  double squares = 0.0;
  for (const double value : values) squares += (value - mean) * (value - mean);
  const double deviation =
      std::sqrt(squares / static_cast<double>(values.size() - 1));
  constexpr double kT975Df7 = 2.364624;
  return mean - kT975Df7 * deviation / std::sqrt(values.size());
}

Difference compare(const Cohort& cohort) {
  Difference result;
  std::vector<double> scores;
  std::vector<double> moves;
  for (int index = 0; index < kTransferGames; ++index) {
    const Game& stock = cohort.games[0][index];
    const Game& candidate = cohort.games[1][index];
    const double score = static_cast<double>(candidate.score - stock.score);
    const double move = static_cast<double>(candidate.moves - stock.moves);
    scores.push_back(score);
    moves.push_back(move);
    result.mean_score += score / kTransferGames;
    result.mean_moves += move / kTransferGames;
    result.score_wins += score > 0.0;
    result.move_wins += move > 0.0;
    if (index < kTransferGames / 2) {
      result.first_half_score += score / (kTransferGames / 2);
      result.first_half_moves += move / (kTransferGames / 2);
    } else {
      result.second_half_score += score / (kTransferGames / 2);
      result.second_half_moves += move / (kTransferGames / 2);
    }
  }
  result.lower95_score = pairedLower95(scores);
  result.lower95_moves = pairedLower95(moves);
  return result;
}

bool gate(const Summary& stock, const Summary& candidate,
          const Difference& difference) {
  return stock.censored == 0 && candidate.censored == 0 &&
         difference.mean_score > 0.0 && difference.mean_moves > 0.0 &&
         difference.first_half_score > 0.0 &&
         difference.first_half_moves > 0.0 &&
         difference.second_half_score > 0.0 &&
         difference.second_half_moves > 0.0 &&
         difference.score_wins >= 5 && difference.move_wins >= 5 &&
         candidate.clears_per_move >= stock.clears_per_move &&
         candidate.reveals_per_move >= stock.reveals_per_move &&
         candidate.lower_quartile_score >= 0.95 * stock.lower_quartile_score &&
         candidate.lower_quartile_moves >= 0.95 * stock.lower_quartile_moves;
}

void writeSummary(std::ostream& output, const Summary& value) {
  output << "{\"meanScore\":" << value.mean_score
         << ",\"meanMoves\":" << value.mean_moves
         << ",\"lowerQuartileScore\":" << value.lower_quartile_score
         << ",\"lowerQuartileMoves\":" << value.lower_quartile_moves
         << ",\"clearsPerMove\":" << value.clears_per_move
         << ",\"revealsPerMove\":" << value.reveals_per_move
         << ",\"workPerMove\":" << value.work_per_move
         << ",\"censored\":" << value.censored << '}';
}

bool selfTest(std::ostream& output) {
  ladder::selfTest(output);
  State fixture;
  fixture.board.fill(kEmpty);
  fixture.board[indexOf(6, 0)] = kSolid;
  fixture.board[indexOf(6, 1)] = 4;
  fixture.board[indexOf(6, 2)] = 5;
  fixture.board[indexOf(5, 2)] = 4;
  fixture.board[indexOf(6, 4)] = 6;
  fixture.next_disc = 3;
  fixture.moves_remaining = 3;
  const Root zero = rootDecision(fixture, 0.0);
  const d4::SearchDecision stock = d4::chooseDepth4Action(fixture);
  if (zero.action != stock.action || zero.context.work > kMaximumWork) {
    throw std::runtime_error("zero-weight ladder D4 parity failed");
  }
  State metadata = fixture;
  metadata.score = 999'999;
  metadata.level = 87;
  metadata.moves_played = 654;
  if (rootDecision(metadata, kEnergyWeight).action !=
      rootDecision(fixture, kEnergyWeight).action) {
    throw std::runtime_error("ladder D4 used hidden metadata");
  }
  State reflected = fixture;
  reflected.board = cfpi::detail::mirrorBoard(fixture.board);
  if (rootDecision(reflected, kEnergyWeight).action !=
      kBoardSize - 1 - rootDecision(fixture, kEnergyWeight).action) {
    throw std::runtime_error("ladder D4 reflection failed");
  }
  for (std::uint32_t sample = 0; sample < 1'000; ++sample) {
    Board probe{};
    const int column = static_cast<int>(sample % kBoardSize);
    const int height = static_cast<int>((sample / kBoardSize) % 8u);
    std::uint32_t bits = mix32(sample ^ 0x4c41'4444u);
    for (int offset = 0; offset < height; ++offset) {
      bits = mix32(bits + static_cast<std::uint32_t>(offset + 1));
      const int token = static_cast<int>(bits % 9u);
      const std::uint8_t cell =
          token < 7 ? static_cast<std::uint8_t>(token + 1)
                    : static_cast<std::uint8_t>(token == 7 ? kSolid
                                                           : kCracked);
      probe[indexOf(kBoardSize - 1 - offset, column)] = cell;
    }
    const double exact =
        ladder::verticalLadderColumnFeatures(probe, column).energy;
    const double cached = cachedColumnEnergy(probe, column);
    if (exact != cached) {
      throw std::runtime_error("ladder column cache changed exact energy");
    }
  }
  output << "vertical ladder D4 self-tests passed (worst work "
         << kWorstCaseWork << ", column cache " << kColumnCacheBytes
         << " bytes)\n";
  return true;
}

int run(std::string_view output_path, std::ostream& report) {
  const Cohort cohort = runCohort();
  const Summary stock = summarize(cohort.games[0]);
  const Summary candidate = summarize(cohort.games[1]);
  const Difference difference = compare(cohort);
  const bool passed = gate(stock, candidate, difference);
  std::ofstream artifact{std::string(output_path)};
  if (!artifact) throw std::runtime_error("could not open ladder D4 artifact");
  artifact << std::fixed << std::setprecision(9)
           << "{\"experiment\":\"fair-d4-vertical-ladder-transfer\""
           << ",\"seedStart\":" << kTransferStart
           << ",\"games\":" << kTransferGames
           << ",\"trainingOnly\":true,\"energyWeight\":" << kEnergyWeight
           << ",\"depth\":" << kDepth
           << ",\"chanceSamples\":" << kChanceSamples
           << ",\"maximumMoves\":" << kMaximumMoves
           << ",\"stock\":";
  writeSummary(artifact, stock);
  artifact << ",\"candidate\":";
  writeSummary(artifact, candidate);
  artifact << ",\"paired\":{\"scoreDelta\":" << difference.mean_score
           << ",\"moveDelta\":" << difference.mean_moves
           << ",\"scoreLower95\":" << difference.lower95_score
           << ",\"moveLower95\":" << difference.lower95_moves
           << ",\"firstHalfScoreDelta\":" << difference.first_half_score
           << ",\"firstHalfMoveDelta\":" << difference.first_half_moves
           << ",\"secondHalfScoreDelta\":" << difference.second_half_score
           << ",\"secondHalfMoveDelta\":" << difference.second_half_moves
           << ",\"scoreWins\":" << difference.score_wins
           << ",\"moveWins\":" << difference.move_wins
           << "},\"gatePassed\":" << (passed ? "true" : "false")
           << ",\"wallSeconds\":" << cohort.wall_seconds
           << ",\"peakRssBytes\":" << d4::peakRssBytes()
           << ",\"freshGameplaySeedsRead\":false"
           << ",\"protectedSeedsRead\":false}\n";
  artifact.close();
  report << std::fixed << std::setprecision(6)
         << "D4_VERTICAL_LADDER {\"scoreDelta\":" << difference.mean_score
         << ",\"moveDelta\":" << difference.mean_moves
         << ",\"scoreLower95\":" << difference.lower95_score
         << ",\"moveLower95\":" << difference.lower95_moves
         << ",\"passed\":" << (passed ? "true" : "false")
         << ",\"wallSeconds\":" << cohort.wall_seconds << "}\n";
  if (d4::peakRssBytes() > kMaximumRssBytes) {
    throw std::runtime_error("ladder D4 exceeded RSS bound");
  }
  return 0;
}

}  // namespace drop7::fair_vertical_ladder_depth4

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