Drop7 Research
approaches/lifetime-objective/flow-ceiling/fair-planner.hppC++219 lines · 8.6 KB
#pragma once

// A receding-horizon planner that may NOT read the hidden board.
//
// Why this exists
// ---------------
// `docs/exploratory/finding-06-flow-ceiling.md` measured a clairvoyant
// receding-horizon planner sustaining the disc-conservation requirement, and
// described the gap to fair depth 4 as "a control gap, not an information gap".
// That measurement cannot support the claim, because the clairvoyant planner is
// privileged twice over: it plans exactly over a window *and* it knows which
// covered disc holds which number, so it knows exactly which covers are worth
// cracking.  An unknown share of its advantage is hidden information that no
// legal policy can ever recover.
//
// This header separates the two privileges by removing them one at a time.
//
//   * `latent_known = false`  - the hidden values under covered cells are
//     unknown.  The planner draws `samples` independent completions of the
//     hidden board from the correct i.i.d. uniform 1..7 marginal, solves the
//     window exactly against each, and plays the move with the best mean value.
//   * `tape_known = false`    - the future numbered discs and the hidden values
//     of future risen rows are unknown too, and are drawn from the same
//     marginal.  Only the next visible disc, which is public state, is used.
//
// With both false the planner reads exactly what `docs/methodology.md` permits
// a deployable policy to read: the visible board, the visible next disc, and
// the moves remaining until the next rise.  The resulting policy is legal.  The
// planner is a determinized / hindsight-optimization (PIMC) controller: within
// a sample it assumes the drawn hidden values will turn out to be true, which
// makes its *value estimates* optimistically biased, but the action it emits is
// a function of public state alone, so the flow rate it achieves in play is an
// achievable-by-a-legal-policy rate.  That is the quantity in question.
//
// Common random numbers: every candidate column inside one decision is scored
// against the same `samples` hidden boards, via `solveWindowRoot`, which
// returns the exact value of every legal root move under one completion.  The
// comparison between columns is therefore paired at the sample level.

#include "flow-common.hpp"
#include "flow-solver.hpp"

#include <array>
#include <atomic>
#include <cstdint>
#include <thread>
#include <vector>

namespace drop7::flowceiling {

struct FairPlannerConfig {
  Objective objective = Objective::kClears;
  int horizon = 7;
  int samples = 8;
  bool latent_known = false;
  bool tape_known = false;
  // Solves the K sampled windows on a thread pool.  The K scenarios are still
  // drawn *serially* from the sampler stream before any of them is solved, so
  // the sequence of sampled worlds — and therefore the decision — is bit
  // identical to a single-threaded run at the same seed.  Only the solving is
  // parallel.  This keeps the K series in finding-07 directly comparable.
  int sample_threads = 1;
  WindowLimits limits{};
};

// Builds one sampled window from public state plus a draw of everything hidden.
// `truth` supplies the hidden quantities that this configuration is allowed to
// know; the rest come from `random`.
inline bool sampleWindow(const FairPlannerConfig& config, const MasterTape& tape,
                         const State& state, const LatentBoard& truth,
                         int move_index, int rises_consumed, Mulberry32& random,
                         Scenario& out, std::string& reason) {
  out = Scenario{};
  out.board = state.board;
  out.moves_remaining = static_cast<std::uint8_t>(state.moves_remaining);
  out.horizon = static_cast<std::uint8_t>(config.horizon);

  out.latent.fill(0);
  for (int index = 0; index < kCellCount; ++index) {
    const std::uint8_t cell = state.board[index];
    if (cell != kSolid && cell != kCracked) continue;
    out.latent[index] =
        config.latent_known ? truth[index] : random.nextDisc();
  }

  // The disc already in hand is public state in every configuration.
  out.disc_tape.clear();
  out.disc_tape.push_back(state.next_disc);
  for (int step = 1; step < config.horizon; ++step) {
    if (config.tape_known) {
      const std::size_t at = static_cast<std::size_t>(move_index + step);
      if (at >= tape.discs.size()) {
        reason = "master tape too short for the window";
        return false;
      }
      out.disc_tape.push_back(tape.discs[at]);
    } else {
      out.disc_tape.push_back(random.nextDisc());
    }
  }

  const int rows = riseRowCount(config.horizon, state.moves_remaining);
  out.rise_latent.clear();
  for (int row = 0; row < rows; ++row) {
    if (config.tape_known) {
      const std::size_t at = static_cast<std::size_t>(rises_consumed + row);
      if (at >= tape.rises.size()) {
        reason = "master rise rows exhausted";
        return false;
      }
      out.rise_latent.push_back(tape.rises[at]);
    } else {
      RiseRow values{};
      for (int column = 0; column < kBoardSize; ++column) {
        values[column] = random.nextDisc();
      }
      out.rise_latent.push_back(values);
    }
  }
  assignScenarioId(out);
  return validateScenario(out, reason);
}

// One decision.  Returns the chosen column, or -1 if none could be scored.
// `truth` is passed only so that a `latent_known` arm can read it; when
// `config.latent_known` is false this function provably never touches it, and
// `flow-run --self-test` checks that by re-deciding the same public position
// against a different hidden board and requiring the same answer.
struct FairDecision {
  int column = -1;
  int samples_used = 0;
  int incomplete = 0;
  std::int64_t nodes = 0;
  double wall_seconds = 0.0;
};

inline FairDecision fairDecision(const FairPlannerConfig& config,
                                 const MasterTape& tape, const State& state,
                                 const LatentBoard& truth, int move_index,
                                 int rises_consumed, Mulberry32& random) {
  FairDecision decision;
  std::array<std::int64_t, kBoardSize> total{};
  std::array<int, kBoardSize> counted{};
  std::array<bool, kBoardSize> legal{};

  // Draw every sampled world first, serially, so the stream is deterministic.
  std::vector<Scenario> windows;
  windows.reserve(static_cast<std::size_t>(config.samples));
  for (int sample = 0; sample < config.samples; ++sample) {
    Scenario window;
    std::string reason;
    if (!sampleWindow(config, tape, state, truth, move_index, rises_consumed,
                      random, window, reason)) {
      break;
    }
    windows.push_back(std::move(window));
  }

  std::vector<RootResult> results(windows.size());
  const int threads = std::max(1, config.sample_threads);
  if (threads <= 1 || windows.size() < 2) {
    for (std::size_t index = 0; index < windows.size(); ++index) {
      results[index] =
          solveWindowRoot(windows[index], config.objective, config.limits);
    }
  } else {
    std::atomic<std::size_t> next{0};
    std::vector<std::thread> pool;
    const int workers =
        std::min(threads, static_cast<int>(windows.size()));
    for (int worker = 0; worker < workers; ++worker) {
      pool.emplace_back([&] {
        for (;;) {
          const std::size_t index = next.fetch_add(1);
          if (index >= windows.size()) return;
          results[index] =
              solveWindowRoot(windows[index], config.objective, config.limits);
        }
      });
    }
    for (std::thread& thread : pool) thread.join();
  }

  for (const RootResult& root : results) {
    decision.nodes += root.nodes;
    decision.wall_seconds += root.wall_seconds;
    if (!root.complete) {
      ++decision.incomplete;
      continue;
    }
    ++decision.samples_used;
    for (int column = 0; column < kBoardSize; ++column) {
      if (!root.legal[static_cast<std::size_t>(column)]) continue;
      legal[static_cast<std::size_t>(column)] = true;
      total[static_cast<std::size_t>(column)] +=
          root.value[static_cast<std::size_t>(column)];
      ++counted[static_cast<std::size_t>(column)];
    }
  }

  // Legality depends only on the visible board, so every sample offers the same
  // column set and the means are directly comparable.  Ties go to the lower
  // column index, deterministically.
  double best = 0.0;
  for (int column = 0; column < kBoardSize; ++column) {
    if (!legal[static_cast<std::size_t>(column)]) continue;
    if (counted[static_cast<std::size_t>(column)] == 0) continue;
    const double mean =
        static_cast<double>(total[static_cast<std::size_t>(column)]) /
        counted[static_cast<std::size_t>(column)];
    if (decision.column < 0 || mean > best) {
      best = mean;
      decision.column = column;
    }
  }
  return decision;
}

}  // namespace drop7::flowceiling