Drop7 Research
approaches/lifetime-objective/chain-reveal-leaf/run.cppC++267 lines · 18.6 KB
// Paired multi-arm cohort runner for the chain-reveal-leaf substrate.
//
// Every arm is the fast fair-D4 search with the one-entry leaf memo and a
// weight vector over the extra terms (extra-terms.hpp); the arm named
// "frozen" is the all-zero vector, which gate.cpp proves bit-identical to the
// unmodified search.  Every arm plays the same ordered seeds so the games are
// paired by seed.
//
//   --arm NAME[:name=value,...]   repeatable; NAME "frozen" must carry no
//                                 non-zero weight
//   --seed-lease ID               REQUIRED: the lease the seeds are read under;
//                                 written into every artifact (the shared
//                                 harness writer's fixed label is replaced)
//   --data-role ROLE              data role written into every artifact
//                                 (default public-development)
//   --shadow                      for each non-frozen arm, also run the
//                                 unmodified fast::FastSearch at every root and
//                                 count decisions whose column differs
//                                 (divergentDecisions): the coverage measure.
//                                 The shadow search never plays a move.
//
// Work is bounded at the worst-case iterative bound (+1) unless --max-work is
// given; completed depth is audited for every decision, and an artifact with
// incomplete decisions is void.
//
// Artifacts: <output> is an index (drop7-chain-reveal-leaf-arms-v1) with the
// per-arm weights, audits and shadow counts; next to it, one
// drop7-lifetime-cohort-v1 file per arm (<output-stem>.<arm>.json, written by
// common/harness.hpp writeArtifact) that fast-engine/analyze.py reads
// directly.  Everything is rewritten after every --chunk completed games, so
// an interrupted run leaves a readable partial file marked "complete": false.

#include "augmented-search.hpp"
#include "fast-search.hpp"
#include "../common/harness.hpp"

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

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

std::uint64_t worstCaseWork(int depth, int strata) {
  const auto b = static_cast<std::uint64_t>(kBoardSize) * static_cast<std::uint64_t>(strata);
  std::uint64_t total = 0;
  for (int d = 1; d <= depth; ++d) { std::uint64_t p = 1; for (int l = 1; l <= d; ++l) { p *= b; total += p; } total += p; }
  return total;
}

struct GameAudit {
  bool done = false;
  std::uint64_t decisions = 0, divergent = 0, shadowWork = 0, memoCalls = 0, memoHits = 0;
};

struct Arm {
  std::string name;
  std::string spec;  // the --arm argument verbatim
  fastx::ExtraWeights weights;
  bool frozen = false;
  std::vector<GameRecord> games;
  std::vector<GameAudit> audits;
  std::atomic<std::uint64_t> decisions{0}, incomplete{0}, illegal{0}, shadowDecisions{0}, divergent{0},
      shadowWork{0}, memoCalls{0}, memoHits{0}, maximumWork{0};
};

std::uint64_t fnv(std::uint64_t h, std::uint64_t v) { for (int i = 0; i < 8; ++i) { h ^= (v >> (8 * i)) & 0xffu; h *= 1099511628211ull; } return h; }
std::uint64_t checksum(const GameRecord& r) { std::uint64_t h = 1469598103934665603ull; h = fnv(h, r.seed); h = fnv(h, static_cast<std::uint64_t>(r.score)); h = fnv(h, static_cast<std::uint64_t>(r.moves)); for (int a : r.actions) h = fnv(h, static_cast<std::uint64_t>(a)); return h; }

std::string armFile(const std::string& output, const std::string& arm) {
  const std::string stem = output.size() > 5 && output.compare(output.size() - 5, 5, ".json") == 0 ? output.substr(0, output.size() - 5) : output;
  std::string safe;
  for (char ch : arm) safe += (std::isalnum(static_cast<unsigned char>(ch)) || ch == '-' || ch == '_') ? ch : '_';
  return stem + "." + safe + ".json";
}

// common/harness.hpp::writeArtifact is shared and frozen by other experiments;
// it labels every cohort "SEEDLEASE-A51D" / "exploratory-development-
// diagnostic".  Replace exactly those two lines with the run's own labels and
// refuse if either line is not where the writer puts it.
std::string relabel(std::string text, const std::string& seedLease, const std::string& dataRole) {
  const std::string leaseLine = "  \"seedLease\": \"SEEDLEASE-A51D\",\n";
  const std::string roleLine = "  \"dataRole\": \"exploratory-development-diagnostic\",\n";
  const auto l = text.find(leaseLine), r = text.find(roleLine);
  if (l == std::string::npos || r == std::string::npos || text.find(leaseLine, l + 1) != std::string::npos || text.find(roleLine, r + 1) != std::string::npos) {
    throw std::runtime_error("harness writeArtifact output changed; cannot relabel seedLease/dataRole");
  }
  text.replace(r, roleLine.size(), "  \"dataRole\": \"" + dataRole + "\",\n");
  text.replace(l, leaseLine.size(), "  \"seedLease\": \"" + seedLease + "\",\n");
  return text;
}

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

int main(int argc, char** argv) {
  try {
    std::vector<std::string> armSpecs; std::string output, label, seedLease, dataRole = "public-development"; std::uint32_t seedStart = 0; bool seedGiven = false, shadow = false, quiet = false;
    int games = 64, maxMoves = 2000, threads = 30, depth = 4, strata = 7, chunk = 0; std::size_t cache = 60'000; std::uint64_t maxWork = 0;
    for (int i = 1; i < argc; ++i) {
      const std::string k = argv[i];
      if (k == "--quiet") { quiet = true; continue; }
      if (k == "--shadow") { shadow = true; continue; }
      if (i + 1 >= argc) throw std::invalid_argument("missing value for " + k);
      const std::string v = argv[++i];
      if (k == "--arm") armSpecs.push_back(v); else if (k == "--output") output = v; else if (k == "--label") label = v;
      else if (k == "--seed-lease") seedLease = v; else if (k == "--data-role") dataRole = v;
      else if (k == "--seed-start") { seedStart = static_cast<std::uint32_t>(std::stoul(v, nullptr, 0)); seedGiven = true; }
      else if (k == "--games") games = std::stoi(v); else if (k == "--max-moves") maxMoves = std::stoi(v); else if (k == "--threads") threads = std::stoi(v);
      else if (k == "--depth") depth = std::stoi(v); else if (k == "--chance-samples") strata = std::stoi(v); else if (k == "--cache") cache = std::stoull(v);
      else if (k == "--max-work") maxWork = std::stoull(v); else if (k == "--chunk") chunk = std::stoi(v);
      else throw std::invalid_argument("unknown option " + k);
    }
    if (armSpecs.empty() || output.empty() || !seedGiven) throw std::invalid_argument("--arm (repeatable), --output and --seed-start are required");
    if (seedLease.empty()) throw std::invalid_argument("--seed-lease ID is required so no cohort can be mislabelled by omission");
    if (dataRole.empty() || dataRole.find('"') != std::string::npos || seedLease.find('"') != std::string::npos) throw std::invalid_argument("bad --seed-lease / --data-role");
    if (games < 1) throw std::invalid_argument("--games must be positive");
    fastx::FastSearchParameters parameters; parameters.depth = depth; parameters.chance_samples = strata;
    parameters.maximum_work = maxWork ? maxWork : worstCaseWork(depth, strata) + 1; parameters.maximum_cache_entries = cache;
    fast::FastSearchParameters shadowParameters; shadowParameters.depth = depth; shadowParameters.chance_samples = strata;
    shadowParameters.maximum_work = parameters.maximum_work; shadowParameters.maximum_cache_entries = cache;

    std::vector<std::unique_ptr<Arm>> arms;
    for (const auto& spec : armSpecs) {
      auto a = std::make_unique<Arm>();
      const auto colon = spec.find(':');
      a->name = spec.substr(0, colon);
      a->spec = spec;
      a->weights = fastx::ExtraWeights::parse(colon == std::string::npos ? "" : spec.substr(colon + 1));
      a->frozen = a->weights.isFrozen();
      if (a->name.empty()) throw std::invalid_argument("empty arm name in " + spec);
      if (a->name == "frozen" && !a->frozen) throw std::invalid_argument("arm 'frozen' must carry no non-zero weight");
      for (const auto& other : arms) if (other->name == a->name) throw std::invalid_argument("duplicate arm name " + a->name);
      a->games.resize(static_cast<std::size_t>(games)); a->audits.resize(static_cast<std::size_t>(games));
      arms.push_back(std::move(a));
    }
    if (chunk < 1) chunk = static_cast<int>(arms.size());
    std::cerr << "chain-reveal-leaf " << label << ": arms"; for (const auto& a : arms) std::cerr << " " << a->name << "[" << a->weights.describe() << "]";
    std::cerr << " x " << games << " games, seeds 0x" << std::hex << seedStart << std::dec << "+, depth " << depth << ", strata " << strata << ", max work " << parameters.maximum_work << ", " << threads << " threads" << (shadow ? ", shadow" : "") << "\n";

    const auto started = std::chrono::steady_clock::now();
    std::mutex writeMutex;
    auto writeAll = [&](bool complete) {
      const std::lock_guard<std::mutex> lock(writeMutex);
      const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
      std::ostringstream armsJson;
      for (std::size_t k = 0; k < arms.size(); ++k) {
        const Arm& a = *arms[k];
        std::vector<GameRecord> done;
        for (std::size_t g = 0; g < a.games.size(); ++g) if (a.audits[g].done) done.push_back(a.games[g]);
        const std::string path = armFile(output, a.name);
        if (!done.empty()) {
          CohortOptions o; o.seedStart = seedStart; o.games = games; o.maximumMoves = maxMoves; o.threads = threads;
          std::ostringstream config;
          config << std::setprecision(12) << "{\"approach\": \"chain-reveal-leaf\", \"arm\": \"" << jsonEscape(a.name) << "\", \"extraWeights\": \"" << a.weights.describe() << "\", \"frozen\": " << (a.frozen ? "true" : "false")
                 << ", \"depth\": " << depth << ", \"chanceSamples\": " << strata << ", \"terminalUtility\": " << parameters.terminal_utility << ", \"maximumWork\": " << parameters.maximum_work
                 << ", \"worstCaseWork\": " << worstCaseWork(depth, strata) << ", \"maximumCacheEntries\": " << cache << ", \"policySeedHex\": \"0x" << std::hex << parameters.policy_seed << std::dec
                 << "\", \"completedGames\": " << done.size() << ", \"requestedGames\": " << games << ", \"complete\": " << (complete ? "true" : "false") << "}";
          const std::string tmp = path + ".tmp";
          std::ostringstream cohort;
          drop7::lifetime::writeArtifact(cohort, "fast fair-D4 + memo + extra terms [" + a.name + "]", config.str(), o, done, wall);
          { std::ofstream out(tmp); if (!out) throw std::runtime_error("cannot open " + tmp);
            out << relabel(cohort.str(), seedLease, dataRole); }
          if (std::rename(tmp.c_str(), path.c_str()) != 0) throw std::runtime_error("cannot rename " + tmp);
        }
        const std::uint64_t calls = a.memoCalls.load(), hits = a.memoHits.load();
        armsJson << (k ? ",\n" : "") << "    {\"name\": \"" << jsonEscape(a.name) << "\", \"weights\": \"" << a.weights.describe() << "\", \"armArg\": \"" << jsonEscape(a.spec) << "\", \"frozen\": " << (a.frozen ? "true" : "false")
                 << ", \"artifact\": \"" << jsonEscape(path) << "\", \"completedGames\": " << done.size()
                 << ",\n      \"decisions\": " << a.decisions << ", \"incompleteDecisions\": " << a.incomplete << ", \"illegalDecisions\": " << a.illegal
                 << ", \"shadowDecisions\": " << a.shadowDecisions << ", \"divergentDecisions\": " << a.divergent << ", \"shadowWork\": " << a.shadowWork
                 << ", \"memoCalls\": " << calls << ", \"memoHits\": " << hits << ", \"memoHitRate\": " << (calls ? static_cast<double>(hits) / static_cast<double>(calls) : 0.0)
                 << ", \"maximumWorkPerDecision\": " << a.maximumWork << ",\n      \"games\": [\n";
        bool first = true;
        for (std::size_t g = 0; g < a.games.size(); ++g) {
          if (!a.audits[g].done) continue;
          const GameRecord& r = a.games[g]; const GameAudit& u = a.audits[g];
          armsJson << (first ? "" : ",\n") << "        {\"seedHex\": \"0x" << std::hex << r.seed << std::dec << "\", \"score\": " << r.score << ", \"moves\": " << r.moves << ", \"censored\": " << (r.censored ? "true" : "false")
                   << ", \"decisions\": " << u.decisions << ", \"divergentDecisions\": " << u.divergent << ", \"memoCalls\": " << u.memoCalls << ", \"memoHits\": " << u.memoHits << ", \"work\": " << r.work
                   << ", \"wallSeconds\": " << r.wallSeconds << ", \"checksumHex\": \"" << std::hex << checksum(r) << std::dec << "\"}";
          first = false;
        }
        armsJson << "\n      ]}";
      }
      std::uint64_t incompleteTotal = 0, illegalTotal = 0;
      for (const auto& a : arms) { incompleteTotal += a->incomplete; illegalTotal += a->illegal; }
      const std::string tmp = output + ".tmp";
      { std::ofstream out(tmp); if (!out) throw std::runtime_error("cannot open " + tmp);
        out << std::setprecision(12) << "{\n  \"format\": \"drop7-chain-reveal-leaf-arms-v1\",\n  \"label\": \"" << jsonEscape(label) << "\",\n  \"engine\": \"fast-engine fair leaf + one-entry memo + weighted extra terms (approaches/lifetime-objective/chain-reveal-leaf)\",\n"
            << "  \"extraTerms\": ["; for (int i = 0; i < fastx::kExtraTerms; ++i) out << (i ? ", " : "") << "\"" << fastx::kExtraNames[i] << "\""; out << "],\n"
            << "  \"config\": {\"depth\": " << depth << ", \"chanceSamples\": " << strata << ", \"terminalUtility\": " << parameters.terminal_utility << ", \"maximumWork\": " << parameters.maximum_work << ", \"worstCaseWork\": " << worstCaseWork(depth, strata)
            << ", \"maximumCacheEntries\": " << cache << ", \"policySeedHex\": \"0x" << std::hex << parameters.policy_seed << std::dec << "\", \"shadow\": " << (shadow ? "true" : "false") << "},\n"
            << "  \"seedLease\": \"" << seedLease << "\",\n  \"dataRole\": \"" << dataRole << "\",\n"
            << "  \"seedStartHex\": \"0x" << std::hex << seedStart << std::dec << "\",\n  \"games\": " << games << ",\n  \"maximumMoves\": " << maxMoves << ",\n  \"threads\": " << threads
            << ",\n  \"complete\": " << (complete ? "true" : "false") << ",\n  \"wallSeconds\": " << wall << ",\n  \"incompleteDecisionsTotal\": " << incompleteTotal << ",\n  \"illegalDecisionsTotal\": " << illegalTotal
            << ",\n  \"arms\": [\n" << armsJson.str() << "\n  ]\n}\n"; }
      if (std::rename(tmp.c_str(), output.c_str()) != 0) throw std::runtime_error("cannot rename " + tmp);
    };

    const int tasks = static_cast<int>(arms.size()) * games;
    std::atomic<int> next{0}, finished{0}; std::mutex logMutex;
    std::vector<std::thread> pool;
    for (int w = 0; w < std::max(1, std::min(threads, tasks)); ++w) pool.emplace_back([&]() {
      for (;;) {
        const int task = next.fetch_add(1); if (task >= tasks) return;
        const int game = task / static_cast<int>(arms.size()); const int which = task % static_cast<int>(arms.size());
        Arm& arm = *arms[static_cast<std::size_t>(which)];
        const std::uint32_t seed = seedStart + static_cast<std::uint32_t>(game);
        fastx::AugmentedFastSearch search{parameters, arm.weights};
        std::unique_ptr<fast::FastSearch> shadowSearch;
        if (shadow && !arm.frozen) shadowSearch = std::make_unique<fast::FastSearch>(shadowParameters);
        GameAudit audit;
        auto decide = [&](const State& s, std::uint64_t& work) {
          fastx::FastSearchMetrics metrics; const int action = search.chooseAction(s, metrics); work += metrics.work;
          ++audit.decisions;
          if (metrics.completed_depth != depth) arm.incomplete.fetch_add(1, std::memory_order_relaxed);
          if (action < 0 || !isLegal(s.board, action)) arm.illegal.fetch_add(1, std::memory_order_relaxed);
          std::uint64_t seen = arm.maximumWork.load(); while (metrics.work > seen && !arm.maximumWork.compare_exchange_weak(seen, metrics.work)) {}
          if (shadowSearch) {
            fast::FastSearchMetrics sm; const int sa = shadowSearch->chooseAction(s, sm);
            audit.shadowWork += sm.work; if (sa != action) ++audit.divergent;
          }
          return action;
        };
        GameRecord record = runGame(seed, decide, maxMoves, true);
        audit.memoCalls = search.leafMemo().calls; audit.memoHits = search.leafMemo().hits; audit.done = true;
        arm.decisions.fetch_add(audit.decisions, std::memory_order_relaxed);
        if (shadowSearch) { arm.shadowDecisions.fetch_add(audit.decisions, std::memory_order_relaxed); arm.divergent.fetch_add(audit.divergent, std::memory_order_relaxed); arm.shadowWork.fetch_add(audit.shadowWork, std::memory_order_relaxed); }
        arm.memoCalls.fetch_add(audit.memoCalls, std::memory_order_relaxed); arm.memoHits.fetch_add(audit.memoHits, std::memory_order_relaxed);
        { const std::lock_guard<std::mutex> lock(writeMutex); arm.games[static_cast<std::size_t>(game)] = std::move(record); arm.audits[static_cast<std::size_t>(game)] = audit; }
        const int done = finished.fetch_add(1) + 1;
        if (!quiet) {
          const std::lock_guard<std::mutex> lock(logMutex);
          const GameRecord& r = arm.games[static_cast<std::size_t>(game)];
          std::cerr << "[" << done << "/" << tasks << "] " << arm.name << " seed 0x" << std::hex << seed << std::dec << " score " << r.score << " moves " << r.moves;
          if (shadowSearch) std::cerr << " divergent " << audit.divergent << "/" << audit.decisions;
          std::cerr << " (" << std::fixed << std::setprecision(1) << r.wallSeconds << "s)\n";
        }
        if (done % chunk == 0 && done < tasks) writeAll(false);
      }
    });
    for (auto& t : pool) t.join();
    writeAll(true);
    const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
    std::uint64_t incompleteTotal = 0, illegalTotal = 0;
    std::cerr << "chain-reveal-leaf " << label << ": done in " << wall << "s";
    for (const auto& a : arms) {
      incompleteTotal += a->incomplete; illegalTotal += a->illegal;
      std::cerr << " | " << a->name << " decisions " << a->decisions;
      if (a->shadowDecisions) std::cerr << " divergent " << a->divergent << "/" << a->shadowDecisions;
      std::cerr << " memo " << (a->memoCalls ? 100.0 * static_cast<double>(a->memoHits) / static_cast<double>(a->memoCalls) : 0.0) << "%";
    }
    std::cerr << "; incomplete " << incompleteTotal << ", illegal " << illegalTotal << "\n";
    if (incompleteTotal != 0) { std::cerr << "ARTIFACT VOID\n"; return 1; }
    return 0;
  } catch (const std::exception& e) { std::cerr << "run failed: " << e.what() << "\n"; return 2; }
}