Drop7 Research
approaches/oracle-curriculum/hindsight-planner/hindsight-planner.cppC++528 lines · 19.0 KB
// Performs bounded hindsight optimization from public root states. Each root
// action is evaluated on the same synthetic future tapes. The planner may optimize
// later actions with knowledge of one synthetic tape, but it never receives the
// real game seed or tape; only the root action averaged across tapes is played.
#define main drop7_oracle_topology_embedded_main
#include "../topology/oracle-topology-audit.cpp"
#undef main

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

namespace drop7::hindsight {

namespace oracle = drop7::oracle_topology;

constexpr std::uint32_t kScreenSeedStart = 0x3e8b'0000u;
constexpr std::uint32_t kConfirmationSeedStart = 0x3e8c'0000u;
constexpr int kScreenGames = 4;
constexpr int kConfirmationGames = 8;
constexpr int kMaximumMoves = 500;
constexpr int kDepth = 8;
constexpr int kBeamWidth = 64;
constexpr int kScenarios = 7;
constexpr double kLowerQuartileWeight = 0.20;
constexpr int kParallelism = 4;
constexpr std::uint32_t kScenarioDomain = 0x4849'4e44u;  // "HIND"
constexpr std::uint32_t kScenarioStride = 0x9e37'79b9u;
constexpr std::uint32_t kAttemptStride = 0x85eb'ca6bu;
constexpr std::uint64_t kMaximumGeneratedPerDecision =
    static_cast<std::uint64_t>(kBoardSize) * kScenarios *
    (1u + static_cast<std::uint64_t>(kDepth - 1) * kBeamWidth * kBoardSize);

static_assert(kLevelBonus == 7'000);
static_assert(kScenarios % kBoardSize == 0);

std::mutex progress_mutex;

struct PlannerMetrics {
  std::uint64_t generated = 0;
  std::uint64_t deduplicated = 0;
  std::size_t peak_candidates = 0;
  std::array<int, kBoardSize> first_next_disc_counts{};
};

struct ActionValue {
  int column = -1;
  double utility = -std::numeric_limits<double>::infinity();
  double mean = -std::numeric_limits<double>::infinity();
  double lower_quartile = -std::numeric_limits<double>::infinity();
  std::array<double, kScenarios> scenarios{};
};

struct Decision {
  int column = -1;
  std::array<ActionValue, kBoardSize> actions{};
  int action_count = 0;
  PlannerMetrics metrics{};
};

std::uint32_t publicHash(const State& canonical) {
  std::uint32_t hash = 0x811c'9dc5u;
  for (const std::uint8_t cell : canonical.board) {
    hash ^= static_cast<std::uint32_t>(cell + 1u);
    hash *= 0x0100'0193u;
  }
  hash ^= static_cast<std::uint32_t>(canonical.next_disc) * 0x9e37'79b9u;
  hash ^= static_cast<std::uint32_t>(canonical.moves_remaining) *
          0x85eb'ca6bu;
  return mix32(hash ^ kScenarioDomain);
}

State normalizedPublicState(const State& input, bool& mirrored) {
  State result = cfpi::detail::canonicalState(input, mirrored);
  // These fields do not change future mechanics. Removing them is also a hard
  // guard against accidentally constructing a synthetic tape from history.
  result.score = 0;
  result.level = 1;
  result.moves_played = 0;
  return result;
}

std::uint32_t scenarioSeed(std::uint32_t public_hash, int scenario) {
  const int target = scenario % kBoardSize + 1;
  const std::uint32_t batch = static_cast<std::uint32_t>(scenario / kBoardSize);
  for (std::uint32_t attempt = 0; attempt < 512; ++attempt) {
    const std::uint32_t candidate = mix32(
        public_hash ^ kScenarioDomain ^ ((batch + 1u) * kScenarioStride) ^
        ((attempt + 1u) * kAttemptStride));
    // The root disc is already public. This is the first unknown visible disc
    // after the root transition because normalized roots start at move zero.
    if (headlessDisc(candidate, 1) == target) return candidate;
  }
  throw std::runtime_error("could not construct stratified synthetic tape");
}

double bestSyntheticContinuation(State root, int first_column,
                                 std::uint32_t tape_seed,
                                 PlannerMetrics& metrics) {
  MoveResult root_move;
  if (!playHeadlessMove(root, tape_seed, first_column, root_move)) {
    return -std::numeric_limits<double>::infinity();
  }
  ++metrics.generated;
  if (root.game_over) return oracle::rankState(root);

  std::vector<oracle::BeamNode> beam{{
      root, first_column, oracle::oracleDynamicKey(root),
      oracle::rankState(root),
  }};
  for (int ply = 1; ply < kDepth; ++ply) {
    std::unordered_map<std::string, oracle::BeamNode> candidates;
    candidates.reserve(static_cast<std::size_t>(kBeamWidth * kBoardSize));
    for (const oracle::BeamNode& node : beam) {
      if (node.state.game_over) {
        const auto found = candidates.find(node.dynamic_key);
        if (found == candidates.end()) {
          candidates.emplace(node.dynamic_key, node);
        } else {
          ++metrics.deduplicated;
          if (node.state.score > found->second.state.score) {
            found->second = node;
          }
        }
        continue;
      }
      int legal_count = 0;
      const auto legal = legalColumns(node.state.board, legal_count);
      for (int offset = 0; offset < legal_count; ++offset) {
        State next = node.state;
        MoveResult move;
        if (!playHeadlessMove(next, tape_seed, legal[offset], move)) continue;
        ++metrics.generated;
        oracle::BeamNode candidate{
            next,
            first_column,
            oracle::oracleDynamicKey(next),
            oracle::rankState(next),
        };
        const auto found = candidates.find(candidate.dynamic_key);
        if (found == candidates.end()) {
          candidates.emplace(candidate.dynamic_key, std::move(candidate));
        } else {
          ++metrics.deduplicated;
          if (candidate.state.score > found->second.state.score) {
            found->second = std::move(candidate);
          }
        }
      }
    }
    if (candidates.empty()) break;
    metrics.peak_candidates =
        std::max(metrics.peak_candidates, candidates.size());
    std::vector<oracle::BeamNode> ranked;
    ranked.reserve(candidates.size());
    for (auto& [key, node] : candidates) {
      (void)key;
      node.rank = oracle::rankState(node.state);
      ranked.push_back(std::move(node));
    }
    std::sort(ranked.begin(), ranked.end(), oracle::betterBeamNode);
    if (static_cast<int>(ranked.size()) > kBeamWidth) {
      ranked.resize(kBeamWidth);
    }
    beam = std::move(ranked);
  }
  if (beam.empty()) return -std::numeric_limits<double>::infinity();
  return std::max_element(
             beam.begin(), beam.end(),
             [](const oracle::BeamNode& first,
                const oracle::BeamNode& second) {
               return oracle::betterBeamNode(second, first);
             })
      ->rank;
}

int tieRank(int column) {
  constexpr std::array<int, kBoardSize> order{{3, 2, 4, 1, 5, 0, 6}};
  for (int rank = 0; rank < kBoardSize; ++rank) {
    if (order[rank] == column) return rank;
  }
  return kBoardSize;
}

void finalize(ActionValue& action) {
  action.mean = std::accumulate(action.scenarios.begin(),
                                action.scenarios.end(), 0.0) /
                kScenarios;
  auto sorted = action.scenarios;
  std::sort(sorted.begin(), sorted.end());
  constexpr int lower_count = (kScenarios + 3) / 4;
  action.lower_quartile =
      std::accumulate(sorted.begin(), sorted.begin() + lower_count, 0.0) /
      lower_count;
  action.utility = (1.0 - kLowerQuartileWeight) * action.mean +
                   kLowerQuartileWeight * action.lower_quartile;
}

Decision chooseMove(const State& input) {
  if (input.game_over) return {};
  bool mirrored = false;
  const State canonical = normalizedPublicState(input, mirrored);
  const std::uint32_t hash = publicHash(canonical);
  Decision result;
  for (int column = 0; column < kBoardSize; ++column) {
    if (!isLegal(canonical.board, column)) continue;
    ActionValue& action = result.actions[result.action_count++];
    action.column = column;
    for (int scenario = 0; scenario < kScenarios; ++scenario) {
      const std::uint32_t tape_seed = scenarioSeed(hash, scenario);
      if (column == 0) {
        ++result.metrics.first_next_disc_counts[headlessDisc(tape_seed, 1) - 1];
      }
      action.scenarios[scenario] = bestSyntheticContinuation(
          canonical, column, tape_seed, result.metrics);
    }
    finalize(action);
  }
  if (result.metrics.generated > kMaximumGeneratedPerDecision) {
    throw std::runtime_error("hindsight planner exceeded its work bound");
  }
  const ActionValue* best = nullptr;
  for (int index = 0; index < result.action_count; ++index) {
    const ActionValue& action = result.actions[index];
    if (best == nullptr || action.utility > best->utility + 1e-9 ||
        (std::abs(action.utility - best->utility) <= 1e-9 &&
         tieRank(action.column) < tieRank(best->column))) {
      best = &action;
    }
  }
  if (best == nullptr) return result;
  result.column = mirrored ? kBoardSize - 1 - best->column : best->column;
  return result;
}

struct GameResult {
  std::int64_t score = 0;
  int moves = 0;
  bool censored = false;
  std::uint64_t generated = 0;
  std::uint64_t deduplicated = 0;
  std::size_t peak_candidates = 0;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
  std::uint64_t waves = 0;
  double seconds = 0;
};

void addThroughput(GameResult& result, const MoveResult& move) {
  result.waves += move.waves.size();
  for (const Wave& wave : move.waves) {
    result.clears += wave.cleared;
    result.reveals += wave.revealed;
  }
}

GameResult runGame(std::uint32_t seed, bool candidate,
                   std::string_view label) {
  const auto started = std::chrono::steady_clock::now();
  State state = initialHeadlessState(seed);
  GameResult result;
  cfpi::BehaviorOptions baseline;
  baseline.max_depth = 3;
  baseline.chance_samples = 5;
  baseline.max_work = 1'000'000;
  baseline.max_cache_entries = 40'000;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    int action = -1;
    if (candidate) {
      const Decision decision = chooseMove(state);
      action = decision.column;
      result.generated += decision.metrics.generated;
      result.deduplicated += decision.metrics.deduplicated;
      result.peak_candidates =
          std::max(result.peak_candidates, decision.metrics.peak_candidates);
    } else {
      action = cfpi::chooseBehaviorAction(state, baseline);
    }
    if (!isLegal(state.board, action)) {
      throw std::runtime_error("public planner selected an illegal action");
    }
    MoveResult move;
    if (!playHeadlessMove(state, seed, action, move)) {
      throw std::runtime_error("paired environment transition failed");
    }
    addThroughput(result, move);
  }
  result.score = state.score;
  result.moves = state.moves_played;
  result.censored = !state.game_over;
  result.seconds = std::chrono::duration<double>(
                       std::chrono::steady_clock::now() - started)
                       .count();
  {
    const std::lock_guard<std::mutex> lock(progress_mutex);
    std::cerr << label << " 0x" << std::hex << seed << std::dec << ' '
              << result.score << '/' << result.moves << '\n';
  }
  return result;
}

struct Cohort {
  std::vector<GameResult> baseline;
  std::vector<GameResult> candidate;
};

Cohort runCohort(std::uint32_t seed_start, int games,
                 std::string_view phase) {
  Cohort result;
  result.baseline.resize(games);
  result.candidate.resize(games);
  std::atomic<int> next{0};
  std::vector<std::future<void>> workers;
  for (int worker = 0; worker < std::min(kParallelism, games); ++worker) {
    workers.push_back(std::async(std::launch::async, [&] {
      for (;;) {
        const int index = next.fetch_add(1);
        if (index >= games) return;
        const std::uint32_t seed = seed_start + index;
        result.baseline[index] =
            runGame(seed, false, std::string(phase) + "-d3");
        result.candidate[index] =
            runGame(seed, true, std::string(phase) + "-hindsight");
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  return result;
}

struct Summary {
  double mean_score = 0;
  double mean_moves = 0;
  double clears_per_move = 0;
  double reveals_per_move = 0;
  double waves_per_move = 0;
  double generated_per_move = 0;
  double seconds_per_move = 0;
  int censored = 0;
};

Summary summarize(const std::vector<GameResult>& games) {
  Summary result;
  std::uint64_t moves = 0;
  std::uint64_t clears = 0;
  std::uint64_t reveals = 0;
  std::uint64_t waves = 0;
  std::uint64_t generated = 0;
  double seconds = 0;
  for (const GameResult& game : games) {
    result.mean_score += game.score / static_cast<double>(games.size());
    result.mean_moves += game.moves / static_cast<double>(games.size());
    result.censored += game.censored;
    moves += game.moves;
    clears += game.clears;
    reveals += game.reveals;
    waves += game.waves;
    generated += game.generated;
    seconds += game.seconds;
  }
  const double count = std::max<std::uint64_t>(1, moves);
  result.clears_per_move = clears / count;
  result.reveals_per_move = reveals / count;
  result.waves_per_move = waves / count;
  result.generated_per_move = generated / count;
  result.seconds_per_move = seconds / count;
  return result;
}

void writeSummary(std::ostream& output, const Summary& summary) {
  output << "{\"meanScore\":" << summary.mean_score
         << ",\"meanMoves\":" << summary.mean_moves
         << ",\"clearsPerMove\":" << summary.clears_per_move
         << ",\"revealsPerMove\":" << summary.reveals_per_move
         << ",\"wavesPerMove\":" << summary.waves_per_move
         << ",\"generatedPerMove\":" << summary.generated_per_move
         << ",\"secondsPerMove\":" << summary.seconds_per_move
         << ",\"censored\":" << summary.censored << '}';
}

bool passedScreen(const Cohort& cohort) {
  const Summary baseline = summarize(cohort.baseline);
  const Summary candidate = summarize(cohort.candidate);
  return candidate.mean_score > baseline.mean_score &&
         candidate.mean_moves > baseline.mean_moves;
}

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
}

bool selfTest(std::ostream& output) {
  State state = initialHeadlessState(0x2d70'8b01u);
  for (const int column : {3, 1, 5}) {
    MoveResult move;
    if (!playHeadlessMove(state, 0x2d70'8b01u, column, move)) return false;
  }
  const Decision first = chooseMove(state);
  const Decision repeated = chooseMove(state);
  State metadata = state;
  metadata.score = 999'999;
  metadata.level = 77;
  metadata.moves_played = 388;
  const Decision blind = chooseMove(metadata);
  State reflected = state;
  reflected.board = cfpi::detail::mirrorBoard(state.board);
  const Decision mirrored = chooseMove(reflected);
  bool strata = true;
  for (const int count : first.metrics.first_next_disc_counts) {
    strata = strata && count == 1;
  }
  const bool passed = first.column >= 0 && first.column == repeated.column &&
                      first.column == blind.column &&
                      mirrored.column == kBoardSize - 1 - first.column &&
                      first.metrics.generated == repeated.metrics.generated &&
                      first.metrics.generated <= kMaximumGeneratedPerDecision &&
                      strata;
  output << "{\"format\":\"drop7-hindsight-self-test-v1\""
         << ",\"canonicalLevelBonus\":" << kLevelBonus
         << ",\"deterministic\":"
         << (first.column == repeated.column ? "true" : "false")
         << ",\"metadataBlind\":"
         << (first.column == blind.column ? "true" : "false")
         << ",\"reflectionSafe\":"
         << (mirrored.column == kBoardSize - 1 - first.column ? "true" : "false")
         << ",\"stratified\":" << (strata ? "true" : "false")
         << ",\"generated\":" << first.metrics.generated
         << ",\"workBound\":" << kMaximumGeneratedPerDecision
         << ",\"passed\":" << (passed ? "true" : "false") << "}\n";
  return passed;
}

int run(std::ostream& console) {
  const auto started = std::chrono::steady_clock::now();
  const Cohort screen = runCohort(kScreenSeedStart, kScreenGames, "screen");
  const bool gate = passedScreen(screen);
  Cohort confirmation;
  if (gate) {
    confirmation = runCohort(kConfirmationSeedStart, kConfirmationGames,
                             "confirmation");
  }
  const std::string path = "/tmp/drop7-hindsight-planner.json";
  std::ofstream output(path);
  if (!output) throw std::runtime_error("could not open hindsight artifact");
  output << std::setprecision(12)
         << "{\n  \"format\":\"drop7-hindsight-planner-v1\","
         << "\n  \"canonicalLevelBonus\":" << kLevelBonus << ','
         << "\n  \"publicStateOnly\":true,"
         << "\n  \"realFutureUsedByPlanner\":false,"
         << "\n  \"configuration\":{\"depth\":" << kDepth
         << ",\"beamWidth\":" << kBeamWidth
         << ",\"scenarios\":" << kScenarios
         << ",\"lowerQuartileWeight\":" << kLowerQuartileWeight
         << ",\"maximumMoves\":" << kMaximumMoves << "},"
         << "\n  \"screen\":{\"seedStart\":\"0x3e8b0000\",\"games\":"
         << kScreenGames << ",\"baseline\":";
  writeSummary(output, summarize(screen.baseline));
  output << ",\"candidate\":";
  writeSummary(output, summarize(screen.candidate));
  output << ",\"passed\":" << (gate ? "true" : "false") << "},"
         << "\n  \"confirmationRead\":" << (gate ? "true" : "false");
  if (gate) {
    output << ",\n  \"confirmation\":{\"seedStart\":\"0x3e8c0000\","
           << "\"games\":" << kConfirmationGames << ",\"baseline\":";
    writeSummary(output, summarize(confirmation.baseline));
    output << ",\"candidate\":";
    writeSummary(output, summarize(confirmation.candidate));
    output << '}';
  }
  output << ",\n  \"peakRssBytes\":" << peakRssBytes()
         << ",\n  \"wallSeconds\":"
         << std::chrono::duration<double>(
                std::chrono::steady_clock::now() - started)
                .count()
         << "\n}\n";
  output.close();
  const Summary baseline = summarize(screen.baseline);
  const Summary candidate = summarize(screen.candidate);
  console << std::fixed << std::setprecision(3)
          << "HINDSIGHT {\"screenPassed\":" << (gate ? "true" : "false")
          << ",\"baselineScore\":" << baseline.mean_score
          << ",\"candidateScore\":" << candidate.mean_score
          << ",\"baselineMoves\":" << baseline.mean_moves
          << ",\"candidateMoves\":" << candidate.mean_moves
          << ",\"confirmationRead\":" << (gate ? "true" : "false")
          << ",\"artifact\":\"" << path << "\"}\n";
  return 0;
}

}  // namespace drop7::hindsight

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