{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "IImo3GGDzbtq"
      },
      "source": [
        "# imports and installations"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "JNuSWy_FzhK6",
        "outputId": "e3f931a4-57f2-4f79-8adc-f2d9f0620c57"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Requirement already satisfied: chess in /usr/local/lib/python3.12/dist-packages (1.11.2)\n",
            "Requirement already satisfied: python-chess in /usr/local/lib/python3.12/dist-packages (1.999)\n",
            "Requirement already satisfied: chess<2,>=1 in /usr/local/lib/python3.12/dist-packages (from python-chess) (1.11.2)\n",
            "Hit:1 https://cloud.r-project.org/bin/linux/ubuntu jammy-cran40/ InRelease\n",
            "Hit:2 https://cli.github.com/packages stable InRelease\n",
            "Hit:3 https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64  InRelease\n",
            "Hit:4 http://security.ubuntu.com/ubuntu jammy-security InRelease\n",
            "Hit:5 http://archive.ubuntu.com/ubuntu jammy InRelease\n",
            "Hit:6 https://r2u.stat.illinois.edu/ubuntu jammy InRelease\n",
            "Hit:7 http://archive.ubuntu.com/ubuntu jammy-updates InRelease\n",
            "Hit:8 http://archive.ubuntu.com/ubuntu jammy-backports InRelease\n",
            "Hit:9 https://ppa.launchpadcontent.net/deadsnakes/ppa/ubuntu jammy InRelease\n",
            "Hit:10 https://ppa.launchpadcontent.net/graphics-drivers/ppa/ubuntu jammy InRelease\n",
            "Hit:11 https://ppa.launchpadcontent.net/ubuntugis/ppa/ubuntu jammy InRelease\n",
            "Reading package lists... Done\n",
            "Building dependency tree... Done\n",
            "Reading state information... Done\n",
            "44 packages can be upgraded. Run 'apt list --upgradable' to see them.\n",
            "\u001b[1;33mW: \u001b[0mSkipping acquire of configured file 'main/source/Sources' as repository 'https://r2u.stat.illinois.edu/ubuntu jammy InRelease' does not seem to provide it (sources.list entry misspelt?)\u001b[0m\n",
            "Reading package lists... Done\n",
            "Building dependency tree... Done\n",
            "Reading state information... Done\n",
            "stockfish is already the newest version (14.1-1).\n",
            "0 upgraded, 0 newly installed, 0 to remove and 44 not upgraded.\n",
            "Hit:1 https://cli.github.com/packages stable InRelease\n",
            "Hit:2 https://cloud.r-project.org/bin/linux/ubuntu jammy-cran40/ InRelease\n",
            "Hit:3 https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64  InRelease\n",
            "Hit:4 http://archive.ubuntu.com/ubuntu jammy InRelease\n",
            "Hit:5 http://archive.ubuntu.com/ubuntu jammy-updates InRelease\n",
            "Hit:6 http://security.ubuntu.com/ubuntu jammy-security InRelease\n",
            "Hit:7 http://archive.ubuntu.com/ubuntu jammy-backports InRelease\n",
            "Hit:8 https://r2u.stat.illinois.edu/ubuntu jammy InRelease\n",
            "Hit:9 https://ppa.launchpadcontent.net/deadsnakes/ppa/ubuntu jammy InRelease\n",
            "Hit:10 https://ppa.launchpadcontent.net/graphics-drivers/ppa/ubuntu jammy InRelease\n",
            "Hit:11 https://ppa.launchpadcontent.net/ubuntugis/ppa/ubuntu jammy InRelease\n",
            "Reading package lists... Done\n",
            "Building dependency tree... Done\n",
            "Reading state information... Done\n",
            "44 packages can be upgraded. Run 'apt list --upgradable' to see them.\n",
            "\u001b[1;33mW: \u001b[0mSkipping acquire of configured file 'main/source/Sources' as repository 'https://r2u.stat.illinois.edu/ubuntu jammy InRelease' does not seem to provide it (sources.list entry misspelt?)\u001b[0m\n",
            "Reading package lists... Done\n",
            "Building dependency tree... Done\n",
            "Reading state information... Done\n",
            "stockfish is already the newest version (14.1-1).\n",
            "0 upgraded, 0 newly installed, 0 to remove and 44 not upgraded.\n"
          ]
        }
      ],
      "source": [
        "! pip install chess\n",
        "! pip install python-chess\n",
        "! sudo apt update\n",
        "! sudo apt install stockfish\n",
        "!apt update\n",
        "!apt install -y stockfish"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "dvAewt_Sza37"
      },
      "outputs": [],
      "source": [
        "import pandas as pd\n",
        "import re\n",
        "import chess\n",
        "import chess.pgn\n",
        "from io import StringIO\n",
        "import re\n",
        "from typing import List, Tuple\n",
        "import io"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "shqIMv3xzqjY"
      },
      "source": [
        "# motifs"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "qKZjFZHmAMwy"
      },
      "source": [
        "## game modification for board\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "G48de-NiAUXb",
        "outputId": "7eb5c4f0-5d43-4731-c02b-5c91a1fa16b0"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "TAKE OUT DISAMBIG TAGS  1.  e4 1. ..  e6  2.  N f3 2. ..  c5  3.  N c3 3. ..  a6  4.  N e2 4. ..  d5  5.  N c3 5. ..  d4  6.  B d3 6. ..  b5  7.  B e2 7. .. d x  c3  8.  B x b5 + 8. .. a x  b5  9.  N e5 9. .. R a4  10.  N x f7 10. .. c x  b2  11.  B x b2 11. .. R  x  e4  +  12.  Q e2 12. .. R  x  e2  +  13.  K x e2 13. .. K  x  f7  14.  B x g7 14. .. K  x  g7  15.  R a d1 15. .. N f6  16.  R c1 16. .. Q d6  17.  R h e1 17. .. Q  x  h2  18.  R f1 18. .. N e4  19.  R f e1 19. .. Q  x  g2  20.  R b1 20. .. Q  x  f2  +  21.  K d1 21. .. Q  x  d2  #  \n",
            "NORMALIZED  1.  e4 1. ..  e6  2.  N f3 2. ..  c5  3.  N c3 3. ..  a6  4.  N e2 4. ..  d5  5.  N c3 5. ..  d4  6.  B d3 6. ..  b5  7.  B e2 7. .. d x  c3  8.  B x b5 + 8. .. a x  b5  9.  N e5 9. .. R a4  10.  N x f7 10. .. c x  b2  11.  B x b2 11. .. R  x  e4  +  12.  Q e2 12. .. R  x  e2  +  13.  K x e2 13. .. K  x  f7  14.  B x g7 14. .. K  x  g7  15.  R a d1 15. .. N f6  16.  R c1 16. .. Q d6  17.  R h e1 17. .. Q  x  h2  18.  R f1 18. .. N e4  19.  R f e1 19. .. Q  x  g2  20.  R b1 20. .. Q  x  f2  +  21.  K d1 21. .. Q  x  d2  #  \n",
            "MOVE PAIRS:  [('1', 'e4', 'e6')]\n",
            "\n",
            "Matched 1 move pairs:\n",
            "1. e4 .. e6\n",
            "[('e4', 'e6')]\n"
          ]
        },
        {
          "output_type": "execute_result",
          "data": {
            "text/plain": [
              "'[Event \"Test\"]\\n  [Site \"Local\"]\\n  [Date \"2025.08.04\"]\\n  [Round \"-\"]\\n  [White \"White\"]\\n  [Black \"Black\"]\\n  [Result \"1-0\"]\\n\\n  1. e4 e6 0-1'"
            ],
            "application/vnd.google.colaboratory.intrinsic+json": {
              "type": "string"
            }
          },
          "metadata": {},
          "execution_count": 5
        }
      ],
      "source": [
        "def format_pgn_string(raw):\n",
        "  print('TAKE OUT DISAMBIG TAGS ', raw)\n",
        "  #print(raw)\n",
        "  # Step 1: Normalize whitespace\n",
        "  normalized = re.sub(r'\\s+', ' ', raw.strip())\n",
        "  print('NORMALIZED ',raw)\n",
        "\n",
        "  #print(\"\\n--- Normalized string ---\\n\")\n",
        "  #print(normalized[:500])  # Print first 500 characters\n",
        "\n",
        "  # Step 2: Try a broader pattern: match \"<num>. <move> <same_num>. .. <move>\"\n",
        "  pattern = re.compile(r'(\\d+)\\.\\s+([^\\s]+)\\s+\\1\\.\\s+\\.\\.\\s+([^\\s]+)')\n",
        "  move_pairs = pattern.findall(normalized)\n",
        "  print('MOVE PAIRS: ',move_pairs)\n",
        "\n",
        "  print(f\"\\nMatched {len(move_pairs)} move pairs:\")\n",
        "  for i, (num, w, b) in enumerate(move_pairs[:5]):\n",
        "      print(f\"{num}. {w} .. {b}\")\n",
        "\n",
        "  if not move_pairs:\n",
        "      raise ValueError(\"Still no move pairs — copy-paste the normalized string here so I can fix it for you.\")\n",
        "\n",
        "  # Continue parsing only if matched\n",
        "  def sanitize(move):\n",
        "      return move.replace('+', '').replace('#', '')#.replace(' ','')\n",
        "\n",
        "  cleaned_moves = [(sanitize(w), sanitize(b)) for _, w, b in move_pairs]\n",
        "  print(cleaned_moves)\n",
        "\n",
        "  pgn_header = \"\"\"[Event \"Test\"]\n",
        "  [Site \"Local\"]\n",
        "  [Date \"2025.08.04\"]\n",
        "  [Round \"-\"]\n",
        "  [White \"White\"]\n",
        "  [Black \"Black\"]\n",
        "  [Result \"1-0\"]\n",
        "\n",
        "  \"\"\"\n",
        "  pgn_body = ' '.join(f\"{i+1}. {w} {b}\" for i, (w, b) in enumerate(cleaned_moves)) + \" 0-1\"\n",
        "  pgn_data = pgn_header + pgn_body\n",
        "  return pgn_data.replace('DISAMBIG_RANK_','').replace('DISAMBIG_FILE_','')\n",
        "\n",
        "\n",
        "raw = \"\"\"1.  e4 1. ..  e6  2.  N f3 2. ..  c5  3.  N c3 3. ..  a6  4.  N e2 4. ..  d5  5.  N c3 5. ..  d4  6.  B d3 6. ..  b5  7.  B e2 7. .. d x  c3  8.  B x b5 + 8. .. a x  b5  9.  N e5 9. .. R a4  10.  N x f7 10. .. c x  b2  11.  B x b2 11. .. R  x  e4  +  12.  Q e2 12. .. R  x  e2  +  13.  K x e2 13. .. K  x  f7  14.  B x g7 14. .. K  x  g7  15.  R a d1 15. .. N f6  16.  R c1 16. .. Q d6  17.  R h e1 17. .. Q  x  h2  18.  R f1 18. .. N e4  19.  R f e1 19. .. Q  x  g2  20.  R b1 20. .. Q  x  f2  +  21.  K d1 21. .. Q  x  d2  #  \"\"\"\n",
        "format_pgn_string(raw)"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "zjhxZp9EP-AB"
      },
      "outputs": [],
      "source": [
        "import chess\n",
        "\n",
        "def parse_chess_game(game_string):\n",
        "    \"\"\"\n",
        "    Parse chess game notation and return structured moves.\n",
        "\n",
        "    Args:\n",
        "        game_string (str): Chess game notation string\n",
        "\n",
        "    Returns:\n",
        "        list: List of tuples (move_number, white_move, black_move)\n",
        "    \"\"\"\n",
        "    # Clean up the string\n",
        "    game_string = ' '.join(game_string.split())\n",
        "\n",
        "    moves = []\n",
        "    move_number = 1\n",
        "\n",
        "    while True:\n",
        "        # Look for the current move number\n",
        "        white_start = f'{move_number}.'\n",
        "        black_start = f'{move_number}. ..'\n",
        "        next_move = f'{move_number + 1}.'\n",
        "\n",
        "        # Find positions\n",
        "        white_pos = game_string.find(white_start)\n",
        "        if white_pos == -1:\n",
        "            break\n",
        "\n",
        "        black_pos = game_string.find(black_start)\n",
        "        next_pos = game_string.find(next_move)\n",
        "\n",
        "        # Extract white moves\n",
        "        if black_pos != -1:\n",
        "            white_moves = game_string[white_pos + len(white_start):black_pos].strip()\n",
        "        elif next_pos != -1:\n",
        "            white_moves = game_string[white_pos + len(white_start):next_pos].strip()\n",
        "        else:\n",
        "            white_moves = game_string[white_pos + len(white_start):].strip()\n",
        "\n",
        "        # Extract black moves\n",
        "        black_moves = \"\"\n",
        "        if black_pos != -1:\n",
        "            if next_pos != -1:\n",
        "                black_moves = game_string[black_pos + len(black_start):next_pos].strip()\n",
        "            else:\n",
        "                black_moves = game_string[black_pos + len(black_start):].strip()\n",
        "\n",
        "        # Clean up moves (remove spaces and disambiguation markers)\n",
        "        white_clean = ' '.join(white_moves.split()).replace(' ','').replace('DISAMBIG_RANK_','').replace('DISAMBIG_FILE_','')\n",
        "        black_clean = ' '.join(black_moves.split()).replace(' ','').replace('DISAMBIG_RANK_','').replace('DISAMBIG_FILE_','')\n",
        "\n",
        "        moves.append((move_number, white_clean, black_clean))\n",
        "        move_number += 1\n",
        "\n",
        "    return moves\n",
        "\n",
        "def moves_to_pgn(move_stack):\n",
        "    \"\"\"\n",
        "    Convert a chess board's move stack to PGN notation.\n",
        "\n",
        "    Args:\n",
        "        move_stack: Chess board move stack\n",
        "\n",
        "    Returns:\n",
        "        str: PGN formatted string\n",
        "    \"\"\"\n",
        "    temp_board = chess.Board()\n",
        "    moves = []\n",
        "\n",
        "    for i, move in enumerate(move_stack):\n",
        "        # Convert to SAN notation\n",
        "        san_move = temp_board.san(move)\n",
        "        temp_board.push(move)\n",
        "\n",
        "        if i % 2 == 0:  # White move\n",
        "            moves.append(f\"{i//2 + 1}. {san_move}\")\n",
        "        else:  # Black move\n",
        "            moves[-1] += f\" {san_move}\"\n",
        "\n",
        "    return \" \".join(moves)\n",
        "\n",
        "def process_chess_game(game_string):\n",
        "    \"\"\"\n",
        "    Complete workflow: parse game string, play moves, and convert to PGN.\n",
        "\n",
        "    Args:\n",
        "        game_string (str): Chess game notation string\n",
        "\n",
        "    Returns:\n",
        "        tuple: (parsed_moves, chess_board, pgn_string)\n",
        "    \"\"\"\n",
        "    # Parse the game\n",
        "    parsed_moves = parse_chess_game(game_string)\n",
        "\n",
        "    # Create board and play moves\n",
        "    board = chess.Board()\n",
        "    for move_number, white, black in parsed_moves:\n",
        "        if white:  # Only push if move exists\n",
        "            board.push_san(white)\n",
        "        if black:  # Only push if move exists\n",
        "            board.push_san(black)\n",
        "\n",
        "    # Convert to PGN\n",
        "    pgn = moves_to_pgn(board.move_stack)\n",
        "\n",
        "    return pgn#parsed_moves, board, pgn\n",
        "\n",
        "# Example usage:\n",
        "#game = '1.  e4 1. ..  e6  2.  N f3 2. ..  c5  3.  N c3 3. ..  a6  4.  N e2 4. ..  d5  5.  N c3 5. ..  d4  6.  B d3 6. ..  b5  7.  B e2 7. .. d x  c3  8.  B x b5 + 8. .. a x  b5  9.  N e5 9. .. R a4  10.  N x f7 10. .. c x  b2  11.  B x b2 11. .. R  x  e4  +  12.  Q e2 12. .. R  x  e2  +  13.  K x e2 13. .. K  x  f7  14.  B x g7 14. .. K  x  g7  15.  R DISAMBIG_FILE_a d1 15. .. N f6  16.  R c1 16. .. Q d6  17.  R DISAMBIG_FILE_h e1 17. .. Q  x  h2  18.  R f1 18. .. N e4  19.  R DISAMBIG_FILE_f e1 19. .. Q  x  g2  20.  R b1 20. .. Q  x  f2  +  21.  K d1 21. .. Q  x  d2  #  '\n",
        "#pgn = process_chess_game(game)\n",
        "#print(\"Parsed moves:\", parsed_moves)\n",
        "#print(\"Final board position:\", board)\n",
        "#print(\"PGN:\", pgn)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "OwZJYpb-0mov"
      },
      "source": [
        "## simple stats -- game length, checks and castles\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "JPzlH3o23Zur"
      },
      "outputs": [],
      "source": [
        "def find_length_of_game(moves):\n",
        "\n",
        "  # Flatten to single list: [(white1, black1), (white2, black2), ...]\n",
        "  flat_moves = [move for pair in moves for move in pair]\n",
        "\n",
        "  # Max number of full moves\n",
        "  max_moves = len(moves)\n",
        "  return max_moves\n",
        "\n",
        "def find_num_checks(moves):\n",
        "\n",
        "  white_checks = sum('+' in white or '#' in white for white, _ in moves)\n",
        "\n",
        "  return white_checks\n",
        "\n",
        "\n",
        "def find_num_castles(moves):\n",
        "\n",
        "  castling = {\n",
        "      'white_kingside_castle': 0,\n",
        "      'white_queenside_castle': 0,\n",
        "      'black_kingside_castle': 0,\n",
        "      'black_queenside_castle': 0\n",
        "  }\n",
        "\n",
        "  for white, black in moves:\n",
        "      if white in ['O-O', '0-0']:\n",
        "          castling['white_kingside_castle'] += 1\n",
        "      elif white in ['O-O-O', '0-0-0']:\n",
        "          castling['white_queenside_castle'] += 1\n",
        "      if black in ['O-O', '0-0']:\n",
        "          castling['black_kingside_castle'] += 1\n",
        "      elif black in ['O-O-O', '0-0-0']:\n",
        "          castling['black_queenside_castle'] += 1\n",
        "\n",
        "  return castling\n",
        "\n",
        "\n",
        "def find_simple_stats(pgn):\n",
        "  moves = re.findall(r'\\d+\\.\\s*(.*?)\\s*\\d+\\.\\s*\\.\\.\\s*(.*?)(?=\\d+\\.|$)', pgn)\n",
        "  game_length = find_length_of_game(moves)\n",
        "  num_checks = find_num_checks(moves)\n",
        "  castles = find_num_castles(moves)\n",
        "  white_kingside_castle, white_queenside_castle = castles['white_kingside_castle'], castles['white_queenside_castle']\n",
        "  return {\n",
        "      'game_length':game_length,\n",
        "      'num_checks': num_checks,\n",
        "      'white_kingside_castle': white_kingside_castle,\n",
        "      'white_queenside_castle': white_queenside_castle\n",
        "      }\n",
        "\n",
        "\n",
        "#find_simple_stats(pgn)"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "2vqa5Rbd3aqY"
      },
      "source": [
        "## tactics -- pins, forks, skewers, and discovered attacks\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "IRbBUf209AvG"
      },
      "outputs": [],
      "source": [
        "BERLIN_TEST = \"\"\"\n",
        "[Event \"Casual Game\"]\n",
        "[Site \"Berlin GER\"]\n",
        "[Date \"1852.??.??\"]\n",
        "[Round \"?\"]\n",
        "[White \"Adolf Anderssen\"]\n",
        "[Black \"Jean Dufresne\"]\n",
        "[Result \"1-0\"]\n",
        "\n",
        "1.e4 e5 2.Nf3 Nc6 3.Bc4 Bc5 4.b4 Bxb4 5.c3 Ba5 6.d4 exd4 7.O-O d3 8.Qb3 Qf6 9.e5 Qg6 10.Re1 Nge7 11.Ba3 b5 12.Qxb5 Rb8 13.Qa4 Bb6 14.Nbd2 Bb7 15.Ne4 Qf5 16.Bxd3 Qh5 17.Nf6+ gxf6 18.exf6 Rg8 19.Rad1 Qxf3 20.Rxe7+ Nxe7 21.Qxd7+ Kxd7 22.Bf5+ Ke8 23.Bd7+ Kf8 24.Bxe7# 1-0\n",
        "\"\"\"\n",
        "\n",
        "PIN_TEST = \"\"\"\n",
        "[Event \"Pin Exists After White Move\"]\n",
        "[Site \"?\"]\n",
        "[Date \"2025.08.10\"]\n",
        "[Round \"-\"]\n",
        "[White \"White\"]\n",
        "[Black \"Black\"]\n",
        "[Result \"*\"]\n",
        "\n",
        "1. e4 d6 2. d4 Nc6 3. Bb5 *\n",
        "\"\"\"\n",
        "\n",
        "FORK_TEST = \"\"\"\n",
        "[Event \"Knight Fork After White Move\"]\n",
        "[Site \"?\"]\n",
        "[Date \"2025.08.10\"]\n",
        "[Round \"-\"]\n",
        "[White \"White\"]\n",
        "[Black \"Black\"]\n",
        "[Result \"*\"]\n",
        "\n",
        "1. e4 e5 2. Nf3 Nc6 3. Na3 Nf6 4. Nb5 a6 5. Nxc7+ Qxc7 *\n",
        "\"\"\"\n",
        "\n",
        "SIM_TEST = \"\"\"\n",
        "[Event \"Testing Simulation\"]\n",
        "[Site \"?\"]\n",
        "[Date \"2025.08.10\"]\n",
        "[Round \"-\"]\n",
        "[White \"White\"]\n",
        "[Black \"Black\"]\n",
        "[Result \"*\"]\n",
        "\n",
        "1. e4 e6 2. Nf3 c5 3. Nc3 a6 4. Ne2 d5 5. Nc3 d4 6. Bd3 b5 7. Be2 dxc3 8. Bxb5+ axb5 9. Ne5 Ra4 10. Nxf7 cxb2 11. Bxb2 Rxe4+ 12. Qe2 Rxe2+ 13. Kxe2 Kxf7 14. Bxg7 Kxg7 15. Rad1 Nf6 16. Rc1 Qd6 17. Rhe1 Qxh2 18. Rf1 Ne4 19. Rfe1 Qxg2 20. Rb1 Qxf2+ 21. Kd1 Qxd2#\n",
        "\"\"\""
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "hyOZXnfa--g-",
        "outputId": "992345a6-45e3-454c-86b7-34108cadf822"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "{'white_forks': 1, 'black_forks': 0, 'white_pins': 1, 'black_pins': 4, 'white_skewers': 3, 'black_skewers': 4, 'white_discovered_attacks': 9, 'black_discovered_attacks': 3}\n",
            "{'white_forks': 0, 'black_forks': 0, 'white_pins': 1, 'black_pins': 0, 'white_skewers': 0, 'black_skewers': 0, 'white_discovered_attacks': 0, 'black_discovered_attacks': 0}\n",
            "{'white_forks': 1, 'black_forks': 0, 'white_pins': 0, 'black_pins': 0, 'white_skewers': 0, 'black_skewers': 0, 'white_discovered_attacks': 1, 'black_discovered_attacks': 0}\n",
            "{'white_forks': 1, 'black_forks': 1, 'white_pins': 0, 'black_pins': 1, 'white_skewers': 2, 'black_skewers': 1, 'white_discovered_attacks': 5, 'black_discovered_attacks': 4}\n"
          ]
        }
      ],
      "source": [
        "# ---------- core detectors (return SETS) ----------\n",
        "def forks_set(board: chess.Board, color: bool, include_pawn_targets: bool=False):\n",
        "    \"\"\"Return a set of forking descriptions for `color` in the *current* position.\n",
        "       Each item is (forking_src_square, frozenset(target_squares)).\n",
        "       Targets: enemy pieces; by default, ignore pawns as targets.\n",
        "    \"\"\"\n",
        "    enemy = not color\n",
        "    out = set()\n",
        "\n",
        "    for src in chess.SQUARES:\n",
        "        p = board.piece_at(src)\n",
        "        if not p or p.color != color:\n",
        "            continue\n",
        "\n",
        "        targets = []\n",
        "        for dst in board.attacks(src):\n",
        "            q = board.piece_at(dst)\n",
        "            if q and q.color == enemy:\n",
        "                if include_pawn_targets or q.piece_type != chess.PAWN:\n",
        "                    # Check if target is undefended or less valuable than attacker\n",
        "                    defenders = len(board.attackers(enemy, dst))\n",
        "                    if defenders == 0 or _PVAL[q.piece_type] > _PVAL[p.piece_type]:\n",
        "                        targets.append(dst)\n",
        "\n",
        "        # Only count as fork if we're gaining material or attacking valuable pieces\n",
        "        if len(targets) >= 2:\n",
        "            target_values = [board.piece_at(sq).piece_type for sq in targets]\n",
        "            if any(pt in (chess.KING, chess.QUEEN, chess.ROOK) for pt in target_values):\n",
        "                out.add((src, frozenset(targets)))\n",
        "\n",
        "    return out\n",
        "\n",
        "\n",
        "def absolute_pins_set(board: chess.Board, color: bool):\n",
        "    \"\"\"Return a set of ABSOLUTE pins *created by* `color` (i.e., against the enemy king).\n",
        "       Each item is the square of the pinned enemy piece (victim_sq).\n",
        "       We verify the pinner is a sliding piece on the same ray toward the enemy king.\n",
        "    \"\"\"\n",
        "    enemy = not color\n",
        "    out = set()\n",
        "    ksq = board.king(enemy)\n",
        "    if ksq is None:\n",
        "        return out\n",
        "\n",
        "    # helper: one-step direction from a->b if aligned (rank/file/diag), else None\n",
        "    def step_dir(a, b):\n",
        "        df = (b % 8) - (a % 8)\n",
        "        dr = (b // 8) - (a // 8)\n",
        "        if df == 0 and dr != 0:\n",
        "            return 8 if dr > 0 else -8\n",
        "        if dr == 0 and df != 0:\n",
        "            return 1 if df > 0 else -1\n",
        "        if abs(df) == abs(dr) and df != 0:\n",
        "            return (1 if df > 0 else -1) + (8 if dr > 0 else -8)\n",
        "        return None\n",
        "\n",
        "    for sq in chess.SQUARES:\n",
        "        v = board.piece_at(sq)\n",
        "        if not v or v.color != enemy:\n",
        "            continue\n",
        "        # quick absolute pin test\n",
        "        if not board.is_pinned(enemy, sq):\n",
        "            continue\n",
        "\n",
        "        # verify the pinner exists on the ray opposite the king\n",
        "        d = step_dir(sq, ksq)\n",
        "        if d is None:\n",
        "            continue\n",
        "        cur = sq - d               # go away from king, through victim, toward pinner\n",
        "        while 0 <= cur < 64:\n",
        "            piece = board.piece_at(cur)\n",
        "            if piece:\n",
        "                if piece.color == color and piece.piece_type in (chess.BISHOP, chess.ROOK, chess.QUEEN):\n",
        "                    # piece must match the ray (rook on rank/file, bishop on diagonal)\n",
        "                    if (abs(d) in (1,8) and piece.piece_type in (chess.ROOK, chess.QUEEN)) or \\\n",
        "                       (abs(d) in (7,9) and piece.piece_type in (chess.BISHOP, chess.QUEEN)):\n",
        "                        out.add(sq)\n",
        "                break\n",
        "            cur -= d\n",
        "    return out\n",
        "\n",
        "\n",
        "\n",
        "# ---------- skewers ----------\n",
        "\n",
        "# simple value scale; KING highest so it's always \"front > rear\" if king is first\n",
        "_PVAL = {\n",
        "    chess.PAWN: 1,\n",
        "    chess.KNIGHT: 3,\n",
        "    chess.BISHOP: 3,\n",
        "    chess.ROOK: 5,\n",
        "    chess.QUEEN: 9,\n",
        "    chess.KING: 100\n",
        "}\n",
        "_DIRS = (+1, -1, +8, -8, +7, +9, -7, -9)\n",
        "\n",
        "def _aligned_step(prev: int, cur: int) -> int | None:\n",
        "    \"\"\"Return the direction step from prev->cur if they lie on a straight/diagonal ray; else None.\"\"\"\n",
        "    df = (cur % 8) - (prev % 8)\n",
        "    dr = (cur // 8) - (prev // 8)\n",
        "    if df == 0 and dr != 0:  return  8 if dr > 0 else -8\n",
        "    if dr == 0 and df != 0:  return  1 if df > 0 else  -1\n",
        "    if abs(df) == abs(dr) and df != 0:\n",
        "        return (1 if df > 0 else -1) + (8 if dr > 0 else -8)\n",
        "    return None\n",
        "\n",
        "def skewers_set(board: chess.Board, color: bool):\n",
        "    \"\"\"\n",
        "    Return a set of skewers CREATED BY `color` in the current position.\n",
        "    A skewer is: our sliding piece (B/R/Q) attacks along a ray where the first enemy\n",
        "    piece (front) is MORE valuable than the second enemy piece (rear).\n",
        "    Each item is (pinner_sq, front_sq, rear_sq).\n",
        "    \"\"\"\n",
        "    enemy = not color\n",
        "    out = set()\n",
        "\n",
        "    for pinner_sq in chess.SQUARES:\n",
        "        pinner = board.piece_at(pinner_sq)\n",
        "        if not pinner or pinner.color != color:\n",
        "            continue\n",
        "        if pinner.piece_type not in (chess.BISHOP, chess.ROOK, chess.QUEEN):\n",
        "            continue\n",
        "\n",
        "        for d in _DIRS:\n",
        "            # only allow directions compatible with the pinner\n",
        "            if pinner.piece_type == chess.ROOK and abs(d) not in (1, 8):\n",
        "                continue\n",
        "            if pinner.piece_type == chess.BISHOP and abs(d) not in (7, 9):\n",
        "                continue\n",
        "\n",
        "            # walk outward along the ray\n",
        "            cur = pinner_sq + d\n",
        "            last = pinner_sq\n",
        "            front_sq = None\n",
        "            rear_sq = None\n",
        "\n",
        "            while 0 <= cur < 64 and _aligned_step(last, cur) == d:\n",
        "                piece = board.piece_at(cur)\n",
        "                if piece:\n",
        "                    if piece.color == enemy:\n",
        "                        if front_sq is None:\n",
        "                            front_sq = cur\n",
        "                            last = cur\n",
        "                            cur += d\n",
        "                            continue  # keep going to seek a rear piece\n",
        "                        else:\n",
        "                            rear_sq = cur\n",
        "                            break\n",
        "                    else:\n",
        "                        # friendly blocker stops this ray\n",
        "                        break\n",
        "                last = cur\n",
        "                cur += d\n",
        "\n",
        "            if front_sq is not None and rear_sq is not None:\n",
        "                front_piece = board.piece_at(front_sq)\n",
        "                rear_piece  = board.piece_at(rear_sq)\n",
        "                if _PVAL[front_piece.piece_type] > _PVAL[rear_piece.piece_type]:\n",
        "                    out.add((pinner_sq, front_sq, rear_sq))\n",
        "\n",
        "    return out\n",
        "\n",
        "\n",
        "def discovered_attacks_set(board: chess.Board, color: bool):\n",
        "    \"\"\"\n",
        "    Return a set of discovered attacks CREATED BY `color` in the current position.\n",
        "    A discovered attack is when moving one piece has revealed an attack\n",
        "    from another friendly piece that was previously blocked.\n",
        "    \"\"\"\n",
        "    enemy = not color\n",
        "    out = set()\n",
        "\n",
        "    # Loop through all pieces of color and find sliding attackers\n",
        "    for src in chess.SQUARES:\n",
        "        piece = board.piece_at(src)\n",
        "        if not piece or piece.color != color:\n",
        "            continue\n",
        "        if piece.piece_type not in (chess.BISHOP, chess.ROOK, chess.QUEEN):\n",
        "            continue\n",
        "\n",
        "        # For each enemy piece, see if we have an unobstructed line of attack\n",
        "        for target_sq in chess.SQUARES:\n",
        "            target_piece = board.piece_at(target_sq)\n",
        "            if not target_piece or target_piece.color != enemy:\n",
        "                continue\n",
        "\n",
        "            # Check that our piece attacks target_sq now\n",
        "            if target_sq in board.attacks(src):\n",
        "                # Simulate putting *any* blocker between src and target — if it existed before, it's not discovered\n",
        "                ray = chess.SquareSet.ray(src, target_sq)\n",
        "                if ray:\n",
        "                    blockers = [sq for sq in ray if sq != src and sq != target_sq]\n",
        "                    # if there is no friendly piece blocking now (there could have been before move)\n",
        "                    if all(board.piece_at(sq) is None for sq in blockers):\n",
        "                        out.add((src, target_sq))\n",
        "    return out\n",
        "\n",
        "def count_new_tactics_over_game(pgn_text: str, include_pawn_targets: bool=False):\n",
        "    \"\"\"\n",
        "    Count how many *new* forks, pins, skewers, discovered attacks\n",
        "    each side creates across a single PGN game.\n",
        "    \"\"\"\n",
        "    try:\n",
        "      game = chess.pgn.read_game(io.StringIO(pgn_text))\n",
        "      if game is None:\n",
        "          print('returned none')\n",
        "          return {\n",
        "              \"white_forks\": 0, \"black_forks\": 0,\n",
        "              \"white_pins\": 0, \"black_pins\": 0,\n",
        "              \"white_skewers\": 0, \"black_skewers\": 0,\n",
        "              \"white_discovered_attacks\": 0, \"black_discovered_attacks\": 0\n",
        "          }\n",
        "\n",
        "      board = game.board()\n",
        "      counts = {\n",
        "          \"white_forks\": 0, \"black_forks\": 0,\n",
        "          \"white_pins\": 0, \"black_pins\": 0,\n",
        "          \"white_skewers\": 0, \"black_skewers\": 0,\n",
        "          \"white_discovered_attacks\": 0, \"black_discovered_attacks\": 0\n",
        "      }\n",
        "\n",
        "      for ply, move in enumerate(game.mainline_moves(), start=1):\n",
        "          mover_color = chess.WHITE if (ply % 2 == 1) else chess.BLACK\n",
        "\n",
        "          # BEFORE\n",
        "          forks_before = forks_set(board, mover_color, include_pawn_targets)\n",
        "          pins_before = absolute_pins_set(board, mover_color)\n",
        "          skewers_before = skewers_set(board, mover_color)\n",
        "          disc_before = discovered_attacks_set(board, mover_color)\n",
        "\n",
        "          board.push(move)\n",
        "\n",
        "          # AFTER\n",
        "          forks_after = forks_set(board, mover_color, include_pawn_targets)\n",
        "          pins_after = absolute_pins_set(board, mover_color)\n",
        "          skewers_after = skewers_set(board, mover_color)\n",
        "          disc_after = discovered_attacks_set(board, mover_color)\n",
        "\n",
        "          if forks_after - forks_before:\n",
        "              counts[\"white_forks\" if mover_color else \"black_forks\"] += 1\n",
        "          if pins_after - pins_before:\n",
        "              counts[\"white_pins\" if mover_color else \"black_pins\"] += 1\n",
        "          if skewers_after - skewers_before:\n",
        "              counts[\"white_skewers\" if mover_color else \"black_skewers\"] += 1\n",
        "          if disc_after - disc_before:\n",
        "              counts[\"white_discovered_attacks\" if mover_color else \"black_discovered_attacks\"] += 1\n",
        "\n",
        "    except:\n",
        "      print('_________________________ERROR_____________________')\n",
        "      print(pgn_text)\n",
        "      return counts\n",
        "\n",
        "    return counts\n",
        "\n",
        "\n",
        "print(count_new_tactics_over_game(BERLIN_TEST))\n",
        "print(count_new_tactics_over_game(PIN_TEST))\n",
        "print(count_new_tactics_over_game(FORK_TEST))\n",
        "print(count_new_tactics_over_game(SIM_TEST))"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "coIhyQfI1Zzq",
        "outputId": "4d036cea-600b-4e99-b352-3cf9cef9fd5f"
      },
      "outputs": [
        {
          "output_type": "execute_result",
          "data": {
            "text/plain": [
              "<Game at 0x7cae791a2540 ('White' vs. 'Black', '2025.08.10' at '?')>"
            ]
          },
          "metadata": {},
          "execution_count": 10
        }
      ],
      "source": [
        "chess.pgn.read_game(io.StringIO(SIM_TEST))"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "TNcTVZyr8ps6"
      },
      "source": [
        "## material loss\n",
        "\n",
        "needs board game modification\n",
        "\n",
        "\n",
        "| Event          | Condition                                                                 |\n",
        "| -------------- | ------------------------------------------------------------------------- |\n",
        "| **Blunder**    | Drop ≥ 200 centipawns vs best move (and move is significantly worse)      |\n",
        "| **Sacrifice**  | Drop > 100 cp, **but** it's still the **best move** (tactical brilliance) |\n",
        "| **Good Trade** | Eval increases ≥ 100 cp after the move                                    |\n",
        "\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "A25x5hGdBJup"
      },
      "outputs": [],
      "source": [
        "pgn_data = \"\"\"\n",
        "[Event \"Casual Game\"]\n",
        "[Site \"Berlin GER\"]\n",
        "[Date \"1852.??.??\"]\n",
        "[Round \"?\"]\n",
        "[White \"Adolf Anderssen\"]\n",
        "[Black \"Jean Dufresne\"]\n",
        "[Result \"1-0\"]\n",
        "\n",
        "1.e4 e5 2.Nf3 Nc6 3.Bc4 Bc5 4.b4 Bxb4 5.c3 Ba5 6.d4 exd4 7.O-O d3 8.Qb3 Qf6 9.e5 Qg6 10.Re1 Nge7 11.Ba3 b5 12.Qxb5 Rb8 13.Qa4 Bb6 14.Nbd2 Bb7 15.Ne4 Qf5 16.Bxd3 Qh5 17.Nf6+ gxf6 18.exf6 Rg8 19.Rad1 Qxf3 20.Rxe7+ Nxe7 21.Qxd7+ Kxd7 22.Bf5+ Ke8 23.Bd7+ Kf8 24.Bxe7# 1-0\n",
        "\"\"\""
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "LY1W75V_Cj0h",
        "outputId": "9f119a88-ea94-45e0-af7e-4b8d3182f11e"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "    Ply   Move  Eval Before  Eval After  Delta Eval  Delta vs Best  Blunder  \\\n",
            "21   22     b5         -115          75         190            156    False   \n",
            "27   28    Bb7           90         220         130             87    False   \n",
            "29   30    Qf5          225         637         412            399    False   \n",
            "32   33   Nf6+          679          64        -615           -613     True   \n",
            "33   34   gxf6           38         223         185            185    False   \n",
            "36   37   Rad1          242        -838       -1080           -928     True   \n",
            "38   39  Rxe7+         -749         482        1231           1218    False   \n",
            "39   40   Nxe7          479       10000        9521           9547    False   \n",
            "46   47  Bxe7#        10000      -10000      -20000         -20000     True   \n",
            "\n",
            "    Sacrifice  Good Trade  \n",
            "21      False        True  \n",
            "27      False        True  \n",
            "29      False        True  \n",
            "32      False       False  \n",
            "33      False        True  \n",
            "36      False       False  \n",
            "38      False        True  \n",
            "39      False        True  \n",
            "46       True       False  \n",
            "{'white_blunders': 3, 'white_sacrifices': 3, 'white_good_trades': 0, 'white_total_centipawn_loss': 21774.0, 'white_average_centipawn_loss': 907.25, 'white_blunder_rate': np.float64(0.125), 'black_blunders': 0, 'black_sacrifices': 0, 'black_good_trades': 6, 'black_total_centipawn_loss': 11200.0, 'black_average_centipawn_loss': 486.95652173913044, 'black_blunder_rate': np.float64(0.0)}\n"
          ]
        }
      ],
      "source": [
        "def eval_score(score_obj, perspective=chess.WHITE):\n",
        "    # Normalize mate scores as +/-10000\n",
        "    if score_obj.is_mate():\n",
        "        mate_score = score_obj.mate()\n",
        "        if mate_score is None:\n",
        "            return 0\n",
        "        return 10000 * (1 if mate_score > 0 else -1)\n",
        "    else:\n",
        "        return score_obj.score()\n",
        "\n",
        "def track_engine_blunders_sacrifices_trades(pgn_text, stockfish_path=\"/usr/games/stockfish\", time_limit=0.1):\n",
        "    pgn = io.StringIO(pgn_text)\n",
        "    game = chess.pgn.read_game(pgn)\n",
        "    board = game.board()\n",
        "\n",
        "    records = []\n",
        "\n",
        "    with chess.engine.SimpleEngine.popen_uci(stockfish_path) as engine:\n",
        "      for ply, move in enumerate(game.mainline_moves(), start=1):\n",
        "          color = board.turn\n",
        "\n",
        "          # Save SAN *before* pushing\n",
        "          san_move = board.san(move)\n",
        "\n",
        "          # Get evaluation before move\n",
        "          info_before = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "          eval_before = eval_score(info_before[\"score\"].white())\n",
        "\n",
        "          # Get best move\n",
        "          best_move = info_before.get(\"pv\", [None])[0]\n",
        "\n",
        "          board.push(move)\n",
        "\n",
        "          # Eval after move\n",
        "          info_after = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "          eval_after = eval_score(info_after[\"score\"].white())\n",
        "\n",
        "          # Re-eval best move if it's not the one played\n",
        "          best_eval = eval_before\n",
        "          if best_move and best_move != move:\n",
        "              board.pop()\n",
        "              board.push(best_move)\n",
        "              info_best = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "              best_eval = eval_score(info_best[\"score\"].white())\n",
        "              board.pop()\n",
        "              board.push(move)\n",
        "\n",
        "          delta = eval_after - eval_before\n",
        "          delta_vs_best = eval_after - best_eval\n",
        "\n",
        "          # Classification\n",
        "          blunder = (eval_after < best_eval - 200)\n",
        "          sacrifice = (eval_after < eval_before - 100) and (move == best_move)\n",
        "          good_trade = (eval_after > eval_before + 100)\n",
        "\n",
        "          records.append({\n",
        "              \"Ply\": ply,\n",
        "              \"Move\": san_move,  # Now safe\n",
        "              \"Eval Before\": eval_before,\n",
        "              \"Eval After\": eval_after,\n",
        "              \"Delta Eval\": delta,\n",
        "              \"Delta vs Best\": delta_vs_best,\n",
        "              \"Blunder\": blunder,\n",
        "              \"Sacrifice\": sacrifice,\n",
        "              \"Good Trade\": good_trade\n",
        "          })\n",
        "\n",
        "    return pd.DataFrame(records)\n",
        "\n",
        "df = track_engine_blunders_sacrifices_trades(pgn_data, stockfish_path='/usr/games/stockfish', time_limit=0.1)\n",
        "print(df[df[\"Blunder\"] | df[\"Sacrifice\"] | df[\"Good Trade\"]])\n",
        "\n",
        "\n",
        "def aggregate_tactical_stats_per_side(df):\n",
        "    # Determine which color made each move\n",
        "    df[\"Color\"] = df[\"Ply\"].apply(lambda x: \"White\" if x % 2 == 1 else \"Black\")\n",
        "\n",
        "    results = {}\n",
        "    for color in [\"White\", \"Black\"]:\n",
        "        color_df = df[df[\"Color\"] == color]\n",
        "        total_moves = len(color_df)\n",
        "\n",
        "        results[f\"{color.lower()}_blunders\"] = int(color_df[\"Blunder\"].sum())\n",
        "        results[f\"{color.lower()}_sacrifices\"] = int(color_df[\"Sacrifice\"].sum())\n",
        "        results[f\"{color.lower()}_good_trades\"] = int(color_df[\"Good Trade\"].sum())\n",
        "        results[f\"{color.lower()}_total_centipawn_loss\"] = float(color_df[\"Delta Eval\"].abs().sum())\n",
        "        results[f\"{color.lower()}_average_centipawn_loss\"] = float(color_df[\"Delta Eval\"].abs().mean()) if total_moves > 0 else 0.0\n",
        "        results[f\"{color.lower()}_blunder_rate\"] = (\n",
        "            color_df[\"Blunder\"].sum() / total_moves if total_moves > 0 else 0.0\n",
        "        )\n",
        "\n",
        "    return results\n",
        "\n",
        "# Example usage\n",
        "df_moves = track_engine_blunders_sacrifices_trades(BERLIN_TEST, stockfish_path=\"/usr/games/stockfish\")\n",
        "summary_stats = aggregate_tactical_stats_per_side(df_moves)\n",
        "print(summary_stats)\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "J26dKFjJc4cg",
        "outputId": "80a86375-c1fc-4569-8958-7e663a88c36e"
      },
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "{'white_blunders': 3, 'white_sacrifices': 1, 'white_good_trades': 0, 'white_total_centipawn_loss': 21553.0, 'white_average_centipawn_loss': 898.0416666666666, 'white_blunder_rate': np.float64(0.125), 'black_blunders': 0, 'black_sacrifices': 0, 'black_good_trades': 5, 'black_total_centipawn_loss': 11125.0, 'black_average_centipawn_loss': 483.69565217391306, 'black_blunder_rate': np.float64(0.0)}\n"
          ]
        }
      ],
      "source": [
        "import io\n",
        "import chess\n",
        "import chess.pgn\n",
        "import chess.engine\n",
        "import pandas as pd\n",
        "\n",
        "def eval_score(score_obj, perspective=chess.WHITE):\n",
        "    \"\"\"Normalize Stockfish evaluation scores; mates are +/-10000.\"\"\"\n",
        "    if score_obj.is_mate():\n",
        "        mate_score = score_obj.mate()\n",
        "        if mate_score is None:\n",
        "            return 0\n",
        "        return 10000 * (1 if mate_score > 0 else -1)\n",
        "    else:\n",
        "        return score_obj.score()\n",
        "\n",
        "def aggregate_tactical_stats_per_side(pgn_text, stockfish_path=\"/usr/games/stockfish\", time_limit=0.1):\n",
        "    \"\"\"\n",
        "    Tracks tactical moves in a PGN and aggregates blunders, sacrifices, and good trades per side.\n",
        "\n",
        "    Returns:\n",
        "        dict: Aggregate stats for White and Black.\n",
        "    \"\"\"\n",
        "    # --- Step 1: Track moves with tactical info ---\n",
        "    pgn = io.StringIO(pgn_text)\n",
        "    game = chess.pgn.read_game(pgn)\n",
        "    board = game.board()\n",
        "\n",
        "    records = []\n",
        "\n",
        "    with chess.engine.SimpleEngine.popen_uci(stockfish_path) as engine:\n",
        "        for ply, move in enumerate(game.mainline_moves(), start=1):\n",
        "            color = board.turn  # True = White, False = Black\n",
        "\n",
        "            san_move = board.san(move)\n",
        "            info_before = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "            eval_before = eval_score(info_before[\"score\"].white())\n",
        "            best_move = info_before.get(\"pv\", [None])[0]\n",
        "\n",
        "            board.push(move)\n",
        "            info_after = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "            eval_after = eval_score(info_after[\"score\"].white())\n",
        "\n",
        "            # Re-evaluate best move if it's not the played one\n",
        "            best_eval = eval_before\n",
        "            if best_move and best_move != move:\n",
        "                board.pop()\n",
        "                board.push(best_move)\n",
        "                info_best = engine.analyse(board, chess.engine.Limit(time=time_limit))\n",
        "                best_eval = eval_score(info_best[\"score\"].white())\n",
        "                board.pop()\n",
        "                board.push(move)\n",
        "\n",
        "            delta = eval_after - eval_before\n",
        "            delta_vs_best = eval_after - best_eval\n",
        "\n",
        "            # Classification\n",
        "            blunder = eval_after < best_eval - 200\n",
        "            sacrifice = eval_after < eval_before - 100 and move == best_move\n",
        "            good_trade = eval_after > eval_before + 100\n",
        "\n",
        "            records.append({\n",
        "                \"Ply\": ply,\n",
        "                \"Color\": \"White\" if color else \"Black\",\n",
        "                \"Blunder\": blunder,\n",
        "                \"Sacrifice\": sacrifice,\n",
        "                \"Good Trade\": good_trade,\n",
        "                \"Delta Eval\": delta\n",
        "            })\n",
        "\n",
        "    df = pd.DataFrame(records)\n",
        "\n",
        "    # --- Step 2: Aggregate per side ---\n",
        "    results = {}\n",
        "    for color in [\"White\", \"Black\"]:\n",
        "        color_df = df[df[\"Color\"] == color]\n",
        "        total_moves = len(color_df)\n",
        "\n",
        "        results[f\"{color.lower()}_blunders\"] = int(color_df[\"Blunder\"].sum())\n",
        "        results[f\"{color.lower()}_sacrifices\"] = int(color_df[\"Sacrifice\"].sum())\n",
        "        results[f\"{color.lower()}_good_trades\"] = int(color_df[\"Good Trade\"].sum())\n",
        "        results[f\"{color.lower()}_total_centipawn_loss\"] = float(color_df[\"Delta Eval\"].abs().sum())\n",
        "        results[f\"{color.lower()}_average_centipawn_loss\"] = float(color_df[\"Delta Eval\"].abs().mean()) if total_moves > 0 else 0.0\n",
        "        results[f\"{color.lower()}_blunder_rate\"] = (\n",
        "            color_df[\"Blunder\"].sum() / total_moves if total_moves > 0 else 0.0\n",
        "        )\n",
        "\n",
        "    return results\n",
        "\n",
        "summary_stats = aggregate_tactical_stats_per_side(BERLIN_TEST, stockfish_path=\"/usr/games/stockfish\", time_limit=0.1)\n",
        "print(summary_stats)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "Jvj0-kp38zNr"
      },
      "source": [
        "## positional strategy & center control\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "Mm9eZAr5ta2w"
      },
      "outputs": [],
      "source": [
        "import chess\n",
        "import chess.engine\n",
        "from collections import defaultdict\n",
        "from typing import Dict, List, Tuple, Set\n",
        "\n",
        "def evaluate_piece_activity(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Evaluate piece activity - how many squares pieces can reach/control.\n",
        "    Active pieces are generally better positioned.\n",
        "    \"\"\"\n",
        "    white_mobility = 0\n",
        "    black_mobility = 0\n",
        "\n",
        "    # Get all legal moves and count by piece color\n",
        "    for move in board.legal_moves:\n",
        "        piece = board.piece_at(move.from_square)\n",
        "        if piece:\n",
        "            if piece.color == chess.WHITE:\n",
        "                white_mobility += 1\n",
        "            else:\n",
        "                black_mobility += 1\n",
        "\n",
        "    total_mobility = white_mobility + black_mobility\n",
        "    return {\n",
        "        'white_activity': white_mobility / max(total_mobility, 1),\n",
        "        'black_activity': black_mobility / max(total_mobility, 1),\n",
        "        'activity_advantage': (white_mobility - black_mobility) / max(total_mobility, 1)\n",
        "    }\n",
        "\n",
        "def evaluate_pawn_structure(board: chess.Board) -> Dict[str, int]:\n",
        "    \"\"\"\n",
        "    Evaluate pawn structure weaknesses and strengths.\n",
        "    Identifies doubled, isolated, backward, and passed pawns.\n",
        "    \"\"\"\n",
        "    def get_pawn_files(color: chess.Color) -> List[int]:\n",
        "        files = []\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.piece_type == chess.PAWN and piece.color == color:\n",
        "                files.append(chess.square_file(square))\n",
        "        return files\n",
        "\n",
        "    white_files = get_pawn_files(chess.WHITE)\n",
        "    black_files = get_pawn_files(chess.BLACK)\n",
        "\n",
        "    # Count doubled pawns (multiple pawns on same file)\n",
        "    white_doubled = len(white_files) - len(set(white_files))\n",
        "    black_doubled = len(black_files) - len(set(black_files))\n",
        "\n",
        "    # Count isolated pawns (no friendly pawns on adjacent files)\n",
        "    def count_isolated(pawn_files: List[int]) -> int:\n",
        "        isolated = 0\n",
        "        file_set = set(pawn_files)\n",
        "        for file in set(pawn_files):\n",
        "            has_neighbor = (file-1 in file_set) or (file+1 in file_set)\n",
        "            if not has_neighbor:\n",
        "                isolated += 1\n",
        "        return isolated\n",
        "\n",
        "    white_isolated = count_isolated(white_files)\n",
        "    black_isolated = count_isolated(black_files)\n",
        "\n",
        "    # Count passed pawns (no enemy pawns can stop advancement)\n",
        "    def count_passed(color: chess.Color) -> int:\n",
        "        passed = 0\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.piece_type == chess.PAWN and piece.color == color:\n",
        "                file = chess.square_file(square)\n",
        "                rank = chess.square_rank(square)\n",
        "\n",
        "                is_passed = True\n",
        "                # Check for blocking pawns\n",
        "                if color == chess.WHITE:\n",
        "                    for check_rank in range(rank + 1, 8):\n",
        "                        for check_file in [file - 1, file, file + 1]:\n",
        "                            if 0 <= check_file <= 7:\n",
        "                                check_square = chess.square(check_file, check_rank)\n",
        "                                check_piece = board.piece_at(check_square)\n",
        "                                if (check_piece and check_piece.piece_type == chess.PAWN\n",
        "                                    and check_piece.color == chess.BLACK):\n",
        "                                    is_passed = False\n",
        "                                    break\n",
        "                        if not is_passed:\n",
        "                            break\n",
        "                else:  # BLACK\n",
        "                    for check_rank in range(rank - 1, -1, -1):\n",
        "                        for check_file in [file - 1, file, file + 1]:\n",
        "                            if 0 <= check_file <= 7:\n",
        "                                check_square = chess.square(check_file, check_rank)\n",
        "                                check_piece = board.piece_at(check_square)\n",
        "                                if (check_piece and check_piece.piece_type == chess.PAWN\n",
        "                                    and check_piece.color == chess.WHITE):\n",
        "                                    is_passed = False\n",
        "                                    break\n",
        "                        if not is_passed:\n",
        "                            break\n",
        "\n",
        "                if is_passed:\n",
        "                    passed += 1\n",
        "        return passed\n",
        "\n",
        "    white_passed = count_passed(chess.WHITE)\n",
        "    black_passed = count_passed(chess.BLACK)\n",
        "\n",
        "    return {\n",
        "        'white_doubled_pawns': white_doubled,\n",
        "        'black_doubled_pawns': black_doubled,\n",
        "        'white_isolated_pawns': white_isolated,\n",
        "        'black_isolated_pawns': black_isolated,\n",
        "        'white_passed_pawns': white_passed,\n",
        "        'black_passed_pawns': black_passed\n",
        "    }\n",
        "\n",
        "def evaluate_king_safety(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Evaluate king safety by looking at pawn shelter and piece attacks near king.\n",
        "    \"\"\"\n",
        "    def king_safety_score(color: chess.Color) -> float:\n",
        "        king_square = board.king(color)\n",
        "        if king_square is None:\n",
        "            return 0.0\n",
        "\n",
        "        safety_score = 0.0\n",
        "        king_file = chess.square_file(king_square)\n",
        "        king_rank = chess.square_rank(king_square)\n",
        "\n",
        "        # Check pawn shelter (pawns in front of king)\n",
        "        pawn_shelter = 0\n",
        "        shelter_files = [king_file - 1, king_file, king_file + 1]\n",
        "\n",
        "        for file in shelter_files:\n",
        "            if 0 <= file <= 7:\n",
        "                # Look for pawns providing shelter\n",
        "                if color == chess.WHITE:\n",
        "                    for rank in range(king_rank + 1, min(king_rank + 3, 8)):\n",
        "                        square = chess.square(file, rank)\n",
        "                        piece = board.piece_at(square)\n",
        "                        if piece and piece.piece_type == chess.PAWN and piece.color == color:\n",
        "                            pawn_shelter += 1\n",
        "                            break\n",
        "                else:  # BLACK\n",
        "                    for rank in range(king_rank - 1, max(king_rank - 3, -1), -1):\n",
        "                        square = chess.square(file, rank)\n",
        "                        piece = board.piece_at(square)\n",
        "                        if piece and piece.piece_type == chess.PAWN and piece.color == color:\n",
        "                            pawn_shelter += 1\n",
        "                            break\n",
        "\n",
        "        safety_score += pawn_shelter * 0.3\n",
        "\n",
        "        # Count enemy attacks near king\n",
        "        enemy_attacks = 0\n",
        "        for dr in [-1, 0, 1]:\n",
        "            for df in [-1, 0, 1]:\n",
        "                if dr == 0 and df == 0:\n",
        "                    continue\n",
        "                check_file = king_file + df\n",
        "                check_rank = king_rank + dr\n",
        "                if 0 <= check_file <= 7 and 0 <= check_rank <= 7:\n",
        "                    check_square = chess.square(check_file, check_rank)\n",
        "                    if board.is_attacked_by(not color, check_square):\n",
        "                        enemy_attacks += 1\n",
        "\n",
        "        safety_score -= enemy_attacks * 0.2\n",
        "\n",
        "        return max(0.0, safety_score)\n",
        "\n",
        "    white_safety = king_safety_score(chess.WHITE)\n",
        "    black_safety = king_safety_score(chess.BLACK)\n",
        "\n",
        "    return {\n",
        "        'white_king_safety': white_safety,\n",
        "        'black_king_safety': black_safety,\n",
        "        'king_safety_advantage': white_safety - black_safety\n",
        "    }\n",
        "\n",
        "def evaluate_piece_coordination(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Evaluate how well pieces support each other.\n",
        "    Measures mutual protection and coordination.\n",
        "    \"\"\"\n",
        "    def coordination_score(color: chess.Color) -> float:\n",
        "        pieces = []\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.color == color:\n",
        "                pieces.append(square)\n",
        "\n",
        "        if len(pieces) <= 1:\n",
        "            return 0.0\n",
        "\n",
        "        # Count how many pieces defend each other\n",
        "        defended_pieces = 0\n",
        "        total_pieces = len(pieces)\n",
        "\n",
        "        for piece_square in pieces:\n",
        "            if board.is_attacked_by(color, piece_square):\n",
        "                defended_pieces += 1\n",
        "\n",
        "        return defended_pieces / total_pieces if total_pieces > 0 else 0.0\n",
        "\n",
        "    white_coord = coordination_score(chess.WHITE)\n",
        "    black_coord = coordination_score(chess.BLACK)\n",
        "\n",
        "    return {\n",
        "        'white_coordination': white_coord,\n",
        "        'black_coordination': black_coord,\n",
        "        'coordination_advantage': white_coord - black_coord\n",
        "    }\n",
        "\n",
        "def evaluate_space_control(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Evaluate territorial control - how much of the board each side controls.\n",
        "    \"\"\"\n",
        "    white_squares = 0\n",
        "    black_squares = 0\n",
        "    total_squares = 0\n",
        "\n",
        "    for square in chess.SQUARES:\n",
        "        total_squares += 1\n",
        "        white_attacks = board.is_attacked_by(chess.WHITE, square)\n",
        "        black_attacks = board.is_attacked_by(chess.BLACK, square)\n",
        "\n",
        "        if white_attacks and not black_attacks:\n",
        "            white_squares += 1\n",
        "        elif black_attacks and not white_attacks:\n",
        "            black_squares += 1\n",
        "        elif white_attacks and black_attacks:\n",
        "            # Contested square - count partially for both\n",
        "            white_squares += 0.5\n",
        "            black_squares += 0.5\n",
        "\n",
        "    return {\n",
        "        'white_space_control': white_squares / total_squares,\n",
        "        'black_space_control': black_squares / total_squares,\n",
        "        'space_advantage': (white_squares - black_squares) / total_squares\n",
        "    }\n",
        "\n",
        "def evaluate_weak_squares(board: chess.Board) -> Dict[str, int]:\n",
        "    \"\"\"\n",
        "    Count weak squares (squares that cannot be defended by pawns).\n",
        "    Particularly important in opponent's territory.\n",
        "    \"\"\"\n",
        "    def count_weak_squares_in_territory(color: chess.Color) -> int:\n",
        "        weak_count = 0\n",
        "        # Define territory (for white: ranks 5-8, for black: ranks 1-4)\n",
        "        if color == chess.WHITE:\n",
        "            territory_ranks = range(4, 8)  # 5th-8th ranks (0-indexed)\n",
        "        else:\n",
        "            territory_ranks = range(0, 4)  # 1st-4th ranks\n",
        "\n",
        "        for rank in territory_ranks:\n",
        "            for file in range(8):\n",
        "                square = chess.square(file, rank)\n",
        "\n",
        "                # Check if this square can be defended by friendly pawns\n",
        "                can_be_defended = False\n",
        "\n",
        "                # Check adjacent files for pawns that could potentially defend\n",
        "                for pawn_file in [file - 1, file + 1]:\n",
        "                    if 0 <= pawn_file <= 7:\n",
        "                        if color == chess.WHITE:\n",
        "                            # Look for white pawns behind this square\n",
        "                            for pawn_rank in range(rank - 1, -1, -1):\n",
        "                                pawn_square = chess.square(pawn_file, pawn_rank)\n",
        "                                piece = board.piece_at(pawn_square)\n",
        "                                if (piece and piece.piece_type == chess.PAWN\n",
        "                                    and piece.color == color):\n",
        "                                    can_be_defended = True\n",
        "                                    break\n",
        "                        else:  # BLACK\n",
        "                            # Look for black pawns behind this square\n",
        "                            for pawn_rank in range(rank + 1, 8):\n",
        "                                pawn_square = chess.square(pawn_file, pawn_rank)\n",
        "                                piece = board.piece_at(pawn_square)\n",
        "                                if (piece and piece.piece_type == chess.PAWN\n",
        "                                    and piece.color == color):\n",
        "                                    can_be_defended = True\n",
        "                                    break\n",
        "                    if can_be_defended:\n",
        "                        break\n",
        "\n",
        "                if not can_be_defended:\n",
        "                    weak_count += 1\n",
        "\n",
        "        return weak_count\n",
        "\n",
        "    return {\n",
        "        'white_weak_squares': count_weak_squares_in_territory(chess.WHITE),\n",
        "        'black_weak_squares': count_weak_squares_in_territory(chess.BLACK)\n",
        "    }\n",
        "\n",
        "def comprehensive_positional_evaluation(board: chess.Board) -> Dict[str, any]:\n",
        "    \"\"\"\n",
        "    Combine all positional evaluations into a comprehensive assessment.\n",
        "    \"\"\"\n",
        "    evaluation = {}\n",
        "\n",
        "    evaluation.update(evaluate_piece_activity(board))\n",
        "    evaluation.update(evaluate_pawn_structure(board))\n",
        "    evaluation.update(evaluate_king_safety(board))\n",
        "    evaluation.update(evaluate_piece_coordination(board))\n",
        "    evaluation.update(evaluate_space_control(board))\n",
        "    evaluation.update(evaluate_weak_squares(board))\n",
        "\n",
        "    # Calculate overall positional advantage\n",
        "    positional_factors = [\n",
        "        evaluation.get('activity_advantage', 0),\n",
        "        evaluation.get('king_safety_advantage', 0),\n",
        "        evaluation.get('coordination_advantage', 0),\n",
        "        evaluation.get('space_advantage', 0)\n",
        "    ]\n",
        "\n",
        "    # Add pawn structure factors\n",
        "    pawn_advantage = (\n",
        "        (evaluation.get('white_passed_pawns', 0) - evaluation.get('black_passed_pawns', 0)) * 0.2 +\n",
        "        (evaluation.get('black_doubled_pawns', 0) - evaluation.get('white_doubled_pawns', 0)) * 0.1 +\n",
        "        (evaluation.get('black_isolated_pawns', 0) - evaluation.get('white_isolated_pawns', 0)) * 0.1\n",
        "    )\n",
        "\n",
        "    positional_factors.append(pawn_advantage)\n",
        "\n",
        "    evaluation['overall_positional_advantage'] = sum(positional_factors) / len(positional_factors)\n",
        "\n",
        "    return evaluation\n",
        "\n",
        "def determine_game_phase(board: chess.Board, move_count: int) -> str:\n",
        "    \"\"\"\n",
        "    Determine current game phase based on material and move count.\n",
        "    \"\"\"\n",
        "    # Count major and minor pieces (excluding pawns and kings)\n",
        "    piece_count = 0\n",
        "    for square in chess.SQUARES:\n",
        "        piece = board.piece_at(square)\n",
        "        if piece and piece.piece_type not in [chess.PAWN, chess.KING]:\n",
        "            piece_count += 1\n",
        "\n",
        "    # Opening: First 15 moves OR many pieces still on board\n",
        "    if move_count <= 15 or piece_count >= 12:\n",
        "        return \"opening\"\n",
        "    # Endgame: Few pieces left\n",
        "    elif piece_count <= 6:\n",
        "        return \"endgame\"\n",
        "    # Middlegame: Everything else\n",
        "    else:\n",
        "        return \"middlegame\"\n",
        "\n",
        "def opening_specific_metrics(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Metrics specific to opening phase: development, center control, king safety.\n",
        "    \"\"\"\n",
        "    metrics = {}\n",
        "\n",
        "    # Development score (pieces off back rank)\n",
        "    def development_score(color: chess.Color) -> float:\n",
        "        back_rank = 0 if color == chess.WHITE else 7\n",
        "        developed_pieces = 0\n",
        "        total_developable = 0  # Knights, bishops, queen\n",
        "\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.color == color:\n",
        "                if piece.piece_type in [chess.KNIGHT, chess.BISHOP, chess.QUEEN]:\n",
        "                    total_developable += 1\n",
        "                    if chess.square_rank(square) != back_rank:\n",
        "                        developed_pieces += 1\n",
        "\n",
        "        return developed_pieces / max(total_developable, 1)\n",
        "\n",
        "    metrics['white_development'] = development_score(chess.WHITE)\n",
        "    metrics['black_development'] = development_score(chess.BLACK)\n",
        "    metrics['development_advantage'] = metrics['white_development'] - metrics['black_development']\n",
        "\n",
        "    # Center control (e4, d4, e5, d5 squares)\n",
        "    center_squares = [chess.E4, chess.D4, chess.E5, chess.D5]\n",
        "    white_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.WHITE, sq))\n",
        "    black_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.BLACK, sq))\n",
        "\n",
        "    metrics['white_center_control'] = white_center / 4\n",
        "    metrics['black_center_control'] = black_center / 4\n",
        "    metrics['center_control_advantage'] = (white_center - black_center) / 4\n",
        "\n",
        "    # Castling status\n",
        "    metrics['white_can_castle'] = board.has_castling_rights(chess.WHITE)\n",
        "    metrics['black_can_castle'] = board.has_castling_rights(chess.BLACK)\n",
        "    metrics['white_has_castled'] = not board.has_castling_rights(chess.WHITE) and \\\n",
        "                                   chess.square_file(board.king(chess.WHITE)) in [2, 6]\n",
        "    metrics['black_has_castled'] = not board.has_castling_rights(chess.BLACK) and \\\n",
        "                                   chess.square_file(board.king(chess.BLACK)) in [2, 6]\n",
        "\n",
        "    return metrics\n",
        "\n",
        "def middlegame_specific_metrics(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Metrics specific to middlegame: piece activity, strategic concepts.\n",
        "    \"\"\"\n",
        "    metrics = {}\n",
        "\n",
        "    # Piece outposts (pieces on strong squares in enemy territory)\n",
        "    def count_outposts(color: chess.Color) -> int:\n",
        "        outposts = 0\n",
        "        territory_ranks = range(4, 8) if color == chess.WHITE else range(0, 4)\n",
        "\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if (piece and piece.color == color and\n",
        "                piece.piece_type in [chess.KNIGHT, chess.BISHOP] and\n",
        "                chess.square_rank(square) in territory_ranks):\n",
        "\n",
        "                # Check if piece is defended and hard to kick out\n",
        "                if board.is_attacked_by(color, square):\n",
        "                    # Check if enemy pawns can attack this square\n",
        "                    file = chess.square_file(square)\n",
        "                    rank = chess.square_rank(square)\n",
        "\n",
        "                    enemy_pawn_attack = False\n",
        "                    for pawn_file in [file - 1, file + 1]:\n",
        "                        if 0 <= pawn_file <= 7:\n",
        "                            if color == chess.WHITE:\n",
        "                                pawn_square = chess.square(pawn_file, rank - 1)\n",
        "                            else:\n",
        "                                pawn_square = chess.square(pawn_file, rank + 1)\n",
        "\n",
        "                            if 0 <= chess.square_rank(pawn_square) <= 7:\n",
        "                                pawn_piece = board.piece_at(pawn_square)\n",
        "                                if (pawn_piece and pawn_piece.piece_type == chess.PAWN and\n",
        "                                    pawn_piece.color != color):\n",
        "                                    enemy_pawn_attack = True\n",
        "                                    break\n",
        "\n",
        "                    if not enemy_pawn_attack:\n",
        "                        outposts += 1\n",
        "\n",
        "        return outposts\n",
        "\n",
        "    metrics['white_outposts'] = count_outposts(chess.WHITE)\n",
        "    metrics['black_outposts'] = count_outposts(chess.BLACK)\n",
        "\n",
        "    # Rook activity (on open/semi-open files)\n",
        "    def rook_activity(color: chess.Color) -> float:\n",
        "        activity_score = 0\n",
        "        rook_count = 0\n",
        "\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.piece_type == chess.ROOK and piece.color == color:\n",
        "                rook_count += 1\n",
        "                file = chess.square_file(square)\n",
        "\n",
        "                # Check if on open file (no pawns)\n",
        "                file_has_pawns = False\n",
        "                for rank in range(8):\n",
        "                    check_square = chess.square(file, rank)\n",
        "                    check_piece = board.piece_at(check_square)\n",
        "                    if check_piece and check_piece.piece_type == chess.PAWN:\n",
        "                        file_has_pawns = True\n",
        "                        break\n",
        "\n",
        "                if not file_has_pawns:\n",
        "                    activity_score += 1.0  # Open file\n",
        "                else:\n",
        "                    # Check for semi-open file (no friendly pawns)\n",
        "                    friendly_pawn_on_file = False\n",
        "                    for rank in range(8):\n",
        "                        check_square = chess.square(file, rank)\n",
        "                        check_piece = board.piece_at(check_square)\n",
        "                        if (check_piece and check_piece.piece_type == chess.PAWN\n",
        "                            and check_piece.color == color):\n",
        "                            friendly_pawn_on_file = True\n",
        "                            break\n",
        "\n",
        "                    if not friendly_pawn_on_file:\n",
        "                        activity_score += 0.5  # Semi-open file\n",
        "\n",
        "        return activity_score / max(rook_count, 1)\n",
        "\n",
        "    metrics['white_rook_activity'] = rook_activity(chess.WHITE)\n",
        "    metrics['black_rook_activity'] = rook_activity(chess.BLACK)\n",
        "\n",
        "    return metrics\n",
        "\n",
        "def endgame_specific_metrics(board: chess.Board) -> Dict[str, float]:\n",
        "    \"\"\"\n",
        "    Metrics specific to endgame: king activity, pawn promotion, opposition.\n",
        "    \"\"\"\n",
        "    metrics = {}\n",
        "\n",
        "    # King activity (how centralized and active the king is)\n",
        "    def king_activity(color: chess.Color) -> float:\n",
        "        king_square = board.king(color)\n",
        "        if king_square is None:\n",
        "            return 0.0\n",
        "\n",
        "        king_file = chess.square_file(king_square)\n",
        "        king_rank = chess.square_rank(king_square)\n",
        "\n",
        "        # Distance from center (d4/e4/d5/e5 area)\n",
        "        center_distance = min(\n",
        "            abs(king_file - 3) + abs(king_rank - 3),\n",
        "            abs(king_file - 3) + abs(king_rank - 4),\n",
        "            abs(king_file - 4) + abs(king_rank - 3),\n",
        "            abs(king_file - 4) + abs(king_rank - 4)\n",
        "        )\n",
        "\n",
        "        # More central = higher activity (max distance is about 7)\n",
        "        activity = (7 - center_distance) / 7\n",
        "\n",
        "        return activity\n",
        "\n",
        "    metrics['white_king_activity'] = king_activity(chess.WHITE)\n",
        "    metrics['black_king_activity'] = king_activity(chess.BLACK)\n",
        "\n",
        "    # Pawn promotion potential (how close pawns are to promoting)\n",
        "    def promotion_potential(color: chess.Color) -> float:\n",
        "        potential = 0.0\n",
        "        promotion_rank = 7 if color == chess.WHITE else 0\n",
        "\n",
        "        for square in chess.SQUARES:\n",
        "            piece = board.piece_at(square)\n",
        "            if piece and piece.piece_type == chess.PAWN and piece.color == color:\n",
        "                rank = chess.square_rank(square)\n",
        "                distance_to_promotion = abs(rank - promotion_rank)\n",
        "                # Closer pawns have higher potential\n",
        "                potential += (7 - distance_to_promotion) / 7\n",
        "\n",
        "        return potential\n",
        "\n",
        "    metrics['white_promotion_potential'] = promotion_potential(chess.WHITE)\n",
        "    metrics['black_promotion_potential'] = promotion_potential(chess.BLACK)\n",
        "\n",
        "    return metrics\n",
        "\n",
        "\n",
        "\n",
        "def analyze_game_by_phases(pgn_moves: List[str]) -> Dict[str, Dict[str, any]]:\n",
        "    \"\"\"\n",
        "    Analyze positional understanding by game phase.\n",
        "    Returns separate evaluations for opening, middlegame, and endgame.\n",
        "    \"\"\"\n",
        "    pgn_moves = chess.pgn.read_game(io.StringIO(pgn_moves))\n",
        "    board = chess.Board()\n",
        "    phase_evaluations = {\n",
        "        'opening': {'positions': [], 'moves': []},\n",
        "        'middlegame': {'positions': [], 'moves': []},\n",
        "        'endgame': {'positions': [], 'moves': []}\n",
        "    }\n",
        "\n",
        "    for move_num, move in enumerate(pgn_moves.mainline_moves(), start=1):\n",
        "        try:\n",
        "            #move = board.parse_san(move_san)\n",
        "            board.push(move)\n",
        "\n",
        "            phase = determine_game_phase(board, move_num + 1)\n",
        "\n",
        "            # Get base positional evaluation\n",
        "            base_eval = comprehensive_positional_evaluation(board)\n",
        "\n",
        "            # Add phase-specific metrics\n",
        "            if phase == 'opening':\n",
        "                phase_specific = opening_specific_metrics(board)\n",
        "            elif phase == 'middlegame':\n",
        "                phase_specific = middlegame_specific_metrics(board)\n",
        "            else:  # endgame\n",
        "                phase_specific = endgame_specific_metrics(board)\n",
        "\n",
        "            evaluation = {\n",
        "                'move_number': move_num + 1,\n",
        "                'move': move,\n",
        "                'fen': board.fen(),\n",
        "                **base_eval,\n",
        "                **phase_specific\n",
        "            }\n",
        "\n",
        "            phase_evaluations[phase]['positions'].append(evaluation)\n",
        "            phase_evaluations[phase]['moves'].append(move)\n",
        "\n",
        "        except chess.InvalidMoveError:\n",
        "            continue\n",
        "\n",
        "    # Calculate phase averages\n",
        "    result = {}\n",
        "    for phase, data in phase_evaluations.items():\n",
        "        if data['positions']:\n",
        "            # Average all numeric metrics for this phase\n",
        "            averaged_metrics = {}\n",
        "            numeric_keys = [k for k in data['positions'][0].keys()\n",
        "                          if isinstance(data['positions'][0][k], (int, float))]\n",
        "\n",
        "            for key in numeric_keys:\n",
        "                values = [pos[key] for pos in data['positions'] if key in pos]\n",
        "                if values:\n",
        "                    averaged_metrics[f'avg_{key}'] = sum(values) / len(values)\n",
        "                    averaged_metrics[f'final_{key}'] = values[-1]  # Final position value\n",
        "\n",
        "            result[phase] = {\n",
        "                'move_count': len(data['positions']),\n",
        "                'move_range': f\"{data['positions'][0]['move_number']}-{data['positions'][-1]['move_number']}\" if data['positions'] else None,\n",
        "                **averaged_metrics\n",
        "            }\n",
        "        else:\n",
        "            result[phase] = {'move_count': 0}\n",
        "\n",
        "    return result\n"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "collapsed_sections": [
        "IImo3GGDzbtq",
        "shqIMv3xzqjY",
        "qKZjFZHmAMwy",
        "OwZJYpb-0mov",
        "TNcTVZyr8ps6",
        "Sim3egYcDfxl",
        "X9Hiiib5GXnd",
        "pzV1bhJOKnzG",
        "_bUH9o7dKu_e"
      ],
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python 3",
      "name": "python3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 0
}