Skip to content
10 changes: 9 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,15 @@ if(IRONPHOENIX_BUILD_TESTS)
target_compile_features(ironphoenix_mate_stop PRIVATE cxx_std_20)
add_test(NAME ironphoenix_mate_stop COMMAND ironphoenix_mate_stop)

set_target_properties(ironphoenix_smoke ironphoenix_property ironphoenix_search_stack ironphoenix_mate_stop PROPERTIES
add_executable(ironphoenix_reverse_futility tests/reverse_futility.cpp)
target_link_libraries(ironphoenix_reverse_futility PRIVATE IronPhoenix::Core)
add_test(NAME ironphoenix_reverse_futility COMMAND ironphoenix_reverse_futility)

add_executable(ironphoenix_attacking_pieces tests/attacking_pieces.cpp)
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
FOLDER "Tests"
)
endif()
Expand Down
34 changes: 34 additions & 0 deletions docs/attacking-pieces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Attacking-piece evaluation for IronPhoenix Teams HCE

This experiment adds a small **direct-pressure bonus against enemy pieces** to
the existing handcrafted material and mobility evaluation.

The two teammates' attack maps are united before evaluating threats. A target
is counted at most once per TEAM, even when multiple allied pieces attack it.
Pawn capture squares count as attacks, not mobility. King attacks and the king
as a target are excluded; mate/check assessment remains in search.

| Attacked enemy piece | Default bonus (cp) |
|---|---:|
| Pawn | 4 |
| Knight or bishop | 10 |
| Rook | 16 |
| Queen | 28 |

The UCI tuning option `EvalAttackingPiecesWeight` ranges from 0–300.
`100` is the default percentage, `0` disables the bonus for testing:

```text
setoption name EvalAttackingPiecesWeight value 0
```

This is **geometric pressure**, not proof of a free winning capture. Checks,
pins, defenders, and SEE are intentionally not fully resolved here to avoid
making every static evaluation dramatically more expensive.

**Only HCE uses this feature.** If a PhoenixNet NNUE is loaded, its output
remains unchanged rather than mixing differently calibrated scores. Threat
bonuses can influence static-eval decisions in RFP/NMP; benchmark NPS and
run an SPRT before assuming a strength increase.

Tests: `ironphoenix_attacking_pieces`.
61 changes: 61 additions & 0 deletions docs/reverse-futility-pruning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Reverse futility pruning in Teams search

At shallow non-PV nodes, RFP skips the subtree when the side-to-move team's
static evaluation minus a safety margin is already at least beta. This can
save nodes in clearly favorable positions; it is a selective heuristic, not
a proof of a minimax bound or of increased playing strength.

The aggressive experimental settings are **depth 1–6** (plies, not full
four-seat cycles) and **130 evaluation units per remaining ply**. When a
same-team ancestor provides a valid improving trend, the margin shrinks by
`min(80, improvingRate / 3)` centipawns, never falling below 90 cp.
No improvement comparison means no discount. The returned fail-high estimate
is `staticEval - max(90, 130 * depth - improvingDiscount)`, rather than
the unadjusted static surplus. No exact TT entry or best move is manufactured
by the cutoff.

RFP runs after mate-distance/TT cutoffs and before NMP and ordinary move
search. Evaluation is already relative to the moving team's perspective:
Red/Yellow versus Blue/Green. The regular four-seat rotation alternates teams,
so the existing child negation is retained. The raw evaluation is recorded on
the per-ply search stack and reused by NMP if RFP declines to cut.

Guards exclude root/PV nodes, synthetic null-move child nodes, mate windows and
mate-range static evaluations, own/partner check, and movers without non-pawn
material. A candidate cutoff additionally requires four living players and
four present, unchecked kings. Checking enemy kings is conservative but also
protects immediate king-capture wins. Pawns one step from promotion in any
of the four orientations disable RFP, even when blocked. Existing geometry
helpers retain the standard rank 11/file k/rank 4/file d promotion lines.
Pseudo-legal terminal captures and promotions are explicitly excluded, and
two legal witnesses are required to preserve stalemate and forced moves.
Every witness make/undo restores the board and hash.

These guards cost work on candidate cutoffs and do not eliminate all tactical
or zugzwang errors. They leave NMP's existing policy and other pruning intact.
The increased max depth and smaller margin intentionally trade tactical safety
for more speculative cutoffs; no Elo gain is assumed. The improving discount
only applies to team-relative, previously recorded evaluations; missing history
does not imply improvement.

Use CTest for deterministic guard/score/state regressions, then compare to
the same main revision with identical compiler, network, hash, threads and
opening suite. Run paired seat/team-swapped fixed-node and fixed-time games,
inspect tactical losses and node/time/depth changes, and use a predeclared
SPRT before claiming an Elo gain.

The old experiment is PR #28 at branch tip
`dcefe70131df6c83a69910e2edec39086e3efa7f`. Its RFP idea and initial constants
are retained, while its unrelated merge history is excluded from this branch.

## Search hot-path optimization (NPS experiment)

The `reverse-futility-pruning-teams` branch also defers expensive
`givesCheck()` calls at non-PV nodes until a move would otherwise be
pruned by LMP, HMP, or SEE. PV nodes still receive full move ordering but
no longer build check metadata used only by non-PV pruning. RFP and null-move
pruning share a single partner-check/non-pawn-material eligibility test and
reuse the recorded static evaluation. No pruning margins, move protections,
or cutoffs were relaxed in this optimization. It targets time per node, not
just reducing the number of nodes searched; measure NPS in an identical
fixed-position/fixed-node search before asserting a speedup.
2 changes: 2 additions & 0 deletions include/ironphoenix/search.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ namespace ironphoenix {
private:
// Exposed only to the mate-stop regression test.
friend struct MateStopTestAccess;
friend struct ReverseFutilityTestAccess;
[[nodiscard]] bool reverseFutilitySafe(Position& pos) const;

struct TimeBudget final {
std::int64_t minimumMs = 0;
Expand Down
2 changes: 2 additions & 0 deletions include/ironphoenix/tuning.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ namespace ironphoenix {
int bishopMobility = 4;
int rookMobility = 2;
int queenMobility = 1;
// 100 = default pressure; 0 = disabled, for A/B tests.
int attackingPiecesWeight = 100;
};

[[nodiscard]] SearchParameters& search() noexcept;
Expand Down
48 changes: 40 additions & 8 deletions src/eval.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ namespace ironphoenix::Eval {
return material;
}

int mobilityForColor(const Position& pos, Color c) noexcept {
int mobilityForColor(const Position& pos, Color c, Bitboard& teamAttacks) noexcept {
const Bitboard blockedByTeam = teamOccupancy(pos, c);
const bool scoreThreats = Tuning::eval().attackingPiecesWeight != 0;

int score = 0;

Expand Down Expand Up @@ -81,6 +82,8 @@ namespace ironphoenix::Eval {

const int mobility = (attacks & ~blockedByTeam).popcount();
score += mobility * mobilityWeight(pt);
if (scoreThreats)
teamAttacks |= attacks;
}
};

Expand All @@ -89,28 +92,57 @@ namespace ironphoenix::Eval {
scorePieces(ROOK);
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)];
}
return score;
}

int playerScore(const Position& pos, Color c) noexcept {
return materialForColor(pos, c)
+ mobilityForColor(pos, c);
int attackingPiecesForTeam(const Position& pos, int team, Bitboard attacks) noexcept {
const int weight = Tuning::eval().attackingPiecesWeight;
if (weight == 0)
return 0;

Bitboard pawns{}, minors{}, rooks{}, queens{};
for (unsigned ci = 0; ci < COLOR_NB; ++ci) {
const Color victim = static_cast<Color>(ci);
if (teamOf(victim) == team || !pos.isAlive(victim))
continue;
pawns |= pos.pieces(victim, PAWN);
minors |= pos.pieces(victim, KNIGHT) | pos.pieces(victim, BISHOP);
rooks |= pos.pieces(victim, ROOK);
queens |= pos.pieces(victim, QUEEN);
}

// Per distinct enemy target across both teammates, not per attack.
// Keep king threats out of this term: mate search handles kings.
const int pressure = 4 * (attacks & pawns).popcount()
+ 10 * (attacks & minors).popcount()
+ 16 * (attacks & rooks).popcount()
+ 28 * (attacks & queens).popcount();
return pressure * weight / 100;
}

int handcraftedEvaluate(const Position& pos) noexcept {
int team0 = 0;
int team1 = 0;
int team0 = 0, team1 = 0;
Bitboard attacks[2]{};

for (unsigned ci = 0; ci < COLOR_NB; ++ci) {
const Color c = static_cast<Color>(ci);
const int score = playerScore(pos, c);

const int score = materialForColor(pos, c)
+ mobilityForColor(pos, c, attacks[teamOf(c)]);
if (teamOf(c) == 0)
team0 += score;
else
team1 += score;
}

team0 += attackingPiecesForTeam(pos, 0, attacks[0]);
team1 += attackingPiecesForTeam(pos, 1, attacks[1]);

const int score = team0 - team1;
return teamOf(pos.sideToMove()) == 0 ? score : -score;
}
Expand Down
Loading
Loading