// play - the distilled student as the LEAF of the parameterised fair search, on
// the base engine, over a named cohort.
//
// play --parity [--parity-games 3] [--parity-moves 40]
// play --leaf-stats --model model.d7pdst --corpus runs/RID/corpus.bin
// play --model model.d7pdst --w 0.3 --scale 20000
// --seed-start 0xa51d1000 --games 64 --depth 4 --chance-samples 7
// --max-work 16000000 --threads 8 --output out.json
//
// THE BLEND
// ---------
// leafValue = (1 - w) * frozen::fairLeaf(s) + w * scale * student(s)
//
// `w = 0` short-circuits to the frozen leaf *before* the model is touched, so
// the comparator arm is the reference bit for bit and costs exactly what the
// reference costs. That is the correctness anchor, and `--parity` checks it
// against the unmodified frozen entry point.
//
// THE WORK BOUND
// --------------
// `finding-05` measured that at depth 4 exact seven-strata chance handling is
// worth +101,171 points (95% lower bound +47,457) while at depth 3 it is worth
// nothing. Worst-case depth-4 work is 3,134,950 at five strata and 11,892,398
// at seven, so the frozen 3,200,000 bound silently degrades a seven-stratum
// depth-4 search to a completed depth 3. Every seven-stratum arm must pass
// `--max-work 16000000`, and the completed depth is recorded per decision and
// summarised so the degradation cannot go unnoticed.
#include "fair-search.hpp"
#include "corpus.hpp"
#include "student.hpp"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace {
using namespace drop7;
using namespace drop7::distill;
// The one cohort outside the seed lease this binary may open: the shared
// evaluation cohort every other arm in this session reported on. It is already
// development data (finding-05's confirmation cohort) and is never tuned on.
constexpr std::uint32_t kEvalStart = 0xa51d'1000u;
constexpr std::uint32_t kEvalEnd = 0xa51d'103fu;
constexpr std::uint32_t kLeaseStart = 0xa526'0000u;
constexpr std::uint32_t kLeaseEnd = 0xa526'ffffu;
struct StudentLeaf : LeafModel {
const Student* model = nullptr;
double value(const State& state) const override {
float scratch[1024];
float residual = 0.0f;
float lifetime = 0.0f;
model->evaluate(state.board.data(), state.next_disc, state.moves_remaining,
scratch, residual, lifetime);
return residual;
}
};
struct Options {
std::string model;
std::string corpus;
std::string output;
std::uint32_t seed_start = kEvalStart;
int games = 64;
int depth = 4;
int chance_samples = 7;
std::uint64_t max_work = 16'000'000;
int max_moves = 2000;
int threads = 8;
double w = 0.0;
double scale = 1.0;
bool parity = false;
bool leaf_stats = false;
int parity_games = 3;
int parity_moves = 40;
std::string label = "student-leaf";
};
struct GameRecord {
std::uint32_t seed = 0;
std::int64_t score = 0;
int moves = 0;
bool censored = false;
std::int64_t cleared = 0;
std::int64_t revealed = 0;
std::int64_t occupancy = 0;
int max_chain_depth = 0;
std::int64_t chain_depth_sum = 0;
std::int64_t waves = 0;
std::uint64_t work = 0;
std::uint64_t leaf_calls = 0;
int shallow_decisions = 0;
int illegal = 0;
double wall_seconds = 0.0;
std::vector<int> cycle_occupancy;
};
int occupied(const Board& board) {
int count = 0;
for (std::uint8_t cell : board) {
if (cell != kEmpty) ++count;
}
return count;
}
double occupancySlope(const std::vector<int>& values, int skip) {
const int n = static_cast<int>(values.size()) - skip;
if (n < 3) return 0.0;
double sx = 0, sy = 0, sxy = 0, sxx = 0;
for (int index = 0; index < n; ++index) {
const double x = index;
const double y = values[static_cast<std::size_t>(index + skip)];
sx += x; sy += y; sxy += x * y; sxx += x * x;
}
const double denominator = n * sxx - sx * sx;
return denominator == 0.0 ? 0.0 : (n * sxy - sx * sy) / denominator;
}
GameRecord playOne(const Options& options, const Student* model,
std::uint32_t seed) {
GameRecord record;
record.seed = seed;
StudentLeaf leaf;
leaf.model = model;
SearchParameters parameters;
parameters.depth = options.depth;
parameters.chanceSamples = options.chance_samples;
parameters.maximumWork = options.max_work;
parameters.leafWeight = options.w;
parameters.leafScale = options.scale;
parameters.leaf = options.w == 0.0 ? nullptr : &leaf;
ParameterizedSearch search{parameters};
const auto started = std::chrono::steady_clock::now();
State state = initialHeadlessState(seed);
int moves_since_rise = 0;
while (!state.game_over && state.moves_played < options.max_moves) {
const RootValues values = search.evaluateRoot(state);
if (values.action < 0 || !isLegal(state.board, values.action)) {
++record.illegal;
break;
}
if (values.completedDepth < options.depth) ++record.shallow_decisions;
record.work += values.work;
record.leaf_calls += values.leafEvaluations;
MoveResult move;
if (!playHeadlessMove(state, seed, values.action, move)) break;
for (const Wave& wave : move.waves) {
record.cleared += wave.cleared;
record.revealed += wave.revealed;
record.chain_depth_sum += wave.depth;
++record.waves;
record.max_chain_depth = std::max(record.max_chain_depth, wave.depth);
}
++record.moves;
record.occupancy += occupied(move.state.board);
++moves_since_rise;
if (move.level_advanced) {
record.cycle_occupancy.push_back(occupied(move.state.board));
moves_since_rise = 0;
}
state = move.state;
}
record.score = state.score;
record.censored = !state.game_over;
record.wall_seconds =
std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
.count();
(void)moves_since_rise;
return record;
}
int runParity(const Options& options) {
namespace refns = drop7::fair_only_depth4;
SearchParameters parameters; // defaults: depth 4, 5 strata, w = 0
ParameterizedSearch mine{parameters};
std::uint64_t mismatches = 0, compared = 0;
for (int game = 0; game < options.parity_games; ++game) {
const std::uint32_t seed = kEvalStart + static_cast<std::uint32_t>(game);
State state = initialHeadlessState(seed);
while (!state.game_over && state.moves_played < options.parity_moves) {
const refns::SearchDecision reference = refns::chooseDepth4Action(state);
std::uint64_t work = 0;
const int candidate = mine.chooseAction(state, work);
++compared;
if (candidate != reference.action) ++mismatches;
MoveResult move;
if (!playHeadlessMove(state, seed, reference.action, move)) break;
}
}
std::printf("parity: %llu moves compared, %llu mismatches\n%s\n",
static_cast<unsigned long long>(compared),
static_cast<unsigned long long>(mismatches),
mismatches == 0 ? "PARITY OK" : "PARITY FAILED");
return mismatches == 0 ? 0 : 1;
}
// Measures the student's own spread against the frozen leaf's, so `--scale` can
// be read as a mixing weight between comparable spreads rather than as an
// arbitrary constant, and measures the per-state inference cost that decides
// whether the model can play at all.
int runLeafStats(const Options& options) {
Student model(options.model);
std::ifstream file(options.corpus, std::ios::binary | std::ios::ate);
if (!file) {
std::cerr << "cannot open " << options.corpus << "\n";
return 1;
}
const std::streamsize bytes = file.tellg();
const std::size_t rows =
static_cast<std::size_t>(bytes) / sizeof(drop7::distill::RootRecord);
std::vector<drop7::distill::RootRecord> records(rows);
file.seekg(0);
file.read(reinterpret_cast<char*>(records.data()), bytes);
std::vector<double> frozen_values, student_values;
std::vector<float> scratch(4096);
const std::size_t limit = std::min<std::size_t>(rows, 20000);
for (std::size_t row = 0; row < limit; ++row) {
const auto& record = records[row];
for (int column = 0; column < kBoardSize; ++column) {
if (((record.legal_mask >> column) & 1u) == 0) continue;
if (record.after_survived[column] == 0) continue;
State state;
for (int index = 0; index < drop7::distill::kCells; ++index) {
state.board[static_cast<std::size_t>(index)] =
record.after_board[column][index];
}
state.next_disc = record.after_next_disc[column];
state.score = 0;
state.level = 1;
state.moves_remaining = record.after_moves_remaining[column];
state.moves_played = 0;
state.game_over = false;
frozen_values.push_back(drop7::distill::frozen::fairLeaf(state));
float residual = 0.0f, lifetime = 0.0f;
model.evaluate(state.board.data(), state.next_disc, state.moves_remaining,
scratch.data(), residual, lifetime);
student_values.push_back(residual);
break; // one afterstate per root keeps the sample independent-ish
}
}
const auto stats = [](const std::vector<double>& values) {
double mean = 0.0;
for (double value : values) mean += value;
mean /= std::max<std::size_t>(values.size(), 1);
double variance = 0.0;
for (double value : values) variance += (value - mean) * (value - mean);
variance /= std::max<std::size_t>(values.size(), 1);
return std::pair<double, double>{mean, std::sqrt(variance)};
};
const auto frozen_stat = stats(frozen_values);
const auto student_stat = stats(student_values);
double covariance = 0.0;
for (std::size_t index = 0; index < frozen_values.size(); ++index) {
covariance += (frozen_values[index] - frozen_stat.first) *
(student_values[index] - student_stat.first);
}
covariance /= std::max<std::size_t>(frozen_values.size(), 1);
// Inference cost, one thread, one state at a time - the way the search calls
// it. Batched throughput is irrelevant at an expectimax leaf.
State probe;
for (int index = 0; index < drop7::distill::kCells; ++index) {
probe.board[static_cast<std::size_t>(index)] = records[0].board[index];
}
probe.next_disc = records[0].next_disc;
probe.moves_remaining = records[0].moves_remaining;
const int repeats = 200000;
const auto started = std::chrono::steady_clock::now();
double sink = 0.0;
for (int index = 0; index < repeats; ++index) {
float residual = 0.0f, lifetime = 0.0f;
probe.next_disc = static_cast<std::uint8_t>(1 + (index % 7));
model.evaluate(probe.board.data(), probe.next_disc, probe.moves_remaining,
scratch.data(), residual, lifetime);
sink += residual;
}
const double elapsed =
std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
.count();
std::printf(
"states sampled %zu\n"
"frozen fairLeaf : mean %.1f sd %.1f\n"
"student residual: mean %.4f sd %.4f\n"
"pearson : %.4f\n"
"equal-influence scale (sd ratio): %.1f\n"
"inference : %.3f us per state, one thread (sink %.3f)\n"
"parameters : %zu, digest 0x%016llx\n",
frozen_values.size(), frozen_stat.first, frozen_stat.second,
student_stat.first, student_stat.second,
covariance / std::max(1e-12, frozen_stat.second * student_stat.second),
frozen_stat.second / std::max(1e-9, student_stat.second),
1e6 * elapsed / repeats, sink, model.parameterCount(),
static_cast<unsigned long long>(model.digest()));
return 0;
}
int runCohort(const Options& options) {
const std::uint32_t last =
options.seed_start + static_cast<std::uint32_t>(options.games) - 1u;
const bool in_eval =
options.seed_start >= kEvalStart && last <= kEvalEnd;
const bool in_lease =
options.seed_start >= kLeaseStart && last <= kLeaseEnd;
if (!in_eval && !in_lease) {
std::cerr << "seeds outside the lease and outside the shared eval cohort\n";
return 2;
}
std::unique_ptr<Student> model;
if (options.w != 0.0) {
if (options.model.empty()) {
std::cerr << "--w != 0 needs --model\n";
return 2;
}
model = std::make_unique<Student>(options.model);
}
std::vector<GameRecord> games(static_cast<std::size_t>(options.games));
std::atomic<int> next{0};
std::mutex log;
const auto started = std::chrono::steady_clock::now();
std::vector<std::thread> pool;
const int threads = std::max(1, std::min(options.threads, options.games));
for (int worker = 0; worker < threads; ++worker) {
pool.emplace_back([&] {
for (;;) {
const int index = next.fetch_add(1);
if (index >= options.games) return;
const std::uint32_t seed =
options.seed_start + static_cast<std::uint32_t>(index);
GameRecord record = playOne(options, model.get(), seed);
{
std::lock_guard<std::mutex> guard(log);
std::printf("game 0x%08x moves %5d score %10lld %.1f s\n", seed,
record.moves, static_cast<long long>(record.score),
record.wall_seconds);
std::fflush(stdout);
}
games[static_cast<std::size_t>(index)] = std::move(record);
}
});
}
for (std::thread& thread : pool) thread.join();
const double wall =
std::chrono::duration<double>(std::chrono::steady_clock::now() - started)
.count();
std::int64_t moves = 0, score = 0, cleared = 0, revealed = 0, occupancy = 0;
std::int64_t work = 0, leaf_calls = 0, waves = 0, depth_sum = 0;
int censored = 0, shallow = 0, illegal = 0, deepest = 0;
double slope_sum = 0.0;
int slope_games = 0;
for (const GameRecord& record : games) {
moves += record.moves;
score += record.score;
cleared += record.cleared;
revealed += record.revealed;
occupancy += record.occupancy;
work += record.work;
leaf_calls += record.leaf_calls;
waves += record.waves;
depth_sum += record.chain_depth_sum;
deepest = std::max(deepest, record.max_chain_depth);
censored += record.censored ? 1 : 0;
shallow += record.shallow_decisions;
illegal += record.illegal;
if (record.cycle_occupancy.size() >= 4) {
slope_sum += occupancySlope(record.cycle_occupancy, 1);
++slope_games;
}
}
const double n = static_cast<double>(options.games);
const double move_total = static_cast<double>(std::max<std::int64_t>(moves, 1));
std::printf(
"\n=== %s w=%.3f scale=%.1f depth %d strata %d work %llu, %d games ===\n"
"score mean %.1f, moves mean %.2f, censored %d\n"
"clears/move %.4f, reveals/move %.4f, mean occupancy %.2f, slope %.3f\n"
"decisions below the requested depth: %d; illegal: %d\n"
"leaf model calls %lld, logical work %lld, %.1f s wall on %d threads\n",
options.label.c_str(), options.w, options.scale, options.depth,
options.chance_samples, static_cast<unsigned long long>(options.max_work),
options.games, score / n, moves / n, censored, cleared / move_total,
revealed / move_total, occupancy / move_total,
slope_games ? slope_sum / slope_games : 0.0, shallow, illegal,
static_cast<long long>(leaf_calls), static_cast<long long>(work), wall,
threads);
(void)waves;
(void)depth_sum;
(void)deepest;
if (!options.output.empty()) {
std::ofstream file(options.output);
if (!file) {
std::cerr << "cannot open " << options.output << "\n";
return 1;
}
file << std::setprecision(12);
file << "{\"schema\":\"drop7-planner-distill-cohort-v1\",\"label\":\""
<< options.label << "\",\"config\":{\"w\":" << options.w
<< ",\"scale\":" << options.scale << ",\"depth\":" << options.depth
<< ",\"chanceSamples\":" << options.chance_samples
<< ",\"maximumWork\":" << options.max_work
<< ",\"model\":\"" << options.model << "\"},\"games\":[";
for (std::size_t index = 0; index < games.size(); ++index) {
const GameRecord& record = games[index];
if (index) file << ',';
file << "{\"seed\":\"0x" << std::hex << record.seed << std::dec
<< "\",\"score\":" << record.score
<< ",\"moves\":" << record.moves
<< ",\"censored\":" << (record.censored ? "true" : "false")
<< ",\"numberedClears\":" << record.cleared
<< ",\"coveredReveals\":" << record.revealed
<< ",\"maxChainDepth\":" << record.max_chain_depth
<< ",\"logicalWork\":" << record.work
<< ",\"modelInferences\":" << record.leaf_calls
<< ",\"shallowDecisions\":" << record.shallow_decisions
<< ",\"illegalDecisions\":" << record.illegal
<< ",\"wallSeconds\":" << record.wall_seconds << "}";
}
file << "],\"wallSeconds\":" << wall << "}\n";
}
return 0;
}
} // namespace
int main(int argc, char** argv) {
Options options;
for (int index = 1; index < argc; ++index) {
const std::string flag = argv[index];
if (flag == "--parity") {
options.parity = true;
} else if (flag == "--leaf-stats") {
options.leaf_stats = true;
} else if (flag == "--model" && index + 1 < argc) {
options.model = argv[++index];
} else if (flag == "--corpus" && index + 1 < argc) {
options.corpus = argv[++index];
} else if (flag == "--output" && index + 1 < argc) {
options.output = argv[++index];
} else if (flag == "--label" && index + 1 < argc) {
options.label = argv[++index];
} else if (flag == "--seed-start" && index + 1 < argc) {
options.seed_start = static_cast<std::uint32_t>(
std::strtoul(argv[++index], nullptr, 0));
} else if (flag == "--games" && index + 1 < argc) {
options.games = std::atoi(argv[++index]);
} else if (flag == "--depth" && index + 1 < argc) {
options.depth = std::atoi(argv[++index]);
} else if (flag == "--chance-samples" && index + 1 < argc) {
options.chance_samples = std::atoi(argv[++index]);
} else if (flag == "--max-work" && index + 1 < argc) {
options.max_work = std::strtoull(argv[++index], nullptr, 0);
} else if (flag == "--max-moves" && index + 1 < argc) {
options.max_moves = std::atoi(argv[++index]);
} else if (flag == "--threads" && index + 1 < argc) {
options.threads = std::atoi(argv[++index]);
} else if (flag == "--w" && index + 1 < argc) {
options.w = std::atof(argv[++index]);
} else if (flag == "--scale" && index + 1 < argc) {
options.scale = std::atof(argv[++index]);
} else if (flag == "--parity-games" && index + 1 < argc) {
options.parity_games = std::atoi(argv[++index]);
} else if (flag == "--parity-moves" && index + 1 < argc) {
options.parity_moves = std::atoi(argv[++index]);
} else {
std::cerr << "unknown argument " << flag << "\n";
return 2;
}
}
if (options.parity) return runParity(options);
if (options.leaf_stats) return runLeafStats(options);
return runCohort(options);
}