diff --git a/CMakeLists.txt b/CMakeLists.txt index 8372b9f..fc1c337 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ set_property(GLOBAL PROPERTY USE_FOLDERS ON) option(IRONPHOENIX_BUILD_TESTS "Build IronPhoenix correctness tests" ON) option(IRONPHOENIX_BUILD_BENCHMARK "Build IronPhoenix microbenchmark" ON) option(IRONPHOENIX_BUILD_DATASET_GENERATOR "Build PhoenixNet dataset generator" ON) +option(IRONPHOENIX_NATIVE_CPU "Optimize Release/RelWithDebInfo for the build host CPU; nonportable binaries" OFF) add_library(ironphoenix_core STATIC src/geometry.cpp @@ -24,6 +25,7 @@ add_library(ironphoenix_core STATIC src/tt.cpp src/eval.cpp src/bishop_eval.cpp + src/king_eval.cpp src/nnue.cpp src/tuning.cpp src/search.cpp @@ -34,10 +36,12 @@ add_library(ironphoenix_core STATIC src/uci.cpp include/ironphoenix/eval.hpp include/ironphoenix/bishop_eval.hpp + include/ironphoenix/king_eval.hpp include/ironphoenix/search_stack.hpp include/ironphoenix/lmr.hpp include/ironphoenix/nnue.hpp include/ironphoenix/tuning.hpp + include/ironphoenix/king_legality.hpp ) add_library(IronPhoenix::Core ALIAS ironphoenix_core) @@ -50,6 +54,24 @@ target_include_directories(ironphoenix_core target_compile_features(ironphoenix_core PUBLIC cxx_std_20) target_link_libraries(ironphoenix_core PUBLIC Threads::Threads) +# Only enable for a binary that will run on the same CPU generation used to +# build it. Keep CI/distribution generic so older systems are not exposed to +# unsupported instructions. PUBLIC propagates ISA flags to tests/apps because +# Bitboard intrinsics are also header-inlined. +if(IRONPHOENIX_NATIVE_CPU) + if(MSVC) + target_compile_options(ironphoenix_core PUBLIC + $<$:/arch:AVX2> + $<$:/arch:AVX2> + ) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(ironphoenix_core PUBLIC + $<$:-march=native> + $<$:-march=native> + ) + endif() +endif() + if(MSVC) target_compile_options(ironphoenix_core PRIVATE /W4 @@ -217,6 +239,36 @@ if(IRONPHOENIX_BUILD_TESTS) target_compile_features(ironphoenix_knight_outpost PRIVATE cxx_std_20) add_test(NAME ironphoenix_knight_outpost COMMAND ironphoenix_knight_outpost) + add_executable(ironphoenix_check_order tests/check_order.cpp) + target_link_libraries(ironphoenix_check_order PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_check_order PRIVATE cxx_std_20) + add_test(NAME ironphoenix_check_order COMMAND ironphoenix_check_order) + set_target_properties(ironphoenix_check_order PROPERTIES FOLDER "Tests") + + add_executable(ironphoenix_pawn_cache tests/pawn_cache.cpp) + target_link_libraries(ironphoenix_pawn_cache PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_pawn_cache PRIVATE cxx_std_20) + add_test(NAME ironphoenix_pawn_cache COMMAND ironphoenix_pawn_cache) + set_target_properties(ironphoenix_pawn_cache PROPERTIES FOLDER "Tests") + + add_executable(ironphoenix_king_legality tests/king_legality.cpp) + target_link_libraries(ironphoenix_king_legality PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_king_legality PRIVATE cxx_std_20) + add_test(NAME ironphoenix_king_legality COMMAND ironphoenix_king_legality) + set_target_properties(ironphoenix_king_legality PROPERTIES FOLDER "Tests") + + add_executable(ironphoenix_eval_cache tests/eval_cache.cpp) + target_link_libraries(ironphoenix_eval_cache PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_eval_cache PRIVATE cxx_std_20) + add_test(NAME ironphoenix_eval_cache COMMAND ironphoenix_eval_cache) + set_target_properties(ironphoenix_eval_cache PROPERTIES FOLDER "Tests") + + add_executable(ironphoenix_king_features tests/king_features.cpp) + target_link_libraries(ironphoenix_king_features PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_king_features PRIVATE cxx_std_20) + add_test(NAME ironphoenix_king_features COMMAND ironphoenix_king_features) + set_target_properties(ironphoenix_king_features PROPERTIES FOLDER "Tests") + add_executable(ironphoenix_bishop_features tests/bishop_features.cpp) target_link_libraries(ironphoenix_bishop_features PRIVATE IronPhoenix::Core) target_compile_features(ironphoenix_bishop_features PRIVATE cxx_std_20) diff --git a/docs/king-evaluation.md b/docs/king-evaluation.md new file mode 100644 index 0000000..afd3cfc --- /dev/null +++ b/docs/king-evaluation.md @@ -0,0 +1,62 @@ +# 4PC Teams King Evaluation + +This branch adds original HCE heuristics inspired by Chess Programming Wiki +[King Safety], [King Centralization], [Mate at a Glance], [Pin], [X-ray] +and [Castling Rights], adapted to Red+Yellow vs Blue+Green and the 14x14 board. + +These are evaluation weights, not illegal move pruning or forced mate detection. + +## Features +- **Middlegame shield**: three oriented forward files/ranks from each king, allowing nearby allied pawn shelter and penalizing gaps. +- **Attacked king zone**: reused team attack maps from the existing mobility stage, covering both enemy colors. +- **Enemy pawn storm**: nearby enemy pawn advances toward a home king. +- **Mate at a glance**: a *king-danger proxy* for an already attacked king with two or fewer unattacked empty adjacent escape squares. This is not a legal mate detector and **never outputs a terminal mate score**. +- **Endgame centralization**: tapered central proximity reward based on surviving enemy non-pawn material. +- **Pawnless wing**: endgame penalty for a wing king when neither teammate has pawns on that wing, with seat-orientated transverse axes. +- **Absolute pins**: a friendly piece shielding a king from an enemy bishop/rook/queen on a real uninterrupted 4PC ray. +- **X-ray pressure**: an allied piece shielding a queen from an enemy slider. No invalid cross-corner rays. +- **Castling rights**: middlegame bonus only when a configured lane has the correct live right bit and original king/rook in place. Does not assume that immediate castling is currently legal. + +The middlegame fraction is based on remaining **enemy** non-pawn material; +the endgame fraction is complementary. No change to the actual rules, moves, +mate scores, TT, or search. NNUE continues to bypass HCE as before. + +## New UCI parameters + +| Option | Default | +|---|---:| +| EvalKingShieldPenalty | 10 | +| EvalKingZoneAttackPenalty | 6 | +| EvalKingPawnStormPenalty | 5 | +| EvalKingMateThreatPenalty | 30 | +| EvalKingCentralizationBonus | 4 | +| EvalKingPawnlessWingPenalty | 14 | +| EvalKingPinPenalty | 14 | +| EvalKingXrayPenalty | 8 | +| EvalKingCastlingRightsBonus | 8 | + +Each can be set to 0 to disable a feature and tuned separately through +Tunex. These defaults are *experimental* and require independent SPRT. +All previously tuned Search/Eval parameters remain intact. + +HCE king terms are deliberately computed after the shared mobility/attack +stage. Reuse of existing attack maps limits extra full-board scans, but rays, +phase calculations and pawn scans cost NPS. Profile on real 4PC positions. + +## Validation + +The king-feature regression test covers: all nine parameter boundaries and +UCI declarations; material-phase king shield and pawn storm; threat proxy and +zone attacks; endgame centralization and wing pawns; pins and queen X-rays; +castling right preconditions; and team-relative sign reversal. + +Run CMake/CTest on Linux and Windows before testing Elo. +Compare both **NPS and paired WDL/SPRT** against \`main\`, ideally varying +one new king term at a time. + +[King Safety]: https://chessprogramming.org/King_Safety +[King Centralization]: https://chessprogramming.org/King_Centralization +[Mate at a Glance]: https://chessprogramming.org/Mate_at_a_Glance +[Pin]: https://chessprogramming.org/Pin +[X-ray]: https://chessprogramming.org/X-ray +[Castling Rights]: https://chessprogramming.org/Castling_Rights diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..df5654b --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,115 @@ +# IronPhoenix performance experiments + +Branch: \`feature/king-evaluation\`. Keep \`main\` stable until Elo tests. + +## Changes + +- Precomputed immutable, seat-aware king shield/storm/wing masks. +- Hoisted four-army material phase, slider presence, team pawns and occupancy + out of the per-king evaluation path. +- Reuse the existing mobility slider-attack maps to avoid pin/X-ray probes + if no opposing slider attacks an allied screening piece. +- Fast (exact) geometric prefilter for PV quiet move check ordering. Discovered + checks and checks that already exist are handled by the full evaluator. +- UCI \`SearchFastCheckPrefilter\` defaults to 1; use 0 for A/B comparison. + A broader version in non-PV pruning was tested and **reverted** because it + showed no measurable benefit on the same runner. +- Benchmark \`ironphoenix_nps_bench\` now measures feature groups in addition + to its original fixed-position search throughput. + +## Latest exact evaluation speedups + +- `SearchPawnAttackCache=1` (default): Each worker caches the union + of each color's pawn attack bitboards by **all three pawn-bitboard words**. + Pawn structures often repeat even when non-pawn pieces change. Values + remain exact; the thread-local cache cannot race between SMP workers. +- `SearchKingRayPrefilter=1` (default): Precomputed geometric rank/file/ + diagonal rays reject king and queen pin/X-ray probes when no opposing + slider is even aligned with the target. All four-color evaluation + scores are preserved. +- Quiescence **lazy tactical picking was tested then reverted**, because + it measured 512,945 NPS versus 516,048 NPS with ordinary full sorting. + +Diagnostic same-runner Linux CI comparisons (individual experiments, +not additive or directly predictive of your CPU): + +| Optimization | Disabled | Enabled | Difference | +|---|---:|---:|---:| +| Pawn attack cache | 510,898 | 524,508 | +2.7% | +| King ray prefilter | 509,880 | 537,731 | +5.5% | + +Both switches accept `0` to disable and `1` to enable in UCI. These +are HCE optimizations; loaded NNUE bypasses handcrafted evaluation. + +### Additional build experiment: whole-program optimization + +CMake supports interprocedural optimization through its built-in option. +On MSVC, test a separate Release build with + +```powershell +cmake -S . -B build-ipo -DIRONPHOENIX_NATIVE_CPU=ON -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON +cmake --build build-ipo --config Release --parallel +``` + +This may improve inlining across translation units, but has **not** been +benchmarked or validated for this repository's toolchain. Compare to +the native Release build with IPO disabled on the same machine. + +## Native CPU Release build (opt-in) + +CMake option: \`IRONPHOENIX_NATIVE_CPU=ON\` (default OFF). + +**Windows (PowerShell / Visual Studio generator):** + +\`\`\`powershell +cmake -S . -B build-native -DIRONPHOENIX_NATIVE_CPU=ON +cmake --build build-native --config Release --parallel +.\build-native\Release\ironphoenix_nps_bench.exe +\`\`\` + +**Linux (GCC/Clang):** + +\`\`\`bash +cmake -S . -B build-native -DCMAKE_BUILD_TYPE=Release -DIRONPHOENIX_NATIVE_CPU=ON +cmake --build build-native --parallel +./build-native/ironphoenix_nps_bench +\`\`\` + +GCC/Clang use \`-march=native\`; MSVC uses \`/arch:AVX2\` for Release +and RelWithDebInfo. The MSVC option is a broad, predictable instruction-set +target rather than necessarily the exact highest available CPU features. + +**Do not redistribute a native build to arbitrary computers**: it may use +instructions unsupported by another CPU. Keep portable builds with the +option OFF. + +## How to judge speed + +1. Run portable and native binaries on the **same computer** with the + same node limit and fixture. +2. Repeat each benchmark (e.g., five times), compare median elapsed time + and NPS rather than a single run. +3. Run both with \`Threads=1\` when comparing per-core changes; then compare + total throughput with \`Threads=4\`, \`8\`, etc. +4. Judge search strength separately via paired SPRT. Higher NPS alone is + not evidence that a pruning change increases Elo. + +CI cloud runners have different CPU allocations, so absolute NPS between +different workflow runs is not a valid speedup measurement. The benchmark's +same-runner feature variants provide better relative evidence. + +Recent **single Linux CI runner** experimental comparisons (not user-machine +NPS and not proven Elo gains): + +| Switch | Disabled | Enabled | Speedup | +|---|---:|---:|---:| +| `SearchEvalCache` | 598,037 | 612,344 | about 2.4% | +| `SearchFastKingLegality` | 856,053 | 861,818 | about 0.7% | + +Benchmark runs use the same fixed 4PC fixture, node budget and worker count, +but the repeated variant searches occur sequentially, so CPU variability and +hash/iteration rounding still affect short measurements. Run longer tests +locally, repeat trials, and rely on medians. + +This feature branch retains all new king-evaluation terms. NNUE still +bypasses HCE; the HCE speed work helps only when NNUE is disabled. diff --git a/include/ironphoenix/king_eval.hpp b/include/ironphoenix/king_eval.hpp new file mode 100644 index 0000000..a938b6a --- /dev/null +++ b/include/ironphoenix/king_eval.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "bitboard.hpp" + +namespace ironphoenix { +class Position; +namespace Eval { + // Red+Yellow advantage minus Blue+Green advantage. + // Supplied attack maps are collected by the existing mobility evaluator. + [[nodiscard]] int kingFeatureDifference(const Position& pos, + const Bitboard (&teamAttacks)[2], + const Bitboard (&sliderAttacks)[2]) noexcept; +} +} diff --git a/include/ironphoenix/king_legality.hpp b/include/ironphoenix/king_legality.hpp new file mode 100644 index 0000000..12f1384 --- /dev/null +++ b/include/ironphoenix/king_legality.hpp @@ -0,0 +1,27 @@ +#pragma once +#include "position.hpp" + +namespace ironphoenix { + +// Only slider attacks can become newly exposed when an ordinary move vacates +// one source square. If that source is not on any rank/file/diagonal passing +// through our king, then an already-safe king cannot become checked. +// Castling/EP remove other pieces and king moves relocate the target, so they +// always require full validation. This returns true conservatively. +[[nodiscard]] IRONPHOENIX_FORCE_INLINE bool kingCheckCanChange( + const Position& pos, Move move) noexcept { + if (move.isEnPassant() || move.isCastle()) + return true; + const Piece moving = pos.pieceAt(move.from()); + if (moving == NO_PIECE || typeOf(moving) == KING) + return true; + const Square king = pos.kingSquare(pos.sideToMove()); + if (king == SQ_NONE) + return true; + for (unsigned family = 0; family < Geometry::LINE_FAMILY_NB; ++family) + if (Geometry::LineId[family][king] == Geometry::LineId[family][move.from()]) + return true; + return false; +} + +} // namespace ironphoenix diff --git a/include/ironphoenix/search.hpp b/include/ironphoenix/search.hpp index c638319..4a837f4 100644 --- a/include/ironphoenix/search.hpp +++ b/include/ironphoenix/search.hpp @@ -73,6 +73,8 @@ namespace ironphoenix { // Exposed only to the mate-stop regression test. friend struct MateStopTestAccess; friend struct ReverseFutilityTestAccess; + friend struct CheckOrderTestAccess; + friend struct EvalCacheTestAccess; [[nodiscard]] bool reverseFutilitySafe(Position& pos) const; struct TimeBudget final { @@ -97,6 +99,21 @@ namespace ironphoenix { bool helperMode_ = false; int helperIndex_ = 0; int threads_ = 1; + + // Search-local, worker-private HCE cache. Lazily invalidated by epoch + // at every start, so changed tuning/network settings never leave stale + // evaluations. Avoid contention between Lazy SMP workers. + static constexpr std::size_t EVAL_CACHE_CAPACITY = 1u << 14; + struct EvalCacheEntry { + Key key = 0; + int score = 0; + std::uint32_t epoch = 0; + }; + mutable std::array evalCache_{}; + mutable std::uint64_t evalCacheHits_ = 0; + std::uint32_t evalCacheEpoch_ = 1; + bool evalCacheActive_ = false; + HistoryTables history_{}; std::thread worker_; std::atomic stopRequested_{ false }; diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 2c14704..fdb9a19 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -31,6 +31,19 @@ namespace ironphoenix { int rfpMarginPerDepth = 141; int rfpMinMargin = 93; int rfpMaxImprovingDiscount = 82; + // 1 = cheaply reject geometrically impossible quiet checks. + // 0 = original full givesCheck() for A/B benchmarking. + int fastCheckPrefilter = 1; + // Exact full-evaluation cache, private to each search worker. + // Set 0 to disable for NPS/SPRT comparisons. + int evalCacheEnabled = 1; + // Geometrically skip redundant inCheck(mover) after safe moves. + // 0 forces original full move-by-move legality verification. + int fastKingLegality = 1; + // Exact union of pawn attack maps, cached per worker. + int pawnAttackCache = 1; + // Skip impossible slider rays in HCE pins/X-ray scoring. + int kingRayPrefilter = 1; }; struct EvalParameters final { @@ -55,6 +68,15 @@ namespace ironphoenix { int bishopColorWeaknessPenalty = 3; int bishopFianchettoBonus = 5; int bishopReturningBonus = 22; + int kingShieldPenalty = 10; + int kingZoneAttackPenalty = 6; + int kingPawnStormPenalty = 5; + int kingMateThreatPenalty = 30; + int kingCentralizationBonus = 4; + int kingPawnlessWingPenalty = 14; + int kingPinPenalty = 14; + int kingXrayPenalty = 8; + int kingCastlingRightsBonus = 8; }; [[nodiscard]] SearchParameters& search() noexcept; diff --git a/src/eval.cpp b/src/eval.cpp index 2f3ad84..17040dd 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -1,5 +1,6 @@ #include "ironphoenix/eval.hpp" #include "ironphoenix/bishop_eval.hpp" +#include "ironphoenix/king_eval.hpp" #include "ironphoenix/geometry.hpp" #include "ironphoenix/nnue.hpp" @@ -7,6 +8,8 @@ #include "ironphoenix/tuning.hpp" #include +#include +#include #include namespace ironphoenix::Eval { @@ -114,12 +117,58 @@ namespace ironphoenix::Eval { && static_cast(Geometry::KnightEnemyProximity[sq] & enemyMaterial); } + // Pawn attacks are determined solely by pawn positions and seat. + // Cache the union, not evaluation scores. Pawn structures change + // much less frequently than the rest of the 4PC position. + // thread_local keeps this lock-free under Lazy SMP. + IRONPHOENIX_FORCE_INLINE Bitboard pawnAttackUnion( + Color c, Bitboard pawns) noexcept { + constexpr std::size_t SLOTS = 256; + struct Entry { + Bitboard key{}; + Bitboard attacks{}; + bool valid = false; + }; + static thread_local std::array, COLOR_NB> cache{}; + + auto compute = [&]() noexcept { + Bitboard attacks{}; + Bitboard remaining = pawns; + while (remaining) + attacks |= Geometry::PawnAttacks[c][popLsb(remaining)]; + return attacks; + }; + + if (!Tuning::search().pawnAttackCache) + return compute(); + + // Mix all three 64-bit words so promoted or remote pawns + // cannot alias simply due to their placement on the 14x14 board. + std::uint64_t hash = pawns.w0 ^ (pawns.w1 * 0x9E3779B97F4A7C15ULL) + ^ (pawns.w2 * 0xBF58476D1CE4E5B9ULL); + hash ^= hash >> 30; + hash *= 0xBF58476D1CE4E5B9ULL; + hash ^= hash >> 27; + auto& slot = cache[static_cast(c)] + [static_cast(hash) & (SLOTS - 1)]; + if (slot.valid && slot.key == pawns) + return slot.attacks; + + slot.key = pawns; + slot.attacks = compute(); + slot.valid = true; + return slot.attacks; + } + int mobilityForColor(const Position& pos, Color c, Bitboard& teamAttacks, unsigned& teamOutposts, - Bitboard* teamBishopAttacks) noexcept { + Bitboard* teamBishopAttacks, + Bitboard& teamSliderAttacks) noexcept { const Bitboard blockedByTeam = teamOccupancy(pos, c); const auto& tune = Tuning::eval(); - const bool scoreThreats = tune.attackingPiecesWeight != 0; + const bool scoreThreats = tune.attackingPiecesWeight != 0 + || tune.kingZoneAttackPenalty != 0 + || tune.kingMateThreatPenalty != 0; const bool scoreBishopWeakness = tune.bishopColorWeaknessPenalty != 0 && pos.isAlive(c); @@ -164,6 +213,8 @@ namespace ironphoenix::Eval { ++teamOutposts; if (scoreThreats) teamAttacks |= attacks; + if (pt != KNIGHT) + teamSliderAttacks |= attacks; } }; @@ -173,11 +224,8 @@ namespace ironphoenix::Eval { scorePieces(QUEEN); // Pawns' capture directions are threats, not mobility. - if (scoreThreats) { - Bitboard pawns = pos.pieces(c, PAWN); - while (pawns) - teamAttacks |= Geometry::PawnAttacks[c][popLsb(pawns)]; - } + if (scoreThreats) + teamAttacks |= pawnAttackUnion(c, pos.pieces(c, PAWN)); return score; } @@ -335,6 +383,7 @@ namespace ironphoenix::Eval { int handcraftedEvaluate(const Position& pos) noexcept { int team0 = 0, team1 = 0; Bitboard attacks[2]{}; + Bitboard sliderAttacks[2]{}; Bitboard bishopAttacks[2][2]{}; unsigned knightOutposts[2]{}; @@ -342,7 +391,8 @@ namespace ironphoenix::Eval { const Color c = static_cast(ci); const int score = materialForColor(pos, c) + mobilityForColor(pos, c, attacks[teamOf(c)], - knightOutposts[teamOf(c)], bishopAttacks[teamOf(c)]) + knightOutposts[teamOf(c)], bishopAttacks[teamOf(c)], + sliderAttacks[teamOf(c)]) + knightDevelopmentForColor(pos, c); if (teamOf(c) == 0) team0 += score; @@ -362,7 +412,9 @@ namespace ironphoenix::Eval { // Team-wise complementary bishop coverage and four seat-oriented // pawn/king structures. All new feature weights default to zero. - const int score = team0 - team1 + bishopFeatureDifference(pos, bishopAttacks); + const int score = team0 - team1 + + bishopFeatureDifference(pos, bishopAttacks) + + kingFeatureDifference(pos, attacks, sliderAttacks); return teamOf(pos.sideToMove()) == 0 ? score : -score; } diff --git a/src/king_eval.cpp b/src/king_eval.cpp new file mode 100644 index 0000000..2826aaa --- /dev/null +++ b/src/king_eval.cpp @@ -0,0 +1,343 @@ +#include "ironphoenix/king_eval.hpp" + +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include + +namespace ironphoenix::Eval { +namespace { + +constexpr int PHASE_MAX = 24; // Opponent pair: Q=4, R=2, B/N=1 + +int homeDepth(Color c, Square sq) noexcept { + switch (c) { + case RED: return Geometry::rankOf(sq); + case BLUE: return Geometry::fileOf(sq); + case YELLOW: return BOARD_RANKS - 1 - Geometry::rankOf(sq); + case GREEN: return BOARD_FILES - 1 - Geometry::fileOf(sq); + } + return 0; +} + +int lateral(Color c, Square sq) noexcept { + return (c == RED || c == YELLOW) + ? Geometry::fileOf(sq) : Geometry::rankOf(sq); +} + +Square sideSquare(Color c, Square king, int offset) noexcept { + const int f = Geometry::fileOf(king); + const int r = Geometry::rankOf(king); + const int x = f + ((c == RED || c == YELLOW) ? offset : 0); + const int y = r + ((c == BLUE || c == GREEN) ? offset : 0); + return Geometry::validXY(x, y) ? Geometry::square(x, y) : SQ_NONE; +} + +// Geometry depends only on king square and seat. Build the tiny lookup once, +// after Geometry::init(), rather than recomputing it for millions of evals. +// The C++11 function-local static initialization is thread-safe for Lazy SMP. +struct KingMasks { + Bitboard storm{}; + Bitboard rookRays{}; + Bitboard bishopRays{}; + std::array, COLOR_NB> shield{}; + std::array, COLOR_NB> shieldLane{}; + std::array home{}; + std::array lateral{}; + std::uint8_t centralDistance = 0; +}; + +struct Lookup { + std::array kings{}; + std::array, COLOR_NB> wings{}; + + Lookup() noexcept { + for (unsigned i = 0; i < SQUARE_NB; ++i) { + const Square sq = static_cast(i); + const int f = Geometry::fileOf(sq), r = Geometry::rankOf(sq); + if (!Geometry::validXY(f, r)) + continue; + auto& m = kings[i]; + m.rookRays = Geometry::Ray[NORTH][sq] + | Geometry::Ray[SOUTH][sq] + | Geometry::Ray[EAST][sq] + | Geometry::Ray[WEST][sq]; + m.bishopRays = Geometry::Ray[NORTH_EAST][sq] + | Geometry::Ray[NORTH_WEST][sq] + | Geometry::Ray[SOUTH_EAST][sq] + | Geometry::Ray[SOUTH_WEST][sq]; + m.centralDistance = static_cast( + std::min(std::abs(f - 6), std::abs(f - 7)) + + std::min(std::abs(r - 6), std::abs(r - 7))); + + for (int dx = -3; dx <= 3; ++dx) { + for (int dy = -3; dy <= 3; ++dy) { + if (Geometry::validXY(f + dx, r + dy)) + m.storm.set(Geometry::square(f + dx, r + dy)); + } + } + + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const auto c = static_cast(ci); + m.home[ci] = static_cast(homeDepth(c, sq)); + m.lateral[ci] = static_cast(lateral(c, sq)); + if (m.home[ci] > 4) + continue; + for (int lane = -1; lane <= 1; ++lane) { + const Square start = sideSquare(c, sq, lane); + if (start == SQ_NONE) + continue; + m.shieldLane[ci][lane + 1] = true; + const Square first = Geometry::step(start, Geometry::pawnForward(c)); + if (first != SQ_NONE) { + m.shield[ci][lane + 1].set(first); + const Square second = Geometry::step(first, Geometry::pawnForward(c)); + if (second != SQ_NONE) + m.shield[ci][lane + 1].set(second); + } + } + } + } + for (unsigned sq = 0; sq < SQUARE_NB; ++sq) { + const int f = Geometry::fileOf(static_cast(sq)); + const int r = Geometry::rankOf(static_cast(sq)); + if (!Geometry::validXY(f, r)) + continue; + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const int across = lateral(static_cast(ci), static_cast(sq)); + if (across <= 4) + wings[ci][0].set(static_cast(sq)); + if (across >= 9) + wings[ci][1].set(static_cast(sq)); + } + } + } +}; + +const Lookup& geometryMasks() noexcept { + static const Lookup lookup{}; + return lookup; +} + +int castleRights(const Position& pos, Color c) noexcept { + if (pos.castlingRights() == 0) + return 0; + int available = 0; + for (unsigned lane = 0; lane < 2; ++lane) { + const CastleLane& def = pos.castleDefinition(c, lane); + if (!def.valid() || def.rightBit >= 8 + || !(pos.castlingRights() & (1u << def.rightBit))) + continue; + if (pos.kingSquare(c) == def.kingFrom + && pos.pieceAt(def.kingFrom) == makePiece(c, KING) + && pos.pieceAt(def.rookFrom) == makePiece(c, ROOK)) + ++available; + } + return available; +} + +struct TeamData { + Bitboard pawns{}; + Bitboard occupants{}; + Bitboard orthogonalSliders{}; + Bitboard diagonalSliders{}; + int phase = 0; +}; + +struct FastContext { + TeamData teams[2]{}; + Bitboard occupancy{}; +}; + +FastContext makeContext(const Position& pos) noexcept { + FastContext ctx{}; + ctx.occupancy = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color c = static_cast(ci); + if (!pos.isAlive(c)) + continue; + auto& t = ctx.teams[teamOf(c)]; + const Bitboard queens = pos.pieces(c, QUEEN); + const Bitboard rooks = pos.pieces(c, ROOK); + const Bitboard bishops = pos.pieces(c, BISHOP); + const Bitboard knights = pos.pieces(c, KNIGHT); + t.pawns |= pos.pieces(c, PAWN); + t.occupants |= pos.occupancy(c); + t.orthogonalSliders |= rooks | queens; + t.diagonalSliders |= bishops | queens; + t.phase += 4 * queens.popcount() + 2 * rooks.popcount() + + bishops.popcount() + knights.popcount(); + } + ctx.teams[0].phase = std::min(PHASE_MAX, ctx.teams[0].phase); + ctx.teams[1].phase = std::min(PHASE_MAX, ctx.teams[1].phase); + return ctx; +} + +// A screened friendly piece in a slider attack ray is a true geometric +// pin (to a king) or x-ray (to a queen). Current-occupancy rook/bishop +// lookups identify the FIRST occupied square in all directions at once; +// only candidate friendly screens then need a second ray lookup. +int rayPressure(const Position& pos, Square target, + Bitboard friends, const TeamData& opponents, + Bitboard occupancy, Bitboard sliderAttacks) noexcept { + int count = 0; + const auto& kingMasks = geometryMasks().kings[target]; + // An x-ray/pin on a king or queen requires a hostile slider already + // collinear with it. If none exists, skip occupancy-dependent ray work. + if (opponents.orthogonalSliders + && (!Tuning::search().kingRayPrefilter + || (opponents.orthogonalSliders & kingMasks.rookRays))) { + Bitboard screens = pos.rookAttacks(target) & friends & sliderAttacks; + while (screens) { + const Square screen = popLsb(screens); + if (typeOf(pos.pieceAt(screen)) == KING) + continue; + const int f = Geometry::fileOf(screen) - int(Geometry::fileOf(target)); + const int r = Geometry::rankOf(screen) - int(Geometry::rankOf(target)); + const Direction dir = f ? (f > 0 ? EAST : WEST) + : (r > 0 ? NORTH : SOUTH); + if (Geometry::rayAttack(screen, dir, occupancy) + & opponents.orthogonalSliders) + ++count; + } + } + if (opponents.diagonalSliders + && (!Tuning::search().kingRayPrefilter + || (opponents.diagonalSliders & kingMasks.bishopRays))) { + Bitboard screens = pos.bishopAttacks(target) & friends & sliderAttacks; + while (screens) { + const Square screen = popLsb(screens); + if (typeOf(pos.pieceAt(screen)) == KING) + continue; + const int f = Geometry::fileOf(screen) - int(Geometry::fileOf(target)); + const int r = Geometry::rankOf(screen) - int(Geometry::rankOf(target)); + const Direction dir = f > 0 + ? (r > 0 ? NORTH_EAST : SOUTH_EAST) + : (r > 0 ? NORTH_WEST : SOUTH_WEST); + if (Geometry::rayAttack(screen, dir, occupancy) + & opponents.diagonalSliders) + ++count; + } + } + return std::min(4, count); +} + +int kingScore(const Position& pos, Color c, + const Bitboard (&attacks)[2], const Bitboard (&sliderAttacks)[2], + const FastContext& ctx, + const Lookup& lookup, const Tuning::EvalParameters& w) noexcept { + if (!pos.isAlive(c)) + return 0; + const Square king = pos.kingSquare(c); + if (king == SQ_NONE) + return 0; + + const unsigned ci = static_cast(c); + const int team = teamOf(c); + const TeamData& own = ctx.teams[team]; + const TeamData& foe = ctx.teams[team ^ 1]; + const KingMasks& m = lookup.kings[king]; + const int phase = foe.phase; + const int endPhase = PHASE_MAX - phase; + int score = 0; + + if (phase) { + int danger = 0; + if (m.home[ci] <= 4) { + if (w.kingShieldPenalty) { + int missing = 0; + for (int lane = 0; lane < 3; ++lane) + missing += m.shieldLane[ci][lane] + && !(m.shield[ci][lane] & own.pawns); + danger += w.kingShieldPenalty * missing; + } + if (w.kingPawnStormPenalty) + danger += w.kingPawnStormPenalty + * std::min(4, (m.storm & foe.pawns).popcount()); + } + + const Bitboard hostile = attacks[team ^ 1]; + const Bitboard zone = Geometry::KingAttacks[king]; + if (w.kingZoneAttackPenalty) + danger += w.kingZoneAttackPenalty + * std::min(8, (zone & hostile).popcount()); + + // This is a heuristic danger pattern, not a mate conclusion. + if (w.kingMateThreatPenalty && hostile.test(king)) { + const int escapes = (zone & ~ctx.occupancy & ~hostile).popcount(); + if (escapes <= 2) + danger += w.kingMateThreatPenalty * (3 - escapes); + } + + if (w.kingCastlingRightsBonus && pos.castlingRights()) + score += phase * w.kingCastlingRightsBonus + * castleRights(pos, c) / PHASE_MAX; + + // Every absolute pin needs a friendly screen already attacked + // by an enemy slider. Use existing mobility attack maps to avoid + // unnecessary x-ray geometry in the common case. + if (w.kingPinPenalty && (sliderAttacks[team ^ 1] & own.occupants)) + danger += w.kingPinPenalty + * rayPressure(pos, king, own.occupants, foe, + ctx.occupancy, sliderAttacks[team ^ 1]); + + score -= danger * phase / PHASE_MAX; + } + + if (endPhase) { + if (w.kingCentralizationBonus) + score += w.kingCentralizationBonus + * (12 - int(m.centralDistance)) * endPhase / PHASE_MAX; + + if (w.kingPawnlessWingPenalty) { + const int across = m.lateral[ci]; + if (across <= 4) { + if (!(lookup.wings[ci][0] & own.pawns)) + score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + } else if (across >= 9) { + if (!(lookup.wings[ci][1] & own.pawns)) + score -= w.kingPawnlessWingPenalty * endPhase / PHASE_MAX; + } + } + } + + if (w.kingXrayPenalty && (foe.orthogonalSliders || foe.diagonalSliders) + && (sliderAttacks[team ^ 1] & own.occupants)) { + Bitboard queens = pos.pieces(c, QUEEN); + int rays = 0; + while (queens) + rays += rayPressure(pos, popLsb(queens), own.occupants, + foe, ctx.occupancy, sliderAttacks[team ^ 1]); + score -= w.kingXrayPenalty * std::min(4, rays); + } + return score; +} + +} // namespace + +int kingFeatureDifference(const Position& pos, + const Bitboard (&teamAttacks)[2], + const Bitboard (&sliderAttacks)[2]) noexcept { + const auto& w = Tuning::eval(); + if (!(w.kingShieldPenalty || w.kingPawnStormPenalty + || w.kingZoneAttackPenalty || w.kingMateThreatPenalty + || w.kingCentralizationBonus || w.kingPawnlessWingPenalty + || w.kingPinPenalty || w.kingXrayPenalty + || w.kingCastlingRightsBonus)) + return 0; + + const FastContext ctx = makeContext(pos); + const Lookup& lookup = geometryMasks(); + int teams[2]{}; + for (unsigned i = 0; i < COLOR_NB; ++i) { + const Color c = static_cast(i); + teams[teamOf(c)] += kingScore(pos, c, teamAttacks, sliderAttacks, + ctx, lookup, w); + } + return teams[0] - teams[1]; +} + +} // namespace ironphoenix::Eval diff --git a/src/multipv.cpp b/src/multipv.cpp index f72b89c..789ee89 100644 --- a/src/multipv.cpp +++ b/src/multipv.cpp @@ -37,6 +37,12 @@ void SearchEngine::startMultiPV( multiPV = std::clamp(multiPV, 2, MAX_MULTI_PV); stopAndWait(); + if (++evalCacheEpoch_ == 0) { + evalCacheEpoch_ = 1; + evalCache_.fill(EvalCacheEntry{}); + } + evalCacheHits_ = 0; + evalCacheActive_ = true; stopRequested_.store(false, std::memory_order_relaxed); ponderHitRequested_.store(false, std::memory_order_relaxed); searching_.store(true, std::memory_order_relaxed); diff --git a/src/search.cpp b/src/search.cpp index 640ca22..8a58905 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -2,6 +2,7 @@ #include "ironphoenix/eval.hpp" #include "ironphoenix/lmr.hpp" +#include "ironphoenix/king_legality.hpp" #include "ironphoenix/tuning.hpp" #include "ironphoenix/movegen.hpp" @@ -9,6 +10,7 @@ #include #include +#include #include #include #include @@ -45,6 +47,66 @@ namespace ironphoenix { return static_cast(pieces); } + // Conservative cheap prefilter for expensive quiet-move givesCheck(). + // Only return false if neither enemy king can be checked by the moved + // piece nor unmasked by vacating the source square. Special moves and + // pre-existing checks are handled by the caller with full givesCheck(). + IRONPHOENIX_FORCE_INLINE bool quietMayGiveCheck( + const Position& pos, Move move) noexcept { + const Piece moving = pos.pieceAt(move.from()); + if (!moving) + return true; + const Color us = colorOf(moving); + const PieceType type = typeOf(moving); + const Square from = move.from(), to = move.to(); + const int fromF = Geometry::fileOf(from), fromR = Geometry::rankOf(from); + const int toF = Geometry::fileOf(to), toR = Geometry::rankOf(to); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color opponent = static_cast(ci); + if (!enemy(us, opponent) || !pos.isAlive(opponent)) + continue; + const Square king = pos.kingSquare(opponent); + if (king == SQ_NONE) + continue; + const int kf = Geometry::fileOf(king), kr = Geometry::rankOf(king); + const int fdx = fromF - kf, fdy = fromR - kr; + // Uncovering a friendly slider check requires vacating a + // square collinear with its target king. + if (!fdx || !fdy || std::abs(fdx) == std::abs(fdy)) + return true; + + const int dx = toF - kf, dy = toR - kr; + const bool orthogonal = !dx || !dy; + const bool diagonal = std::abs(dx) == std::abs(dy); + switch (type) { + case PAWN: + if (Geometry::PawnAttacks[us][to].test(king)) + return true; + break; + case KNIGHT: + if (Geometry::KnightAttacks[to].test(king)) + return true; + break; + case BISHOP: + if (diagonal) return true; + break; + case ROOK: + if (orthogonal) return true; + break; + case QUEEN: + if (orthogonal || diagonal) return true; + break; + case KING: + if (Geometry::KingAttacks[to].test(king)) + return true; + break; + default: + return true; + } + } + return false; + } + IRONPHOENIX_FORCE_INLINE int captureOrderingScore(Piece movingPiece, Move move) noexcept { // Full four-player SEE is expensive enough that using it on every // generated capture purely for ordering costs substantial NPS. @@ -90,6 +152,14 @@ namespace ironphoenix { void SearchEngine::start(Position position, SearchLimits limits, std::ostream& out) { stopAndWait(); + // One search always sees one set of tuning weights; do not reuse HCE + // entries across successive searches or engine option changes. + if (++evalCacheEpoch_ == 0) { + evalCacheEpoch_ = 1; + evalCache_.fill(EvalCacheEntry{}); + } + evalCacheHits_ = 0; + evalCacheActive_ = true; stopRequested_.store(false, std::memory_order_relaxed); ponderHitRequested_.store(false, std::memory_order_relaxed); searching_.store(true, std::memory_order_relaxed); @@ -384,7 +454,21 @@ namespace ironphoenix { } int SearchEngine::evaluate(const Position& pos) const noexcept { - return Eval::evaluate(pos); + if (!evalCacheActive_ || !Tuning::search().evalCacheEnabled) + return Eval::evaluate(pos); + + const Key key = pos.key(); + EvalCacheEntry& entry = + evalCache_[static_cast(key) & (EVAL_CACHE_CAPACITY - 1)]; + if (entry.epoch == evalCacheEpoch_ && entry.key == key) { + ++evalCacheHits_; + return entry.score; + } + const int score = Eval::evaluate(pos); + entry.key = key; + entry.score = score; + entry.epoch = evalCacheEpoch_; + return score; } int SearchEngine::historyBonus(int depth) const noexcept { @@ -456,6 +540,8 @@ namespace ironphoenix { std::array scored; const std::size_t count = static_cast(end - begin); + bool existingCheckKnown = false; + bool existingCheck = false; for (std::size_t i = 0; i < count; ++i) { const Move move = begin[i]; const bool terminal = move.isCapture() && isTerminalKingCapture(pos, move); @@ -465,7 +551,36 @@ namespace ironphoenix { // check metadata for pruning safety. const bool needCheck = givesCheckBits != nullptr || ((!move.isCapture() && !move.isPromotion()) && move != ttMove); - const bool givesCheck = !terminal && needCheck && pos.givesCheck(move); + bool givesCheck = false; + if (!terminal && needCheck) { + bool possible = true; + if (Tuning::search().fastCheckPrefilter + && !move.isCapture() && !move.isPromotion() + && !move.isEnPassant() && !move.isCastle() + && !quietMayGiveCheck(pos, move)) { + // The old givesCheck() also reports checks that were + // already present before a quiet move. Compute this once + // per node so the fast prefilter is semantically exact. + if (!existingCheckKnown) { + existingCheckKnown = true; + const Color mover = pos.sideToMove(); + const Bitboard occ = pos.occupancy(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color foe = static_cast(ci); + if (!enemy(mover, foe) || !pos.isAlive(foe)) + continue; + const Square king = pos.kingSquare(foe); + if (king != SQ_NONE && pos.isAttackedBy(king, mover, occ)) { + existingCheck = true; + break; + } + } + } + possible = existingCheck; + } + if (possible) + givesCheck = pos.givesCheck(move); + } scored[i].score = moveScore(pos, move, ttMove, ply, givesCheck); scored[i].move = move; @@ -654,10 +769,17 @@ namespace ironphoenix { pvLength_[ply + 1] = ply + 1; } else { + // A non-king move from a square outside every king ray + // cannot expose a new enemy attack on an already-safe king. + // King moves, EP, castling, checks and aligned sources keep + // full verification. All subsequent move/search logic is same. + const bool verifyKing = inCheckNode + || !Tuning::search().fastKingLegality + || kingCheckCanChange(pos, move); StateInfo st; pos.makeMove(move, st); - if (pos.inCheck(us)) { + if (verifyKing && pos.inCheck(us)) { legal = false; } else { @@ -990,9 +1112,12 @@ namespace ironphoenix { pvLength_[ply + 1] = ply + 1; } else { + const bool verifyKing = inCheckNode + || !tuning.fastKingLegality + || kingCheckCanChange(pos, move); StateInfo st; pos.makeMove(move, st); - if (pos.inCheck(us)) { + if (verifyKing && pos.inCheck(us)) { legal = false; } else { diff --git a/src/tt.cpp b/src/tt.cpp index 40b7855..7686fc5 100644 --- a/src/tt.cpp +++ b/src/tt.cpp @@ -87,21 +87,28 @@ bool TranspositionTable::probe(Key key, int ply, TTProbe& out) const noexcept { return false; const std::size_t index = static_cast(key) & mask_; - std::unique_lock guard(locks_[index % LOCK_STRIPES], std::defer_lock); - if (concurrent_) guard.lock(); - const Cluster& cluster = table_[index]; - for (const Entry& entry : cluster.entry) { - if (entry.key == key && entry.key != 0) { - out.hit = true; - out.move = Move(entry.move); - out.score = scoreFromTT(entry.score, ply); - out.depth = entry.depth; - out.bound = boundOf(entry); - out.pvNode = pvOf(entry); - return true; + // The common Threads=1 path should not even construct a lock. + // Concurrent Lazy SMP workers lock the same cluster stripe for probes + // and writes, so entries remain coherent across cores. + const auto probeCluster = [&]() noexcept { + const Cluster& cluster = table_[index]; + for (const Entry& entry : cluster.entry) { + if (entry.key == key && entry.key != 0) { + out.hit = true; + out.move = Move(entry.move); + out.score = scoreFromTT(entry.score, ply); + out.depth = entry.depth; + out.bound = boundOf(entry); + out.pvNode = pvOf(entry); + return true; + } } - } - return false; + return false; + }; + if (!concurrent_) + return probeCluster(); + std::lock_guard lock(locks_[index % LOCK_STRIPES]); + return probeCluster(); } void TranspositionTable::store(Key key, @@ -115,42 +122,48 @@ void TranspositionTable::store(Key key, return; const std::size_t index = static_cast(key) & mask_; - std::unique_lock guard(locks_[index % LOCK_STRIPES], std::defer_lock); - if (concurrent_) guard.lock(); - Cluster& cluster = table_[index]; - Entry* replace = &cluster.entry[0]; - - for (Entry& entry : cluster.entry) { - if (entry.key == key) { - replace = &entry; - break; - } - if (entry.key == 0) { - replace = &entry; - break; + const auto storeCluster = [&]() noexcept { + Cluster& cluster = table_[index]; + Entry* replace = &cluster.entry[0]; + const auto replacementScore = [&](const Entry& entry) noexcept { + const unsigned age = (generation_ - generationOf(entry)) + & (GENERATION_MOD - 1u); + return static_cast(entry.depth) - static_cast(age) * 4 + + (pvOf(entry) ? 2 : 0); + }; + int replaceValue = replacementScore(*replace); + for (Entry& entry : cluster.entry) { + if (entry.key == key || entry.key == 0) { + replace = &entry; + break; + } + const int value = replacementScore(entry); + if (value < replaceValue) { + replaceValue = value; + replace = &entry; + } } - const unsigned age = (generation_ - generationOf(entry)) & (GENERATION_MOD - 1u); - const int value = static_cast(entry.depth) - static_cast(age) * 4 + (pvOf(entry) ? 2 : 0); - - const unsigned replaceAge = (generation_ - generationOf(*replace)) & (GENERATION_MOD - 1u); - const int replaceValue = static_cast(replace->depth) - static_cast(replaceAge) * 4 - + (pvOf(*replace) ? 2 : 0); - if (value < replaceValue) - replace = &entry; + Move move = bestMove; + if (!move && replace->key == key) + move = Move(replace->move); + + const int ttScore = scoreToTT(score, ply); + replace->key = key; + replace->move = move.v; + replace->score = static_cast(std::clamp(ttScore, -32767, 32767)); + replace->depth = static_cast(std::clamp(depth, -1, 127)); + replace->flags = static_cast( + static_cast(bound) + | (pvNode ? PV_MASK : 0u) + | ((generation_ & (GENERATION_MOD - 1u)) << GENERATION_SHIFT)); + }; + if (!concurrent_) { + storeCluster(); + return; } - - if (!bestMove && replace->key == key) - bestMove = Move(replace->move); - - const int ttScore = scoreToTT(score, ply); - replace->key = key; - replace->move = bestMove.v; - replace->score = static_cast(std::clamp(ttScore, -32767, 32767)); - replace->depth = static_cast(std::clamp(depth, -1, 127)); - replace->flags = static_cast(static_cast(bound) - | (pvNode ? PV_MASK : 0u) - | ((generation_ & (GENERATION_MOD - 1u)) << GENERATION_SHIFT)); + std::lock_guard lock(locks_[index % LOCK_STRIPES]); + storeCluster(); } int TranspositionTable::hashfull() const noexcept { diff --git a/src/tuning.cpp b/src/tuning.cpp index 1bd6b06..aa616e2 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -55,7 +55,12 @@ namespace ironphoenix::Tuning { || name == "SearchRFPMaxDepth" || name == "SearchRFPMarginPerDepth" || name == "SearchRFPMinMargin" - || name == "SearchRFPMaxImprovingDiscount"; + || name == "SearchRFPMaxImprovingDiscount" + || name == "SearchFastCheckPrefilter" + || name == "SearchEvalCache" + || name == "SearchFastKingLegality" + || name == "SearchPawnAttackCache" + || name == "SearchKingRayPrefilter"; } bool isEvalParameter(std::string_view name) noexcept { @@ -79,7 +84,16 @@ namespace ironphoenix::Tuning { || name == "EvalBishopVsKnightBonus" || name == "EvalBishopColorWeaknessPenalty" || name == "EvalBishopFianchettoBonus" - || name == "EvalBishopReturningBonus"; + || name == "EvalBishopReturningBonus" + || name == "EvalKingShieldPenalty" + || name == "EvalKingZoneAttackPenalty" + || name == "EvalKingPawnStormPenalty" + || name == "EvalKingMateThreatPenalty" + || name == "EvalKingCentralizationBonus" + || name == "EvalKingPawnlessWingPenalty" + || name == "EvalKingPinPenalty" + || name == "EvalKingXrayPenalty" + || name == "EvalKingCastlingRightsBonus"; } bool isLmrParameter(std::string_view name) noexcept { @@ -164,6 +178,16 @@ namespace ironphoenix::Tuning { p.rfpMinMargin = value; else if (name == "SearchRFPMaxImprovingDiscount" && inRange(value, 0, 5000)) p.rfpMaxImprovingDiscount = value; + else if (name == "SearchFastCheckPrefilter" && inRange(value, 0, 1)) + p.fastCheckPrefilter = value; + else if (name == "SearchEvalCache" && inRange(value, 0, 1)) + p.evalCacheEnabled = value; + else if (name == "SearchFastKingLegality" && inRange(value, 0, 1)) + p.fastKingLegality = value; + else if (name == "SearchPawnAttackCache" && inRange(value, 0, 1)) + p.pawnAttackCache = value; + else if (name == "SearchKingRayPrefilter" && inRange(value, 0, 1)) + p.kingRayPrefilter = value; else changed = false; @@ -218,6 +242,24 @@ namespace ironphoenix::Tuning { p.bishopFianchettoBonus = value; else if (name == "EvalBishopReturningBonus" && inRange(value, 0, 100)) p.bishopReturningBonus = value; + else if (name == "EvalKingShieldPenalty" && inRange(value, 0, 200)) + p.kingShieldPenalty = value; + else if (name == "EvalKingZoneAttackPenalty" && inRange(value, 0, 200)) + p.kingZoneAttackPenalty = value; + else if (name == "EvalKingPawnStormPenalty" && inRange(value, 0, 200)) + p.kingPawnStormPenalty = value; + else if (name == "EvalKingMateThreatPenalty" && inRange(value, 0, 1000)) + p.kingMateThreatPenalty = value; + else if (name == "EvalKingCentralizationBonus" && inRange(value, 0, 200)) + p.kingCentralizationBonus = value; + else if (name == "EvalKingPawnlessWingPenalty" && inRange(value, 0, 200)) + p.kingPawnlessWingPenalty = value; + else if (name == "EvalKingPinPenalty" && inRange(value, 0, 200)) + p.kingPinPenalty = value; + else if (name == "EvalKingXrayPenalty" && inRange(value, 0, 200)) + p.kingXrayPenalty = value; + else if (name == "EvalKingCastlingRightsBonus" && inRange(value, 0, 200)) + p.kingCastlingRightsBonus = value; else return false; @@ -293,6 +335,11 @@ namespace ironphoenix::Tuning { << "option name SearchRFPMarginPerDepth type spin default " << searchDefaults.rfpMarginPerDepth << " min 0 max 5000\n" << "option name SearchRFPMinMargin type spin default " << searchDefaults.rfpMinMargin << " min 0 max 5000\n" << "option name SearchRFPMaxImprovingDiscount type spin default " << searchDefaults.rfpMaxImprovingDiscount << " min 0 max 5000\n" + << "option name SearchFastCheckPrefilter type spin default " << searchDefaults.fastCheckPrefilter << " min 0 max 1\n" + << "option name SearchEvalCache type spin default " << searchDefaults.evalCacheEnabled << " min 0 max 1\n" + << "option name SearchFastKingLegality type spin default " << searchDefaults.fastKingLegality << " min 0 max 1\n" + << "option name SearchPawnAttackCache type spin default " << searchDefaults.pawnAttackCache << " min 0 max 1\n" + << "option name SearchKingRayPrefilter type spin default " << searchDefaults.kingRayPrefilter << " min 0 max 1\n" << "option name EvalPawnValue type spin default " << defaults.pawnValue << " min 0 max 5000\n" << "option name EvalKnightValue type spin default " << defaults.knightValue << " min 0 max 5000\n" << "option name EvalBishopValue type spin default " << defaults.bishopValue << " min 0 max 5000\n" @@ -314,6 +361,15 @@ namespace ironphoenix::Tuning { << "option name EvalBishopColorWeaknessPenalty type spin default " << defaults.bishopColorWeaknessPenalty << " min 0 max 100\n" << "option name EvalBishopFianchettoBonus type spin default " << defaults.bishopFianchettoBonus << " min 0 max 100\n" << "option name EvalBishopReturningBonus type spin default " << defaults.bishopReturningBonus << " min 0 max 100\n" + << "option name EvalKingShieldPenalty type spin default " << defaults.kingShieldPenalty << " min 0 max 200\n" + << "option name EvalKingZoneAttackPenalty type spin default " << defaults.kingZoneAttackPenalty << " min 0 max 200\n" + << "option name EvalKingPawnStormPenalty type spin default " << defaults.kingPawnStormPenalty << " min 0 max 200\n" + << "option name EvalKingMateThreatPenalty type spin default " << defaults.kingMateThreatPenalty << " min 0 max 1000\n" + << "option name EvalKingCentralizationBonus type spin default " << defaults.kingCentralizationBonus << " min 0 max 200\n" + << "option name EvalKingPawnlessWingPenalty type spin default " << defaults.kingPawnlessWingPenalty << " min 0 max 200\n" + << "option name EvalKingPinPenalty type spin default " << defaults.kingPinPenalty << " min 0 max 200\n" + << "option name EvalKingXrayPenalty type spin default " << defaults.kingXrayPenalty << " min 0 max 200\n" + << "option name EvalKingCastlingRightsBonus type spin default " << defaults.kingCastlingRightsBonus << " min 0 max 200\n" << "option name ResetTuning type button\n"; } @@ -341,7 +397,12 @@ namespace ironphoenix::Tuning { << " SearchRFPMaxDepth=" << s.rfpMaxDepth << " SearchRFPMarginPerDepth=" << s.rfpMarginPerDepth << " SearchRFPMinMargin=" << s.rfpMinMargin - << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount << '\n'; + << " SearchRFPMaxImprovingDiscount=" << s.rfpMaxImprovingDiscount + << " SearchFastCheckPrefilter=" << s.fastCheckPrefilter + << " SearchEvalCache=" << s.evalCacheEnabled + << " SearchFastKingLegality=" << s.fastKingLegality + << " SearchPawnAttackCache=" << s.pawnAttackCache + << " SearchKingRayPrefilter=" << s.kingRayPrefilter << '\n'; out << "info string EvalPawnValue=" << e.pawnValue << " EvalKnightValue=" << e.knightValue @@ -363,7 +424,16 @@ namespace ironphoenix::Tuning { << " EvalBishopVsKnightBonus=" << e.bishopVsKnightBonus << " EvalBishopColorWeaknessPenalty=" << e.bishopColorWeaknessPenalty << " EvalBishopFianchettoBonus=" << e.bishopFianchettoBonus - << " EvalBishopReturningBonus=" << e.bishopReturningBonus << '\n'; + << " EvalBishopReturningBonus=" << e.bishopReturningBonus + << " EvalKingShieldPenalty=" << e.kingShieldPenalty + << " EvalKingZoneAttackPenalty=" << e.kingZoneAttackPenalty + << " EvalKingPawnStormPenalty=" << e.kingPawnStormPenalty + << " EvalKingMateThreatPenalty=" << e.kingMateThreatPenalty + << " EvalKingCentralizationBonus=" << e.kingCentralizationBonus + << " EvalKingPawnlessWingPenalty=" << e.kingPawnlessWingPenalty + << " EvalKingPinPenalty=" << e.kingPinPenalty + << " EvalKingXrayPenalty=" << e.kingXrayPenalty + << " EvalKingCastlingRightsBonus=" << e.kingCastlingRightsBonus << '\n'; } } // namespace ironphoenix::Tuning diff --git a/tests/check_order.cpp b/tests/check_order.cpp new file mode 100644 index 0000000..a465088 --- /dev/null +++ b/tests/check_order.cpp @@ -0,0 +1,168 @@ +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace ironphoenix { +struct CheckOrderTestAccess { + static void order(SearchEngine& s, const Position& pos, MoveList& moves, + std::array& scores, + std::array& checks) { + s.orderMoves(pos, moves.begin(), moves.end(), Move{}, 1, + scores.data(), checks.data()); + } +}; +} + +namespace { +int failures=0; +void require(bool ok, const char* reason) { + if (!ok) { ++failures; std::cerr << "FAIL: " << reason << '\n'; } +} +Square sq(char f, int r) { return Geometry::square(f-'a', r-1); } + +Position setup() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED, KING)); + p.placePiece(sq('a',7), makePiece(BLUE, KING)); + p.placePiece(sq('g',14), makePiece(YELLOW, KING)); + p.placePiece(sq('n',7), makePiece(GREEN, KING)); + p.placePiece(sq('e',5), makePiece(RED, BISHOP)); + p.placePiece(sq('h',6), makePiece(RED, ROOK)); + p.placePiece(sq('h',4), makePiece(RED, KNIGHT)); + p.placePiece(sq('i',11), makePiece(YELLOW, QUEEN)); + p.placePiece(sq('f',10), makePiece(YELLOW, BISHOP)); + p.placePiece(sq('d',8), makePiece(BLUE, ROOK)); + p.placePiece(sq('c',6), makePiece(BLUE, KNIGHT)); + p.placePiece(sq('k',9), makePiece(GREEN, QUEEN)); + p.placePiece(sq('j',8), makePiece(GREEN, BISHOP)); + p.placePiece(sq('g',3), makePiece(RED, PAWN)); + p.placePiece(sq('g',12), makePiece(YELLOW, PAWN)); + p.placePiece(sq('c',7), makePiece(BLUE, PAWN)); + p.placePiece(sq('l',7), makePiece(GREEN, PAWN)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} + +void compare(const Position& p, SearchEngine& engine) { + MoveList original; + generatePseudoLegalMoves(p, original); + if (!original.size) + return; + MoveList slow=original; + MoveList fast=original; + std::array scoresSlow{},scoresFast{}; + std::array checkSlow{},checkFast{}; + + Tuning::search().fastCheckPrefilter=0; + CheckOrderTestAccess::order(engine,p,slow,scoresSlow,checkSlow); + Tuning::search().fastCheckPrefilter=1; + CheckOrderTestAccess::order(engine,p,fast,scoresFast,checkFast); + + require(fast.size == slow.size, "move count unchanged"); + for (std::size_t i=0;i runFixedDepth(int fast) { + Tuning::search().fastCheckPrefilter=fast; + auto engine=std::make_unique(); + SearchLimits limits{}; + limits.depth=3; + std::ostringstream out; + engine->start(setup(), limits, out); + const auto start=std::chrono::steady_clock::now(); + while (engine->searching() && + std::chrono::steady_clock::now()-start < std::chrono::seconds(12)) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + require(!engine->searching(), "depth-3 search completed"); + engine->stopAndWait(); + const std::string result=out.str(); + const std::size_t best=result.rfind("bestmove "); + require(best!=std::string::npos, "bestmove emitted"); + const std::string finalMove=best==std::string::npos ? "" : + result.substr(best, result.find('\n',best)-best); + + // Ignore elapsed time/NPS, which are nondeterministic across searches. + const std::size_t info=result.rfind("info depth 3 "); + require(info!=std::string::npos,"third-depth info emitted"); + const std::size_t score=info==std::string::npos ? std::string::npos : + result.find(" score ",info); + const std::size_t nodes=score==std::string::npos ? score : + result.find(" nodes ",score); + const std::string finalScore=(score==std::string::npos || nodes==std::string::npos) + ? "" : result.substr(score,nodes-score); + return {finalMove,finalScore}; +} + +void test() { + const auto defaults=Tuning::search(); + require(defaults.fastCheckPrefilter==1, "fast check default enabled"); + require(Tuning::setSearchParameter("SearchFastCheckPrefilter",0), "can disable fast check"); + require(Tuning::setSearchParameter("SearchFastCheckPrefilter",1), "can enable fast check"); + require(!Tuning::setSearchParameter("SearchFastCheckPrefilter",2), "invalid fast check rejected"); + auto engine=std::make_unique(); + Position p=setup(); + for (unsigned ci=0;ci(ci)); + p.finalizeSetup(); + compare(p,*engine); + } + + // Already existing rook check must not be falsely filtered out. + p=setup(); + p.placePiece(sq('c',9), makePiece(RED, ROOK)); + p.setSideToMove(RED); + p.finalizeSetup(); + compare(p,*engine); + + // Compare multiple legal 4-player successor states. + p=setup(); + for (int ply=0;ply<18;++ply) { + compare(p,*engine); + MoveList legal; + generateLegalMoves(p, legal); + Move selected{}; + for(std::size_t k=0;k(ply)*7u)%legal.size]; + if (!isTerminalKingCapture(p,m)) { selected=m; break; } + } + if (!selected) break; + StateInfo state{}; + p.makeMove(selected,state); + require(p.verify(), "position valid after selected legal move"); + } + const auto originalSearch=runFixedDepth(0); + const auto optimizedSearch=runFixedDepth(1); + require(originalSearch==optimizedSearch, + "fast prefilter preserves depth-3 bestmove and score"); + Tuning::search()=defaults; +} +} +int main() { + Geometry::init(); + test(); + if (!failures) std::cout<<"Check ordering fast prefilter: PASS\n"; + return failures?1:0; +} diff --git a/tests/eval_cache.cpp b/tests/eval_cache.cpp new file mode 100644 index 0000000..643561a --- /dev/null +++ b/tests/eval_cache.cpp @@ -0,0 +1,109 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include +#include + +using namespace ironphoenix; +namespace ironphoenix { +struct EvalCacheTestAccess { + static int eval(SearchEngine& engine, const Position& p) { + return engine.evaluate(p); + } + static std::uint64_t hits(const SearchEngine& engine) { + return engine.evalCacheHits_; + } + static void newSearch(SearchEngine& engine) { + ++engine.evalCacheEpoch_; + engine.evalCacheActive_ = true; + engine.evalCacheHits_ = 0; + } +}; +} +namespace { +int failures=0; +void expect(bool okay, const char* label) { + if (!okay) { ++failures; std::cerr << "FAIL: " << label << '\n'; } +} +Square sq(char f, int r) { return Geometry::square(f-'a',r-1); } +Position setup() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED,KING)); + p.placePiece(sq('a',7), makePiece(BLUE,KING)); + p.placePiece(sq('g',14), makePiece(YELLOW,KING)); + p.placePiece(sq('n',7), makePiece(GREEN,KING)); + p.placePiece(sq('e',5), makePiece(RED,KNIGHT)); + p.placePiece(sq('d',8), makePiece(BLUE,BISHOP)); + p.placePiece(sq('f',10), makePiece(YELLOW,ROOK)); + p.placePiece(sq('j',8), makePiece(GREEN,QUEEN)); + p.placePiece(sq('f',3), makePiece(RED,PAWN)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} +void test() { + expect(!NNUE::loaded(), "test exact HCE scores"); + Tuning::resetSearch(); + Tuning::resetEval(); + auto engine=std::make_unique(); + auto p=setup(); + const int baseline=Eval::evaluate(p); + + expect(Tuning::search().evalCacheEnabled==1, "default enabled"); + expect(Tuning::setSearchParameter("SearchEvalCache",0),"UCI accepts disable"); + expect(Tuning::setSearchParameter("SearchEvalCache",1),"UCI accepts enable"); + expect(!Tuning::setSearchParameter("SearchEvalCache",2),"UCI rejects invalid option"); + std::ostringstream options; + Tuning::printUciOptions(options); + expect(options.str().find("option name SearchEvalCache type spin default 1 min 0 max 1") + != std::string::npos, "UCI option advertised"); + + // Per-worker entries start inactive; the first call cannot hit stale data. + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"inactive cache exact"); + expect(EvalCacheTestAccess::hits(*engine)==0, "inactive cache no hits"); + EvalCacheTestAccess::newSearch(*engine); + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"first lookup full eval"); + expect(EvalCacheTestAccess::hits(*engine)==0, "first lookup misses"); + expect(EvalCacheTestAccess::eval(*engine,p)==baseline,"second lookup exact cache"); + expect(EvalCacheTestAccess::hits(*engine)==1, "repeated key hits"); + + p.setSideToMove(BLUE); + p.finalizeSetup(); + expect(EvalCacheTestAccess::eval(*engine,p)==Eval::evaluate(p), + "side-to-move key changes cached score"); + + p.setSideToMove(RED); + p.finalizeSetup(); + const auto original = Tuning::eval(); + Tuning::eval().knightMobility += 15; + const int changed = Eval::evaluate(p); + expect(changed != baseline, "changing eval weight affects fixture score"); + // Options are only modified after stop; the next search bumps epoch. + EvalCacheTestAccess::newSearch(*engine); + expect(EvalCacheTestAccess::eval(*engine,p)==changed, + "new search never reuses stale evaluation across tuning changes"); + + Tuning::search().evalCacheEnabled=0; + const int raw=EvalCacheTestAccess::eval(*engine,p); + expect(raw==Eval::evaluate(p),"disabled cache returns exact evaluation"); + expect(EvalCacheTestAccess::hits(*engine)==0, + "disabled cache never returns hits"); + + Tuning::eval()=original; + Tuning::resetSearch(); +} +} +int main() { + Geometry::init(); + test(); + if(!failures) std::cout<<"Exact search-local eval cache tests: PASS\n"; + return failures ? 1 : 0; +} diff --git a/tests/king_features.cpp b/tests/king_features.cpp new file mode 100644 index 0000000..87faf7c --- /dev/null +++ b/tests/king_features.cpp @@ -0,0 +1,234 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include + +using namespace ironphoenix; + +namespace { +int failures = 0; +void expect(bool ok, const char* reason) { + if (!ok) { ++failures; std::cerr << "FAIL: " << reason << '\n'; } +} +Square sq(char file, int rank) { return Geometry::square(file - 'a', rank - 1); } + +Position kings() { + Position p; + p.clear(); + p.placePiece(sq('g', 1), makePiece(RED, KING)); + p.placePiece(sq('a', 7), makePiece(BLUE, KING)); + p.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + p.placePiece(sq('n', 7), makePiece(GREEN, KING)); + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} + +using P = Tuning::EvalParameters; +int contribution(Position p, int P::*field, int amount) { + const P original = Tuning::eval(); + Tuning::eval().*field = 0; + const int without = Eval::evaluate(p); + Tuning::eval().*field = amount; + const int with = Eval::evaluate(p); + Tuning::eval() = original; + return with - without; +} + +void testOptions() { + Tuning::resetEval(); + const P defaults{}; + struct Weight { const char* name; int P::*field; int value; int max; }; + const Weight parameters[] = { + {"EvalKingShieldPenalty", &P::kingShieldPenalty, 10, 200}, + {"EvalKingZoneAttackPenalty", &P::kingZoneAttackPenalty, 6, 200}, + {"EvalKingPawnStormPenalty", &P::kingPawnStormPenalty, 5, 200}, + {"EvalKingMateThreatPenalty", &P::kingMateThreatPenalty, 30, 1000}, + {"EvalKingCentralizationBonus", &P::kingCentralizationBonus, 4, 200}, + {"EvalKingPawnlessWingPenalty", &P::kingPawnlessWingPenalty, 14, 200}, + {"EvalKingPinPenalty", &P::kingPinPenalty, 14, 200}, + {"EvalKingXrayPenalty", &P::kingXrayPenalty, 8, 200}, + {"EvalKingCastlingRightsBonus", &P::kingCastlingRightsBonus, 8, 200}, + }; + std::ostringstream uci; + Tuning::printUciOptions(uci); + for (const auto& p : parameters) { + expect(Tuning::eval().*(p.field) == p.value, "feature starts at default"); + expect(uci.str().find(std::string("option name ") + p.name + + " type spin default " + std::to_string(p.value) + + " min 0 max " + std::to_string(p.max) + "\n") + != std::string::npos, "UCI option format/default valid"); + expect(Tuning::setEvalParameter(p.name, p.max), "accept upper bound"); + expect(Tuning::eval().*(p.field) == p.max, "new weight stored"); + expect(!Tuning::setEvalParameter(p.name, p.max+1), "reject upper overflow"); + expect(!Tuning::setEvalParameter(p.name, -1), "reject negative weight"); + } + Tuning::resetEval(); + for (const auto& p : parameters) + expect(Tuning::eval().*(p.field) == defaults.*(p.field), + "reset restores current default"); + std::ostringstream params; + Tuning::printCurrent(params); + expect(params.str().find("EvalKingPinPenalty=14") != std::string::npos, + "tuneparams reports king weights"); +} + +void testMiddlegame() { + auto pos = kings(); + // Only enemy non-pawn pieces enable the middlegame safety blend. + pos.placePiece(sq('c', 9), makePiece(BLUE, QUEEN)); + pos.placePiece(sq('l', 9), makePiece(GREEN, QUEEN)); + pos.finalizeSetup(); + + const int noShield = contribution(pos, &P::kingShieldPenalty, 100); + expect(noShield < 0, "missing shield penalizes Red king"); + pos.placePiece(sq('f', 2), makePiece(RED, PAWN)); + pos.placePiece(sq('g', 2), makePiece(RED, PAWN)); + pos.placePiece(sq('h', 2), makePiece(RED, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingShieldPenalty, 100) > noShield, + "three forward shield pawns protect Red king"); + + pos.erasePiece(sq('f', 2)); + pos.erasePiece(sq('g', 2)); + pos.erasePiece(sq('h', 2)); + pos.placePiece(sq('g', 3), makePiece(BLUE, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPawnStormPenalty, 100) < 0, + "nearby enemy pawn penalizes Red king"); + + pos.setSideToMove(BLUE); + pos.finalizeSetup(); + const int blue = contribution(pos, &P::kingPawnStormPenalty, 100); + pos.setSideToMove(RED); + pos.finalizeSetup(); + expect(blue == -contribution(pos, &P::kingPawnStormPenalty, 100), + "king features obey team-relative perspective"); +} + +void testKingZoneMatePressure() { + auto pos = kings(); + pos.erasePiece(sq('g', 1)); + pos.placePiece(sq('d', 1), makePiece(RED, KING)); + pos.placePiece(sq('d', 6), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingZoneAttackPenalty, 100) < 0, + "enemy rook controls squares around the king"); + expect(contribution(pos, &P::kingMateThreatPenalty, 100) < 0, + "constrained checked king receives at-a-glance danger penalty"); +} + +void testEndgame() { + auto pos = kings(); + const int home = contribution(pos, &P::kingCentralizationBonus, 12); + pos.erasePiece(sq('g', 1)); + pos.placePiece(sq('g', 7), makePiece(RED, KING)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCentralizationBonus, 12) > home, + "endgame king centralization rewards active king"); + + pos.erasePiece(sq('g', 7)); + pos.placePiece(sq('d', 1), makePiece(RED, KING)); + pos.finalizeSetup(); + const int wing = contribution(pos, &P::kingPawnlessWingPenalty, 100); + expect(wing < 0, "pawnless wing penalizes endgame king"); + pos.placePiece(sq('d', 2), makePiece(RED, PAWN)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPawnlessWingPenalty, 100) > wing, + "pawn on same wing removes penalty"); +} + +void testPinsAndXRays() { + auto pos = kings(); + pos.placePiece(sq('g', 3), makePiece(RED, KNIGHT)); + pos.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPinPenalty, 100) < 0, + "rook pins a piece to Red king"); + pos.erasePiece(sq('g', 8)); + pos.placePiece(sq('i', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingPinPenalty, 100) == 0, + "off-ray rook cannot pin"); + + pos = kings(); + pos.placePiece(sq('g', 2), makePiece(RED, QUEEN)); + pos.placePiece(sq('g', 3), makePiece(RED, PAWN)); + pos.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingXrayPenalty, 100) < 0, + "friendly pawn screens own queen from enemy rook X-ray"); + pos.erasePiece(sq('g', 3)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingXrayPenalty, 100) == 0, + "no X-ray penalty without a screening piece"); +} + +void testCastlingRights() { + auto pos = kings(); + pos.placePiece(sq('d', 1), makePiece(RED, ROOK)); + pos.placePiece(sq('k', 9), makePiece(BLUE, QUEEN)); + pos.configureCastle(RED, 0, + CastleLane{sq('g', 1), sq('d', 1), sq('i', 1), sq('h', 1), 0}); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) == 0, + "castle geometry alone does not grant rights bonus"); + pos.setCastlingRights(1); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) > 0, + "real castling right plus king and rook earns bonus"); + pos.erasePiece(sq('d', 1)); + pos.finalizeSetup(); + expect(contribution(pos, &P::kingCastlingRightsBonus, 100) == 0, + "missing rook invalidates bonus despite stale rights flag"); +} + +} // namespace + +int main() { + Geometry::init(); + // Compare the same exact HCE scores across the geometric ray prefilter + // on hand-built king, pin, and queen X-ray positions. + auto kingTest = kings(); + kingTest.placePiece(sq('g', 3), makePiece(RED, KNIGHT)); + kingTest.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + kingTest.placePiece(sq('j', 9), makePiece(GREEN, QUEEN)); + kingTest.finalizeSetup(); + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + kingTest.setSideToMove(static_cast(ci)); + kingTest.finalizeSetup(); + Tuning::search().kingRayPrefilter = 0; + const int reference = Eval::evaluate(kingTest); + Tuning::search().kingRayPrefilter = 1; + expect(Eval::evaluate(kingTest) == reference, + "ray prefilter preserves score for each moving color"); + } + auto queenXray = kings(); + queenXray.placePiece(sq('g', 2), makePiece(RED, QUEEN)); + queenXray.placePiece(sq('g', 3), makePiece(RED, PAWN)); + queenXray.placePiece(sq('g', 8), makePiece(BLUE, ROOK)); + queenXray.placePiece(sq('i', 9), makePiece(GREEN, BISHOP)); + queenXray.finalizeSetup(); + Tuning::search().kingRayPrefilter = 0; + const int queenWithout = Eval::evaluate(queenXray); + Tuning::search().kingRayPrefilter = 1; + expect(Eval::evaluate(queenXray) == queenWithout, + "queen X-ray score unchanged with geometric prefilter"); + Tuning::resetSearch(); + expect(!NNUE::loaded(), "king evaluation features apply to HCE"); + testOptions(); + testMiddlegame(); + testKingZoneMatePressure(); + testEndgame(); + testPinsAndXRays(); + testCastlingRights(); + Tuning::resetEval(); + if (!failures) std::cout << "4PC Teams king evaluation tests: PASS\n"; + return failures ? 1 : 0; +} diff --git a/tests/king_legality.cpp b/tests/king_legality.cpp new file mode 100644 index 0000000..600501a --- /dev/null +++ b/tests/king_legality.cpp @@ -0,0 +1,146 @@ +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/king_legality.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/search.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace ironphoenix; +namespace { +int failures=0; +std::uint64_t checked=0, skipped=0; +void expect(bool ok,const char* why) { + if(!ok){ ++failures; std::cerr<<"FAIL: "< depth3(int on) { + Tuning::search().fastKingLegality=on; + auto engine=std::make_unique(); + SearchLimits limits{}; + limits.depth=3; + std::ostringstream out; + engine->start(fixture(),limits,out); + const auto until=std::chrono::steady_clock::now()+std::chrono::seconds(15); + while (engine->searching() && std::chrono::steady_clock::now()searching(),"depth3 terminates"); + engine->stopAndWait(); + const auto text=out.str(); + const auto b=text.rfind("bestmove "); + expect(b!=std::string::npos,"bestmove output present"); + const std::string best=b==std::string::npos?"":text.substr( + b,text.find('\n',b)-b); + const auto i=text.rfind("info depth 3 "); + expect(i!=std::string::npos,"depth3 info present"); + const auto a=i==std::string::npos?i:text.find(" score ",i); + const auto z=a==std::string::npos?a:text.find(" nodes ",a); + const std::string score=(a==std::string::npos || z==std::string::npos) + ?"":text.substr(a,z-a); + return {best,score}; +} + +void test() { + Tuning::resetSearch(); + expect(Tuning::search().fastKingLegality==1,"enabled by default"); + expect(Tuning::setSearchParameter("SearchFastKingLegality",0),"disable accepted"); + expect(Tuning::setSearchParameter("SearchFastKingLegality",1),"enable accepted"); + expect(!Tuning::setSearchParameter("SearchFastKingLegality",2),"invalid setting rejected"); + std::ostringstream options; + Tuning::printUciOptions(options); + expect(options.str().find( + "option name SearchFastKingLegality type spin default 1 min 0 max 1") + != std::string::npos,"UCI setting advertised"); + + Position p=fixture(); + for (unsigned ci=0;ci(ci)); + p.finalizeSetup(); + verifyPosition(p); + } + p=fixture(); + for(int ply=0;ply<24;++ply) { + verifyPosition(p); + MoveList moves; + generateLegalMoves(p,moves); + Move selected{}; + for(std::size_t n=0;n(ply)*11)%moves.size]; + if (!(move.isCapture() && isTerminalKingCapture(p,move))) { + selected=move; + break; + } + } + if(!selected)break; + StateInfo state{}; + p.makeMove(selected,state); + expect(p.verify(),"successor position valid"); + } + expect(skipped>100,"many moves skip redundant king check"); + expect(checked>500,"hundreds of 4PC pseudo-legal moves validated"); + const auto original=depth3(0); + const auto optimized=depth3(1); + expect(original==optimized, + "depth3 bestmove/score unchanged by optimized king legality"); + Tuning::resetSearch(); +} +} +int main(){ + Geometry::init(); + test(); + if(!failures) + std::cout<<"Fast 4PC king legality tests: PASS ("< #include @@ -96,7 +97,88 @@ int main(int argc, char** argv) { << "Last completed iteration: " << lastNodes << " nodes, " << lastNps << " NPS\n" << "Engine finished: " << (finished ? "yes" : "no (time limit)") << '\n'; + // Compare feature groups on the same runner and search position. Never + // treat absolute CI NPS as cross-machine evidence; compare relative changes. + const auto original = Tuning::eval(); + const auto originalSearch = Tuning::search(); + auto measure = [&](const char* label) { + auto probe = std::make_unique(); + SearchLimits probeLimits{}; + probeLimits.nodes = requested; + std::ostringstream trace; + const auto probeStart = std::chrono::steady_clock::now(); + probe->start(pos, probeLimits, trace); + while (probe->searching() + && std::chrono::steady_clock::now() - probeStart < std::chrono::seconds(120)) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const bool done = !probe->searching(); + probe->stopAndWait(); + std::uint64_t nps = 0; + const std::string output = trace.str(); + for (auto it = std::sregex_iterator(output.begin(), output.end(), info); + it != std::sregex_iterator(); ++it) + nps = std::stoull((*it)[2].str()); + std::cout << "King feature NPS: " << label << " = " << nps << '\n'; + return done; + }; + + Tuning::eval().kingPinPenalty = 0; + Tuning::eval().kingXrayPenalty = 0; + const bool pinsDone = measure("pin-and-xray-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingShieldPenalty = 0; + Tuning::eval().kingPawnStormPenalty = 0; + Tuning::eval().kingPawnlessWingPenalty = 0; + const bool pawnsDone = measure("shield-storm-wing-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingZoneAttackPenalty = 0; + Tuning::eval().kingMateThreatPenalty = 0; + const bool threatDone = measure("king-zone-and-mate-disabled"); + + Tuning::eval() = original; + Tuning::eval().kingShieldPenalty = 0; + Tuning::eval().kingZoneAttackPenalty = 0; + Tuning::eval().kingPawnStormPenalty = 0; + Tuning::eval().kingMateThreatPenalty = 0; + Tuning::eval().kingCentralizationBonus = 0; + Tuning::eval().kingPawnlessWingPenalty = 0; + Tuning::eval().kingPinPenalty = 0; + Tuning::eval().kingXrayPenalty = 0; + Tuning::eval().kingCastlingRightsBonus = 0; + const bool noKingDone = measure("all-new-king-features-disabled"); + Tuning::eval() = original; + Tuning::search().fastCheckPrefilter = 0; + const bool originalOrderingDone = measure("fast-check-prefilter-disabled"); + Tuning::search() = originalSearch; + + Tuning::search().evalCacheEnabled = 0; + const bool cacheDisabledDone = measure("eval-cache-disabled"); + Tuning::search() = originalSearch; + const bool cacheEnabledDone = measure("eval-cache-enabled"); + + Tuning::search().fastKingLegality = 0; + const bool fullLegalityDone = measure("full-king-legality"); + Tuning::search() = originalSearch; + const bool fastLegalityDone = measure("fast-king-legality"); + + Tuning::search().pawnAttackCache = 0; + const bool pawnCacheOffDone = measure("pawn-attack-cache-disabled"); + Tuning::search() = originalSearch; + const bool pawnCacheOnDone = measure("pawn-attack-cache-enabled"); + + Tuning::search().kingRayPrefilter = 0; + const bool rayOffDone = measure("king-ray-prefilter-disabled"); + Tuning::search() = originalSearch; + const bool rayOnDone = measure("king-ray-prefilter-enabled"); + // This is a diagnostic, not an NPS threshold test. CI runner CPU differs // from local hardware, and iteration counters may be below node budget. - return finished ? 0 : 1; + return (finished && pinsDone && pawnsDone && threatDone + && noKingDone && originalOrderingDone + && cacheDisabledDone && cacheEnabledDone + && fullLegalityDone && fastLegalityDone + && pawnCacheOffDone && pawnCacheOnDone + && rayOffDone && rayOnDone) ? 0 : 1; } diff --git a/tests/pawn_cache.cpp b/tests/pawn_cache.cpp new file mode 100644 index 0000000..d04fe9d --- /dev/null +++ b/tests/pawn_cache.cpp @@ -0,0 +1,107 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/movegen.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include +#include +#include + +using namespace ironphoenix; +namespace { +int failures=0; +void check(bool condition, const char* message) { + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} +Square sq(char f, int r) { return Geometry::square(f - 'a', r - 1); } +Position fixture() { + Position p; + p.clear(); + p.placePiece(sq('g',1), makePiece(RED,KING)); + p.placePiece(sq('a',7), makePiece(BLUE,KING)); + p.placePiece(sq('g',14), makePiece(YELLOW,KING)); + p.placePiece(sq('n',7), makePiece(GREEN,KING)); + p.placePiece(sq('e',5), makePiece(RED, KNIGHT)); + p.placePiece(sq('h',11), makePiece(YELLOW, BISHOP)); + p.placePiece(sq('c',6), makePiece(BLUE, ROOK)); + p.placePiece(sq('k',8), makePiece(GREEN, QUEEN)); + for (char file: {'d','e','f','h','i','j'}) { + p.placePiece(sq(file,3), makePiece(RED,PAWN)); + p.placePiece(sq(file,12), makePiece(YELLOW,PAWN)); + } + for (int rank: {4,5,6,8,9,10}) { + p.placePiece(sq('c',rank), makePiece(BLUE,PAWN)); + p.placePiece(sq('l',rank), makePiece(GREEN,PAWN)); + } + p.setSideToMove(RED); + p.finalizeSetup(); + return p; +} +void compare(const Position& pos) { + Tuning::search().pawnAttackCache=0; + const int reference=Eval::evaluate(pos); + Tuning::search().pawnAttackCache=1; + const int cached=Eval::evaluate(pos); + check(reference==cached, "cached and raw HCE evaluation exactly equal"); + check(Eval::evaluate(pos)==cached,"second evaluation matches cached result"); +} +void test() { + check(!NNUE::loaded(),"test uses HCE"); + Tuning::resetSearch(); + Tuning::resetEval(); + check(Tuning::search().pawnAttackCache==1,"default enabled"); + check(Tuning::setSearchParameter("SearchPawnAttackCache",0),"disable works"); + check(Tuning::setSearchParameter("SearchPawnAttackCache",1),"enable works"); + check(!Tuning::setSearchParameter("SearchPawnAttackCache",2),"invalid value rejected"); + std::ostringstream options; + Tuning::printUciOptions(options); + check(options.str().find("option name SearchPawnAttackCache type spin default 1 min 0 max 1") + != std::string::npos,"UCI option exposed"); + + Position p=fixture(); + for(unsigned ci=0;ci(ci)); + p.finalizeSetup(); + compare(p); + } + p=fixture(); + for(int i=0;i<65;++i) { + compare(p); + MoveList legal; + generateLegalMoves(p, legal); + if(!legal.size) break; + Move chosen{}; + for (std::size_t j=0;j(i)*13u)%legal.size]; + if (!(move.isCapture() && isTerminalKingCapture(p,move))) { + chosen=move; + break; + } + } + if (!chosen) break; + StateInfo st{}; + p.makeMove(chosen,st); + check(p.verify(),"successor valid"); + } + // Explicitly change a pawn bitboard and test the new cache key. + p=fixture(); + p.erasePiece(sq('d',3)); + p.finalizeSetup(); + compare(p); + p.placePiece(sq('d',3), makePiece(RED,PAWN)); + p.finalizeSetup(); + compare(p); + Tuning::resetSearch(); +} +} +int main() { + Geometry::init(); + test(); + if(!failures)std::cout<<"Pawn attack union cache: PASS\n"; + return failures?1:0; +}