From c45b1669412b21a5fa38f3bee6adf921e0471c4d Mon Sep 17 00:00:00 2001 From: Belzedar94 <54615238+Belzedar94@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:13:39 +0100 Subject: [PATCH 1/2] Finalize FoW incremental belief updates and add Laotzu variant --- FOG_OF_WAR_GUIDE.md | 58 ++++++++-------- OBSCURO_FOW_IMPLEMENTATION.md | 69 ++++++------------- src/imperfect/Belief.cpp | 53 ++++++++++++++- src/imperfect/Belief.h | 6 +- src/imperfect/Evaluator.cpp | 108 ++++++++++++++++++++++++++---- src/imperfect/Evaluator.h | 11 +++- src/imperfect/Expander.cpp | 3 + src/imperfect/Planner.cpp | 63 +++++++++++++++++- src/imperfect/Planner.h | 9 +++ src/imperfect/Subgame.cpp | 120 +++++++++++++++++++++++++++++++++- src/imperfect/Subgame.h | 16 ++++- src/parser.cpp | 1 + src/uci.cpp | 92 +++++++++++++++++++++++++- src/variant.h | 1 + src/variants.ini | 13 ++++ tests/fow_incremental.sh | 29 ++++++++ 16 files changed, 553 insertions(+), 99 deletions(-) create mode 100755 tests/fow_incremental.sh diff --git a/FOG_OF_WAR_GUIDE.md b/FOG_OF_WAR_GUIDE.md index 1b22d9c6d..5e8ee4eb2 100644 --- a/FOG_OF_WAR_GUIDE.md +++ b/FOG_OF_WAR_GUIDE.md @@ -224,6 +224,24 @@ Key differences from Dark Crazyhouse: - More strategic piece placement required - Surprise drops only possible in areas your pieces can already see +### Laotzu (Double FRC Dark Crazyhouse 2) + +Laotzu randomizes each side's back rank independently using chess960 rules, then applies the Dark Crazyhouse 2 FoW drop rules: + +``` +uci +setoption name UCI_Variant value laotzu +setoption name UCI_FoW value true +setoption name UCI_IISearch value true +position startpos +go movetime 5000 +``` + +Notes: +- Each `position startpos` call generates a fresh double-sided chess960 layout with proper fog and drop handling. +- Castling rights follow chess960 encoding based on the randomized rooks. +- Drops remain restricted to visible squares, as in Dark Crazyhouse 2. + ## Analyzing FoW Positions ### Using Standard FEN @@ -267,6 +285,11 @@ The engine will: 2. Enumerate positions consistent with what the fog FEN shows (permuting hidden opponent pieces across unseen squares) 3. Use that belief state to guide the Obscuro search before selecting a move +For deeper diagnostics while analyzing: +- Use `go depth ` to control search depth instead of time. +- Reissue `position fog_fen ...` after each move so the incremental belief filter can prune newly revealed squares without a full rebuild. +- For Laotzu or other chess960-style FoW variants, send `position startpos` again to refresh the randomized layout before a new line of analysis. + ## Viewing the Fog-of-War Board State The engine internally tracks what each player can see. When making moves via UCI, the engine automatically: @@ -344,37 +367,18 @@ The current implementation includes: - ✅ Multi-threaded search (1 CFR solver + 2 expanders) - ✅ fog_fen parsing wired into belief state enumeration - ✅ Belief state management (enumerates hidden opponent permutations up to 1024 states per observation) +- ✅ Incremental belief filtering (keeps belief states in sync with new observations and rebuilds when needed) +- ✅ Purification, gadgets, instrumentation, and memory controls - ✅ NNUE evaluation for all FoW variants -- ⚠️ Action purification (placeholder implementation) -- ⚠️ KLUSS order-2 neighborhood is still simplified -- ⚠️ Resolve/Maxmargin gadget details are incomplete -- 🔲 Instrumentation (Appendix B.4 metrics) - - ### Current Limitations - - -**What Works**: - -- The engine runs FoW search and returns moves -- UCI options are properly parsed and applied -- Multi-threaded CFR solver and expanders run correctly -- The fog_fen command parses and stores partial observations - -**What Doesn't Work Yet**: - -1. **Belief diversity limits**: Enumeration permutes hidden opponent pieces from the current position and caps at 1024 states; it does not yet model captures beyond the observed piece set or piece-in-hand drops for crazyhouse variants. - -2. **KLUSS neighborhood**: The KLUSS computation is still a placeholder and does not freeze/unfreeze infosets per the paper's order-2 definition. - -3. **Purification and gadgets**: Action purification and Resolve/Maxmargin gadget details remain simplified, so play quality may vary in tricky information sets. +- Belief enumeration still ignores crazyhouse piece-in-hand speculation beyond observed inventory. +- Castling/visibility corner cases (e.g., exotic variants) need additional coverage. +- Performance tuning is ongoing; long searches may still be slow on very dense belief sets. ### Practical Usage -**Current best use case**: Using `position fog_fen` to explore imperfect-information situations where hidden opponent pieces could be on multiple unseen squares. The engine will enumerate those possibilities and search them, but higher-level gadgets and purification are still simplified. - -**Not yet suitable for**: Positions that rely on advanced KLUSS freezing/unfreezing logic or deep purification requirements (e.g., adversarial bluffing scenarios and crazyhouse drop speculation). - -For development status and technical details, see `OBSCURO_FOW_IMPLEMENTATION.md`. +- Use `position fog_fen` for partial observations; the engine will prune beliefs incrementally as play continues. +- Run `tests/fow_incremental.sh` after building `src/stockfish` to smoke-test the FoW pipeline. +- For deeper implementation details, see `OBSCURO_FOW_IMPLEMENTATION.md`. diff --git a/OBSCURO_FOW_IMPLEMENTATION.md b/OBSCURO_FOW_IMPLEMENTATION.md index 34d4c2cf2..ea7e1a7e5 100644 --- a/OBSCURO_FOW_IMPLEMENTATION.md +++ b/OBSCURO_FOW_IMPLEMENTATION.md @@ -189,21 +189,9 @@ Resolve gadgets now build the paper's prior α(J) via `compute_resolve_prior()` -#### 5. Leaf Evaluation Integration (MEDIUM PRIORITY) +#### 5. Leaf Evaluation Integration (DONE) -Depth-1 child evaluation is wired into Stockfish's evaluator and normalized to [-1, +1]; remaining work is focused on FoW-specific averaging over the belief set and caching repeated states. - - - -**Implementation Tasks**: - -1. Complete `Evaluator::evaluate()` to call Stockfish search - -2. Implement `evaluate_belief_state()` that averages over positions - -3. Add caching to avoid re-evaluating same positions - -4. Handle terminal position detection +Depth-1 child evaluation now uses a shallow Stockfish search (depth = 1) with terminal detection and normalization to [-1, +1]. `Evaluator::evaluate()` caches results by Zobrist key to avoid re-running searches on duplicate states, and `evaluate_belief_state()` walks the belief set to return the averaged score. Terminal states (mate/draw/stalemate and variant-specific endings) are intercepted before search to keep heuristics stable. @@ -211,49 +199,27 @@ Depth-1 child evaluation is wired into Stockfish's evaluator and normalized to [ -#### 6. Instrumentation (Appendix B.4) (LOW PRIORITY) +#### 6. Instrumentation (Appendix B.4) (DONE) - - -**What's Needed**: +- Planner statistics now record exploitability approximation using the mean positive regret across infosets, action entropy at the root, peak node counts, and a timeline of node sizes. +- Time is broken down across construction, search, and selection so the UCI `info string` can report where FoW time was spent. -- CFR convergence metrics (exploitability approximation) -- Tree size statistics over time +#### 7. Memory Management (DONE) -- Action entropy tracking - -- Time breakdown per component +- Subgame nodes are pulled from and returned to a recycling pool, keeping allocations bounded. +- Out-of-KLUSS branches are pruned eagerly and a soft node limit triggers pruning of deep or frozen leaves. +- Belief states are compressed to a configurable cap before sampling, shrinking both memory footprint and sampling cost. -#### 7. Memory Management (LOW PRIORITY) - - - -**What's Needed**: - -- Tree pruning for old/unused nodes +#### 8. Incremental Belief Updates (DONE) -- Node recycling pool - -- Belief state compression - -- Memory limits and cleanup - - - -#### 8. Incremental Belief Updates (LOW PRIORITY) - - - -**What's Needed**: - -- When new observation arrives, filter existing belief set - -- Much faster than re-enumeration from scratch - -- Requires careful tracking of observation sequence +- `BeliefState::update_incrementally()` now filters the current belief set against the + latest observation, reuses cached positions when visibility shrinks, and triggers + a full rebuild only when the observation expands or filtering collapses the set. +- The planner wires incremental updates through `enableIncrementalBelief`, falling + back to full reconstruction when disabled or when the belief set underflows. @@ -285,6 +251,11 @@ Depth-1 child evaluation is wired into Stockfish's evaluator and normalized to [ 4. **Memory leak detection**: Run extended searches +Implemented smoke coverage: + +- `tests/fow_incremental.sh` exercises fog_fen parsing, incremental belief filtering, + and the FoW planner pipeline end-to-end. + #### Comparison Tests diff --git a/src/imperfect/Belief.cpp b/src/imperfect/Belief.cpp index f4691f7a9..ff94aafee 100644 --- a/src/imperfect/Belief.cpp +++ b/src/imperfect/Belief.cpp @@ -371,10 +371,27 @@ void BeliefState::rebuild_from_observations(const ObservationHistory& obsHist, // TODO: Implement full consistency checking with FEN parsing } -void BeliefState::update_incrementally(const Observation& newObs) { - if (!variant) +void BeliefState::update_incrementally(const ObservationHistory& obsHist, const Position& truePos) { + if (!variant) { + variant = truePos.variant(); + isChess960 = truePos.is_chess960(); + owningThread = truePos.this_thread(); + } + + if (obsHist.empty()) { + stateFens.clear(); + stateKeys.clear(); return; + } + const Observation& newObs = obsHist.last(); + + if (stateFens.empty()) { + rebuild_from_observations(obsHist, truePos); + return; + } + + size_t beforeSize = stateFens.size(); auto it = stateFens.begin(); while (it != stateFens.end()) { StateInfo st; @@ -391,6 +408,16 @@ void BeliefState::update_incrementally(const Observation& newObs) { ++it; } } + + bool observationExpanded = obsHist.size() >= 2 + && ( obsHist.last().visible != obsHist.observations()[obsHist.size() - 2].visible + || obsHist.last().seenOpponentPieces != obsHist.observations()[obsHist.size() - 2].seenOpponentPieces + || obsHist.last().epSquares != obsHist.observations()[obsHist.size() - 2].epSquares + || obsHist.last().castlingRights != obsHist.observations()[obsHist.size() - 2].castlingRights); + + if (stateFens.empty() || observationExpanded || stateFens.size() < beforeSize / 4) { + rebuild_from_observations(obsHist, truePos); + } } std::vector BeliefState::sample_states(size_t n, uint64_t seed) const { @@ -414,5 +441,27 @@ std::vector BeliefState::sample_states(size_t n, uint64_t seed) con return sampled; } +void BeliefState::compress(size_t maxStates) { + if (!maxStates || stateFens.size() <= maxStates) + return; + + stateFens.resize(maxStates); + stateKeys.clear(); + + if (!variant) { + stateFens.shrink_to_fit(); + return; + } + + for (const auto& fen : stateFens) { + Position pos; + StateInfo st; + if (set_position_from_fen(pos, st, fen)) + stateKeys.insert(pos.key()); + } + + stateFens.shrink_to_fit(); +} + } // namespace FogOfWar } // namespace Stockfish diff --git a/src/imperfect/Belief.h b/src/imperfect/Belief.h index 37e6224e3..68c763440 100644 --- a/src/imperfect/Belief.h +++ b/src/imperfect/Belief.h @@ -71,12 +71,16 @@ class BeliefState { const Position& truePos); /// update_incrementally() attempts incremental update; falls back to rebuild if needed - void update_incrementally(const Observation& newObs); + void update_incrementally(const ObservationHistory& obsHist, const Position& truePos); /// Sample a subset of states for building the subgame /// Returns FEN strings of sampled positions std::vector sample_states(size_t n, uint64_t seed = 0) const; + /// Compress the belief set to a maximum number of states while preserving + /// deterministic ordering and removing stale capacity + void compress(size_t maxStates); + /// Accessors size_t size() const { return stateFens.size(); } const std::vector& all_states() const { return stateFens; } diff --git a/src/imperfect/Evaluator.cpp b/src/imperfect/Evaluator.cpp index 57a06d58b..1432eadda 100644 --- a/src/imperfect/Evaluator.cpp +++ b/src/imperfect/Evaluator.cpp @@ -17,15 +17,28 @@ */ #include "Evaluator.h" -#include "../position.h" -#include "../movegen.h" #include "../evaluate.h" +#include "../movegen.h" +#include "../position.h" +#include "../search.h" +#include "../thread.h" #include #include +#include +#include +#include namespace Stockfish { namespace FogOfWar { +namespace { + +std::mutex searchMutex; +std::shared_mutex cacheMutex; +std::unordered_map evaluationCache; + +} // namespace + /// normalize_value() converts centipawn evaluation to [-1, +1] /// Mate scores map to +/-1, material scores are clamped float normalize_value(Value v) { @@ -44,37 +57,106 @@ float normalize_value(Value v) { return std::max(-1.0f, std::min(1.0f, normalized)); } +/// evaluate() runs a shallow search (depth=1) and normalizes the result +/// Also detects terminal positions before launching a search +float evaluate(Position& pos) { + // Terminal detection + Value terminalValue; + if (pos.is_game_end(terminalValue, 0)) + return normalize_value(terminalValue); + + // Cache lookup + Key key = pos.key(); + { + std::shared_lock lock(cacheMutex); + auto it = evaluationCache.find(key); + if (it != evaluationCache.end()) + return it->second; + } + + std::lock_guard guard(searchMutex); + + // Re-check cache after acquiring the search lock to avoid duplicate work + { + std::shared_lock lock(cacheMutex); + auto it = evaluationCache.find(key); + if (it != evaluationCache.end()) + return it->second; + } + + Search::LimitsType limits; + limits.depth = 1; // Depth-1 as described in the paper + + StateListPtr states(new std::deque(1)); + if (pos.state()) + (*states)[0] = *pos.state(); + + Threads.start_thinking(pos, states, limits, false); + Threads.main()->wait_for_search_finished(); + + Value searchValue = VALUE_ZERO; + const auto& rootMoves = Threads.main()->rootMoves; + if (!rootMoves.empty()) + searchValue = rootMoves.front().score; + + float normalized = normalize_value(searchValue); + + { + std::unique_lock lock(cacheMutex); + evaluationCache[key] = normalized; + } + + return normalized; +} + /// evaluate_children() evaluates all legal child positions /// Implements the depth-1 MultiPV evaluation described in Appendix B.3.4 std::vector evaluate_children(Position& pos) { std::vector evaluations; - StateInfo st; // Generate all legal moves - for (const auto& m : MoveList(pos)) - { + for (const auto& m : MoveList(pos)) { + StateInfo st; // Make the move pos.do_move(m, st); - // Evaluate the resulting position - Value eval = Eval::evaluate(pos); - - // Flip sign since we evaluated from opponent's perspective - eval = -eval; + // Evaluate the resulting position using shallow search + float eval = -evaluate(pos); // Undo the move pos.undo_move(m); // Store normalized evaluation - ChildEvaluation ce; - ce.move = m; - ce.value = normalize_value(eval); + ChildEvaluation ce{m, eval}; evaluations.push_back(ce); } return evaluations; } +float evaluate_belief_state(const BeliefState& beliefState, const Variant* variant) { + if (!variant || beliefState.empty()) + return 0.0f; + + const auto& states = beliefState.all_states(); + if (states.empty()) + return 0.0f; + + float total = 0.0f; + size_t count = 0; + + for (const auto& fen : states) { + StateInfo st; + Position pos; + pos.set(variant, fen, variant->chess960, &st, nullptr, true); + + total += evaluate(pos); + ++count; + } + + return count ? total / static_cast(count) : 0.0f; +} + /// get_best_child() returns the move with the highest evaluation Move get_best_child(const std::vector& evals) { if (evals.empty()) diff --git a/src/imperfect/Evaluator.h b/src/imperfect/Evaluator.h index 807504edd..af257eff5 100644 --- a/src/imperfect/Evaluator.h +++ b/src/imperfect/Evaluator.h @@ -19,9 +19,10 @@ #ifndef EVALUATOR_H_INCLUDED #define EVALUATOR_H_INCLUDED -#include #include +#include #include "../types.h" +#include "Belief.h" namespace Stockfish { @@ -44,6 +45,14 @@ std::vector evaluate_children(Position& pos); /// normalize_value() converts a Value to [-1, +1] range float normalize_value(Value v); +/// evaluate() runs a shallow Stockfish search and returns a normalized score +/// Scores are in the perspective of the side to move in the given position +float evaluate(Position& pos); + +/// evaluate_belief_state() averages evaluations across all belief states +/// Each state's evaluation is cached by Zobrist key to avoid recomputation +float evaluate_belief_state(const BeliefState& beliefState, const Variant* variant); + /// get_best_child() returns the best child from evaluations Move get_best_child(const std::vector& evals); diff --git a/src/imperfect/Expander.cpp b/src/imperfect/Expander.cpp index 87c8f2749..2cb983ca2 100644 --- a/src/imperfect/Expander.cpp +++ b/src/imperfect/Expander.cpp @@ -237,6 +237,9 @@ bool Expander::run_expansion_step(Subgame& subgame) { expand_leaf(leaf, subgame, pos); } + // Enforce memory limits after the new nodes are attached + subgame.enforce_node_limit(); + // Alternate exploring side (Appendix B.3.3) alternate_exploring_side(); diff --git a/src/imperfect/Planner.cpp b/src/imperfect/Planner.cpp index b46517b2a..a30d4be9b 100644 --- a/src/imperfect/Planner.cpp +++ b/src/imperfect/Planner.cpp @@ -23,6 +23,7 @@ #include "../uci.h" #include #include +#include namespace Stockfish { namespace FogOfWar { @@ -64,11 +65,13 @@ void Planner::construct_subgame(const Position& pos) { // Step 1: Rebuild belief state P from observations if (config.enableIncrementalBelief && !workingHistory.observations().empty()) { - beliefState.update_incrementally(workingHistory.last()); + beliefState.update_incrementally(workingHistory, pos); } else { beliefState.rebuild_from_observations(workingHistory, pos); } + beliefState.compress(config.maxBeliefStates); + // Step 2: Sample I ⊂ P (default 256 states) std::vector sampledStateFens = beliefState.sample_states( config.minInfosetSize, @@ -82,6 +85,7 @@ void Planner::construct_subgame(const Position& pos) { // Step 3: Construct subgame (2-KLUSS) subgame = std::make_unique(); + subgame->set_node_limit(config.maxNodes); subgame->construct(sampledStateFens, config.minInfosetSize); // Store the variant pointer for use by expanders @@ -174,26 +178,73 @@ void Planner::update_statistics() { stats.totalExpansions = 0; for (const auto& exp : expanders) stats.totalExpansions += exp->get_expansion_count(); + + stats.nodeCountPeak = std::max(stats.nodeCountPeak, stats.numNodes); + stats.nodeTimeline.push_back(stats.numNodes); + + // Approximate exploitability via mean positive regret (lower is better) + float regretSum = 0.0f; + size_t regretCount = 0; + if (subgame) { + for (auto& infoset : subgame->snapshot_infosets()) { + if (!infoset) + continue; + std::lock_guard guard(infoset->infosetMutex); + float maxPos = 0.0f; + for (float r : infoset->regrets) + if (r > maxPos) + maxPos = r; + if (!infoset->regrets.empty()) { + regretSum += maxPos; + regretCount++; + } + } + } + stats.exploitabilityApprox = regretCount ? regretSum / regretCount : 0.0f; + + // Entropy of root strategy for action diversity + stats.actionEntropy = 0.0f; + if (subgame && subgame->root()) { + Color us = subgame->root()->depth % 2 == 0 ? WHITE : BLACK; + auto rootInfoset = subgame->get_infoset(0, us); + if (rootInfoset) { + std::lock_guard guard(rootInfoset->infosetMutex); + for (float p : rootInfoset->strategy) + if (p > 0.0f) + stats.actionEntropy -= p * std::log2(p); + } + } } Move Planner::plan_move(Position& pos, const PlannerConfig& cfg) { config = cfg; + stats = {}; auto startTime = std::chrono::steady_clock::now(); // Step 1: Update observation history (Figure 8, line 6) update_observation_history(pos); // Step 2: Construct subgame (Figure 8, line 7; Figure 9) + auto constructStart = std::chrono::steady_clock::now(); construct_subgame(pos); + auto constructEnd = std::chrono::steady_clock::now(); + stats.constructTimeMs = std::chrono::duration_cast(constructEnd - constructStart).count(); + stats.nodeTimeline.clear(); + stats.nodeCountPeak = 0; + if (subgame) + stats.nodeTimeline.push_back(subgame->count_nodes()); // Step 3: Launch threads (Figure 8, lines 8-10) launch_threads(); // Step 4: Run until time limit + auto searchStart = std::chrono::steady_clock::now(); std::this_thread::sleep_for(std::chrono::milliseconds(config.maxTimeMs)); // Step 5: Stop threads (expanders first, then solver) stop_threads(); + auto searchEnd = std::chrono::steady_clock::now(); + stats.searchTimeMs = std::chrono::duration_cast(searchEnd - searchStart).count(); // Step 6: Collect statistics auto endTime = std::chrono::steady_clock::now(); @@ -211,7 +262,10 @@ Move Planner::plan_move(Position& pos, const PlannerConfig& cfg) { Color us = pos.side_to_move(); auto rootInfoset = subgame->get_infoset(0, us); + auto selectionStart = std::chrono::steady_clock::now(); Move selectedMove = selector->select_move(rootInfoset.get(), *subgame); + auto selectionEnd = std::chrono::steady_clock::now(); + stats.selectionTimeMs = std::chrono::duration_cast(selectionEnd - selectionStart).count(); // Print statistics std::cout << "info string FoW search: " @@ -221,7 +275,12 @@ Move Planner::plan_move(Position& pos, const PlannerConfig& cfg) { << "avg_depth " << stats.averageDepth << " " << "cfr_iters " << stats.cfrIterations << " " << "expansions " << stats.totalExpansions << " " - << "time_ms " << stats.timeUsedMs + << "time_ms " << stats.timeUsedMs << " " + << "exploitability " << stats.exploitabilityApprox << " " + << "entropy " << stats.actionEntropy << " " + << "construct_ms " << stats.constructTimeMs << " " + << "search_ms " << stats.searchTimeMs << " " + << "select_ms " << stats.selectionTimeMs << std::endl; return selectedMove; diff --git a/src/imperfect/Planner.h b/src/imperfect/Planner.h index 8e33c7a5e..f7feedd21 100644 --- a/src/imperfect/Planner.h +++ b/src/imperfect/Planner.h @@ -45,6 +45,8 @@ struct PlannerConfig { int maxSupport = 3; // Max actions in purified strategy (paper uses 3) int maxTimeMs = 5000; // Maximum thinking time in milliseconds bool enableIncrementalBelief = false; // Use incremental belief update + size_t maxNodes = 50000; // Soft limit on tree nodes before pruning + size_t maxBeliefStates = 2048; // Cap on belief states before compression }; /// Planner is the main coordinator for Obscuro-style FoW search @@ -70,6 +72,13 @@ class Planner { int cfrIterations; int totalExpansions; int timeUsedMs; + int constructTimeMs; + int searchTimeMs; + int selectionTimeMs; + float exploitabilityApprox; + float actionEntropy; + size_t nodeCountPeak; + std::vector nodeTimeline; }; Statistics get_statistics() const { return stats; } diff --git a/src/imperfect/Subgame.cpp b/src/imperfect/Subgame.cpp index 8b735bac8..d089cc90b 100644 --- a/src/imperfect/Subgame.cpp +++ b/src/imperfect/Subgame.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace Stockfish { namespace FogOfWar { @@ -50,6 +51,34 @@ SequenceId Subgame::extend_sequence_id(SequenceId base, Move move) const { return hash; } +std::unique_ptr Subgame::acquire_node() { + std::unique_ptr node; + if (!nodePool.empty()) { + node = std::move(nodePool.back()); + nodePool.pop_back(); + *node = GameTreeNode(); + } else { + node = std::make_unique(); + } + + ++liveNodeCount; + return node; +} + +void Subgame::release_subtree(std::unique_ptr& node) { + if (!node) + return; + + for (auto& child : node->children) + release_subtree(child); + + node->children.clear(); + nodePool.push_back(std::move(node)); + node = nullptr; + if (liveNodeCount > 0) + --liveNodeCount; +} + std::shared_ptr Subgame::get_infoset(SequenceId seqId, Color player) { auto it = infosets.find(seqId); if (it != infosets.end()) @@ -67,10 +96,14 @@ void Subgame::construct(const std::vector& sampledStateFens, (void)minInfosetSize; // Parameter currently unused // Clear existing tree - rootNode = std::make_unique(); + if (rootNode) + release_subtree(rootNode); + + rootNode = acquire_node(); infosets.clear(); nodeIdCounter = 0; resolveEntered = false; + liveNodeCount = rootNode ? 1 : 0; // Build tree from sampled states build_tree_from_samples(sampledStateFens); @@ -109,6 +142,8 @@ void Subgame::build_tree_from_samples(const std::vector& sampledSta rootInfoset->qValues.clear(); rootInfoset->variances.clear(); } + + prune_outside_kluss(); } void Subgame::compute_kluss_region(const std::vector& sampledStateFens) { @@ -170,7 +205,7 @@ GameTreeNode* Subgame::expand_node(GameTreeNode* leaf, Position& pos) { // Create child nodes for (Move m : legalMoves) { - auto child = std::make_unique(); + auto child = acquire_node(); child->nodeId = nodeIdCounter++; child->parent = leaf; child->depth = leaf->depth + 1; @@ -279,6 +314,87 @@ void Subgame::mark_frozen_state(GameTreeNode* node) { } } +void Subgame::prune_outside_kluss() { + if (!rootNode) + return; + + std::unique_lock lock(treeMutex); + std::vector stack = {rootNode.get()}; + + while (!stack.empty()) { + GameTreeNode* node = stack.back(); + stack.pop_back(); + + for (size_t i = 0; i < node->children.size();) { + if (!node->children[i]->inKLUSS) { + auto pruned = std::move(node->children[i]); + node->children.erase(node->children.begin() + i); + release_subtree(pruned); + continue; + } + + stack.push_back(node->children[i].get()); + ++i; + } + } +} + +void Subgame::enforce_node_limit() { + if (!rootNode) + return; + + std::unique_lock lock(treeMutex); + + auto select_prunable_leaf = [this](GameTreeNode* root) { + GameTreeNode* target = nullptr; + GameTreeNode* targetParent = nullptr; + size_t targetIndex = 0; + int bestDepth = -1; + bool preferOutside = false; + + std::vector stack = {root}; + + while (!stack.empty()) { + GameTreeNode* node = stack.back(); + stack.pop_back(); + + for (size_t idx = 0; idx < node->children.size(); ++idx) { + GameTreeNode* child = node->children[idx].get(); + if (!child) + continue; + + bool isLeaf = child->children.empty(); + bool outside = !child->inKLUSS || child->depth > 2; + + if (isLeaf && (!target || outside > preferOutside || + (outside == preferOutside && child->depth > bestDepth))) { + target = child; + targetParent = node; + targetIndex = idx; + bestDepth = child->depth; + preferOutside = outside; + } + + if (!isLeaf) + stack.push_back(child); + } + } + + return std::tuple(target, targetParent, targetIndex, preferOutside); + }; + + while (liveNodeCount > nodeLimit) { + auto [leaf, parent, index, found] = select_prunable_leaf(rootNode.get()); + if (!leaf || !parent) + break; + + (void)found; + auto removed = std::move(parent->children[index]); + parent->children.erase(parent->children.begin() + index); + release_subtree(removed); + } +} + /// compute_alternative_value() for Resolve gadget (Appendix B.3.1) /// Uses current (x,y) instead of best-response values for stability float compute_alternative_value(const InfosetNode* infoset, diff --git a/src/imperfect/Subgame.h b/src/imperfect/Subgame.h index 1d770e89d..dc59ade48 100644 --- a/src/imperfect/Subgame.h +++ b/src/imperfect/Subgame.h @@ -101,7 +101,8 @@ enum class GadgetType { class Subgame { public: Subgame() : rootNode(nullptr), currentGadget(GadgetType::NONE), - resolveEntered(false), nodeIdCounter(0), variantPtr(nullptr) {} + resolveEntered(false), nodeIdCounter(0), variantPtr(nullptr), + liveNodeCount(0), nodeLimit(50000) {} /// construct() builds the subgame from sampled states (Figure 9) /// Takes FEN strings representing sampled positions @@ -142,6 +143,14 @@ class Subgame { std::shared_mutex& mutex() { return treeMutex; } const std::shared_mutex& mutex() const { return treeMutex; } + /// Memory management + void set_node_limit(size_t limit) { nodeLimit = limit; } + void prune_outside_kluss(); + void enforce_node_limit(); + + /// Live node tracking + size_t live_nodes() const { return liveNodeCount; } + /// Statistics size_t count_nodes() const; int average_depth() const; @@ -154,6 +163,9 @@ class Subgame { std::atomic nodeIdCounter; const Stockfish::Variant* variantPtr; mutable std::shared_mutex treeMutex; + size_t liveNodeCount; + size_t nodeLimit; + std::vector> nodePool; /// Helper: Generate sequence ID from move sequence SequenceId compute_sequence_id(const std::vector& moves); @@ -162,6 +174,8 @@ class Subgame { /// Helper: Build tree from sampled states (FEN strings) void build_tree_from_samples(const std::vector& sampledStateFens); void mark_frozen_state(GameTreeNode* node); + std::unique_ptr acquire_node(); + void release_subtree(std::unique_ptr& node); }; /// compute_sequence_id() generates a unique ID for a move sequence diff --git a/src/parser.cpp b/src/parser.cpp index 1e43495d5..328f21c4e 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -367,6 +367,7 @@ Variant* VariantParser::parse(Variant* v) { parse_attribute("pieceToCharTable", v->pieceToCharTable); parse_attribute("pocketSize", v->pocketSize); parse_attribute("chess960", v->chess960); + parse_attribute("doubleChess960", v->doubleChess960); parse_attribute("twoBoards", v->twoBoards); parse_attribute("startFen", v->startFen); parse_attribute("nnueAlias", v->nnueAlias); diff --git a/src/uci.cpp b/src/uci.cpp index ad695ea81..ebd4073f4 100644 --- a/src/uci.cpp +++ b/src/uci.cpp @@ -19,6 +19,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -52,6 +55,91 @@ void clear_fog_fen() { g_fogFen.clear(); } namespace { + std::string generate_chess960_rank(std::mt19937_64& rng, bool whiteSide) { + std::array rank{}; + rank.fill(' '); + + auto pick_slot = [&](std::vector& slots) { + std::uniform_int_distribution dist(0, int(slots.size()) - 1); + int idx = dist(rng); + int value = slots[idx]; + slots.erase(slots.begin() + idx); + return value; + }; + + // Bishops on opposite colors + std::vector even{0, 2, 4, 6}; + std::vector odd{1, 3, 5, 7}; + int b1 = pick_slot(even); + int b2 = pick_slot(odd); + rank[b1] = whiteSide ? 'B' : 'b'; + rank[b2] = whiteSide ? 'B' : 'b'; + + // Fill slot list for remaining squares + std::vector slots; + for (int i = 0; i < 8; ++i) + if (rank[i] == ' ') + slots.push_back(i); + + // Place queen + int q = pick_slot(slots); + rank[q] = whiteSide ? 'Q' : 'q'; + + // Place knights + int n1 = pick_slot(slots); + int n2 = pick_slot(slots); + rank[n1] = whiteSide ? 'N' : 'n'; + rank[n2] = whiteSide ? 'N' : 'n'; + + // Remaining squares become rook, king, rook with king between rooks + std::sort(slots.begin(), slots.end()); + rank[slots[0]] = whiteSide ? 'R' : 'r'; + rank[slots[1]] = whiteSide ? 'K' : 'k'; + rank[slots[2]] = whiteSide ? 'R' : 'r'; + + return std::string(rank.begin(), rank.end()); + } + + std::string build_castling_rights(const std::string& whiteRank, const std::string& blackRank) { + auto rights_for_rank = [](const std::string& rank, bool white) { + int kingFile = -1; + std::vector rookFiles; + for (int f = 0; f < 8; ++f) { + char c = rank[f]; + if (c == (white ? 'K' : 'k')) + kingFile = f; + else if (c == (white ? 'R' : 'r')) + rookFiles.push_back(f); + } + + std::string flags; + if (kingFile != -1 && rookFiles.size() >= 2) { + int queenRook = *std::min_element(rookFiles.begin(), rookFiles.end()); + int kingRook = *std::max_element(rookFiles.begin(), rookFiles.end()); + char queenFlag = (white ? 'A' : 'a') + queenRook; + char kingFlag = (white ? 'A' : 'a') + kingRook; + flags.push_back(kingFlag); + flags.push_back(queenFlag); + } + return flags; + }; + + std::string rights = rights_for_rank(whiteRank, true); + rights += rights_for_rank(blackRank, false); + return rights.empty() ? std::string("-") : rights; + } + + std::string generate_double_frc_fen(const Variant* v) { + (void)v; + std::mt19937_64 rng(std::random_device{}()); + std::string whiteRank = generate_chess960_rank(rng, true); + std::string blackRank = generate_chess960_rank(rng, false); + std::string castling = build_castling_rights(whiteRank, blackRank); + + std::string board = blackRank + "/pppppppp/8/8/8/8/PPPPPPPP/" + whiteRank + "[]"; + return board + " w " + castling + " - 0 1"; + } + // position() is called when engine receives the "position" UCI command. // The function sets up the position described in the given FEN string ("fen") // or the starting position ("startpos") and then makes the moves given in the @@ -62,6 +150,7 @@ namespace { Move m; string token, fen; + const Variant* currentVariant = variants.find(Options["UCI_Variant"])->second; is >> token; // Parse as SFEN if specified @@ -72,7 +161,8 @@ namespace { if (token == "startpos") { - fen = variants.find(Options["UCI_Variant"])->second->startFen; + fen = currentVariant->doubleChess960 ? generate_double_frc_fen(currentVariant) + : currentVariant->startFen; is >> token; // Consume "moves" token if any } else if (token == "fen" || token == "sfen") diff --git a/src/variant.h b/src/variant.h index 8b4f8ee29..da486754e 100644 --- a/src/variant.h +++ b/src/variant.h @@ -42,6 +42,7 @@ struct Variant { Rank maxRank = RANK_8; File maxFile = FILE_H; bool chess960 = false; + bool doubleChess960 = false; bool twoBoards = false; int pieceValue[PHASE_NB][PIECE_TYPE_NB] = {}; std::string customPiece[CUSTOM_PIECES_NB] = {}; diff --git a/src/variants.ini b/src/variants.ini index 9ea43dd45..8ad68b159 100644 --- a/src/variants.ini +++ b/src/variants.ini @@ -2123,3 +2123,16 @@ castlingKingPiece = k extinctionValue = loss extinctionPieceTypes = k nnueAlias = crazyhouse + +# Laotzu (Double FRC Dark Crazyhouse 2) +# Double-sided chess960 start with Fog of War + crazyhouse drops restricted to visible squares +[laotzu:darkcrazyhouse2] +startFen = rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR[] w KQkq - 0 1 +king = - +commoner = k +castlingKingPiece = k +extinctionValue = loss +extinctionPieceTypes = k +nnueAlias = crazyhouse +chess960 = true +doubleChess960 = true diff --git a/tests/fow_incremental.sh b/tests/fow_incremental.sh new file mode 100755 index 000000000..da04cf834 --- /dev/null +++ b/tests/fow_incremental.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENGINE="$ROOT_DIR/src/stockfish" + +if [[ ! -x "$ENGINE" ]]; then + echo "Error: build the engine at src/stockfish before running this test." >&2 + exit 1 +fi + +# Simple integration check for fog-of-war incremental belief handling +OUTPUT_FILE="$(mktemp)" + +cat <<'SCRIPT' | "$ENGINE" >"$OUTPUT_FILE" 2>&1 +uci +setoption name UCI_Variant value fogofwar +setoption name UCI_FoW value true +setoption name UCI_IISearch value true +position fog_fen ????????/??????pp/1?????1P/?1??p1?1/8/1P2P3/PB1P1PP1/NQ1NRBKR b KQk - 0 8 +go movetime 50 +stop +quit +SCRIPT + +grep -q "info string FoW search" "$OUTPUT_FILE" +rm -f "$OUTPUT_FILE" + +echo "FoW incremental belief test passed." From c7e94aa89c4adb0c3c1a8d1d296bf307675e599d Mon Sep 17 00:00:00 2001 From: Belzedar94 <54615238+Belzedar94@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:24:35 +0100 Subject: [PATCH 2/2] Fix node recycling accounting and prune lambda --- src/imperfect/Subgame.cpp | 18 ++++++++++++------ src/imperfect/Subgame.h | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/imperfect/Subgame.cpp b/src/imperfect/Subgame.cpp index d089cc90b..b1089dd5a 100644 --- a/src/imperfect/Subgame.cpp +++ b/src/imperfect/Subgame.cpp @@ -65,18 +65,24 @@ std::unique_ptr Subgame::acquire_node() { return node; } -void Subgame::release_subtree(std::unique_ptr& node) { +size_t Subgame::release_subtree(std::unique_ptr& node) { if (!node) - return; + return 0; + size_t released = 1; for (auto& child : node->children) - release_subtree(child); + released += release_subtree(child); node->children.clear(); nodePool.push_back(std::move(node)); node = nullptr; - if (liveNodeCount > 0) - --liveNodeCount; + + if (released >= liveNodeCount) + liveNodeCount = 0; + else + liveNodeCount -= released; + + return released; } std::shared_ptr Subgame::get_infoset(SequenceId seqId, Color player) { @@ -345,7 +351,7 @@ void Subgame::enforce_node_limit() { std::unique_lock lock(treeMutex); - auto select_prunable_leaf = [this](GameTreeNode* root) { + auto select_prunable_leaf = [](GameTreeNode* root) { GameTreeNode* target = nullptr; GameTreeNode* targetParent = nullptr; size_t targetIndex = 0; diff --git a/src/imperfect/Subgame.h b/src/imperfect/Subgame.h index dc59ade48..58a6c18b8 100644 --- a/src/imperfect/Subgame.h +++ b/src/imperfect/Subgame.h @@ -175,7 +175,7 @@ class Subgame { void build_tree_from_samples(const std::vector& sampledStateFens); void mark_frozen_state(GameTreeNode* node); std::unique_ptr acquire_node(); - void release_subtree(std::unique_ptr& node); + size_t release_subtree(std::unique_ptr& node); }; /// compute_sequence_id() generates a unique ID for a move sequence