Drop7 Research
approaches/lifetime-objective/fast-engine/cohort.cppC++258 lines · 11.1 KB
// Whole-game cohort runner on the fast search.
//
// Emits the standard exploratory artifact by including the existing
// approaches/lifetime-objective/common/harness.hpp -- which owns the game loop,
// the enforced score-decomposition identity (score == 17,000*rises +
// 70,000*clears + sum(waveDepthPoints)), the summary statistics and the
// per-game detail -- rather than writing a second one.  Gameplay therefore runs
// through the UNMODIFIED drop7::playHeadlessMove; only the decision comes from
// drop7::fast::FastSearch.  That is deliberate: it isolates the search, and the
// engine itself is gated separately (finding-13 section 4D).
//
// Three things this runner adds over a bare harness call:
//
//  1. COMPLETED-DEPTH AUDIT.  --max-work is passed explicitly from the
//     worst-case bound, never from measured work, and every single decision's
//     completed depth is checked against the requested depth.  A configuration
//     bound sized for one setting silently degraded a depth-4 search to depth 3
//     once already in this repository's history; `incompleteDecisions` must be
//     0 or the arm is void.
//  2. CHUNKED DURABILITY.  Games are run in chunks and the cumulative artifact
//     is rewritten after every chunk, so a runtime kill loses at most one chunk.
//  3. A declared cache capacity, because at these depths the transposition
//     cache evicts and eviction is part of the configuration.

#include "fast-search.hpp"
#include "../../../approaches/lifetime-objective/common/harness.hpp"

#include <atomic>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>

namespace drop7::lifetime::fastcohort {

using drop7::fast::FastSearch;
using drop7::fast::FastSearchMetrics;
using drop7::fast::FastSearchParameters;

// Worst-case iterative-deepening work, exactly the expression
// fair-only-depth4.cpp:worstCaseIterativeWork uses, generalised in the
// branching factor.  This is what --max-work must be sized from.
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;
}

std::uint64_t worstCaseCacheEntries(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 = 2; depth <= maximumDepth; ++depth) {
    std::uint64_t power = 1;
    for (int level = 1; level < depth; ++level) {
      power *= branches;
      total += power;
    }
  }
  return total;
}

struct Audit {
  std::atomic<std::uint64_t> decisions{0};
  std::atomic<std::uint64_t> incomplete{0};
  std::atomic<std::uint64_t> maximumWorkSeen{0};
  std::atomic<std::uint64_t> maximumCacheSeen{0};
  std::atomic<int> minimumCompletedDepth{99};
};

struct Options {
  CohortOptions cohort;
  int depth = 5;
  int strata = 7;
  std::size_t cacheEntries = 60'000;
  std::uint64_t maximumWork = 0;  // 0 => worst-case bound
  int chunk = 16;
  std::string output;
  std::string label;
};

Options parse(int argc, char** argv) {
  Options options;
  options.cohort.games = 64;
  options.cohort.maximumMoves = 2000;
  options.cohort.threads = 16;
  for (int index = 1; index + 1 < argc; index += 2) {
    const std::string key = argv[index];
    const std::string value = argv[index + 1];
    if (key == "--seed-start") {
      options.cohort.seedStart =
          static_cast<std::uint32_t>(std::stoul(value, nullptr, 0));
    } else if (key == "--games") options.cohort.games = std::stoi(value);
    else if (key == "--max-moves") options.cohort.maximumMoves = std::stoi(value);
    else if (key == "--threads") options.cohort.threads = std::stoi(value);
    else if (key == "--depth") options.depth = std::stoi(value);
    else if (key == "--chance-samples") options.strata = std::stoi(value);
    else if (key == "--cache") options.cacheEntries = std::stoull(value);
    else if (key == "--max-work") options.maximumWork = std::stoull(value);
    else if (key == "--chunk") options.chunk = std::stoi(value);
    else if (key == "--output") options.output = value;
    else if (key == "--label") options.label = value;
    else throw std::invalid_argument("unknown option " + key);
  }
  return options;
}

}  // namespace drop7::lifetime::fastcohort

int main(int argc, char** argv) {
  using namespace drop7;
  using namespace drop7::lifetime;
  using namespace drop7::lifetime::fastcohort;
  try {
    Options options = parse(argc, argv);
    const std::uint64_t bound = worstCaseWork(options.depth, options.strata);
    if (options.maximumWork == 0) options.maximumWork = bound + 1;
    if (options.maximumWork <= bound) {
      throw std::runtime_error(
          "--max-work must exceed the worst-case bound " +
          std::to_string(bound) +
          "; a bound that can bind silently degrades the completed depth");
    }
    if (options.output.empty()) throw std::runtime_error("--output is required");

    FastSearchParameters parameters;
    parameters.depth = options.depth;
    parameters.chance_samples = options.strata;
    parameters.maximum_work = options.maximumWork;
    parameters.maximum_cache_entries = options.cacheEntries;

    std::cerr << "arm " << options.label << ": depth " << options.depth
              << ", strata " << options.strata << ", terminalUtility "
              << parameters.terminal_utility << ", policySeed 0x" << std::hex
              << parameters.policy_seed << std::dec << "\n"
              << "  worst-case work " << bound << ", --max-work "
              << options.maximumWork << " (headroom "
              << (options.maximumWork - bound) << ")\n"
              << "  worst-case cache entries "
              << worstCaseCacheEntries(options.depth, options.strata)
              << ", declared capacity " << options.cacheEntries
              << (worstCaseCacheEntries(options.depth, options.strata) >
                          options.cacheEntries
                      ? "  => the cache EVICTS; eviction is part of this "
                        "configuration\n"
                      : "  => the cache never evicts\n")
              << "  seeds 0x" << std::hex << options.cohort.seedStart << "-0x"
              << (options.cohort.seedStart +
                  static_cast<std::uint32_t>(options.cohort.games) - 1u)
              << std::dec << ", " << options.cohort.games << " games, cap "
              << options.cohort.maximumMoves << " moves, "
              << options.cohort.threads << " threads\n";

    Audit audit;
    const auto started = std::chrono::steady_clock::now();
    std::vector<GameRecord> pooled;
    const int chunk = std::max(1, options.chunk);

    for (int done = 0; done < options.cohort.games; done += chunk) {
      CohortOptions slice = options.cohort;
      slice.seedStart =
          options.cohort.seedStart + static_cast<std::uint32_t>(done);
      slice.games = std::min(chunk, options.cohort.games - done);
      slice.threads = std::min(options.cohort.threads, slice.games);

      auto records = runCohort(slice, [&]() {
        return [search = FastSearch{parameters}, &audit](
                   const State& state, std::uint64_t& work) mutable {
          FastSearchMetrics metrics;
          const int action = search.chooseAction(state, metrics);
          work += metrics.work;
          audit.decisions.fetch_add(1, std::memory_order_relaxed);
          if (metrics.completed_depth != search.requestedDepth()) {
            audit.incomplete.fetch_add(1, std::memory_order_relaxed);
          }
          int previous = audit.minimumCompletedDepth.load();
          while (metrics.completed_depth < previous &&
                 !audit.minimumCompletedDepth.compare_exchange_weak(
                     previous, metrics.completed_depth)) {
          }
          std::uint64_t seen = audit.maximumWorkSeen.load();
          while (metrics.work > seen &&
                 !audit.maximumWorkSeen.compare_exchange_weak(seen,
                                                              metrics.work)) {
          }
          std::uint64_t entries = audit.maximumCacheSeen.load();
          const auto used = static_cast<std::uint64_t>(metrics.cache_entries);
          while (used > entries &&
                 !audit.maximumCacheSeen.compare_exchange_weak(entries, used)) {
          }
          return action;
        };
      });
      pooled.insert(pooled.end(), records.begin(), records.end());

      const double wall =
          std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
              .count();
      std::ostringstream config;
      config << std::setprecision(12) << "{\"engine\": \"fast-engine\""
             << ", \"depth\": " << options.depth
             << ", \"chanceSamples\": " << options.strata
             << ", \"terminalUtility\": " << parameters.terminal_utility
             << ", \"maximumWork\": " << options.maximumWork
             << ", \"worstCaseWork\": " << bound
             << ", \"maximumCacheEntries\": " << options.cacheEntries
             << ", \"worstCaseCacheEntries\": "
             << worstCaseCacheEntries(options.depth, options.strata)
             << ", \"decisions\": " << audit.decisions.load()
             << ", \"incompleteDecisions\": " << audit.incomplete.load()
             << ", \"minimumCompletedDepth\": "
             << audit.minimumCompletedDepth.load()
             << ", \"maximumWorkPerDecision\": " << audit.maximumWorkSeen.load()
             << ", \"maximumCacheEntriesUsed\": "
             << audit.maximumCacheSeen.load()
             << ", \"gamesComplete\": " << pooled.size() << "}";
      CohortOptions written = options.cohort;
      written.games = static_cast<int>(pooled.size());
      std::ofstream file(options.output);
      if (!file) throw std::runtime_error("cannot open " + options.output);
      writeArtifact(file, "fast-engine-parameterized-fair-search", config.str(),
                    written, pooled, wall);
      std::cerr << "  chunk done: " << pooled.size() << "/"
                << options.cohort.games << " games, " << wall
                << "s, artifact rewritten\n";
    }

    std::cerr << "AUDIT " << options.label << ": decisions "
              << audit.decisions.load() << ", incompleteDecisions "
              << audit.incomplete.load() << ", minimumCompletedDepth "
              << audit.minimumCompletedDepth.load() << " (requested "
              << options.depth << "), maximumWorkPerDecision "
              << audit.maximumWorkSeen.load() << " of " << options.maximumWork
              << ", maximumCacheEntriesUsed " << audit.maximumCacheSeen.load()
              << " of " << options.cacheEntries << "\n";
    if (audit.incomplete.load() != 0) {
      std::cerr << "ARM VOID: the work bound bound on "
                << audit.incomplete.load() << " decisions\n";
      return 1;
    }
    std::cerr << "ARM OK\n";
    return 0;
  } catch (const std::exception& error) {
    std::cerr << "cohort failed: " << error.what() << '\n';
    return 1;
  }
}