AXLR / Catalog / personal

chess_fen_validate

Validate a chess position given in Forsyth-Edwards Notation (FEN): the six fields, piece and pawn counts, one king each, no pawns on the back ranks, castling rights consistent with the king and rook squares, a plausible en-passant square, the move counters, and that the side not to move is not in check; returns every problem found, a board diagram, who is in check and the material count.

Install

/tools install chess_fen_validate
/tools install chess_fen_validate@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · chess_fen_validate 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool chess_fen_validate; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
fenstringyeslength ≤ 200
strict_en_passantbooleannofalseRequire that an en-passant capture is actually possible (newer convention)
unicodebooleannofalseDiagram with chess symbols
Input schema (JSON)
{
  "type": "object",
  "required": [
    "fen"
  ],
  "additionalProperties": false,
  "properties": {
    "fen": {
      "type": "string",
      "maxLength": 200
    },
    "strict_en_passant": {
      "type": "boolean",
      "default": false,
      "description": "Require that an en-passant capture is actually possible (newer convention)"
    },
    "unicode": {
      "type": "boolean",
      "default": false,
      "description": "Diagram with chess symbols"
    }
  }
}

Example

Input

{
  "fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
}

Output (stdout)

{
  "valid": true,
  "side_to_move": "black",
  "en_passant": {
    "square": "e3",
    "captured_pawn": "e4",
    "capture_possible_from": []
  },
  "errors": []
}

Files

The command the install card registers is python3 chess_fen_validate.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

chess_fen_validate.py · sha256 e61337ab73908e132c85b8eccb1f7985024c55735eee3b9c85ed2388ff9aa236
#!/usr/bin/env python3
"""Validate a chess position in Forsyth-Edwards Notation (FEN): syntax of the six fields, piece
counts, kings, pawns on the back ranks, castling rights against the king and rook squares, the
en-passant square, the move counters and that the side not to move is not in check. Returns a
board diagram, the material count and every problem found.

Input (JSON on stdin):
  fen                     the FEN string
  strict_en_passant       true: also require that an en-passant capture is actually possible (the
                          newer convention); default false (the PGN standard's: record the square
                          after every two-square pawn move)
  unicode                 true: diagram with chess symbols instead of letters
"""
import json
import sys

FILES = "abcdefgh"
VALUES = {"p": 1, "n": 3, "b": 3, "r": 5, "q": 9, "k": 0}
NAMES = {"p": "pawns", "n": "knights", "b": "bishops", "r": "rooks", "q": "queens", "k": "kings"}
UNICODE = {"K": "♔", "Q": "♕", "R": "♖", "B": "♗", "N": "♘", "P": "♙",
           "k": "♚", "q": "♛", "r": "♜", "b": "♝", "n": "♞", "p": "♟"}


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def sq(name):
    """'e4' -> (file 0-7, rank 0-7)."""
    return FILES.index(name[0]), int(name[1]) - 1


def sqname(f, r):
    return f"{FILES[f]}{r + 1}"


def parse_board(text, errors):
    ranks = text.split("/")
    if len(ranks) != 8:
        errors.append(f"the board has {len(ranks)} ranks; it needs 8 separated by '/'")
        return None
    board = {}
    for i, rank_text in enumerate(ranks):
        rank = 7 - i
        f = 0
        previous_digit = False
        for ch in rank_text:
            if ch.isdigit():
                if ch in "09":
                    errors.append(f"rank {rank + 1}: '{ch}' is not a valid empty-square count (1-8)")
                    return None
                if previous_digit:
                    errors.append(f"rank {rank + 1}: two digits in a row ('{rank_text}')")
                f += int(ch)
                previous_digit = True
            elif ch in "pnbrqkPNBRQK":
                if f > 7:
                    errors.append(f"rank {rank + 1} has more than 8 squares ('{rank_text}')")
                    return None
                board[(f, rank)] = ch
                f += 1
                previous_digit = False
            else:
                errors.append(f"rank {rank + 1}: '{ch}' is not a piece letter (pnbrqk / PNBRQK) or digit")
                return None
        if f != 8:
            errors.append(f"rank {rank + 1} has {f} squares, not 8 ('{rank_text}')")
            return None
    return board


def attackers(board, target, by_white):
    """Squares of pieces of the given colour that attack target."""
    tf, tr = target
    found = []
    own = (lambda p: p.isupper()) if by_white else (lambda p: p.islower())
    # pawns
    dr = -1 if by_white else 1
    for df in (-1, 1):
        s = (tf + df, tr + dr)
        p = board.get(s)
        if p and own(p) and p.lower() == "p":
            found.append(s)
    # knights
    for df, d in ((1, 2), (2, 1), (-1, 2), (-2, 1), (1, -2), (2, -1), (-1, -2), (-2, -1)):
        s = (tf + df, tr + d)
        p = board.get(s)
        if p and own(p) and p.lower() == "n":
            found.append(s)
    # king
    for df in (-1, 0, 1):
        for d in (-1, 0, 1):
            if df or d:
                s = (tf + df, tr + d)
                p = board.get(s)
                if p and own(p) and p.lower() == "k":
                    found.append(s)
    # sliders
    for (df, d), kinds in (((1, 0), "rq"), ((-1, 0), "rq"), ((0, 1), "rq"), ((0, -1), "rq"),
                           ((1, 1), "bq"), ((1, -1), "bq"), ((-1, 1), "bq"), ((-1, -1), "bq")):
        f, r = tf + df, tr + d
        while 0 <= f < 8 and 0 <= r < 8:
            p = board.get((f, r))
            if p:
                if own(p) and p.lower() in kinds:
                    found.append((f, r))
                break
            f, r = f + df, r + d
    return found


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = set(request) - {"fen", "strict_en_passant", "unicode"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    fen = request.get("fen")
    if not isinstance(fen, str) or not fen.strip() or len(fen) > 200:
        fail("fen must be a non-empty string of at most 200 characters")
    strict_ep = request.get("strict_en_passant", False)
    use_unicode = request.get("unicode", False)
    if not isinstance(strict_ep, bool) or not isinstance(use_unicode, bool):
        fail("strict_en_passant and unicode must be booleans")
    errors, warnings = [], []
    fields = fen.split()
    result = {"fen": fen.strip()}
    if len(fields) != 6:
        errors.append(f"FEN has {len(fields)} fields; it needs 6 (board, side, castling, en passant, halfmove clock, fullmove number)")
    fields += [None] * (6 - len(fields))
    board = parse_board(fields[0], errors)
    side, castling, ep, half, full = fields[1:6]
    if side not in ("w", "b"):
        errors.append(f"side to move must be 'w' or 'b', not {side!r}")
    if castling is not None and castling != "-":
        if any(c not in "KQkq" for c in castling) or len(set(castling)) != len(castling) or \
                "".join(sorted(castling, key="KQkq".index)) != castling:
            errors.append(f"castling field {castling!r} must be '-' or KQkq letters in that order without repeats")
    if ep is not None and ep != "-":
        if len(ep) != 2 or ep[0] not in FILES or ep[1] not in "36":
            errors.append(f"en passant field {ep!r} must be '-' or a square on rank 3 or 6")
    halfmove = fullmove = None
    if half is not None:
        if half.isdigit():
            halfmove = int(half)
        else:
            errors.append(f"halfmove clock {half!r} must be a non-negative integer")
    if full is not None:
        if full.isdigit() and int(full) >= 1:
            fullmove = int(full)
        else:
            errors.append(f"fullmove number {full!r} must be a positive integer")

    if board is not None:
        counts = {p: 0 for p in "PNBRQKpnbrqk"}
        for p in board.values():
            counts[p] += 1
        for colour, upper in (("white", True), ("black", False)):
            c = {k: counts[k.upper() if upper else k] for k in "pnbrqk"}
            if c["k"] != 1:
                errors.append(f"{colour} has {c['k']} kings; there must be exactly one")
            if c["p"] > 8:
                errors.append(f"{colour} has {c['p']} pawns; at most 8")
            total = sum(c.values())
            if total > 16:
                errors.append(f"{colour} has {total} pieces; at most 16")
            extra_pieces = max(0, c["q"] - 1) + max(0, c["r"] - 2) + max(0, c["b"] - 2) + max(0, c["n"] - 2)
            if extra_pieces > 8 - c["p"]:
                errors.append(f"{colour} has {extra_pieces} pieces beyond the initial set but only {8 - c['p']} pawns could have promoted")
        for (f, r), p in board.items():
            if p.lower() == "p" and r in (0, 7):
                errors.append(f"pawn on {sqname(f, r)}: pawns cannot stand on the first or eighth rank")
        # castling rights against the pieces
        if castling and castling != "-" and all(c in "KQkq" for c in castling):
            need = {"K": ("e1", "h1", "K", "R"), "Q": ("e1", "a1", "K", "R"), "k": ("e8", "h8", "k", "r"), "q": ("e8", "a8", "k", "r")}
            for right in castling:
                ksq, rsq, king, rook = need[right]
                if board.get(sq(ksq)) != king:
                    errors.append(f"castling right {right} but the {'white' if right.isupper() else 'black'} king is not on {ksq}")
                elif board.get(sq(rsq)) != rook:
                    errors.append(f"castling right {right} but there is no {'white' if right.isupper() else 'black'} rook on {rsq}")
        # en passant square
        ep_info = None
        if ep and ep != "-" and len(ep) == 2 and ep[0] in FILES and ep[1] in "36" and side in ("w", "b"):
            f, r = sq(ep)
            if (side == "w") != (r == 5):
                errors.append(f"en passant square {ep} is on the wrong rank for {('white' if side == 'w' else 'black')} to move "
                              f"(rank {'6' if side == 'w' else '3'} expected)")
            else:
                mover_pawn = "p" if side == "w" else "P"
                pawn_sq = (f, r - 1) if side == "w" else (f, r + 1)
                origin = (f, r + 1) if side == "w" else (f, r - 1)
                if board.get(pawn_sq) != mover_pawn:
                    errors.append(f"en passant square {ep} but no {'black' if side == 'w' else 'white'} pawn on {sqname(*pawn_sq)} that just moved two squares")
                if (f, r) in board or origin in board:
                    errors.append(f"en passant square {ep}: {ep} and {sqname(*origin)} must be empty after a two-square pawn move")
                capturer = "P" if side == "w" else "p"
                capturers = [sqname(f + d, pawn_sq[1]) for d in (-1, 1) if 0 <= f + d < 8 and board.get((f + d, pawn_sq[1])) == capturer]
                ep_info = {"square": ep, "captured_pawn": sqname(*pawn_sq), "capture_possible_from": capturers}
                if strict_ep and not capturers:
                    errors.append(f"en passant square {ep} is recorded but no pawn can capture en passant (strict convention)")
            if halfmove not in (None, 0):
                errors.append(f"en passant square {ep} means a pawn just moved, so the halfmove clock must be 0, not {halfmove}")
        if ep_info:
            result["en_passant"] = ep_info
        # checks
        kings = {p: s for s, p in board.items() if p in "Kk"}
        if counts["K"] == 1 and counts["k"] == 1 and side in ("w", "b"):
            white_checkers = attackers(board, kings["k"], True)    # white pieces attacking the black king
            black_checkers = attackers(board, kings["K"], False)   # black pieces attacking the white king
            to_move_checkers = black_checkers if side == "w" else white_checkers
            other_checkers = white_checkers if side == "w" else black_checkers
            if other_checkers:
                errors.append(f"the side not to move ({'black' if side == 'w' else 'white'}) is in check from "
                              + ", ".join(sqname(*s) for s in other_checkers) + "; that position cannot arise")
            if len(to_move_checkers) > 2:
                errors.append(f"the side to move is in check from {len(to_move_checkers)} pieces; at most 2 is possible")
            result["in_check"] = bool(to_move_checkers)
            if to_move_checkers:
                result["checkers"] = [f"{board[s]}{sqname(*s)}" for s in to_move_checkers]
        # counters plausibility
        if halfmove is not None and fullmove is not None and side in ("w", "b"):
            plies = 2 * (fullmove - 1) + (1 if side == "b" else 0)
            if halfmove > plies:
                warnings.append(f"halfmove clock {halfmove} exceeds the {plies} plies played since move 1 (possible only from a set-up position)")
        if halfmove is not None and halfmove >= 100:
            warnings.append("halfmove clock is 100 or more: either player may claim a draw under the fifty-move rule")
        # diagram and material
        diagram = []
        for r in range(7, -1, -1):
            row = []
            for f in range(8):
                p = board.get((f, r))
                row.append((UNICODE[p] if use_unicode else p) if p else ".")
            diagram.append(f"{r + 1} " + " ".join(row))
        diagram.append("  " + " ".join(FILES))
        result["diagram"] = diagram
        material = {}
        for colour, upper in (("white", True), ("black", False)):
            c = {NAMES[k]: counts[k.upper() if upper else k] for k in "pnbrqk"}
            c["points"] = sum(VALUES[k] * counts[k.upper() if upper else k] for k in "pnbrqk")
            material[colour] = c
        material["balance"] = material["white"]["points"] - material["black"]["points"]
        material["scale"] = "pawn 1, knight 3, bishop 3, rook 5, queen 9"
        result["material"] = material
    result["side_to_move"] = {"w": "white", "b": "black"}.get(side)
    result["castling"] = castling
    result["halfmove_clock"] = halfmove
    result["fullmove_number"] = fullmove
    result["valid"] = not errors
    result["errors"] = errors
    result["warnings"] = warnings
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/chess_fen_validate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/chess_fen_validate/1.0.0/chess_fen_validate.py