Drop7 Research
approaches/lifetime-objective/suite-validation/posmode.cppC++423 lines · 16.0 KB
// Position-mode evaluation of the scenario suite, plus its CHECK gates.
//
// Usage
//   posmode --self-test
//   posmode --suite <suite.jsonl> --horizon 9 --tapes 16 --policies a,b,c \
//           --threads 8 --jsonl <out.jsonl>
//
// Output is one JSON object per (position, tape, policy).  Analysis lives in
// analyze.py so that the C++ side stays a measurement instrument.

#include "posmode.hpp"

#include <atomic>
#include <chrono>
#include <cstdio>
#include <fstream>
#include <iostream>
#include <mutex>
#include <ostream>
#include <sstream>
#include <thread>

namespace {

using namespace drop7;
using namespace drop7::scenario;
using namespace drop7::suitevalidation;

struct Options {
  std::string suite;
  std::string output;
  int horizon = 9;
  int tapes = 16;
  int threads = 8;
  int limit = 0;
  std::uint32_t tape_offset = 0x0100u;
  std::vector<std::string> policies;
  bool self_test = false;
};

// A position stripped of its randomness: exactly what position mode holds fixed.
struct Position {
  Scenario base;            // the minted record, kept for its start position
  std::string suite_id;     // the minted id, the position's stable name
  std::string origin;
  int occupied = 0;
  int covered = 0;
  std::vector<Scenario> tapes;  // K independent futures for this position
};

std::string jsonField(const std::string& line, const std::string& key) {
  std::string value;
  if (!io_detail::parseString(line, key, value)) return "";
  return value;
}

// ---------------------------------------------------------------------------
// CHECK gates
// ---------------------------------------------------------------------------

// Gate 1: at (depth 4, 5 strata, work 3,200,000) the parameterized driver must
// select exactly the column the frozen reference selects.  This is
// `risk::parityCheck`, run on this work's own lease rather than on the seeds
// finding-05 used.
bool gateComparatorParity() {
  std::cout << "gate 1: comparator parity (d4s5 == frozen chooseDepth4Action)\n";
  return risk::parityCheck(leaseSeed(0x0000u), 3, 40, std::cout);
}

// Gate 2 is `build/scenario/scenario-parity`, not reimplemented here.  It proves
// `ScenarioEngine<StreamRevealSource>` trajectory-identical to
// `drop7::playHeadlessMove` over 8,192 game-plays and 218,470 moves, which is
// the reason a scenario score means anything at all.  `reproduce` runs it.

// Gate 3: the information boundary.  Position mode holds the start position and
// the visible next disc fixed and redraws everything else.  A policy that reads
// only public state must therefore choose the SAME first column on every one of
// the K tapes.  A policy that leaked the tape or the latent board could not.
bool gateInformationBoundary(const std::vector<Position>& positions) {
  std::cout << "gate 3: information boundary (first column invariant across tapes)\n";
  const std::vector<std::string> names{"d2s5", "d2s7", "d3s5", "d3s7", "d4s5",
                                       "d4s7", "center-first", "lowest-column"};
  std::uint64_t checked = 0;
  std::uint64_t violations = 0;
  for (const std::string& name : names) {
    Policy policy(*findPolicy(name));
    for (std::size_t index = 0; index < positions.size() && index < 24; ++index) {
      const Position& position = positions[index];
      int expected = -2;
      for (const Scenario& tape : position.tapes) {
        Mulberry32 rng(1u);
        std::uint64_t work = 0;
        auto engine = makeScenarioEngine(tape);
        const int column = policy.chooseColumn(engine.state(), rng, work);
        if (expected == -2) expected = column;
        ++checked;
        if (column != expected) ++violations;
      }
    }
  }
  std::cout << "  " << checked << " decisions checked, " << violations
            << " violations\n";
  return violations == 0;
}

// Gate 4: determinism and thread-count independence of the whole evaluation.
// Checked in `main` by comparing a 1-thread and an N-thread run of a small
// cohort; this helper hashes a set of records.
std::uint64_t hashRecords(const std::vector<std::string>& rows) {
  std::uint64_t hash = 1469598103934665603ull;
  for (const std::string& row : rows) {
    for (unsigned char byte : row) {
      hash ^= byte;
      hash *= 1099511628211ull;
    }
  }
  return hash;
}

// ---------------------------------------------------------------------------
// Loading and taping
// ---------------------------------------------------------------------------

bool loadPositions(const Options& options, std::vector<Position>& positions) {
  std::ifstream input(options.suite);
  if (!input) {
    std::cerr << "cannot read " << options.suite << "\n";
    return false;
  }
  std::string line;
  int accepted = 0;
  int rejected = 0;
  while (std::getline(input, line)) {
    if (line.empty()) continue;
    Scenario scenario;
    std::string reason;
    if (!deserializeScenario(line, scenario, reason)) {
      ++rejected;
      continue;
    }
    Position position;
    position.base = scenario;
    position.suite_id = scenario.id;
    position.origin = jsonField(line, "origin");
    if (position.origin.empty()) position.origin = "unknown";
    position.occupied = occupiedCells(scenario.board);
    position.covered = coveredCells(scenario.board);
    positions.push_back(std::move(position));
    ++accepted;
    if (options.limit > 0 && accepted >= options.limit) break;
  }
  if (rejected != 0) {
    std::cerr << "refusing to run: " << rejected
              << " suite lines failed self-verification\n";
    return false;
  }
  return !positions.empty();
}

// Tapes are drawn single-threaded and up front, exactly as `mint.cpp` builds its
// candidates, so seed consumption and every drawn future are identical
// regardless of the thread count used to play them.  One lease seed per tape
// index; the per-position stream is that seed spread by splitmix64 over the
// position index, so adding positions never perturbs an existing one.
void drawTapes(const Options& options, std::vector<Position>& positions) {
  for (std::size_t index = 0; index < positions.size(); ++index) {
    Position& position = positions[index];
    for (int tape = 0; tape < options.tapes; ++tape) {
      const std::uint32_t lease =
          leaseSeed(options.tape_offset + static_cast<std::uint32_t>(tape));
      Mulberry32 random(derivedSeed(lease, index));
      Scenario taped;
      if (!retapeAt(position.base, options.horizon, random, taped)) {
        std::cerr << "retape failed for position " << position.suite_id << "\n";
        std::exit(2);
      }
      std::string reason;
      if (!validateScenario(taped, reason)) {
        std::cerr << "retaped scenario invalid: " << reason << "\n";
        std::exit(2);
      }
      position.tapes.push_back(std::move(taped));
    }
  }
}

// ---------------------------------------------------------------------------
// Evaluation
// ---------------------------------------------------------------------------

// The order positions are *processed* in.  Positions are visited in an order
// determined only by the content hash of their id, so that a run stopped early
// leaves a prefix that is an unbiased subsample of the whole suite rather than
// "all the harvested ones".  The order is deterministic and independent of the
// thread count.
std::vector<std::size_t> processingOrder(const std::vector<Position>& positions) {
  std::vector<std::pair<std::uint64_t, std::size_t>> keyed;
  for (std::size_t index = 0; index < positions.size(); ++index) {
    std::uint64_t hash = 1469598103934665603ull;
    for (unsigned char byte : positions[index].suite_id) {
      hash ^= byte;
      hash *= 1099511628211ull;
    }
    keyed.emplace_back(hash, index);
  }
  std::sort(keyed.begin(), keyed.end());
  std::vector<std::size_t> order;
  for (const auto& entry : keyed) order.push_back(entry.second);
  return order;
}

std::vector<std::string> evaluate(const Options& options,
                                  const std::vector<Position>& positions,
                                  const std::vector<PolicySpec>& specs,
                                  int threads, double& wall_seconds,
                                  std::ostream* stream = nullptr) {
  std::vector<std::vector<std::string>> rows(positions.size());
  const std::vector<std::size_t> order = processingOrder(positions);
  std::mutex stream_mutex;
  std::atomic<std::size_t> next{0};
  std::atomic<int> done{0};
  const auto started = std::chrono::steady_clock::now();

  const auto worker = [&]() {
    std::vector<Policy> pool;
    pool.reserve(specs.size());
    for (const PolicySpec& spec : specs) pool.emplace_back(spec);
    for (;;) {
      const std::size_t slot_index = next.fetch_add(1);
      if (slot_index >= positions.size()) return;
      const std::size_t index = order[slot_index];
      const Position& position = positions[index];
      for (std::size_t slot = 0; slot < specs.size(); ++slot) {
        for (int tape = 0; tape < options.tapes; ++tape) {
          // The policy's own randomization stream depends on the position, the
          // tape index and the policy name, never on the tape contents.
          Mulberry32 policy_rng(derivedSeed(
              leaseSeed(0x0080u),
              (static_cast<std::uint64_t>(index) << 20) ^
                  (static_cast<std::uint64_t>(tape) << 8) ^ slot));
          const PlayRecord record = playScenario(
              position.tapes[static_cast<std::size_t>(tape)], pool[slot],
              policy_rng);
          std::ostringstream out;
          out << "{\"position\":\"" << position.suite_id << "\""
              << ",\"origin\":\"" << position.origin << "\""
              << ",\"occupiedStart\":" << position.occupied
              << ",\"coveredStart\":" << position.covered
              << ",\"movesRemaining\":"
              << static_cast<int>(position.base.moves_remaining)
              << ",\"horizon\":" << options.horizon << ",\"tape\":" << tape
              << ",\"tapeId\":\""
              << position.tapes[static_cast<std::size_t>(tape)].id << "\""
              << ",\"policy\":\"" << specs[slot].name << "\""
              << ",\"points\":" << record.points
              << ",\"moves\":" << record.moves
              << ",\"clears\":" << record.clears
              << ",\"reveals\":" << record.reveals
              << ",\"rises\":" << record.rises
              << ",\"maxChainDepth\":" << record.max_chain_depth
              << ",\"died\":" << (record.died ? "true" : "false")
              << ",\"clearedBoard\":"
              << (record.cleared_board ? "true" : "false")
              << ",\"firstColumn\":" << record.first_column
              << ",\"occupiedEnd\":" << record.occupied_end
              << ",\"work\":" << record.work << "}";
          rows[index].push_back(out.str());
        }
      }
      const int completed = ++done;
      if (stream != nullptr) {
        // Stream each finished position immediately, so a run stopped by the
        // resource budget still yields a complete, analysable subsample.
        std::lock_guard<std::mutex> guard(stream_mutex);
        for (const std::string& row : rows[index]) *stream << row << "\n";
        stream->flush();
      }
      if (completed % 4 == 0) {
        std::printf("  %d/%zu positions evaluated (%.0f s)\n", completed,
                    positions.size(),
                    std::chrono::duration<double>(
                        std::chrono::steady_clock::now() - started)
                        .count());
        std::fflush(stdout);
      }
    }
  };

  std::vector<std::thread> workers;
  for (int slot = 0; slot < std::max(1, threads); ++slot) {
    workers.emplace_back(worker);
  }
  for (std::thread& thread : workers) thread.join();
  wall_seconds =
      std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
          .count();

  std::vector<std::string> flat;
  for (std::size_t slot_index = 0; slot_index < order.size(); ++slot_index) {
    for (const std::string& row : rows[order[slot_index]]) flat.push_back(row);
  }
  return flat;
}

}  // namespace

int main(int argc, char** argv) {
  Options options;
  for (int index = 1; index < argc; ++index) {
    const std::string flag = argv[index];
    const auto value = [&]() -> std::string {
      if (index + 1 >= argc) {
        std::cerr << "missing value for " << flag << "\n";
        std::exit(2);
      }
      return argv[++index];
    };
    if (flag == "--suite") {
      options.suite = value();
    } else if (flag == "--jsonl") {
      options.output = value();
    } else if (flag == "--horizon") {
      options.horizon = std::stoi(value());
    } else if (flag == "--tapes") {
      options.tapes = std::stoi(value());
    } else if (flag == "--threads") {
      options.threads = std::stoi(value());
    } else if (flag == "--limit") {
      options.limit = std::stoi(value());
    } else if (flag == "--tape-offset") {
      options.tape_offset =
          static_cast<std::uint32_t>(std::stoul(value(), nullptr, 0));
    } else if (flag == "--policies") {
      std::stringstream stream(value());
      std::string name;
      while (std::getline(stream, name, ',')) options.policies.push_back(name);
    } else if (flag == "--self-test") {
      options.self_test = true;
    } else {
      std::cerr << "unknown option " << flag << "\n";
      return 2;
    }
  }
  if (options.threads > 8) {
    std::cerr << "refusing to use more than 8 threads (shared machine)\n";
    return 2;
  }
  if (options.policies.empty()) {
    for (const PolicySpec& spec : knownPolicies()) {
      options.policies.push_back(spec.name);
    }
  }
  std::vector<PolicySpec> specs;
  for (const std::string& name : options.policies) {
    const PolicySpec* spec = findPolicy(name);
    if (spec == nullptr) {
      std::cerr << "unknown policy " << name << "\n";
      return 2;
    }
    specs.push_back(*spec);
  }

  if (options.self_test) {
    bool ok = gateComparatorParity();
    if (!options.suite.empty()) {
      Options probe = options;
      probe.limit = 24;
      probe.tapes = std::min(options.tapes, 4);
      std::vector<Position> positions;
      if (!loadPositions(probe, positions)) return 2;
      drawTapes(probe, positions);
      ok = gateInformationBoundary(positions) && ok;

      std::cout << "gate 4: determinism and thread-count independence\n";
      std::vector<PolicySpec> small{*findPolicy("d2s5"), *findPolicy("d3s5"),
                                    *findPolicy("random-legal")};
      double wall = 0.0;
      Options tiny = probe;
      tiny.tapes = probe.tapes;
      const auto one = evaluate(tiny, positions, small, 1, wall);
      const auto many = evaluate(tiny, positions, small, 8, wall);
      const bool identical = one == many;
      std::cout << "  1-thread hash " << std::hex << hashRecords(one)
                << ", 8-thread hash " << hashRecords(many) << std::dec << ", "
                << (identical ? "identical" : "DIFFERENT") << "\n";
      ok = identical && ok;
    }
    std::cout << (ok ? "SELF-TEST OK\n" : "SELF-TEST FAILED\n");
    return ok ? 0 : 1;
  }

  if (options.suite.empty()) {
    std::cerr << "--suite <suite.jsonl> is required\n";
    return 2;
  }
  std::vector<Position> positions;
  if (!loadPositions(options, positions)) return 2;
  drawTapes(options, positions);
  std::printf(
      "position mode: %zu positions x %d tapes x %zu policies, horizon %d\n",
      positions.size(), options.tapes, specs.size(), options.horizon);
  std::printf(
      "seed lease SEEDLEASE-A52-SUITE, tape offsets 0x%x..0x%x of 0x%08x\n",
      options.tape_offset,
      options.tape_offset + static_cast<std::uint32_t>(options.tapes) - 1,
      kLeaseStart);

  double wall = 0.0;
  std::ofstream output;
  if (!options.output.empty()) {
    output.open(options.output);
    if (!output) {
      std::cerr << "cannot write " << options.output << "\n";
      return 2;
    }
  }
  const auto rows = evaluate(options, positions, specs, options.threads, wall,
                             options.output.empty() ? nullptr : &output);
  std::printf("evaluated %zu rows in %.1f s on %d threads\n", rows.size(), wall,
              options.threads);
  if (!options.output.empty()) std::printf("wrote %s\n", options.output.c_str());
  return 0;
}