Drop7 Research
approaches/lifetime-objective/scenario/generate.hppC++220 lines · 7.5 KB
#pragma once

// Scenario generation.
//
// Two sampling routes, on purpose:
//
//   * harvested - snapshots of positions a real game actually visits, taken by
//     playing the base engine with the lowest-column policy and then assigning
//     latent values to the covered cells from the lease RNG.  These have the
//     joint structure of real play (column profile, cover geometry, number
//     histogram) that no synthetic sampler reproduces.
//   * synthetic - positions drawn from a controlled occupancy, cover fraction,
//     and number profile, so the suite spans easy-open boards and near-death
//     crowded boards rather than only whatever the harvesting policy produces.
//
// Both routes end in the same `Scenario` record, so the solver and the labels
// cannot tell them apart except through the recorded origin field.

#include "scenario.hpp"

#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>

namespace drop7::scenario {

enum class NumberProfile { kUniform, kLowHeavy, kHighHeavy };

inline const char* numberProfileName(NumberProfile profile) {
  switch (profile) {
    case NumberProfile::kLowHeavy:
      return "low-heavy";
    case NumberProfile::kHighHeavy:
      return "high-heavy";
    default:
      return "uniform";
  }
}

inline std::uint8_t sampleNumber(Mulberry32& random, NumberProfile profile) {
  const std::uint8_t base = random.nextDisc();
  if (profile == NumberProfile::kUniform) return base;
  const std::uint8_t second = random.nextDisc();
  if (profile == NumberProfile::kLowHeavy) return std::min(base, second);
  return std::max(base, second);
}

inline int lowestColumnPolicy(const Board& board) {
  int best = -1;
  int best_height = kBoardSize + 1;
  for (int column = 0; column < kBoardSize; ++column) {
    if (!isLegal(board, column)) continue;
    int height = 0;
    for (int row = 0; row < kBoardSize; ++row) {
      if (board[indexOf(row, column)] != kEmpty) ++height;
    }
    if (height < best_height) {
      best_height = height;
      best = column;
    }
  }
  return best;
}

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

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

// Fills latent values, disc tape, and future risen rows, then stamps the id.
inline bool completeScenario(Scenario& scenario, int horizon,
                             Mulberry32& random) {
  scenario.horizon = static_cast<std::uint8_t>(horizon);
  scenario.latent.fill(0);
  for (int index = 0; index < kCellCount; ++index) {
    const std::uint8_t cell = scenario.board[index];
    if (cell == kSolid || cell == kCracked) {
      scenario.latent[index] = random.nextDisc();
    }
  }
  scenario.disc_tape.clear();
  for (int move = 0; move < horizon; ++move) {
    scenario.disc_tape.push_back(random.nextDisc());
  }
  scenario.rise_latent.clear();
  const int rises = riseRowCount(horizon, scenario.moves_remaining);
  for (int row = 0; row < rises; ++row) {
    RiseRow values{};
    for (int column = 0; column < kBoardSize; ++column) {
      values[column] = random.nextDisc();
    }
    scenario.rise_latent.push_back(values);
  }
  assignScenarioId(scenario);
  std::string reason;
  return validateScenario(scenario, reason);
}

struct HarvestOptions {
  int horizon = 8;
  int warmup_moves = 4;   // never snapshot the opening position
  int max_moves = 400;
};

// Plays one base-engine game with the lowest-column policy and returns a
// uniformly chosen mid-game snapshot as a scenario.  The base engine has no
// latent board (audit-01 M2), so the latent values are drawn here; the visible
// position is exactly one a real game reached.
inline bool harvestScenario(std::uint32_t game_seed, std::uint32_t label_seed,
                            const HarvestOptions& options, Scenario& out) {
  State state = initialHeadlessState(game_seed);
  std::vector<State> visited;
  for (int move = 0; move < options.max_moves; ++move) {
    if (state.game_over) break;
    const int column = lowestColumnPolicy(state.board);
    if (column < 0) break;
    MoveResult result;
    if (!playHeadlessMove(state, game_seed, column, result)) break;
    if (state.game_over) break;
    visited.push_back(state);
  }
  if (static_cast<int>(visited.size()) <= options.warmup_moves) return false;
  Mulberry32 random(label_seed);
  const std::size_t span = visited.size() -
                           static_cast<std::size_t>(options.warmup_moves);
  const std::size_t pick =
      static_cast<std::size_t>(options.warmup_moves) +
      static_cast<std::size_t>(random.nextBits() % span);
  const State& snapshot = visited[pick];

  out = Scenario{};
  out.board = snapshot.board;
  out.moves_remaining = static_cast<std::uint8_t>(snapshot.moves_remaining);
  return completeScenario(out, options.horizon, random);
}

struct SyntheticOptions {
  int horizon = 8;
  int target_cells = 24;
  double cover_fraction = 0.35;
  double cracked_share = 0.25;  // of the covered cells
  NumberProfile profile = NumberProfile::kUniform;
  int moves_remaining = 0;  // 0 draws uniformly from 1..5
  int attempts = 400;
};

// Draws a gravity-settled, popper-free position with a controlled occupancy and
// cover fraction.  Columns are filled bottom-up, which makes settling automatic;
// a draw that lands on an already-poppable position is rejected and redrawn.
inline bool syntheticScenario(std::uint32_t label_seed,
                              const SyntheticOptions& options, Scenario& out) {
  Mulberry32 random(label_seed);
  for (int attempt = 0; attempt < options.attempts; ++attempt) {
    std::array<int, kBoardSize> heights{};
    int remaining = std::min(options.target_cells, kBoardSize * (kBoardSize - 1));
    // Spread the requested cells over columns, capped at 6 so at least one
    // legal column always exists.
    while (remaining > 0) {
      bool placed = false;
      for (int column = 0; column < kBoardSize && remaining > 0; ++column) {
        if (heights[column] >= kBoardSize - 1) continue;
        if (random.nextBits() % 2u == 0u) {
          ++heights[column];
          --remaining;
          placed = true;
        }
      }
      if (!placed && remaining > 0) {
        bool any_room = false;
        for (int column = 0; column < kBoardSize; ++column) {
          if (heights[column] < kBoardSize - 1) any_room = true;
        }
        if (!any_room) break;
      }
    }
    Board board{};
    for (int column = 0; column < kBoardSize; ++column) {
      for (int step = 0; step < heights[column]; ++step) {
        const int row = kBoardSize - 1 - step;
        const double roll = random.nextUnit();
        std::uint8_t cell;
        if (roll < options.cover_fraction) {
          cell = random.nextUnit() < options.cracked_share ? kCracked : kSolid;
        } else {
          cell = sampleNumber(random, options.profile);
        }
        board[indexOf(row, column)] = cell;
      }
    }
    int popper_count = 0;
    findPoppers(board, popper_count);
    if (popper_count != 0) continue;
    int legal_count = 0;
    legalColumns(board, legal_count);
    if (legal_count == 0) continue;

    out = Scenario{};
    out.board = board;
    out.moves_remaining = static_cast<std::uint8_t>(
        options.moves_remaining > 0
            ? options.moves_remaining
            : 1 + static_cast<int>(random.nextBits() % kMovesPerLevel));
    if (completeScenario(out, options.horizon, random)) return true;
  }
  return false;
}

}  // namespace drop7::scenario