Drop7 Research
approaches/lifetime-objective/flow-ceiling/pv-replay.cppC++421 lines · 15.9 KB
// pv-replay - replay a clairvoyant principal variation and measure its flow.
//
//   pv-replay --input <solved.jsonl> [--jsonl <per-move.jsonl>]
//             [--also-fair] [--verbose <scenario-id>]
//
// `<solved.jsonl>` is the output of
// `build/scenario/solve --input <suite.jsonl> --jsonl <solved.jsonl>`: every
// line carries the full scenario record plus `principalVariation`, `optimum`,
// `optimalMovesSurvived`, `optimalClearCount` and `optimalMaxChainDepth`.
//
// The replay re-runs the stored line through the scenario engine and reports,
// per move and aggregated:
//
//   * numbered discs cleared and covered cells revealed  (the flow rates that
//     finding-01 shows must reach 2.400 and 1.400 per move for a policy to
//     survive indefinitely),
//   * wave depths,
//   * score split into row-rise bonus, board-clear bonus and chain waves,
//   * board occupancy before and after the line.
//
// The replayed score is checked against the stored `optimum` on every scenario;
// a mismatch is reported rather than absorbed.
//
// `--also-fair` plays the frozen fair depth-4 comparator over the same
// scenario, the same tape and the same latent board, so the clairvoyant flow
// rate has a paired public-information control measured by the same code.

#include "fair-only-depth4-noentry.cpp"

#include "flow-common.hpp"

#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>

namespace {

using namespace drop7;
using namespace drop7::scenario;
using namespace drop7::flowceiling;
namespace d4 = drop7::fair_only_depth4;

// Identical to `mint.cpp`'s `fairAction` for depth four: the repository
// comparator, unmodified, reading only public state.
int fairDepth4Action(const State& state) {
  if (state.game_over) return -1;
  const d4::SearchDecision decision = d4::chooseDepth4Action(state);
  return decision.action;
}

struct SolvedLine {
  Scenario scenario;
  std::vector<int> pv;
  std::int64_t optimum = 0;
  bool complete = false;
};

bool parseSolvedLine(const std::string& line, SolvedLine& out,
                     std::string& error) {
  if (!deserializeScenario(line, out.scenario, error)) return false;
  std::vector<std::uint8_t> columns;
  if (!io_detail::parseFlatArrayKey(line, "principalVariation", columns)) {
    error = "principalVariation missing";
    return false;
  }
  out.pv.clear();
  for (std::uint8_t column : columns) out.pv.push_back(column);
  long long optimum = 0;
  if (!io_detail::parseInt(line, "optimum", optimum)) {
    error = "optimum missing";
    return false;
  }
  out.optimum = optimum;
  out.complete = line.find("\"complete\":true") != std::string::npos;
  return true;
}

// One replayed line, accumulated with exactly the accounting `flow-common.hpp`
// uses for whole games.
struct LineStat {
  GameStat game;
  int start_occupied = 0;
  int start_covered = 0;
  int end_occupied = 0;
  int end_covered = 0;
  std::int64_t replayed = 0;
  bool matched_optimum = false;
};

template <typename Chooser>
LineStat replayLine(const Scenario& scenario, Chooser& chooser,
                    std::vector<MoveStat>* per_move) {
  LineStat out;
  out.start_occupied = occupiedCellCount(scenario.board);
  out.start_covered = coveredCellCount(scenario.board);
  out.end_occupied = out.start_occupied;
  out.end_covered = out.start_covered;
  auto engine = makeScenarioEngine(scenario);
  for (int move = 0; move < scenario.horizon; ++move) {
    if (engine.state().game_over) break;
    const int column = chooser(engine.state(), move);
    if (column < 0 || !isLegal(engine.state().board, column)) break;
    const int disc = engine.state().next_disc;
    MoveResult result;
    if (!engine.play(column, result)) break;
    const MoveStat stat = describeMove(move + 1, column, disc, result);
    out.game.absorb(stat, result);
    if (per_move != nullptr) per_move->push_back(stat);
    out.replayed += stat.delta;
    out.end_occupied = stat.occupied_after;
    out.end_covered = stat.covered_after;
    if (engine.state().game_over) {
      out.game.died = true;
      break;
    }
  }
  return out;
}

struct Aggregate {
  int lines = 0;
  int died = 0;
  std::int64_t moves = 0;
  std::int64_t cleared = 0;
  std::int64_t revealed = 0;
  std::int64_t score = 0;
  std::int64_t chain_points = 0;
  std::int64_t rise_points = 0;
  std::int64_t clear_points = 0;
  int rises = 0;
  int clear_moves = 0;
  int clear_awards = 0;
  int double_clear_awards = 0;
  int fifth_drop_clears = 0;
  int identity_violations = 0;
  int max_wave_depth = 0;
  std::int64_t start_occupied = 0;
  std::int64_t end_occupied = 0;
  std::int64_t start_covered = 0;
  std::int64_t end_covered = 0;
  std::array<std::int64_t, kMaxWaveDepth> wave_depth_count{};
  std::array<std::int64_t, kMaxWaveDepth> wave_depth_cleared{};
  std::vector<double> per_line_clears;
  std::vector<double> per_line_reveals;

  void absorb(const LineStat& line) {
    ++lines;
    if (line.game.died) ++died;
    moves += line.game.moves;
    cleared += line.game.cleared;
    revealed += line.game.revealed;
    score += line.game.score;
    chain_points += line.game.chain_points;
    rise_points += line.game.rise_points;
    clear_points += line.game.clear_points;
    rises += line.game.rises;
    clear_moves += line.game.clear_moves;
    clear_awards += line.game.clear_awards;
    double_clear_awards += line.game.double_clear_awards;
    fifth_drop_clears += line.game.fifth_drop_clears;
    identity_violations += line.game.identity_violations;
    max_wave_depth = std::max(max_wave_depth, line.game.max_wave_depth);
    start_occupied += line.start_occupied;
    end_occupied += line.end_occupied;
    start_covered += line.start_covered;
    end_covered += line.end_covered;
    for (int depth = 0; depth < kMaxWaveDepth; ++depth) {
      wave_depth_count[static_cast<std::size_t>(depth)] +=
          line.game.wave_depth_count[static_cast<std::size_t>(depth)];
      wave_depth_cleared[static_cast<std::size_t>(depth)] +=
          line.game.wave_depth_cleared[static_cast<std::size_t>(depth)];
    }
    if (line.game.moves > 0) {
      per_line_clears.push_back(line.game.clearsPerMove());
      per_line_reveals.push_back(line.game.revealsPerMove());
    }
  }
};

double mean(const std::vector<double>& values) {
  if (values.empty()) return 0.0;
  double total = 0.0;
  for (double value : values) total += value;
  return total / static_cast<double>(values.size());
}

void reportAggregate(const char* label, const Aggregate& aggregate) {
  std::printf("\n=== %s ===\n", label);
  std::printf("lines %d, died inside the horizon %d, moves played %lld\n",
              aggregate.lines, aggregate.died,
              static_cast<long long>(aggregate.moves));
  const double moves = static_cast<double>(aggregate.moves);
  std::printf(
      "clears/move %.4f   (requirement 2.4000)   reveals/move %.4f   "
      "(requirement 1.4000)\n",
      moves > 0 ? aggregate.cleared / moves : 0.0,
      moves > 0 ? aggregate.revealed / moves : 0.0);
  std::printf("per-line mean clears/move %.4f   reveals/move %.4f\n",
              mean(aggregate.per_line_clears), mean(aggregate.per_line_reveals));
  std::printf("score %lld = rise %lld + clear %lld + chain %lld\n",
              static_cast<long long>(aggregate.score),
              static_cast<long long>(aggregate.rise_points),
              static_cast<long long>(aggregate.clear_points),
              static_cast<long long>(aggregate.chain_points));
  if (aggregate.score > 0) {
    const double total = static_cast<double>(aggregate.score);
    std::printf("share  rise %.2f%%  clear %.2f%%  chain %.2f%%\n",
                100.0 * aggregate.rise_points / total,
                100.0 * aggregate.clear_points / total,
                100.0 * aggregate.chain_points / total);
  }
  std::printf(
      "rises %d, board-clear moves %d, 70k awards %d, double awards %d, "
      "fifth-drop clears %d, identity violations %d\n",
      aggregate.rises, aggregate.clear_moves, aggregate.clear_awards,
      aggregate.double_clear_awards, aggregate.fifth_drop_clears,
      aggregate.identity_violations);
  std::printf("occupancy mean start %.2f -> end %.2f (covered %.2f -> %.2f)\n",
              aggregate.lines > 0
                  ? static_cast<double>(aggregate.start_occupied) /
                        aggregate.lines
                  : 0.0,
              aggregate.lines > 0
                  ? static_cast<double>(aggregate.end_occupied) / aggregate.lines
                  : 0.0,
              aggregate.lines > 0
                  ? static_cast<double>(aggregate.start_covered) /
                        aggregate.lines
                  : 0.0,
              aggregate.lines > 0
                  ? static_cast<double>(aggregate.end_covered) / aggregate.lines
                  : 0.0);
  std::int64_t waves = 0;
  for (std::int64_t count : aggregate.wave_depth_count) waves += count;
  std::printf("waves %lld, deepest %d\n", static_cast<long long>(waves),
              aggregate.max_wave_depth);
  std::printf("depth  waves    share   discs cleared\n");
  for (int depth = 1; depth < kMaxWaveDepth; ++depth) {
    const std::int64_t count =
        aggregate.wave_depth_count[static_cast<std::size_t>(depth)];
    if (count == 0) continue;
    std::printf("%5d  %6lld  %6.2f%%  %10lld\n", depth,
                static_cast<long long>(count),
                waves > 0 ? 100.0 * static_cast<double>(count) / waves : 0.0,
                static_cast<long long>(
                    aggregate.wave_depth_cleared[static_cast<std::size_t>(
                        depth)]));
  }
}

struct Options {
  std::string input;
  std::string jsonl_output;
  std::string verbose_id;
  bool also_fair = false;
};

int run(const Options& options) {
  std::ifstream input(options.input);
  if (!input) {
    std::cerr << "cannot open " << options.input << "\n";
    return 2;
  }
  std::ofstream jsonl;
  if (!options.jsonl_output.empty()) {
    jsonl.open(options.jsonl_output);
    if (!jsonl) {
      std::cerr << "cannot write " << options.jsonl_output << "\n";
      return 2;
    }
  }

  Aggregate optimal;
  Aggregate fair;
  int mismatches = 0;
  int incomplete = 0;
  std::string line;
  int line_number = 0;
  while (std::getline(input, line)) {
    ++line_number;
    if (line.find_first_not_of(" \t\r\n") == std::string::npos) continue;
    SolvedLine solved;
    std::string error;
    if (!parseSolvedLine(line, solved, error)) {
      std::cerr << options.input << ":" << line_number << ": " << error << "\n";
      return 2;
    }
    if (!solved.complete) ++incomplete;

    std::vector<MoveStat> per_move;
    auto pv_chooser = [&solved](const State&, int move) {
      return move < static_cast<int>(solved.pv.size()) ? solved.pv[move] : -1;
    };
    LineStat stat = replayLine(solved.scenario, pv_chooser, &per_move);
    stat.matched_optimum = stat.replayed == solved.optimum;
    if (!stat.matched_optimum) ++mismatches;
    optimal.absorb(stat);

    LineStat fair_stat;
    if (options.also_fair) {
      auto fair_chooser = [](const State& state, int) {
        return fairDepth4Action(state);
      };
      fair_stat = replayLine(solved.scenario, fair_chooser, nullptr);
      fair.absorb(fair_stat);
    }

    if (jsonl) {
      jsonl << "{\"schema\":\"drop7-pv-replay-v1\",\"id\":\""
            << solved.scenario.id << "\""
            << ",\"horizon\":" << static_cast<int>(solved.scenario.horizon)
            << ",\"optimum\":" << solved.optimum
            << ",\"replayed\":" << stat.replayed
            << ",\"matchedOptimum\":" << (stat.matched_optimum ? "true" : "false")
            << ",\"startOccupied\":" << stat.start_occupied
            << ",\"startCovered\":" << stat.start_covered
            << ",\"endOccupied\":" << stat.end_occupied
            << ",\"endCovered\":" << stat.end_covered
            << ",\"moves\":" << stat.game.moves
            << ",\"cleared\":" << stat.game.cleared
            << ",\"revealed\":" << stat.game.revealed
            << ",\"clearsPerMove\":" << stat.game.clearsPerMove()
            << ",\"revealsPerMove\":" << stat.game.revealsPerMove()
            << ",\"risePoints\":" << stat.game.rise_points
            << ",\"clearPoints\":" << stat.game.clear_points
            << ",\"chainPoints\":" << stat.game.chain_points
            << ",\"clearAwards\":" << stat.game.clear_awards
            << ",\"doubleClearAwards\":" << stat.game.double_clear_awards
            << ",\"fifthDropClears\":" << stat.game.fifth_drop_clears
            << ",\"maxWaveDepth\":" << stat.game.max_wave_depth
            << ",\"died\":" << (stat.game.died ? "true" : "false");
      if (options.also_fair) {
        jsonl << ",\"fairMoves\":" << fair_stat.game.moves
              << ",\"fairCleared\":" << fair_stat.game.cleared
              << ",\"fairRevealed\":" << fair_stat.game.revealed
              << ",\"fairScore\":" << fair_stat.game.score
              << ",\"fairMaxWaveDepth\":" << fair_stat.game.max_wave_depth
              << ",\"fairDied\":" << (fair_stat.game.died ? "true" : "false");
      }
      jsonl << ",\"perMove\":[";
      for (std::size_t index = 0; index < per_move.size(); ++index) {
        const MoveStat& move = per_move[index];
        if (index != 0) jsonl << ',';
        jsonl << "{\"move\":" << move.move_index << ",\"col\":" << move.column
              << ",\"disc\":" << move.disc << ",\"cleared\":" << move.cleared
              << ",\"revealed\":" << move.revealed
              << ",\"waves\":" << move.wave_count
              << ",\"maxDepth\":" << move.max_wave_depth
              << ",\"delta\":" << move.delta
              << ",\"chain\":" << move.chain_points
              << ",\"rise\":" << move.rise_points
              << ",\"clearBonus\":" << move.clear_points
              << ",\"clearAwards\":" << move.clear_awards
              << ",\"occupied\":" << move.occupied_after
              << ",\"covered\":" << move.covered_after << "}";
      }
      jsonl << "]}\n";
    }

    if (!options.verbose_id.empty() &&
        options.verbose_id == std::string(solved.scenario.id)) {
      std::printf("scenario %s H=%d optimum=%lld replayed=%lld\n",
                  solved.scenario.id,
                  static_cast<int>(solved.scenario.horizon),
                  static_cast<long long>(solved.optimum),
                  static_cast<long long>(stat.replayed));
      for (const MoveStat& move : per_move) {
        std::printf(
            "  move %2d col %d disc %d cleared %2d revealed %2d waves %2d "
            "maxdepth %2d delta %8lld (chain %7lld rise %6lld clear %6lld) "
            "occupied %2d covered %2d\n",
            move.move_index, move.column, move.disc, move.cleared,
            move.revealed, move.wave_count, move.max_wave_depth,
            static_cast<long long>(move.delta),
            static_cast<long long>(move.chain_points),
            static_cast<long long>(move.rise_points),
            static_cast<long long>(move.clear_points), move.occupied_after,
            move.covered_after);
      }
    }
  }

  std::printf("replayed %d line(s); optimum mismatches %d; solver-incomplete "
              "lines %d\n",
              optimal.lines, mismatches, incomplete);
  reportAggregate("clairvoyant optimal line", optimal);
  if (options.also_fair) {
    reportAggregate("fair depth-4 on the same scenarios", fair);
  }
  return mismatches == 0 ? 0 : 1;
}

}  // namespace

int main(int argc, char** argv) {
  Options options;
  for (int index = 1; index < argc; ++index) {
    const std::string flag = argv[index];
    if (flag == "--input" && index + 1 < argc) {
      options.input = argv[++index];
    } else if (flag == "--jsonl" && index + 1 < argc) {
      options.jsonl_output = argv[++index];
    } else if (flag == "--verbose" && index + 1 < argc) {
      options.verbose_id = argv[++index];
    } else if (flag == "--also-fair") {
      options.also_fair = true;
    } else {
      std::cerr << "unknown argument " << flag << "\n";
      return 2;
    }
  }
  if (options.input.empty()) {
    std::cerr << "usage: pv-replay --input <solved.jsonl> [--jsonl out.jsonl] "
                 "[--also-fair] [--verbose <id>]\n";
    return 2;
  }
  return run(options);
}