Drop7 Research
approaches/oracle-curriculum/oracle-distillation/oracle-distill.cppC++798 lines · 32.7 KB
#define main drop7_phase_student_embedded_main
#include "../../value-policy-learning/phase-distillation/phase-student.cpp"
#undef main

#include <sys/resource.h>

#include <chrono>
#include <fstream>
#include <unordered_map>

// Leakage-free policy distillation from the deliberately privileged future
// oracle in perfect-information-oracle/main.ts.  Only the teacher functions
// below receive a game seed.  Every example and student inference crosses the
// publicState() boundary, which retains only board, visible disc, and rise
// phase.  Protected probe/validation/final seed families are rejected.
namespace drop7::oracle_distill {

using Clock = std::chrono::steady_clock;
using phase_student::Example;
using phase_student::Network;
using phase_student::PolicyMetrics;

constexpr std::uint32_t kTrainingPartitionStart = 0x3d00'0000u;
constexpr std::uint32_t kTrainingPartitionEnd = 0x3e00'0000u;
constexpr std::uint32_t kOracleTrainStart = 0x3d7a'0000u;
constexpr std::uint32_t kBehaviorTrainStart = 0x3d7b'0000u;
constexpr std::uint32_t kOracleHoldoutStart = 0x3d7c'0000u;
constexpr std::uint32_t kBehaviorHoldoutStart = 0x3d7d'0000u;
constexpr std::uint32_t kScreenStart = 0x3d7e'0000u;
constexpr std::uint32_t kConfirmationStart = 0x3d7f'0000u;

constexpr int kOracleDepth = 4;
constexpr int kOracleBeam = 128;
constexpr double kTerminalPenalty = -1'000'000'000.0;
constexpr float kTeacherMass = 0.92f;

// These gates and the conservative deployment rule are fixed before the
// whole-game holdout or fresh policy screen is evaluated.
constexpr double kMinimumHoldoutTop1 = 0.30;
constexpr double kMinimumHoldoutTop2 = 0.55;
constexpr double kMaximumHoldoutCrossEntropy = 1.75;
constexpr float kMinimumStudentProbability = 0.40f;
constexpr float kMinimumProbabilityAdvantage = 0.12f;
constexpr double kMaximumExactQRangeLoss = 0.10;
constexpr double kMinimumExactQTolerance = 5'000.0;

constexpr std::array<int, kBoardSize> kCenterFirst{{3, 2, 4, 1, 5, 0, 6}};

enum class RollIn { kOracle, kBehavior };

struct Config {
  int oracle_train_games = 12;
  int behavior_train_games = 32;
  int oracle_holdout_games = 4;
  int behavior_holdout_games = 12;
  int label_moves = 200;
  int epochs = 30;
  int batch_size = 64;
  float learning_rate = 0.001f;
  int screen_games = 8;
  int confirmation_games = 16;
  int evaluation_moves = 500;
  std::string output = "/tmp/drop7-oracle-distill.json";
  std::string model = "/tmp/drop7-oracle-distill.bin";
};

struct OracleStats {
  std::uint64_t generated = 0;
  std::uint64_t deduplicated = 0;
  std::size_t peak_candidates = 0;
};

struct OraclePlan {
  int column = -1;
  OracleStats stats{};
};

struct BeamNode {
  State state{};
  int first_column = -1;
  std::string dynamic_key;
  double rank = -std::numeric_limits<double>::infinity();
};

struct Corpus {
  std::vector<Example> examples;
  std::uint64_t oracle_generated = 0;
  std::uint64_t oracle_deduplicated = 0;
  std::int64_t rollin_score = 0;
  int rollin_moves = 0;
  int games = 0;
  int censored = 0;
};

struct GameOutcome {
  std::int64_t score = 0;
  int moves = 0;
  bool censored = false;
  int switches = 0;
};

struct Summary {
  double baseline_mean_score = 0;
  double baseline_mean_moves = 0;
  double mean_score = 0;
  double mean_moves = 0;
  double score_delta = 0;
  double move_delta = 0;
  double score_lower95 = 0;
  double move_lower95 = 0;
  double switches_per_move = 0;
  int censored = 0;
};

State publicState(const State& source) {
  State result;
  result.board = source.board;
  result.next_disc = source.next_disc;
  result.moves_remaining = source.moves_remaining;
  // Score, level, move count, terminal metadata, seed, and all future random
  // values are deliberately absent from the student's effective input.
  result.score = 0;
  result.level = 1;
  result.moves_played = 0;
  result.game_over = false;
  return result;
}

void validateTrainingRange(std::uint32_t start, int games) {
  if (games < 1) throw std::invalid_argument("game count must be positive");
  const std::uint64_t end =
      static_cast<std::uint64_t>(start) + static_cast<std::uint64_t>(games);
  if (start < kTrainingPartitionStart || end > kTrainingPartitionEnd) {
    throw std::invalid_argument("seed range leaves the 0x3d training family");
  }
}

double originalCombinedUtility(const State& state) {
  if (state.game_over) return -250'000.0;
  const cfpi::detail::PhaseFeatures f =
      cfpi::detail::extractPhaseFeatures(state);
  // Implements the reference TypeScript `combined` leaf without phase-safety
  // additions.  The feature extractor uses one downward exposure edge.
  return 180.0 * f.open_columns - 10.0 * f.height_load -
         620.0 * f.solid_cells - 220.0 * f.cracked_cells -
         18.0 * f.numbered_cells - 90.0 * f.high_low_numbers +
         140.0 * f.direct_potential + 360.0 * f.latent_chain_potential +
         100.0 * f.cracked_exposure + 40.0 * f.solid_exposure -
         550.0 * f.adjacent_ones - 750.0 * f.triple_twos -
         120.0 * f.dead_low_numbers;
}

double rankState(const State& state) {
  return static_cast<double>(state.score) + originalCombinedUtility(state) +
         (state.game_over ? kTerminalPenalty : 0.0);
}

std::string dynamicKey(const State& state) {
  std::string key = serializeBoard(state.board);
  key.push_back('|');
  key += std::to_string(state.next_disc);
  key.push_back('|');
  key += std::to_string(state.level);
  key.push_back('|');
  key += std::to_string(state.moves_remaining);
  key.push_back('|');
  key += std::to_string(state.moves_played);
  key.push_back('|');
  key.push_back(state.game_over ? '1' : '0');
  return key;
}

bool betterBeamNode(const BeamNode& left, const BeamNode& right) {
  if (left.rank != right.rank) return left.rank > right.rank;
  if (left.state.score != right.state.score) {
    return left.state.score > right.state.score;
  }
  if (left.first_column != right.first_column) {
    return left.first_column < right.first_column;
  }
  return left.dynamic_key < right.dynamic_key;
}

bool dominatesEquivalent(const BeamNode& candidate, const BeamNode& prior) {
  if (candidate.state.score != prior.state.score) {
    return candidate.state.score > prior.state.score;
  }
  return candidate.first_column < prior.first_column;
}

void insertCandidate(std::unordered_map<std::string, BeamNode>& candidates,
                     BeamNode candidate, OracleStats& stats) {
  const auto found = candidates.find(candidate.dynamic_key);
  if (found == candidates.end()) {
    candidates.emplace(candidate.dynamic_key, std::move(candidate));
    return;
  }
  ++stats.deduplicated;
  if (dominatesEquivalent(candidate, found->second)) {
    found->second = std::move(candidate);
  }
}

OraclePlan planOracleMove(const State& root, std::uint32_t game_seed,
                          int depth = kOracleDepth,
                          int beam_width = kOracleBeam) {
  if (root.game_over) return {};
  if (depth < 1 || depth > 12 || beam_width < 1 || beam_width > 2'048) {
    throw std::invalid_argument("oracle work bounds are invalid");
  }
  BeamNode initial{root, -1, dynamicKey(root), rankState(root)};
  std::vector<BeamNode> beam{std::move(initial)};
  OracleStats stats;
  for (int ply = 0; ply < depth; ++ply) {
    std::unordered_map<std::string, BeamNode> candidates;
    candidates.reserve(static_cast<std::size_t>(beam_width * kBoardSize));
    for (const BeamNode& node : beam) {
      if (node.state.game_over) {
        insertCandidate(candidates, node, stats);
        continue;
      }
      int legal_count = 0;
      const auto legal = legalColumns(node.state.board, legal_count);
      for (int offset = 0; offset < legal_count; ++offset) {
        const int column = legal[offset];
        State next = node.state;
        MoveResult move;
        if (!playHeadlessMove(next, game_seed, column, move)) continue;
        ++stats.generated;
        BeamNode candidate;
        candidate.state = std::move(next);
        candidate.first_column =
            node.first_column < 0 ? column : node.first_column;
        candidate.dynamic_key = dynamicKey(candidate.state);
        insertCandidate(candidates, std::move(candidate), stats);
      }
    }
    if (candidates.empty()) break;
    stats.peak_candidates =
        std::max(stats.peak_candidates, candidates.size());
    std::vector<BeamNode> ranked;
    ranked.reserve(candidates.size());
    for (auto& entry : candidates) {
      entry.second.rank = rankState(entry.second.state);
      ranked.push_back(std::move(entry.second));
    }
    std::sort(ranked.begin(), ranked.end(), betterBeamNode);
    if (static_cast<int>(ranked.size()) > beam_width) {
      ranked.resize(static_cast<std::size_t>(beam_width));
    }
    beam = std::move(ranked);
  }
  std::sort(beam.begin(), beam.end(), betterBeamNode);
  for (const BeamNode& node : beam) {
    if (node.first_column >= 0) return {node.first_column, stats};
  }
  return {-1, stats};
}

Example oracleExample(const State& source, int teacher_column) {
  const phase_student::CanonicalState canonical =
      phase_student::canonicalize(publicState(source));
  const int label = canonical.mirrored
                        ? kBoardSize - 1 - teacher_column
                        : teacher_column;
  if (!isLegal(canonical.state.board, label)) {
    throw std::runtime_error("canonical oracle label is illegal");
  }
  std::array<float, kBoardSize> targets{};
  int legal_count = 0;
  legalColumns(canonical.state.board, legal_count);
  const float background = (1.0f - kTeacherMass) / legal_count;
  for (int column = 0; column < kBoardSize; ++column) {
    if (isLegal(canonical.state.board, column)) targets[column] = background;
  }
  targets[label] += kTeacherMass;
  return {canonical.state, targets, label, kOracleDepth};
}

cfpi::BehaviorOptions behaviorOptions() {
  cfpi::BehaviorOptions options;
  options.max_depth = 3;
  options.chance_samples = 5;
  options.max_work = 1'000'000;
  options.max_cache_entries = 40'000;
  options.terminal_utility = -1'000'000.0;
  return options;
}

Corpus collectCorpus(std::uint32_t seed_start, int games, int maximum_moves,
                     RollIn roll_in, std::string_view label) {
  validateTrainingRange(seed_start, games);
  Corpus result;
  result.games = games;
  result.examples.reserve(static_cast<std::size_t>(games * maximum_moves));
  const cfpi::BehaviorOptions behavior = behaviorOptions();
  for (int game = 0; game < games; ++game) {
    const std::uint32_t seed = seed_start + static_cast<std::uint32_t>(game);
    State state = initialHeadlessState(seed);
    const std::size_t before = result.examples.size();
    while (!state.game_over && state.moves_played < maximum_moves) {
      const OraclePlan oracle = planOracleMove(state, seed);
      if (oracle.column < 0 || !isLegal(state.board, oracle.column)) {
        throw std::runtime_error("privileged oracle returned no legal move");
      }
      result.oracle_generated += oracle.stats.generated;
      result.oracle_deduplicated += oracle.stats.deduplicated;
      result.examples.push_back(oracleExample(state, oracle.column));
      const int action = roll_in == RollIn::kOracle
                             ? oracle.column
                             : cfpi::chooseBehaviorAction(state, behavior);
      MoveResult move;
      if (!playHeadlessMove(state, seed, action, move)) {
        throw std::runtime_error("roll-in transition rejected a legal action");
      }
    }
    result.rollin_score += state.score;
    result.rollin_moves += state.moves_played;
    result.censored += !state.game_over;
    std::cerr << label << ' ' << (game + 1) << '/' << games << " seed 0x"
              << std::hex << seed << std::dec << " labels "
              << result.examples.size() - before << " score " << state.score
              << " moves " << state.moves_played << '\n';
  }
  return result;
}

void appendCorpus(Corpus& target, Corpus source) {
  target.oracle_generated += source.oracle_generated;
  target.oracle_deduplicated += source.oracle_deduplicated;
  target.rollin_score += source.rollin_score;
  target.rollin_moves += source.rollin_moves;
  target.games += source.games;
  target.censored += source.censored;
  target.examples.insert(target.examples.end(),
                         std::make_move_iterator(source.examples.begin()),
                         std::make_move_iterator(source.examples.end()));
}

int chooseStudentAction(const Network& network, const State& state) {
  return network.chooseAction(publicState(state));
}

struct HybridDecision {
  int action = -1;
  int behavior_action = -1;
  bool switched = false;
};

HybridDecision chooseHybridAction(const Network& network, const State& source,
                                  const cfpi::BehaviorOptions& behavior) {
  const phase_student::TeacherLabel exact =
      phase_student::queryTeacher(publicState(source), behavior);
  const bool mirrored = phase_student::mirrorIsSmaller(source.board);
  const int behavior_actual = mirrored
                                  ? kBoardSize - 1 - exact.canonical_action
                                  : exact.canonical_action;
  const State observable = publicState(source);
  const phase_student::CanonicalState canonical =
      phase_student::canonicalize(observable);
  const auto logits = network.logitsCanonical(canonical.state);
  const auto probabilities =
      phase_student::legalProbabilities(canonical.state, logits);
  int student = -1;
  float best_probability = -1.0f;
  for (const int column : kCenterFirst) {
    if (!isLegal(canonical.state.board, column)) continue;
    if (probabilities[column] > best_probability) {
      best_probability = probabilities[column];
      student = column;
    }
  }
  int chosen = exact.canonical_action;
  if (student >= 0 && student != exact.canonical_action &&
      std::isfinite(exact.values[student]) &&
      std::isfinite(exact.values[exact.canonical_action])) {
    double minimum = std::numeric_limits<double>::infinity();
    double maximum = -std::numeric_limits<double>::infinity();
    for (int column = 0; column < kBoardSize; ++column) {
      if (!isLegal(canonical.state.board, column) ||
          !std::isfinite(exact.values[column])) {
        continue;
      }
      minimum = std::min(minimum, exact.values[column]);
      maximum = std::max(maximum, exact.values[column]);
    }
    const double tolerance = std::max(
        kMinimumExactQTolerance,
        kMaximumExactQRangeLoss * std::max(0.0, maximum - minimum));
    const float probability_advantage =
        probabilities[student] - probabilities[exact.canonical_action];
    if (probabilities[student] >= kMinimumStudentProbability &&
        probability_advantage >= kMinimumProbabilityAdvantage &&
        exact.values[student] >=
            exact.values[exact.canonical_action] - tolerance) {
      chosen = student;
    }
  }
  const int actual = mirrored ? kBoardSize - 1 - chosen : chosen;
  return {actual, behavior_actual, actual != behavior_actual};
}

GameOutcome playPolicyGame(std::uint32_t seed, int maximum_moves,
                           const Network* network, bool hybrid) {
  const cfpi::BehaviorOptions behavior = behaviorOptions();
  State state = initialHeadlessState(seed);
  int switches = 0;
  while (!state.game_over && state.moves_played < maximum_moves) {
    int action = -1;
    if (network == nullptr) {
      action = cfpi::chooseBehaviorAction(state, behavior);
    } else if (hybrid) {
      const HybridDecision decision =
          chooseHybridAction(*network, state, behavior);
      action = decision.action;
      switches += decision.switched;
    } else {
      action = chooseStudentAction(*network, state);
    }
    if (!isLegal(state.board, action)) {
      throw std::runtime_error("evaluated policy selected an illegal action");
    }
    MoveResult move;
    if (!playHeadlessMove(state, seed, action, move)) {
      throw std::runtime_error("evaluated policy transition failed");
    }
  }
  return {state.score, state.moves_played, !state.game_over, switches};
}

double lower95(const std::vector<double>& values) {
  if (values.empty()) return 0;
  const double mean =
      std::accumulate(values.begin(), values.end(), 0.0) / values.size();
  if (values.size() < 2) return mean;
  double squared = 0;
  for (const double value : values) squared += (value - mean) * (value - mean);
  const double deviation =
      std::sqrt(squared / static_cast<double>(values.size() - 1));
  return mean - 1.96 * deviation / std::sqrt(static_cast<double>(values.size()));
}

Summary comparePolicies(const Network& network, std::uint32_t seed_start,
                        int games, int maximum_moves,
                        std::string_view label) {
  validateTrainingRange(seed_start, games);
  Summary summary;
  std::vector<double> score_deltas;
  std::vector<double> move_deltas;
  int total_switches = 0;
  int candidate_moves = 0;
  for (int game = 0; game < games; ++game) {
    const std::uint32_t seed = seed_start + static_cast<std::uint32_t>(game);
    const GameOutcome baseline =
        playPolicyGame(seed, maximum_moves, nullptr, false);
    const GameOutcome candidate =
        playPolicyGame(seed, maximum_moves, &network, true);
    summary.baseline_mean_score +=
        static_cast<double>(baseline.score) / games;
    summary.baseline_mean_moves +=
        static_cast<double>(baseline.moves) / games;
    summary.mean_score += static_cast<double>(candidate.score) / games;
    summary.mean_moves += static_cast<double>(candidate.moves) / games;
    score_deltas.push_back(static_cast<double>(candidate.score - baseline.score));
    move_deltas.push_back(static_cast<double>(candidate.moves - baseline.moves));
    total_switches += candidate.switches;
    candidate_moves += candidate.moves;
    summary.censored += candidate.censored;
    std::cerr << label << ' ' << (game + 1) << '/' << games << " seed 0x"
              << std::hex << seed << std::dec << " baseline "
              << baseline.score << '/' << baseline.moves << " hybrid "
              << candidate.score << '/' << candidate.moves << " switches "
              << candidate.switches << '\n';
  }
  summary.score_delta =
      std::accumulate(score_deltas.begin(), score_deltas.end(), 0.0) / games;
  summary.move_delta =
      std::accumulate(move_deltas.begin(), move_deltas.end(), 0.0) / games;
  summary.score_lower95 = lower95(score_deltas);
  summary.move_lower95 = lower95(move_deltas);
  summary.switches_per_move =
      candidate_moves > 0 ? static_cast<double>(total_switches) / candidate_moves
                          : 0.0;
  return summary;
}

int positiveInteger(const char* value, std::string_view flag) {
  std::size_t consumed = 0;
  const long long parsed = std::stoll(value, &consumed, 0);
  if (consumed != std::string(value).size() || parsed < 1 ||
      parsed > std::numeric_limits<int>::max()) {
    throw std::invalid_argument(std::string(flag) + " must be positive");
  }
  return static_cast<int>(parsed);
}

float positiveFloat(const char* value, std::string_view flag) {
  std::size_t consumed = 0;
  const float parsed = std::stof(value, &consumed);
  if (consumed != std::string(value).size() || !(parsed > 0) ||
      !std::isfinite(parsed)) {
    throw std::invalid_argument(std::string(flag) + " must be positive");
  }
  return parsed;
}

Config parseConfig(int argc, char** argv) {
  Config config;
  for (int index = 2; index < argc; ++index) {
    const std::string flag = argv[index];
    if (index + 1 >= argc) throw std::invalid_argument("missing value for " + flag);
    const char* value = argv[++index];
    if (flag == "--oracle-train-games") {
      config.oracle_train_games = positiveInteger(value, flag);
    } else if (flag == "--behavior-train-games") {
      config.behavior_train_games = positiveInteger(value, flag);
    } else if (flag == "--oracle-holdout-games") {
      config.oracle_holdout_games = positiveInteger(value, flag);
    } else if (flag == "--behavior-holdout-games") {
      config.behavior_holdout_games = positiveInteger(value, flag);
    } else if (flag == "--label-moves") {
      config.label_moves = positiveInteger(value, flag);
    } else if (flag == "--epochs") {
      config.epochs = positiveInteger(value, flag);
    } else if (flag == "--batch-size") {
      config.batch_size = positiveInteger(value, flag);
    } else if (flag == "--learning-rate") {
      config.learning_rate = positiveFloat(value, flag);
    } else if (flag == "--screen-games") {
      config.screen_games = positiveInteger(value, flag);
    } else if (flag == "--confirmation-games") {
      config.confirmation_games = positiveInteger(value, flag);
    } else if (flag == "--evaluation-moves") {
      config.evaluation_moves = positiveInteger(value, flag);
    } else if (flag == "--output") {
      config.output = value;
    } else if (flag == "--model") {
      config.model = value;
    } else {
      throw std::invalid_argument("unknown argument " + flag);
    }
  }
  return config;
}

void writeSummary(std::ostream& output, std::string_view name,
                  const Summary& summary) {
  output << '"' << name << "\":{\"baselineMeanScore\":"
         << summary.baseline_mean_score << ",\"baselineMeanMoves\":"
         << summary.baseline_mean_moves << ",\"meanScore\":"
         << summary.mean_score
         << ",\"meanMoves\":" << summary.mean_moves
         << ",\"pairedScoreDelta\":" << summary.score_delta
         << ",\"pairedMoveDelta\":" << summary.move_delta
         << ",\"scoreDeltaLower95\":" << summary.score_lower95
         << ",\"moveDeltaLower95\":" << summary.move_lower95
         << ",\"switchesPerMove\":" << summary.switches_per_move
         << ",\"censored\":" << summary.censored << '}';
}

void writeArtifact(const Config& config, const Corpus& training,
                   const Corpus& oracle_holdout,
                   const Corpus& behavior_holdout,
                   const PolicyMetrics& all_metrics,
                   const PolicyMetrics& oracle_metrics,
                   const PolicyMetrics& behavior_metrics,
                   bool label_gate, const Summary& screen,
                   bool screen_gate, const Summary& confirmation,
                   bool confirmation_ran, bool confirmation_gate,
                   double seconds) {
  std::ofstream output(config.output);
  if (!output) throw std::runtime_error("could not open result artifact");
  output << std::setprecision(10)
         << "{\n  \"format\":\"drop7-oracle-distill-v1\",\n"
         << "  \"teacher\":\"privileged-future-depth4-beam128\",\n"
         << "  \"studentInput\":[\"board\",\"nextDisc\",\"movesRemaining\"],\n"
         << "  \"forbiddenStudentInput\":[\"gameSeed\",\"futureDiscs\","
            "\"revealRng\",\"score\",\"level\",\"movesPlayed\"],\n"
         << "  \"reflection\":\"lexicographic-canonicalization\",\n"
         << "  \"network\":\"sparse-614x128x128x7\",\n"
         << "  \"parameterBytes\":384540,\n"
         << "  \"seeds\":{\"oracleTrain\":\"0x3d7a0000\","
            "\"behaviorTrain\":\"0x3d7b0000\","
            "\"oracleHoldout\":\"0x3d7c0000\","
            "\"behaviorHoldout\":\"0x3d7d0000\","
            "\"screen\":\"0x3d7e0000\","
            "\"confirmation\":\"0x3d7f0000\"},\n"
         << "  \"trainingExamples\":" << training.examples.size()
         << ",\"oracleHoldoutExamples\":" << oracle_holdout.examples.size()
         << ",\"behaviorHoldoutExamples\":"
         << behavior_holdout.examples.size() << ",\n"
         << "  \"heldout\":{\"all\":{\"top1\":" << all_metrics.top1
         << ",\"top2\":" << all_metrics.top2 << ",\"crossEntropy\":"
         << all_metrics.loss << "},\"oracleRollin\":{\"top1\":"
         << oracle_metrics.top1 << ",\"top2\":" << oracle_metrics.top2
         << ",\"crossEntropy\":" << oracle_metrics.loss
         << "},\"behaviorRollin\":{\"top1\":" << behavior_metrics.top1
         << ",\"top2\":" << behavior_metrics.top2
         << ",\"crossEntropy\":" << behavior_metrics.loss << "}},\n"
         << "  \"labelGates\":{\"top1\":0.30,\"top2\":0.55,"
            "\"crossEntropyMaximum\":1.75,\"passed\":"
         << (label_gate ? "true" : "false") << "},\n"
         << "  \"deploymentRule\":{\"studentProbability\":0.40,"
            "\"probabilityAdvantage\":0.12,\"exactQRangeLoss\":0.10,"
            "\"minimumExactQTolerance\":5000},\n  ";
  writeSummary(output, "screen", screen);
  output << ",\n  \"screenPassedBothMeans\":"
         << (screen_gate ? "true" : "false")
         << ",\n  \"confirmationRan\":"
         << (confirmation_ran ? "true" : "false")
         << ",\n  \"confirmationPassedBothMeans\":"
         << (confirmation_gate ? "true" : "false") << ",\n  ";
  writeSummary(output, "confirmation", confirmation);
  output << ",\n  \"decision\":\""
         << (label_gate && screen_gate && confirmation_gate ? "accept"
                                                             : "reject")
         << "\",\n"
         << "  \"protectedSeedsRead\":false,\n"
         << "  \"model\":\"" << config.model << "\",\n"
         << "  \"elapsedSeconds\":" << seconds << "\n}\n";
}

int run(const Config& config, std::ostream& output) {
  validateTrainingRange(kOracleTrainStart, config.oracle_train_games);
  validateTrainingRange(kBehaviorTrainStart, config.behavior_train_games);
  validateTrainingRange(kOracleHoldoutStart, config.oracle_holdout_games);
  validateTrainingRange(kBehaviorHoldoutStart, config.behavior_holdout_games);
  validateTrainingRange(kScreenStart, config.screen_games);
  validateTrainingRange(kConfirmationStart, config.confirmation_games);
  const auto started = Clock::now();

  Corpus training = collectCorpus(kOracleTrainStart,
                                  config.oracle_train_games,
                                  config.label_moves, RollIn::kOracle,
                                  "oracle-train");
  appendCorpus(training, collectCorpus(kBehaviorTrainStart,
                                       config.behavior_train_games,
                                       config.label_moves, RollIn::kBehavior,
                                       "behavior-train"));
  const Corpus oracle_holdout = collectCorpus(
      kOracleHoldoutStart, config.oracle_holdout_games, config.label_moves,
      RollIn::kOracle, "oracle-holdout");
  const Corpus behavior_holdout = collectCorpus(
      kBehaviorHoldoutStart, config.behavior_holdout_games,
      config.label_moves, RollIn::kBehavior, "behavior-holdout");

  Network network(0x3d7a'4e4eu);
  phase_student::train(network, training.examples, config.epochs,
                       config.batch_size, config.learning_rate,
                       "oracle-distill");
  std::vector<Example> all_holdout = oracle_holdout.examples;
  all_holdout.insert(all_holdout.end(), behavior_holdout.examples.begin(),
                     behavior_holdout.examples.end());
  const PolicyMetrics all_metrics =
      phase_student::evaluatePolicy(network, all_holdout);
  const PolicyMetrics oracle_metrics =
      phase_student::evaluatePolicy(network, oracle_holdout.examples);
  const PolicyMetrics behavior_metrics =
      phase_student::evaluatePolicy(network, behavior_holdout.examples);
  const bool label_gate = all_metrics.top1 >= kMinimumHoldoutTop1 &&
                          all_metrics.top2 >= kMinimumHoldoutTop2 &&
                          all_metrics.loss <= kMaximumHoldoutCrossEntropy;

  // This first policy comparison is always run.  The model and override rule
  // are locked before reading these whole-game evaluation seeds.
  const Summary screen = comparePolicies(network, kScreenStart,
                                         config.screen_games,
                                         config.evaluation_moves, "screen");
  const bool screen_gate = screen.score_delta > 0.0 && screen.move_delta > 0.0;
  Summary confirmation;
  bool confirmation_ran = false;
  if (screen_gate) {
    confirmation_ran = true;
    confirmation = comparePolicies(network, kConfirmationStart,
                                   config.confirmation_games,
                                   config.evaluation_moves, "confirmation");
  }
  const bool confirmation_gate =
      confirmation_ran && confirmation.score_delta > 0.0 &&
      confirmation.move_delta > 0.0;
  network.save(config.model);
  const double seconds =
      std::chrono::duration<double>(Clock::now() - started).count();
  writeArtifact(config, training, oracle_holdout, behavior_holdout,
                all_metrics, oracle_metrics, behavior_metrics, label_gate,
                screen, screen_gate, confirmation, confirmation_ran,
                confirmation_gate, seconds);
  output << std::fixed << std::setprecision(6)
         << "ORACLE_DISTILL_RESULT {\"trainingSeedOnly\":true"
         << ",\"trainingExamples\":" << training.examples.size()
         << ",\"heldoutExamples\":" << all_holdout.size()
         << ",\"heldoutTop1\":" << all_metrics.top1
         << ",\"heldoutTop2\":" << all_metrics.top2
         << ",\"heldoutCrossEntropy\":" << all_metrics.loss
         << ",\"labelGate\":" << (label_gate ? "true" : "false")
         << ",\"screenScoreDelta\":" << screen.score_delta
         << ",\"screenMoveDelta\":" << screen.move_delta
         << ",\"screenPassed\":" << (screen_gate ? "true" : "false")
         << ",\"confirmationRan\":"
         << (confirmation_ran ? "true" : "false")
         << ",\"confirmationPassed\":"
         << (confirmation_gate ? "true" : "false")
         << ",\"decision\":\""
         << (label_gate && screen_gate && confirmation_gate ? "accept"
                                                             : "reject")
         << "\",\"artifact\":\"" << config.output << "\"}\n";
  return 0;
}

bool selfTest(std::ostream& output) {
  Network network(0x3d7a'4e4eu);
  State state = initialHeadlessState(0x3d70'0000u);
  state.board[indexOf(5, 0)] = 3;
  state.board[indexOf(5, 1)] = 5;
  state.board[indexOf(4, 1)] = 2;
  state.board[indexOf(5, 4)] = 4;
  state.next_disc = 6;
  state.moves_remaining = 3;
  const int action = chooseStudentAction(network, state);
  State reflected = state;
  reflected.board = phase_student::mirrorBoard(state.board);
  const int reflected_action = chooseStudentAction(network, reflected);
  const bool reflection_safe =
      reflected_action == kBoardSize - 1 - action;
  State forbidden_changed = state;
  forbidden_changed.score = 9'999'999;
  forbidden_changed.level = 77;
  forbidden_changed.moves_played = 381;
  forbidden_changed.game_over = true;
  const bool public_isolation =
      chooseStudentAction(network, forbidden_changed) == action;
  const bool legal = isLegal(state.board, action);
  const OraclePlan oracle_first =
      planOracleMove(initialHeadlessState(0x3d70'0000u), 0x3d70'0000u,
                     kOracleDepth, kOracleBeam);
  const OraclePlan oracle_second =
      planOracleMove(initialHeadlessState(0x3d70'0000u), 0x3d70'0000u,
                     kOracleDepth, kOracleBeam);
  const bool oracle_deterministic =
      oracle_first.column == oracle_second.column &&
      oracle_first.stats.generated == oracle_second.stats.generated;
  // The reference TypeScript oracle selects column zero for this fixture.  The
  // native leaf differs only in its downward-exposure edge handling.
  const bool oracle_reference = oracle_first.column == 0;
  const bool oracle_legal =
      isLegal(initialBoard(), oracle_first.column);
  const bool seed_guard = [&] {
    try {
      validateTrainingRange(0x4d70'0000u, 1);
      return false;
    } catch (const std::invalid_argument&) {
      return true;
    }
  }();
  const bool passed = reflection_safe && public_isolation && legal &&
                      oracle_deterministic && oracle_reference &&
                      oracle_legal && seed_guard;
  output << "ORACLE_DISTILL_SELF_TEST {\"passed\":"
         << (passed ? "true" : "false")
         << ",\"publicStateIsolation\":"
         << (public_isolation ? "true" : "false")
         << ",\"reflectionSafe\":"
         << (reflection_safe ? "true" : "false")
         << ",\"legalMask\":" << (legal ? "true" : "false")
         << ",\"oracleDeterministic\":"
         << (oracle_deterministic ? "true" : "false")
         << ",\"oracleReferenceAction\":" << oracle_first.column
         << ",\"oracleGenerated\":" << oracle_first.stats.generated
         << ",\"protectedSeedGuard\":"
         << (seed_guard ? "true" : "false") << "}\n";
  return passed;
}

}  // namespace drop7::oracle_distill

int main(int argc, char** argv) {
  try {
    if (argc == 2 && std::string(argv[1]) == "--self-test") {
      return drop7::oracle_distill::selfTest(std::cout) ? 0 : 1;
    }
    if (argc >= 2 && std::string(argv[1]) == "--run") {
      return drop7::oracle_distill::run(
          drop7::oracle_distill::parseConfig(argc, argv), std::cout);
    }
    std::cerr
        << "usage: drop7_oracle_distill --self-test | --run "
           "[--oracle-train-games N] [--behavior-train-games N] "
           "[--oracle-holdout-games N] [--behavior-holdout-games N] "
           "[--label-moves N] [--epochs N] [--batch-size N] "
           "[--learning-rate X] [--screen-games N] "
           "[--confirmation-games N] [--evaluation-moves N] "
           "[--output PATH] [--model PATH]\n";
    return 2;
  } catch (const std::exception& error) {
    std::cerr << "drop7_oracle_distill: " << error.what() << '\n';
    return 1;
  }
}