Drop7 Research
approaches/lifetime-objective/flow-ceiling/flow-common.hppC++439 lines · 16.7 KB
#pragma once

// Long-game machinery for the flow-ceiling measurement.
//
// The unit of analysis here is a whole game played inside the scenario
// (latent) randomness model, so that a clairvoyant planner and a public
// policy can be given *the same fixed future* and compared move for move.
//
// A `MasterTape` is that fixed future: one start position, one numbered disc
// per absolute move index, and one hidden row per absolute rise index.  Rises
// land on fixed move indices regardless of which columns are chosen
// (`movesRemainingAt` and `risesConsumed` are functions of the move index
// alone), so a master tape defines the same future for every policy.  Nothing
// is redrawn when a policy deviates, which is exactly the property the base
// engine lacks (audit-01 finding M2) and the reason this work runs inside the
// scenario engine at all.
//
// Everything about scoring, cascades, gravity, rises and termination comes from
// `scenario.hpp`, which `scenario-parity.cpp` proves trajectory-identical to
// `drop7::playHeadlessMove`.

#include "../scenario/scenario.hpp"
#include "../scenario/scenario-io.hpp"

#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <sstream>
#include <string>
#include <vector>

namespace drop7::flowceiling {

using namespace drop7;
using namespace drop7::scenario;

constexpr int kMaxWaveDepth = 64;

// The disc-conservation requirement derived in finding-01: five placed discs
// plus seven risen covered discs enter a 49-cell board every five moves.
constexpr double kRequiredClearsPerMove = 12.0 / 5.0;   // 2.400
constexpr double kRequiredRevealsPerMove = 7.0 / 5.0;   // 1.400

// ---------------------------------------------------------------------------
// Master tape
// ---------------------------------------------------------------------------

struct MasterTape {
  std::uint32_t seed = 0;
  Board start_board{};
  LatentBoard start_latent{};
  int start_moves_remaining = kMovesPerLevel;
  std::vector<std::uint8_t> discs;
  std::vector<RiseRow> rises;
};

// A real game start: `drop7::initialBoard()` is an empty board with a solid
// covered bottom row, and a fresh game has five moves before the first rise.
// The seven covered cells need hidden values, which the base engine does not
// define, so they are drawn here from the lease RNG.
inline MasterTape makeMasterTape(std::uint32_t seed, int max_moves) {
  MasterTape tape;
  tape.seed = seed;
  tape.start_board = initialBoard();
  tape.start_moves_remaining = kMovesPerLevel;
  Mulberry32 random(seed);
  tape.start_latent.fill(0);
  for (int index = 0; index < kCellCount; ++index) {
    const std::uint8_t cell = tape.start_board[index];
    if (cell == kSolid || cell == kCracked) {
      tape.start_latent[index] = random.nextDisc();
    }
  }
  // Slack past `max_moves` so the last window can always be cut.
  const int disc_count = max_moves + 2 * kMovesPerLevel + 64;
  for (int move = 0; move < disc_count; ++move) {
    tape.discs.push_back(random.nextDisc());
  }
  const int rise_count = disc_count / kMovesPerLevel + 8;
  for (int row = 0; row < rise_count; ++row) {
    RiseRow values{};
    for (int column = 0; column < kBoardSize; ++column) {
      values[column] = random.nextDisc();
    }
    tape.rises.push_back(values);
  }
  return tape;
}

inline State masterStartState(const MasterTape& tape) {
  State state;
  state.board = tape.start_board;
  state.next_disc = tape.discs.empty() ? 1 : tape.discs[0];
  state.score = 0;
  state.level = 1;
  state.moves_remaining = tape.start_moves_remaining;
  state.moves_played = 0;
  state.game_over = false;
  return state;
}

// Plays one move of the long game.  `move_index` and `rises_consumed` are
// absolute counters into the master tape; everything else is the ordinary
// scenario move loop.
inline bool playMasterMove(const MasterTape& tape, const State& state,
                           const LatentBoard& latent, int move_index,
                           int rises_consumed, int column, MoveResult& result,
                           LatentBoard& latent_out) {
  LatentRevealSource source;
  source.tape = tape.discs.data();
  source.tape_length = static_cast<int>(tape.discs.size());
  source.tape_index = move_index + 1;
  source.rise_rows = tape.rises.data();
  source.rise_count = static_cast<int>(tape.rises.size());
  source.rise_index = rises_consumed;
  return playScenarioMove(state, latent, column, source, result, latent_out);
}

// Cuts a horizon-H scenario out of the long game at the current position.  This
// is the object the exact solver consumes; it is a self-verifying record with a
// content-hash id, so a window can be dumped and re-solved independently.
inline bool cutWindow(const MasterTape& tape, const State& state,
                      const LatentBoard& latent, int move_index,
                      int rises_consumed, int horizon, Scenario& out,
                      std::string& reason) {
  const int available = static_cast<int>(tape.discs.size()) - move_index;
  if (horizon > available) {
    reason = "master tape too short for the window";
    return false;
  }
  const int rows = riseRowCount(horizon, state.moves_remaining);
  if (rises_consumed + rows > static_cast<int>(tape.rises.size())) {
    reason = "master rise rows exhausted";
    return false;
  }
  out = Scenario{};
  out.board = state.board;
  out.latent = latent;
  out.moves_remaining = static_cast<std::uint8_t>(state.moves_remaining);
  out.horizon = static_cast<std::uint8_t>(horizon);
  out.disc_tape.assign(tape.discs.begin() + move_index,
                       tape.discs.begin() + move_index + horizon);
  out.rise_latent.assign(tape.rises.begin() + rises_consumed,
                         tape.rises.begin() + rises_consumed + rows);
  assignScenarioId(out);
  return validateScenario(out, reason);
}

// ---------------------------------------------------------------------------
// Per-move accounting
// ---------------------------------------------------------------------------

inline int occupiedCellCount(const Board& board) {
  int count = 0;
  for (std::uint8_t cell : board) {
    if (cell != kEmpty) ++count;
  }
  return count;
}

inline int coveredCellCount(const Board& board) {
  int count = 0;
  for (std::uint8_t cell : board) {
    if (cell == kSolid || cell == kCracked) ++count;
  }
  return count;
}

struct MoveStat {
  int move_index = 0;        // 1-based within the game
  int column = 0;
  int disc = 0;
  int cleared = 0;           // numbered discs removed by this move's cascades
  int revealed = 0;          // covered cells opened by this move's cascades
  int wave_count = 0;
  int max_wave_depth = 0;
  std::int64_t delta = 0;
  std::int64_t chain_points = 0;
  std::int64_t rise_points = 0;
  std::int64_t clear_points = 0;
  int clear_awards = 0;      // 70,000 bonuses paid by this move: 0, 1 or 2
  bool rise = false;
  bool double_clear_award = false;  // audit-01 M1: two bonuses in one move
  bool fifth_drop_clear = false;    // a clear on the drop that also rises
  int occupied_after = 0;
  int covered_after = 0;
  bool game_over = false;
  bool identity_ok = true;
};

// Splits the move's score delta into the three sources of
// `score = 17,000 x rises + 70,000 x boardClears + sum_waves points`.
inline MoveStat describeMove(int move_index, int column, int disc,
                             const MoveResult& result) {
  MoveStat stat;
  stat.move_index = move_index;
  stat.column = column;
  stat.disc = disc;
  stat.rise = result.level_advanced;
  stat.delta = result.score_delta;
  for (const Wave& wave : result.waves) {
    stat.cleared += wave.cleared;
    stat.revealed += wave.revealed;
    stat.chain_points += wave.points;
    stat.max_wave_depth = std::max(stat.max_wave_depth, wave.depth);
  }
  stat.wave_count = static_cast<int>(result.waves.size());
  stat.rise_points = result.level_advanced ? kLevelBonus : 0;
  stat.clear_points = stat.delta - stat.chain_points - stat.rise_points;
  stat.clear_awards =
      static_cast<int>(stat.clear_points / kClearBonus);
  stat.identity_ok = stat.clear_points >= 0 &&
                     stat.clear_points % kClearBonus == 0 &&
                     stat.clear_awards <= 2;
  stat.double_clear_award = stat.clear_awards >= 2;
  stat.fifth_drop_clear = result.level_advanced && stat.clear_awards > 0;
  stat.occupied_after = occupiedCellCount(result.state.board);
  stat.covered_after = coveredCellCount(result.state.board);
  stat.game_over = result.state.game_over;
  return stat;
}

// ---------------------------------------------------------------------------
// Whole-game accumulation
// ---------------------------------------------------------------------------

struct GameStat {
  std::uint32_t seed = 0;
  std::string policy;
  int moves = 0;
  std::int64_t score = 0;
  std::int64_t cleared = 0;
  std::int64_t revealed = 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;        // moves on which the board was empty at least once
  int clear_awards = 0;       // 70,000 bonuses paid
  int double_clear_awards = 0;
  int fifth_drop_clears = 0;
  int max_wave_depth = 0;
  bool died = false;
  bool censored = false;      // hit the move cap alive
  int incomplete_windows = 0;
  int identity_violations = 0;
  std::int64_t solver_nodes = 0;
  double solve_seconds = 0.0;
  int measure_from = 0;      // warm-up moves played but excluded from stats
  int pv_mismatches = 0;
  std::array<std::int64_t, kMaxWaveDepth> wave_depth_count{};
  std::array<std::int64_t, kMaxWaveDepth> wave_depth_cleared{};
  std::vector<int> cycle_occupancy;   // occupied cells after each rise
  std::vector<int> cycle_covered;
  std::vector<int> move_occupancy;    // occupied cells after each move
  std::vector<int> move_covered;      // covered cells after each move
  std::vector<int> move_revealed;     // covers opened by each move

  void absorb(const MoveStat& stat, const MoveResult& result) {
    ++moves;
    score += stat.delta;
    cleared += stat.cleared;
    revealed += stat.revealed;
    chain_points += stat.chain_points;
    rise_points += stat.rise_points;
    clear_points += stat.clear_points;
    if (stat.rise) ++rises;
    if (stat.clear_awards > 0) ++clear_moves;
    clear_awards += stat.clear_awards;
    if (stat.double_clear_award) ++double_clear_awards;
    if (stat.fifth_drop_clear) ++fifth_drop_clears;
    if (!stat.identity_ok) ++identity_violations;
    max_wave_depth = std::max(max_wave_depth, stat.max_wave_depth);
    for (const Wave& wave : result.waves) {
      const int slot = std::min(wave.depth, kMaxWaveDepth - 1);
      ++wave_depth_count[static_cast<std::size_t>(slot)];
      wave_depth_cleared[static_cast<std::size_t>(slot)] += wave.cleared;
    }
    move_occupancy.push_back(stat.occupied_after);
    move_covered.push_back(stat.covered_after);
    move_revealed.push_back(stat.revealed);
    if (stat.rise) {
      cycle_occupancy.push_back(stat.occupied_after);
      cycle_covered.push_back(stat.covered_after);
    }
  }

  double clearsPerMove() const {
    return moves > 0 ? static_cast<double>(cleared) / moves : 0.0;
  }
  double revealsPerMove() const {
    return moves > 0 ? static_cast<double>(revealed) / moves : 0.0;
  }
};

// Least-squares slope of occupancy against cycle index, in cells per five-move
// cycle.  Positive means the board is filling faster than it is being emptied
// even under the policy being measured.
inline double occupancySlope(const std::vector<int>& occupancy, int skip) {
  const int n = static_cast<int>(occupancy.size()) - skip;
  if (n < 3) return 0.0;
  double sum_x = 0.0;
  double sum_y = 0.0;
  double sum_xy = 0.0;
  double sum_xx = 0.0;
  for (int index = 0; index < n; ++index) {
    const double x = index;
    const double y = occupancy[static_cast<std::size_t>(index + skip)];
    sum_x += x;
    sum_y += y;
    sum_xy += x * y;
    sum_xx += x * x;
  }
  const double denominator = n * sum_xx - sum_x * sum_x;
  if (denominator == 0.0) return 0.0;
  return (n * sum_xy - sum_x * sum_y) / denominator;
}

// ---------------------------------------------------------------------------
// Long-game driver
// ---------------------------------------------------------------------------

// `chooser(state, latent, move_index, rises_consumed)` returns a column, or a
// negative value to resign.  A clairvoyant chooser may read `latent`; a public
// policy must not, and `flow-run.cpp` keeps the two in separate functions so
// the boundary is visible rather than conditional.
// `measure_from` plays the first `measure_from` moves normally but excludes
// them from every statistic.  This is how a horizon or sample-count arm can be
// measured *from a warm mid-game state* rather than from the sparse opening:
// give two arms the same warm-up settings and the same tape and they reach a
// bit-identical state, after which only the measured segment differs.
template <typename Chooser>
inline void playLongGame(const MasterTape& tape, Chooser& chooser,
                         int max_moves, GameStat& out,
                         std::vector<MoveStat>* per_move,
                         int measure_from = 0) {
  State state = masterStartState(tape);
  LatentBoard latent = tape.start_latent;
  int rises_consumed = 0;
  for (int move = 0; move < max_moves; ++move) {
    if (state.game_over) break;
    const int column = chooser(state, latent, move, rises_consumed);
    if (column < 0 || !isLegal(state.board, column)) {
      out.died = true;
      return;
    }
    MoveResult result;
    LatentBoard next_latent{};
    if (!playMasterMove(tape, state, latent, move, rises_consumed, column,
                        result, next_latent)) {
      out.died = true;
      return;
    }
    const MoveStat stat =
        describeMove(move + 1 - measure_from, column, state.next_disc, result);
    if (move >= measure_from) {
      out.absorb(stat, result);
      if (per_move != nullptr) per_move->push_back(stat);
    }
    if (result.level_advanced) ++rises_consumed;
    state = result.state;
    latent = next_latent;
    if (state.game_over) {
      out.died = true;
      return;
    }
  }
  out.censored = true;
}

// ---------------------------------------------------------------------------
// JSON emission
// ---------------------------------------------------------------------------

inline std::string joinInts(const std::vector<int>& values) {
  std::ostringstream out;
  for (std::size_t index = 0; index < values.size(); ++index) {
    if (index != 0) out << ',';
    out << values[index];
  }
  return out.str();
}

inline std::string waveHistogramJson(
    const std::array<std::int64_t, kMaxWaveDepth>& counts) {
  std::ostringstream out;
  out << '{';
  bool first = true;
  for (int depth = 0; depth < kMaxWaveDepth; ++depth) {
    if (counts[static_cast<std::size_t>(depth)] == 0) continue;
    if (!first) out << ',';
    first = false;
    out << '"' << depth << "\":" << counts[static_cast<std::size_t>(depth)];
  }
  out << '}';
  return out.str();
}

inline std::string gameStatJson(const GameStat& game) {
  std::ostringstream out;
  out << "{\"schema\":\"drop7-flow-game-v1\""
      << ",\"seed\":" << game.seed
      << ",\"policy\":\"" << game.policy << "\""
      << ",\"moves\":" << game.moves
      << ",\"score\":" << game.score
      << ",\"cleared\":" << game.cleared
      << ",\"revealed\":" << game.revealed
      << ",\"clearsPerMove\":" << game.clearsPerMove()
      << ",\"revealsPerMove\":" << game.revealsPerMove()
      << ",\"chainPoints\":" << game.chain_points
      << ",\"risePoints\":" << game.rise_points
      << ",\"clearPoints\":" << game.clear_points
      << ",\"rises\":" << game.rises
      << ",\"clearMoves\":" << game.clear_moves
      << ",\"clearAwards\":" << game.clear_awards
      << ",\"doubleClearAwards\":" << game.double_clear_awards
      << ",\"fifthDropClears\":" << game.fifth_drop_clears
      << ",\"maxWaveDepth\":" << game.max_wave_depth
      << ",\"died\":" << (game.died ? "true" : "false")
      << ",\"censored\":" << (game.censored ? "true" : "false")
      << ",\"incompleteWindows\":" << game.incomplete_windows
      << ",\"identityViolations\":" << game.identity_violations
      << ",\"pvMismatches\":" << game.pv_mismatches
      << ",\"solverNodes\":" << game.solver_nodes
      << ",\"solveSeconds\":" << game.solve_seconds
      << ",\"measureFrom\":" << game.measure_from
      << ",\"waveDepthCount\":" << waveHistogramJson(game.wave_depth_count)
      << ",\"waveDepthCleared\":" << waveHistogramJson(game.wave_depth_cleared)
      << ",\"cycleOccupancy\":[" << joinInts(game.cycle_occupancy) << "]"
      << ",\"cycleCovered\":[" << joinInts(game.cycle_covered) << "]"
      << ",\"moveOccupancy\":[" << joinInts(game.move_occupancy) << "]"
      << ",\"moveCovered\":[" << joinInts(game.move_covered) << "]"
      << ",\"moveRevealed\":[" << joinInts(game.move_revealed) << "]"
      << "}";
  return out.str();
}

}  // namespace drop7::flowceiling