diff --git a/CMakeLists.txt b/CMakeLists.txt index 2114194..c1b6660 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,7 +194,12 @@ if(IRONPHOENIX_BUILD_TESTS) target_link_libraries(ironphoenix_attacking_pieces PRIVATE IronPhoenix::Core) add_test(NAME ironphoenix_attacking_pieces COMMAND ironphoenix_attacking_pieces) - set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop ironphoenix_reverse_futility ironphoenix_attacking_pieces PROPERTIES + add_executable(ironphoenix_queen_infiltration tests/queen_infiltration.cpp) + target_link_libraries(ironphoenix_queen_infiltration PRIVATE IronPhoenix::Core) + target_compile_features(ironphoenix_queen_infiltration PRIVATE cxx_std_20) + add_test(NAME ironphoenix_queen_infiltration COMMAND ironphoenix_queen_infiltration) + + set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop ironphoenix_reverse_futility ironphoenix_attacking_pieces ironphoenix_queen_infiltration PROPERTIES FOLDER "Tests" ) endif() diff --git a/docs/queen-infiltration.md b/docs/queen-infiltration.md new file mode 100644 index 0000000..692d3ef --- /dev/null +++ b/docs/queen-infiltration.md @@ -0,0 +1,40 @@ +# Queen Infiltration (Teams handcrafted evaluation) + +`queen-infiltration-v2` starts from current `main` and ports the old +four-orientation queen-infiltration feature without discarding newer +material, mobility, attacked-piece evaluation, search or web GUI code. + +A queen earns a **small positional bonus** when: + +- It has left its own back/home camp. +- It has penetrated at least two squares past the central boundary + towards an **alive enemy** king's side of the four-player cross board. +- It is within Chebyshev distance six of that enemy king. +- Its square is **not attacked by the opposing team**. + +Each enemy color is evaluated using its own home-edge direction: RED +from the bottom, BLUE from the left, YELLOW from the top, GREEN from the +right. Teammates' kings do not count as infiltration targets. + +For each eligible enemy king: + +```text +bonus = (depth_into_enemy_half - 1) * (7 - chebyshev_distance) * EvalQueenInfiltration +``` + +The default `EvalQueenInfiltration` is **2**, configurable through UCI +from **0** (disabled) to **20**. Example: + +```text +setoption name EvalQueenInfiltration value 0 +``` + +The attack-map safety check is made only for queens with a candidate +infiltration bonus. This limits overhead when no enemy camp is reached. + +**HCE only:** a loaded NNUE remains authoritative and is unchanged. +The positional bonus is experimental, can influence RFP/NMP static +evaluations, and may or may not improve Elo. Test against `main` +with a matched build and a paired four-player opening suite. + +CTest: `ironphoenix_queen_infiltration`. diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index 5ed4eaf..47ed371 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -36,6 +36,8 @@ namespace ironphoenix { int queenMobility = 1; // 100 = default pressure; 0 = disabled, for A/B tests. int attackingPiecesWeight = 100; + // Experimental enemy-king camp penetration bonus. Zero disables. + int queenInfiltration = 2; }; [[nodiscard]] SearchParameters& search() noexcept; diff --git a/src/eval.cpp b/src/eval.cpp index c03ef03..a6e586a 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -5,6 +5,9 @@ #include "ironphoenix/position.hpp" #include "ironphoenix/tuning.hpp" +#include +#include + namespace ironphoenix::Eval { namespace { @@ -126,6 +129,81 @@ namespace ironphoenix::Eval { return pressure * weight / 100; } + // 4PC home camps point towards four different board edges. + // A queen that reaches an enemy's half of the cross may develop + // useful pressure against that side's king. This is deliberately + // not the same as an attack on the king or a guaranteed tactic. + IRONPHOENIX_FORCE_INLINE int enemyCampDepth(Color enemyColor, int file, int rank) noexcept { + switch (enemyColor) { + case RED: return 7 - rank; + case BLUE: return 7 - file; + case YELLOW: return rank - 6; + case GREEN: return file - 6; + } + return 0; + } + + IRONPHOENIX_FORCE_INLINE bool inHomeCamp(Color color, int file, int rank) noexcept { + switch (color) { + case RED: return rank <= 2; + case BLUE: return file <= 2; + case YELLOW: return rank >= 11; + case GREEN: return file >= 11; + } + return false; + } + + int queenInfiltrationForColor(const Position& pos, Color c) noexcept { + const int weight = Tuning::eval().queenInfiltration; + if (weight <= 0 || !pos.isAlive(c)) + return 0; + + int score = 0; + Bitboard queens = pos.pieces(c, QUEEN); + const Bitboard occupancy = pos.occupancy(); + + while (queens) { + const Square queen = popLsb(queens); + const int file = static_cast(Geometry::fileOf(queen)); + const int rank = static_cast(Geometry::rankOf(queen)); + + // Staying in the queen's own camp is not infiltration. + if (inHomeCamp(c, file, rank)) + continue; + + int potential = 0; + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color opponent = static_cast(ci); + if (!enemy(c, opponent) || !pos.isAlive(opponent)) + continue; + + const Square king = pos.kingSquare(opponent); + if (king == SQ_NONE) + continue; + + // Require material penetration beyond the centre, + // not just a queen occupying a central square. + const int depth = enemyCampDepth(opponent, file, rank); + if (depth < 2) + continue; + + const int kingFile = static_cast(Geometry::fileOf(king)); + const int kingRank = static_cast(Geometry::rankOf(king)); + const int distance = std::max(std::abs(file - kingFile), std::abs(rank - kingRank)); + if (distance > 6) + continue; + + potential += (depth - 1) * (7 - distance); + } + + // Avoid expensive attack-map queries for queens without any + // eligible infiltration bonus. Never reward a hanging queen. + if (potential > 0 && !pos.isAttackedByEnemy(queen, c, occupancy)) + score += potential * weight; + } + return score; + } + int handcraftedEvaluate(const Position& pos) noexcept { int team0 = 0, team1 = 0; Bitboard attacks[2]{}; @@ -133,7 +211,8 @@ namespace ironphoenix::Eval { for (unsigned ci = 0; ci < COLOR_NB; ++ci) { const Color c = static_cast(ci); const int score = materialForColor(pos, c) - + mobilityForColor(pos, c, attacks[teamOf(c)]); + + mobilityForColor(pos, c, attacks[teamOf(c)]) + + queenInfiltrationForColor(pos, c); if (teamOf(c) == 0) team0 += score; else diff --git a/src/tuning.cpp b/src/tuning.cpp index d9f07aa..5e2080f 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -59,7 +59,8 @@ namespace ironphoenix::Tuning { || name == "EvalBishopMobility" || name == "EvalRookMobility" || name == "EvalQueenMobility" - || name == "EvalAttackingPiecesWeight"; + || name == "EvalAttackingPiecesWeight" + || name == "EvalQueenInfiltration"; } bool isLmrParameter(std::string_view name) noexcept { @@ -158,6 +159,8 @@ namespace ironphoenix::Tuning { p.queenMobility = value; else if (name == "EvalAttackingPiecesWeight" && inRange(value, 0, 300)) p.attackingPiecesWeight = value; + else if (name == "EvalQueenInfiltration" && inRange(value, 0, 20)) + p.queenInfiltration = value; else return false; @@ -232,6 +235,7 @@ namespace ironphoenix::Tuning { << "option name EvalRookMobility type spin default 2 min -100 max 100\n" << "option name EvalQueenMobility type spin default 1 min -100 max 100\n" << "option name EvalAttackingPiecesWeight type spin default 100 min 0 max 300\n" + << "option name EvalQueenInfiltration type spin default 2 min 0 max 20\n" << "option name ResetTuning type button\n"; } @@ -261,7 +265,8 @@ namespace ironphoenix::Tuning { << " EvalBishopMobility=" << e.bishopMobility << " EvalRookMobility=" << e.rookMobility << " EvalQueenMobility=" << e.queenMobility - << " EvalAttackingPiecesWeight=" << e.attackingPiecesWeight << '\n'; + << " EvalAttackingPiecesWeight=" << e.attackingPiecesWeight + << " EvalQueenInfiltration=" << e.queenInfiltration << '\n'; } } // namespace ironphoenix::Tuning diff --git a/tests/queen_infiltration.cpp b/tests/queen_infiltration.cpp new file mode 100644 index 0000000..7e69418 --- /dev/null +++ b/tests/queen_infiltration.cpp @@ -0,0 +1,94 @@ +#include "ironphoenix/eval.hpp" +#include "ironphoenix/geometry.hpp" +#include "ironphoenix/nnue.hpp" +#include "ironphoenix/position.hpp" +#include "ironphoenix/tuning.hpp" + +#include + +using namespace ironphoenix; + +namespace { +int failures = 0; + +void expect(bool condition, const char* description) { + if (!condition) { + std::cerr << "FAIL: " << description << '\n'; + ++failures; + } +} + +Square sq(char file, int rank) { + return Geometry::square(file - 'a', rank - 1); +} + +Position queenPosition(Color queenColor, char file, int rank, + bool attacked = false) { + Position pos; + pos.clear(); + pos.placePiece(sq('g', 1), makePiece(RED, KING)); + pos.placePiece(sq('a', 7), makePiece(BLUE, KING)); + pos.placePiece(sq('g', 14), makePiece(YELLOW, KING)); + pos.placePiece(sq('n', 7), makePiece(GREEN, KING)); + pos.placePiece(sq(file, rank), makePiece(queenColor, QUEEN)); + if (attacked) + pos.placePiece(sq('d', 9), makePiece(BLUE, ROOK)); + pos.setSideToMove(queenColor); + pos.finalizeSetup(); + return pos; +} + +// Subtract an evaluation of the exact same position with the new term +// disabled. This isolates infiltration from material, mobility and the +// existing attacked-piece score without disabling any other features. +int bonus(const Position& pos, int weight = 2) { + const auto saved = Tuning::eval(); + Tuning::eval().queenInfiltration = 0; + const int baseline = Eval::evaluate(pos); + Tuning::eval().queenInfiltration = weight; + const int updated = Eval::evaluate(pos); + Tuning::eval() = saved; + return updated - baseline; +} +} + +int main() { + Geometry::init(); + expect(!NNUE::loaded(), "tests run in HCE mode"); + Tuning::resetEval(); + expect(Tuning::eval().queenInfiltration == 2, "default infiltration weight"); + + const Position red = queenPosition(RED, 'd', 7); + const Position yellow = queenPosition(YELLOW, 'k', 7); + const Position blue = queenPosition(BLUE, 'g', 4); + const Position green = queenPosition(GREEN, 'g', 11); + expect(bonus(red) > 0, "RED infiltrates BLUE's camp"); + expect(bonus(yellow) > 0, "YELLOW infiltrates GREEN's camp"); + expect(bonus(blue) > 0, "BLUE infiltrates RED's camp"); + expect(bonus(green) > 0, "GREEN infiltrates YELLOW's camp"); + + expect(bonus(red) == 24, "RED infiltration score accounts for depth and king distance"); + expect(bonus(red, 4) == 48, "infiltration scales with configured weight"); + expect(bonus(red, 0) == 0, "disabled infiltration adds no score"); + expect(bonus(queenPosition(RED, 'g', 4)) == 0, "centre is not infiltration"); + expect(bonus(queenPosition(RED, 'g', 11)) == 0, "allied king camp adds no bonus"); + expect(bonus(queenPosition(RED, 'g', 1)) == 0, "queen in own home camp adds no bonus"); + expect(bonus(queenPosition(RED, 'd', 7, true)) == 0, "hanging infiltrating queen is not rewarded"); + + Position oppositeTurn = red; + oppositeTurn.setSideToMove(BLUE); + oppositeTurn.finalizeSetup(); + expect(bonus(oppositeTurn) == -24, "bonus follows team-relative score perspective"); + + expect(Tuning::setEvalParameter("EvalQueenInfiltration", 0), "UCI setting permits disable"); + expect(Tuning::eval().queenInfiltration == 0, "zero stored"); + expect(Tuning::setEvalParameter("EvalQueenInfiltration", 20), "maximum weight accepted"); + expect(!Tuning::setEvalParameter("EvalQueenInfiltration", -1), "negative weight rejected"); + expect(!Tuning::setEvalParameter("EvalQueenInfiltration", 21), "weight beyond maximum rejected"); + Tuning::resetEval(); + expect(Tuning::eval().queenInfiltration == 2, "reset restores default"); + + if (!failures) + std::cout << "Queen infiltration tests: PASS\n"; + return failures ? 1 : 0; +}