Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 180 additions & 5 deletions moonfish/psqt.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,46 @@
EG_KING,
)

############
# Evaluation bonuses/penalties beyond PST
############

# Bishop pair bonus (having both bishops)
MG_BISHOP_PAIR_BONUS = 30
EG_BISHOP_PAIR_BONUS = 50

# Rook on open file (no pawns on file)
MG_ROOK_OPEN_FILE = 20
EG_ROOK_OPEN_FILE = 10

# Rook on semi-open file (no friendly pawns on file)
MG_ROOK_SEMI_OPEN_FILE = 10
EG_ROOK_SEMI_OPEN_FILE = 5

# Isolated pawn penalty (no friendly pawns on adjacent files)
MG_ISOLATED_PAWN = -10
EG_ISOLATED_PAWN = -20

# Doubled pawn penalty (multiple friendly pawns on the same file)
MG_DOUBLED_PAWN = -10
EG_DOUBLED_PAWN = -15

# Passed pawn bonus by rank (rank 1 to 8, index 0 unused)
# Bonus increases as pawn advances; huge in endgame
# Index = rank for white (rank 2..7 are the meaningful ones; 1 and 8 don't exist for pawns)
MG_PASSED_PAWN_BONUS = (0, 0, 5, 10, 20, 40, 60, 0)
EG_PASSED_PAWN_BONUS = (0, 0, 10, 20, 40, 70, 120, 0)

# File masks: for each file (0-7), a set of square indices on that file
FILE_SQUARES: tuple[set[int], ...] = tuple(
{file + rank * 8 for rank in range(8)} for file in range(8)
)
Comment on lines +191 to +193
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused FILE_SQUARES constant
FILE_SQUARES is defined here but never referenced anywhere in the codebase. This appears to be dead code left over from development. Consider removing it to keep the module clean.

Suggested change
FILE_SQUARES: tuple[set[int], ...] = tuple(
{file + rank * 8 for rank in range(8)} for file in range(8)
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


# Adjacent files for each file (0-7)
ADJACENT_FILES: tuple[tuple[int, ...], ...] = tuple(
tuple(f for f in (file - 1, file + 1) if 0 <= f <= 7) for file in range(8)
)

############
# Tapered Evaluation: https://www.chessprogramming.org/Tapered_Eval
# Phase values are used to determine on what phase of the game
Expand Down Expand Up @@ -263,16 +303,16 @@ def inner(board: chess.Board):
@board_evaluation_cache
def board_evaluation(board: chess.Board) -> float:
"""
This functions receives a board and assigns a value to it, it acts as
an evaluation function of the current state for this game. It returns

Evaluates the board using PeSTO PST values plus structural bonuses:
- Pawn structure (passed, isolated, doubled pawns)
- Bishop pair bonus
- Rook on open/semi-open file

Arguments:
- board: current board state.

Returns:
- total_value(int): integer representing
current value for this board.
- total_value(int): integer representing current value for this board.
"""

phase = get_phase(board)
Expand All @@ -282,15 +322,150 @@ def board_evaluation(board: chess.Board) -> float:
eg_white = 0
eg_black = 0

# Track piece locations for structural evaluation
white_pawns: list[int] = []
black_pawns: list[int] = []
white_rooks: list[int] = []
black_rooks: list[int] = []
white_bishop_count = 0
black_bishop_count = 0

# iterate only occupied squares via piece_map()
for square, piece in board.piece_map().items():
pt = piece.piece_type
if piece.color == chess.WHITE:
mg_white += MG_PESTO[pt][square ^ 56] + MG_PIECE_VALUES[pt]
eg_white += EG_PESTO[pt][square ^ 56] + EG_PIECE_VALUES[pt]
if pt == chess.PAWN:
white_pawns.append(square)
elif pt == chess.ROOK:
white_rooks.append(square)
elif pt == chess.BISHOP:
white_bishop_count += 1
else:
mg_black += MG_PESTO[pt][square] + MG_PIECE_VALUES[pt]
eg_black += EG_PESTO[pt][square] + EG_PIECE_VALUES[pt]
if pt == chess.PAWN:
black_pawns.append(square)
elif pt == chess.ROOK:
black_rooks.append(square)
elif pt == chess.BISHOP:
black_bishop_count += 1

# Pawn file sets for structural analysis
white_pawn_files = set()
black_pawn_files = set()
white_pawns_per_file: dict[int, int] = {}
black_pawns_per_file: dict[int, int] = {}

for sq in white_pawns:
f = sq % 8
white_pawn_files.add(f)
white_pawns_per_file[f] = white_pawns_per_file.get(f, 0) + 1

for sq in black_pawns:
f = sq % 8
black_pawn_files.add(f)
black_pawns_per_file[f] = black_pawns_per_file.get(f, 0) + 1

# --- Pawn structure ---
# White pawns
for sq in white_pawns:
f = sq % 8
r = sq // 8 + 1 # rank 1-8 (rank 1 = row 0)

# Doubled pawn: more than one pawn on same file
if white_pawns_per_file[f] > 1:
mg_white += MG_DOUBLED_PAWN
eg_white += EG_DOUBLED_PAWN
Comment on lines +377 to +380
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doubled pawn penalty scales per-pawn
The penalty is applied to each pawn on the file. Two pawns on the same file incur 2× MG_DOUBLED_PAWN total, and three pawns incur 3× penalty. A more standard approach is to penalize only the extra pawns (i.e., count - 1 times per file), so doubled pawns receive 1× penalty and tripled pawns receive 2×. The current approach over-penalizes relative to the weight values, since the effective doubled-pawn penalty for a standard doubled pawn pair is -20 MG / -30 EG rather than the -10 / -15 the constants suggest.

If this is intentional, consider renaming the constants (e.g. MG_DOUBLED_PAWN_PER_PAWN) or adding a comment to clarify. Otherwise, the fix would be to apply the penalty (count - 1) times per file rather than per pawn:

for f, count in white_pawns_per_file.items():
    if count > 1:
        mg_white += MG_DOUBLED_PAWN * (count - 1)
        eg_white += EG_DOUBLED_PAWN * (count - 1)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


# Isolated pawn: no friendly pawns on adjacent files
has_adjacent = any(af in white_pawn_files for af in ADJACENT_FILES[f])
if not has_adjacent:
mg_white += MG_ISOLATED_PAWN
eg_white += EG_ISOLATED_PAWN

# Passed pawn: no enemy pawns on same or adjacent files that can block/capture
is_passed = True
check_files = (f,) + ADJACENT_FILES[f]
for cf in check_files:
for bsq in black_pawns:
bf = bsq % 8
br = bsq // 8 + 1
if bf == cf and br > r: # black pawn ahead of white pawn
is_passed = False
break
if not is_passed:
break
if is_passed and 2 <= r <= 7:
mg_white += MG_PASSED_PAWN_BONUS[r]
eg_white += EG_PASSED_PAWN_BONUS[r]

# Black pawns
for sq in black_pawns:
f = sq % 8
r = sq // 8 + 1 # rank 1-8

# Doubled pawn
if black_pawns_per_file[f] > 1:
mg_black += MG_DOUBLED_PAWN
eg_black += EG_DOUBLED_PAWN

# Isolated pawn
has_adjacent = any(af in black_pawn_files for af in ADJACENT_FILES[f])
if not has_adjacent:
mg_black += MG_ISOLATED_PAWN
eg_black += EG_ISOLATED_PAWN

# Passed pawn (for black, no white pawns ahead = lower rank number)
is_passed = True
check_files = (f,) + ADJACENT_FILES[f]
for cf in check_files:
for wsq in white_pawns:
wf = wsq % 8
wr = wsq // 8 + 1
if wf == cf and wr < r: # white pawn ahead of black pawn
is_passed = False
break
if not is_passed:
break
if is_passed and 2 <= r <= 7:
# For black, rank 7 is closest to promotion (like white rank 2)
# Mirror the rank: black rank 7 -> index 2, rank 2 -> index 7
bonus_rank = 9 - r
mg_black += MG_PASSED_PAWN_BONUS[bonus_rank]
eg_black += EG_PASSED_PAWN_BONUS[bonus_rank]

# --- Bishop pair bonus ---
if white_bishop_count >= 2:
mg_white += MG_BISHOP_PAIR_BONUS
eg_white += EG_BISHOP_PAIR_BONUS
if black_bishop_count >= 2:
mg_black += MG_BISHOP_PAIR_BONUS
eg_black += EG_BISHOP_PAIR_BONUS

# --- Rook on open/semi-open file ---
for sq in white_rooks:
f = sq % 8
if f not in white_pawn_files:
if f not in black_pawn_files:
# Open file: no pawns at all
mg_white += MG_ROOK_OPEN_FILE
eg_white += EG_ROOK_OPEN_FILE
else:
# Semi-open file: no friendly pawns
mg_white += MG_ROOK_SEMI_OPEN_FILE
eg_white += EG_ROOK_SEMI_OPEN_FILE

for sq in black_rooks:
f = sq % 8
if f not in black_pawn_files:
if f not in white_pawn_files:
mg_black += MG_ROOK_OPEN_FILE
eg_black += EG_ROOK_OPEN_FILE
else:
mg_black += MG_ROOK_SEMI_OPEN_FILE
eg_black += EG_ROOK_SEMI_OPEN_FILE

# calculate board score based on phase
if board.turn == chess.WHITE:
Expand Down