Drop7 Research
approaches/fair-expectimax/root-risk/fair-root-risk.cppC++770 lines · 30.9 KB
// Runs one bounded risk-sensitive ablation over the reference fair
// depth-three evaluator.  Seven fixed public root scenarios differ only in the
// immediate chance transition; every observed successor then uses the exact
// non-clairvoyant fair continuation search for two more action plies.
#define DROP7_FAIR_ONLY_HORIZON_LIBRARY
#include "../reference/fair-only-horizon.cpp"
#undef DROP7_FAIR_ONLY_HORIZON_LIBRARY

#include <unordered_set>

namespace drop7::fair_root_risk {

namespace fair = drop7::fair_only_horizon;

constexpr int kRootScenarios = 7;
constexpr int kContinuationDepth = 2;
constexpr double kMeanWeight = 0.75;
constexpr double kCvarWeight = 0.25;
constexpr double kCvarFraction = 0.25;
constexpr std::uint32_t kRootScenarioDomain = 0x5249'534bu;
constexpr std::uint32_t kRootRevealDomain = 0x5245'564cu;
constexpr std::uint32_t kRootDiscDomain = 0x4449'5343u;
constexpr std::uint32_t kScreenStart = 0x3e9d'0000u;
constexpr int kScreenGames = 8;
constexpr std::uint32_t kConfirmationStart = 0x3e9e'0000u;
constexpr int kConfirmationGames = 16;
constexpr int kMaximumMoves = 1'000;
constexpr int kParallelism = 4;

static_assert(kLevelBonus == 7'000);
static_assert(fair::kDepth == kContinuationDepth + 1);
static_assert(fair::kChanceSamples == 5);
static_assert(kRootScenarios == 7);
static_assert(kMeanWeight == 0.75 && kCvarWeight == 0.25);
static_assert(kMeanWeight + kCvarWeight == 1.0);
static_assert(kCvarFraction == 0.25);
static_assert(kMaximumMoves == fair::kMaximumMoves);
static_assert((kScreenStart >> 24) != 0x7du &&
              (kScreenStart >> 24) != 0xd7u);
static_assert((kConfirmationStart >> 24) != 0x7du &&
              (kConfirmationStart >> 24) != 0xd7u);
static_assert(kScreenStart + kScreenGames < kConfirmationStart);

std::mutex risk_progress_mutex;

std::uint32_t publicHash(const State& source) {
  bool ignored = false;
  const State state = cfpi::detail::canonicalState(source, ignored);
  std::uint32_t hash = 0x811c'9dc5u;
  for (const std::uint8_t cell : state.board) {
    hash ^= static_cast<std::uint32_t>(cell + 1u);
    hash *= 0x0100'0193u;
  }
  hash ^= state.next_disc;
  hash *= 0x0100'0193u;
  hash ^= static_cast<std::uint32_t>(state.moves_remaining);
  return mix32(hash);
}

std::uint32_t safePublicSeed(std::uint32_t seed) {
  const std::uint32_t family = seed >> 24;
  return family == 0x7du || family == 0xd7u ? seed ^ 0x4000'0000u : seed;
}

std::uint32_t rootScenarioSeed(const State& canonical, int scenario) {
  if (scenario < 0 || scenario >= kRootScenarios) {
    throw std::invalid_argument("invalid fair root scenario");
  }
  return safePublicSeed(mix32(
      publicHash(canonical) ^ kRootScenarioDomain ^
      (static_cast<std::uint32_t>(scenario + 1) * 0x9e37'79b9u)));
}

std::uint32_t eventBits(std::uint32_t seed, std::uint32_t domain,
                        int event) {
  return mix32(seed ^ domain ^
               (static_cast<std::uint32_t>(event + 1) * 0x85eb'ca6bu));
}

std::uint8_t bitsToDisc(std::uint32_t bits) {
  return static_cast<std::uint8_t>(
      ((static_cast<std::uint64_t>(bits) * kBoardSize) >> 32) + 1u);
}

struct RootScenarioRandom {
  std::uint32_t seed = 0;
  int event = 0;

  std::uint8_t nextDisc() {
    return bitsToDisc(eventBits(seed, kRootRevealDomain, event++));
  }
};

std::uint8_t scenarioNextDisc(std::uint32_t seed) {
  return bitsToDisc(eventBits(seed, kRootDiscDomain, 0));
}

double empiricalLowerCvar25(
    const std::array<double, kRootScenarios>& scenarios) {
  std::array<double, kRootScenarios> sorted = scenarios;
  std::sort(sorted.begin(), sorted.end());
  constexpr double tail_mass = kCvarFraction * kRootScenarios;
  constexpr int whole = static_cast<int>(tail_mass);
  constexpr double fractional = tail_mass - whole;
  static_assert(whole == 1);
  static_assert(fractional == 0.75);
  double total = 0;
  for (int index = 0; index < whole; ++index) total += sorted[index];
  total += fractional * sorted[whole];
  return total / tail_mass;
}

struct RootActionRisk {
  std::array<double, kRootScenarios> scenarios{};
  double mean = -std::numeric_limits<double>::infinity();
  double cvar25 = -std::numeric_limits<double>::infinity();
  double robust = -std::numeric_limits<double>::infinity();
};

RootActionRisk evaluateRootAction(const State& canonical, int action,
                                  fair::SearchContext& context) {
  if (!isLegal(canonical.board, action)) {
    throw std::invalid_argument("illegal fair root-risk action");
  }
  RootActionRisk result;
  for (int scenario = 0; scenario < kRootScenarios; ++scenario) {
    fair::checkBudget(context);
    const std::uint32_t seed = rootScenarioSeed(canonical, scenario);
    RootScenarioRandom random{seed, 0};
    MoveResult move;
    const bool played =
        cfpi::detail::playMoveSampled(canonical, action, random, move);
    ++context.work;
    if (!played) {
      result.scenarios[scenario] = fair::kTerminalUtility;
      continue;
    }
    const double immediate = static_cast<double>(move.score_delta);
    if (move.state.game_over) {
      result.scenarios[scenario] = immediate + fair::kTerminalUtility;
      continue;
    }
    move.state.score = 0;
    // The next visible disc is indexed only by the public root and scenario,
    // not by how many covers this sibling happened to reveal.
    move.state.next_disc = scenarioNextDisc(seed);
    bool ignored = false;
    const State next = cfpi::detail::canonicalState(move.state, ignored);
    result.scenarios[scenario] =
        immediate + fair::bestFutureValue(next, kContinuationDepth, context);
  }
  result.mean = std::accumulate(result.scenarios.begin(),
                                result.scenarios.end(), 0.0) /
                kRootScenarios;
  result.cvar25 = empiricalLowerCvar25(result.scenarios);
  result.robust = kMeanWeight * result.mean +
                  kCvarWeight * result.cvar25;
  return result;
}

struct RiskDecision {
  int action = -1;
  bool complete = false;
  std::uint64_t nodes = 0;
  std::uint64_t work = 0;
  std::uint64_t cache_hits = 0;
  std::size_t cache_entries = 0;
  std::array<RootActionRisk, kBoardSize> actions{};
};

// Deliberately accepts no game seed.  All chance scenarios are derived from
// the canonical public board, visible next disc, and moves-until-rise only.
RiskDecision chooseRiskAction(const State& source) {
  if (source.game_over) return {};
  bool mirrored = false;
  const State canonical = cfpi::detail::canonicalState(source, mirrored);
  fair::SearchContext context;
  RiskDecision result;
  int canonical_action = -1;
  double best = -std::numeric_limits<double>::infinity();
  try {
    for (const int action : cfpi::detail::kColumnOrder) {
      if (!isLegal(canonical.board, action)) continue;
      result.actions[action] = evaluateRootAction(canonical, action, context);
      if (result.actions[action].robust > best) {
        best = result.actions[action].robust;
        canonical_action = action;
      }
    }
    result.complete = canonical_action >= 0;
  } catch (const fair::WorkLimitReached&) {
    result.complete = false;
  }
  if (canonical_action < 0) canonical_action = centerFirstMove(canonical.board);
  result.action = mirrored && canonical_action >= 0
                      ? kBoardSize - 1 - canonical_action
                      : canonical_action;
  result.nodes = context.nodes;
  result.work = context.work;
  result.cache_hits = context.cache_hits;
  result.cache_entries = context.cache.size();
  if (mirrored) {
    std::array<RootActionRisk, kBoardSize> reflected{};
    for (int action = 0; action < kBoardSize; ++action) {
      reflected[kBoardSize - 1 - action] = result.actions[action];
    }
    result.actions = reflected;
  }
  return result;
}

struct RiskGameResult {
  fair::GameResult game;
  std::uint64_t switches = 0;
  std::uint64_t root_scenarios = 0;
  std::uint64_t switch_audit_work = 0;
  std::uint64_t switch_audit_nodes = 0;
  double selected_mean_total = 0.0;
  double selected_cvar_total = 0.0;
  double selected_tail_gap_total = 0.0;
};

void reportRiskGame(std::string_view label, const RiskGameResult& result) {
  const std::lock_guard<std::mutex> lock(risk_progress_mutex);
  std::cerr << label << " seed 0x" << std::hex << result.game.seed
            << std::dec << ' ' << result.game.score << " ("
            << result.game.moves << " moves"
            << (result.game.censored ? ", capped" : "")
            << ", switches " << result.switches << ", clears "
            << result.game.numbered_cleared << ", reveals "
            << result.game.covers_revealed << ", policy work "
            << result.game.work << ", audit work "
            << result.switch_audit_work << ")\n";
}

RiskGameResult runRiskGame(std::uint32_t seed, std::string_view label) {
  const auto started = std::chrono::steady_clock::now();
  State state = initialHeadlessState(seed);
  RiskGameResult result;
  result.game.seed = seed;
  while (!state.game_over && state.moves_played < kMaximumMoves) {
    const RiskDecision risk = chooseRiskAction(state);
    if (!risk.complete || !isLegal(state.board, risk.action)) {
      throw std::runtime_error("fair root-risk search did not complete");
    }
    const fair::SearchDecision fair_decision = fair::chooseFairAction(state);
    if (!fair_decision.complete ||
        fair_decision.completed_depth != fair::kDepth ||
        !isLegal(state.board, fair_decision.action)) {
      throw std::runtime_error("fair switch audit did not complete");
    }
    result.switches += risk.action != fair_decision.action;
    result.switch_audit_work += fair_decision.work;
    result.switch_audit_nodes += fair_decision.nodes;
    result.game.work += risk.work;
    result.game.nodes += risk.nodes;
    result.game.cache_hits += risk.cache_hits;
    result.game.maximum_cache_entries =
        std::max(result.game.maximum_cache_entries, risk.cache_entries);
    int legal_count = 0;
    legalColumns(state.board, legal_count);
    result.root_scenarios +=
        static_cast<std::uint64_t>(legal_count * kRootScenarios);
    const RootActionRisk& selected = risk.actions[risk.action];
    result.selected_mean_total += selected.mean;
    result.selected_cvar_total += selected.cvar25;
    result.selected_tail_gap_total += selected.mean - selected.cvar25;
    MoveResult move;
    if (!playHeadlessMove(state, seed, risk.action, move)) {
      throw std::runtime_error("fair root-risk transition failed");
    }
    fair::observeMove(move, result.game);
  }
  result.game.score = state.score;
  result.game.moves = state.moves_played;
  result.game.censored = !state.game_over;
  result.game.peak_rss_bytes = fair::peakRssBytes();
  result.game.elapsed_seconds = std::chrono::duration<double>(
                                    std::chrono::steady_clock::now() - started)
                                    .count();
  reportRiskGame(label, result);
  return result;
}

struct Cohort {
  std::vector<fair::GameResult> fair;
  std::vector<RiskGameResult> risk;
  double wall_seconds = 0.0;
};

Cohort runCohort(std::uint32_t seed_start, int games,
                 std::string_view phase) {
  const auto started = std::chrono::steady_clock::now();
  Cohort result;
  result.fair.resize(static_cast<std::size_t>(games));
  result.risk.resize(static_cast<std::size_t>(games));
  std::atomic<int> next{0};
  std::vector<std::future<void>> workers;
  for (int worker = 0; worker < std::min(kParallelism, games); ++worker) {
    workers.push_back(std::async(std::launch::async, [&] {
      for (;;) {
        const int game = next.fetch_add(1);
        if (game >= games) return;
        const std::uint32_t seed =
            seed_start + static_cast<std::uint32_t>(game);
        result.fair[static_cast<std::size_t>(game)] = fair::runFairGame(
            seed, std::string(phase) + "-fair-only-d3-s5");
        result.risk[static_cast<std::size_t>(game)] = runRiskGame(
            seed, std::string(phase) + "-fair-root-risk-s7");
      }
    }));
  }
  for (auto& worker : workers) worker.get();
  result.wall_seconds = std::chrono::duration<double>(
                            std::chrono::steady_clock::now() - started)
                            .count();
  return result;
}

struct RiskSummary {
  fair::Summary game;
  double mean_switches = 0.0;
  double switch_rate = 0.0;
  std::uint64_t root_scenarios = 0;
  double scenarios_per_move = 0.0;
  std::uint64_t switch_audit_work = 0;
  std::uint64_t switch_audit_nodes = 0;
  double audit_work_per_move = 0.0;
  double observed_work_per_move = 0.0;
  double mean_selected_value = 0.0;
  double mean_selected_cvar25 = 0.0;
  double mean_selected_tail_gap = 0.0;
};

RiskSummary summarizeRisk(const std::vector<RiskGameResult>& games) {
  if (games.empty()) throw std::invalid_argument("empty risk cohort");
  std::vector<fair::GameResult> common;
  common.reserve(games.size());
  RiskSummary result;
  std::uint64_t moves = 0;
  std::uint64_t switches = 0;
  for (const RiskGameResult& game : games) {
    common.push_back(game.game);
    moves += static_cast<std::uint64_t>(game.game.moves);
    switches += game.switches;
    result.mean_switches +=
        static_cast<double>(game.switches) / games.size();
    result.root_scenarios += game.root_scenarios;
    result.switch_audit_work += game.switch_audit_work;
    result.switch_audit_nodes += game.switch_audit_nodes;
    result.mean_selected_value +=
        game.selected_mean_total / games.size();
    result.mean_selected_cvar25 +=
        game.selected_cvar_total / games.size();
    result.mean_selected_tail_gap +=
        game.selected_tail_gap_total / games.size();
  }
  result.game = fair::summarize(common);
  const double move_count =
      static_cast<double>(std::max<std::uint64_t>(1, moves));
  result.switch_rate = switches / move_count;
  result.scenarios_per_move = result.root_scenarios / move_count;
  result.audit_work_per_move = result.switch_audit_work / move_count;
  result.observed_work_per_move =
      (result.game.work + result.switch_audit_work) / move_count;
  result.mean_selected_value /= move_count / games.size();
  result.mean_selected_cvar25 /= move_count / games.size();
  result.mean_selected_tail_gap /= move_count / games.size();
  return result;
}

struct PairedSummary {
  fair::DifferenceStats score;
  fair::DifferenceStats moves;
  fair::DifferenceStats numbered_cleared;
  fair::DifferenceStats covers_revealed;
  fair::DifferenceStats maximum_chain;
};

PairedSummary pairedSummary(const Cohort& cohort) {
  if (cohort.fair.size() != cohort.risk.size() || cohort.fair.empty()) {
    throw std::invalid_argument("invalid fair root-risk paired cohort");
  }
  std::vector<double> scores;
  std::vector<double> moves;
  std::vector<double> cleared;
  std::vector<double> revealed;
  std::vector<double> chains;
  for (std::size_t game = 0; game < cohort.fair.size(); ++game) {
    const fair::GameResult& baseline = cohort.fair[game];
    const fair::GameResult& candidate = cohort.risk[game].game;
    scores.push_back(static_cast<double>(candidate.score - baseline.score));
    moves.push_back(static_cast<double>(candidate.moves - baseline.moves));
    cleared.push_back(static_cast<double>(candidate.numbered_cleared) -
                      baseline.numbered_cleared);
    revealed.push_back(static_cast<double>(candidate.covers_revealed) -
                       baseline.covers_revealed);
    chains.push_back(
        static_cast<double>(candidate.maximum_chain - baseline.maximum_chain));
  }
  return {
      fair::differences(scores), fair::differences(moves),
      fair::differences(cleared), fair::differences(revealed),
      fair::differences(chains),
  };
}

bool improvesBothMeans(const fair::Summary& baseline,
                       const RiskSummary& candidate) {
  return candidate.game.mean_score > baseline.mean_score &&
         candidate.game.mean_moves > baseline.mean_moves;
}

void writeRiskGame(std::ostream& output, const RiskGameResult& result) {
  output << "{\"game\":";
  fair::writeGame(output, result.game);
  output << ",\"switches\":" << result.switches
         << ",\"rootScenarios\":" << result.root_scenarios
         << ",\"switchAuditWork\":" << result.switch_audit_work
         << ",\"switchAuditNodes\":" << result.switch_audit_nodes
         << ",\"selectedMeanTotal\":" << result.selected_mean_total
         << ",\"selectedCvar25Total\":" << result.selected_cvar_total
         << ",\"selectedTailGapTotal\":"
         << result.selected_tail_gap_total << '}';
}

void writeRiskSummary(std::ostream& output, const RiskSummary& result) {
  output << "{\"game\":";
  fair::writeSummary(output, result.game);
  output << ",\"meanSwitches\":" << result.mean_switches
         << ",\"switchRate\":" << result.switch_rate
         << ",\"rootScenarios\":" << result.root_scenarios
         << ",\"scenariosPerMove\":" << result.scenarios_per_move
         << ",\"switchAuditWork\":" << result.switch_audit_work
         << ",\"switchAuditNodes\":" << result.switch_audit_nodes
         << ",\"auditWorkPerMove\":" << result.audit_work_per_move
         << ",\"observedWorkPerMove\":"
         << result.observed_work_per_move
         << ",\"meanSelectedValue\":" << result.mean_selected_value
         << ",\"meanSelectedCvar25\":"
         << result.mean_selected_cvar25
         << ",\"meanSelectedTailGap\":"
         << result.mean_selected_tail_gap << '}';
}

void writePaired(std::ostream& output, const PairedSummary& result) {
  output << "{\"score\":";
  fair::writeDifference(output, result.score);
  output << ",\"moves\":";
  fair::writeDifference(output, result.moves);
  output << ",\"numberedCleared\":";
  fair::writeDifference(output, result.numbered_cleared);
  output << ",\"coversRevealed\":";
  fair::writeDifference(output, result.covers_revealed);
  output << ",\"maximumChain\":";
  fair::writeDifference(output, result.maximum_chain);
  output << '}';
}

void writePairs(std::ostream& output, const Cohort& cohort) {
  output << '[';
  for (std::size_t game = 0; game < cohort.fair.size(); ++game) {
    if (game > 0) output << ',';
    output << "{\"seed\":" << cohort.fair[game].seed
           << ",\"fairOnlyD3S5\":";
    fair::writeGame(output, cohort.fair[game]);
    output << ",\"rootRisk\":";
    writeRiskGame(output, cohort.risk[game]);
    output << '}';
  }
  output << ']';
}

void writeCohort(std::ostream& output, std::uint32_t seed_start,
                 const Cohort& cohort, const fair::Summary& baseline,
                 const RiskSummary& candidate,
                 const PairedSummary& paired, bool passed) {
  output << "{\"seedStart\":" << seed_start
         << ",\"maximumMoves\":" << kMaximumMoves
         << ",\"fairOnlyD3S5\":";
  fair::writeSummary(output, baseline);
  output << ",\"rootRisk\":";
  writeRiskSummary(output, candidate);
  output << ",\"paired\":";
  writePaired(output, paired);
  output << ",\"wallSeconds\":" << cohort.wall_seconds
         << ",\"passed\":" << (passed ? "true" : "false")
         << ",\"pairs\":";
  writePairs(output, cohort);
  output << '}';
}

bool selfTest(std::ostream& output) {
  std::ostringstream fair_output;
  const bool fair_test = fair::selfTest(fair_output);
  const State source = fair::fixtureState(fair::kTypeScriptFixtures[1]);
  const RiskDecision first = chooseRiskAction(source);
  const RiskDecision repeat = chooseRiskAction(source);
  bool deterministic = first.complete && repeat.complete &&
                       first.action == repeat.action &&
                       first.work == repeat.work &&
                       first.nodes == repeat.nodes &&
                       first.cache_hits == repeat.cache_hits &&
                       first.cache_entries == repeat.cache_entries;
  for (int action = 0; action < kBoardSize; ++action) {
    deterministic = deterministic &&
                    first.actions[action].scenarios ==
                        repeat.actions[action].scenarios &&
                    first.actions[action].robust ==
                        repeat.actions[action].robust;
  }

  State metadata = source;
  metadata.score = 9'876'543;
  metadata.level = 417;
  metadata.moves_played = 991;
  const RiskDecision metadata_decision = chooseRiskAction(metadata);
  bool metadata_blind = metadata_decision.action == first.action &&
                        metadata_decision.work == first.work;
  for (int action = 0; action < kBoardSize; ++action) {
    metadata_blind = metadata_blind &&
                     metadata_decision.actions[action].scenarios ==
                         first.actions[action].scenarios;
  }

  State reflected = source;
  reflected.board = cfpi::detail::mirrorBoard(source.board);
  const RiskDecision reflected_decision = chooseRiskAction(reflected);
  bool reflection_safe =
      reflected_decision.action == kBoardSize - 1 - first.action &&
      reflected_decision.work == first.work;
  for (int action = 0; action < kBoardSize; ++action) {
    reflection_safe = reflection_safe &&
        reflected_decision.actions[kBoardSize - 1 - action].scenarios ==
            first.actions[action].scenarios;
  }

  std::unordered_set<std::uint32_t> scenario_seeds;
  bool public_scenarios = true;
  for (int scenario = 0; scenario < kRootScenarios; ++scenario) {
    const std::uint32_t seed = rootScenarioSeed(source, scenario);
    scenario_seeds.insert(seed);
    public_scenarios = public_scenarios &&
                       seed == rootScenarioSeed(metadata, scenario) &&
                       seed == rootScenarioSeed(reflected, scenario) &&
                       (seed >> 24) != 0x7du &&
                       (seed >> 24) != 0xd7u;
  }
  public_scenarios = public_scenarios &&
                     scenario_seeds.size() == kRootScenarios;

  const std::array<double, kRootScenarios> cvar_fixture{{
      6.0, 0.0, 5.0, 1.0, 4.0, 2.0, 3.0,
  }};
  const double cvar = empiricalLowerCvar25(cvar_fixture);
  const double robust = kMeanWeight * 3.0 + kCvarWeight * cvar;
  const bool aggregator_exact = std::abs(cvar - 3.0 / 7.0) < 1.0e-12 &&
                                std::abs(robust - 33.0 / 14.0) < 1.0e-12;
  const bool legal = isLegal(source.board, first.action);
  const bool bounded = first.work <= fair::kMaximumWork &&
                       first.cache_entries <= fair::kMaximumCacheEntries;
  const bool fixed_protocol =
      kLevelBonus == 7'000 && kRootScenarios == 7 &&
      kContinuationDepth == 2 && kMeanWeight == 0.75 &&
      kCvarWeight == 0.25 && kCvarFraction == 0.25 &&
      kMaximumMoves == 1'000 && kScreenStart == 0x3e9d'0000u &&
      kConfirmationStart == 0x3e9e'0000u &&
      (kScreenStart >> 24) != 0x7du &&
      (kScreenStart >> 24) != 0xd7u &&
      (kConfirmationStart >> 24) != 0x7du &&
      (kConfirmationStart >> 24) != 0xd7u;
  // Game seed blindness is structural: chooseRiskAction has no seed argument.
  constexpr bool game_seed_parameter_absent = true;
  const bool passed = fair_test && deterministic && metadata_blind &&
                      reflection_safe && public_scenarios &&
                      aggregator_exact && legal && bounded &&
                      fixed_protocol && game_seed_parameter_absent;
  output << fair_output.str();
  output << std::setprecision(12)
         << "FAIR_ROOT_RISK_SELF_TEST {\"passed\":"
         << (passed ? "true" : "false")
         << ",\"fairSearchVerified\":"
         << (fair_test ? "true" : "false")
         << ",\"deterministic\":"
         << (deterministic ? "true" : "false")
         << ",\"metadataBlind\":"
         << (metadata_blind ? "true" : "false")
         << ",\"gameSeedParameterAbsent\":true"
         << ",\"reflectionSafe\":"
         << (reflection_safe ? "true" : "false")
         << ",\"independentPublicScenarios\":"
         << (public_scenarios ? "true" : "false")
         << ",\"aggregatorExact\":"
         << (aggregator_exact ? "true" : "false")
         << ",\"complete\":" << (first.complete ? "true" : "false")
         << ",\"legal\":" << (legal ? "true" : "false")
         << ",\"bounded\":" << (bounded ? "true" : "false")
         << ",\"work\":" << first.work
         << ",\"cacheEntries\":" << first.cache_entries
         << ",\"levelBonus\":" << kLevelBonus << "}\n";
  return passed;
}

struct Options {
  std::string output = "/tmp/drop7-fair-root-risk.json";
};

Options parseOptions(int argc, char** argv, int begin) {
  Options result;
  for (int index = begin; index < argc; index += 2) {
    if (index + 1 >= argc) {
      throw std::invalid_argument("missing fair root-risk option value");
    }
    const std::string argument = argv[index];
    if (argument == "--output") {
      result.output = argv[index + 1];
    } else {
      throw std::invalid_argument("unknown fair root-risk option " + argument);
    }
  }
  return result;
}

void writeArtifact(const Options& options, const Cohort& screen,
                   const fair::Summary& screen_baseline,
                   const RiskSummary& screen_candidate,
                   const PairedSummary& screen_paired,
                   bool screen_passed,
                   const Cohort* confirmation,
                   const fair::Summary* confirmation_baseline,
                   const RiskSummary* confirmation_candidate,
                   const PairedSummary* confirmation_paired,
                   bool confirmation_passed, double total_wall) {
  std::ofstream output(options.output);
  if (!output) {
    throw std::runtime_error("could not open fair root-risk artifact");
  }
  output << std::setprecision(10)
         << "{\n  \"experiment\":\"fair-root-risk-v1\",\n"
         << "  \"preregistered\":true,\n"
         << "  \"publicStateOnly\":true,\n"
         << "  \"gameSeedPolicyInput\":false,\n"
         << "  \"metadataInputs\":{\"score\":false,\"level\":false,"
            "\"movesPlayed\":false},\n"
         << "  \"scoring\":{\"levelBonus\":7000},\n"
         << "  \"baseline\":{\"name\":\"confirmed-fair-only-d3-s5\","
            "\"depth\":"
         << fair::kDepth << ",\"chanceSamples\":"
         << fair::kChanceSamples << ",\"maximumWork\":"
         << fair::kMaximumWork << ",\"maximumCacheEntries\":"
         << fair::kMaximumCacheEntries << "},\n"
         << "  \"candidate\":{\"rootScenarios\":" << kRootScenarios
         << ",\"continuationDepth\":" << kContinuationDepth
         << ",\"totalActionPlies\":" << kContinuationDepth + 1
         << ",\"scenarioKind\":"
            "\"independent-fixed-public-immediate-transition\","
            "\"futureScenarioTapeUsed\":false,"
            "\"aggregator\":{\"meanWeight\":"
         << kMeanWeight << ",\"cvar25Weight\":" << kCvarWeight
         << ",\"empiricalTailMass\":1.75,"
            "\"fractionalOrderWeights\":[1.0,0.75]}},\n"
         << "  \"protocol\":{\"screenSeedStart\":" << kScreenStart
         << ",\"screenGames\":" << kScreenGames
         << ",\"confirmationSeedStart\":" << kConfirmationStart
         << ",\"confirmationGames\":" << kConfirmationGames
         << ",\"maximumMoves\":" << kMaximumMoves
         << ",\"parallelism\":" << kParallelism << "},\n"
         << "  \"screen\":";
  writeCohort(output, kScreenStart, screen, screen_baseline,
              screen_candidate, screen_paired, screen_passed);
  output << ",\n  \"confirmation\":";
  if (confirmation == nullptr) {
    output << "null";
  } else {
    writeCohort(output, kConfirmationStart, *confirmation,
                *confirmation_baseline, *confirmation_candidate,
                *confirmation_paired, confirmation_passed);
  }
  output << ",\n  \"screenPassed\":"
         << (screen_passed ? "true" : "false")
         << ",\n  \"confirmationRan\":"
         << (confirmation != nullptr ? "true" : "false")
         << ",\n  \"confirmationPassed\":"
         << (confirmation_passed ? "true" : "false")
         << ",\n  \"qualified\":"
         << (screen_passed && confirmation_passed ? "true" : "false")
         << ",\n  \"totalWallSeconds\":" << total_wall
         << ",\n  \"peakRssBytes\":" << fair::peakRssBytes() << "\n}\n";
  if (!output) {
    throw std::runtime_error("could not write fair root-risk artifact");
  }
}

int run(const Options& options, std::ostream& output) {
  const auto started = std::chrono::steady_clock::now();
  const Cohort screen = runCohort(kScreenStart, kScreenGames, "screen");
  const fair::Summary screen_baseline = fair::summarize(screen.fair);
  const RiskSummary screen_candidate = summarizeRisk(screen.risk);
  const PairedSummary screen_paired = pairedSummary(screen);
  const bool screen_passed =
      improvesBothMeans(screen_baseline, screen_candidate);

  Cohort confirmation;
  fair::Summary confirmation_baseline;
  RiskSummary confirmation_candidate;
  PairedSummary confirmation_paired;
  bool confirmation_passed = false;
  if (screen_passed) {
    confirmation = runCohort(kConfirmationStart, kConfirmationGames,
                             "confirmation");
    confirmation_baseline = fair::summarize(confirmation.fair);
    confirmation_candidate = summarizeRisk(confirmation.risk);
    confirmation_paired = pairedSummary(confirmation);
    confirmation_passed =
        improvesBothMeans(confirmation_baseline, confirmation_candidate);
  }
  const double total_wall = std::chrono::duration<double>(
                                std::chrono::steady_clock::now() - started)
                                .count();
  writeArtifact(
      options, screen, screen_baseline, screen_candidate, screen_paired,
      screen_passed, screen_passed ? &confirmation : nullptr,
      screen_passed ? &confirmation_baseline : nullptr,
      screen_passed ? &confirmation_candidate : nullptr,
      screen_passed ? &confirmation_paired : nullptr,
      confirmation_passed, total_wall);
  output << std::fixed << std::setprecision(3)
         << "FAIR_ROOT_RISK_RESULT {\"levelBonus\":7000"
         << ",\"screenFairScore\":" << screen_baseline.mean_score
         << ",\"screenFairMoves\":" << screen_baseline.mean_moves
         << ",\"screenRiskScore\":"
         << screen_candidate.game.mean_score
         << ",\"screenRiskMoves\":"
         << screen_candidate.game.mean_moves
         << ",\"screenScoreDelta\":" << screen_paired.score.mean
         << ",\"screenMoveDelta\":" << screen_paired.moves.mean
         << ",\"screenSwitchRate\":" << screen_candidate.switch_rate
         << ",\"screenPassed\":"
         << (screen_passed ? "true" : "false")
         << ",\"confirmationRan\":"
         << (screen_passed ? "true" : "false")
         << ",\"confirmationPassed\":"
         << (confirmation_passed ? "true" : "false")
         << ",\"peakRssBytes\":" << fair::peakRssBytes()
         << ",\"totalWallSeconds\":" << total_wall
         << ",\"artifact\":\"" << options.output << "\"}\n";
  return 0;
}

}  // namespace drop7::fair_root_risk

int main(int argc, char** argv) {
  try {
    if (argc >= 2 && std::string_view(argv[1]) == "--self-test") {
      return drop7::fair_root_risk::selfTest(std::cout) ? EXIT_SUCCESS
                                                        : EXIT_FAILURE;
    }
    if (argc >= 2 && std::string_view(argv[1]) == "--run") {
      const auto options =
          drop7::fair_root_risk::parseOptions(argc, argv, 2);
      return drop7::fair_root_risk::run(options, std::cout);
    }
    std::cerr << "usage: drop7_fair_root_risk --self-test | --run "
                 "[--output PATH]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "drop7_fair_root_risk: " << error.what() << '\n';
    return EXIT_FAILURE;
  }
}