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
1 change: 1 addition & 0 deletions ChessRealms.Engine.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<File Path="docs/performance.md" />
<File Path="docs/pgn.md" />
<File Path="docs/releasing.md" />
<File Path="docs/repetition.md" />
<File Path="docs/san.md" />
</Folder>
<Folder Name="/scripts/" Id="9f3eeaa3-c0d2-4d93-af81-f93234779361">
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ Exchange a single game's tags and mainline in PGN:
```csharp
PgnGame document = PgnGame.FromGame(game, result: PgnResult.WhiteWin);
string pgn = document.ToPgn();
PgnGame imported = PgnGame.Parse(pgn);
ChessGame replay = imported.ToGame();
PgnGame imported = PgnGame.Parse(pgn, out ChessGame replay);
```

`PgnGame` is an immutable snapshot. Its declared result can represent resignation
Expand All @@ -93,13 +92,13 @@ Save and restore the current move line, including a successful draw claim:

```csharp
string json = GameRecord.Serialize(game.ExportRecord());
GameRecord record = GameRecord.Deserialize(json);
ChessGame restored = ChessGame.FromRecord(record);
ChessGame restored = ChessGame.FromRecordJson(json);
```

JSON records include a schema version, initial FEN, coordinate moves and an
optional draw claim. Import validates the game by replaying its moves. The
optional draw claim. `FromRecordJson` validates and restores the game in one replay. The
restored game preserves history, repetition counts and undo support.
Use `GameRecord.Deserialize` when an immutable record is needed instead of a game.

The package intentionally exposes only the high-level types in the
`ChessRealms.Engine` namespace. Bitboards, encoded moves, magic tables, raw
Expand Down
66 changes: 57 additions & 9 deletions docs/game-rules-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pseudolegal moves are filtered for king safety.
move, to identify the winner.
- `GetPiece(Square)` reads one square. `CopyBoardTo(Span<ChessPiece>)` copies all
64 squares in a1-to-h8 order. `History` is a read-only snapshot of successful
moves with the move, FEN before/after and move flags. `UndoMove()` restores
moves with the move, FEN before/after and move flags. `PlyCount` reads the number
of recorded half-moves without constructing a history snapshot. `UndoMove()` restores
the previous position, repetition count and outcome, including after a
terminal move or a draw claim made after that move. It returns false if there
is no move to undo. A claim itself is not a move and cannot be undone when
Expand All @@ -45,6 +46,39 @@ destination of at least 64 entries, with `ChessPiece.Empty` for vacant squares.
The raw bitboard position is deliberately not exposed; use FEN, board inspection,
legal moves, history and `Clone()` for consumer scenarios.

## FEN output

`ToFen()` returns the current position as a canonical FEN string. After a move,
the string is formatted on first request and cached for that position.
`TryFormatFen(Span<char> destination, out int charsWritten)` writes the same text
into a caller-owned buffer without creating a FEN string. Both work after the
game finishes and leave its position, history and outcome unchanged.

If the buffer is too small, `TryFormatFen` returns false, sets `charsWritten` to
zero and leaves the entire destination unchanged. On success, only the written
prefix is modified. FEN uses invariant ASCII notation. Move counters have
arbitrary precision, so callers must accommodate their size rather than assume
a fixed maximum FEN length.

`GetFenAtPly(int ply)` returns FEN for one position in the current recorded line.
Zero selects the initial position, even when the game started from a custom FEN;
`PlyCount` selects the current position. A ply counts one recorded half-move,
independently of the fullmove number in FEN. For history entry `i`, ply `i` is
the position before the move and ply `i + 1` is the position after it.

`TryFormatFenAtPly(int ply, Span<char> destination, out int charsWritten)` writes
that position directly to a caller-owned buffer without creating or caching a
FEN string. An out-of-range ply makes `GetFenAtPly` throw
`ArgumentOutOfRangeException`; the span method returns false with zero written
and leaves the destination unchanged. The same span failure contract applies
when the buffer is too small.

Individual string queries format and cache only the selected position. Reading
`History` materializes all before/after strings, reusing any already cached
strings. Its public entries retain their value semantics and remain unchanged
after later moves, undo or a different continuation. Historical queries on the
game follow its current line; undone positions are no longer addressable there.

## Standard Algebraic Notation

`FormatSan` and `TryFormatSan` format a playable coordinate move in the current
Expand All @@ -61,6 +95,9 @@ behavior, examples and performance tradeoffs.
PGN model. `ToPgn()` and `WriteTo(TextWriter)` export it; `Parse` and `TryParse`
import one game's tags, initial FEN, canonical SAN mainline and declared result.
`ToGame()` replays an independent game with history and undo.
`PgnGame.Parse(text, out ChessGame game)` returns the document and the game built
during validation in one replay; the corresponding `TryParse` overload returns
both objects or neither. Later game changes do not affect the document.

The declared `PgnResult` is separate from `ChessGame.Outcome`: a decisive result
can describe resignation while the position still has legal moves. PGN does not
Expand Down Expand Up @@ -107,15 +144,21 @@ whole record succeeds; it does not change the record or the original game.

## JSON game records

Use the static `GameRecord.Serialize` and `GameRecord.Deserialize` methods to
save and load records:
Use `GameRecord.Serialize` to save a record and `ChessGame.FromRecordJson` to
restore a playable game directly:

```csharp
string json = GameRecord.Serialize(game.ExportRecord());
GameRecord loaded = GameRecord.Deserialize(json);
ChessGame restored = ChessGame.FromRecord(loaded);
ChessGame restored = ChessGame.FromRecordJson(json);
```

`FromRecordJson(string json)` validates and restores the entire game in one
replay, returning an independent instance with history, repetition counts,
outcome, draw claims and undo support. It accepts the same versioned record
format as `GameRecord.Deserialize`. Use `GameRecord.Deserialize(json)` when an
immutable record is needed; `ChessGame.FromRecord(record)` remains available
for records already in memory.

The version-one format is:

```json
Expand Down Expand Up @@ -157,10 +200,12 @@ supply it. It is independent of the package version: package `1.1.0` does not
imply schema version `1.1.0`. A missing or unsupported schema version is rejected;
no migration of other versions is currently provided.

Deserialization checks the document and validates the entire game through the
same replay used by `ChessGame.FromRecord`, including draw claims. It returns an
immutable record only after all checks succeed. Calling `FromRecord` afterwards
replays it again to create the game to use. Invalid JSON, unsupported versions,
Both JSON entry points check the document and validate the entire game through
the same replay used by `ChessGame.FromRecord`, including draw claims. They
return a result only after all checks succeed. `GameRecord.Deserialize` returns
an immutable record; calling `FromRecord` afterwards replays it again.
`ChessGame.FromRecordJson` returns the game built during validation directly.
Invalid JSON, unsupported versions,
invalid FEN, unplayable moves and unavailable claims throw `JsonException`.
Replay errors retain the original `FormatException` as their inner exception;
move errors include their zero-based index. Null method arguments throw
Expand Down Expand Up @@ -212,6 +257,9 @@ compares placement, side to move, castling rights and the en passant square
only when a *legal* en passant capture exists; move counters are ignored. A
pinned pawn does not make en passant available for this purpose.

The [repetition guide](repetition.md) explains position identity, counting,
undo, hash collisions and why restoring counts requires the game line.

Dead-position detection covers king versus king, king plus one bishop or knight
versus king, and bishops-only positions where every bishop is on the same
square color. It does not attempt general dead-position proof, including
Expand Down
50 changes: 46 additions & 4 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ BenchmarkDotNet. Run from the repository root after restoring dependencies:

```sh
dotnet build src/ChessRealms.Engine.Benchmark -c Release --no-restore
dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*CoreBenchmarks*' '*GameApiBenchmarks*' '*SanBenchmarks*' '*PgnBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 8 --iterationTime 150 --artifacts artifacts/benchmarks/local --exporters JSON
dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*CoreBenchmarks*' '*FenBenchmarks*' '*GameApiBenchmarks*' '*HistoryBenchmarks*' '*RecordJsonBenchmarks*' '*SanBenchmarks*' '*PgnBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 8 --iterationTime 150 --artifacts artifacts/benchmarks/local --exporters JSON
```

Use a single filter to focus on one suite:

| Suite | Coverage |
| --- | --- |
| `CoreBenchmarks` | Pseudolegal generation, depth-three perft and FEN formatting for the start position and Kiwipete. |
| `FenBenchmarks` | Fresh string and span formatting, and the first four FEN fields, for the start position, Kiwipete, en passant and large counters. |
| `GameApiBenchmarks` | Legal moves, FEN, history, cloning, move + undo, records and JSON at 0 and 20 plies. |
| `HistoryBenchmarks` | Replay with no FEN reads, per-move FEN/history reads, a final history read, warmed snapshots, cloning, historical FEN and move + undo at 0, 20 and 120 plies. |
| `RecordJsonBenchmarks` | Deserialize + restore versus direct JSON restoration, both alone and followed by a move and JSON export, at 0, 20 and 120 plies. |
| `SanBenchmarks` | Span/string formatting, parsing and move + undo for opening, ambiguous and mating moves. |
| `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies. |
| `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies, including Parse + ToGame versus import with the validated game. |

Setup and input preparation are outside measurements. PGN scenarios use prefixes
of the same validated line. Move + undo returns to the same position, with
Expand All @@ -24,6 +27,22 @@ and collection growth. Export to `TextWriter.Null` excludes output storage,
encoding and I/O. Running without arguments starts the existing depth-six perft
benchmark, which can take longer.

`ImportAndRestore` and `ImportWithGame` both parse the same input and create the
PGN document and game. The former replays again through `ToGame()`; the latter
returns the game built during validation. Both benchmark methods return the
game and discard the document after parsing. Setup checks matching document
output and game records outside measurements.

History scenarios use prefixes of the same validated PGN line. Replay creates
a fresh game per operation. Snapshot and individual historical FEN queries use
a game whose strings have already been requested during setup. The move + undo
cases distinguish applying a move alone from reading its FEN before undoing it.

JSON restoration scenarios use the same line and compare equivalent results.
Each operation creates a fresh game; input JSON and the next legal move are
prepared during setup. Move-and-save cases include record export and JSON
serialization, but exclude database, network and other server work.

For longer measurements in isolated benchmark processes, omit `--inProcess`
and the short iteration settings. Compare the same fixtures on the same machine
and runtime, and inspect confidence intervals and benchmark warnings before
Expand All @@ -41,6 +60,18 @@ cross-check small allocation readings from the in-process benchmark harness;
its different warmup duration means results need not match BDN byte for byte.
It does not measure execution time, retained memory or peak live heap.

For a separate approximate retained-heap comparison:

```sh
dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --history-memory-probe
```

This retains batches of 256 games at 0, 20 and 120 plies, measuring the heap
after full collections. It distinguishes games with no FEN queries, a current
FEN query, and a complete history query. Returned history snapshots are not kept.
Use the same runtime and fixture for comparisons; GC heap deltas include noise
and are not exact object-size or peak-memory measurements.

Keep generated reports and temporary audit notes under `artifacts/benchmarks/`,
which Git ignores. Record the revision, runtime and command with results used
in a PR. Rerun relevant scenarios after changes instead of treating historical
Expand All @@ -49,7 +80,18 @@ numbers as current API guarantees.
## API usage

`History`, `GetLegalMoves()` and `Clone()` return independently owned snapshots
or games. Read a history snapshot once before traversing it. `ToFen()` reuses an
existing FEN string; `FenBefore` references the preceding position's string.
or games. Read a history snapshot once before traversing it. Use `PlyCount` for
the recorded half-move count without materializing history. `ToFen()` and
`GetFenAtPly()` format and cache a position's FEN on first request. `History`
materializes all entries, reusing those caches; `FenBefore` references the
preceding position's string.
`TryFormatFen` writes to a caller-owned character span; `FenBenchmarks` measures
formatting separately from reading the cached string through `ToFen()`.
`TryFormatFenAtPly` writes a single historical position without populating the
string cache or materializing history.
Use `ChessGame.FromRecordJson` to restore a playable game directly from record
JSON with one validated replay. `GameRecord.Deserialize` followed by
`ChessGame.FromRecord` performs two replays; keep that path when the separate
record is needed for its own purpose.
The [SAN guide](san.md) covers span queries and text application; the
[PGN guide](pgn.md) covers model ownership, export and restoration.
39 changes: 31 additions & 8 deletions docs/pgn.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,35 @@ from the mainline under the engine's [rule boundaries](game-rules-api.md).

```csharp
ReadOnlySpan<char> input = "1. e4 e5 2. Nf3 *";
if (PgnGame.TryParse(input, out PgnGame? document, out PgnDiagnostic? error))
if (PgnGame.TryParse(input, out PgnGame? document, out ChessGame? game, out PgnDiagnostic? error))
{
ChessGame replay = document.ToGame();
// Both outputs are non-null here. The game was built during PGN validation.
Console.WriteLine($"Declared result: {document.Result}; board: {game.ToFen()}");
}
else
{
Console.WriteLine($"{error.Code} at {error.Line}:{error.Column}: {error.Message}");
}
```

For the throwing form, use `PgnGame.Parse(input, out ChessGame game)`, which
returns the `PgnGame` document. Both overloads replay the mainline once and
return that validated game with history, counters, repetition and undo. The
document owns an immutable snapshot: changing or undoing the returned game
does not change its moves, tags or declared result. As with `ToGame()`, the
declared result does not create a new engine ending.

Use `PgnGame.Parse(input)` or the three-argument `TryParse` when only the
document is needed. Calling `ToGame()` on it later creates an independent
game through another replay. The overloads returning both objects avoid that
second replay when importing for immediate game use.

`Parse` throws `PgnParseException`, a `FormatException` exposing the same
`Diagnostic`. `TryParse` returns false with a null document on any format,
legality or unsupported-feature error; it never exposes a partly imported game.
`Diagnostic`. All overloads use the same validation and diagnostics.
`TryParse` returns false with a null document and, for the overload returning a
game, a null game on any format, legality or unsupported-feature error. This
includes errors after legal moves or after the termination marker; neither
output exposes a partially imported game.
Success returns a null diagnostic. Neither operation takes or mutates an
existing game.

Expand Down Expand Up @@ -133,10 +149,17 @@ and redundant setup tags are normalized on export.
## Costs and validation

PGN import consumes SAN from character spans, but processing a document still
allocates model and replay data. `WriteTo` avoids creating the complete output
string. `ToGame()` performs a replay each time, so keep the returned game when
you need to continue using it. Coordinate-based move application pays no PGN
processing cost.
allocates model and replay data. Use the `Parse`/`TryParse` overload returning
the game when both are needed; keep that instance for subsequent operations.
`ToGame()` performs another replay each time. `WriteTo` avoids creating the
complete output string. Coordinate-based move application pays no PGN processing
cost.

Export advances one position through the validated mainline, sharing legal-move
generation and SAN formatting with `ChessGame`. It does not build another game
history, undo stack or repetition dictionary. Move numbers use span formatting
with a string fallback for unusually large counters. Parsing the initial FEN,
ordering tags and storing output can still allocate.

Tests cover model ownership, result conflicts, custom FEN/counters, escaping,
canonical output, structured diagnostics, unsupported features, special moves,
Expand Down
Loading
Loading