Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `internal/engine`: perft (with start-position and Kiwipete regression tests),
static evaluation (material + piece-square tables + bishop pair), and an
iterative-deepening negamax alpha-beta search with quiescence search,
MVV-LVA move ordering, and a hard time budget.
a shared lock-free transposition table, Lazy SMP, and a hard time budget.
- `internal/engine`: search heuristics — killer moves + a `[side][from][to]`
history table in move ordering, null-move pruning, and late move reductions.
- `internal/uci`: a UCI protocol loop (`uci`, `isready`, `ucinewgame`,
`position`, `go`, `stop`, `quit`) supporting `go depth`, `go movetime`, and
`go wtime/btime`.
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ Early. The engine plays legal chess via a UCI loop with:
- **Move generation** — delegated to `dragontoothmg` (verified with perft).
- **Evaluation** — material + piece-square tables + bishop pair.
- **Search** — iterative deepening, negamax alpha-beta, quiescence search,
MVV-LVA move ordering, a shared transposition table with hash-move ordering,
Lazy SMP (multi-threaded search), hard time limits.
a shared transposition table with hash-move ordering, MVV-LVA + killer-move +
history move ordering, null-move pruning, late move reductions, Lazy SMP
(multi-threaded search), hard time limits.

Not yet implemented: killer/history heuristics, null-move pruning, opening book,
endgame tablebases. See the [roadmap](#roadmap).
Not yet implemented: aspiration windows / PVS, opening book, endgame tablebases.
See the [roadmap](#roadmap).

## Install

Expand Down Expand Up @@ -94,8 +95,9 @@ docs/ architecture notes and ADRs
- [x] Quiescence search + MVV-LVA move ordering
- [x] Transposition table (Zobrist hash from `dragontoothmg`) + hash-move ordering
- [x] Lazy SMP — multi-threaded search over the shared TT (`Threads` UCI option)
- [ ] Killer moves + history heuristic
- [ ] Null-move pruning, late move reductions
- [x] Killer moves + history heuristic
- [x] Null-move pruning + late move reductions
- [ ] Aspiration windows / principal variation search
- [ ] Opening book (Polyglot) and Syzygy tablebase probing
- [ ] Strength testing harness (SPRT via cutechess-cli)

Expand Down
137 changes: 137 additions & 0 deletions cmd/gochess/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package main

import (
"io"
"os"
"os/exec"
"strings"
"testing"
)

// capture redirects os.Stdout for the duration of fn and returns what was
// written. The CLI helpers print directly to stdout, so this is the only way to
// exercise them.
func capture(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdout = w
done := make(chan string, 1)
go func() {
b, _ := io.ReadAll(r)
done <- string(b)
}()
fn()
_ = w.Close()
os.Stdout = orig
return <-done
}

func TestRunBench(t *testing.T) {
out := capture(t, runBench)
if !strings.Contains(out, "bench:") || !strings.Contains(out, "nps)") {
t.Errorf("bench output missing summary line:\n%s", out)
}
if strings.Count(out, "bestmove") < 4 {
t.Errorf("expected one line per bench position:\n%s", out)
}
}

func TestRunPerftDefaultAndExplicit(t *testing.T) {
if out := capture(t, func() { runPerft(nil) }); !strings.Contains(out, "nps") {
t.Errorf("default perft produced no series:\n%s", out)
}
out := capture(t, func() {
runPerft([]string{"2", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"})
})
if !strings.Contains(out, "depth 1") || !strings.Contains(out, "depth 2") {
t.Errorf("perft series incomplete:\n%s", out)
}
}

func TestRunPerftRejectsBadDepth(t *testing.T) {
if os.Getenv("GOCHESS_BAD_DEPTH") == "1" {
runPerft([]string{"notanumber"})
return
}
// runPerft calls os.Exit(2) on a bad depth; run it in a subprocess.
out, err := runSelf(t, "TestRunPerftRejectsBadDepth", "GOCHESS_BAD_DEPTH=1")
if err == nil {
t.Fatalf("expected a non-zero exit, output:\n%s", out)
}
if !strings.Contains(out, "invalid depth") {
t.Errorf("missing diagnostic:\n%s", out)
}
}

func TestMainDispatch(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
{[]string{"version"}, "gochess "},
{[]string{"help"}, "usage: gochess"},
} {
out := capture(t, func() {
os.Args = append([]string{"gochess"}, tc.args...)
main()
})
if !strings.Contains(out, tc.want) {
t.Errorf("main %v: output missing %q:\n%s", tc.args, tc.want, out)
}
}
}

func TestMainSpeaksUCIOnStdin(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
origIn := os.Stdin
os.Stdin = r
defer func() { os.Stdin = origIn }()
go func() {
_, _ = io.WriteString(w, "uci\nquit\n")
_ = w.Close()
}()

out := capture(t, func() {
os.Args = []string{"gochess"} // no subcommand -> UCI mode
main()
})
if !strings.Contains(out, "uciok") {
t.Errorf("no-arg main did not speak UCI:\n%s", out)
}
}

func TestMainUnknownCommandExits(t *testing.T) {
if os.Getenv("GOCHESS_UNKNOWN") == "1" {
os.Args = []string{"gochess", "bogus"}
main()
return
}
out, err := runSelf(t, "TestMainUnknownCommandExits", "GOCHESS_UNKNOWN=1")
if err == nil {
t.Fatalf("expected non-zero exit for unknown command:\n%s", out)
}
if !strings.Contains(out, "unknown command") {
t.Errorf("missing diagnostic:\n%s", out)
}
}

// runSelf re-executes this test binary running only testName, with extra env
// set, and returns the combined output. Used to observe os.Exit paths.
func runSelf(t *testing.T, testName, env string) (string, error) {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
cmd := exec.Command(exe, "-test.run=^"+testName+"$")
cmd.Env = append(os.Environ(), env)
b, err := cmd.CombinedOutput()
return string(b), err
}
12 changes: 9 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ unapply closure), FEN parsing, an incrementally-updated Zobrist hash
deepening around a negamax alpha-beta core, with:
- quiescence search at the horizon (captures and promotions only),
- transposition-table probes/stores with hash-move ordering,
- MVV-LVA move ordering,
- move ordering: TT move, promotions, MVV-LVA captures, two killer moves
per ply, then quiet moves by a `[side][from][to]` history score,
- null-move pruning (skipped in check, at shallow depth, with only pawns
left, or right after another null move; the pass board is built through
FEN so the shared TT never sees a stale hash),
- late move reductions — late quiet moves are searched a ply or two
shallower and re-searched at full depth only if they beat alpha,
- mate-distance-aware scoring (`mateScore - ply`), ply-rebased through the TT,
- Lazy SMP: `SearchParams.Threads` workers deepen independently on their own
board copy over one shared TT; the deepest completed result wins,
Expand All @@ -65,8 +71,8 @@ Search is synchronous, so `stop` is a no-op and `bestmove` is emitted as soon as

## Deliberately not here yet

Killer/history heuristics, null-move pruning, LMR, aspiration windows, opening
book, tablebases, pondering, `SearchMoves`/`MultiPV`.
Aspiration windows, principal variation search, static exchange evaluation,
search extensions, opening book, tablebases, pondering, `SearchMoves`/`MultiPV`.

## Key invariants

Expand Down
20 changes: 11 additions & 9 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ choices see the [ADRs](adr/).
## Architecture

`engine.Search` is the search core. It is a plain function around a `searcher`
value (node counter + deadline) with a few free collaborators rather than an
injected object graph — small enough that dependency injection would cost more
than it buys.
value (node counter, deadline, per-ply killer moves, `[side][from][to]` history
table) with a few free collaborators rather than an injected object graph —
small enough that dependency injection would cost more than it buys.

| collaborator | responsibility |
|---|---|
| `dragontoothmg.Board` | board state, legal move generation, `Apply` / unapply, Zobrist hash, FEN — never reimplemented (see [ADR 0002](adr/0002-use-dragontoothmg-for-move-generation.md)) |
| `engine.Evaluate` | static evaluation from the side-to-move's view (positive = better for that side) |
| `engine.orderMoves` | orders moves to maximise alpha-beta cut-offs; TT/hash move first, then promotions, then MVV-LVA captures, then quiets |
| `searcher.orderMoves` | orders moves to maximise alpha-beta cut-offs: TT/hash move, promotions, MVV-LVA captures, the two killer moves for the ply, then quiets by history score |
| `engine.TT` | shared, lock-free transposition table keyed by `Board.Hash`; probed/stored inside `negamax` and seeded into move ordering |
| `searcher` | carries the node count, TT handle, shared stop flag and wall-clock deadline; answers `timeUp` and flips `stopped` |
| `searcher` | carries the node count, TT handle, shared stop flag, wall-clock deadline, and the per-searcher killer/history tables; answers `timeUp` and flips `stopped` |

`internal/uci` is the protocol layer and the only place that prints `info` /
`bestmove`. It owns the persistent `engine.TT` (`Hash` option) and the `Threads`
Expand All @@ -36,7 +36,9 @@ and `bench` subcommands.
- [Transposition Table](https://www.chessprogramming.org/Transposition_Table) — power-of-two table keyed by `dragontoothmg.Board.Hash()`; stores EXACT / LOWER / UPPER bounds with the best move, mate scores rebased by ply on store and probe. Lock-free (Hyatt XOR) so Lazy-SMP workers share one table. The stored move seeds move ordering even when the entry is too shallow to cut
- [Lazy SMP](https://www.chessprogramming.org/Lazy_SMP) — `Threads` workers run iterative deepening in parallel on private board copies over the shared TT; workers start at staggered depths so they diverge, and the deepest completed result wins
- [Quiescence Search](https://www.chessprogramming.org/Quiescence_Search) at the horizon — captures and promotions only, depth-bounded by `maxPly`
- [Move Ordering](https://www.chessprogramming.org/Move_Ordering) — TT/hash move first, then promotions, then [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, then quiet moves
- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) — `R = 2`, or `3` from depth 6; tried only when not in check, at depth ≥ 3, with a non-mate beta, with non-pawn material for the side to move, and not immediately after another null move. The pass position is built through FEN because `dragontoothmg` keeps the Zobrist hash and en-passant square in unexported fields
- [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions) — from depth 3, quiet moves past the third in the ordered list are searched 1 ply shallower (2 from the seventh move at depth ≥ 5); a reduced search that beats alpha is repeated at full depth. Moves that give or evade check are never reduced
- [Move Ordering](https://www.chessprogramming.org/Move_Ordering) — TT/hash move, then promotions, then [MVV-LVA](https://www.chessprogramming.org/MVV-LVA) captures, then the two [killer moves](https://www.chessprogramming.org/Killer_Heuristic) for the ply, then quiet moves by [history](https://www.chessprogramming.org/History_Heuristic) score (`depth²` per beta cut-off, per `[side][from][to]`, clamped). Killer and history tables are per-searcher, so Lazy-SMP workers keep independent copies
- [Mate-distance scoring](https://www.chessprogramming.org/Mate_Distance_Pruning) — `mateScore - ply`, so the shortest mate is preferred; a proven mate ends iterative deepening early
- Draw detection — the fifty-move rule (`Halfmoveclock >= 100`) is scored `0` inside the tree
- Time management — a hard wall-clock budget checked every 2048 nodes; the UCI layer spends `1/30` of the remaining clock when the GUI sends `wtime` / `btime` instead of `movetime`
Expand All @@ -55,10 +57,10 @@ update and no pawn or evaluation hash.

### Search

- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — null-window scout + re-search
- [Killer](https://www.chessprogramming.org/Killer_Heuristic) and [history](https://www.chessprogramming.org/History_Heuristic) heuristics in `orderMoves`
- [Null Move Pruning](https://www.chessprogramming.org/Null_Move_Pruning) and [Late Move Reductions](https://www.chessprogramming.org/Late_Move_Reductions)
- [Principal Variation Search](https://www.chessprogramming.org/Principal_Variation_Search) — null-window scout + re-search (LMR already does a null-window reduced search; PVS would extend that to every move past the first)
- [Aspiration Windows](https://www.chessprogramming.org/Aspiration_Windows) around the previous iteration's score
- Tuning the null-move and LMR formulas (verification search, adaptive `R`, reduction from the history score) against SPRT
- [Check extensions](https://www.chessprogramming.org/Check_Extensions) and other search extensions
- [Static Exchange Evaluation](https://www.chessprogramming.org/Static_Exchange_Evaluation) for capture ordering and bad-capture pruning in quiescence
- Threefold-[repetition](https://www.chessprogramming.org/Repetitions) detection (needs a position history the current `Search` does not keep)
- A real principal variation in the `info` line (only `bestmove` is reported today)
Expand Down
Loading