diff --git a/.gitignore b/.gitignore index 5ed6431..06caf39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,34 @@ +# No easy way to test these, so only kept as "manual inspection" tests locally +evaluation_test.go +search_test.go + +# various .nnue models (alternative models or models for testing) +models/ + bin/ todo.md -cmp.py + +# Perft output comparison files perft1 perft2 -cpu.prof + +# Profiling +*.prof + +# SPRT + opening book *.pgn + +# some opening books? *.epd # fastchess config.json +__pycache__/ +.DS_Store + +# --------------------- + # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # diff --git a/engine/evaluation.go b/engine/evaluation.go index 18d3ace..6bfdbaa 100644 --- a/engine/evaluation.go +++ b/engine/evaluation.go @@ -152,7 +152,7 @@ func (pos *Position) EndgameMaterial(color uint8) int32 { return ans } -func Evaluate(pos *Position) int32 { +func EvaluateHCE(pos *Position) int32 { us := pos.Turn them := pos.Turn ^ 1 @@ -201,3 +201,11 @@ func Evaluate(pos *Position) int32 { return eval } + +func EvaluateNNUE(pos *Position) int32 { + return int32(pos.Nnue.Evaluate(pos.Turn) * 1000) +} + +func Evaluate(pos *Position) int32 { + return EvaluateNNUE(pos) +} diff --git a/engine/nnue.go b/engine/nnue.go new file mode 100644 index 0000000..55cdb10 --- /dev/null +++ b/engine/nnue.go @@ -0,0 +1,242 @@ +package engine + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "os" +) + +const ( + Magic = "NNUE" + Version = 1 +) + +type NNUE struct { + NumInputs int + L1 int + + WInput []float32 // [L1 * NumInputs] + BInput []float32 // [L1] + WOutput []float32 // [2 * L1] + BOutput float32 + + FeatureCols [][]float32 + + Acc Accumulator +} + +type Accumulator struct { + Values [2][]float32 +} + +// TODO: Accumulator stack + +// for black's perspective, the board is flipped for evaluation purposes +// this way the first layer parameters only has to "learn" how to play one perspective, which helps with generalization (?) +func FeatureIndex(perspective uint8, pieceColor uint8, pieceType uint8, sq Square) uint16 { + friendly := perspective == pieceColor + pieceIdx := pieceType + if !friendly { + pieceIdx += 6 + } + if perspective == Black { + sq ^= FlipVertical + } + return 64*uint16(pieceIdx) + uint16(sq) +} + +func LoadNNUE(path string) (*NNUE, error) { + // resolve path (either user-provided or embedded default written to a temp file) + resolvedPath, cleanup, err := resolveNNUEPath(path) + if err != nil { + return nil, err + } + // remove temp file (if any) when done + if cleanup != nil { + defer cleanup() + } + + f, err := os.Open(resolvedPath) + if err != nil { + return nil, err + } + defer f.Close() + + // header + magic := make([]byte, 4) + if _, err := io.ReadFull(f, magic); err != nil { + return nil, err + } + if string(magic) != Magic { + return nil, errors.New("invalid NNUE magic") + } + + var version uint32 + if err := binary.Read(f, binary.LittleEndian, &version); err != nil { + return nil, err + } + if version != Version { + return nil, fmt.Errorf("unsupported NNUE version %d", version) + } + + // read as uint32 since python wrote 32 bit ints + var numInputs32, l132 uint32 + if err := binary.Read(f, binary.LittleEndian, &numInputs32); err != nil { + return nil, err + } + if err := binary.Read(f, binary.LittleEndian, &l132); err != nil { + return nil, err + } + + numInputs := int(numInputs32) + l1 := int(l132) + + nnue := &NNUE{ + NumInputs: numInputs, + L1: l1, + } + + // read network parameters + nnue.WInput = make([]float32, l1*numInputs) + nnue.BInput = make([]float32, l1) + nnue.WOutput = make([]float32, 2*l1) + + if err := binary.Read(f, binary.LittleEndian, &nnue.WInput); err != nil { + return nil, err + } + if err := binary.Read(f, binary.LittleEndian, &nnue.BInput); err != nil { + return nil, err + } + if err := binary.Read(f, binary.LittleEndian, &nnue.WOutput); err != nil { + return nil, err + } + if err := binary.Read(f, binary.LittleEndian, &nnue.BOutput); err != nil { + return nil, err + } + + // build helper representation of first layer's weights + nnue.BuildFeatureCols() + + // allocate accumulators for both sides + nnue.Acc.Values[0] = make([]float32, l1) + nnue.Acc.Values[1] = make([]float32, l1) + + return nnue, nil +} + +// must be called first before using anything +func (nnue *NNUE) BuildFeatureCols() { + numInputs := int(nnue.NumInputs) + l1 := int(nnue.L1) + cols := make([][]float32, numInputs) + for f := 0; f < numInputs; f++ { + col := make([]float32, l1) + for o := 0; o < l1; o++ { + col[o] = nnue.WInput[o*numInputs+f] + } + cols[f] = col + } + nnue.FeatureCols = cols +} + +// add a whole set of initial features (overwriting existing features). +// this is the only place where input bias is added +func (nnue *NNUE) Refresh(features []uint16, perspective uint8) { + acc := nnue.Acc.Values[perspective] + copy(acc, nnue.BInput) + for _, f := range features { + col := nnue.FeatureCols[f] + for o := 0; o < len(acc); o += 16 { + acc[o] += col[o] + acc[o+1] += col[o+1] + acc[o+2] += col[o+2] + acc[o+3] += col[o+3] + acc[o+4] += col[o+4] + acc[o+5] += col[o+5] + acc[o+6] += col[o+6] + acc[o+7] += col[o+7] + acc[o+8] += col[o+8] + acc[o+9] += col[o+9] + acc[o+10] += col[o+10] + acc[o+11] += col[o+11] + acc[o+12] += col[o+12] + acc[o+13] += col[o+13] + acc[o+14] += col[o+14] + acc[o+15] += col[o+15] + } + } +} + +// refresh both perspectives +func (nnue *NNUE) RefreshAll(featuresW, featuresB []uint16) { + nnue.Refresh(featuresW, White) + nnue.Refresh(featuresB, Black) +} + +// incrementally add a feature +// NOTE: only using Add() is incorrect, since no bias is added +// remember to also to perform an empty refresh? (ex: in FromFEN) +func (nnue *NNUE) Add(feature uint16, perspective uint8) { + col := nnue.FeatureCols[feature] + acc := nnue.Acc.Values[perspective] + for o := 0; o < len(acc); o += 16 { + acc[o] += col[o] + acc[o+1] += col[o+1] + acc[o+2] += col[o+2] + acc[o+3] += col[o+3] + acc[o+4] += col[o+4] + acc[o+5] += col[o+5] + acc[o+6] += col[o+6] + acc[o+7] += col[o+7] + acc[o+8] += col[o+8] + acc[o+9] += col[o+9] + acc[o+10] += col[o+10] + acc[o+11] += col[o+11] + acc[o+12] += col[o+12] + acc[o+13] += col[o+13] + acc[o+14] += col[o+14] + acc[o+15] += col[o+15] + } +} + +// incrementally remove a feature +func (nnue *NNUE) Remove(feature uint16, perspective uint8) { + col := nnue.FeatureCols[feature] + acc := nnue.Acc.Values[perspective] + for o := 0; o < len(acc); o += 16 { + acc[o] -= col[o] + acc[o+1] -= col[o+1] + acc[o+2] -= col[o+2] + acc[o+3] -= col[o+3] + acc[o+4] -= col[o+4] + acc[o+5] -= col[o+5] + acc[o+6] -= col[o+6] + acc[o+7] -= col[o+7] + acc[o+8] -= col[o+8] + acc[o+9] -= col[o+9] + acc[o+10] -= col[o+10] + acc[o+11] -= col[o+11] + acc[o+12] -= col[o+12] + acc[o+13] -= col[o+13] + acc[o+14] -= col[o+14] + acc[o+15] -= col[o+15] + } +} + +func (nnue *NNUE) Evaluate(side uint8) float32 { + ourAcc := nnue.Acc.Values[side] + theirAcc := nnue.Acc.Values[1-side] + var result float32 = 0.0 + + // ReLU before output layer + for i := 0; i < nnue.L1; i++ { + result += nnue.WOutput[i] * max(ourAcc[i], 0.0) + } + for j := 0; j < nnue.L1; j++ { + result += nnue.WOutput[nnue.L1+j] * max(theirAcc[j], 0.0) + } + result += nnue.BOutput + return result +} diff --git a/engine/nnue/256.nnue b/engine/nnue/256.nnue new file mode 100644 index 0000000..93f75c6 Binary files /dev/null and b/engine/nnue/256.nnue differ diff --git a/engine/nnue_loader.go b/engine/nnue_loader.go new file mode 100644 index 0000000..3a54c89 --- /dev/null +++ b/engine/nnue_loader.go @@ -0,0 +1,71 @@ +package engine + +import ( + "embed" + "fmt" + "os" +) + +//go:embed nnue/*.nnue +var nnueFS embed.FS + +// resolveNNUEPath returns a filesystem path to open for the NNUE data +// +// If `path` is empty: it writes the embedded default NNUE to a temp file and +// returns that temp path and a cleanup func that should be deferred by caller +// +// If `path` is non-empty it is returned as-is (no cleanup function) +// +// error - failure if neither the provided filepath nor the embedded NNUE is available +func resolveNNUEPath(path string) (string, func(), error) { + // caller provides path + if path != "" { + // caller is responsible for not deleting this file + return path, func() {}, nil + } + // embedded default + return writeEmbeddedToTemp("nnue/256.nnue") +} + +// write the embedded file `embeddedName` to a temporary +// file and returns the temp path and a cleanup func that removes it +func writeEmbeddedToTemp(embeddedName string) (string, func(), error) { + data, err := nnueFS.ReadFile(embeddedName) + if err != nil { + return "", nil, fmt.Errorf("embedded default nnue not found (%s): %w", embeddedName, err) + } + + // tmp file at /tmp/nnue-xxxxxx.nnue + tmp, err := os.CreateTemp("", "nnue-*.nnue") + if err != nil { + return "", nil, err + } + // close tmp file at the end + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + }() + + // write NNUE data to tmp file + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return "", nil, err + } + // force data to disk + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return "", nil, err + } + // close tmp file + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return "", nil, err + } + // cleanup function -- deleting tmp file + cleanup := func() { + _ = os.Remove(tmpName) + } + return tmpName, cleanup, nil +} diff --git a/engine/nnue_test.go b/engine/nnue_test.go new file mode 100644 index 0000000..415ca30 --- /dev/null +++ b/engine/nnue_test.go @@ -0,0 +1,47 @@ +package engine_test + +import ( + "fmt" + "silverfish/engine" + "testing" +) + +// entirely manual verification against pytorch eval script for now +// TODO: automatically run the torch eval script +func TestRefreshEval(t *testing.T) { + pos := engine.FromFEN("6k1/5p1p/1q2p1p1/1PnpP3/3N4/1Pr5/P5PP/3QR1K1 w - - 3 37") + + // For 256_test.nnue (one nonzero weight) + //pos := engine.FromFEN("8/8/8/8/8/8/P7/8 w - - 0 1") + + // var featuresW []uint16 + // var featuresB []uint16 + // for sq := engine.SquareA1; sq <= engine.SquareH8; sq++ { + // if pos.Board[sq] >= 10 { + // featuresW = append(featuresW, engine.FeatureIndex(engine.White, engine.Black, pos.Board[sq]-uint8(10), sq)) + // featuresB = append(featuresB, engine.FeatureIndex(engine.Black, engine.Black, pos.Board[sq]-uint8(10), sq)) + // } else if pos.Board[sq] != 6 { + // featuresW = append(featuresW, engine.FeatureIndex(engine.White, engine.White, pos.Board[sq], sq)) + // featuresB = append(featuresB, engine.FeatureIndex(engine.Black, engine.White, pos.Board[sq], sq)) + // } + // } + fmt.Println(1000 * pos.Nnue.Evaluate(pos.Turn)) + + pos.DoMove(engine.NewMoveFromStr("d1a1")) + // nnue.Remove(engine.FeatureIndex(engine.White, engine.White, engine.Queen, engine.SquareD1), engine.White) + // nnue.Remove(engine.FeatureIndex(engine.Black, engine.White, engine.Queen, engine.SquareD1), engine.Black) + // nnue.Add(engine.FeatureIndex(engine.White, engine.White, engine.Queen, engine.SquareA1), engine.White) + // nnue.Add(engine.FeatureIndex(engine.Black, engine.White, engine.Queen, engine.SquareA1), engine.Black) + + fmt.Println(1000 * pos.Nnue.Evaluate(pos.Turn)) + + pos.DoMove(engine.NewMoveFromStr("b6a5")) + // nnue.Remove(engine.FeatureIndex(engine.White, engine.Black, engine.Queen, engine.SquareB6), engine.White) + // nnue.Remove(engine.FeatureIndex(engine.Black, engine.Black, engine.Queen, engine.SquareB6), engine.Black) + // nnue.Add(engine.FeatureIndex(engine.White, engine.Black, engine.Queen, engine.SquareA5), engine.White) + // nnue.Add(engine.FeatureIndex(engine.Black, engine.Black, engine.Queen, engine.SquareA5), engine.Black) + + fmt.Println(1000 * pos.Nnue.Evaluate(pos.Turn)) + + // t.Errorf("") +} diff --git a/engine/position.go b/engine/position.go index bb66a65..16a365f 100644 --- a/engine/position.go +++ b/engine/position.go @@ -13,7 +13,11 @@ type Position struct { // Turn: 0=white 1=black Turn uint8 - Board [64]uint8 + // 0-5: white pieces + // 10-15: black pieces + // 5: NoSquare + Board [64]uint8 + Pieces [2][6]Bitboard Sides [2]Bitboard Blockers Bitboard @@ -40,6 +44,8 @@ type Position struct { // past states History []State + + Nnue *NNUE } type State struct { @@ -64,6 +70,13 @@ func StartingPosition() Position { func (pos *Position) PutPiece(sq Square, piece uint8, color uint8) { sqBB := Bitboard(1 << sq) pos.Pieces[color][piece] |= sqBB + + // nnue operations come before this adjustment by 10 (0-5 expected; FeatureIndex performs adjustment by 6) + fWhite := FeatureIndex(White, color, piece, sq) + fBlack := FeatureIndex(Black, color, piece, sq) + pos.Nnue.Add(fWhite, White) + pos.Nnue.Add(fBlack, Black) + if color == Black { piece += 10 } @@ -73,6 +86,12 @@ func (pos *Position) PutPiece(sq Square, piece uint8, color uint8) { } func (pos *Position) PutPiecesBB(pieces [2][6]Bitboard) { + // this check is for testing purposes only + // .. well this whole function is for testing purposes only + if pos.Nnue == nil { + pos.Nnue, _ = LoadNNUE("") + } + for sq := SquareA1; sq <= SquareH8; sq++ { pos.RemovePiece(sq) for piece := Pawn; piece <= King; piece++ { @@ -98,6 +117,12 @@ func (pos *Position) RemovePiece(sq Square) { if color != NoColor { pos.Pieces[color][piece] &^= sqBB } + + // nnue operations come after this adjustment by 10 (0-5 expected; FeatureIndex performs adjustment by 6) + fWhite := FeatureIndex(White, color, piece, sq) + fBlack := FeatureIndex(Black, color, piece, sq) + pos.Nnue.Remove(fWhite, White) + pos.Nnue.Remove(fBlack, Black) } func (pos *Position) Equals(otherPos Position) bool { @@ -110,6 +135,7 @@ func (pos *Position) Equals(otherPos Position) bool { } // (color, piece) +// see engine.Position.Board documentation for encoding func (pos *Position) GetSquare(sq Square) (uint8, uint8) { p := pos.Board[sq] if p == NoPiece { @@ -197,9 +223,18 @@ func (pos *Position) ToFEN() string { return fen } +// note: full refresh automatically on creation func FromFEN(fen string) Position { var pos Position + var err error + pos.Nnue, err = LoadNNUE("") + if err != nil { + panic("error loading NNUE file") + } + // empty refresh to add biases + pos.Nnue.RefreshAll([]uint16{}, []uint16{}) + parts := strings.Split(fen, " ") if len(parts) < 6 { panic("invalid FEN: not enough parts") @@ -223,7 +258,8 @@ func FromFEN(fen string) Position { spaces := uint8(char - '0') sq := NewSquare(rank, file) for i := uint8(0); i < spaces; i++ { - pos.RemovePiece(sq) + //pos.RemovePiece(sq) + pos.Board[sq] = NoPiece sq++ } file += spaces @@ -248,11 +284,12 @@ func FromFEN(fen string) Position { } } - if turnPart == "w" { + switch turnPart { + case "w": pos.Turn = White - } else if turnPart == "b" { + case "b": pos.Turn = Black - } else { + default: panic("invalid turn field") } @@ -333,13 +370,14 @@ func (pos *Position) DoMove(move Move) { } // update castling rights - if movingPiece == King { + switch movingPiece { + case King: if ourColor == White && from == SquareE1 { pos.CastlingRights &^= 0b0011 } else if ourColor == Black && from == SquareE8 { pos.CastlingRights &^= 0b1100 } - } else if movingPiece == Rook { + case Rook: switch { case from == SquareA1 && ourColor == White: pos.CastlingRights &^= WhiteQueenside diff --git a/engine/types.go b/engine/types.go index 6fcedad..d8d3b97 100644 --- a/engine/types.go +++ b/engine/types.go @@ -192,6 +192,7 @@ const BB_FileA = Bitboard(0x101010101010101) const BB_FileH = Bitboard(0x8080808080808080) const BB_Empty = Bitboard(0) const BB_Full = Bitboard(0xffffffffffffffff) +const FlipVertical = 0b111000 // xor mask for flipping a square vertically // rows func RankOf(square Square) uint8 { diff --git a/tools/visualizer.py b/tools/bitboard_visualizer.py similarity index 100% rename from tools/visualizer.py rename to tools/bitboard_visualizer.py diff --git a/tools/cmp_perfts.py b/tools/cmp_perfts.py new file mode 100644 index 0000000..70f1ee4 --- /dev/null +++ b/tools/cmp_perfts.py @@ -0,0 +1,27 @@ +# used in early development to compare 2 perft results (typically stockfish vs our engine) + +def compare_files(file1_path, file2_path): + with open(file1_path, 'r') as f1: + lines1 = set(line.strip() for line in f1 if line.strip()) + + with open(file2_path, 'r') as f2: + lines2 = set(line.strip() for line in f2 if line.strip()) + + only_in_file1 = lines1 - lines2 + only_in_file2 = lines2 - lines1 + + if not only_in_file1 and not only_in_file2: + print("The files have the same content (order ignored).") + else: + if only_in_file1: + print("Lines only in file 1:") + for line in sorted(only_in_file1): + print(" ", line) + if only_in_file2: + print("Lines only in file 2:") + for line in sorted(only_in_file2): + print(" ", line) + +# put perft result in perft1 and perft2 +compare_files('perft1', 'perft2') + diff --git a/tools/eval_visualizer.py b/tools/eval_visualizer.py new file mode 100644 index 0000000..c094880 --- /dev/null +++ b/tools/eval_visualizer.py @@ -0,0 +1,330 @@ +import chess +import numpy as np +from pathlib import Path +import struct +import tkinter as tk +from tkinter import ttk, filedialog, messagebox + +GRID_SIZE = 8 +CELL_SIZE = 64 +BOARD_SIZE = GRID_SIZE * CELL_SIZE +MARGIN = 30 +CANVAS_SIZE = BOARD_SIZE + 2 * MARGIN +FONT = ("Consolas", 12) + +PIECE_LETTERS = ["K", "Q", "R", "B", "N", "P"] + +FLIP = 0b111000 +def feature(perspective, piece_color, piece_type, sq: int) -> int: + friendly = (piece_color == perspective) + piece_idx = piece_type + (0 if friendly else 6) # 0..11 + if perspective == chess.BLACK: + sq ^= FLIP + return 64 * piece_idx + sq # 0..767 + +class NNUEModel: + def __init__(self): + self.loaded = False + self.num_inputs = None + self.l1 = None + self.W_in = None # shape (L1, NUM_INPUTS) + self.b_in = None # shape (L1,) + self.W_out = None # shape (1, 2*L1) + self.b_out = None # scalar + + def load_from_file(self, path: Path): + with open(path, "rb") as f: + magic = f.read(4) + if magic != b"NNUE": + raise ValueError("not an NNUE file") + version_bytes = f.read(4) + version = struct.unpack(" tuple[float, float]: + if not self.loaded: + raise RuntimeError("model not loaded") + + num_inputs = self.num_inputs + feats_side = np.zeros(num_inputs, dtype=np.float32) + feats_opp = np.zeros(num_inputs, dtype=np.float32) + + piece_type_to_idx = {chess.PAWN:0, chess.KNIGHT:1, chess.BISHOP:2, + chess.ROOK:3, chess.QUEEN:4, chess.KING:5} + + stm = board.turn # true=white, false=black + + for sq in chess.SQUARES: + piece = board.piece_at(sq) + if piece is None: + continue + pt_idx = piece_type_to_idx[piece.piece_type] + feature_index = feature(stm, piece.color, pt_idx, sq) + opp_feature_index = feature(not stm, piece.color, pt_idx, sq) + + feats_side[feature_index] = 1.0 + feats_opp[opp_feature_index] = 1.0 + + # np.set_printoptions(threshold=np.inf) + # print(self.W_in.transpose()) + + # W_in shape: (L1, NUM_INPUTS) + a_side = np.dot(self.W_in, feats_side) + self.b_in + a_opp = np.dot(self.W_in, feats_opp) + self.b_in + # print(a_side) + # print("\n\n\n\n\n\n") + # print(a_opp) + h_side = np.maximum(0.0, a_side) + h_opp = np.maximum(0.0, a_opp) + concat = np.concatenate([h_side, h_opp]) # shape (2*L1,) + raw = float(np.dot(self.W_out, concat) + self.b_out) # in pawns (assumption) + centipawns = raw * 1000.0 + return raw, centipawns + + +class NNUEVisualizer(tk.Tk): + def __init__(self): + super().__init__() + self.title("NNUE Evaluation Visualizer") + self.geometry(f"{CANVAS_SIZE+500}x{CANVAS_SIZE+260}") + self.resizable(False, False) + + self.board = chess.Board.empty() # start with empty board + self.model = NNUEModel() + + # drawing canvas + self.canvas = tk.Canvas(self, width=CANVAS_SIZE, height=CANVAS_SIZE, bg="#aaa") + self.canvas.grid(row=0, column=0, columnspan=6, padx=5, pady=5) + self.canvas.bind("", self.on_click_left) + self.canvas.bind("", self.on_click_right) + + # FEN entry + ttk.Label(self, text="FEN:", font=FONT).grid(row=1, column=0, sticky="w", padx=5) + self.var_fen = tk.StringVar() + self.entry_fen = ttk.Entry(self, textvariable=self.var_fen, font=FONT, width=80) + self.entry_fen.grid(row=1, column=1, columnspan=4, sticky="w", padx=5) + self.entry_fen.bind("", self.on_fen_enter) + ttk.Button(self, text="Apply FEN", command=self.on_fen_apply).grid(row=1, column=5, padx=5) + + # NNUE path entry + load + ttk.Label(self, text="NNUE file:", font=FONT).grid(row=2, column=0, sticky="w", padx=5) + self.var_nnue_path = tk.StringVar() + self.entry_nnue = ttk.Entry(self, textvariable=self.var_nnue_path, font=FONT, width=60) + self.entry_nnue.grid(row=2, column=1, columnspan=3, sticky="w", padx=5) + ttk.Button(self, text="Browse...", command=self.browse_nnue).grid(row=2, column=4, padx=2) + ttk.Button(self, text="Load NNUE", command=self.load_nnue).grid(row=2, column=5, padx=2) + + # Piece palette + self.selected_piece = tk.StringVar(value="P") + self.selected_color = tk.StringVar(value="white") + palette_frame = ttk.Frame(self) + palette_frame.grid(row=3, column=0, columnspan=6, pady=8) + ttk.Label(palette_frame, text="Piece:", font=FONT).grid(row=0, column=0, padx=4) + for i, p in enumerate(PIECE_LETTERS): + b = ttk.Radiobutton(palette_frame, text=p, value=p, variable=self.selected_piece) + b.grid(row=0, column=1 + i) + ttk.Label(palette_frame, text="Color:", font=FONT).grid(row=1, column=0, padx=4) + ttk.Radiobutton(palette_frame, text="White", value="white", variable=self.selected_color).grid(row=1, column=1) + ttk.Radiobutton(palette_frame, text="Black", value="black", variable=self.selected_color).grid(row=1, column=2) + ttk.Button(palette_frame, text="Delete Mode (Right-click)", command=lambda: self.selected_piece.set("")).grid(row=1, column=3, padx=8) + ttk.Button(palette_frame, text="Clear Board", command=self.clear_board).grid(row=1, column=4, padx=8) + ttk.Button(palette_frame, text="Starting Position", command=self.starting_position).grid(row=1, column=5, padx=8) + + # evaluation display + eval_frame = ttk.Frame(self) + eval_frame.grid(row=4, column=0, columnspan=6, pady=6) + ttk.Label(eval_frame, text="Evaluation:", font=("Consolas", 14, "bold")).grid(row=0, column=0, padx=5) + self.var_eval = tk.StringVar(value="(no model loaded)") + ttk.Label(eval_frame, textvariable=self.var_eval, font=("Consolas", 14)).grid(row=0, column=1, padx=5) + self.var_raw = tk.StringVar(value="") + ttk.Label(eval_frame, textvariable=self.var_raw, font=("Consolas", 10)).grid(row=1, column=0, columnspan=2) + + self.draw_board() + self.update_fen() + + def browse_nnue(self): + path = filedialog.askopenfilename(filetypes=[("NNUE files", "*.nnue"), ("All files","*.*")]) + if path: + self.var_nnue_path.set(path) + + def load_nnue(self): + path = self.var_nnue_path.get().strip() + if not path: + messagebox.showwarning("No file", "Please select a .nnue file path first.") + return + try: + self.model.load_from_file(Path(path)) + messagebox.showinfo("Loaded", f"Loaded NNUE (NUM_INPUTS={self.model.num_inputs}, L1={self.model.l1})") + self.update_evaluation() + except Exception as e: + messagebox.showerror("Load error", f"Failed to load NNUE: {e}") + + def clear_board(self): + self.board = chess.Board.empty() + self.draw_board() + self.update_fen() + self.update_evaluation() + + def starting_position(self): + self.board = chess.Board() # standard starting pos + self.draw_board() + self.update_fen() + self.update_evaluation() + + def on_fen_enter(self, event): + self.on_fen_apply() + + def on_fen_apply(self): + fen_text = self.var_fen.get().strip() + if not fen_text: + return + try: + # Allow user to input only piece placement or full FEN. + # chess.Board accepts full FEN. If just piece placement, append default fields. + if " " not in fen_text: + fen_text_full = fen_text + " w - - 0 1" + else: + fen_text_full = fen_text + b = chess.Board(fen=fen_text_full) + # Keep exactly the placement, turn, etc. but our canvas only displays piece placement + # we set board to b + self.board = b + self.draw_board() + self.update_fen() + self.update_evaluation() + except Exception as e: + messagebox.showerror("FEN error", f"Could not parse FEN: {e}") + + def update_fen(self): + # produce full FEN string from self.board + try: + fen = self.board.fen() + self.var_fen.set(fen) + except Exception: + self.var_fen.set("") + + def square_from_coords(self, x, y): + clicked_x = x - MARGIN + clicked_y = y - MARGIN + c = int(clicked_x // CELL_SIZE) + r = int(clicked_y // CELL_SIZE) + if 0 <= c < GRID_SIZE and 0 <= r < GRID_SIZE: + file = c + rank = 7 - r + sq = chess.square(file, rank) + return sq + return None + + def on_click_left(self, event): + sq = self.square_from_coords(event.x, event.y) + if sq is None: + return + sel = self.selected_piece.get() + if sel: + color = chess.WHITE if self.selected_color.get() == "white" else chess.BLACK + # map letter to piece type + letter = sel.upper() + piece_map = {"P": chess.PAWN, "N": chess.KNIGHT, "B": chess.BISHOP, + "R": chess.ROOK, "Q": chess.QUEEN, "K": chess.KING} + piece = chess.Piece(piece_map[letter], color) + # place piece (replace whatever is there) + self.board.set_piece_at(sq, piece) + else: + # if no piece selected, toggle through removing/placing? We'll toggle empty -> nothing + self.board.remove_piece_at(sq) + self.draw_board() + self.update_fen() + self.update_evaluation() + + def on_click_right(self, event): + # delete piece on right-click + sq = self.square_from_coords(event.x, event.y) + if sq is None: + return + self.board.remove_piece_at(sq) + self.draw_board() + self.update_fen() + self.update_evaluation() + + def draw_board(self): + self.canvas.delete("all") + # squares + for r in range(GRID_SIZE): + for c in range(GRID_SIZE): + x0, y0 = c*CELL_SIZE + MARGIN, r*CELL_SIZE + MARGIN + x1, y1 = x0+CELL_SIZE, y0+CELL_SIZE + color = "#eee" if (r+c)%2==0 else "#555" + self.canvas.create_rectangle(x0, y0, x1, y1, fill=color, outline="") + # piece? + file = c + rank = 7 - r + sq = chess.square(file, rank) + piece = self.board.piece_at(sq) + if piece: + # draw letter + letter = piece.symbol() + # letter returns lowercase for black, uppercase for white + # we draw uppercase on canvas and prefix color via fill + display = letter.upper() + fill = "white" if piece.color == chess.WHITE else "black" + self.canvas.create_text((x0+x1)//2, (y0+y1)//2, text=display, font=("Consolas", 28, "bold"), fill=fill) + + # column labels + for c in range(GRID_SIZE): + x = c*CELL_SIZE + CELL_SIZE//2 + MARGIN + y = MARGIN//2 + self.canvas.create_text(x, y, text=chr(ord('A') + c), font=FONT, fill="black") + # row labels + for r in range(GRID_SIZE): + x = MARGIN // 2 + y = r*CELL_SIZE + CELL_SIZE//2 + MARGIN + self.canvas.create_text(x, y, text=str(8 - r), font=FONT, fill="black") + + def update_evaluation(self): + if not self.model.loaded: + self.var_eval.set("(no model loaded)") + self.var_raw.set("") + return + try: + raw_pawns, cp = self.model.evaluate(self.board) + # show centipawns rounded + if abs(cp) > 100000: + eval_str = f"{cp:.1f} cp" + else: + eval_str = f"{int(round(cp))} cp" + self.var_eval.set(eval_str) + self.var_raw.set(f"Raw (pawns): {raw_pawns:.6f} (converted: {cp:.2f} cp)") + except Exception as e: + self.var_eval.set("(eval error)") + self.var_raw.set(str(e)) + +if __name__ == "__main__": + app = NNUEVisualizer() + app.mainloop() diff --git a/tools/run_perft_wait.sh b/tools/run_perft_wait.sh new file mode 100755 index 0000000..b07d576 --- /dev/null +++ b/tools/run_perft_wait.sh @@ -0,0 +1,17 @@ +#!/usr/bin/expect -f + +set timeout -1 + +spawn go run ./cmd/silverfish --profile + +send "uci\r" +send "position startpos\r" +send "go perft depth 5\r" + +expect { + -re {Perft result:} { + send "quit\r" + } +} + +expect eof