Drop7 Research
approaches/lifetime-objective/leaf-evolution/evaluate.cppC++315 lines · 13.8 KB
// Population evaluator: plays every individual of a population on the same
// ordered seed block and writes one JSON artifact with per-game rows.
//
// Common random numbers by construction: individual k and individual j play
// seed s from the same disc tape and the same reveal tape, so a paired
// difference between them shares every chance event up to their first
// divergent decision.  The frozen vector is supplied by the caller as an
// ordinary population member when a control arm is wanted; this program has no
// opinion about which line is the control.
//
// Work is bounded at the worst-case iterative bound so it can never bind, and
// every decision's completed depth is audited exactly as fast-engine/cohort.cpp
// does; an artifact with incompleteDecisions != 0 is void.

#include "weighted-search.hpp"
#include "../common/harness.hpp"

#include <atomic>
#include <chrono>
#include <cstdint>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>

namespace {

using namespace drop7;
using drop7::lifetime::GameRecord;
using drop7::lifetime::runGame;

std::uint64_t worstCaseWork(int maximumDepth, int strata) {
  const auto branches =
      static_cast<std::uint64_t>(kBoardSize) * static_cast<std::uint64_t>(strata);
  std::uint64_t total = 0;
  for (int depth = 1; depth <= maximumDepth; ++depth) {
    std::uint64_t power = 1;
    for (int level = 1; level <= depth; ++level) {
      power *= branches;
      total += power;
    }
    total += power;
  }
  return total;
}

struct Individual {
  std::string name;
  fastw::LeafWeights weights;
  std::vector<GameRecord> games;
  std::atomic<std::uint64_t> decisions{0};
  std::atomic<std::uint64_t> incomplete{0};
  std::atomic<std::uint64_t> illegal{0};
  std::atomic<std::uint64_t> maximumWork{0};
};

struct Options {
  std::string population;
  std::string output;
  std::string label;
  std::uint32_t seedStart = 0;
  int games = 32;
  int maximumMoves = 2000;
  int threads = 30;
  int depth = 4;
  int strata = 7;
  std::size_t cache = 60'000;
  bool quiet = false;
};

Options parse(int argc, char** argv) {
  Options o;
  bool seedGiven = false;
  for (int i = 1; i < argc; ++i) {
    const std::string key = argv[i];
    if (key == "--quiet") { o.quiet = true; continue; }
    if (i + 1 >= argc) throw std::invalid_argument("missing value for " + key);
    const std::string value = argv[++i];
    if (key == "--population") o.population = value;
    else if (key == "--output") o.output = value;
    else if (key == "--label") o.label = value;
    else if (key == "--seed-start") { o.seedStart = static_cast<std::uint32_t>(std::stoul(value, nullptr, 0)); seedGiven = true; }
    else if (key == "--games") o.games = std::stoi(value);
    else if (key == "--max-moves") o.maximumMoves = std::stoi(value);
    else if (key == "--threads") o.threads = std::stoi(value);
    else if (key == "--depth") o.depth = std::stoi(value);
    else if (key == "--chance-samples") o.strata = std::stoi(value);
    else if (key == "--cache") o.cache = std::stoull(value);
    else throw std::invalid_argument("unknown option " + key);
  }
  if (o.population.empty()) throw std::invalid_argument("--population is required");
  if (o.output.empty()) throw std::invalid_argument("--output is required");
  if (!seedGiven) throw std::invalid_argument("--seed-start is required");
  if (o.games < 1 || o.threads < 1) throw std::invalid_argument("bad games/threads");
  return o;
}

std::uint64_t fnv1a(std::uint64_t hash, std::uint64_t value) {
  for (int byte = 0; byte < 8; ++byte) {
    hash ^= (value >> (8 * byte)) & 0xffu;
    hash *= 1099511628211ull;
  }
  return hash;
}

std::uint64_t checksum(const GameRecord& r) {
  std::uint64_t h = 1469598103934665603ull;
  h = fnv1a(h, r.seed);
  h = fnv1a(h, static_cast<std::uint64_t>(r.score));
  h = fnv1a(h, static_cast<std::uint64_t>(r.moves));
  for (int a : r.actions) h = fnv1a(h, static_cast<std::uint64_t>(a));
  return h;
}

std::string jsonEscape(const std::string& s) {
  std::string out;
  for (char c : s) {
    if (c == '"' || c == '\\') out += '\\';
    out += c;
  }
  return out;
}

}  // namespace

int main(int argc, char** argv) {
  try {
    const Options o = parse(argc, argv);
    const std::uint64_t bound = worstCaseWork(o.depth, o.strata);

    fastw::FastSearchParameters parameters;
    parameters.depth = o.depth;
    parameters.chance_samples = o.strata;
    parameters.maximum_work = bound + 1;
    parameters.maximum_cache_entries = o.cache;

    std::vector<std::unique_ptr<Individual>> population;
    {
      std::ifstream file(o.population);
      if (!file) throw std::runtime_error("cannot open " + o.population);
      std::string line;
      while (std::getline(file, line)) {
        std::string name;
        fastw::LeafWeights weights;
        if (!fastw::parsePopulationLine(line, name, weights)) continue;
        for (const auto& existing : population) {
          if (existing->name == name) throw std::runtime_error("duplicate individual " + name);
        }
        auto individual = std::make_unique<Individual>();
        individual->name = name;
        individual->weights = weights;
        individual->games.resize(static_cast<std::size_t>(o.games));
        population.push_back(std::move(individual));
      }
    }
    if (population.empty()) throw std::runtime_error("population is empty");

    std::cerr << "evaluate " << o.label << ": " << population.size()
              << " individuals x " << o.games << " games, seeds 0x" << std::hex
              << o.seedStart << "-0x" << (o.seedStart + static_cast<std::uint32_t>(o.games) - 1u)
              << std::dec << ", depth " << o.depth << ", strata " << o.strata
              << ", cache " << o.cache << ", max-work " << parameters.maximum_work
              << " (worst case " << bound << "), " << o.threads << " threads\n";

    const int tasks = static_cast<int>(population.size()) * o.games;
    std::atomic<int> next{0};
    std::atomic<int> finished{0};
    std::mutex logMutex;
    const auto started = std::chrono::steady_clock::now();
    const int threads = std::max(1, std::min(o.threads, tasks));
    std::vector<std::thread> pool;
    for (int worker = 0; worker < threads; ++worker) {
      pool.emplace_back([&]() {
        for (;;) {
          const int task = next.fetch_add(1);
          if (task >= tasks) return;
          // game-major order: all individuals on seed g before seed g+1, so a
          // partially finished artifact is still seed-aligned across the
          // population
          const int game = task / static_cast<int>(population.size());
          const int which = task % static_cast<int>(population.size());
          Individual& individual = *population[static_cast<std::size_t>(which)];
          const std::uint32_t seed = o.seedStart + static_cast<std::uint32_t>(game);
          fastw::WeightedFastSearch search{parameters, individual.weights};
          auto decide = [&](const State& state, std::uint64_t& work) {
            fastw::FastSearchMetrics metrics;
            const int action = search.chooseAction(state, metrics);
            work += metrics.work;
            individual.decisions.fetch_add(1, std::memory_order_relaxed);
            if (metrics.completed_depth != o.depth) {
              individual.incomplete.fetch_add(1, std::memory_order_relaxed);
            }
            if (action < 0 || !isLegal(state.board, action)) {
              individual.illegal.fetch_add(1, std::memory_order_relaxed);
            }
            std::uint64_t seen = individual.maximumWork.load();
            while (metrics.work > seen &&
                   !individual.maximumWork.compare_exchange_weak(seen, metrics.work)) {
            }
            return action;
          };
          individual.games[static_cast<std::size_t>(game)] =
              runGame(seed, decide, o.maximumMoves, true);
          const int done = finished.fetch_add(1) + 1;
          if (o.quiet) continue;
          const GameRecord& r = individual.games[static_cast<std::size_t>(game)];
          const std::lock_guard<std::mutex> lock(logMutex);
          std::cerr << "[" << done << "/" << tasks << "] " << individual.name
                    << " seed 0x" << std::hex << seed << std::dec << " score "
                    << r.score << " moves " << r.moves << " (" << std::fixed
                    << std::setprecision(1) << r.wallSeconds << "s)\n";
        }
      });
    }
    for (std::thread& t : pool) t.join();
    const double wall =
        std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();

    std::uint64_t incompleteTotal = 0, illegalTotal = 0;
    std::ofstream out(o.output);
    if (!out) throw std::runtime_error("cannot open " + o.output);
    out << std::setprecision(12);
    out << "{\n  \"format\": \"drop7-leaf-evolution-population-v1\",\n";
    out << "  \"label\": \"" << jsonEscape(o.label) << "\",\n";
    out << "  \"engine\": \"fast-engine weighted leaf (approaches/lifetime-objective/leaf-evolution)\",\n";
    out << "  \"config\": {\"depth\": " << o.depth << ", \"chanceSamples\": " << o.strata
        << ", \"terminalUtility\": " << parameters.terminal_utility
        << ", \"maximumWork\": " << parameters.maximum_work
        << ", \"worstCaseWork\": " << bound
        << ", \"maximumCacheEntries\": " << o.cache
        << ", \"policySeedHex\": \"0x" << std::hex << parameters.policy_seed << std::dec << "\"},\n";
    out << "  \"seedStartHex\": \"0x" << std::hex << o.seedStart << std::dec << "\",\n";
    out << "  \"games\": " << o.games << ",\n";
    out << "  \"maximumMoves\": " << o.maximumMoves << ",\n";
    out << "  \"threads\": " << o.threads << ",\n";
    out << "  \"wallSeconds\": " << wall << ",\n";
    out << "  \"leafTerms\": [";
    for (int i = 0; i < fastw::kLeafTerms; ++i) out << (i ? ", " : "") << "\"" << fastw::kLeafNames[i] << "\"";
    out << "],\n";
    out << "  \"individuals\": [\n";
    for (std::size_t k = 0; k < population.size(); ++k) {
      const Individual& ind = *population[k];
      incompleteTotal += ind.incomplete.load();
      illegalTotal += ind.illegal.load();
      std::int64_t scoreTotal = 0;
      std::uint64_t moveTotal = 0, clearedTotal = 0, revealedTotal = 0, workTotal = 0;
      int censored = 0, identityFailures = 0;
      std::vector<double> scores, moves;
      for (const GameRecord& r : ind.games) {
        scoreTotal += r.score;
        moveTotal += static_cast<std::uint64_t>(r.moves);
        clearedTotal += r.numberedCleared;
        revealedTotal += r.coversRevealed;
        workTotal += r.work;
        if (r.censored) ++censored;
        if (r.levelPoints + r.clearPoints + r.chainPoints != r.score) ++identityFailures;
        scores.push_back(static_cast<double>(r.score));
        moves.push_back(static_cast<double>(r.moves));
      }
      const double n = static_cast<double>(ind.games.size());
      out << (k ? ",\n" : "") << "    {\"name\": \"" << jsonEscape(ind.name) << "\", \"frozen\": "
          << (ind.weights.isFrozen() ? "true" : "false") << ", \"weights\": [";
      for (int i = 0; i < fastw::kLeafTerms; ++i) out << (i ? ", " : "") << ind.weights.w[i];
      out << "],\n      \"decisions\": " << ind.decisions.load()
          << ", \"incompleteDecisions\": " << ind.incomplete.load()
          << ", \"illegalDecisions\": " << ind.illegal.load()
          << ", \"maximumWorkPerDecision\": " << ind.maximumWork.load()
          << ", \"scoreIdentityFailures\": " << identityFailures
          << ", \"censoredGames\": " << censored
          << ",\n      \"score\": {\"mean\": " << static_cast<double>(scoreTotal) / n
          << ", \"median\": " << drop7::lifetime::quantile(scores, 0.5)
          << ", \"q25\": " << drop7::lifetime::quantile(scores, 0.25)
          << ", \"min\": " << drop7::lifetime::quantile(scores, 0.0)
          << ", \"max\": " << drop7::lifetime::quantile(scores, 1.0) << "}"
          << ", \"moves\": {\"mean\": " << static_cast<double>(moveTotal) / n
          << ", \"q25\": " << drop7::lifetime::quantile(moves, 0.25) << "}"
          << ", \"numberedClearsPerMove\": " << (moveTotal ? static_cast<double>(clearedTotal) / static_cast<double>(moveTotal) : 0.0)
          << ", \"coverRevealsPerMove\": " << (moveTotal ? static_cast<double>(revealedTotal) / static_cast<double>(moveTotal) : 0.0)
          << ", \"workPerMove\": " << (moveTotal ? static_cast<double>(workTotal) / static_cast<double>(moveTotal) : 0.0)
          << ",\n      \"games\": [\n";
      for (std::size_t g = 0; g < ind.games.size(); ++g) {
        const GameRecord& r = ind.games[g];
        out << (g ? ",\n" : "") << "        {\"seedHex\": \"0x" << std::hex << r.seed << std::dec
            << "\", \"score\": " << r.score << ", \"moves\": " << r.moves
            << ", \"censored\": " << (r.censored ? "true" : "false")
            << ", \"rises\": " << r.rises << ", \"boardClears\": " << r.boardClears
            << ", \"chainPoints\": " << r.chainPoints
            << ", \"numberedCleared\": " << r.numberedCleared
            << ", \"coversRevealed\": " << r.coversRevealed
            << ", \"maxChainDepth\": " << r.maxChainDepth
            << ", \"meanOccupiedCells\": " << r.meanOccupancy
            << ", \"work\": " << r.work << ", \"wallSeconds\": " << r.wallSeconds
            << ", \"checksumHex\": \"" << std::hex << checksum(r) << std::dec << "\"}";
      }
      out << "\n      ]}";
    }
    out << "\n  ],\n  \"incompleteDecisionsTotal\": " << incompleteTotal
        << ",\n  \"illegalDecisionsTotal\": " << illegalTotal << "\n}\n";
    std::cerr << "evaluate " << o.label << ": done in " << wall << "s; incomplete "
              << incompleteTotal << ", illegal " << illegalTotal << "\n";
    if (incompleteTotal != 0) {
      std::cerr << "ARTIFACT VOID: incomplete decisions\n";
      return 1;
    }
    return 0;
  } catch (const std::exception& error) {
    std::cerr << "evaluate failed: " << error.what() << '\n';
    return 2;
  }
}