From 1eebe9f7f5695555ff866ac0317311da4246cbfb Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 23 Aug 2025 19:49:50 +0200 Subject: [PATCH] feat: remove entire looks blunderish logic --- PYTHON/lichess_bot/engine.py | 557 +++++++++++------------------------ 1 file changed, 168 insertions(+), 389 deletions(-) diff --git a/PYTHON/lichess_bot/engine.py b/PYTHON/lichess_bot/engine.py index 1887fff..e393f11 100644 --- a/PYTHON/lichess_bot/engine.py +++ b/PYTHON/lichess_bot/engine.py @@ -311,20 +311,6 @@ class RandomEngine: mv = self.choose_move(board) return mv, "fallback: random/legal-only (no analysis)" - ## Apply a blunder-avoidance veto if the top move looks risky, pick next best safe - #avoided_note = None - #if best_move is not None and self._looks_blunderish(board, best_move): - # avoided_note = f"avoided risky {board.san(best_move)} ({best_move.uci()})" - # for cand, _ in scores[1:]: - # if not self._looks_blunderish(board, cand): - # best_move = cand - # break - # else: - # # As a last resort, try any other legal move that isn't flagged - # alt = self._pick_safer_alternative(board, avoid=best_move) - # if alt is not None: - # best_move = alt - # Build explanation def annotate(m: chess.Move) -> str: return self._annotate_move_simple(board, m) @@ -336,8 +322,6 @@ class RandomEngine: f"source=search depth={depth_used} time={elapsed:.2f}s nodes={getattr(self, '_nodes', 0)} candidates={len(scores)}", f"best {board.san(top[0][0])} ({top[0][0].uci()}) score={best_cp:.1f} reasons=[{annotate(top[0][0])}]", ] - #if avoided_note: - # lines.append(avoided_note) if len(top) > 1: lines.append("alternatives:") for mv, sc in top[1:]: @@ -408,6 +392,174 @@ class RandomEngine: return best_move, "\n".join(lines) + def evaluate_proposed_move_with_suggestion( + self, + state: "chess.Board | str", + proposed_move: "chess.Move | str", + time_budget_sec: Optional[float] = None, + ) -> Tuple[Optional[float], str, Optional[chess.Move], str]: + """Evaluate a proposed move on a given position and also propose the engine's move. + + Inputs: + - state: either a chess.Board or a FEN string representing the current position. + - proposed_move: a chess.Move object or a string (UCI preferred, SAN fallback). + - time_budget_sec: optional time limit for each analysis pass. + + Returns a tuple: (proposed_score_cp, proposed_explanation, engine_move, engine_explanation) + - proposed_score_cp: search score (centipawns, mover POV) for the proposed move, or None if illegal/unavailable. + - proposed_explanation: a detailed, human-readable breakdown of where the score came from. + - engine_move: the move our engine would play from this position. + - engine_explanation: the usual explanation for the engine's chosen move. + """ + # Normalize board + if isinstance(state, chess.Board): + board = state.copy(stack=False) + else: + try: + board = chess.Board(state) + except Exception: + # If FEN parsing fails, assume start position + board = chess.Board() + + # Parse proposed move + def _parse_move(b: chess.Board, mv: "chess.Move | str") -> Optional[chess.Move]: + if isinstance(mv, chess.Move): + return mv if mv in b.legal_moves else None + # Try UCI first + try: + m = chess.Move.from_uci(str(mv).strip()) + if m in b.legal_moves: + return m + except Exception: + pass + # Try SAN + try: + m = b.parse_san(str(mv).strip()) + if m in b.legal_moves: + return m + except Exception: + pass + return None + + pmove = _parse_move(board, proposed_move) + + # Evaluate proposed move (ensure we set a deadline specifically for this pass) + start = time.time() + budget = time_budget_sec if time_budget_sec is not None else min(1.5, self.max_time_sec) + self._deadline = start + max(0.01, budget) + self._nodes = 0 + + proposed_score: Optional[float] = None + if pmove is None: + # Still compute our own suggestion below + proposed_expl = ( + "source=search\nillegal_or_unavailable_move=true\n" + "note=The proposed move is not legal in the provided position." + ) + else: + # Compute search score for the specific move + # Use full alpha-beta for consistency with choose_move_with_explanation + # Depth is limited by deadline + depth_used = 0 + best_score_for_move: Optional[float] = None + # A tiny iterative deepening loop focused on the single move + for d in range(1, self.depth + 1): + if time.time() >= self._deadline: + break + depth_used = d + board.push(pmove) + try: + sc = -self._alphabeta(board, d - 1, -float("inf"), float("inf"), start) + finally: + board.pop() + best_score_for_move = sc + proposed_score = best_score_for_move + + # Build a detailed breakdown similar to choose_move_with_explanation + def _move_breakdown(b: chess.Board, m: chess.Move, search_score: float) -> str: + # SEE and risk + try: + see_val = int(self._see_value(b, m)) + except Exception: + see_val = 0 + risk_total = self._risk_score(b, m) + try: + risk_qtrap = self._queen_trap_risk(b, m) + except Exception: + risk_qtrap = 0 + risk_bxf = 600 if (self._is_early_game(b) and self._is_bishop_sac_on_f2f7(b, m)) else 0 + + # Static eval components pre/post (mover perspective) + pre_white_score, pre_comp = self._evaluate_components(b) + pre_stm = pre_white_score if b.turn == chess.WHITE else -pre_white_score + + b.push(m) + try: + post_white_score, post_comp = self._evaluate_components(b) + post_stm = - (post_white_score if b.turn == chess.WHITE else -post_white_score) + finally: + b.pop() + + tactical_delta = search_score - post_stm + + def fmt_comps(prefix: str, comp: dict, white_score_val: float, stm_val: float) -> list[str]: + parts = [] + parts.append(f"{prefix}: stm_eval={stm_val:.1f} (from white_score={white_score_val:.1f} {'as-is' if (prefix=='pre') else 'flipped to mover'})") + parts.append(" components (white-centric):") + parts.append(f" material={comp['material']}") + parts.append(f" doubled_pawns_term={comp['doubled_pawns_term']}") + parts.append(f" mobility_term={comp['mobility_term']}") + parts.append(f" mobility_white={comp['mob_w']} mobility_black={comp['mob_b']}") + parts.append(f" center_score={comp['center_score']}") + parts.append(f" rook_file_bonus={comp['rook_file_bonus']}") + parts.append(f" king_safety={comp['safety']}") + parts.append(f" queen_raid_penalty={comp['queen_raid_pen']}") + parts.append(f" piece_square_table={comp['pst']}") + parts.append(f" hanging_pieces_term={comp['hanging_pieces_term']}") + return parts + + pre_lines = fmt_comps("pre", pre_comp, pre_white_score, pre_stm) + post_lines = fmt_comps("post", post_comp, post_white_score, post_stm) + + elapsed = time.time() - start + nodes = getattr(self, "_nodes", 0) + mv_san = None + try: + mv_san = b.san(m) + except Exception: + pass + ann = self._annotate_move_simple(b, m) + lines = [ + f"source=search depth={depth_used} time={elapsed:.2f}s nodes={nodes}", + f"move {mv_san if mv_san else ''} ({m.uci()}) score={search_score:.1f} reasons=[{ann}]", + "details:", + f" see={see_val}", + f" risk_total={risk_total}", + f" risk_queen_trap={risk_qtrap}", + f" risk_bishop_sac_f2f7={risk_bxf}", + f" pre_static_eval: {pre_stm:.1f}", + f" post_static_eval: {post_stm:.1f}", + f" search_score: {search_score:.1f}", + f" tactical_delta: {tactical_delta:+.1f}", + ] + lines.extend(pre_lines) + lines.extend(post_lines) + return "\n".join(lines) + + if proposed_score is None: + proposed_expl = ( + "source=search\nunable_to_compute_score=true\n" + "note=Time limit reached before evaluating the proposed move." + ) + else: + proposed_expl = _move_breakdown(board, pmove, proposed_score) + + # Now get the engine's own suggestion (separate pass to keep API simple) + eng_budget = time_budget_sec if time_budget_sec is not None else min(1.5, self.max_time_sec) + move_suggestion, engine_expl = self.choose_move_with_explanation(board, time_budget_sec=eng_budget) + + return proposed_score, proposed_expl, move_suggestion, engine_expl + def _analyze_root(self, board: chess.Board, depth: int, start: float) -> list[Tuple[chess.Move, float]]: alpha = -float("inf") beta = float("inf") @@ -1184,376 +1336,3 @@ class RandomEngine: finally: board.pop() return risk - - # --- Blunder veto helpers --- - def _looks_blunderish(self, board: chess.Board, move: chess.Move) -> bool: - """Lightweight tactical sanity checks to avoid common blunders. - - Heuristics: - - Use a small DB of logged blunders - - Negative SEE on the move beyond a threshold (typically losing material) - - Large immediate static-eval drop - - Opponent has mate-in-1 or many forcing checks - - Opponent has profitable captures right away - - Destination under-defended and opponent can capture for non-negative SEE - - Tiny depth-2 probe is very favorable for the opponent - - Non-forced king moves in middlegame - """ - # Known logged blunder? - try: - if self._is_logged_blunder(board, move): - return True - except Exception: - pass - - # Strongly negative SEE on our own move - try: - see = int(self._see_value(board, move)) - except Exception: - see = 0 - is_cap = board.is_capture(move) - if see <= -120: - return True - # Pawns: non-capture push into under-defended square is often a blunder even at milder SEE - pc0 = board.piece_at(move.from_square) - if pc0 and pc0.piece_type == chess.PAWN and not board.is_capture(move) and not move.promotion: - if see <= -60: - return True - from_file = chess.square_file(move.from_square) - if from_file in (0, 7): - heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK)) - if self._is_early_game(board) or heavy_pieces >= 2: - if see < 0: - return True - - # Pre-move static eval (from mover perspective) - pre_eval = self._evaluate(board) - - board.push(move) - try: - # Post-move eval (flip sign back to mover perspective) - post_eval_for_opp = self._evaluate(board) - post_eval_for_us = -post_eval_for_opp - # For captures with non-negative SEE, require a much larger immediate static drop to flag as a blunder - drop_thresh = 350 if (is_cap and see >= 0) else 220 - if post_eval_for_us < pre_eval - drop_thresh: - return True - - # Opponent mate-in-1? - for opp in board.legal_moves: - board.push(opp) - try: - if board.is_checkmate(): - return True - finally: - board.pop() - - # Forcing checks right away - check_count = 0 - heavy_check = False - my_color = not board.turn - for opp in board.legal_moves: - try: - if board.gives_check(opp): - check_count += 1 - pc = board.piece_at(opp.from_square) - if pc and pc.piece_type in (chess.QUEEN, chess.ROOK, chess.BISHOP): - heavy_check = True - if board.is_capture(opp) or opp.promotion: - heavy_check = True - except Exception: - pass - # For captures that pass SEE, be less sensitive to raw check counts - if (not (is_cap and see >= 0) and (check_count >= 2 or (check_count >= 1 and heavy_check))) or ((is_cap and see >= 0) and check_count >= 3): - return True - - # King exits limited after check presence - ksq = board.king(my_color) - if ksq is not None and check_count >= 1: - king_exits = 0 - for m in board.legal_moves: - if m.from_square == ksq and not board.is_capture(m): - king_exits += 1 - if king_exits >= 2: - break - if king_exits <= 1: - return True - - # Under-defended destination - moved_piece = board.piece_at(move.to_square) - if moved_piece: - # If this was a capture with non-negative SEE, trust SEE over crude defenders/attackers - if not (is_cap and see >= 0): - attackers_op = len(board.attackers(not my_color, move.to_square)) - defenders_me = len(board.attackers(my_color, move.to_square)) - if attackers_op >= max(1, defenders_me): - for opp in board.legal_moves: - if opp.to_square == move.to_square and board.is_capture(opp): - try: - opp_see2 = int(self._see_value(board, opp)) - except Exception: - opp_see2 = 0 - if opp_see2 >= 0: - return True - - # Greedy corner-rook takes by minors are often traps (similar to queen corner traps) - if is_cap: - moved_by = board.piece_at(move.to_square) - from_pc = board.piece_at(move.from_square) if moved_by is None else moved_by # after push, piece on to_square - victim_pre = None - # Reconstruct victim type by undoing and checking - board.pop() - try: - victim_pre = board.piece_at(move.to_square) - finally: - board.push(move) - if from_pc and from_pc.piece_type in (chess.KNIGHT, chess.BISHOP) and victim_pre and victim_pre.piece_type == chess.ROOK and move.to_square in {chess.A8, chess.H8, chess.A1, chess.H1}: - # If destination is under-defended OR the minor has very limited safe exits, treat as blunderish - attackers = len(board.attackers(not my_color, move.to_square)) - defenders = len(board.attackers(my_color, move.to_square)) - # Count safe exits for the minor from the corner square - safe_exits = 0 - for esc in board.legal_moves: - if esc.from_square == move.to_square: - # Avoid landing on attacked squares - if not board.is_attacked_by(not my_color, esc.to_square): - safe_exits += 1 - if safe_exits >= 3: - break - if attackers >= max(1, defenders) or safe_exits <= 1: - return True - - # Unsafe promotions to queen: if the new queen square is under-defended, treat as blunderish - if move.promotion == chess.QUEEN: - qsq = move.to_square - attackers = len(board.attackers(not my_color, qsq)) - defenders = len(board.attackers(my_color, qsq)) - if attackers >= max(1, defenders): - return True - - # Rook-pawn loosening immediately increasing own hanging penalties is a red flag - mover_from_file = chess.square_file(move.from_square) - if moved_piece and moved_piece.piece_type == chess.PAWN and mover_from_file in (0, 7) and not board.is_capture(move) and not move.promotion: - _, pre_comp = self._evaluate_components(board) # board here is after push - pre_hanging_term = pre_comp.get("hanging_pieces_term", 0) - # Undo move to get baseline, then re-push for state safety - board.pop() - try: - _, base_comp = self._evaluate_components(board) - base_hanging_term = base_comp.get("hanging_pieces_term", 0) - finally: - board.push(move) - # If our side's hanging penalty worsened notably, treat as blunder-ish - # Remember: components are white-centric; flip based on mover color - delta_hang_white = pre_hanging_term - base_hanging_term - worsen = (delta_hang_white < -80) if my_color == chess.WHITE else (delta_hang_white > 80) - if worsen: - return True - - # Tiny probe depth-2 for opponent side - try: - old_deadline = getattr(self, "_deadline", None) - self._deadline = time.time() + 0.03 - probe = self._alphabeta(board, 2, -float('inf'), float('inf'), time.time()) - if old_deadline is not None: - self._deadline = old_deadline - # For safe captures (SEE >= 0), require a much stronger opponent probe to flag as blunder - # Only relax the tiny-probe threshold for queen captures that already passed SEE - relax = is_cap and see >= 0 and board.piece_at(move.from_square) and board.piece_at(move.from_square).piece_type == chess.QUEEN - threshold = 450 if relax else 250 - if probe >= threshold: - return True - except Exception: - try: - if old_deadline is not None: - self._deadline = old_deadline - except Exception: - pass - finally: - board.pop() - - # Non-forced king move in middlegame - pc0 = board.piece_at(move.from_square) - if pc0 and pc0.piece_type == chess.KING and not board.is_castling(move): - # If we're currently in check, allow king moves (forced). Otherwise, scrutinize. - if not board.is_check(): - # Penalize king moves that keep the king in the center early or that stay on the same file/rank while heavy pieces remain. - heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK)) - if self._is_early_game(board) or heavy_pieces >= 2: - # Compute a crude safety trend: if post static eval (mover perspective) does not improve and king remains central/edge-trapped, veto. - pre_eval = self._evaluate(board) - board.push(move) - try: - post_eval_for_opp = self._evaluate(board) - post_eval_for_us = -post_eval_for_opp - to_sq = move.to_square - file_idx = chess.square_file(to_sq) - rank_idx = chess.square_rank(to_sq) - centralish = (file_idx in (3, 4)) and (rank_idx in (2, 3, 4, 5)) - stays_corner = (to_sq in {chess.A1, chess.H1, chess.A8, chess.H8}) - # If not improving eval and moving into/remaining in danger zones, veto. - if post_eval_for_us <= pre_eval and (not centralish or stays_corner): - return True - finally: - board.pop() - # Generic: non-forced king shuffle is discouraged - return True - return False - - def _pick_safer_alternative(self, board: chess.Board, avoid: Optional[chess.Move] = None) -> Optional[chess.Move]: - """Pick a safer alternative move using ordering heuristics, avoiding a specific move if provided. - - Prefers non-logged, non-blunderish moves; falls back to the least-risk option. - """ - moves = [m for m in self._ordered_moves(board) if avoid is None or m != avoid] - if not moves: - return None - - def is_non_forced_king_move(mv: chess.Move) -> bool: - pc = board.piece_at(mv.from_square) - if not pc or pc.piece_type != chess.KING: - return False - if board.is_castling(mv): - return False - # Non-check, non-capture king moves are considered non-forced - if board.is_capture(mv): - return False - try: - if board.gives_check(mv): - return False - except Exception: - pass - # Avoid passive king shuffles when heavy pieces remain - heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK)) - return heavy_pieces >= 2 or self._is_early_game(board) - - def is_plain_pawn_push(mv: chess.Move) -> bool: - pc = board.piece_at(mv.from_square) - if not pc or pc.piece_type != chess.PAWN: - return False - if board.is_capture(mv) or mv.promotion: - return False - try: - if board.gives_check(mv): - return False - except Exception: - pass - return True - - # First pass: strictly avoid logged blunders and blunderish moves; also skip passive king shuffles if possible - # Prefer SEE-sound captures with the lowest risk (avoid unsafe queen promotions) - safe_caps: list[tuple[int, chess.Move]] = [] - for m in moves: - if board.is_capture(m): - try: - see_m = int(self._see_value(board, m)) - except Exception: - see_m = 0 - if see_m >= 0: - try: - if self._is_logged_blunder(board, m): - continue - except Exception: - pass - try: - r = self._risk_score(board, m) - except Exception: - r = 9999 - # Small tie-breaker: prefer non-queen promotions over queen when risks tie - if m.promotion == chess.QUEEN: - r += 50 - safe_caps.append((r, m)) - if safe_caps: - safe_caps.sort(key=lambda t: t[0]) - return safe_caps[0][1] - - # Otherwise look for solid non-captures following safety heuristics - for m in moves: - try: - if self._is_logged_blunder(board, m): - continue - except Exception: - pass - if is_non_forced_king_move(m): - continue - # If many minors are undeveloped, prefer non-pawn moves first - mover = board.piece_at(m.from_square) - if mover and self._most_minors_undeveloped(board, mover.color) and is_plain_pawn_push(m): - continue - # Prefer captures or developing moves over rook-pawn pushes - if mover and mover.piece_type == chess.PAWN and is_plain_pawn_push(m): - from_file = chess.square_file(m.from_square) - if from_file in (0, 7): - continue - if not self._looks_blunderish(board, m): - return m - - # Second pass: choose least-risk non-logged, non-blunderish move; allow passive king last - scored: list[tuple[int, chess.Move]] = [] - for m in moves: - try: - if self._is_logged_blunder(board, m): - continue - except Exception: - pass - # Skip blunderish moves outright, but allow passive king moves as last resort (still add risk) - try: - bl = self._looks_blunderish(board, m) - except Exception: - bl = False - if bl and not is_non_forced_king_move(m): - continue - try: - r = self._risk_score(board, m) - except Exception: - r = 9999 - # Slightly inflate risk for passive king moves to avoid endless shuffling when heavy pieces remain - if is_non_forced_king_move(m): - r += 250 - # Inflate risk for plain pawn pushes if minors are still undeveloped - mover = board.piece_at(m.from_square) - if mover and self._most_minors_undeveloped(board, mover.color) and is_plain_pawn_push(m): - r += 180 - # Extra risk for rook-pawn pushes in general - if mover and mover.piece_type == chess.PAWN and is_plain_pawn_push(m): - if chess.square_file(m.from_square) in (0, 7): - r += 120 - # And extra for SEE-negative plain pawn advances - try: - see_m = int(self._see_value(board, m)) - except Exception: - see_m = 0 - if see_m < 0: - r += 220 - scored.append((r, m)) - if scored: - scored.sort(key=lambda t: t[0]) - return scored[0][1] - - # Last resort: still avoid logged blunders if at all possible - non_logged = [] - for m in moves: - try: - if self._is_logged_blunder(board, m): - continue - except Exception: - pass - non_logged.append(m) - if not non_logged and avoid is not None: - # Better to take the previously avoided (but not logged) move than a known logged blunder - try: - if not self._is_logged_blunder(board, avoid): - return avoid - except Exception: - return avoid - target_pool = non_logged if non_logged else moves - try: - target_pool.sort(key=lambda m: self._risk_score(board, m)) - except Exception: - pass - return target_pool[0] - - def _is_logged_blunder(self, board: chess.Board, move: chess.Move) -> bool: - fen = board.fen() - bad = self._logged_blunders.get(fen) - return bool(bad and move.uci() in bad)