diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be1391e..1afb4684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.6.0] - 2026-10-21 + +`moved_piece_before` と `moved_piece_before_move`、通常の着手結果 `MoveApplyFacts`、 +`clone_for_search`、反復判定、容量確保 API は引き続き提供する。 +二歩の照会 `has_pawn_on_file` と利きの primitive `rook_rank_attacks` も維持する。 + +### Deprecated + +- 探索用のキー予測、着手前メタデータ、探索スタック用の型、予約領域専用の着手、 + 履歴保持上限の設定と、未使用の補助 API を非推奨にした。 + すべて 1.x 中は従来のシグネチャと動作を維持し、2.0.0 で削除予定とする。 + 対象と移行方法は [1.6.0 の移行ガイド](docs/book/src/getting-started/migration-1.6.md) を参照する。 + +### Performance + +- `Position::clone()` の履歴チャンクの複製方法を改善した。 + 全履歴の独立した所有権と、事前確保済みの容量を維持する。 +- `Position::clone_from()` が複製先の履歴領域を再利用する。 + 全履歴と複製元の予約容量を維持し、複製先に余る領域は保持する。 + 同じ保存先へ局面を繰り返し複製する場合に利用できる。 +- `Position::clone_for_search()` が根履歴の board key 索引を準備する。 + 一致候補がない場合だけ根以前の千日手走査を省き、一致候補がある場合は従来の詳細判定を行う。 + 連続王手と持ち駒の優劣を含む判定の意味は変えない。 + 索引構築と照会には費用があり、すべての局面で高速化するものではない。 +- `rook_rank_attacks()` を専用実装から `rook_attacks()` と段マスクの合成に変更した。 + 戻り値は維持するが、従来実装との速度の同等性は保証しない。 + +### Maintenance + +- bindings 内部の未使用 getter 3 件と開発用依存 `shogi_core` を削除した。 +- 計測、配置実験、外部データ照合用の example は source と再現手順を維持し、crates.io 配布から除外した。 + `sfen_roundtrip` は配布に含める。 +- Python の配布物から `__pycache__` と `.pyc` を除外した。 +- 探索連携の文書を `MoveApplyFacts` と `current_state_cache()` の実装に合わせて修正した。 + ## [1.5.1] - 2026-10-19 ### Fixed @@ -619,7 +654,8 @@ identical to 1.0.0. - The standard and AVX2 Python distributions are mutually exclusive because both provide the same import package. -[Unreleased]: https://github.com/nyoki-mtl/rsshogi/compare/v1.5.1...HEAD +[Unreleased]: https://github.com/nyoki-mtl/rsshogi/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/nyoki-mtl/rsshogi/compare/v1.5.1...v1.6.0 [1.5.1]: https://github.com/nyoki-mtl/rsshogi/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/nyoki-mtl/rsshogi/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/nyoki-mtl/rsshogi/compare/v1.3.1...v1.4.0 diff --git a/Cargo.lock b/Cargo.lock index 60aafb95..2080d5b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,7 +500,7 @@ checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "rsshogi" -version = "1.5.1" +version = "1.6.0" dependencies = [ "criterion", "encoding_rs", @@ -509,14 +509,13 @@ dependencies = [ "proptest", "serde", "serde_json", - "shogi_core", "thiserror", "zstd", ] [[package]] name = "rsshogi-py" -version = "1.5.1" +version = "1.6.0" dependencies = [ "pyo3", "rsshogi", @@ -586,12 +585,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "shogi_core" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0a7de4ff7c66bf0b6346bda1d530c94a496f8aa3317938923886c58191aa14" - [[package]] name = "syn" version = "2.0.106" diff --git a/README.md b/README.md index f3c68f1a..b9859622 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # rsshogi -この文書は rsshogi 1.5.1 を対象とする。 -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 -旧定跡 API の削除を含むため、更新時は移行手順を確認する。 - [![crates.io](https://img.shields.io/crates/v/rsshogi?style=flat-square&logo=rust&logoColor=white)](https://crates.io/crates/rsshogi) [![docs.rs](https://img.shields.io/docsrs/rsshogi?style=flat-square&logo=rust&logoColor=white)](https://docs.rs/rsshogi) [![Documentation](https://img.shields.io/badge/docs-mdBook-1f6feb?style=flat-square&logo=readthedocs&logoColor=white)](https://nyoki-mtl.github.io/rsshogi/) [![PyPI](https://img.shields.io/pypi/v/rsshogi?style=flat-square&logo=pypi&logoColor=white)](https://pypi.org/project/rsshogi/) [![Python](https://img.shields.io/pypi/pyversions/rsshogi?style=flat-square&logo=python&logoColor=white)](https://nyoki-mtl.github.io/rsshogi/getting-started/installation.html) [![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat-square&logo=opensourceinitiative&logoColor=white)](https://github.com/nyoki-mtl/rsshogi/blob/main/LICENSE) `rsshogi` は、将棋局面の表現、合法手生成、1 手詰め判定、棋譜や定跡の入出力、深層学習データの操作を行う MIT ライセンスの Rust ライブラリおよび Python パッケージである。 @@ -16,7 +12,7 @@ Native Book は独立圧縮ブロックを持つ wire version 4 を使用する ```toml [dependencies] -rsshogi = "1.5.1" +rsshogi = "1.6.0" ``` デフォルトでは探索エンジン向けの最小コア(`board`、`mate`、`types`、`movegen`)のみが有効になる。 @@ -24,11 +20,14 @@ rsshogi = "1.5.1" ```toml [dependencies] -rsshogi = { version = "1.5.1", features = ["records", "book"] } +rsshogi = { version = "1.6.0", features = ["records", "book"] } ``` 各 Feature の詳細は [rsshogi マニュアルの全体アーキテクチャ](https://nyoki-mtl.github.io/rsshogi/architecture.html) および [docs.rs の Feature flags](https://docs.rs/crate/rsshogi/latest/features) を参照。 +1.6.0 は公開済み API の互換性を維持する。 +非推奨 API は 2.0.0 で削除予定であり、対象と移行方法は [移行ガイド](docs/book/src/getting-started/migration-1.6.md) を参照。 + ### Python Python 3.10 以降で利用できる。 @@ -79,10 +78,6 @@ print(board.to_sfen()) - **[Rust API リファレンス(docs.rs)](https://docs.rs/rsshogi)**:Rust クレートの全公開型と関数の仕様。 - **[CHANGELOG](CHANGELOG.md)**:バージョンごとの変更履歴。 -## 利用例 - -- [rsshogi-nnue-mini](https://github.com/nyoki-mtl/rsshogi-nnue-mini):`rsshogi` を基盤とした USI 将棋エンジンの実装例。 - ## ライセンス MIT diff --git a/crates/rsshogi-py/Cargo.toml b/crates/rsshogi-py/Cargo.toml index c2c8716b..299c8bc2 100644 --- a/crates/rsshogi-py/Cargo.toml +++ b/crates/rsshogi-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsshogi-py" -version = "1.5.1" +version = "1.6.0" edition = "2024" publish = false license = "MIT" diff --git a/crates/rsshogi-py/README-avx2.md b/crates/rsshogi-py/README-avx2.md index 69d0c7a4..66a533e6 100644 --- a/crates/rsshogi-py/README-avx2.md +++ b/crates/rsshogi-py/README-avx2.md @@ -1,7 +1,5 @@ # rsshogi-avx2 -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 - `rsshogi-avx2` は、AVX2 対応 x86_64 CPU 向けに最適化した `rsshogi` パッケージです。 import 名は標準版と同じ `rsshogi` で、公開 API は共通です。 diff --git a/crates/rsshogi-py/README.md b/crates/rsshogi-py/README.md index 893f6094..df8f4808 100644 --- a/crates/rsshogi-py/README.md +++ b/crates/rsshogi-py/README.md @@ -1,7 +1,5 @@ # rsshogi Python パッケージ -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 - `rsshogi` は、将棋局面の操作、合法手生成、1 手詰め判定、棋譜や定跡の入出力、深層学習データの操作を提供する Python パッケージである。 Rust コアライブラリ `rsshogi` の PyO3 バインディングとして実装されている。 diff --git a/crates/rsshogi-py/pyproject-avx2.toml b/crates/rsshogi-py/pyproject-avx2.toml index 09e0bf42..817793ba 100644 --- a/crates/rsshogi-py/pyproject-avx2.toml +++ b/crates/rsshogi-py/pyproject-avx2.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "rsshogi-avx2" -version = "1.5.1" +version = "1.6.0" description = "Python bindings for the rsshogi core library (AVX2 build)." readme = "README-avx2.md" requires-python = ">=3.10" @@ -25,4 +25,4 @@ module-name = "rsshogi._rsshogi" python-source = "python" manifest-path = "Cargo.toml" rustc-args = ["-C", "target-feature=+avx2"] -exclude = ["python/rsshogi/*.pyd", "python/rsshogi/*.so"] +exclude = ["python/rsshogi/*.pyd", "python/rsshogi/*.so", "**/__pycache__/**", "**/*.pyc"] diff --git a/crates/rsshogi-py/pyproject.toml b/crates/rsshogi-py/pyproject.toml index 2eeaaa2c..9317b13f 100644 --- a/crates/rsshogi-py/pyproject.toml +++ b/crates/rsshogi-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "rsshogi" -version = "1.5.1" +version = "1.6.0" description = "Python tools for shogi board handling, move legality, records, and training-data formats." readme = "README.md" requires-python = ">=3.10" @@ -23,4 +23,4 @@ urls = { "Repository" = "https://github.com/nyoki-mtl/rsshogi" } [tool.maturin] module-name = "rsshogi._rsshogi" python-source = "python" -exclude = ["python/rsshogi/*.pyd", "python/rsshogi/*.so"] +exclude = ["python/rsshogi/*.pyd", "python/rsshogi/*.so", "**/__pycache__/**", "**/*.pyc"] diff --git a/crates/rsshogi-py/python/rsshogi/__init__.py b/crates/rsshogi-py/python/rsshogi/__init__.py index 5e27f0c5..32501a87 100644 --- a/crates/rsshogi-py/python/rsshogi/__init__.py +++ b/crates/rsshogi-py/python/rsshogi/__init__.py @@ -40,7 +40,7 @@ from . import book, core, initial_positions, numpy, policy, record, sazpack, svg, types, usi # Version -__version__ = "1.5.1" +__version__ = "1.6.0" __all__ = [ # Submodules diff --git a/crates/rsshogi-py/src/lib.rs b/crates/rsshogi-py/src/lib.rs index a701b4f8..6ae677ce 100644 --- a/crates/rsshogi-py/src/lib.rs +++ b/crates/rsshogi-py/src/lib.rs @@ -216,11 +216,6 @@ impl PyMove { pub(crate) fn from_move(inner: Move) -> Self { Self { inner } } - - #[allow(dead_code)] - pub(crate) fn inner(&self) -> Move { - self.inner - } } #[pyclass(from_py_object, name = "AperyMove")] diff --git a/crates/rsshogi-py/src/python_types.rs b/crates/rsshogi-py/src/python_types.rs index 65f2cbda..aa3a5ca8 100644 --- a/crates/rsshogi-py/src/python_types.rs +++ b/crates/rsshogi-py/src/python_types.rs @@ -863,20 +863,10 @@ impl PyMoveType { pub(crate) fn from_move_type(inner: MoveType) -> Self { Self { inner } } - - #[allow(dead_code)] - pub(crate) fn inner(&self) -> MoveType { - self.inner - } } impl PyRepetitionState { pub(crate) fn from_state(inner: RepetitionState) -> Self { Self { inner } } - - #[allow(dead_code)] - pub(crate) fn inner(&self) -> RepetitionState { - self.inner - } } diff --git a/crates/rsshogi-py/uv.lock b/crates/rsshogi-py/uv.lock index 16375a7e..47a40166 100644 --- a/crates/rsshogi-py/uv.lock +++ b/crates/rsshogi-py/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.10" [[package]] name = "rsshogi" -version = "1.5.1" +version = "1.6.0" source = { editable = "." } diff --git a/crates/rsshogi/Cargo.toml b/crates/rsshogi/Cargo.toml index 5ca803db..ab08b9fb 100644 --- a/crates/rsshogi/Cargo.toml +++ b/crates/rsshogi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsshogi" -version = "1.5.1" +version = "1.6.0" edition = "2024" rust-version = "1.95" license = "MIT" @@ -8,18 +8,23 @@ description = "Reusable Rust shogi primitives for board state, move generation, repository = "https://github.com/nyoki-mtl/rsshogi" keywords = ["shogi"] categories = ["games"] -exclude = ["src/bin/**"] +exclude = [ + "src/bin/**", + "examples/native_book_layout_probe.rs", + "examples/native_book_measure.rs", + "examples/native_book_ybb_verify.rs", + "examples/sazpack_measure.rs", + "examples/sbinpack_size_breakdown.rs", + "examples/sbk_open_measure.rs", +] -# We intend to publish only this crate to crates.io (B案). -# Python bindings are provided separately via crates/rsshogi-py. +# crates.io には本クレートを公開し、Python bindings は別パッケージで配布する。 publish = true [dependencies] thiserror = "2" log = "0.4" -# data-only crates: records feature 専用(非 data 利用ゼロを grep 確認済み)。 -# optional 化して `records` の dep: でのみ引くことで、エンジンビルド(data 系 off)では -# これらの crate を一切コンパイルしない(task 0421 / Decision 4 の compile 削減の要)。 +# 棋譜処理用の依存は、records feature を有効にした場合だけコンパイルする。 encoding_rs = { version = "0.8", optional = true } serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } @@ -30,11 +35,9 @@ zstd = { version = "0.13", optional = true, default-features = false } default = [] hash-128 = [] -# ─── Data ecosystem features (task 0421 / Decision 4: Option B-split) ─── -# エンジン consumer(nnue/dfpn/az)は default(data 系 off)でビルドし、 -# py が必要分を opt-in する。粒度は consumer の実利用に合わせる。 +# 棋譜、定跡、学習データ用の機能は用途に応じて個別に有効化する。 -# policy-labels: labels::policy::CompactMoveLabel 等。py + az が使用(az 単独有効化のため独立)。 +# policy-labels: labels::policy::CompactMoveLabel 等の Policy ラベル変換。 policy-labels = [] # position-serialization: packed_sfen / huffman_coded_pos / bit_io。records が require。 @@ -73,7 +76,6 @@ python-data = ["book", "records", "position-serialization", "svg", "validation"] [dev-dependencies] criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } -shogi_core = "0.1.5" proptest = { version = "1.4", default-features = false, features = ["std"] } # 統合テスト(tests/perft.rs・tests/movegen_integration.rs)が JSON 期待値の # deserialize で使う。lib の serde は records feature の optional dep だが、テストは diff --git a/crates/rsshogi/README.md b/crates/rsshogi/README.md index 1536621e..b0a3c650 100644 --- a/crates/rsshogi/README.md +++ b/crates/rsshogi/README.md @@ -1,7 +1,5 @@ # rsshogi -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 - `rsshogi` は、将棋局面の操作、合法手生成、1 手詰め判定、棋譜や定跡の入出力を提供する Rust ライブラリである。 ```rust diff --git a/crates/rsshogi/benches/apply_undo.rs b/crates/rsshogi/benches/apply_undo.rs index 63fdb974..79db6bae 100644 --- a/crates/rsshogi/benches/apply_undo.rs +++ b/crates/rsshogi/benches/apply_undo.rs @@ -109,12 +109,6 @@ fn bench_key_probe(c: &mut Criterion) { c.bench_function("key_probe", |b| { b.iter(|| std::hint::black_box(std::hint::black_box(&pos).key())); }); - - c.bench_function("key_after_probe", |b| { - let mv = moves[0]; - let probe = base.clone(); - b.iter(|| std::hint::black_box(std::hint::black_box(&probe).key_after(mv))); - }); } /// perft を apply / undo の総合指標として測る。 diff --git a/crates/rsshogi/benches/attack_tables.rs b/crates/rsshogi/benches/attack_tables.rs index daf22535..067e3182 100644 --- a/crates/rsshogi/benches/attack_tables.rs +++ b/crates/rsshogi/benches/attack_tables.rs @@ -1,60 +1,16 @@ use criterion::{Criterion, black_box, criterion_group, criterion_main}; use rsshogi::{ - board::attack_tables::{ - BISHOP_BEAMS, LANCE_BEAMS, ROOK_BEAMS, bishop_attacks, lance_attacks, rook_attacks, - }, + board::attack_tables::{bishop_attacks, lance_attacks, rook_attacks}, types::{Bitboard, Color, Square}, }; const OCCUPANCY: Bitboard = Bitboard::from_packed_bits(0x1_2345_6789_abcd_ef01_2345); -fn legacy_ray_attacks(ray: Bitboard, occupied: Bitboard, increasing: bool) -> Bitboard { - let ray_bits = ray.packed_bits(); - let blockers = ray_bits & occupied.packed_bits(); - if blockers == 0 { - return ray; - } - - let attacks = if increasing { - let blocker = blockers.trailing_zeros(); - ray_bits & ((1u128 << (blocker + 1)) - 1) - } else { - let blocker = 127 - blockers.leading_zeros(); - ray_bits & !((1u128 << blocker) - 1) - }; - Bitboard::from_packed_bits(attacks) -} - -fn legacy_bishop_attacks(square: Square, occupied: Bitboard) -> Bitboard { - let beams = BISHOP_BEAMS[square]; - legacy_ray_attacks(beams.ne, occupied, true) - | legacy_ray_attacks(beams.se, occupied, true) - | legacy_ray_attacks(beams.sw, occupied, false) - | legacy_ray_attacks(beams.nw, occupied, false) -} - -fn legacy_rook_attacks(square: Square, occupied: Bitboard) -> Bitboard { - let beams = ROOK_BEAMS[square]; - legacy_ray_attacks(beams.n, occupied, false) - | legacy_ray_attacks(beams.e, occupied, true) - | legacy_ray_attacks(beams.s, occupied, true) - | legacy_ray_attacks(beams.w, occupied, false) -} - -fn legacy_lance_attacks(square: Square, occupied: Bitboard, color: Color) -> Bitboard { - let beams = LANCE_BEAMS[square]; - let ray = match color { - Color::BLACK => beams.black, - Color::WHITE => beams.white, - }; - legacy_ray_attacks(ray, occupied, color == Color::WHITE) -} - fn bench_slider_attacks(c: &mut Criterion) { let squares: Vec<_> = Square::iter().collect(); let mut group = c.benchmark_group("slider_attacks_all_squares"); - group.bench_function("bishop_candidate", |b| { + group.bench_function("bishop", |b| { b.iter(|| { let mut attacks = Bitboard::EMPTY; for &square in &squares { @@ -63,16 +19,7 @@ fn bench_slider_attacks(c: &mut Criterion) { black_box(attacks) }); }); - group.bench_function("bishop_legacy", |b| { - b.iter(|| { - let mut attacks = Bitboard::EMPTY; - for &square in &squares { - attacks |= legacy_bishop_attacks(square, black_box(OCCUPANCY)); - } - black_box(attacks) - }); - }); - group.bench_function("rook_candidate", |b| { + group.bench_function("rook", |b| { b.iter(|| { let mut attacks = Bitboard::EMPTY; for &square in &squares { @@ -81,16 +28,7 @@ fn bench_slider_attacks(c: &mut Criterion) { black_box(attacks) }); }); - group.bench_function("rook_legacy", |b| { - b.iter(|| { - let mut attacks = Bitboard::EMPTY; - for &square in &squares { - attacks |= legacy_rook_attacks(square, black_box(OCCUPANCY)); - } - black_box(attacks) - }); - }); - group.bench_function("lance_candidate", |b| { + group.bench_function("lance", |b| { b.iter(|| { let mut attacks = Bitboard::EMPTY; for &square in &squares { @@ -100,16 +38,6 @@ fn bench_slider_attacks(c: &mut Criterion) { black_box(attacks) }); }); - group.bench_function("lance_legacy", |b| { - b.iter(|| { - let mut attacks = Bitboard::EMPTY; - for &square in &squares { - attacks |= legacy_lance_attacks(square, black_box(OCCUPANCY), Color::BLACK); - attacks |= legacy_lance_attacks(square, black_box(OCCUPANCY), Color::WHITE); - } - black_box(attacks) - }); - }); group.finish(); } diff --git a/crates/rsshogi/build.rs b/crates/rsshogi/build.rs index 6cbb39c3..2428f049 100644 --- a/crates/rsshogi/build.rs +++ b/crates/rsshogi/build.rs @@ -35,8 +35,6 @@ use crate::types::SquareTable;\n\ // 滑り駒の攻撃テーブル output.push_str(&generate_slider_beams()); - output.push_str(&generate_rook_rank_extraction_masks()); - output.push_str(&generate_bishop_diagonal_extraction_masks()); output.push_str(&generate_check_candidate_table()); // ファイルに書き込み @@ -846,7 +844,7 @@ fn generate_slider_beams() -> String { let west_reversed = raw_bitboard_bits(west).reverse_bits(); let _ = writeln!( output, - " RookRayBits {{ e: {east}u128, w: {west}u128, e_raw: {east_raw}u128, s_raw: {south_raw}u128, n_reversed: {north_reversed}u128, w_reversed: {west_reversed}u128 }}," + " RookRayBits {{ e_raw: {east_raw}u128, s_raw: {south_raw}u128, n_reversed: {north_reversed}u128, w_reversed: {west_reversed}u128 }}," ); } output.push_str("]);\n\n"); @@ -854,14 +852,6 @@ fn generate_slider_beams() -> String { output } -fn generate_rook_rank_extraction_masks() -> String { - String::new() -} - -fn generate_bishop_diagonal_extraction_masks() -> String { - String::new() -} - fn generate_lance_beams(sq: i8) -> (u128, u128) { (slider_beam(sq, 0, -1), slider_beam(sq, 0, 1)) } diff --git a/crates/rsshogi/src/board/attack_tables.rs b/crates/rsshogi/src/board/attack_tables.rs index 0e1792d4..0d1283d1 100644 --- a/crates/rsshogi/src/board/attack_tables.rs +++ b/crates/rsshogi/src/board/attack_tables.rs @@ -46,8 +46,6 @@ struct BishopRayBits { #[derive(Clone, Copy)] struct RookRayBits { - e: u128, - w: u128, e_raw: u128, s_raw: u128, n_reversed: u128, @@ -91,26 +89,28 @@ const fn build_file_attack_table() -> [[u16; 512]; 9] { const FILE_ATTACKS: [[u16; 512]; 9] = build_file_attack_table(); -/// Returns the pseudo-legal pawn attacks from `sq` for `color`. +/// 指定色の歩の、指定マスからの利きを返す。 /// /// # Safety /// -/// `sq` must be a valid board square. +/// `sq` は盤上の有効なマスでなければならない。 #[must_use] #[inline] +#[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。PAWN_ATTACKS を参照してください")] pub unsafe fn pawn_attacks_unchecked(sq: Square, color: Color) -> Bitboard { // SAFETY: the caller guarantees `sq` is in the 81-entry generated table; // `Color` has exactly two valid discriminants and indexes its two entries. unsafe { *PAWN_ATTACKS.as_slice().get_unchecked(sq.to_index()).get_unchecked(color.to_index()) } } -/// Returns the pseudo-legal knight attacks from `sq` for `color`. +/// 指定色の桂の、指定マスからの利きを返す。 /// /// # Safety /// -/// `sq` must be a valid board square. +/// `sq` は盤上の有効なマスでなければならない。 #[must_use] #[inline] +#[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。KNIGHT_ATTACKS を参照してください")] pub unsafe fn knight_attacks_unchecked(sq: Square, color: Color) -> Bitboard { // SAFETY: the caller guarantees `sq` is in the 81-entry generated table; // `Color` has exactly two valid discriminants and indexes its two entries. @@ -119,13 +119,14 @@ pub unsafe fn knight_attacks_unchecked(sq: Square, color: Color) -> Bitboard { } } -/// Returns the pseudo-legal silver attacks from `sq` for `color`. +/// 指定色の銀の、指定マスからの利きを返す。 /// /// # Safety /// -/// `sq` must be a valid board square. +/// `sq` は盤上の有効なマスでなければならない。 #[must_use] #[inline] +#[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。SILVER_ATTACKS を参照してください")] pub unsafe fn silver_attacks_unchecked(sq: Square, color: Color) -> Bitboard { // SAFETY: the caller guarantees `sq` is in the 81-entry generated table; // `Color` has exactly two valid discriminants and indexes its two entries. @@ -134,43 +135,35 @@ pub unsafe fn silver_attacks_unchecked(sq: Square, color: Color) -> Bitboard { } } -/// Returns the pseudo-legal gold attacks from `sq` for `color`. +/// 指定色の金の、指定マスからの利きを返す。 /// /// # Safety /// -/// `sq` must be a valid board square. +/// `sq` は盤上の有効なマスでなければならない。 #[must_use] #[inline] +#[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。GOLD_ATTACKS を参照してください")] pub unsafe fn gold_attacks_unchecked(sq: Square, color: Color) -> Bitboard { // SAFETY: the caller guarantees `sq` is in the 81-entry generated table; // `Color` has exactly two valid discriminants and indexes its two entries. unsafe { *GOLD_ATTACKS.as_slice().get_unchecked(sq.to_index()).get_unchecked(color.to_index()) } } -/// Returns the pseudo-legal king attacks from `sq`. +/// 玉の、指定マスからの利きを返す。 /// /// # Safety /// -/// `sq` must be a valid board square. +/// `sq` は盤上の有効なマスでなければならない。 #[must_use] #[inline] +#[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。KING_ATTACKS を参照してください")] pub unsafe fn king_attacks_unchecked(sq: Square) -> Bitboard { // SAFETY: the caller guarantees `sq` is in the 81-entry generated table. unsafe { *KING_ATTACKS.as_slice().get_unchecked(sq.to_index()) } } #[inline] -fn nearest_blocker(ray_bits: u128, occupied_bits: u128, increasing: bool) -> Option { - let blockers = ray_bits & occupied_bits; - if blockers == 0 { - return None; - } - - let index = if increasing { blockers.trailing_zeros() } else { 127 - blockers.leading_zeros() }; - Some(index as usize) -} - -#[inline] +#[cfg(not(all(target_arch = "x86_64", target_feature = "avx2")))] fn increasing_ray_attacks(ray_bits: u128, occupied_bits: u128) -> u128 { let blockers = ray_bits & occupied_bits; ray_bits & (blockers ^ blockers.wrapping_sub(1)) @@ -317,16 +310,11 @@ pub fn rook_file_attacks(sq: Square, occupied: Bitboard) -> Bitboard { Bitboard::from_packed_bits(attacks) } +/// 指定マスからの飛車の横利きを返す。 #[must_use] #[inline] pub fn rook_rank_attacks(sq: Square, occupied: Bitboard) -> Bitboard { - let beams = ROOK_RAY_BITS[sq.to_index()]; - let occupied_bits = occupied.packed_bits(); - let east = increasing_ray_attacks(beams.e, occupied_bits); - let west = nearest_blocker(beams.w, occupied_bits, false) - .map_or(beams.w, |blocker| beams.w ^ ROOK_RAY_BITS[blocker].w); - let attacks = east | west; - Bitboard::from_packed_bits(attacks) + rook_attacks(sq, occupied) & Bitboard::rank_mask(sq.rank()) } #[must_use] diff --git a/crates/rsshogi/src/board/attack_tables/tests/mod.rs b/crates/rsshogi/src/board/attack_tables/tests/mod.rs index 7434b0a6..e24dbeb1 100644 --- a/crates/rsshogi/src/board/attack_tables/tests/mod.rs +++ b/crates/rsshogi/src/board/attack_tables/tests/mod.rs @@ -39,29 +39,9 @@ fn king_attacks(sq: Square) -> Bitboard { } #[test] -fn unchecked_step_attack_accessors_match_generated_geometry() { +fn slider_step_attack_accessors_match_generated_geometry() { for sq in Square::iter() { for color in Color::iter() { - // SAFETY: `Square::iter` yields only board squares. - assert_eq!( - unsafe { pawn_attacks_unchecked(sq, color) }, - PAWN_ATTACKS[sq][color.to_index()] - ); - // SAFETY: `Square::iter` yields only board squares. - assert_eq!( - unsafe { knight_attacks_unchecked(sq, color) }, - KNIGHT_ATTACKS[sq][color.to_index()] - ); - // SAFETY: `Square::iter` yields only board squares. - assert_eq!( - unsafe { silver_attacks_unchecked(sq, color) }, - SILVER_ATTACKS[sq][color.to_index()] - ); - // SAFETY: `Square::iter` yields only board squares. - assert_eq!( - unsafe { gold_attacks_unchecked(sq, color) }, - GOLD_ATTACKS[sq][color.to_index()] - ); let lance = LANCE_BEAMS[sq]; let expected_lance = match color { Color::BLACK => lance.black, @@ -69,8 +49,6 @@ fn unchecked_step_attack_accessors_match_generated_geometry() { }; assert_eq!(crate::board::attack_tables::lance_step_attacks(sq, color), expected_lance); } - // SAFETY: `Square::iter` yields only board squares. - assert_eq!(unsafe { king_attacks_unchecked(sq) }, KING_ATTACKS[sq]); let bishop = BISHOP_BEAMS[sq]; assert_eq!( crate::board::attack_tables::bishop_step_attacks(sq), diff --git a/crates/rsshogi/src/board/mod.rs b/crates/rsshogi/src/board/mod.rs index 11c8df33..cd55485c 100644 --- a/crates/rsshogi/src/board/mod.rs +++ b/crates/rsshogi/src/board/mod.rs @@ -134,10 +134,14 @@ pub use movegen::{ generate_moves_to_move32_into, generate_quiet_checks, generate_recaptures, generate_recaptures_all, generate_recaptures_all_move32, generate_recaptures_move32, }; +// 1.x の公開パスを維持する。 +#[allow(deprecated)] +pub use position::MoveStackSiteFacts; + pub use position::{ BoardArray, CapturedPieceDelta, DeclarationDetail, DeclarationEvaluation, MoveApplyFacts, - MoveDelta32, MoveStackSiteFacts, PackedPiece, PartialKeys, Ply, Position, StateCacheView, - StateHistory, StateHistoryEntry, ValidationIssue, ValidationReport, + MoveDelta32, PackedPiece, PartialKeys, Ply, Position, StateCacheView, StateHistory, + StateHistoryEntry, ValidationIssue, ValidationReport, }; #[cfg(feature = "position-serialization")] pub use position::{ diff --git a/crates/rsshogi/src/board/movegen/types.rs b/crates/rsshogi/src/board/movegen/types.rs index 2e304ccf..5c640bd5 100644 --- a/crates/rsshogi/src/board/movegen/types.rs +++ b/crates/rsshogi/src/board/movegen/types.rs @@ -28,6 +28,10 @@ pub trait MoveGenType { #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。MoveGenType::GENERATE_ALL_LEGAL を使ってください" + )] fn is_generate_all_legal() -> bool { Self::GENERATE_ALL_LEGAL } diff --git a/crates/rsshogi/src/board/position/accessors.rs b/crates/rsshogi/src/board/position/accessors.rs index 4cd6aaab..917b94a8 100644 --- a/crates/rsshogi/src/board/position/accessors.rs +++ b/crates/rsshogi/src/board/position/accessors.rs @@ -2,9 +2,11 @@ use super::Position; use super::types::{BoardArray, Ply}; use crate::board::bitboard_set::BitboardSet; use crate::board::zobrist::ZobristKey; -use crate::types::{ - Bitboard, Color, File, Hand, Move, Move32, Move32Metadata, MoveBase, Piece, PieceType, Square, -}; +use crate::types::{Bitboard, Color, File, Hand, Move, Move32, MoveBase, Piece, PieceType, Square}; + +// 1.x の互換 API の返り値。 +#[allow(deprecated)] +use crate::types::Move32Metadata; impl Position { /// 指定マスのPieceを取得。 @@ -219,6 +221,11 @@ impl Position { /// 1 回で取りたいときに使う。 #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。着手前の探索用情報は consumer 側で構築してください" + )] + #[allow(deprecated)] // 1.x のメタデータ返却実装で非推奨の型と alias を維持する。 pub fn move32_metadata(&self, mv: Move32) -> Move32Metadata { let piece_after = if mv.has_piece_info() { mv.piece_after_move() } else { self.moved_piece_after(mv) }; @@ -242,6 +249,11 @@ impl Position { /// `Move32` を探索向けに分類したメタデータを返す。 #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。着手前の探索用情報は consumer 側で構築してください" + )] + #[allow(deprecated)] // 1.x のメタデータ返却実装で非推奨の型と alias を維持する。 pub fn classify_move32(&self, mv: Move32) -> Move32Metadata { self.move32_metadata(mv) } @@ -328,6 +340,10 @@ impl Position { /// 指定した複数の駒種のビットボードを取得。 #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。pieces_by_type の結果の論理和を使ってください" + )] pub fn pieces_by_types(&self, piece_types: &[PieceType]) -> Bitboard { let mut out = Bitboard::EMPTY; for &piece_type in piece_types { @@ -338,6 +354,10 @@ impl Position { /// 指定した複数の駒種のビットボードを取得(配列版)。 #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。pieces_by_type の結果の論理和を使ってください" + )] pub fn pieces_by_types_array(&self, piece_types: [PieceType; N]) -> Bitboard { let mut out = Bitboard::EMPTY; for &piece_type in &piece_types { @@ -390,6 +410,10 @@ impl Position { /// 指定色・複数駒種のビットボードを取得。 #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。pieces_for の結果の論理和を使ってください" + )] pub fn pieces_for_types(&self, piece_types: &[PieceType], color: Color) -> Bitboard { let mut out = Bitboard::EMPTY; for &piece_type in piece_types { @@ -400,6 +424,10 @@ impl Position { /// 指定色・複数駒種のビットボードを取得(配列版)。 #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。pieces_for の結果の論理和を使ってください" + )] pub fn pieces_for_types_array( &self, piece_types: [PieceType; N], diff --git a/crates/rsshogi/src/board/position/cache.rs b/crates/rsshogi/src/board/position/cache.rs index 18cdd6d0..0b55d32b 100644 --- a/crates/rsshogi/src/board/position/cache.rs +++ b/crates/rsshogi/src/board/position/cache.rs @@ -200,6 +200,7 @@ impl Position { /// 指し手が空き王手になる候補かを判定する。 #[must_use] + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。blockers_for_king を参照してください")] pub fn is_discovery_check_on_king(&self, c: Color, mv: crate::types::Move32) -> bool { self.blockers_for_king(c).test(mv.to_move().from_sq()) } diff --git a/crates/rsshogi/src/board/position/hash.rs b/crates/rsshogi/src/board/position/hash.rs index a8aa3655..8928572f 100644 --- a/crates/rsshogi/src/board/position/hash.rs +++ b/crates/rsshogi/src/board/position/hash.rs @@ -109,11 +109,14 @@ impl Position { /// `mv` を適用した後の盤面キー(持ち駒を含まない)を、局面を変更せずに求める。 /// - /// 千日手判定用の第 2 キーを先読みしたい消費者向け。局面全体のキーは - /// [`Position::key_after`] を使う。 + /// 持ち駒を含まない着手後の盤面キーを予測する互換 API。 /// `mv` は現局面の合法手でなければならない。合法性は検査しない。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。キー予測は consumer 側で実装し、着手後のキーは key または board_key を参照してください" + )] pub fn board_key_after(&self, mv: Move32) -> ZobristKey { self.board_key_after_impl(mv) } @@ -121,6 +124,10 @@ impl Position { /// `Move` 版の [`Position::board_key_after`]。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。キー予測は consumer 側で実装し、着手後のキーは key または board_key を参照してください" + )] pub fn board_key_after_move(&self, mv: Move) -> ZobristKey { self.board_key_after_impl(mv) } @@ -156,16 +163,15 @@ impl Position { /// `mv` を適用した後の局面キーを、局面を変更せずに求める。 /// - /// 主な用途は探索エンジンの置換表 prefetch で、 - /// `key_after` でキーを確定 → engine 側で TT を prefetch → [`Position::apply_move32`] - /// の順に使う。prefetch の発行 API は本 crate には置かない - /// (置換表は engine の所有物であり、rsshogi は prefetch すべきアドレスを知り得ない)。 - /// /// 戻り値は `mv` を適用した後の [`Position::key`] と一致する。 /// `mv` は現局面の合法手でなければならない。合法性は検査しないため、 /// 非合法手を渡した場合の戻り値は意味を持たない。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。キー予測は consumer 側で実装し、着手後のキーは key または board_key を参照してください" + )] pub fn key_after(&self, mv: Move32) -> ZobristKey { self.key_after_impl(mv) } @@ -173,6 +179,10 @@ impl Position { /// `Move` 版の [`Position::key_after`]。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。キー予測は consumer 側で実装し、着手後のキーは key または board_key を参照してください" + )] pub fn key_after_move(&self, mv: Move) -> ZobristKey { self.key_after_impl(mv) } @@ -182,6 +192,10 @@ impl Position { /// 盤上の駒も持ち駒も動かないため、手番キーとの XOR だけで求まる。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。key() ^ Zobrist::side() を使ってください" + )] pub fn key_after_null(&self) -> ZobristKey { self.key() ^ Zobrist::side() } diff --git a/crates/rsshogi/src/board/position/mod.rs b/crates/rsshogi/src/board/position/mod.rs index 6a804450..10c55ca5 100644 --- a/crates/rsshogi/src/board/position/mod.rs +++ b/crates/rsshogi/src/board/position/mod.rs @@ -157,18 +157,23 @@ pub use huffman_coded_pos::{HuffmanCodedPos, HuffmanCodedPosError}; #[cfg(feature = "position-serialization")] pub use packed_sfen::{PackedSfen, PackedSfenError, PackedSfenSink}; +// 1.x の公開パスを維持する。 +#[allow(deprecated)] +pub use types::MoveStackSiteFacts; + pub use types::{ BoardArray, CapturedPieceDelta, DeclarationDetail, DeclarationEvaluation, MoveApplyFacts, - MoveDelta32, MoveError, MoveStackSiteFacts, PackedPiece, Ply, ValidationError, ValidationIssue, - ValidationReport, + MoveDelta32, MoveError, PackedPiece, Ply, ValidationError, ValidationIssue, ValidationReport, }; // ANCHOR: position_struct /// 将棋の盤面状態を管理する構造体 /// /// キャッシュ効率のため、頻繁にアクセスされるデータを先頭に配置 +/// +/// [`Clone::clone_from`] は複製先の履歴領域を再利用する。 +/// 複製元の予約容量も継承し、複製先に余る領域は以後の再利用のために保持する。 #[repr(C, align(64))] -#[derive(Clone)] pub struct Position { /// `Piece` を81要素並べた盤面 pub(super) board: BoardArray, @@ -202,6 +207,40 @@ pub struct Position { } // ANCHOR_END: position_struct +impl Clone for Position { + #[inline] + // derive と同じフィールド単位の Clone を維持し、通常の複製のコード生成を揃える。 + #[allow(clippy::clone_on_copy)] + fn clone(&self) -> Self { + Self { + board: Clone::clone(&self.board), + bitboards: Clone::clone(&self.bitboards), + hands: Clone::clone(&self.hands), + side_to_move: Clone::clone(&self.side_to_move), + entering_king_rule: Clone::clone(&self.entering_king_rule), + entering_king_point: Clone::clone(&self.entering_king_point), + ply: Clone::clone(&self.ply), + st_index: Clone::clone(&self.st_index), + state_stack: Clone::clone(&self.state_stack), + king_square: Clone::clone(&self.king_square), + } + } + + #[inline] + fn clone_from(&mut self, source: &Self) { + self.state_stack.clone_from(&source.state_stack); + self.board = source.board; + self.bitboards = source.bitboards; + self.hands = source.hands; + self.side_to_move = source.side_to_move; + self.entering_king_rule = source.entering_king_rule; + self.entering_king_point = source.entering_king_point; + self.ply = source.ply; + self.st_index = source.st_index; + self.king_square = source.king_square; + } +} + impl Default for Position { fn default() -> Self { Self::empty() @@ -377,7 +416,7 @@ impl Position { self.st_index } - /// recursive search で使用する追加 state slot を事前確保する。 + /// 通常の着手を含む追加 state slot を事前確保する。 /// /// 現在の head から `additional_states` 回の連続 push が `StateStack` の /// chunk を増設しないことを保証する。予約 token は管理しないため、undo 後は @@ -398,7 +437,7 @@ impl Position { } } - /// 次の search state を allocation なしで積めるかを返す。 + /// 次の state を allocation なしで積めるかを返す。 /// /// strict reservation の残量ではなく、現在の head の次に物理 slot があるかを表す。 #[must_use] diff --git a/crates/rsshogi/src/board/position/rules.rs b/crates/rsshogi/src/board/position/rules.rs index 617d0ab2..e74a6f6d 100644 --- a/crates/rsshogi/src/board/position/rules.rs +++ b/crates/rsshogi/src/board/position/rules.rs @@ -187,6 +187,10 @@ impl Position { /// 捕獲または成りの指し手かどうか #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。指し手の分類方針は consumer 側で実装してください" + )] pub fn is_capture_or_promotion(&self, mv: Move32) -> bool { mv.is_promotion() || self.is_capture_move32(mv) } @@ -194,6 +198,10 @@ impl Position { /// 歩の成り指し手かどうか #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。指し手の分類方針は consumer 側で実装してください" + )] pub fn is_pawn_promotion(&self, mv: Move32) -> bool { self.is_pawn_promotion_impl(mv) } @@ -212,13 +220,21 @@ impl Position { /// 捕獲または歩の成り指し手かどうか #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。指し手の分類方針は consumer 側で実装してください" + )] pub fn is_capture_or_pawn_promotion(&self, mv: Move32) -> bool { - self.is_pawn_promotion(mv) || self.is_capture_move32(mv) + self.is_pawn_promotion_impl(mv) || self.is_capture_move32(mv) } /// 捕獲または価値のある駒の成り(歩・角・飛)かどうか #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。指し手の分類方針は consumer 側で実装してください" + )] pub fn is_capture_or_valuable_promotion(&self, mv: Move32) -> bool { self.is_capture_or_valuable_promotion_impl(mv) } @@ -239,6 +255,10 @@ impl Position { /// MovePicker の capture 段階で生成される指し手かどうか #[must_use] #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。指し手の分類方針は consumer 側で実装してください" + )] pub fn is_capture_stage(&self, mv: Move32) -> bool { self.is_capture_move32(mv) } @@ -420,7 +440,7 @@ impl Position { return; } - let limit = plies_from_null; + let limit = stack.repetition_scan_limit(current_idx, plies_from_null, current_board_key); let mut repetition_counter: i32 = 0; let mut repetition_distance: i32 = 0; let mut repetition_times: i32 = 0; @@ -640,6 +660,13 @@ impl Position { /// /// 既定値は `Ply::MAX` で、null move 以降の全履歴を保持する。これより短い値は /// 長周期の千日手や連続王手を見逃しうるため、探索上の近似を明示的に選ぶ場合に限る。 + /// + /// 1.6.0 から非推奨で、2.0.0 で削除予定。探索用の履歴保持方針は consumer 側で管理する。 + /// 既存の挙動は維持する。通常の `clone()` には影響しない。 + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。探索用の履歴保持方針は consumer 側で管理してください" + )] pub fn set_max_repetition_ply(ply: Ply) { MAX_REPETITION_PLY.store(ply, Ordering::Relaxed); } @@ -650,9 +677,14 @@ impl Position { /// [`Position::set_max_repetition_ply`] で明示的に上限を下げた場合は、その範囲だけを残す。 /// 返される局面の盤面・手駒・手番・ハッシュ・直近 state は元局面と等価で、 /// `state_stack` だけが search 向けに切り詰められる。 + /// 根以前の履歴の索引を構築し、一致候補がない場合の走査を省く。 + /// 索引構築には確保を伴う。コピーの準備費用と、その後の走査の削減効果は別である。 + /// 索引は通常の clone 間で共有し、保持範囲内の判定結果は変えない。 #[must_use] pub fn clone_for_search(&self) -> Self { - self.clone_for_search_bounded(max_repetition_ply()) + let mut result = self.clone_for_search_bounded(max_repetition_ply()); + result.state_stack.prepare_repetition_index(); + result } /// 指定範囲の反復判定履歴だけを保持した内部探索用 clone を返す。 diff --git a/crates/rsshogi/src/board/position/tests/basics.rs b/crates/rsshogi/src/board/position/tests/basics.rs index db39fa7e..cf34a760 100644 --- a/crates/rsshogi/src/board/position/tests/basics.rs +++ b/crates/rsshogi/src/board/position/tests/basics.rs @@ -1,5 +1,6 @@ use super::helpers::apply_usi_move; use super::*; +use crate::types::HandPiece; const fn is_promoted_piece(piece: Piece) -> bool { matches!( @@ -211,7 +212,7 @@ fn test_apply_move_variants() { let state_stack = capture_pos.state_stack(); let state = state_stack.current(); assert_eq!(state.cold.captured_piece(), captured); - assert_eq!(capture_pos.hand(Color::BLACK).pawn_count(), 1); + assert_eq!(capture_pos.hand(Color::BLACK).count(HandPiece::PAWN), 1); let sfen = "lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPP1PPPP/1B5R1/LNSGKGSNL b P 1"; let mut drop_pos = crate::board::position_from_sfen(sfen).unwrap(); @@ -221,7 +222,10 @@ fn test_apply_move_variants() { drop_pos.apply_move32(mv); let placed_piece = drop_pos.piece_on(to); assert_eq!(placed_piece, Piece::B_PAWN); - assert_eq!(drop_pos.hand(Color::BLACK).pawn_count(), initial_hand.pawn_count() - 1); + assert_eq!( + drop_pos.hand(Color::BLACK).count(HandPiece::PAWN), + initial_hand.count(HandPiece::PAWN) - 1 + ); assert_eq!(drop_pos.state_stack().current().cold.captured_piece(), Piece::NONE); let sfen = "lnsgkgsnl/1r5b1/2R6/ppppppppp/9/9/PPPPPPPPP/1B7/LNSGKGSNL b - 1"; @@ -246,6 +250,7 @@ fn test_apply_move_variants() { } #[test] +#[allow(deprecated)] // 1.x のメタデータ API の分類結果を検査する。 fn test_move32_metadata_classifies_capture_drop_and_promotion() { let start = crate::board::hirate_position(); let quiet = start.move32_from_move(Move::from_usi("7g7f").expect("valid move")); diff --git a/crates/rsshogi/src/board/position/tests/clone.rs b/crates/rsshogi/src/board/position/tests/clone.rs new file mode 100644 index 00000000..f7e01507 --- /dev/null +++ b/crates/rsshogi/src/board/position/tests/clone.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::board::{ + LegalAll, Move32List, generate_moves_move32, hirate_position, position_from_sfen, +}; +use crate::types::EnteringKingRule; +use proptest::prelude::*; + +// 比較用は最適化する Clone を呼ばず、従来の全 chunk 複製を使う。 +fn reference_clone(source: &Position) -> Position { + let mut result = Position::empty(); + result.board = source.board; + result.bitboards = source.bitboards; + result.hands = source.hands; + result.side_to_move = source.side_to_move; + result.entering_king_rule = source.entering_king_rule; + result.entering_king_point = source.entering_king_point; + result.ply = source.ply; + result.st_index = source.st_index; + result.king_square = source.king_square; + result.state_stack = source.state_stack.reference_clone(); + result +} + +fn assert_same(actual: &Position, expected: &Position) { + assert_eq!(actual.to_sfen(None), expected.to_sfen(None)); + assert_eq!(actual.entering_king_rule, expected.entering_king_rule); + assert_eq!(actual.entering_king_point, expected.entering_king_point); + assert_eq!(actual.king_square, expected.king_square); + assert_eq!(format!("{:?}", actual.bitboards), format!("{:?}", expected.bitboards)); + assert_eq!(actual.st_index, expected.st_index); + assert_eq!(actual.state_stack.current_index(), expected.state_stack.current_index()); + for index in 0..=actual.st_index { + // hot と cold の全フィールドを比較し、padding byte は比較しない。 + assert_eq!( + format!("{:?}", actual.state_stack[index]), + format!("{:?}", expected.state_stack[index]) + ); + } + assert_eq!(actual.repetition_state(), expected.repetition_state()); + for ply in [0, 4, 16, 64, usize::MAX] { + assert_eq!( + actual.repetition_state_with_distance(ply), + expected.repetition_state_with_distance(ply) + ); + } +} + +fn copy_and_compare(source: &Position, target: &mut Position) { + let expected = reference_clone(source); + target.clone_from(source); + assert_same(target, &expected); + assert_same(&source.clone(), &expected); + assert!(target.state_stack.chunk_count() >= source.state_stack.chunk_count()); + assert_ne!( + std::ptr::from_ref(target.current_state()), + std::ptr::from_ref(source.current_state()) + ); +} + +fn kings(depth: usize) -> Position { + let mut pos = position_from_sfen("4k4/9/9/9/9/9/9/9/4K4 b - 1").unwrap(); + for usi in ["5i6i", "5a6a", "6i5i", "6a5a"].into_iter().cycle().take(depth) { + let mv = pos.move32_from_usi(usi).unwrap(); + pos.try_apply_move32(mv).unwrap(); + } + pos +} + +#[test] +fn clone_from_replaces_history_settings_and_index_across_capacities() { + for depth in [0, 1, 63, 64, 65, 150] { + let mut source = kings(depth); + source.prepare_search_state_capacity(130).unwrap(); + source.set_entering_king_rule(EnteringKingRule::Point27); + for target_depth in [0, 63, 192] { + let mut target = kings(target_depth).clone_for_search(); + let original_capacity = target.state_stack.chunk_count(); + copy_and_compare(&source, &mut target); + assert!(target.state_stack.chunk_count() >= original_capacity); + source = source.clone_for_search(); + copy_and_compare(&source, &mut target); + target.apply_null_move().unwrap(); + target.undo_null_move().unwrap(); + assert_same(&target, &source); + while target.state_stack_depth() > 0 { + let mv = target.current_state().last_move(); + target.undo_move32(mv).unwrap(); + } + copy_and_compare(&Position::empty(), &mut target); + } + } +} + +#[test] +fn clone_from_preserves_capture_drop_promotion_and_perpetual_check() { + let cases = [ + ( + "4r3k/9/9/9/9/9/9/9/K4R3 b P 1", + vec!["P*5h", "5a5h", "9i8i", "P*4g", "4i4g", "1a2a", "4g4i", "2a1a", "8i9i", "5h5a"], + ), + ( + "lr2+R2G1/6g1k/p3ppppp/2p1b4/9/2P+b4P/P+p3PPP1/4G1SK1/LN3+p1NL b 2SNLgsn3p 1", + vec!["2a1a", "1b2b", "1a2a", "2b1b"], + ), + ("4k4/9/4P4/9/9/9/9/9/4K4 b - 1", vec!["5c5b+"]), + ]; + for (sfen, moves) in cases { + let mut source = position_from_sfen(sfen).unwrap(); + let mut target = kings(192).clone_for_search(); + let mut applied = Vec::new(); + let repeats = if moves.len() == 1 { 1 } else { 3 }; + for usi in moves.repeat(repeats) { + let mv = source.move32_from_usi(usi).unwrap(); + source.try_apply_move32(mv).unwrap(); + applied.push(mv); + copy_and_compare(&source, &mut target); + } + for mv in applied.into_iter().rev() { + source.undo_move32(mv).unwrap(); + target.undo_move32(mv).unwrap(); + assert_same(&target, &source); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + #[test] + fn clone_matches_reference_after_random_apply_undo(choices in prop::collection::vec(any::(), 80..180)) { + let mut source = hirate_position(); + let mut target = kings(192).clone_for_search(); + let mut path = Vec::new(); + for (step, choice) in choices.into_iter().enumerate() { + if choice % 4 == 0 && !path.is_empty() { + source.undo_move32(path.pop().unwrap()).unwrap(); + } else { + let mut legal = Move32List::new(); + generate_moves_move32::(&source, &mut legal); + if !legal.is_empty() { + let mv = legal[usize::from(choice) % legal.len()]; + source.try_apply_move32(mv).unwrap(); + path.push(mv); + } + } + if step % 13 == 0 { + copy_and_compare(&source, &mut target); + let mut expected = reference_clone(&source); + let mut legal = Move32List::new(); + generate_moves_move32::(&source, &mut legal); + if !legal.is_empty() { + let mv = legal[usize::from(choice) % legal.len()]; + target.try_apply_move32(mv).unwrap(); + expected.try_apply_move32(mv).unwrap(); + assert_same(&target, &expected); + target.undo_move32(mv).unwrap(); + expected.undo_move32(mv).unwrap(); + } + for &mv in path.iter().rev().take(3) { + target.undo_move32(mv).unwrap(); + expected.undo_move32(mv).unwrap(); + assert_same(&target, &expected); + } + } + } + } +} diff --git a/crates/rsshogi/src/board/position/tests/mod.rs b/crates/rsshogi/src/board/position/tests/mod.rs index efa75a13..62ca64a3 100644 --- a/crates/rsshogi/src/board/position/tests/mod.rs +++ b/crates/rsshogi/src/board/position/tests/mod.rs @@ -4,6 +4,7 @@ mod attacks; mod basics; mod cache_view; mod carry_over; +mod clone; mod helpers; #[cfg(feature = "position-serialization")] mod huffman_coded_pos; diff --git a/crates/rsshogi/src/board/position/tests/rules/indexed_repetition.rs b/crates/rsshogi/src/board/position/tests/rules/indexed_repetition.rs new file mode 100644 index 00000000..155f90ac --- /dev/null +++ b/crates/rsshogi/src/board/position/tests/rules/indexed_repetition.rs @@ -0,0 +1,143 @@ +use super::helpers::move_from_usi; +use crate::board::{Position, hirate_position, position_from_sfen}; +use crate::types::{Move32, RepetitionState}; + +const LONG_CYCLE: [&str; 18] = [ + "9i8i", "1a2a", "8i7i", "2a3a", "7i7h", "3a3b", "7h7g", "3b3c", "7g8g", "3c2c", "8g9g", "2c1c", + "9g9h", "1c1b", "9h8h", "1b2b", "8h9i", "2b1a", +]; + +fn assert_same_repetition(plain: &Position, indexed: &Position) { + assert_eq!(plain.to_sfen(None), indexed.to_sfen(None)); + assert_eq!(plain.key(), indexed.key()); + assert_eq!(plain.plies_from_null(), indexed.plies_from_null()); + assert_eq!(plain.continuous_checks(), indexed.continuous_checks()); + assert_eq!(plain.repetition_counter(), indexed.repetition_counter()); + assert_eq!(plain.repetition_distance(), indexed.repetition_distance()); + assert_eq!(plain.repetition_times(), indexed.repetition_times()); + assert_eq!(plain.repetition_type(), indexed.repetition_type()); + assert_eq!(plain.repetition_state(), indexed.repetition_state()); + for ply in [0, 3, 4, 8, 16, 64, usize::MAX] { + assert_eq!( + plain.repetition_state_with_distance(ply), + indexed.repetition_state_with_distance(ply) + ); + assert_eq!(plain.repetition_state_with_ply(ply), indexed.repetition_state_with_ply(ply)); + assert_eq!( + plain.cached_repetition_state_with_ply(ply), + indexed.cached_repetition_state_with_ply(ply) + ); + } +} + +fn apply_pair(plain: &mut Position, indexed: &mut Position, usi: &str) -> Move32 { + let mv = move_from_usi(plain, usi); + plain.try_apply_move32(mv).unwrap(); + indexed.try_apply_move32(mv).unwrap(); + assert_same_repetition(plain, indexed); + mv +} + +#[test] +fn indexed_repetition_matches_full_scan_across_root_and_long_cycles() { + let root = position_from_sfen("8k/9/9/9/9/9/9/9/K8 b - 1").unwrap(); + let sequence = LONG_CYCLE.repeat(3); + for split in [0, 1, 17, 18, 35, 53] { + let mut plain = root.clone(); + for usi in &sequence[..split] { + let mv = move_from_usi(&plain, usi); + plain.apply_move32(mv); + } + let mut indexed = plain.clone_for_search(); + assert_same_repetition(&plain, &indexed); + for usi in &sequence[split..] { + apply_pair(&mut plain, &mut indexed, usi); + } + assert_eq!(indexed.repetition_state(), RepetitionState::Draw); + assert_eq!(indexed.repetition_distance(), -18); + } +} + +#[test] +fn indexed_repetition_preserves_captures_drops_hands_and_perpetual_check() { + let cases = [ + ( + "4r3k/9/9/9/9/9/9/9/K4R3 b P 1", + vec!["P*5h", "5a5h", "9i8i", "P*4g", "4i4g", "1a2a", "4g4i", "2a1a", "8i9i", "5h5a"], + ), + ( + "lr2+R2G1/6g1k/p3ppppp/2p1b4/9/2P+b4P/P+p3PPP1/4G1SK1/LN3+p1NL b 2SNLgsn3p 1", + vec!["2a1a", "1b2b", "1a2a", "2b1b"], + ), + ]; + for (sfen, cycle) in cases { + let sequence = cycle.repeat(3); + for split in 0..sequence.len() { + let mut plain = position_from_sfen(sfen).unwrap(); + for usi in &sequence[..split] { + let mv = move_from_usi(&plain, usi); + plain.apply_move32(mv); + } + let mut indexed = plain.clone_for_search(); + for usi in &sequence[split..] { + apply_pair(&mut plain, &mut indexed, usi); + } + } + } +} + +#[test] +fn indexed_repetition_keeps_hand_dominance_candidates() { + let mut plain = + position_from_sfen("lr6l/4g1pkp/2ns1s3/4pp1SP/PPBP2Pp1/2gpPP3/2B2S3/2K6/L6RL b 4P2g3np 1") + .unwrap(); + let mut indexed = plain.clone_for_search(); + for usi in ["P*2c", "2b2a", "2c2b+", "2a2b"] { + apply_pair(&mut plain, &mut indexed, usi); + } + assert_eq!(indexed.repetition_state(), RepetitionState::Inferior); +} + +#[test] +#[allow(deprecated)] // 1.x の探索用 null move と根履歴索引の組み合わせを検査する。 +fn indexed_repetition_handles_clone_null_undo_before_root_and_reset() { + let mut plain = hirate_position(); + for usi in ["7g7f", "3c3d", "8h2b+", "3a2b"] { + let mv = move_from_usi(&plain, usi); + plain.apply_move32(mv); + } + let mut indexed = plain.clone_for_search(); + plain.prepare_search_state_capacity(8).unwrap(); + indexed.prepare_search_state_capacity(8).unwrap(); + let mv = apply_pair(&mut plain, &mut indexed, "B*4e"); + // SAFETY: 両局面の stack には、直前に適用した mv の state がある。 + unsafe { + plain.undo_move32_unchecked(mv); + indexed.undo_move32_unchecked(mv); + } + assert_same_repetition(&plain, &indexed); + for _ in 0..3 { + plain.try_apply_search_null_move().unwrap(); + indexed.try_apply_search_null_move().unwrap(); + assert_same_repetition(&plain, &indexed); + } + for _ in 0..3 { + plain.undo_search_null_move().unwrap(); + indexed.undo_search_null_move().unwrap(); + assert_same_repetition(&plain, &indexed); + } + indexed = indexed.clone(); + assert_same_repetition(&plain, &indexed.clone_for_search()); + for _ in 0..2 { + let mv = plain.last_move(); + plain.undo_move32(mv).unwrap(); + indexed.undo_move32(mv).unwrap(); + assert_same_repetition(&plain, &indexed); + } + for usi in ["2g2f", "8c8d", "2f2e", "8d8e"] { + apply_pair(&mut plain, &mut indexed, usi); + } + plain.set_sfen(crate::board::STARTPOS_SFEN).unwrap(); + indexed.set_sfen(crate::board::STARTPOS_SFEN).unwrap(); + apply_pair(&mut plain, &mut indexed, "7g7f"); +} diff --git a/crates/rsshogi/src/board/position/tests/rules/mod.rs b/crates/rsshogi/src/board/position/tests/rules/mod.rs index c3589bba..9b28905f 100644 --- a/crates/rsshogi/src/board/position/tests/rules/mod.rs +++ b/crates/rsshogi/src/board/position/tests/rules/mod.rs @@ -9,5 +9,6 @@ mod declaration_win; mod discovered_checks; mod drops; mod helpers; +mod indexed_repetition; mod repetition; mod split_legality; diff --git a/crates/rsshogi/src/board/position/tests/search_substrate.rs b/crates/rsshogi/src/board/position/tests/search_substrate.rs index b9a77e3d..e5b536e3 100644 --- a/crates/rsshogi/src/board/position/tests/search_substrate.rs +++ b/crates/rsshogi/src/board/position/tests/search_substrate.rs @@ -3,6 +3,7 @@ use crate::types::{Color, Move32, Piece, PieceType, Square}; use super::MoveError; +#[allow(deprecated)] // 1.x の探索専用 apply/undo の契約を検査する。 fn assert_search_move_roundtrip(mut pos: super::Position, mv: Move32) { let before = generate_position_state(&pos); let key = pos.key(); @@ -61,6 +62,7 @@ fn prepared_search_moves_cover_normal_capture_promotion_drop_and_king() { } #[test] +#[allow(deprecated)] // 1.x の探索専用 apply/undo の契約を検査する。 fn search_move_capacity_failure_does_not_mutate_position() { let mut pos = hirate_position(); let initial_capacity = pos.state_stack().prepared_additional_capacity(); @@ -93,6 +95,7 @@ fn search_move_capacity_failure_does_not_mutate_position() { } #[test] +#[allow(deprecated)] // 1.x の探索専用 apply/undo の契約を検査する。 fn search_null_preserves_game_ply_and_roundtrips() { let mut pos = hirate_position(); pos.prepare_search_state_capacity(1).expect("prepare search null state"); @@ -124,6 +127,7 @@ fn ordinary_null_still_advances_game_ply() { } #[test] +#[allow(deprecated)] // 1.x の探索専用 apply/undo の契約を検査する。 fn null_undo_underflow_is_atomic_for_ordinary_and_search_apis() { let mut ordinary = position_from_sfen("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 42") @@ -151,6 +155,7 @@ fn null_undo_underflow_is_atomic_for_ordinary_and_search_apis() { } #[test] +#[allow(deprecated)] // 1.x の探索専用 apply/undo の契約を検査する。 fn prepared_capacity_is_cloned_without_sharing_state_storage() { let mut original = hirate_position(); original.prepare_search_state_capacity(65).expect("prepare capacity before clone"); diff --git a/crates/rsshogi/src/board/position/tests/undo.rs b/crates/rsshogi/src/board/position/tests/undo.rs index 3f3de69a..c4b60610 100644 --- a/crates/rsshogi/src/board/position/tests/undo.rs +++ b/crates/rsshogi/src/board/position/tests/undo.rs @@ -395,6 +395,7 @@ fn test_apply_move32_with_delta_promotion_move() { } #[test] +#[allow(deprecated)] // 1.x の探索スタック用変換と実際の着手結果の一致を検査する。 fn test_apply_move32_with_facts_normal_move() { let mut pos = crate::board::hirate_position(); let mv = move_from_usi(&pos, "7g7f"); @@ -487,6 +488,7 @@ fn test_apply_move32_with_facts_promotion_move() { } #[test] +#[allow(deprecated)] // 1.x の探索スタック用変換と実際の着手結果の一致を検査する。 fn test_apply_move32_with_facts_king_move() { let sfen = "4k4/9/9/9/9/9/9/9/4K4 b - 1"; let mut pos = crate::board::position_from_sfen(sfen).unwrap(); diff --git a/crates/rsshogi/src/board/position/tests/update_errors.rs b/crates/rsshogi/src/board/position/tests/update_errors.rs index ac5584cc..428924c2 100644 --- a/crates/rsshogi/src/board/position/tests/update_errors.rs +++ b/crates/rsshogi/src/board/position/tests/update_errors.rs @@ -17,6 +17,7 @@ fn assert_update_error( } #[test] +#[allow(deprecated)] // 1.x の探索専用着手が失敗した場合の状態不変性を検査する。 fn fallible_updates_reject_invalid_moves_without_mutation() { let mut pos = hirate_position(); for mv in [Move32::from_raw(0x4400), Move32::MOVE_NONE, Move32::from_raw(0xffff)] { @@ -103,6 +104,7 @@ fn position_state_import_rejects_invalid_representation_before_mutation() { } #[test] +#[allow(deprecated)] // 1.x の探索専用着手が失敗した場合の状態不変性を検査する。 fn ply_limits_reject_before_mutation_and_keep_maximum_ply_undoable() { let mut state = generate_position_state(&hirate_position()); state.ply = u16::MAX - 1; diff --git a/crates/rsshogi/src/board/position/types.rs b/crates/rsshogi/src/board/position/types.rs index 01412732..136e367b 100644 --- a/crates/rsshogi/src/board/position/types.rs +++ b/crates/rsshogi/src/board/position/types.rs @@ -377,6 +377,11 @@ impl MoveApplyFacts { #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。MoveApplyFacts の moved_piece_after と to を参照してください" + )] + #[allow(deprecated)] // 1.x の探索スタック用の型を返す互換変換を維持する。 pub const fn search_stack_site(self) -> MoveStackSiteFacts { MoveStackSiteFacts { moved_piece: self.moved_piece_after, to: self.to } } @@ -402,6 +407,10 @@ impl MoveApplyFacts { /// サーチスタックの継続サイトに必要な指し手適用後の情報。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。探索スタック用の型は consumer 側で定義してください" +)] pub struct MoveStackSiteFacts { pub moved_piece: Piece, pub to: Square, diff --git a/crates/rsshogi/src/board/position/updates.rs b/crates/rsshogi/src/board/position/updates.rs index 8e8cdc26..c642f066 100644 --- a/crates/rsshogi/src/board/position/updates.rs +++ b/crates/rsshogi/src/board/position/updates.rs @@ -202,6 +202,10 @@ impl Position { /// /// `mv` は合法手、`gives_check` はその手の王手判定結果でなければならない。 #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。探索専用の着手と履歴管理は consumer 側で実装してください" + )] pub fn try_apply_search_move32_with_facts( &mut self, mv: Move32, @@ -768,6 +772,10 @@ impl Position { /// allocation なしで利用できる次の物理 slot がない場合は、局面を変更せず /// [`MoveError::StateCapacityExceeded`] を返す。 #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。探索専用の着手と履歴管理は consumer 側で実装してください" + )] pub fn try_apply_search_null_move(&mut self) -> Result<(), MoveError> { if !self.state_stack().has_prepared_next() { return Err(MoveError::StateCapacityExceeded); @@ -864,6 +872,10 @@ impl Position { /// /// 巻き戻せる state がない場合は [`MoveError::StackUnderflow`] を返す。 #[inline] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。探索専用の着手と履歴管理は consumer 側で実装してください" + )] pub fn undo_search_null_move(&mut self) -> Result<(), MoveError> { self.undo_null_move_impl::() } diff --git a/crates/rsshogi/src/board/state_info.rs b/crates/rsshogi/src/board/state_info.rs index 4c5c0dd0..7542fb1f 100644 --- a/crates/rsshogi/src/board/state_info.rs +++ b/crates/rsshogi/src/board/state_info.rs @@ -23,7 +23,9 @@ use super::position::Position; use crate::board::zobrist::{Zobrist, ZobristKey}; use crate::types::Bitboard; use crate::types::{Color, Hand, MOVE_NONE, Move32, Piece, PieceType, RepetitionState}; +use std::collections::HashSet; use std::ops::{Deref, DerefMut, Index}; +use std::sync::Arc; /// `StateInfo` 内でのスタック位置を表す型 pub type StateIndex = usize; @@ -108,6 +110,10 @@ impl CheckSquares { /// 圧縮表現を `PieceType::COUNT` 長の完全配列へ展開する。 #[inline] #[must_use] + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。CheckSquares::get で必要な駒種を参照してください" + )] pub fn to_full_array(self) -> [Bitboard; PieceType::COUNT] { std::array::from_fn(|idx| self.get(PieceType::new(i8::try_from(idx).unwrap_or(i8::MAX)))) } @@ -732,6 +738,7 @@ type StateChunk = Box<[StateInfo; STATE_CHUNK_CAPACITY]>; /// /// - `pop` / `pop_unchecked` で外れた entry の参照 /// - `reset_sfen` / `reset_with_position` 後の任意 entry の参照 +/// - `clone_from` によって内容を置き換えた複製先の entry の参照 /// - `clone` / `clone_for_search` 後の別インスタンス間の pointer identity /// - `drop` 後の任意 entry の参照 /// @@ -741,6 +748,8 @@ type StateChunk = Box<[StateInfo; STATE_CHUNK_CAPACITY]>; /// 旧 entry は live ではあっても current ではなくなる。 #[derive(Debug)] pub struct StateStack { + /// 探索開始時に固定した履歴。通常の clone 間では読み取り専用で共有する。 + repetition_index: Option>, /// chunk のリスト。各 chunk は `Box` 配下の固定長配列で、grow しても再配置されない。 chunks: Vec, @@ -749,7 +758,52 @@ pub struct StateStack { } // ANCHOR_END: state_stack_struct +/// 根以前の反復候補を除外するための索引。 +/// +/// 持ち駒の優劣比較も候補に残すため、持ち駒を含まない board key を使う。 +/// 集合への一致だけでは反復と判定せず、必ず通常の履歴走査に戻す。 +#[derive(Debug)] +struct RepetitionIndex { + root: StateIndex, + boards: HashSet, +} + impl StateStack { + #[cfg(test)] + pub(crate) fn reference_clone(&self) -> Self { + Self { + chunks: self.chunks.clone(), + head: self.head, + repetition_index: self.repetition_index.clone(), + } + } + + /// 現在の根までの履歴を索引化する。 + pub(crate) fn prepare_repetition_index(&mut self) { + let root = self.current_index(); + let first = root.saturating_sub(usize::from(self.hot(root).plies_from_null)); + let boards = (first..=root).map(|i| self.hot(i).board_key).collect(); + self.repetition_index = Some(Arc::new(RepetitionIndex { root, boards })); + } + + /// 索引で一致候補がない場合だけ、走査を根以後の経路に限定する。 + #[inline] + pub(crate) fn repetition_scan_limit( + &self, + current: StateIndex, + plies: Ply, + key: ZobristKey, + ) -> Ply { + let Some(index) = &self.repetition_index else { return plies }; + let Some(depth) = current.checked_sub(index.root) else { return plies }; + // null move の境界が根以後にある場合、元の範囲ですでに十分短い。 + if depth >= usize::from(plies) || index.boards.contains(&key) { + return plies; + } + // depth < plies なので Ply に収まる。 + Ply::try_from(depth).expect("depth below plies must fit in Ply") + } + #[inline(always)] const fn locate(idx: StateIndex) -> (usize, usize) { (idx / STATE_CHUNK_CAPACITY, idx % STATE_CHUNK_CAPACITY) @@ -826,11 +880,12 @@ impl StateStack { #[must_use] pub fn new() -> Self { let chunks = vec![Self::new_chunk()]; - Self { chunks, head: 0 } + Self { chunks, head: 0, repetition_index: None } } /// 探索開始時のリセット(root state のみ初期化し、追加 chunk は破棄) pub(crate) fn reset_sfen(&mut self) { + self.repetition_index = None; self.head = 0; self.chunks.truncate(1); self.chunks[0][0].reset_sfen(); @@ -1053,6 +1108,7 @@ impl StateStack { let popped = self.head; self.head = popped - 1; + self.invalidate_repetition_index_before_root(); Some(popped) } @@ -1064,9 +1120,18 @@ impl StateStack { pub(crate) unsafe fn pop_unchecked(&mut self) -> StateIndex { let popped = self.head; self.head = popped - 1; + self.invalidate_repetition_index_before_root(); popped } + /// 根より前の履歴を変更できる状態では、固定した集合を再利用しない。 + #[inline] + fn invalidate_repetition_index_before_root(&mut self) { + if self.repetition_index.as_ref().is_some_and(|index| self.head < index.root) { + self.repetition_index = None; + } + } + /// 現在の深さ(スタックに積まれている `StateInfo` の数)を取得 #[cfg(test)] #[must_use] @@ -1086,8 +1151,23 @@ impl Index for StateStack { impl Clone for StateStack { fn clone(&self) -> Self { - let chunks = self.chunks.iter().map(|chunk| Box::new((**chunk).clone())).collect(); - Self { chunks, head: self.head } + let chunks = self.chunks.clone(); + Self { chunks, head: self.head, repetition_index: self.repetition_index.clone() } + } + + fn clone_from(&mut self, source: &Self) { + // 予約容量も継承し、複製先に余る chunk は次回のために保持する。 + while self.chunks.len() < source.chunks.len() { + self.chunks.push(Self::new_chunk()); + } + let (last_chunk, last_offset) = Self::locate(source.head); + for (target, source) in self.chunks[..last_chunk].iter_mut().zip(&source.chunks) { + target.as_mut_slice().clone_from_slice(source.as_slice()); + } + self.chunks[last_chunk][..=last_offset] + .clone_from_slice(&source.chunks[last_chunk][..=last_offset]); + self.head = source.head; + self.repetition_index.clone_from(&source.repetition_index); } } diff --git a/crates/rsshogi/src/board/state_info/tests/mod.rs b/crates/rsshogi/src/board/state_info/tests/mod.rs index d7725316..990be1bb 100644 --- a/crates/rsshogi/src/board/state_info/tests/mod.rs +++ b/crates/rsshogi/src/board/state_info/tests/mod.rs @@ -3,8 +3,48 @@ use crate::board::test_support::move_from_usi_expect; use crate::board::zobrist::Zobrist; use crate::types::{Color, Hand, MOVE_NONE, Piece, PieceType}; +#[test] +fn repetition_index_filters_only_old_candidates_and_respects_null_boundary() { + let mut stack = StateStack::new(); + for i in 0..=8 { + if i > 0 { + stack.push_empty(); + } + stack.current_mut().hot.board_key = ZobristKey::from_u64(i as u64); + stack.current_mut().hot.plies_from_null = i; + } + stack.prepare_repetition_index(); + let hit = ZobristKey::from_u64(2); + let miss = ZobristKey::from_u64(99); + assert_eq!(stack.repetition_scan_limit(12, 12, hit), 12); + assert_eq!(stack.repetition_scan_limit(12, 12, miss), 4); + assert_eq!(stack.repetition_scan_limit(12, 3, miss), 3); + + let mut clone = stack.clone(); + assert!(Arc::ptr_eq( + stack.repetition_index.as_ref().unwrap(), + clone.repetition_index.as_ref().unwrap() + )); + assert!(stack.clone_suffix(4).repetition_index.is_none()); + stack.pop().unwrap(); + assert!(stack.repetition_index.is_none()); + assert!(clone.repetition_index.is_some()); + assert_eq!(stack.repetition_scan_limit(11, 11, miss), 11); + // SAFETY: clone は深さ 8 の stack を保持する。 + unsafe { clone.pop_unchecked() }; + assert!(clone.repetition_index.is_none()); + + stack.current_mut().hot.plies_from_null = 2; + stack.prepare_repetition_index(); + assert_eq!(stack.repetition_scan_limit(11, 6, hit), 4); + assert_eq!(stack.repetition_scan_limit(11, 6, ZobristKey::from_u64(6)), 6); + stack.reset_sfen(); + assert!(stack.repetition_index.is_none()); +} + #[allow(clippy::cognitive_complexity)] fn assert_state_info_matches(actual: &StateInfo, expected: &StateInfo) { + assert_eq!(actual.cold.undo, expected.cold.undo); assert_eq!(actual.board_key, expected.board_key); assert_eq!(actual.key, expected.key); assert_eq!(actual.pawn_key, expected.pawn_key); @@ -23,6 +63,25 @@ fn assert_state_info_matches(actual: &StateInfo, expected: &StateInfo) { assert_eq!(actual.last_moved_piece_type(), expected.last_moved_piece_type()); } +#[test] +fn clone_from_reuses_chunks_and_replaces_repetition_index() { + let mut source = StateStack::new(); + source.prepare_additional(130); + source.prepare_repetition_index(); + let mut target = StateStack::new(); + target.prepare_additional(260); + let pointers: Vec<_> = target.chunks.iter().map(|chunk| chunk.as_ptr()).collect(); + target.clone_from(&source); + assert_eq!(pointers, target.chunks.iter().map(|chunk| chunk.as_ptr()).collect::>()); + assert!(Arc::ptr_eq( + source.repetition_index.as_ref().unwrap(), + target.repetition_index.as_ref().unwrap() + )); + source.repetition_index = None; + target.clone_from(&source); + assert!(target.repetition_index.is_none()); +} + #[test] #[allow(clippy::cognitive_complexity)] fn test_state_info_default() { @@ -374,6 +433,31 @@ fn test_state_stack_clone_does_not_preserve_pointer_identity() { assert_ne!(original, cloned_ptr, "cloned StateStack must be a different allocation"); } +#[test] +fn clone_preserves_prepared_chunks_and_independent_entries() { + let mut stack = StateStack::new(); + for value in 1..=70 { + stack.push_empty(); + stack.current_mut().plies_from_null = value; + } + assert!(stack.prepare_additional(130)); + let mut cloned = stack.clone(); + assert_eq!(cloned.chunk_count(), stack.chunk_count()); + assert_eq!(cloned.prepared_additional_capacity(), stack.prepared_additional_capacity()); + for index in 0..=stack.current_index() { + assert_state_info_matches(&cloned[index], &stack[index]); + assert_ne!(std::ptr::from_ref(&cloned[index]), std::ptr::from_ref(&stack[index])); + } + cloned.current_mut().repetition_counter = 99; + assert_eq!(stack.current().repetition_counter, 0); + let chunks = cloned.chunk_count(); + for _ in 0..130 { + cloned.push_for_move(); + } + assert_eq!(cloned.chunk_count(), chunks); + assert_eq!(stack.current_index(), 70); +} + /// chunk 境界をまたぐ index 計算が正しく動作することを検証。 #[test] fn test_state_stack_index_across_chunks() { diff --git a/crates/rsshogi/src/types/hand.rs b/crates/rsshogi/src/types/hand.rs index fbf41a3e..73ed01e2 100644 --- a/crates/rsshogi/src/types/hand.rs +++ b/crates/rsshogi/src/types/hand.rs @@ -95,6 +95,10 @@ impl Hand { None => hand, } } + #[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。checked_sub を使い、枚数不足時の扱いを呼び出し側で決めてください" + )] pub const fn sub_one(hand: Self, p: HandPiece) -> Self { match hand.checked_sub(p, 1) { Some(v) => v, @@ -120,24 +124,31 @@ impl Hand { && a.count(HandPiece::ROOK) >= b.count(HandPiece::ROOK) && a.count(HandPiece::GOLD) >= b.count(HandPiece::GOLD) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn pawn_count(self) -> u32 { self.count(HandPiece::PAWN) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn lance_count(self) -> u32 { self.count(HandPiece::LANCE) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn knight_count(self) -> u32 { self.count(HandPiece::KNIGHT) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn silver_count(self) -> u32 { self.count(HandPiece::SILVER) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn bishop_count(self) -> u32 { self.count(HandPiece::BISHOP) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn rook_count(self) -> u32 { self.count(HandPiece::ROOK) } + #[deprecated(since = "1.6.0", note = "2.0.0 で削除予定。Hand::count を使ってください")] pub const fn gold_count(self) -> u32 { self.count(HandPiece::GOLD) } diff --git a/crates/rsshogi/src/types/mod.rs b/crates/rsshogi/src/types/mod.rs index f7a809f1..fd327586 100644 --- a/crates/rsshogi/src/types/mod.rs +++ b/crates/rsshogi/src/types/mod.rs @@ -76,9 +76,12 @@ pub use game_result::GameResult; pub use hand::{HAND_BIT_MASK, HAND_BORROW_MASK, Hand, HandPiece}; pub use ki2::{Ki2Notation, Ki2ResolveError, ParseKi2NotationError}; pub(crate) use moves::MoveBase; +// 1.x の公開パスを維持する。 +#[allow(deprecated)] +pub use moves::Move32Metadata; + pub use moves::{ AperyMove, AperyMove32, MOVE_END, MOVE_NONE, MOVE_NULL, MOVE_RESIGN, MOVE_WIN, Move, Move32, - Move32Metadata, }; pub use piece::{Piece, PieceType}; pub use rank::{Rank, is_promotable_rank}; diff --git a/crates/rsshogi/src/types/moves.rs b/crates/rsshogi/src/types/moves.rs index 701a8efe..e6015aad 100644 --- a/crates/rsshogi/src/types/moves.rs +++ b/crates/rsshogi/src/types/moves.rs @@ -463,6 +463,10 @@ impl MoveBase for Move32 { } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[deprecated( + since = "1.6.0", + note = "2.0.0 で削除予定。着手前の探索用情報の型は consumer 側で定義してください" +)] pub struct Move32Metadata { piece_after_move: Piece, captured_piece: Piece, @@ -471,6 +475,8 @@ pub struct Move32Metadata { is_drop: bool, from_to_index: u16, } +// 1.x の互換実装内で型を参照する。 +#[allow(deprecated)] impl Move32Metadata { const NO_FROM_TO_INDEX: u16 = u16::MAX; @@ -796,6 +802,7 @@ mod tests { } #[test] + #[allow(deprecated)] // 1.x の Move32Metadata のレイアウトと index 表現を検査する。 fn move32_metadata_uses_a_compact_optional_index_sentinel() { let metadata = Move32Metadata::new(Piece::B_PAWN, Piece::NONE, Some(123)); let absent = Move32Metadata::with_from_to_index( diff --git a/crates/rsshogi/tests/legacy_api_compat.rs b/crates/rsshogi/tests/legacy_api_compat.rs new file mode 100644 index 00000000..93caacd7 --- /dev/null +++ b/crates/rsshogi/tests/legacy_api_compat.rs @@ -0,0 +1,188 @@ +//! 1.x の廃止予定 API の公開パスと動作を、外部 crate と同じ入口から検査する。 + +// 非推奨 API の互換性検査では警告を許可し、維持対象の検査では関数単位で禁止する。 +#![allow(deprecated)] + +use rsshogi::board::attack_tables::{ + GOLD_ATTACKS, KING_ATTACKS, KNIGHT_ATTACKS, PAWN_ATTACKS, SILVER_ATTACKS, + gold_attacks_unchecked, king_attacks_unchecked, knight_attacks_unchecked, + pawn_attacks_unchecked, rook_rank_attacks, silver_attacks_unchecked, +}; +use rsshogi::board::movegen::{LegalAll, MoveGenType}; +use rsshogi::board::{hirate_position, position_from_sfen}; +use rsshogi::types::{Bitboard, Color, File, Hand, HandPiece, Move, PieceType, Square}; + +#[test] +#[deny(deprecated)] +fn retained_rule_and_attack_primitives_are_not_deprecated() { + let position = hirate_position(); + assert!(position.has_pawn_on_file(Color::BLACK, File::FILE_5)); + let square = Square::from_usi("5e").unwrap(); + let occupied = Bitboard::EMPTY; + assert_eq!( + rsshogi::board::attack_tables::rook_attacks(square, occupied), + rook_rank_attacks(square, occupied) + | rsshogi::board::attack_tables::rook_file_attacks(square, occupied) + ); +} + +#[test] +fn key_prediction_matches_real_updates_without_mutating_source() { + let fixtures = [ + ("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1", "7g7f"), + ("lnsgk1snl/1r4gb1/p1pppp2p/6pp1/1p7/2P6/PP1PPPP1P/1BG4R1/LNS1KGSNL b p 11", "2h2d"), + ("lnsgk1snl/1p4g2/pR1ppp2p/2p6/9/9/P1SPPPP1P/2G6/LN2KGSNL b B3Prb2p 25", "8c8f+"), + ("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPP1PPPP/1B5R1/LNSGKGSNL b P 1", "P*5e"), + ("lnsgkgsnl/1b5r1/pppp1pppp/9/9/9/PPPPPPPPP/1R5B1/LNSGKGSNL w p 1", "P*5e"), + ]; + for (sfen, usi) in fixtures { + let position = position_from_sfen(sfen).unwrap(); + let mv = Move::from_usi(usi).unwrap(); + let full = position.move32_from_move(mv); + assert!(position.is_legal_move(mv), "{usi}"); + let before = (position.key(), position.board_key(), position.game_ply()); + let predicted = position.key_after(full); + let predicted_board = position.board_key_after(full); + assert_eq!(predicted, position.key_after_move(mv)); + assert_eq!(predicted_board, position.board_key_after_move(mv)); + assert_eq!(before, (position.key(), position.board_key(), position.game_ply())); + let mut actual = position.clone(); + actual.apply_move32(full); + assert_eq!(predicted, actual.key(), "{usi}"); + assert_eq!(predicted_board, actual.board_key(), "{usi}"); + } + let position = hirate_position(); + let mut actual = position.clone(); + actual.apply_null_move().unwrap(); + assert_eq!(position.key_after_null(), actual.key()); +} + +#[test] +fn classification_and_metadata_keep_their_published_meaning() { + for (sfen, usi, capture, pawn_promotion, valuable_promotion) in [ + ("4k4/9/9/9/9/9/4P4/9/4K4 b - 1", "5g5f", false, false, false), + ("4k4/9/9/9/4p4/4P4/9/9/4K4 b - 1", "5f5e", true, false, false), + ("4k4/9/4P4/9/9/9/9/9/4K4 b - 1", "5c5b+", false, true, true), + ("4k4/9/4S4/9/9/9/9/9/4K4 b - 1", "5c4b+", false, false, false), + ("4k4/9/9/9/9/9/9/9/4K4 b P 1", "P*5e", false, false, false), + ] { + let position = position_from_sfen(sfen).unwrap(); + let mv = Move::from_usi(usi).unwrap(); + let full = position.move32_from_move(mv); + assert!(position.is_legal_move(mv)); + assert_eq!(position.is_capture_or_promotion(full), capture || mv.is_promotion()); + assert_eq!(position.is_pawn_promotion(full), pawn_promotion); + assert_eq!(position.is_capture_or_pawn_promotion(full), capture || pawn_promotion); + assert_eq!(position.is_capture_or_valuable_promotion(full), capture || valuable_promotion); + assert_eq!(position.is_capture_stage(full), capture); + let metadata: rsshogi::types::moves::Move32Metadata = position.move32_metadata(full); + let alias: rsshogi::types::Move32Metadata = position.classify_move32(full); + assert_eq!(metadata, alias); + assert_eq!(metadata.is_capture(), capture); + assert_eq!(metadata.is_promotion(), mv.is_promotion()); + assert_eq!(metadata.is_drop(), mv.is_drop()); + assert_eq!(metadata.from_to_index(), full.from_to_index()); + assert_eq!(position.moved_piece_before(full), position.moved_piece_before_move(mv)); + let mut actual = position.clone(); + let facts = actual.try_apply_move32_with_facts(full).unwrap(); + assert_eq!(metadata.piece_after_move(), facts.moved_piece_after); + assert_eq!(metadata.captured_piece(), facts.captured_piece); + let site: rsshogi::board::MoveStackSiteFacts = facts.search_stack_site(); + let same_path: rsshogi::board::position::MoveStackSiteFacts = site; + assert_eq!(same_path.moved_piece, facts.moved_piece_after); + assert_eq!(same_path.to, facts.to); + } +} + +#[test] +fn helpers_keep_const_generic_and_failure_behavior() { + const EMPTY: Hand = Hand::sub_one(Hand::ZERO, HandPiece::PAWN); + assert_eq!(EMPTY, Hand::ZERO); + let hand = Hand::ZERO.checked_add(HandPiece::PAWN, 2).unwrap(); + assert_eq!(Hand::sub_one(hand, HandPiece::PAWN).pawn_count(), 1); + for count in [ + hand.lance_count(), + hand.knight_count(), + hand.silver_count(), + hand.bishop_count(), + hand.rook_count(), + hand.gold_count(), + ] { + assert_eq!(count, 0); + } + assert!(LegalAll::is_generate_all_legal()); + let position = hirate_position(); + let types = [PieceType::PAWN, PieceType::GOLD]; + let expected = position.pieces_by_type(types[0]) | position.pieces_by_type(types[1]); + assert_eq!(position.pieces_by_types(&types), expected); + assert_eq!(position.pieces_by_types_array(types), expected); + let expected = + position.pieces_for(types[0], Color::BLACK) | position.pieces_for(types[1], Color::BLACK); + assert_eq!(position.pieces_for_types(&types, Color::BLACK), expected); + assert_eq!(position.pieces_for_types_array(types, Color::BLACK), expected); + assert_eq!(position.pieces_by_types(&[]), Bitboard::EMPTY); + assert!(position.has_pawn_on_file(Color::BLACK, File::FILE_5)); + let mv = position.move32_from_move(Move::from_usi("5g5f").unwrap()); + assert_eq!( + position.is_discovery_check_on_king(Color::BLACK, mv), + position.blockers_for_king(Color::BLACK).test(mv.to_move().from_sq()) + ); + let checks = position.current_state_cache().check_squares(); + let full = checks.to_full_array(); + for pt in PieceType::iter() { + assert_eq!(full[pt.to_index()], checks.get(pt)); + } +} + +#[test] +fn legacy_attack_functions_match_independent_board_geometry() { + for square in Square::iter() { + for color in Color::iter() { + // SAFETY: square と color は有効な値だけを列挙している。 + unsafe { + assert_eq!( + pawn_attacks_unchecked(square, color), + PAWN_ATTACKS[square][color.to_index()] + ); + assert_eq!( + knight_attacks_unchecked(square, color), + KNIGHT_ATTACKS[square][color.to_index()] + ); + assert_eq!( + silver_attacks_unchecked(square, color), + SILVER_ATTACKS[square][color.to_index()] + ); + assert_eq!( + gold_attacks_unchecked(square, color), + GOLD_ATTACKS[square][color.to_index()] + ); + assert_eq!(king_attacks_unchecked(square), KING_ATTACKS[square]); + } + } + // 横方向の各遮蔽配置を盤上の歩行で検査し、旧 API の境界を維持する。 + for mask in 0_u16..512 { + let mut occupied = Bitboard::EMPTY; + for file in 0..9 { + if mask & (1 << file) != 0 { + occupied |= Bitboard::from_square(Square::from_file_rank( + File::new(file), + square.rank(), + )); + } + } + let mut expected = Bitboard::EMPTY; + for direction in [-1_i8, 1] { + let mut file = square.file().raw() + direction; + while (0..9).contains(&file) { + let target = Square::from_file_rank(File::new(file), square.rank()); + expected |= Bitboard::from_square(target); + if occupied.test(target) { + break; + } + file += direction; + } + } + assert_eq!(rook_rank_attacks(square, occupied), expected); + } + } +} diff --git a/crates/rsshogi/tests/property_zobrist_key.rs b/crates/rsshogi/tests/property_zobrist_key.rs index 9891bd5a..611d27fc 100644 --- a/crates/rsshogi/tests/property_zobrist_key.rs +++ b/crates/rsshogi/tests/property_zobrist_key.rs @@ -1,8 +1,7 @@ //! Zobrist キーの代数的な契約を、実際の局面走査で固定する。 //! -//! 対象は 3 つ。 +//! 対象は 2 つ。 //! -//! - `key_after` / `board_key_after` が、実際に手を適用した後のキーと一致すること //! - 合成キーが「盤面キー ^ 持ち駒寄与の全再計算」に分解できること //! - apply / undo でキーが往復すること @@ -48,56 +47,6 @@ fn legal_moves(pos: &Position) -> Vec { moves.iter().map(|&mv| pos.move32_from_move(mv)).filter(|&mv| pos.is_legal_move32(mv)).collect() } -#[test] -fn key_after_matches_the_key_produced_by_applying_the_move() { - board::init(); - - for sfen in SCENARIOS { - let base = position_from_sfen(sfen).expect("valid sfen"); - let moves = legal_moves(&base); - assert!(!moves.is_empty(), "scenario must have legal moves: {sfen}"); - - for mv in moves { - let predicted_key = base.key_after(mv); - let predicted_board_key = base.board_key_after(mv); - let predicted_board_key_from_move = base.board_key_after_move(mv.to_move()); - - let mut pos = base.clone(); - pos.apply_move32(mv); - - assert_eq!(predicted_key, pos.key(), "key_after mismatch for {mv:?} in {sfen}"); - assert_eq!( - predicted_board_key, - pos.board_key(), - "board_key_after mismatch for {mv:?} in {sfen}" - ); - assert_eq!( - predicted_board_key_from_move, - pos.board_key(), - "board_key_after_move mismatch for {mv:?} in {sfen}" - ); - } - } -} - -#[test] -fn key_after_null_matches_the_key_produced_by_applying_a_null_move() { - board::init(); - - for sfen in SCENARIOS { - let mut pos = position_from_sfen(sfen).expect("valid sfen"); - if pos.is_in_check() { - // null move は王手中には適用できない。 - continue; - } - - let predicted = pos.key_after_null(); - pos.apply_null_move().expect("null move"); - - assert_eq!(predicted, pos.key(), "key_after_null mismatch in {sfen}"); - } -} - #[test] fn composite_key_decomposes_into_board_key_and_hand_contribution() { board::init(); diff --git a/crates/rsshogi/tests/repetition_history_limit.rs b/crates/rsshogi/tests/repetition_history_limit.rs new file mode 100644 index 00000000..f158e8b8 --- /dev/null +++ b/crates/rsshogi/tests/repetition_history_limit.rs @@ -0,0 +1,111 @@ +//! プロセス全体の履歴保持上限と、索引付きコピーの互換性を専用バイナリで検査する。 +//! +//! static を変更するため、このバイナリにはテストを 1 件だけ置く。 + +// 2.0.0 で削除するまで、公開済み setter の動作を検査する。 +#![allow(deprecated)] + +use rsshogi::board::{Position, position_from_sfen}; +use rsshogi::types::{Move, Move32, RepetitionState}; + +const LONG_CYCLE: [&str; 18] = [ + "9i8i", "1a2a", "8i7i", "2a3a", "7i7h", "3a3b", "7h7g", "3b3c", "7g8g", "3c2c", "8g9g", "2c1c", + "9g9h", "1c1b", "9h8h", "1b2b", "8h9i", "2b1a", +]; + +struct RestoreDefaultLimit; + +impl Drop for RestoreDefaultLimit { + fn drop(&mut self) { + Position::set_max_repetition_ply(u16::MAX); + } +} + +fn apply(position: &mut Position, usi: &str) -> Move32 { + let mv = Move::from_usi(usi).unwrap(); + assert!(position.is_legal_move(mv), "{usi}"); + let mv = position.move32_from_move(mv); + position.try_apply_move32(mv).unwrap(); + mv +} + +fn assert_same_repetition(plain: &Position, indexed: &Position) { + assert_eq!(plain.to_sfen(None), indexed.to_sfen(None)); + assert_eq!(plain.key(), indexed.key()); + assert_eq!(plain.repetition_counter(), indexed.repetition_counter()); + assert_eq!(plain.repetition_distance(), indexed.repetition_distance()); + assert_eq!(plain.repetition_state(), indexed.repetition_state()); + for ply in [0, 1, 4, 17, 18, 64, usize::MAX] { + assert_eq!( + plain.repetition_state_with_distance(ply), + indexed.repetition_state_with_distance(ply) + ); + assert_eq!(plain.repetition_state_with_ply(ply), indexed.repetition_state_with_ply(ply)); + assert_eq!( + plain.cached_repetition_state_with_ply(ply), + indexed.cached_repetition_state_with_ply(ply) + ); + } +} + +fn check_limits(root: &Position, history: &[&str], future: &[&str], limits: &[u16]) { + let mut source = root.clone(); + let mut snapshots = vec![source.to_sfen(None)]; + for usi in history { + apply(&mut source, usi); + snapshots.push(source.to_sfen(None)); + } + for &limit in limits { + Position::set_max_repetition_ply(limit); + let kept = usize::from(limit).min(history.len()); + let mut indexed = source.clone_for_search(); + // 独立した SFEN から保持対象だけを再生し、索引のない参照局面を作る。 + let mut plain = position_from_sfen(&snapshots[history.len() - kept]).unwrap(); + for usi in &history[history.len() - kept..] { + apply(&mut plain, usi); + } + assert_same_repetition(&plain, &indexed); + + // 保持された履歴をちょうど kept 手だけ undo でき、それ以前には戻れない。 + let mut undo_copy = indexed.clone(); + for _ in 0..kept { + undo_copy.undo_move32(undo_copy.last_move()).unwrap(); + } + assert_eq!(undo_copy.to_sfen(None), snapshots[history.len() - kept]); + assert!(undo_copy.undo_move32(undo_copy.last_move()).is_err()); + + // setter は既存コピーの保持範囲を変えず、新しい clone_for_search にだけ効く。 + Position::set_max_repetition_ply(0); + for usi in future { + apply(&mut plain, usi); + apply(&mut indexed, usi); + assert_same_repetition(&plain, &indexed); + } + if future.len() >= LONG_CYCLE.len() * 3 { + assert_eq!(indexed.repetition_state(), RepetitionState::Draw); + } + + // 通常の clone は上限 0 でも元の履歴を維持する。 + let mut full = source.clone(); + for _ in history { + full.undo_move32(full.last_move()).unwrap(); + } + assert_eq!(full.to_sfen(None), snapshots[0]); + } +} + +#[test] +fn global_history_limit_preserves_bounded_indexed_clone_behavior() { + let _restore = RestoreDefaultLimit; + let root = position_from_sfen("8k/9/9/9/9/9/9/9/K8 b - 1").unwrap(); + let mut future = vec![LONG_CYCLE[17]]; + future.extend(LONG_CYCLE.repeat(3)); + check_limits(&root, &LONG_CYCLE[..17], &future, &[0, 1, 4, 16, 17, 18, u16::MAX]); + + let mut after_null = rsshogi::board::hirate_position(); + apply(&mut after_null, "7g7f"); + apply(&mut after_null, "3c3d"); + after_null.apply_null_move().unwrap(); + // 上限が大きくても null move より前の履歴は持ち込まない。 + check_limits(&after_null, &["2c2d", "2g2f", "2d2e"], &["2f2e"], &[0, 1, 2, 3, 4, u16::MAX]); +} diff --git a/crates/rsshogi/tests/zero_allocation_contract.rs b/crates/rsshogi/tests/zero_allocation_contract.rs index 5f3344fd..a5d9e38c 100644 --- a/crates/rsshogi/tests/zero_allocation_contract.rs +++ b/crates/rsshogi/tests/zero_allocation_contract.rs @@ -101,6 +101,81 @@ fn apply_and_undo_do_not_allocate() { }); } +#[allow(deprecated)] // 1.x の search null move で予約容量と clone 後の無確保契約を検査する。 +fn prepared_clone_keeps_allocation_free_capacity() { + let mut source = hirate_position(); + source.prepare_search_state_capacity(130).unwrap(); + let mut cloned = source.clone(); + let original_key = source.key(); + assert_no_allocation("prepared clone capacity", || { + for _ in 0..130 { + cloned.try_apply_search_null_move().unwrap(); + } + for _ in 0..130 { + cloned.undo_search_null_move().unwrap(); + } + }); + assert_eq!(cloned.key(), original_key); + assert_eq!(source.key(), original_key); + assert_eq!(source.state_stack_depth(), 0); +} + +fn prepared_normal_moves_do_not_allocate() { + let mut source = Position::from_sfen("4k4/9/9/9/9/9/9/9/4K4 b - 1").unwrap(); + source.prepare_search_state_capacity(130).unwrap(); + let original_key = source.key(); + let original_ply = source.game_ply(); + let mut setup = source.clone(); + let moves: Vec<_> = ["5i6i", "5a6a", "6i5i", "6a5a"] + .into_iter() + .cycle() + .take(130) + .map(|usi| { + let mv = setup.move32_from_move(rsshogi::types::Move::from_usi(usi).unwrap()); + assert!(setup.is_legal_move32(mv)); + setup.apply_move32(mv); + mv + }) + .collect(); + let mut cloned = source.clone(); + assert_no_allocation("prepared normal moves", || { + for &mv in &moves { + assert!(cloned.has_prepared_search_state()); + cloned.apply_move32(mv); + } + for &mv in moves.iter().rev() { + cloned.undo_move32(mv).unwrap(); + } + }); + assert_eq!(cloned.key(), original_key); + assert_eq!(cloned.game_ply(), original_ply); + assert_eq!(cloned.state_stack_depth(), 0); +} + +fn clone_from_reuses_prepared_capacity() { + let mut source = hirate_position(); + source.prepare_search_state_capacity(130).unwrap(); + let mut target = hirate_position(); + target.prepare_search_state_capacity(260).unwrap(); + for indexed in [false, true] { + if indexed { + source = source.clone_for_search(); + } + assert_no_allocation("prepared clone_from", || { + for _ in 0..8 { + target.clone_from(&source); + } + for _ in 0..130 { + target.apply_null_move().unwrap(); + } + for _ in 0..130 { + target.undo_null_move().unwrap(); + } + }); + assert_eq!(target.key(), source.key()); + } +} + fn mate_in_one_in_place_does_not_allocate() { let mut fixtures: Vec = positions(); for pos in &mut fixtures { @@ -147,6 +222,9 @@ fn hot_paths_do_not_allocate() { move_generation_does_not_allocate(); fresh_move_lists_live_on_the_stack(); apply_and_undo_do_not_allocate(); + prepared_clone_keeps_allocation_free_capacity(); + prepared_normal_moves_do_not_allocate(); + clone_from_reuses_prepared_capacity(); mate_in_one_in_place_does_not_allocate(); is_mated_does_not_allocate(); } diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 21e0dda0..56913a7c 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -10,6 +10,7 @@ - [インストール](getting-started/installation.md) - [クイックスタート](getting-started/quickstart.md) - [実践レシピ集](getting-started/examples.md) +- [1.6.0 の互換性と 2.0.0 への移行](getting-started/migration-1.6.md) --- diff --git a/docs/book/src/architecture.md b/docs/book/src/architecture.md index e3a8b270..eacc5402 100644 --- a/docs/book/src/architecture.md +++ b/docs/book/src/architecture.md @@ -75,8 +75,8 @@ USI 探索エンジンのように盤面操作と合法手生成だけを必要 `rsshogi` は「将棋のルール、局面状態、データ形式を正確に扱う基盤ライブラリ」に徹しており、探索エンジン固有のポリシーとは明確に境界を引いている。 -1. **探索アルゴリズムと評価関数を本体に含めない**:置換表(Transposition Table)、NNUE 評価関数、オーダリング用の History テーブル、並列探索(Lazy SMP)、持ち時間制御、定跡の確率的選択ロジックは利用側のエンジン層に委ねる。代わりに、`key_after_move` によるプリフェッチ支援、`StateCacheView` や `MoveFacts` による戦術キャッシュの公開、`clone_for_search_bounded` による軽量な探索用クローンなど、探索エンジンが必要とする低レベルプリミティブを提供する。 -2. **プロセス全体のグローバルな設定変更を持たない**:不成(成れるのに成らない手)を含むかどうかといった手生成方針は、グローバルなフラグではなく `Legal` / `LegalAll` や `Evasions` / `EvasionsAll` などの型パラメータと関数で呼び出し箇所ごとに明示する。これにより、同一プロセス内で探索部(`Legal`)と棋譜解析部(`LegalAll`)が干渉せずに共存できる。 +1. **探索アルゴリズムと評価関数を本体に含めない**:置換表(Transposition Table)、NNUE 評価関数、オーダリング用の History テーブル、並列探索(Lazy SMP)、持ち時間制御、定跡の確率的選択ロジックは利用側のエンジン層に委ねる。代わりに、`StateCacheView` による戦術キャッシュの参照、`MoveApplyFacts` による指し手適用結果の取得、`clone_for_search()` による独立した探索用コピーなど、探索エンジンが必要とする低レベルプリミティブを提供する。着手前のキー予測や TT prefetch は、consumer が自身の探索状態を使って実装する。 +2. **設定は呼び出し箇所ごとに指定する**:不成(成れるのに成らない手)を含むかどうかといった手生成方針は、グローバルなフラグではなく `Legal` / `LegalAll` や `Evasions` / `EvasionsAll` などの型パラメータと関数で呼び出し箇所ごとに明示する。これにより、同一プロセス内で探索部(`Legal`)と棋譜解析部(`LegalAll`)が干渉せずに共存できる。 互換性のために残す例外として、`Position::set_max_repetition_ply()` はプロセス全体の `clone_for_search()` の履歴保持上限を変更する。この API は 1.6.0 から非推奨で、2.0.0 で削除予定である。 3. **ホットパスでのヒープ確保を排除する**:合法手リスト `MoveList` / `Move32List` は `MaybeUninit` を用いた固定長のスタック配列で実装し、局面履歴 `StateStack` は 64 局面単位のチャンク配列で管理することで、探索中のメモリアロケーションとポインタ移動を防ぐ。 4. **公開 raw 値とワイヤ形式の互換性を固定する**:`Square`(`0..=80`、`SQ_NONE = 81`)、`Hand(u32)`、`Move(u16)`(やねうら王互換ビット配置)、`AperyMove(u16)`(cshogi / Apery 互換ビット配置)、Zobrist キー、および HCP / PackedSfen / SBINPACK / SAZPACK のバイナリレイアウトは契約テストで固定し、外部ツールや学習データとの相互運用性を保証する。 diff --git a/docs/book/src/getting-started/installation.md b/docs/book/src/getting-started/installation.md index 0d05bc06..d5b19be5 100644 --- a/docs/book/src/getting-started/installation.md +++ b/docs/book/src/getting-started/installation.md @@ -1,9 +1,5 @@ # インストール -この文書は rsshogi 1.5.1 を対象とする。 -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 -旧定跡 API の削除を含むため、更新時は移行手順を確認する。 - ## Python パッケージの導入 Python 3.10 以降に対応する。 @@ -49,7 +45,7 @@ Python パッケージを sdist からビルドする場合も同様であり、 ```toml [dependencies] -rsshogi = "1.5.1" +rsshogi = "1.6.0" ``` ### 用途別の Feature フラグ指定 @@ -59,12 +55,12 @@ rsshogi = "1.5.1" | 用途 | `Cargo.toml` の設定例 | |---|---| -| **USI 将棋エンジン(64-bit ハッシュ)** | `rsshogi = "1.5.1"` | -| **USI 将棋エンジン(128-bit ハッシュ + 定跡参照)** | `rsshogi = { version = "1.5.1", features = ["hash-128", "book"] }` | -| **棋譜変換、定跡構築、教師データ生成ツール** | `rsshogi = { version = "1.5.1", features = ["records", "book"] }` | -| **AlphaZero 系探索(Policy ラベルのみ利用)** | `rsshogi = { version = "1.5.1", features = ["policy-labels"] }` | -| **盤面エディタ、GUI バックエンド(局面検証 + SVG)** | `rsshogi = { version = "1.5.1", features = ["validation", "svg", "records"] }` | -| **全機能の有効化** | `rsshogi = { version = "1.5.1", features = ["python-data", "hash-128"] }` | +| **USI 将棋エンジン(64-bit ハッシュ)** | `rsshogi = "1.6.0"` | +| **USI 将棋エンジン(128-bit ハッシュ + 定跡参照)** | `rsshogi = { version = "1.6.0", features = ["hash-128", "book"] }` | +| **棋譜変換、定跡構築、教師データ生成ツール** | `rsshogi = { version = "1.6.0", features = ["records", "book"] }` | +| **AlphaZero 系探索(Policy ラベルのみ利用)** | `rsshogi = { version = "1.6.0", features = ["policy-labels"] }` | +| **盤面エディタ、GUI バックエンド(局面検証 + SVG)** | `rsshogi = { version = "1.6.0", features = ["validation", "svg", "records"] }` | +| **全機能の有効化** | `rsshogi = { version = "1.6.0", features = ["python-data", "hash-128"] }` | 各 Feature が有効化するモジュールと依存クレートの詳細は [全体アーキテクチャの Cargo Feature フラグ一覧](../architecture.md#cargo-feature-フラグ一覧) を参照。 @@ -75,7 +71,7 @@ rsshogi = "1.5.1" ```toml [dependencies] -rsshogi = { git = "https://github.com/nyoki-mtl/rsshogi", tag = "v1.5.1", features = ["records"] } +rsshogi = { git = "https://github.com/nyoki-mtl/rsshogi", tag = "v1.6.0", features = ["records"] } ``` ## 次のステップ diff --git a/docs/book/src/getting-started/migration-1.6.md b/docs/book/src/getting-started/migration-1.6.md new file mode 100644 index 00000000..1b58e6bd --- /dev/null +++ b/docs/book/src/getting-started/migration-1.6.md @@ -0,0 +1,62 @@ +# 1.6.0 の互換性と 2.0.0 への移行 + +1.6.0 は、公開済みの Rust API のシグネチャと動作を維持する。 +下記の API は 1.6.0 から非推奨であり、2.0.0 で削除予定である。 +非推奨警告が出ても 1.x では従来の処理を継続できる。 +警告をエラーとして扱う環境では、移行するまで対象の呼び出しに限って `#[allow(deprecated)]` を指定する。 + +## 本体に残す操作 + +本体は将棋の基本型、盤面の照会、合法性判定、通常の着手と undo、適用結果、反復判定、棋譜と定跡の入出力を提供する。 +利用側は探索局面、評価器、探索スタック、TT と prefetch、着手前の探索用情報を所有する。 + +`moved_piece_before(Move32)` と `moved_piece_before_move(Move)` は、通常の局面照会として両方維持する。 +`MoveApplyFacts` とそのフィールド、`clone_for_search()`、反復判定 API も維持する。 +二歩の照会 `has_pawn_on_file` と飛車の横利き `rook_rank_attacks` も、本体の primitive として維持する。 +`prepare_search_state_capacity()` と `has_prepared_search_state()` は、通常の `Position` の容量確保と確認に利用できる。 +これらを探索専用の着手 API と一括して廃止することはしない。 + +## 2.0.0 で削除する探索用 API + +| 対象 | 移行方法 | +|---|---| +| `Position::key_after`、`key_after_move` | 着手前のキー予測は利用側の盤面表現で実装する。通常の着手後は `key()` または `MoveApplyFacts::key_after` を読む。 | +| `Position::board_key_after`、`board_key_after_move` | 着手前の盤面キー予測は利用側で実装する。通常の着手後は `board_key()` または `MoveApplyFacts::board_key_after` を読む。 | +| `Position::key_after_null` | 手番のみを反転する予測は `key() ^ Zobrist::side()` で表せる。 | +| `Position::move32_metadata`、`classify_move32`、`types::Move32Metadata` | 利用側の型に、必要な移動後の駒、捕獲駒、成り、駒打ち、移動元と移動先のインデックスをまとめる。 | +| `MoveApplyFacts::search_stack_site`、`board::MoveStackSiteFacts` | `MoveApplyFacts` の `moved_piece_after` と `to` から、利用側の探索スタック用の値を構築する。`board::position::MoveStackSiteFacts` も同じ廃止対象である。 | +| `Position::try_apply_search_move32_with_facts` | 予約領域だけを使う探索専用の着手は利用側へ移す。通常の局面更新には `try_apply_move32_with_facts` を使う。 | +| `Position::try_apply_search_null_move`、`undo_search_null_move` | 対局手数を進めない探索用 null move と履歴は利用側で管理する。通常の `apply_null_move` と `undo_null_move` は対局手数も更新するため、単純な置換はできない。 | +| `Position::set_max_repetition_ply` | 保持履歴を短くする探索上の近似方針は利用側で管理する。既定の全履歴保持と setter の既存動作は 1.x 中維持する。 | + +`Move32Metadata` はデータ型であり、合法性判定や局面更新を行わない。 +独自の型へ移行する際は、import、型注釈、比較テストも変更する。 +本体の `Position` 版を期待値としているテストは、独立した盤面照会または固定した期待値で検査する。 + +`MoveStackSiteFacts` と `Move32Metadata` の公開パスは 1.x 中維持する。 +型の reexport や内部フィールドを非公開化して、削除前に互換性を失わせることもしない。 + +## 2.0.0 で削除する補助 API + +| 対象 | 移行方法 | +|---|---| +| `Position::is_capture_or_promotion` | `is_capture_move32(mv) || mv.is_promotion()`。 | +| `Position::is_pawn_promotion` | 成り手かを調べ、移動元の駒が歩かを確認する。 | +| `Position::is_capture_or_pawn_promotion` | 駒取り判定と歩の成り判定を組み合わせる。 | +| `Position::is_capture_or_valuable_promotion` | 歩、角、飛の成りを優先する分類方針を利用側で実装する。 | +| `Position::is_capture_stage` | 駒取りかを知りたい場合は `is_capture_move32` を使い、MovePicker の段階は利用側が決める。 | +| `Position::is_discovery_check_on_king` | 旧関数は `blockers_for_king(color).test(mv.to_move().from_sq())` に等しい。これは移動先まで含めた空き王手の確定判定ではない。 | +| `Position::pieces_by_types`、`pieces_by_types_array` | 駒種ごとの `pieces_by_type` の結果を論理和でまとめる。 | +| `Position::pieces_for_types`、`pieces_for_types_array` | 駒種ごとの `pieces_for` の結果を論理和でまとめる。 | +| `CheckSquares::to_full_array` | 必要な駒種を `CheckSquares::get` で参照する。 | +| `Hand::pawn_count`、`lance_count`、`knight_count`、`silver_count`、`bishop_count`、`rook_count`、`gold_count` | `Hand::count(HandPiece::...)` を使う。 | +| `Hand::sub_one` | `checked_sub(piece, 1)` を使う。枚数不足時に元の値を返す旧動作を維持するには `hand.checked_sub(piece, 1).unwrap_or(hand)` とする。 | +| `MoveGenType::is_generate_all_legal` | `MoveGenType::GENERATE_ALL_LEGAL` を参照する。 | +| `pawn_attacks_unchecked`、`knight_attacks_unchecked`、`silver_attacks_unchecked`、`gold_attacks_unchecked`、`king_attacks_unchecked` | 公開の `PAWN_ATTACKS`、`KNIGHT_ATTACKS`、`SILVER_ATTACKS`、`GOLD_ATTACKS`、`KING_ATTACKS` を添字で参照する。通常の添字アクセスは範囲を検査する。 | + +## 更新時に確認すること + +1.6.0 を使うために、非推奨 API をすべて置き換える必要はない。 +ビルド時の非推奨警告を確認し、2.0.0 へ更新するまでに、利用している API を上表の方法で移行する。 +探索用 null move の手数管理や履歴上限の変更は、通常の API への置換で動作が変わるため、移行後の反復判定も確認する。 +Python の呼び出し方法に変更はない。 diff --git a/docs/book/src/internals/bitboard/attacks.md b/docs/book/src/internals/bitboard/attacks.md index b136e6a9..2ce4fd05 100644 --- a/docs/book/src/internals/bitboard/attacks.md +++ b/docs/book/src/internals/bitboard/attacks.md @@ -284,7 +284,7 @@ const fn increasing_ray_attacks(ray_bits: u128, occupied_bits: u128) -> u128 { | `bishop_attacks(sq, occupied)` | 正方向 2 本(`ne`, `se`)と負方向 2 本(`sw_reversed`, `nw_reversed`)を `paired_increasing_attacks` + `reverse_bits` で計算 | 角の 4 方向利き計算。 | | `rook_attacks(sq, occupied)` | 正方向 2 本(`e_raw`, `s_raw`)と負方向 2 本(`n_reversed`, `w_reversed`)を `paired_increasing_attacks` + `reverse_bits` で計算 | 飛車の 4 方向一括利き計算。 | | `rook_file_attacks(sq, occupied)` | `(occupied.packed_bits() >> file_start) & 0x1ff` による `FILE_ATTACKS` テーブル参照 | 飛車の縦方向のみの利き抽出(1 手詰めソルバの差分利き計算等)。 | -| `rook_rank_attacks(sq, occupied)` | インデックス増加方向(`beams.e`)は `increasing_ray_attacks`、減少方向(`beams.w`)は最寄りブロッカー `blocker`(MSB)を求めて `beams.w ^ ROOK_RAY_BITS[blocker].w` で影の部分を打ち消す Classical アプローチ | 飛車の横方向のみの利き抽出。 | +| `rook_rank_attacks(sq, occupied)` | `rook_attacks(sq, occupied) & Bitboard::rank_mask(sq.rank())` | 飛車の横方向のみの利き抽出。 | | `horse_attacks(sq, occupied)` | `bishop_attacks(sq, occupied) \| king_attacks(sq)` | 馬の利き計算。 | | `dragon_attacks(sq, occupied)` | `rook_attacks(sq, occupied) \| king_attacks(sq)` | 龍の利き計算。 | diff --git a/docs/book/src/internals/mate/index.md b/docs/book/src/internals/mate/index.md index c31b70e8..2b021370 100644 --- a/docs/book/src/internals/mate/index.md +++ b/docs/book/src/internals/mate/index.md @@ -36,7 +36,7 @@ flowchart TD 1. まず `KingNeighborhood::new` で敵玉周辺のビットボード情報を構築する。 2. `generate_checks_all_move32_drop_first_into` を用いて、**持ち駒を打つ王手から順番に** 1 手ずつストリーム供給する。実戦の 1 手詰めは頭金や腹銀などの「駒打ち王手」で決まる割合が非常に高いため、盤上移動の王手を生成する前に詰みを発見して早期リターンできる確率が劇的に高まる。 3. 各王手候補に対して盤面非破壊の `refute_candidate`(静的反駁)を実行する。もし全局面の王手がすべて静的反駁で棄却されれば、**`Position` のクローンも `apply_move32` も 1 回も実行されることなく** `None` が返る。 -4. 静的反駁を生き残った最初の候補手が現れた時点で初めて `pos.clone_for_search_bounded(16)` を遅延生成(`lazy_work.get_or_insert_with(...)`)し、`work.apply_move32(mv)` と `work.is_mated()` を呼ぶ。 +4. 静的反駁を生き残った最初の候補手が現れた時点で初めて内部 API の `pos.clone_for_search_bounded(16)` で作業局面を遅延生成(`lazy_work.get_or_insert_with(...)`)し、`work.apply_move32(mv)` と `work.is_mated()` を呼ぶ。 ### 経路 2:リスト生成と In-Place 検証(`solve_mate_in_one_in_place`) diff --git a/docs/book/src/internals/position/index.md b/docs/book/src/internals/position/index.md index ded6bfc2..6c527fb9 100644 --- a/docs/book/src/internals/position/index.md +++ b/docs/book/src/internals/position/index.md @@ -39,7 +39,7 @@ Position | :--- | :--- | :--- | | `apply_move(mv)` / `apply_move32(mv)` | `()` | 指し手が合法であることを前提とする探索ループ向け API。内部で `try_apply_move32` を呼び、不正な指し手に対しては `.expect(...)` でパニックする。 | | `try_apply_move(mv)` / `try_apply_move32(mv)` | `Result<(), MoveError>` | 外部入力や棋譜再生など、不正な指し手が混入し得る境界向け API。盤面を変更する前に移動元、移動先、持ち駒、上位駒ビットの整合性を検証し、違反時は `Err(MoveError)` を返す。 | -| `try_apply_move32_with_facts(mv)` | `Result` | 局面更新と同時に、探索の枝刈りや延長判定で必要となる事実情報(捕獲駒、王手フラグ、成りフラグ等)をまとめた `MoveFacts` を返す。 | +| `try_apply_move32_with_facts(mv)` | `Result` | 局面更新と同時に、捕獲駒、王手や成りのフラグ、更新後のキー、巻き戻し用の差分などをまとめた `MoveApplyFacts` を返す。 | | `apply_move32_with_gives_check(mv, gives_check)` | `()` | 呼び出し側(指し手生成や `gives_check` 事前判定)でその手が王手になるか否かがすでに判明している場合に、局面更新時の王手再計算を省略する高速パス。 | --- @@ -58,4 +58,4 @@ Position - **[StateStack と StateInfo の設計](state-management.md)**:64 要素チャンクによる `StateStack` のヒープ再確保回避、`StateHot` と `StateColdPayload` の分離、`TacticalCache` と `CheckSquares` の 9 スロット圧縮、および Null Move の 2 経路を解説する。 - **[Zobrist ハッシュと差分更新](zobrist.md)**:`StateHot` を唯一の真実の源(Single Source of Truth)とする O(1) 巻き戻し、128 ビットハッシュとの乱数列互換性を保つ 4 回抽選 PRNG、および 72 スロット持ち駒テーブルを解説する。 -- **[探索エンジン連携プリミティブ](search-integration.md)**:`MoveFacts`、`StateCacheView`、`key_after_move` と投機的プリフェッチ、千日手や連続王手の千日手判定、および `clone_for_search_bounded` を解説する。 +- **[探索エンジン連携プリミティブ](search-integration.md)**:`MoveApplyFacts`、`StateCacheView`、consumer 側のキー予測と prefetch の責務、千日手や連続王手の判定、および `clone_for_search()` による独立した探索用コピーを解説する。 diff --git a/docs/book/src/internals/position/search-integration.md b/docs/book/src/internals/position/search-integration.md index e96dfef4..85f9f80a 100644 --- a/docs/book/src/internals/position/search-integration.md +++ b/docs/book/src/internals/position/search-integration.md @@ -1,79 +1,88 @@ # 探索エンジン連携プリミティブ -`Position` は、単にルールに従って局面を進めるだけでなく、Alpha-Beta 探索や MCTS(モンテカルロ木探索)のホットパスで必要となる情報を最小コストで引き出すための専用プリミティブを備えている。 -本ページでは、`MoveFacts`、`StateCacheView`、`key_after_move` と投機的プリフェッチ、千日手や連続王手判定、および探索用クローン(`clone_for_search_bounded`)の設計を解説する。 +`Position` は、指し手の適用結果と現在の戦術キャッシュを利用側が再利用するための primitive を提供する。 +本ページでは、`MoveApplyFacts`、`StateCacheView`、探索側のキー予測の責務、千日手判定、独立した探索用コピーを解説する。 ---- +## 1. 指し手の適用結果と戦術キャッシュ -## 1. `MoveFacts` と `StateCacheView` +`Position::try_apply_move32_with_facts(mv)` は、局面を更新して `Result` を返す。 +`MoveApplyFacts` は、更新中に確定した情報を利用側が盤面から再取得せずに利用するための型である。 -探索ノードで `try_apply_move32_with_facts` を呼び出すと、局面更新の中で自然に判明した属性が `MoveFacts` 構造体として返される。 +| フィールド | 内容 | +|---|---| +| `mv`、`from`、`to` | 適用した `Move32` と移動元、移動先。駒打ちの `from` は `None`。 | +| `moved_piece_before`、`moved_piece_after` | 移動前後の駒。 | +| `captured_piece` | 捕獲した駒。捕獲がない場合は `Piece::NONE`。 | +| `dropped_piece_type`、`promoted`、`moved_king`、`gives_check` | 駒打ち、成り、玉の移動、王手の情報。 | +| `key_after`、`board_key_after`、`partial_keys_after` | 適用後の局面キー、盤面キー、部分キー群。 | +| `delta` | 駒打ち、移動、成り、捕獲を表す `MoveDelta32` 型の盤面差分。 | -```rust -pub struct MoveFacts { - pub move32: Move32, - pub moved_piece_after: Piece, - pub captured_piece: Option, - pub gives_check: bool, - pub is_drop: bool, - pub is_promotion: bool, -} -``` +捕獲前または駒打ち前の持ち駒枚数と、移動側と次の手番も保持する。 +利用側は探索スタックや評価器の更新に必要な値を選んで利用できる。 +全フィールドと契約は Rust API の `MoveApplyFacts` を参照する。 -探索エンジンは、Late Move Reductions(LMR)や Futility Pruning、Check Extension の条件判定において「今の指し手が駒取りだったか」「成りだったか」「王手になったか」を毎回チェックする。 -`MoveFacts` を受け取ることで、局面更新後に改めて `Position` の状態や指し手ビットをデコードし直す重複計算を省ける。 +現在の戦術キャッシュは `Position::current_state_cache()` が返す `StateCacheView` から取得する。 +この view は `checkers()`、`check_squares()`、`check_square(piece_type)`、`blockers_for_king(color)`、`pinners(color)` を提供する。 +ハッシュ値、直前手、捕獲駒、連続王手カウントはこの view に含まれず、必要に応じて `Position` の個別 accessor を使う。 +view は局面への共有借用を保持するため、局面を更新する前に借用を終え、更新後は取得し直す。 -また、`Position::state_cache_view()` が返す `StateCacheView` は、現在の `StateHot` に格納されているハッシュ値群(`key`、`board_key`、`pawn_key`、`non_pawn_material`)、戦術ビットボード(`checkers`、`blockers_for_king`、`pinners`)、直前手(`last_move`)、捕獲駒(`captured_piece`)、連続王手カウント(`continuous_check`)、および `plies_from_null` を 1 つの読み取り専用ビューとして提供する。 +## 2. 探索前のキー予測と prefetch の責務 ---- +候補手を適用する前のキー予測、TT の事前照会、prefetch は利用側が実装する。 +探索用局面の盤面表現や、次に照会するテーブルの管理は、その利用側が所有する。 +本体は `Zobrist::psq`、`Zobrist::side`、`Zobrist::hand_delta` などの基本操作を提供する。 -## 2. `key_after_move` と置換表の投機的プリフェッチ(`prefetch`) +1.x では既存のキー予測 API を非推奨の互換実装として維持する。 +2.0.0 の削除対象と移行方法は [移行ガイド](../../getting-started/migration-1.6.md) を参照する。 -大規模な置換表(数 GB〜数十 GB)は CPU の L3 キャッシュに収まらず、メインメモリ(DRAM)へのアクセスには約 100〜200 クロックサイクルのレイテンシがかかる。 -`Position::key_after_move(mv)`(または `key_after_move32(mv)`)は、**実際に `apply_move32` で盤面や `BitboardSet` を更新することなく、その指し手を指した後の Zobrist ハッシュ値(`key`)だけを数命令の XOR で先行算出する**。 +通常の `Position` で着手後のキーが必要な場合は、実際に手を適用してから `key()` や `board_key()` を読む。 +`try_apply_move32_with_facts()` が返す `MoveApplyFacts` にも、適用後のキーと盤面差分が含まれる。 +この更新結果は利用側が再利用できるが、着手前の独立した予測 API ではない。 ```rust -let next_key = pos.key_after_move32(mv); -tt.prefetch(next_key); // メモリコントローラへキャッシュラインの先読みを発行 - -// プリフェッチが裏で進行している間に、王手判定や局面更新を実行する -pos.apply_move32(mv); +let facts = pos.try_apply_move32_with_facts(mv)?; +assert_eq!(facts.key_after, pos.key()); +assert_eq!(facts.board_key_after, pos.board_key()); ``` -このように `apply_move32` の重い処理(ビットボードの更新、ピンや王手マスの再計算)を実行する前に `key_after_move` で次局面のキーを求めて `_mm_prefetch` 等を発行することで、メモリアクセスのレイテンシを局面更新の計算時間とオーバーラップさせて隠蔽できる。 - ---- +## 3. 千日手判定 -## 3. 千日手、連続王手、優劣局面の高速判定 +局面更新時には `board_key` と履歴中の手番側の持ち駒を比較し、反復回数、距離、種類をキャッシュする。 +同一局面に戻るには少なくとも 4 手を要するため、4 手前から 2 手ずつ遡る。 +null move より前や保持範囲外の履歴は対象にしない。 +根履歴に一致候補がないことを索引で確認できた場合は、根以前の走査を省く。 -`crates/rsshogi/src/board/position/rules.rs` における反復局面判定(`update_repetition` / `repetition_state`)は、盤面ハッシュ `board_key` と `StateHot` の履歴メタデータを組み合わせて高速化されている。 +盤面キーと持ち駒が一致する場合は、反復回数を更新し、連続王手の継続手数と反復距離から `Lose`、`Win`、`Draw` を判定する。 +盤面キーが一致して持ち駒が異なる場合は、`Hand::is_equal_or_superior` による駒種ごとの比較で `Superior` または `Inferior` を判定する。 -### 遡及範囲の限定と 2 手おきの走査 - -駒打ちや盤上移動では手番が毎回反転するため、同一手番の同一盤面(`board_key` が等しい局面)は必ず **偶数手前(2 手前、4 手前、6 手前...)** にしか出現しない。 -さらに、Null Move を挟んだ過去には千日手や連続王手が成立しないため、探索すべき上限手数は `max_back = state.plies_from_null.min(current_idx)`(および呼び出し側が指定した `max_ply`)に制限される。 - -``` -現局面 (idx) ──[2 手前 (idx - 2)]──[4 手前 (idx - 4)]── ... ──[上限: idx - max_back] -``` +呼び出し側は、規則に基づく判定と探索経路内の反復を区別して API を選ぶ。 -### 判定ロジックの優先順位 +| API | 判定範囲 | +|---|---| +| `is_repetition(3)` | 現局面が 3 回以上繰り返され、同一局面の 4 回目になったか。 | +| `repetition_state()` | 4 回目以降の反復の種類、または持ち駒の優劣。 | +| `repetition_state_with_ply(ply)` | 指定範囲の履歴を走査する。規則上成立済みの 4 回目は `ply` に関わらず返す。 | +| `repetition_state_with_distance(depth)` | 指定範囲だけを走査し、反復の種類と距離を返す。4 回目でも範囲外の一致は返さない。 | +| `cached_repetition_state_with_ply(ply)` | 履歴を再走査せず、現在の state にある反復距離と種類を使う。 | -過去の局面 `prev`(`step = 2, 4, 6, ...`)を遡る際、`prev.board_key == current.board_key` が一致した時点で以下の判定を行う。 +## 4. 独立した探索用コピー -1. **完全同一局面(`prev.key == current.key`、すなわち持ち駒も完全に同一)**: - - `repetition_count` を `prev.repetition_count + 1` に更新する。 - - **連続王手の千日手判定**:現局面までの連続王手手数 `continuous_check[us] * 2 >= step` であれば、手番側が王手をかけ続けて同一局面に達しているため反則負け(`RepetitionState::Lose`)となる。逆に相手側の `continuous_check[them] * 2 >= step` であれば相手側の反則により勝ち(`RepetitionState::Win`)となる。 - - 連続王手でなければ通常の千日手(`RepetitionState::Draw`)となる。なお、公式ルール上の千日手成立(同一局面 4 回目、すなわち `repetition_count >= 3`)は `Position::is_repetition(max_ply)` で判定でき、探索中に 2 回目の出現時点で即座に打ち切る場合は `Position::repetition_state(max_ply)` を用いる。 -2. **同一盤面で持ち駒のみが異なる局面(`prev.key != current.key`)**: - - `Hand` のボロービットを用いた [SWAR 一括優劣比較](../types/pieces.md) により、手番側の持ち駒 `current.hand` と `prev.hand` を比較する。 - - 全駒種で `current.hand >= prev.hand` なら **優等局面(`RepetitionState::Superior`)**、全駒種で `current.hand <= prev.hand` なら **劣等局面(`RepetitionState::Inferior`)** と判定する。 +`Position::clone()` は全履歴と予約容量を複製し、元局面と独立した局面を作る。 +同じ保存先へ繰り返し複製する場合は、`saved.clone_from(&pos)` で保存先の履歴領域を再利用できる。 +`clone_from()` も全履歴を複製し、複製元の予約容量を維持する。 +保存先に余る領域は保持するため、履歴が短い局面を複製した後も再利用できる。 ---- +`clone_for_search()` は null move と保持上限に応じた履歴を詰め直し、不要な予約領域を引き継がない。 +内部の履歴ストレージを理解したコピー操作として、両 API を提供する。 -## 4. 探索用軽量クローン:`clone_for_search_bounded` +`clone_for_search()` は根履歴の board key 索引を作る。 +一致候補がない場合だけ根以前の走査を省き、一致した場合は持ち駒と連続王手を含む詳細判定を行う。 +索引だけで千日手を判定することはない。 +索引構築には確保を伴い、一致候補が多い履歴では照会の費用が加わる。 +コピーの準備費用と、その後の走査の削減効果を分けて評価する。 -並列探索(Lazy SMP や YBWC)でスレッドごとに `Position` を複製したり、探索ノードの途中で 1 手詰めソルバ等のサブ探索用に作業用 `Position` を作ったりする場合、通常の `Position::clone()` を呼ぶと対局開始からの全履歴チャンクがコピーされてしまう。 +履歴上限を変更する `set_max_repetition_ply()` は 1.6.0 から非推奨で、2.0.0 で削除予定である。 +既存の動作と既定値 `Ply::MAX` は維持する。 +上限を短くする近似方針は利用側で管理する。 -`Position::clone_for_search_bounded(max_back_plies)` は、巻き戻しや千日手判定に必要な直近の履歴上限 `keep_back = max_back_plies.max(plies_from_null)` を算出し、`StateStack::clone_suffix(keep_back + 1)` によって末尾の必要な `StateInfo` だけを持つ軽量な `Position` を生成する。 -例えば 1 手詰めソルバ(`mate.rs`)では `pos.clone_for_search_bounded(16)` を用いており、対局が 200 手進んでいてもわずか 1 チャンク以内のコピーで作業局面を構築できる。 +軽量な探索専用局面と評価器に固有の状態は、必要な性能と探索方式に合わせて利用側で実装する。 diff --git a/docs/book/src/internals/position/state-management.md b/docs/book/src/internals/position/state-management.md index 186de165..dfb1bd76 100644 --- a/docs/book/src/internals/position/state-management.md +++ b/docs/book/src/internals/position/state-management.md @@ -8,24 +8,40 @@ ## 1. `StateStack` の 64 要素チャンク分割ストレージ 単純な `Vec` で履歴スタックを実装すると、探索深さや長手数の棋譜で `Vec` の容量(capacity)を超えた瞬間にヒープの再確保と全要素のメモリコピーが発生し、さらに既存要素への参照アドレスが無効化される。 -`rsshogi` の `StateStack` は、固定長 64 要素の配列チャンク `Box<[StateInfo; 64]>`(`STATE_CHUNK_CAPACITY = 64`)を束ねたセグメント構造を採用している。 +`StateStack` は、固定長 64 要素の配列を `Box` に格納するセグメント構造を採用している。 +履歴は添字で参照し、着手で head を進め、undo で戻す。 ```rust -pub(crate) const STATE_CHUNK_CAPACITY: usize = 64; -const STATE_CHUNK_SHIFT: usize = 6; -const STATE_CHUNK_MASK: usize = STATE_CHUNK_CAPACITY - 1; - -pub(crate) struct StateStack { - chunks: Vec>, - len: usize, +pub struct StateStack { + repetition_index: Option>, + chunks: Vec>, + head: StateIndex, } ``` ### セグメント構造の利点 -1. **インデックス計算がビットシフトとマスクのみで完結する**:チャンクサイズが 2 のべき乗(`64 = 2^6`)であるため、深さ `index` の要素は `chunks[index >> 6][index & 63]` で除算なしに直接参照できる。 -2. **アドレス安定性とヒープ再確保の排除**:新しいチャンクが必要になった場合でも既存の `Box<[StateInfo; 64]>` は一切移動しない。また、`pop()` 時は `self.len -= 1` とするだけで確保済みのチャンクを解放せずに保持するため、探索木の昇降でヒープアロケーションは一度も発生しない。 -3. **履歴末尾の高速部分複製(`clone_suffix`)**:並列探索のワーカー起動や 1 手詰めソルバの作業局面生成で使われる `Position::clone_for_search_bounded(max_back_plies)` は、`StateStack::clone_suffix` を呼び出す。何百手もの全対局履歴をコピーするのではなく、直近の必要な手数分(および `plies_from_null` の範囲)だけを新しい `StateStack` へ切り出してコピーし、巻き戻し不可能な過去のチャンク複製コストを省いている。 +1. **添字による直接参照**:深さ `index` の要素をチャンクとチャンク内の位置に分けて参照する。 + 千日手判定も同じ添字を使い、同一手番の履歴を直接読む。 +2. **アドレス安定性と容量の再利用**:新しいチャンクが増えても既存の配列は移動しない。 + undo 後も確保済みチャンクを保持し、事前確保した範囲では追加確保を行わない。 +3. **必要な履歴の独立した複製**:`clone_for_search()` は null move 以降の保持対象を複製する。 + 通常の `clone()` は全履歴と予約容量を維持する。 + +通常の `Position::clone()` は boxed chunk を直接複製し、履歴を私有する独立した局面を作る。 +`clone_for_search()` が構築する board key の索引は clone 間で共有するが、履歴本体は共有しない。 +索引に一致候補がない場合だけ根以前の走査を省き、一致した場合は従来の詳細判定を行う。 + +`Position::clone_from(&source)` は複製先の boxed chunk を再利用し、有効な履歴だけを上書きする。 +複製元の予約容量を収容できるだけの chunk があれば、新たに確保しない。 +不足する chunk は確保し、複製先に余る chunk は以後の再利用のために保持する。 +盤面、全履歴、入玉設定、反復索引は複製元に合わせて置き換える。 +複製先に以前の view や pointer を保持している場合は、状態の意味が変わるため再取得を要する。 + +`set_max_repetition_ply()` は 1.6.0 から非推奨で、2.0.0 で削除予定である。 +既定値は `Ply::MAX` であり、長い履歴を自動的に短縮しない。 +明示的な上限を下げると長周期の千日手や連続王手を見逃し得るため、その近似方針は consumer 側で管理する。 +`clone_for_search()` 自体は引き続き提供する。 --- @@ -36,8 +52,8 @@ pub(crate) struct StateStack { ```rust #[derive(Clone, Copy, Debug)] pub(crate) struct StateInfo { - pub(crate) hot: StateHot, pub(crate) cold: StateColdPayload, + pub(crate) hot: StateHot, } ``` @@ -101,12 +117,15 @@ pub(crate) struct StateInfo { ## 4. Null Move(パス手)の 2 経路設計 手番だけを相手に渡す Null Move(パス手)には、用途に応じて **`ply`(対局手数)を進める経路** と **`ply` を進めない探索専用経路** の 2 種類が実装されている。 -内部的には共通のジェネリック実装 `apply_null_move_internal` / `undo_null_move_internal` によって分岐なしでコンパイルされる。 +内部的には共通のジェネリック実装 `apply_null_move_impl` と `undo_null_move_impl` で処理する。 | API ペア | 定数パラメータ | 動作の違いと主な用途 | | :--- | :--- | :--- | -| `apply_null_move` / `undo_null_move` | `ADVANCE_GAME_PLY = true` | `self.ply` を `+1` / `-1` する。王手がかかっている局面での呼び出しはアサーション違反となる。外部からの手番反転テストや汎用パス処理で使用する。 | -| `try_apply_search_null_move` / `undo_search_null_move` | `ADVANCE_GAME_PLY = false` | **`self.ply` を一切変化させない**。王手中は `false` を返して安全に棄却する。探索エンジンの Null Move Pruning において、実対局の手数(`ply`)を汚さずに手番だけを切り替えるために使用する。 | +| `apply_null_move` / `undo_null_move` | `true` | 対局手数を `+1` / `-1` する。王手がかかっていない局面で適用する。戻り値は `Result<(), MoveError>`。 | +| `try_apply_search_null_move` / `undo_search_null_move` | `false` | 対局手数を変えない。適用には予約済み slot と王手がかかっていない局面が必要。戻り値は `Result<(), MoveError>` で、容量不足は `StateCapacityExceeded`。1.6.0 から非推奨で、2.0.0 で削除予定。 | + +事前確保した容量内の無確保動作は、通常の `apply_move32` と undo でも検査している。 +容量確保 API は維持し、探索専用の着手経路とは廃止対象を分ける。 いずれの経路でも、新しい `StateInfo` が `StateStack` にプッシュされ、`key` と `board_key` には手番ハッシュ `Zobrist::side()` が XOR され、`plies_from_null` と `continuous_check` は `0` にリセットされる。 これにより、Null Move を挟んだ前後の局面で誤って連続王手や不正な千日手遡及が発生しないことが保証される。 diff --git a/docs/book/src/introduction.md b/docs/book/src/introduction.md index 779d5bc9..7d10d830 100644 --- a/docs/book/src/introduction.md +++ b/docs/book/src/introduction.md @@ -1,9 +1,5 @@ # rsshogi -この文書は rsshogi 1.5.1 を対象とする。 -Native Book は独立圧縮ブロックを持つ wire version 4 を使用する。 -旧定跡 API の削除を含むため、更新時は移行手順を確認する。 - `rsshogi` は、将棋局面の表現、合法手生成、1 手詰め判定、棋譜や定跡の入出力、深層学習向け教師データの操作を提供する MIT ライセンスの Rust ライブラリおよび Python パッケージである。 探索エンジンや学習パイプラインの基盤として再利用できるよう、ヒープ確保を排した高速な局面操作プリミティブと、外部データ形式との厳密なバイナリ互換性を兼ね備える。 @@ -40,7 +36,7 @@ Rust では `Cargo.toml` に `rsshogi` を追加する。 ```toml [dependencies] -rsshogi = { version = "1.5.1", features = ["records", "book"] } +rsshogi = { version = "1.6.0", features = ["records", "book"] } ``` ```rust diff --git a/docs/book/src/reference/formats/book-architecture.md b/docs/book/src/reference/formats/book-architecture.md index 7b418639..dd45a221 100644 --- a/docs/book/src/reference/formats/book-architecture.md +++ b/docs/book/src/reference/formats/book-architecture.md @@ -1,7 +1,5 @@ # 定跡アーキテクチャ -本章は rsshogi 1.5.1 の Native Book v4 を説明する。 - 定跡は Native Book v4、外部形式の reader、変換用の所有 IR に分ける。 評価値による採択や抽選は利用側のエンジンが行う。 @@ -17,7 +15,7 @@ 局面は盤面、持ち駒、手番を含む 32B の正規形 `PackedSfen` で識別する。手数と履歴は含まず、復元時の手数は 1。キーは byte 辞書順で比較し、Zobrist の `hash-128` 設定には依存しない。 -ライブラリの版は 1.5.1、ファイル形式は version 4(magic `RSSHGBK4`)である。 +ファイル形式は version 4(magic `RSSHGBK4`)である。 ライブラリの版番号とファイル形式の版番号は独立している。 本体は 128B ヘッダー、56B 単位のブロック索引、圧縮ブロック、コメント索引、本文、文書メタデータを持つ。 diff --git a/docs/book/src/reference/formats/book.md b/docs/book/src/reference/formats/book.md index 6e9e17cf..adb5fe99 100644 --- a/docs/book/src/reference/formats/book.md +++ b/docs/book/src/reference/formats/book.md @@ -1,6 +1,6 @@ # Native Book v4 バイナリ形式 -本章は rsshogi 1.5.1 が読み書きする圧縮配置(wire version 4)を説明する。 +本章は Native Book の圧縮配置(wire version 4)を説明する。 Native Book v4 は、局面と候補手をブロック単位で部分読込し、編集差分を単一の `.book` ファイルへ追記する形式である。 Rust の `book` feature と Python の `rsshogi.book` から利用できる。