// Parity gate for the scenario engine.
//
// Proves that `ScenarioEngine<StreamRevealSource>` is trajectory-identical to
// `drop7::playHeadlessMove`. Nothing downstream of this file is trustworthy
// unless this reports zero mismatches: a mismatch means the scenario engine is
// not the same game.
//
// Also checks the two paired transforms (gravity, row rise) directly, and
// reports the marginal reveal distribution of both reveal sources so the claim
// "same marginal, different dynamics" is measured rather than asserted.
#include "scenario.hpp"
#include "scenario-io.hpp"
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
namespace {
using namespace drop7;
using namespace drop7::scenario;
constexpr std::uint32_t kLeaseStart = 0xa51d'c000u;
constexpr std::uint32_t kLeaseEnd = 0xa51d'ffffu;
std::uint32_t leaseSeed(std::uint32_t offset) {
const std::uint32_t seed = kLeaseStart + offset;
if (seed > kLeaseEnd) {
std::cerr << "seed lease SEEDLEASE-A51D-SCEN exhausted\n";
std::exit(2);
}
return seed;
}
int lowestColumnMove(const Board& board) {
int best = -1;
int best_height = kBoardSize + 1;
for (int column = 0; column < kBoardSize; ++column) {
if (!isLegal(board, column)) continue;
int height = 0;
for (int row = 0; row < kBoardSize; ++row) {
if (board[indexOf(row, column)] != kEmpty) ++height;
}
if (height < best_height) {
best_height = height;
best = column;
}
}
return best;
}
int centerPolicy(const Board& board) { return centerFirstMove(board); }
// The scenario-engine image of `drop7::playHeadlessMove`: same per-move reveal
// stream derivation, same visible-disc override.
bool playHeadlessScenarioMove(ScenarioEngine<StreamRevealSource>& engine,
std::uint32_t game_seed, int column,
MoveResult& result) {
const std::uint32_t reveal_seed =
mix32(game_seed ^
(static_cast<std::uint32_t>(engine.state().moves_played + 1) *
0x85eb'ca6bu) ^
kRevealDomain);
Mulberry32 random(reveal_seed);
engine.source().random = &random;
if (!engine.play(column, result)) return false;
if (!engine.state().game_over) {
engine.mutableState().next_disc =
headlessDisc(game_seed, engine.state().moves_played);
}
return true;
}
struct Counters {
long long seeds = 0;
long long moves = 0;
long long mismatches = 0;
long long games_ending_naturally = 0;
long long total_score_reference = 0;
long long total_score_scenario = 0;
};
bool sameWaves(const std::vector<Wave>& left, const std::vector<Wave>& right) {
if (left.size() != right.size()) return false;
for (std::size_t index = 0; index < left.size(); ++index) {
if (left[index].depth != right[index].depth) return false;
if (left[index].cleared != right[index].cleared) return false;
if (left[index].revealed != right[index].revealed) return false;
if (left[index].points != right[index].points) return false;
}
return true;
}
std::string describeWaves(const std::vector<Wave>& waves) {
std::string out = "[";
for (std::size_t index = 0; index < waves.size(); ++index) {
if (index != 0) out += ",";
out += "{d=" + std::to_string(waves[index].depth) +
",c=" + std::to_string(waves[index].cleared) +
",r=" + std::to_string(waves[index].revealed) +
",p=" + std::to_string(waves[index].points) + "}";
}
return out + "]";
}
// Plays one seed under both engines in lockstep and compares every observable.
void compareSeed(std::uint32_t seed, int (*policy)(const Board&),
const char* policy_name, int max_moves, Counters& counters) {
State reference = initialHeadlessState(seed);
ScenarioEngine<StreamRevealSource> engine(initialHeadlessState(seed),
LatentBoard{},
StreamRevealSource{});
++counters.seeds;
for (int move = 0; move < max_moves; ++move) {
if (reference.game_over || engine.state().game_over) break;
const int column = policy(reference.board);
if (column < 0) break;
const int scenario_column = policy(engine.state().board);
MoveResult reference_result;
MoveResult scenario_result;
const bool reference_ok =
playHeadlessMove(reference, seed, column, reference_result);
const bool scenario_ok = playHeadlessScenarioMove(engine, seed,
scenario_column,
scenario_result);
++counters.moves;
bool bad = false;
std::string what;
if (column != scenario_column) {
bad = true;
what = "policy chose different columns (boards already diverged)";
}
if (reference_ok != scenario_ok) {
bad = true;
what = "playMove acceptance differs";
}
if (!reference_ok) break;
if (reference.board != engine.state().board) {
bad = true;
what = "board differs";
}
if (reference.next_disc != engine.state().next_disc) {
bad = true;
what = "next disc differs";
}
if (reference.score != engine.state().score) {
bad = true;
what = "score differs";
}
if (reference.moves_remaining != engine.state().moves_remaining) {
bad = true;
what = "moves remaining differs";
}
if (reference.level != engine.state().level) {
bad = true;
what = "level differs";
}
if (reference.moves_played != engine.state().moves_played) {
bad = true;
what = "moves played differs";
}
if (reference.game_over != engine.state().game_over) {
bad = true;
what = "game over differs";
}
if (reference_result.score_delta != scenario_result.score_delta) {
bad = true;
what = "score delta differs";
}
if (reference_result.cleared_board != scenario_result.cleared_board) {
bad = true;
what = "cleared board flag differs";
}
if (reference_result.level_advanced != scenario_result.level_advanced) {
bad = true;
what = "level advanced flag differs";
}
if (!sameWaves(reference_result.waves, scenario_result.waves)) {
bad = true;
what = "wave list differs";
}
if (bad) {
++counters.mismatches;
if (counters.mismatches <= 5) {
std::cerr << "MISMATCH policy=" << policy_name << " seed=0x" << std::hex
<< seed << std::dec << " move=" << move << ": " << what
<< "\n reference board " << serializeBoard(reference.board)
<< "\n scenario board "
<< serializeBoard(engine.state().board)
<< "\n reference waves "
<< describeWaves(reference_result.waves)
<< "\n scenario waves "
<< describeWaves(scenario_result.waves)
<< "\n reference score " << reference.score
<< " scenario score " << engine.state().score << "\n";
}
return;
}
}
if (reference.game_over) ++counters.games_ending_naturally;
counters.total_score_reference += reference.score;
counters.total_score_scenario += engine.state().score;
}
// Direct check that the paired gravity transform moves the latent array by the
// permutation the board actually experienced.
bool checkGravityPairing(int trials) {
Mulberry32 random(0x9e37'79b9u);
for (int trial = 0; trial < trials; ++trial) {
Board board{};
LatentBoard tags{};
constexpr std::array<std::uint8_t, 11> alphabet{
{kEmpty, kEmpty, 1, 2, 3, 4, 5, 6, 7, kSolid, kCracked}};
for (int index = 0; index < kCellCount; ++index) {
board[index] = alphabet[random.nextBits() % alphabet.size()];
tags[index] = static_cast<std::uint8_t>(index + 1);
}
Board out_board{};
LatentBoard out_tags{};
applyGravityPaired(board, tags, out_board, out_tags);
if (out_board != applyGravity(board)) {
std::cerr << "gravity board result differs from drop7::applyGravity\n";
return false;
}
for (int index = 0; index < kCellCount; ++index) {
if (out_board[index] == kEmpty) {
if (out_tags[index] != 0) {
std::cerr << "gravity left a tag on an empty cell\n";
return false;
}
continue;
}
const int source = out_tags[index] - 1;
if (source < 0 || source >= kCellCount ||
board[source] != out_board[index]) {
std::cerr << "gravity latent permutation does not track the board\n";
return false;
}
}
}
return true;
}
bool checkRisePairing(int trials) {
Mulberry32 random(0x8523'11abu);
for (int trial = 0; trial < trials; ++trial) {
Board board{};
LatentBoard tags{};
for (int column = 0; column < kBoardSize; ++column) {
const int height = static_cast<int>(random.nextBits() % 6u);
for (int row = kBoardSize - 1; row >= kBoardSize - height; --row) {
board[indexOf(row, column)] =
static_cast<std::uint8_t>(1u + random.nextBits() % 9u);
}
}
for (int index = 0; index < kCellCount; ++index) {
tags[index] = static_cast<std::uint8_t>(index + 1);
}
RiseRow rise{};
for (int column = 0; column < kBoardSize; ++column) rise[column] = 200;
Board out_board{};
LatentBoard out_tags{};
Board expected{};
const bool expected_ok = raiseCoveredRow(board, expected);
const bool ok =
raiseCoveredRowPaired(board, tags, rise, out_board, out_tags);
if (ok != expected_ok) {
std::cerr << "rise legality differs from drop7::raiseCoveredRow\n";
return false;
}
if (!ok) continue;
if (out_board != expected) {
std::cerr << "rise board differs from drop7::raiseCoveredRow\n";
return false;
}
for (int row = 0; row < kBoardSize - 1; ++row) {
for (int column = 0; column < kBoardSize; ++column) {
if (out_tags[indexOf(row, column)] != tags[indexOf(row + 1, column)]) {
std::cerr << "rise latent shift does not match the board shift\n";
return false;
}
}
}
for (int column = 0; column < kBoardSize; ++column) {
if (out_tags[indexOf(kBoardSize - 1, column)] != 200 ||
out_board[indexOf(kBoardSize - 1, column)] != kSolid) {
std::cerr << "risen row did not take its specified latent values\n";
return false;
}
}
}
return true;
}
// A latent-source game reveals exactly the values that were placed under the
// covered cells. This checks the defining property of LatentRevealSource.
bool checkLatentConsumption(int seeds) {
for (int index = 0; index < seeds; ++index) {
const std::uint32_t seed = leaseSeed(0x1000u + static_cast<std::uint32_t>(index));
Mulberry32 random(seed);
Scenario scenario;
scenario.board = initialBoard();
for (int cell = 0; cell < kCellCount; ++cell) {
scenario.latent[cell] =
scenario.board[cell] == kSolid ? random.nextDisc() : 0;
}
scenario.moves_remaining = kMovesPerLevel;
scenario.horizon = 12;
for (int move = 0; move < scenario.horizon; ++move) {
scenario.disc_tape.push_back(random.nextDisc());
}
const int rises = riseRowCount(scenario.horizon, scenario.moves_remaining);
for (int row = 0; row < rises; ++row) {
RiseRow values{};
for (int column = 0; column < kBoardSize; ++column) {
values[column] = random.nextDisc();
}
scenario.rise_latent.push_back(values);
}
assignScenarioId(scenario);
std::string reason;
if (!validateScenario(scenario, reason)) {
std::cerr << "generated scenario invalid: " << reason << "\n";
return false;
}
// Round-trip through JSONL.
Scenario reloaded;
std::string error;
if (!deserializeScenario(serializeScenario(scenario), reloaded, error)) {
std::cerr << "scenario JSONL round trip failed: " << error << "\n";
return false;
}
if (reloaded.board != scenario.board ||
reloaded.latent != scenario.latent ||
reloaded.disc_tape != scenario.disc_tape ||
reloaded.rise_latent != scenario.rise_latent ||
reloaded.moves_remaining != scenario.moves_remaining ||
reloaded.horizon != scenario.horizon ||
std::string(reloaded.id) != std::string(scenario.id)) {
std::cerr << "scenario JSONL round trip lost information\n";
return false;
}
auto engine = makeScenarioEngine(scenario);
for (int move = 0; move < scenario.horizon; ++move) {
if (engine.state().game_over) break;
const LatentBoard before = engine.latent();
const Board before_board = engine.state().board;
const int column = lowestColumnMove(engine.state().board);
if (column < 0) break;
MoveResult result;
if (!engine.play(column, result)) break;
// Every revealed value must have been one of the latent values that were
// sitting under a covered cell beforehand.
int revealed = 0;
for (const Wave& wave : result.waves) revealed += wave.revealed;
if (revealed > 0) {
int covered_before = 0;
for (int cell = 0; cell < kCellCount; ++cell) {
if (before_board[cell] == kSolid || before_board[cell] == kCracked) {
++covered_before;
}
if ((before_board[cell] == kSolid ||
before_board[cell] == kCracked) !=
(before[cell] != 0)) {
std::cerr << "latent array out of step with the board\n";
return false;
}
}
if (revealed > covered_before + kBoardSize) {
std::cerr << "more reveals than covered cells\n";
return false;
}
}
if (engine.source().invalid_latent) {
std::cerr << "revealed a covered cell with no latent value\n";
return false;
}
for (int cell = 0; cell < kCellCount; ++cell) {
const std::uint8_t board_cell = engine.state().board[cell];
const bool covered = board_cell == kSolid || board_cell == kCracked;
if (covered != (engine.latent()[cell] != 0)) {
std::cerr << "latent invariant violated after move " << move << "\n";
return false;
}
}
}
}
return true;
}
void reportRevealMarginals() {
std::array<long long, 8> stream{};
Mulberry32 random(leaseSeed(0x1100u));
for (int draw = 0; draw < 700000; ++draw) ++stream[random.nextDisc()];
double chi = 0.0;
const double expected = 700000.0 / 7.0;
for (int value = 1; value <= 7; ++value) {
const double diff = static_cast<double>(stream[value]) - expected;
chi += diff * diff / expected;
}
std::printf("reveal marginal chi-square (6 df, 700k draws): %.3f\n", chi);
}
} // namespace
int main(int argc, char** argv) {
int seeds = 512;
int max_moves = 2000;
for (int index = 1; index < argc; ++index) {
const std::string flag = argv[index];
if (flag == "--seeds" && index + 1 < argc) {
seeds = std::atoi(argv[++index]);
} else if (flag == "--max-moves" && index + 1 < argc) {
max_moves = std::atoi(argv[++index]);
}
}
std::printf("scenario parity gate\n");
std::printf("lease SEEDLEASE-A51D-SCEN 0x%08x-0x%08x\n", kLeaseStart,
kLeaseEnd);
if (!checkGravityPairing(20000)) {
std::printf("FAIL: paired gravity transform\n");
return 1;
}
std::printf("paired gravity transform: 20000 random boards, OK\n");
if (!checkRisePairing(20000)) {
std::printf("FAIL: paired rise transform\n");
return 1;
}
std::printf("paired rise transform: 20000 random boards, OK\n");
Counters center;
Counters lowest;
for (int index = 0; index < seeds; ++index) {
const std::uint32_t seed = leaseSeed(static_cast<std::uint32_t>(index));
compareSeed(seed, centerPolicy, "center-first", max_moves, center);
compareSeed(seed, lowestColumnMove, "lowest-column", max_moves, lowest);
}
std::printf(
"center-first : seeds=%lld moves=%lld mismatches=%lld "
"reference_total_score=%lld scenario_total_score=%lld\n",
center.seeds, center.moves, center.mismatches,
center.total_score_reference, center.total_score_scenario);
std::printf(
"lowest-column : seeds=%lld moves=%lld mismatches=%lld "
"reference_total_score=%lld scenario_total_score=%lld\n",
lowest.seeds, lowest.moves, lowest.mismatches,
lowest.total_score_reference, lowest.total_score_scenario);
const long long mismatches = center.mismatches + lowest.mismatches;
const long long moves = center.moves + lowest.moves;
if (!checkLatentConsumption(64)) {
std::printf("FAIL: latent reveal source invariants\n");
return 1;
}
std::printf("latent source invariants + JSONL round trip: 64 games, OK\n");
reportRevealMarginals();
std::printf("TOTAL: seeds=%d policies=2 game-plays=%lld moves=%lld mismatches=%lld\n",
seeds, center.seeds + lowest.seeds, moves, mismatches);
if (mismatches != 0) {
std::printf("PARITY GATE FAILED\n");
return 1;
}
std::printf("PARITY GATE PASSED\n");
return 0;
}