#include "../../../src/core/native/public-behavior.hpp"
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <future>
#include <iomanip>
#include <iostream>
#include <limits>
#include <list>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/resource.h>
#include <unordered_map>
#include <utility>
#include <vector>
// Implements the fair-only horizon evaluator in native code and excludes every
// phase-horizon residual. Search is full-width iterative-deepening depth three
// with five stratified chance samples, an observable-state seed, an LRU cache,
// and the same terminal utility as the TypeScript reference.
namespace drop7::fair_only_horizon {
constexpr int kDepth = 3;
constexpr int kChanceSamples = 5;
constexpr std::uint64_t kMaximumWork = 1'000'000;
constexpr std::size_t kMaximumCacheEntries = 40'000;
constexpr double kTerminalUtility = -1'000'000.0;
constexpr double kFairTerminalUtility = -2'500'000.0;
constexpr std::uint32_t kPolicySeed = 0xd707'5eedu;
constexpr std::uint32_t kScreenSeedStart = 0x3e95'0000u;
constexpr std::uint32_t kConfirmationSeedStart = 0x3e96'0000u;
constexpr int kScreenGames = 8;
constexpr int kConfirmationGames = 16;
constexpr int kMaximumMoves = 1'000;
constexpr int kParallelism = 4;
// FAIR_PHASE_BASELINE_WEIGHTS from approaches/fair-expectimax/phase-fair-combination/main.ts.
// The first four and last twelve fair-tuner features are transition/action
// features and are zero in evaluateFairPosition's leaf vector. Search already
// adds immediate score with its fixed unit coefficient. In particular, the
// 300-point revealed-cover override is inert in this leaf-only use.
constexpr double kImmediateScoreWeight = 1.0;
constexpr double kRevealedCoverWeight = 300.0;
constexpr double kOpenColumnsWeight = 180.0;
constexpr double kHeightLoadWeight = -20.0;
constexpr double kSolidCellsWeight = -620.0;
constexpr double kCrackedCellsWeight = -220.0;
constexpr double kNumberedCellsWeight = -18.0;
constexpr double kHighLowNumbersWeight = -90.0;
constexpr double kDirectPotentialWeight = 1'600.0;
constexpr double kLatentChainPotentialWeight = 700.0;
constexpr double kCrackedExposureWeight = 100.0;
constexpr double kSolidExposureWeight = 40.0;
constexpr double kAdjacentOnesWeight = -550.0;
constexpr double kTripleTwosWeight = -750.0;
constexpr double kDeadLowNumbersWeight = -120.0;
constexpr double kCoveredHeightRiskWeight = -95.0;
constexpr double kLowNumberHeightRiskWeight = -85.0;
constexpr double kDangerHeightSquaredWeight = -1'250.0;
constexpr double kRoughnessWeight = 0.0;
constexpr double kRisePressureWeight = -35.0;
constexpr double kNextDiscVerticalOptionsWeight = 220.0;
static_assert(kLevelBonus == 17'000);
static_assert(kImmediateScoreWeight == 1.0);
static_assert(kRevealedCoverWeight == 300.0);
static_assert(kScreenSeedStart + kScreenGames < kConfirmationSeedStart);
std::mutex progress_mutex;
struct FairFeatures {
cfpi::detail::PhaseFeatures heuristic{};
double covered_height_risk = 0.0;
double low_number_height_risk = 0.0;
double danger_height_squared = 0.0;
double roughness = 0.0;
double rise_pressure = 0.0;
double next_disc_vertical_options = 0.0;
};
FairFeatures extractFairFeatures(const State& state) {
if (state.moves_remaining < 1 || state.moves_remaining > kMovesPerLevel ||
state.next_disc < 1 || state.next_disc > kBoardSize) {
throw std::invalid_argument("invalid public state for fair evaluator");
}
FairFeatures result;
result.heuristic = cfpi::detail::extractPhaseFeatures(state);
const auto heights = cfpi::detail::columnHeights(state.board);
int maximum_height = 0;
for (int column = 0; column < kBoardSize; ++column) {
const int height = heights[column];
maximum_height = std::max(maximum_height, height);
result.rise_pressure +=
static_cast<double>(height * height * height) /
state.moves_remaining;
if (height < kBoardSize && height + 1 == state.next_disc) {
result.next_disc_vertical_options += 1.0;
}
}
for (int row = 0; row < kBoardSize; ++row) {
const int elevation = kBoardSize - row;
for (int column = 0; column < kBoardSize; ++column) {
const std::uint8_t cell = state.board[indexOf(row, column)];
const double edge_multiplier =
column == 0 || column == kBoardSize - 1 ? 1.65 : 1.0;
if (cell == kSolid) {
result.covered_height_risk +=
elevation * elevation * edge_multiplier;
} else if (cell == kCracked) {
result.covered_height_risk +=
elevation * elevation * edge_multiplier * 0.72;
} else if (cell == 1 || cell == 2) {
const int height_risk = std::max(0, elevation - 2);
result.low_number_height_risk += height_risk * height_risk;
}
}
}
for (int column = 1; column < kBoardSize; ++column) {
result.roughness += std::abs(heights[column] - heights[column - 1]);
}
const int danger = std::max(0, maximum_height - 4);
result.danger_height_squared = danger * danger;
return result;
}
double fairLeaf(const State& state) {
if (state.game_over) return kFairTerminalUtility;
const FairFeatures features = extractFairFeatures(state);
const auto& f = features.heuristic;
// Preserve the TypeScript dot-product order for parity.
double result = 0.0;
result += kOpenColumnsWeight * f.open_columns;
result += kHeightLoadWeight * f.height_load;
result += kSolidCellsWeight * f.solid_cells;
result += kCrackedCellsWeight * f.cracked_cells;
result += kNumberedCellsWeight * f.numbered_cells;
result += kHighLowNumbersWeight * f.high_low_numbers;
result += kDirectPotentialWeight * f.direct_potential;
result += kLatentChainPotentialWeight * f.latent_chain_potential;
result += kCrackedExposureWeight * f.cracked_exposure;
result += kSolidExposureWeight * f.solid_exposure;
result += kAdjacentOnesWeight * f.adjacent_ones;
result += kTripleTwosWeight * f.triple_twos;
result += kDeadLowNumbersWeight * f.dead_low_numbers;
result += kCoveredHeightRiskWeight * features.covered_height_risk;
result += kLowNumberHeightRiskWeight * features.low_number_height_risk;
result += kDangerHeightSquaredWeight * features.danger_height_squared;
result += kRoughnessWeight * features.roughness;
result += kRisePressureWeight * features.rise_pressure;
result += kNextDiscVerticalOptionsWeight *
features.next_disc_vertical_options;
return result;
}
class WorkLimitReached : public std::exception {};
struct CacheEntry {
double value = 0.0;
std::list<std::string>::iterator order;
};
struct SearchContext {
std::unordered_map<std::string, CacheEntry> cache;
std::list<std::string> order;
std::uint64_t nodes = 0;
std::uint64_t work = 0;
std::uint64_t cache_hits = 0;
};
void checkBudget(const SearchContext& context) {
if (context.work >= kMaximumWork) throw WorkLimitReached{};
}
void cacheValue(SearchContext& context, std::string key, double value) {
const auto prior = context.cache.find(key);
if (prior != context.cache.end()) {
context.order.erase(prior->second.order);
context.cache.erase(prior);
}
while (context.cache.size() >= kMaximumCacheEntries) {
const std::string& oldest = context.order.front();
context.cache.erase(oldest);
context.order.pop_front();
}
context.order.push_back(key);
const auto order = std::prev(context.order.end());
context.cache.emplace(std::move(key), CacheEntry{value, order});
}
double bestFutureValue(const State& state, int depth, SearchContext& context);
struct ActionValue {
double value = 0.0;
double expected_score = 0.0;
};
ActionValue evaluateAction(const State& state, int column, int depth,
SearchContext& context) {
const std::uint32_t state_seed =
cfpi::detail::scenarioSeedForState(state, kPolicySeed, depth);
ActionValue result;
for (int sample = 0; sample < kChanceSamples; ++sample) {
checkBudget(context);
cfpi::detail::StratifiedRandom random{
state_seed, sample, kChanceSamples, 0};
MoveResult move;
const bool played =
cfpi::detail::playMoveSampled(state, column, random, move);
++context.work;
if (!played) {
result.value += kTerminalUtility;
continue;
}
const double score_delta = static_cast<double>(move.score_delta);
result.expected_score += score_delta;
if (move.state.game_over) {
result.value += score_delta + kTerminalUtility;
continue;
}
move.state.score = 0;
move.state.next_disc = cfpi::detail::sampledNextDisc(
state_seed, sample, kChanceSamples);
bool ignored = false;
const State next = cfpi::detail::canonicalState(move.state, ignored);
result.value +=
score_delta + bestFutureValue(next, depth - 1, context);
}
result.value /= kChanceSamples;
result.expected_score /= kChanceSamples;
return result;
}
double evaluateLeaf(const State& state, SearchContext& context) {
checkBudget(context);
++context.work;
const double value = fairLeaf(state);
if (!std::isfinite(value)) {
throw std::runtime_error("fair-only evaluator returned non-finite value");
}
return value;
}
double bestFutureValue(const State& state, int depth, SearchContext& context) {
++context.nodes;
checkBudget(context);
if (state.game_over) return kTerminalUtility;
if (depth == 0) return evaluateLeaf(state, context);
const std::string key = cfpi::detail::dynamicStateKey(state, depth);
const auto cached = context.cache.find(key);
if (cached != context.cache.end()) {
++context.cache_hits;
const double value = cached->second.value;
context.order.splice(context.order.end(), context.order,
cached->second.order);
return value;
}
double best = -std::numeric_limits<double>::infinity();
for (const int column : cfpi::detail::kColumnOrder) {
if (!isLegal(state.board, column)) continue;
best = std::max(best, evaluateAction(state, column, depth, context).value);
}
if (!std::isfinite(best)) best = kTerminalUtility;
cacheValue(context, key, best);
return best;
}
struct RootEvaluation {
int action = -1;
double value = -std::numeric_limits<double>::infinity();
std::array<double, kBoardSize> values{};
std::array<double, kBoardSize> expected_scores{};
};
RootEvaluation rootDecision(const State& canonical, int depth,
SearchContext& context) {
RootEvaluation result;
result.values.fill(-std::numeric_limits<double>::infinity());
result.expected_scores.fill(-std::numeric_limits<double>::infinity());
for (const int column : cfpi::detail::kColumnOrder) {
if (!isLegal(canonical.board, column)) continue;
const ActionValue candidate =
evaluateAction(canonical, column, depth, context);
result.values[column] = candidate.value;
result.expected_scores[column] = candidate.expected_score;
if (candidate.value > result.value) {
result.value = candidate.value;
result.action = column;
}
}
return result;
}
struct SearchDecision {
int action = -1;
int completed_depth = 0;
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<double, kBoardSize> root_values{};
std::array<double, kBoardSize> root_expected_scores{};
};
SearchDecision chooseFairAction(const State& source) {
if (source.game_over) return {};
bool mirrored = false;
const State canonical = cfpi::detail::canonicalState(source, mirrored);
SearchContext context;
RootEvaluation completed;
int completed_depth = 0;
for (int depth = 1; depth <= kDepth; ++depth) {
try {
completed = rootDecision(canonical, depth, context);
if (completed.action < 0) break;
completed_depth = depth;
} catch (const WorkLimitReached&) {
break;
}
}
int action = completed.action;
if (action < 0) action = centerFirstMove(canonical.board);
SearchDecision result;
result.action = mirrored ? kBoardSize - 1 - action : action;
result.completed_depth = completed_depth;
result.complete = completed_depth == kDepth;
result.nodes = context.nodes;
result.work = context.work;
result.cache_hits = context.cache_hits;
result.cache_entries = context.cache.size();
result.root_values.fill(-std::numeric_limits<double>::infinity());
result.root_expected_scores.fill(-std::numeric_limits<double>::infinity());
if (completed_depth > 0) {
for (int canonical_column = 0; canonical_column < kBoardSize;
++canonical_column) {
const int source_column = mirrored
? kBoardSize - 1 - canonical_column
: canonical_column;
result.root_values[source_column] = completed.values[canonical_column];
result.root_expected_scores[source_column] =
completed.expected_scores[canonical_column];
}
}
return result;
}
cfpi::BehaviorOptions baselineOptions() {
cfpi::BehaviorOptions result;
result.max_depth = kDepth;
result.chance_samples = kChanceSamples;
result.max_work = kMaximumWork;
result.max_cache_entries = kMaximumCacheEntries;
result.terminal_utility = kTerminalUtility;
result.policy_seed = kPolicySeed;
return result;
}
std::uint64_t peakRssBytes() {
rusage usage{};
if (getrusage(RUSAGE_SELF, &usage) != 0) return 0;
#if defined(__APPLE__)
return static_cast<std::uint64_t>(usage.ru_maxrss);
#else
return static_cast<std::uint64_t>(usage.ru_maxrss) * 1024u;
#endif
}
struct GameResult {
std::uint32_t seed = 0;
std::int64_t score = 0;
int moves = 0;
bool censored = false;
std::uint64_t numbered_cleared = 0;
std::uint64_t covers_revealed = 0;
int maximum_chain = 0;
std::uint64_t work = 0;
std::uint64_t nodes = 0;
std::uint64_t cache_hits = 0;
std::size_t maximum_cache_entries = 0;
std::uint64_t peak_rss_bytes = 0;
double elapsed_seconds = 0.0;
};
void observeMove(const MoveResult& move, GameResult& result) {
result.maximum_chain =
std::max(result.maximum_chain, static_cast<int>(move.waves.size()));
for (const Wave& wave : move.waves) {
result.numbered_cleared += static_cast<std::uint64_t>(wave.cleared);
result.covers_revealed += static_cast<std::uint64_t>(wave.revealed);
}
}
void reportGame(std::string_view label, const GameResult& result) {
const std::lock_guard<std::mutex> lock(progress_mutex);
std::cerr << label << " seed 0x" << std::hex << result.seed << std::dec
<< ' ' << result.score << " (" << result.moves << " moves"
<< (result.censored ? ", capped" : "") << ", clears "
<< result.numbered_cleared << ", reveals "
<< result.covers_revealed << ", work " << result.work << ")\n";
}
GameResult runBaselineGame(std::uint32_t seed, std::string_view label) {
const auto started = std::chrono::steady_clock::now();
State state = initialHeadlessState(seed);
GameResult result;
result.seed = seed;
const cfpi::BehaviorOptions options = baselineOptions();
while (!state.game_over && state.moves_played < kMaximumMoves) {
cfpi::BehaviorMetrics metrics;
const int action = cfpi::chooseBehaviorAction(state, options, &metrics);
if (!metrics.complete || metrics.completed_depth != kDepth) {
throw std::runtime_error("cfpi baseline did not complete exact depth three");
}
if (!isLegal(state.board, action)) {
throw std::runtime_error("cfpi baseline chose an illegal action");
}
result.work += metrics.work;
result.nodes += metrics.nodes;
result.cache_hits += metrics.cache_hits;
result.maximum_cache_entries =
std::max(result.maximum_cache_entries, metrics.cache_entries);
MoveResult move;
if (!playHeadlessMove(state, seed, action, move)) {
throw std::runtime_error("cfpi baseline transition failed");
}
observeMove(move, result);
}
result.score = state.score;
result.moves = state.moves_played;
result.censored = !state.game_over;
result.peak_rss_bytes = peakRssBytes();
result.elapsed_seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started)
.count();
reportGame(label, result);
return result;
}
GameResult runFairGame(std::uint32_t seed, std::string_view label) {
const auto started = std::chrono::steady_clock::now();
State state = initialHeadlessState(seed);
GameResult result;
result.seed = seed;
while (!state.game_over && state.moves_played < kMaximumMoves) {
const SearchDecision decision = chooseFairAction(state);
if (!decision.complete || decision.completed_depth != kDepth) {
throw std::runtime_error("fair-only search did not complete depth three");
}
if (!isLegal(state.board, decision.action)) {
throw std::runtime_error("fair-only search chose an illegal action");
}
result.work += decision.work;
result.nodes += decision.nodes;
result.cache_hits += decision.cache_hits;
result.maximum_cache_entries =
std::max(result.maximum_cache_entries, decision.cache_entries);
MoveResult move;
if (!playHeadlessMove(state, seed, decision.action, move)) {
throw std::runtime_error("fair-only transition failed");
}
observeMove(move, result);
}
result.score = state.score;
result.moves = state.moves_played;
result.censored = !state.game_over;
result.peak_rss_bytes = peakRssBytes();
result.elapsed_seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started)
.count();
reportGame(label, result);
return result;
}
struct Cohort {
std::vector<GameResult> baseline;
std::vector<GameResult> fair;
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.baseline.resize(games);
result.fair.resize(games);
std::atomic<int> next_game{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_game.fetch_add(1);
if (game >= games) return;
const std::uint32_t seed =
seed_start + static_cast<std::uint32_t>(game);
result.baseline[game] = runBaselineGame(
seed, std::string(phase) + "-cfpi-d3");
result.fair[game] =
runFairGame(seed, std::string(phase) + "-fair-only-d3");
}
}));
}
for (auto& worker : workers) worker.get();
result.wall_seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started)
.count();
return result;
}
struct Summary {
int games = 0;
double mean_score = 0.0;
double mean_moves = 0.0;
int censored = 0;
double mean_numbered_cleared = 0.0;
double mean_covers_revealed = 0.0;
double clears_per_move = 0.0;
double reveals_per_move = 0.0;
double mean_maximum_chain = 0.0;
std::uint64_t work = 0;
std::uint64_t nodes = 0;
std::uint64_t cache_hits = 0;
double work_per_move = 0.0;
double aggregate_game_seconds = 0.0;
std::size_t maximum_cache_entries = 0;
std::uint64_t peak_rss_bytes = 0;
};
Summary summarize(const std::vector<GameResult>& games) {
if (games.empty()) throw std::invalid_argument("empty fair-only cohort");
Summary result;
result.games = static_cast<int>(games.size());
std::uint64_t moves = 0;
std::uint64_t cleared = 0;
std::uint64_t revealed = 0;
for (const GameResult& game : games) {
result.mean_score += static_cast<double>(game.score) / games.size();
result.mean_moves += static_cast<double>(game.moves) / games.size();
result.censored += game.censored;
result.mean_numbered_cleared +=
static_cast<double>(game.numbered_cleared) / games.size();
result.mean_covers_revealed +=
static_cast<double>(game.covers_revealed) / games.size();
result.mean_maximum_chain +=
static_cast<double>(game.maximum_chain) / games.size();
result.work += game.work;
result.nodes += game.nodes;
result.cache_hits += game.cache_hits;
result.aggregate_game_seconds += game.elapsed_seconds;
result.maximum_cache_entries =
std::max(result.maximum_cache_entries, game.maximum_cache_entries);
result.peak_rss_bytes =
std::max(result.peak_rss_bytes, game.peak_rss_bytes);
moves += static_cast<std::uint64_t>(game.moves);
cleared += game.numbered_cleared;
revealed += game.covers_revealed;
}
const double move_count =
static_cast<double>(std::max<std::uint64_t>(1, moves));
result.clears_per_move = cleared / move_count;
result.reveals_per_move = revealed / move_count;
result.work_per_move = result.work / move_count;
return result;
}
struct DifferenceStats {
double mean = 0.0;
double lower_95 = 0.0;
int wins = 0;
int ties = 0;
int losses = 0;
};
DifferenceStats differences(const std::vector<double>& values) {
if (values.empty()) throw std::invalid_argument("empty paired differences");
DifferenceStats result;
for (const double value : values) {
result.mean += value / values.size();
result.wins += value > 0.0;
result.ties += value == 0.0;
result.losses += value < 0.0;
}
double squares = 0.0;
for (const double value : values) {
squares += (value - result.mean) * (value - result.mean);
}
const double deviation = values.size() > 1
? std::sqrt(squares / (values.size() - 1))
: 0.0;
result.lower_95 =
result.mean - 1.96 * deviation / std::sqrt(values.size());
return result;
}
struct PairedSummary {
DifferenceStats score;
DifferenceStats moves;
DifferenceStats numbered_cleared;
DifferenceStats covers_revealed;
};
PairedSummary pairedSummary(const Cohort& cohort) {
if (cohort.baseline.size() != cohort.fair.size() ||
cohort.baseline.empty()) {
throw std::invalid_argument("invalid fair-only paired cohort");
}
std::vector<double> scores;
std::vector<double> moves;
std::vector<double> cleared;
std::vector<double> revealed;
for (std::size_t game = 0; game < cohort.baseline.size(); ++game) {
scores.push_back(static_cast<double>(cohort.fair[game].score -
cohort.baseline[game].score));
moves.push_back(static_cast<double>(cohort.fair[game].moves -
cohort.baseline[game].moves));
cleared.push_back(static_cast<double>(cohort.fair[game].numbered_cleared) -
cohort.baseline[game].numbered_cleared);
revealed.push_back(static_cast<double>(cohort.fair[game].covers_revealed) -
cohort.baseline[game].covers_revealed);
}
return {differences(scores), differences(moves), differences(cleared),
differences(revealed)};
}
bool improvesBothMeans(const Summary& baseline, const Summary& fair) {
return fair.mean_score > baseline.mean_score &&
fair.mean_moves > baseline.mean_moves;
}
void writeGame(std::ostream& output, const GameResult& game) {
output << "{\"seed\":" << game.seed << ",\"score\":" << game.score
<< ",\"moves\":" << game.moves
<< ",\"censored\":" << (game.censored ? "true" : "false")
<< ",\"numberedCleared\":" << game.numbered_cleared
<< ",\"coversRevealed\":" << game.covers_revealed
<< ",\"maximumChain\":" << game.maximum_chain
<< ",\"work\":" << game.work << ",\"nodes\":" << game.nodes
<< ",\"cacheHits\":" << game.cache_hits
<< ",\"maximumCacheEntries\":" << game.maximum_cache_entries
<< ",\"elapsedSeconds\":" << game.elapsed_seconds
<< ",\"peakRssBytes\":" << game.peak_rss_bytes << '}';
}
void writeSummary(std::ostream& output, const Summary& summary) {
output << "{\"games\":" << summary.games
<< ",\"meanScore\":" << summary.mean_score
<< ",\"meanMoves\":" << summary.mean_moves
<< ",\"censored\":" << summary.censored
<< ",\"meanNumberedCleared\":"
<< summary.mean_numbered_cleared
<< ",\"meanCoversRevealed\":" << summary.mean_covers_revealed
<< ",\"clearsPerMove\":" << summary.clears_per_move
<< ",\"revealsPerMove\":" << summary.reveals_per_move
<< ",\"meanMaximumChain\":" << summary.mean_maximum_chain
<< ",\"work\":" << summary.work
<< ",\"workPerMove\":" << summary.work_per_move
<< ",\"nodes\":" << summary.nodes
<< ",\"cacheHits\":" << summary.cache_hits
<< ",\"aggregateGameSeconds\":"
<< summary.aggregate_game_seconds
<< ",\"maximumCacheEntries\":" << summary.maximum_cache_entries
<< ",\"peakRssBytes\":" << summary.peak_rss_bytes << '}';
}
void writeDifference(std::ostream& output, const DifferenceStats& difference) {
output << "{\"mean\":" << difference.mean
<< ",\"lower95\":" << difference.lower_95
<< ",\"wins\":" << difference.wins
<< ",\"ties\":" << difference.ties
<< ",\"losses\":" << difference.losses << '}';
}
void writePaired(std::ostream& output, const PairedSummary& paired) {
output << "{\"score\":";
writeDifference(output, paired.score);
output << ",\"moves\":";
writeDifference(output, paired.moves);
output << ",\"numberedCleared\":";
writeDifference(output, paired.numbered_cleared);
output << ",\"coversRevealed\":";
writeDifference(output, paired.covers_revealed);
output << '}';
}
void writePairs(std::ostream& output, const Cohort& cohort) {
output << '[';
for (std::size_t game = 0; game < cohort.baseline.size(); ++game) {
if (game != 0) output << ',';
output << "{\"seed\":" << cohort.baseline[game].seed
<< ",\"cfpi\":";
writeGame(output, cohort.baseline[game]);
output << ",\"fairOnly\":";
writeGame(output, cohort.fair[game]);
output << '}';
}
output << ']';
}
void writeCohort(std::ostream& output, std::uint32_t seed_start,
const Cohort& cohort, const Summary& baseline,
const Summary& fair, const PairedSummary& paired,
bool passed) {
output << "{\"seedStart\":" << seed_start
<< ",\"maximumMoves\":" << kMaximumMoves << ",\"cfpi\":";
writeSummary(output, baseline);
output << ",\"fairOnly\":";
writeSummary(output, fair);
output << ",\"paired\":";
writePaired(output, paired);
output << ",\"wallSeconds\":" << cohort.wall_seconds
<< ",\"passed\":" << (passed ? "true" : "false")
<< ",\"pairs\":";
writePairs(output, cohort);
output << '}';
}
struct Options {
std::string output = "/tmp/drop7-fair-only-horizon.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-only option value");
}
const std::string argument = argv[index];
if (argument == "--output") {
result.output = argv[index + 1];
} else {
throw std::invalid_argument("unknown fair-only option " + argument);
}
}
return result;
}
void writeArtifact(const Options& options, const Cohort& screen,
const Summary& screen_baseline,
const Summary& screen_fair,
const PairedSummary& screen_paired, bool screen_passed,
const Cohort* confirmation,
const Summary* confirmation_baseline,
const Summary* confirmation_fair,
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-only result artifact");
}
output << std::setprecision(10)
<< "{\n \"experiment\":\"historical-fair-only-horizon\",\n"
<< " \"preregistered\":true,\n"
<< " \"publicStateOnly\":true,\n"
<< " \"phaseResidualIncluded\":false,\n"
<< " \"scoring\":{\"levelBonus\":" << kLevelBonus << "},\n"
<< " \"fairLeafWeights\":{\"openColumns\":"
<< kOpenColumnsWeight << ",\"heightLoad\":" << kHeightLoadWeight
<< ",\"solidCells\":" << kSolidCellsWeight
<< ",\"crackedCells\":" << kCrackedCellsWeight
<< ",\"numberedCells\":" << kNumberedCellsWeight
<< ",\"highLowNumbers\":" << kHighLowNumbersWeight
<< ",\"directPotential\":" << kDirectPotentialWeight
<< ",\"latentChainPotential\":"
<< kLatentChainPotentialWeight
<< ",\"crackedExposure\":" << kCrackedExposureWeight
<< ",\"solidExposure\":" << kSolidExposureWeight
<< ",\"adjacentOnes\":" << kAdjacentOnesWeight
<< ",\"tripleTwos\":" << kTripleTwosWeight
<< ",\"deadLowNumbers\":" << kDeadLowNumbersWeight
<< ",\"coveredHeightRisk\":" << kCoveredHeightRiskWeight
<< ",\"lowNumberHeightRisk\":" << kLowNumberHeightRiskWeight
<< ",\"dangerHeightSquared\":"
<< kDangerHeightSquaredWeight
<< ",\"roughness\":" << kRoughnessWeight
<< ",\"risePressure\":" << kRisePressureWeight
<< ",\"nextDiscVerticalOptions\":"
<< kNextDiscVerticalOptionsWeight
<< ",\"inertLeafOnlyRevealedCoverOverride\":"
<< kRevealedCoverWeight << "},\n"
<< " \"search\":{\"depth\":" << kDepth
<< ",\"chanceSamples\":" << kChanceSamples
<< ",\"policySeed\":" << kPolicySeed
<< ",\"maximumWork\":" << kMaximumWork
<< ",\"maximumCacheEntries\":" << kMaximumCacheEntries
<< ",\"terminalUtility\":" << kTerminalUtility
<< ",\"maximumMoves\":" << kMaximumMoves
<< ",\"parallelism\":" << kParallelism << "},\n"
<< " \"historicalTwoSeedEvidence\":{\"legacyScoring\":true,"
"\"seeds\":[493879296,493879297],\"moves\":[155,160],"
"\"numberedCleared\":[331,351],"
"\"coversRevealed\":[186,201],\"maximumChains\":[7,9]},\n"
<< " \"screen\":";
writeCohort(output, kScreenSeedStart, screen, screen_baseline, screen_fair,
screen_paired, screen_passed);
output << ",\n \"confirmation\":";
if (confirmation == nullptr) {
output << "null";
} else {
writeCohort(output, kConfirmationSeedStart, *confirmation,
*confirmation_baseline, *confirmation_fair,
*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\":" << peakRssBytes() << "\n}\n";
}
struct ParityFixture {
const char* name;
const char* board;
int next_disc;
int moves_remaining;
double leaf;
int action;
std::uint64_t nodes;
std::uint64_t work;
std::size_t cache_entries;
std::uint64_t cache_hits;
std::array<double, kBoardSize> root_values;
std::array<double, kBoardSize> expected_scores;
};
constexpr std::array<ParityFixture, 3> kTypeScriptFixtures{{
{"initial",
"0000000000000000000000000000000000000000008888888", 4, 5,
-4057.5, 3, 19'600, 38'430, 557, 213,
{{-791.2325500000001, -906.8204000000002, -791.0671875,
-747.3290875, -791.0671875, -906.8204000000002,
-791.2325500000001}},
{{0, 0, 0, 0, 0, 0, 0}}},
{"manual",
"0000000000000000000000000000000000000009003588488", 6, 3,
-548.8708333333332, 1, 31'360, 61'670, 893, 157,
{{14251.606231875001, 16008.323409843752, 15488.496067441407,
15023.25721203125, 15915.650916679688, 14858.3069646875,
15066.728959999999}},
{{0, 0, 0, 0, 0, 0, 0}}},
{"walked",
"0000000000000000000006107166886898888888888888888", 3, 5,
-23504.1, 6, 26'950, 52'850, 767, 283,
{{-21506.26535234375, -26583.396035, -23511.670576875003,
-25931.96154, -26695.164760000003, -21331.297685,
-20260.29117125}},
{{0, 0, 46, 0, 0, 0, 0}}},
}};
State fixtureState(const ParityFixture& fixture) {
const std::string_view board(fixture.board);
if (board.size() != kCellCount) {
throw std::logic_error("invalid TypeScript parity board length");
}
State result;
for (int index = 0; index < kCellCount; ++index) {
const char token = board[index];
if (token < '0' || token > '9') {
throw std::logic_error("invalid TypeScript parity board token");
}
result.board[index] = static_cast<std::uint8_t>(token - '0');
}
result.next_disc = static_cast<std::uint8_t>(fixture.next_disc);
result.moves_remaining = fixture.moves_remaining;
return result;
}
bool selfTest(std::ostream& output) {
const bool behavior = cfpi::selfTest(output);
double maximum_leaf_error = 0.0;
double maximum_root_error = 0.0;
double maximum_score_error = 0.0;
bool fixture_parity = true;
for (const ParityFixture& fixture : kTypeScriptFixtures) {
const State state = fixtureState(fixture);
maximum_leaf_error =
std::max(maximum_leaf_error, std::abs(fairLeaf(state) - fixture.leaf));
const SearchDecision decision = chooseFairAction(state);
fixture_parity = fixture_parity && decision.complete &&
decision.completed_depth == kDepth &&
decision.action == fixture.action &&
decision.nodes == fixture.nodes &&
decision.work == fixture.work &&
decision.cache_entries == fixture.cache_entries &&
decision.cache_hits == fixture.cache_hits;
for (int column = 0; column < kBoardSize; ++column) {
maximum_root_error = std::max(
maximum_root_error,
std::abs(decision.root_values[column] - fixture.root_values[column]));
maximum_score_error = std::max(
maximum_score_error,
std::abs(decision.root_expected_scores[column] -
fixture.expected_scores[column]));
}
}
fixture_parity = fixture_parity && maximum_leaf_error <= 1.0e-9 &&
maximum_root_error <= 1.0e-8 &&
maximum_score_error <= 1.0e-9;
const State source = fixtureState(kTypeScriptFixtures[1]);
State reflected = source;
reflected.board = cfpi::detail::mirrorBoard(source.board);
const SearchDecision source_decision = chooseFairAction(source);
const SearchDecision reflected_decision = chooseFairAction(reflected);
const bool reflection_safe =
fairLeaf(source) == fairLeaf(reflected) &&
reflected_decision.action ==
kBoardSize - 1 - source_decision.action;
State metadata = source;
metadata.score = 8'765'432;
metadata.level = 91;
metadata.moves_played = 417;
const SearchDecision metadata_decision = chooseFairAction(metadata);
const bool public_only = fairLeaf(source) == fairLeaf(metadata) &&
metadata_decision.action == source_decision.action &&
metadata_decision.work == source_decision.work;
State terminal = source;
terminal.game_over = true;
const bool terminal_safe = fairLeaf(terminal) == kFairTerminalUtility;
const bool fixed_protocol =
kLevelBonus == 17'000 && kDepth == 3 && kChanceSamples == 5 &&
kMaximumWork == 1'000'000 && kMaximumCacheEntries == 40'000 &&
kMaximumMoves == 1'000 && kScreenSeedStart == 0x3e95'0000u &&
kConfirmationSeedStart == 0x3e96'0000u &&
(kScreenSeedStart >> 24) != 0x7du &&
(kScreenSeedStart >> 24) != 0xd7u &&
(kConfirmationSeedStart >> 24) != 0x7du &&
(kConfirmationSeedStart >> 24) != 0xd7u;
const bool passed = behavior && fixture_parity && reflection_safe &&
public_only && terminal_safe && fixed_protocol;
output << std::setprecision(12)
<< "FAIR_ONLY_HORIZON_SELF_TEST {\"passed\":"
<< (passed ? "true" : "false")
<< ",\"typescriptFixtureParity\":"
<< (fixture_parity ? "true" : "false")
<< ",\"fixtureCount\":" << kTypeScriptFixtures.size()
<< ",\"maximumLeafError\":" << maximum_leaf_error
<< ",\"maximumRootError\":" << maximum_root_error
<< ",\"maximumExpectedScoreError\":" << maximum_score_error
<< ",\"reflectionSafe\":"
<< (reflection_safe ? "true" : "false")
<< ",\"publicStateOnly\":" << (public_only ? "true" : "false")
<< ",\"terminalSafe\":" << (terminal_safe ? "true" : "false")
<< ",\"phaseResidualIncluded\":false"
<< ",\"levelBonus\":" << kLevelBonus << "}\n";
return passed;
}
int run(const Options& options, std::ostream& output) {
const auto started = std::chrono::steady_clock::now();
const Cohort screen =
runCohort(kScreenSeedStart, kScreenGames, "screen");
const Summary screen_baseline = summarize(screen.baseline);
const Summary screen_fair = summarize(screen.fair);
const PairedSummary screen_paired = pairedSummary(screen);
const bool screen_passed =
improvesBothMeans(screen_baseline, screen_fair);
Cohort confirmation;
Summary confirmation_baseline;
Summary confirmation_fair;
PairedSummary confirmation_paired;
bool confirmation_passed = false;
if (screen_passed) {
confirmation = runCohort(kConfirmationSeedStart, kConfirmationGames,
"confirmation");
confirmation_baseline = summarize(confirmation.baseline);
confirmation_fair = summarize(confirmation.fair);
confirmation_paired = pairedSummary(confirmation);
confirmation_passed =
improvesBothMeans(confirmation_baseline, confirmation_fair);
}
const double total_wall = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started)
.count();
writeArtifact(
options, screen, screen_baseline, screen_fair, screen_paired,
screen_passed, screen_passed ? &confirmation : nullptr,
screen_passed ? &confirmation_baseline : nullptr,
screen_passed ? &confirmation_fair : nullptr,
screen_passed ? &confirmation_paired : nullptr, confirmation_passed,
total_wall);
output << std::fixed << std::setprecision(3)
<< "FAIR_ONLY_HORIZON_RESULT {\"levelBonus\":" << kLevelBonus
<< ",\"screenCfpiScore\":" << screen_baseline.mean_score
<< ",\"screenCfpiMoves\":" << screen_baseline.mean_moves
<< ",\"screenFairScore\":" << screen_fair.mean_score
<< ",\"screenFairMoves\":" << screen_fair.mean_moves
<< ",\"screenScoreDelta\":" << screen_paired.score.mean
<< ",\"screenMoveDelta\":" << screen_paired.moves.mean
<< ",\"screenPassed\":"
<< (screen_passed ? "true" : "false")
<< ",\"confirmationRan\":"
<< (screen_passed ? "true" : "false")
<< ",\"confirmationPassed\":"
<< (confirmation_passed ? "true" : "false")
<< ",\"peakRssBytes\":" << peakRssBytes()
<< ",\"totalWallSeconds\":" << total_wall
<< ",\"artifact\":\"" << options.output << "\"}\n";
return 0;
}
} // namespace drop7::fair_only_horizon
#ifndef DROP7_FAIR_ONLY_HORIZON_LIBRARY
int main(int argc, char** argv) {
try {
if (argc >= 2 && std::string_view(argv[1]) == "--self-test") {
return drop7::fair_only_horizon::selfTest(std::cout) ? EXIT_SUCCESS
: EXIT_FAILURE;
}
if (argc >= 2 && std::string_view(argv[1]) == "--run") {
const auto options =
drop7::fair_only_horizon::parseOptions(argc, argv, 2);
return drop7::fair_only_horizon::run(options, std::cout);
}
std::cerr << "usage: drop7_fair_only_horizon --self-test | --run "
"[--output PATH]\n";
return 2;
} catch (const std::exception& error) {
std::cerr << "error: " << error.what() << '\n';
return 1;
}
}
#endif