From 17c2804c06426a449dbdaf2033a23b636cca0211 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 8 Sep 2026 17:20:17 +0200 Subject: [PATCH 01/12] Add the shape-pattern transposition table and make it the default. TransTableP keys positions by suit-length shape and stores, under each shape, the relative-rank patterns that decided the result (the cards at or above the lowest winning rank per suit, by owner), following the cache design of macroxue's bridge-solver. Patterns are ordered most general first and bucketed by the owner of the first relevant suit's top card, so a lookup scans only the buckets it can match. Blocks are cache-line aligned and pooled per size class; when the memory maximum is reached the table is cleared rather than harvested. Results are identical to TransTableL. Performance is at parity on random deals and markedly better on void-heavy deals and under tight memory limits, where TransTableL's fixed per-shape blocks overflow and lookups degrade to long linear scans. TTKind::Pattern (2) is the new SolverConfig default; DDS_TT_KIND= small|large|pattern overrides it. Specs and C API docs updated. Co-authored-by: Cursor --- docs/c++_interface.md | 2 +- library/src/api/dds_c_api.h | 3 +- library/src/solver_context/solver_context.cpp | 58 +- library/src/solver_context/solver_context.hpp | 9 +- library/src/trans_table/BUILD.bazel | 4 + library/src/trans_table/trans_table_p.cpp | 693 ++++++++++++++ library/src/trans_table/trans_table_p.hpp | 231 +++++ library/tests/dds_c_api_test.cpp | 21 +- .../tests/system/configure_tt_api_test.cpp | 109 ++- library/tests/trans_table/BUILD.bazel | 1 + .../tests/trans_table/trans_table_p_test.cpp | 851 ++++++++++++++++++ specs/solver-context.md | 2 +- specs/transposition-table.md | 35 +- 13 files changed, 1978 insertions(+), 41 deletions(-) create mode 100644 library/src/trans_table/trans_table_p.cpp create mode 100644 library/src/trans_table/trans_table_p.hpp create mode 100644 library/tests/trans_table/trans_table_p_test.cpp diff --git a/docs/c++_interface.md b/docs/c++_interface.md index 21a004dbc..6620fdc9d 100644 --- a/docs/c++_interface.md +++ b/docs/c++_interface.md @@ -36,7 +36,7 @@ Primary entry points: Fields: -- `tt_kind_`: `TTKind::Small` or `TTKind::Large` +- `tt_kind_`: `TTKind::Pattern` (default), `TTKind::Large` or `TTKind::Small` - `tt_mem_default_mb_`: default TT memory in MB - `tt_mem_maximum_mb_`: maximum TT memory in MB diff --git a/library/src/api/dds_c_api.h b/library/src/api/dds_c_api.h index b2b50e805..3c9b16940 100644 --- a/library/src/api/dds_c_api.h +++ b/library/src/api/dds_c_api.h @@ -73,7 +73,8 @@ DLLEXPORT int dds_c_calc_par_pbn(DDS_C_SOLVER_CTX ctx, is decomposed into scalars rather than mirrored as a struct: passing a struct by value is exactly the ABI question this shim exists to avoid, and a mirror type would be a second definition to keep in sync. tt_kind: 0 = Small, - 1 = Large (matching enum class TTKind). Returns NULL on failure. */ + 1 = Large, 2 = Pattern (matching enum class TTKind). Returns NULL on + failure. */ DLLEXPORT DDS_C_SOLVER_CTX dds_c_create_solvercontext(int tt_kind, int def_mb, int max_mb); diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index ad866c035..ca58d6760 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -13,11 +13,51 @@ #include //#include #include +#include #include #include namespace { +/// Optional DDS_TT_KIND=small|large|pattern override of the configured kind. +auto tt_kind_from_environment(TTKind configured) -> TTKind +{ + const char* s = std::getenv("DDS_TT_KIND"); + if (s == nullptr) return configured; + const std::string value(s); + if (value == "small") return TTKind::Small; + if (value == "large") return TTKind::Large; + if (value == "pattern") return TTKind::Pattern; + return configured; +} + +auto tt_kind_of(const TransTable* tt) -> TTKind +{ + if (dynamic_cast(tt) != nullptr) return TTKind::Small; + if (dynamic_cast(tt) != nullptr) return TTKind::Pattern; + return TTKind::Large; +} + +auto tt_kind_letter(TTKind kind) -> char +{ + switch (kind) { + case TTKind::Small: return 'S'; + case TTKind::Pattern: return 'P'; + case TTKind::Large: break; + } + return 'L'; +} + +auto make_trans_table(TTKind kind) -> std::unique_ptr +{ + switch (kind) { + case TTKind::Small: return std::make_unique(); + case TTKind::Pattern: return std::make_unique(); + case TTKind::Large: break; + } + return std::make_unique(); +} + #if defined(DDS_TOP_LEVEL) || defined(DDS_AB_STATS) || defined(DDS_AB_HITS) || \ defined(DDS_TT_STATS) || defined(DDS_TIMING) || defined(DDS_MOVES) std::string next_debug_file_suffix() @@ -68,8 +108,8 @@ auto SolverContext::trans_table() const -> TransTable* auto SolverContext::SearchContext::trans_table() -> TransTable* { if (tt_) return tt_.get(); // Require owner (for config and utilities). If missing, fall back - // to Large with built-in defaults. - TTKind kind = (owner_ ? owner_->config().tt_kind_ : TTKind::Large); + // to the SolverConfig default with built-in memory limits. + TTKind kind = tt_kind_from_environment(owner_ ? owner_->config().tt_kind_ : SolverConfig{}.tt_kind_); int defMB = (owner_ ? owner_->config().tt_mem_default_mb_ : 0); int maxMB = (owner_ ? owner_->config().tt_mem_maximum_mb_ : 0); // Final fallback to THREADMEM_* constants @@ -93,11 +133,7 @@ auto SolverContext::SearchContext::trans_table() -> TransTable* { } if (maxMB < defMB) maxMB = defMB; - // Create appropriate concrete table - if (kind == TTKind::Small) - tt_ = std::unique_ptr(new TransTableS()); - else - tt_ = std::unique_ptr(new TransTableL()); + tt_ = make_trans_table(kind); tt_->set_memory_default(defMB); tt_->set_memory_maximum(maxMB); @@ -105,7 +141,7 @@ auto SolverContext::SearchContext::trans_table() -> TransTable* { #ifdef DDS_UTILITIES_LOG { - const char kch = (kind == TTKind::Small ? 'S' : 'L'); + const char kch = tt_kind_letter(kind); char buf[96]; std::snprintf(buf, sizeof(buf), "tt:create|%c|%d|%d", kch, defMB, maxMB); if (owner_) owner_->utilities().log_append(std::string(buf)); @@ -120,7 +156,7 @@ auto SolverContext::SearchContext::trans_table() -> TransTable* { if (const char* dbg = std::getenv("DDS_DEBUG_TT_CREATE")) { if (*dbg) { std::cerr << "[DDS] TT create: kind=" - << (kind == TTKind::Small ? 'S' : 'L') + << tt_kind_letter(kind) << " defMB=" << defMB << " maxMB=" << maxMB << std::endl; @@ -251,9 +287,7 @@ auto SolverContext::configure_tt(TTKind kind, int defMB, int maxMB) -> void if (!tt) return; // Nothing to apply now; will take effect on lazy creation. // If kind changes, dispose and recreate now to ensure effect is applied. - bool is_small = (dynamic_cast(tt) != nullptr); - TTKind current_kind = is_small ? TTKind::Small : TTKind::Large; - if (current_kind != kind) { + if (tt_kind_of(tt) != kind) { dispose_trans_table(); // Force immediate creation with new config to keep behavior explicit. (void)trans_table(); diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index cb05081d7..cbe3942ae 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -20,7 +20,12 @@ // Minimal configuration scaffold for future expansion. // TT configuration without depending on Memory headers. -enum class TTKind { Small, Large }; +/// Transposition table implementation: +/// - Small: pool-based, low memory (TransTableS) +/// - Large: paged, flat per-shape entry lists (TransTableL) +/// - Pattern: shape → generality-ordered relative-rank patterns (TransTableP) +/// The integer values are part of the C ABI (dds_c_create_solvercontext). +enum class TTKind { Small = 0, Large = 1, Pattern = 2 }; /** * @brief Configuration options for SolverContext instances. @@ -31,7 +36,7 @@ enum class TTKind { Small, Large }; */ struct SolverConfig { - TTKind tt_kind_ = TTKind::Large; + TTKind tt_kind_ = TTKind::Pattern; int tt_mem_default_mb_ = 0; int tt_mem_maximum_mb_ = 0; }; diff --git a/library/src/trans_table/BUILD.bazel b/library/src/trans_table/BUILD.bazel index d9caef65c..96baa314a 100644 --- a/library/src/trans_table/BUILD.bazel +++ b/library/src/trans_table/BUILD.bazel @@ -5,11 +5,13 @@ cc_library( name = "trans_table", srcs = [ "trans_table_l.cpp", + "trans_table_p.cpp", "trans_table_s.cpp", ], hdrs = [ "trans_table.hpp", "trans_table_l.hpp", + "trans_table_p.hpp", "trans_table_s.hpp", ], visibility = ["//visibility:public"], @@ -28,11 +30,13 @@ cc_library( name = "testable_trans_table", srcs = [ "trans_table_l.cpp", + "trans_table_p.cpp", "trans_table_s.cpp", ], hdrs = [ "trans_table.hpp", "trans_table_l.hpp", + "trans_table_p.hpp", "trans_table_s.hpp", ], copts = DDS_CPPOPTS, diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp new file mode 100644 index 000000000..ceea08f6d --- /dev/null +++ b/library/src/trans_table/trans_table_p.cpp @@ -0,0 +1,693 @@ +/* + DDS, a bridge double dummy solver. + + Copyright (C) 2006-2014 by Bo Haglund / + 2014-2018 by Bo Haglund & Soren Hein. + + See LICENSE and README. +*/ + +/* + Shape → pattern transposition table. + + Positions are keyed by (trick, hand, suit-length shape). Under each key the + table holds patterns. A pattern records, for the cards that decided a + search result (all cards at or above the lowest winning rank in each + suit), which hand holds each of them, in *relative* rank order — the same + 2-bits-per-card encoding TransTableL uses, restricted to the top twelve + cards of every suit (the thirteenth is implied by the shape). + + A position matches a pattern when it agrees with it on every relevant + card. Re-adding a pattern that is already stored intersects the bounds. + + The patterns of a shape live in one contiguous array, grouped into buckets + by the owner of the top card of the pattern's first relevant suit, and + within a bucket ordered by generality (fewest relevant cards first, newest + first among equals): general patterns match the most positions, so trying + them first gives the earliest cut-offs. A lookup scans, with a fixed + stride, only the buckets its own top cards allow. + + Experiments with a subsumption tree (storing more specific patterns + beneath more general ones, as bridge-solver does) trimmed the number of + patterns visited per lookup by about 15% but made every visit slower, + since skipping a subtree needs its size, a dependent load that serialises + the scan. The flat array was faster on every workload tried. +*/ + +#include "trans_table_p.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace +{ + +constexpr std::size_t MiB = 1024u * 1024u; +constexpr std::uint64_t HashMultiplier = 0x9E3779B97F4A7C15ull; + +/// Fibonacci hashing: the top bits of the product are well mixed, the low +/// bits are not. table_size must be a power of two. +auto hash_slot(std::uint64_t key, std::size_t table_size) -> std::size_t +{ + const int bits = std::countr_zero(table_size); + return static_cast((key * HashMultiplier) >> (64 - bits)); +} + +} // namespace + + +TransTableP::TransTableP() = default; + + +TransTableP::~TransTableP() +{ + return_all_memory(); +} + + +auto TransTableP::init(const int hand_lookup[][15]) -> void +{ + // For every 13-bit set of remaining cards in a suit, record which hand + // holds each remaining card, top card first, 2 bits per card, and spread + // the result over the three pattern words in the suit's own byte. + ownership_.assign(8192, Ownership{}); + std::vector> ranks(8192); + + unsigned top_bit_rank = 1; + unsigned top_bit_no = 2; + for (unsigned ind = 1; ind < 8192; ++ind) { + if (ind >= (top_bit_rank << 1)) { + top_bit_rank <<= 1; + ++top_bit_no; + } + for (int s = 0; s < DDS_SUITS; ++s) { + ranks[ind][s] = (ranks[ind ^ top_bit_rank][s] >> 2) | + (static_cast(hand_lookup[s][top_bit_no]) << 24); + for (int k = 0; k < PatternWords; ++k) { + const std::uint32_t top_byte = + (ranks[ind][s] << (6 + 8 * k)) & 0xff000000u; + ownership_[ind].set[s][k] = top_byte >> (8 * s); + } + } + } +} + + +auto TransTableP::set_memory_default(const int megabytes) -> void +{ + default_bytes_ = static_cast(std::max(megabytes, 0)) * MiB; +} + + +auto TransTableP::set_memory_maximum(const int megabytes) -> void +{ + maximum_bytes_ = static_cast(std::max(megabytes, 0)) * MiB; +} + + +auto TransTableP::make_tt() -> void +{ + if (default_bytes_ == 0) { + default_bytes_ = static_cast(THREADMEM_LARGE_DEF_MB) * MiB; + } + if (maximum_bytes_ == 0) { + maximum_bytes_ = static_cast(THREADMEM_LARGE_MAX_MB) * MiB; + } + maximum_bytes_ = std::max(maximum_bytes_, default_bytes_); + + return_all_memory(); + shapes_.assign(InitialShapes, ShapeSlot{}); +} + + +auto TransTableP::reset_memory(const ResetReason reason) -> void +{ + if (shapes_.empty()) { + return; + } + ++reset_counts_[static_cast(reason)]; + + release_trees(); + if (reason == ResetReason::MemoryExhausted) { + free_spare_trees(); + } + std::vector fresh(InitialShapes); + shapes_.swap(fresh); +} + + +auto TransTableP::return_all_memory() -> void +{ + release_trees(); + free_spare_trees(); + std::vector().swap(shapes_); +} + + +auto TransTableP::dynamic_bytes() const -> std::size_t +{ + return tree_bytes_ + shapes_.capacity() * sizeof(ShapeSlot); +} + + +auto TransTableP::memory_in_use() const -> double +{ + const std::size_t bytes = ownership_.capacity() * sizeof(Ownership) + dynamic_bytes(); + return static_cast(bytes) / 1024.0; +} + + +auto TransTableP::node_count() const -> std::size_t +{ + return node_count_; +} + + +auto TransTableP::shape_count() const -> std::size_t +{ + return shape_count_; +} + + +// --------------------------------------------------------------------------- +// Keys and pattern encoding +// --------------------------------------------------------------------------- + +auto TransTableP::shape_key(const int trick, const int hand, const int hand_dist[]) + -> std::uint64_t +{ + // hand_dist holds 12 bits per hand (spades, hearts, diamonds; clubs are + // implied by the trick). trick + 1 keeps the key non-zero. + return (static_cast(trick + 1) << 50) | + (static_cast(hand) << 48) | + (static_cast(hand_dist[0]) << 36) | + (static_cast(hand_dist[1]) << 24) | + (static_cast(hand_dist[2]) << 12) | + static_cast(hand_dist[3]); +} + + +auto TransTableP::mask_word(const int suit, const int relevant, const int word) -> std::uint32_t +{ + const int cards_in_word = std::clamp(relevant - 4 * word, 0, 4); + if (cards_in_word == 0) { + return 0; + } + const std::uint32_t byte = (0xffu << (8 - 2 * cards_in_word)) & 0xffu; + return byte << (24 - 8 * suit); +} + + +auto TransTableP::position_set(const unsigned short aggr_target[], std::uint32_t set[]) const + -> void +{ + for (int k = 0; k < PatternWords; ++k) { + set[k] = ownership_[aggr_target[0]].set[0][k] | + ownership_[aggr_target[1]].set[1][k] | + ownership_[aggr_target[2]].set[2][k] | + ownership_[aggr_target[3]].set[3][k]; + } +} + + +auto TransTableP::make_pattern( + const unsigned short aggr_target[], + const unsigned short win_ranks[], + PatternKey& key, + NodeCards& cards) const -> void +{ + key = PatternKey{}; + for (int s = 0; s < DDS_SUITS; ++s) { + const unsigned w = win_ranks[s]; + cards.least_win[s] = 0; + if (w == 0) { + continue; + } + // Everything at or above the lowest winning rank is relevant. + const unsigned lowest = w & (0u - w); + const unsigned relevant = aggr_target[s] & ~(lowest - 1u); + if (relevant == 0) { + continue; + } + const int count = std::popcount(relevant); + cards.least_win[s] = static_cast(count); + for (int k = 0; k < PatternWords; ++k) { + key.word[k].set |= ownership_[relevant].set[s][k]; + key.word[k].mask |= mask_word(s, count, k); + } + } +} + + +auto TransTableP::same_pattern(const PatternKey& a, const PatternKey& b) -> bool +{ + for (int k = 0; k < PatternWords; ++k) { + if (a.word[k].set != b.word[k].set || a.word[k].mask != b.word[k].mask) { + return false; + } + } + return true; +} + + +auto TransTableP::matches(const PatternKey& pattern, const std::uint32_t set[]) -> bool +{ + // The first word (top four cards of every suit) decides most mismatches; + // test it alone before touching the rest of the node. + if ((pattern.word[0].set ^ set[0]) & pattern.word[0].mask) { + return false; + } + return (((pattern.word[1].set ^ set[1]) & pattern.word[1].mask) | + ((pattern.word[2].set ^ set[2]) & pattern.word[2].mask)) == 0; +} + + +auto TransTableP::weight_of(const PatternKey& key) -> std::uint32_t +{ + // Two mask bits per relevant card. + return static_cast( + std::popcount(key.word[0].mask) + std::popcount(key.word[1].mask) + + std::popcount(key.word[2].mask)) / 2u; +} + + +auto TransTableP::bucket_of(const PatternKey& key) -> int +{ + for (int s = 0; s < DDS_SUITS; ++s) { + const int shift = 24 - 8 * s; + if ((key.word[0].mask >> shift) & 0xffu) { + const int owner = static_cast((key.word[0].set >> (shift + 6)) & 3u); + return 1 + DDS_HANDS * s + owner; + } + } + return 0; +} + + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +auto TransTableP::PatternTree::insert(const std::size_t at, const PatternNode& node) -> void +{ + PatternNode* p = nodes() + at; + std::memmove(p + 1, p, (size - at) * sizeof(PatternNode)); + *p = node; + ++size; +} + + +auto TransTableP::size_class(const std::size_t capacity) -> int +{ + return std::countr_zero(capacity / InitialTreeNodes); +} + + +auto TransTableP::acquire_tree(const std::size_t capacity) -> PatternTree* +{ + // Capacities are InitialTreeNodes << class; tree_bytes_ counts spare + // blocks too, so reusing one costs nothing against the budget. + auto& spares = spare_trees_[size_class(capacity)]; + PatternTree* tree; + if (!spares.empty()) { + tree = spares.back(); + spares.pop_back(); + } else { + tree = static_cast( + ::operator new(PatternTree::bytes_for(capacity), std::align_val_t{CacheLine})); + tree_bytes_ += PatternTree::bytes_for(capacity); + } + std::memset(tree, 0, sizeof(PatternTree)); + tree->capacity = static_cast(capacity); + return tree; +} + + +auto TransTableP::release_tree(PatternTree* tree) -> void +{ + spare_trees_[size_class(tree->capacity)].push_back(tree); +} + + +auto TransTableP::release_trees() -> void +{ + for (ShapeSlot& slot : shapes_) { + if (slot.tree) { + release_tree(slot.tree); + slot.tree = nullptr; + } + } + shape_count_ = 0; + node_count_ = 0; +} + + +auto TransTableP::free_spare_trees() -> void +{ + for (auto& spares : spare_trees_) { + for (PatternTree* tree : spares) { + tree_bytes_ -= PatternTree::bytes_for(tree->capacity); + ::operator delete(tree, std::align_val_t{CacheLine}); + } + spares.clear(); + } +} + + +auto TransTableP::reserve_one_more(ShapeSlot& slot) -> bool +{ + PatternTree* old = slot.tree; + const std::size_t old_capacity = old ? old->capacity : 0; + if (old && old->size < old_capacity) { + return true; + } + const std::size_t wanted = std::max(InitialTreeNodes, old_capacity * 2); + if (spare_trees_[size_class(wanted)].empty() && + dynamic_bytes() + PatternTree::bytes_for(wanted) > maximum_bytes_) { + reset_memory(ResetReason::MemoryExhausted); + return false; + } + PatternTree* fresh = acquire_tree(wanted); + if (old) { + std::memcpy(fresh, old, PatternTree::bytes_for(old->size)); + fresh->capacity = static_cast(wanted); + release_tree(old); + } + slot.tree = fresh; + return true; +} + + +auto TransTableP::find_shape(const std::uint64_t key) const -> std::size_t +{ + if (shapes_.empty()) { + return NoSlot; + } + const std::size_t mask = shapes_.size() - 1; + for (std::size_t i = hash_slot(key, shapes_.size()); shapes_[i].key != 0; i = (i + 1) & mask) { + if (shapes_[i].key == key) { + return i; + } + } + return NoSlot; +} + + +auto TransTableP::grow_shapes() -> void +{ + const std::size_t new_size = shapes_.size() * 2; + if (new_size * sizeof(ShapeSlot) + tree_bytes_ > maximum_bytes_) { + reset_memory(ResetReason::MemoryExhausted); + return; + } + std::vector fresh(new_size); + const std::size_t mask = new_size - 1; + for (const ShapeSlot& slot : shapes_) { + if (slot.key == 0) { + continue; + } + std::size_t i = hash_slot(slot.key, new_size); + while (fresh[i].key != 0) { + i = (i + 1) & mask; + } + fresh[i] = slot; + } + shapes_.swap(fresh); +} + + +auto TransTableP::find_or_insert_shape(const std::uint64_t key) -> std::size_t +{ + if (shape_count_ * 2 >= shapes_.size()) { + grow_shapes(); + } + const std::size_t mask = shapes_.size() - 1; + std::size_t i = hash_slot(key, shapes_.size()); + while (shapes_[i].key != 0 && shapes_[i].key != key) { + i = (i + 1) & mask; + } + if (shapes_[i].key == 0) { + shapes_[i].key = key; + ++shape_count_; + } + return i; +} + + +// --------------------------------------------------------------------------- +// Lookup +// --------------------------------------------------------------------------- + +auto TransTableP::lookup( + const int trick, + const int hand, + const unsigned short aggr_target[], + const int hand_dist[], + const int limit, + bool& lower_flag) -> NodeCards const* +{ + if (shapes_.empty() || trick < 0 || trick >= MaxTricks) { + return nullptr; + } + const std::uint64_t key = shape_key(trick, hand, hand_dist); + const std::size_t slot = find_shape(key); + last_key_[trick][hand] = key; + last_slot_[trick][hand] = slot; + if (slot == NoSlot || shapes_[slot].tree == nullptr) { + return nullptr; + } + + std::uint32_t set[PatternWords]; + position_set(aggr_target, set); + const PatternTree& tree = *shapes_[slot].tree; + if (NodeCards const* found = find_cut(tree, 0, tree.bucket_end[0], set, limit, lower_flag)) { + return found; + } + for (int s = 0; s < DDS_SUITS; ++s) { + const int owner = static_cast((set[0] >> (30 - 8 * s)) & 3u); + const int bucket = 1 + DDS_HANDS * s + owner; + if (NodeCards const* found = find_cut( + tree, tree.bucket_end[bucket - 1], tree.bucket_end[bucket], set, limit, lower_flag)) { + return found; + } + } + return nullptr; +} + + +auto TransTableP::find_cut( + const PatternTree& tree, + const std::size_t begin, + const std::size_t end, + const std::uint32_t set[], + const int limit, + bool& lower_flag) -> NodeCards const* +{ + for (std::size_t i = begin; i < end; ++i) { + const PatternNode& node = tree[i]; + if (!matches(node.key, set)) { + continue; + } + if (node.cards.lower_bound > limit) { + lower_flag = true; + return &node.cards; + } + if (node.cards.upper_bound <= limit) { + lower_flag = false; + return &node.cards; + } + } + return nullptr; +} + + +// --------------------------------------------------------------------------- +// Insertion +// --------------------------------------------------------------------------- + +auto TransTableP::add( + const int trick, + const int hand, + const unsigned short aggr_target[], + const unsigned short win_ranks[], + const NodeCards& first, + const bool flag) -> void +{ + if (shapes_.empty() || trick < 0 || trick >= MaxTricks) { + return; + } + const std::uint64_t key = last_key_[trick][hand]; + if (key == 0) { + return; // add() without a preceding lookup() for this trick/hand + } + + PatternKey pattern; + NodeCards cards = first; + make_pattern(aggr_target, win_ranks, pattern, cards); + if (!flag) { + cards.best_move_suit = 0; + cards.best_move_rank = 0; + } + + // The preceding lookup() usually found the slot already; it is only stale + // if the table was rebuilt or reset in between. + std::size_t slot = last_slot_[trick][hand]; + if (slot == NoSlot || slot >= shapes_.size() || shapes_[slot].key != key) { + slot = find_or_insert_shape(key); + } + if (!reserve_one_more(shapes_[slot])) { + return; // the table was just reset; drop this entry + } + PatternTree& tree = *shapes_[slot].tree; + + // Within its bucket the pattern goes before the first one with more + // relevant cards; an identical pattern can only sit among those with + // exactly as many. + const int bucket = bucket_of(pattern); + const std::uint32_t weight = weight_of(pattern); + std::size_t at = tree.bucket_begin(bucket); + const std::size_t end = tree.bucket_end[bucket]; + for (; at < end; ++at) { + const std::uint32_t stored = weight_of(tree[at].key); + if (stored > weight) { + break; + } + if (stored == weight && same_pattern(tree[at].key, pattern)) { + tighten(tree[at].cards, cards, flag); + return; + } + } + + tree.insert(at, PatternNode{pattern, cards}); + for (int b = bucket; b < BucketCount; ++b) { + ++tree.bucket_end[b]; + } + ++node_count_; +} + + +auto TransTableP::tighten(NodeCards& stored, const NodeCards& cards, const bool flag) -> void +{ + stored.lower_bound = std::max(stored.lower_bound, cards.lower_bound); + stored.upper_bound = std::min(stored.upper_bound, cards.upper_bound); + if (flag) { + stored.best_move_suit = cards.best_move_suit; + stored.best_move_rank = cards.best_move_rank; + } +} + + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +auto TransTableP::print_suits(std::ofstream& fout, const int trick, const int hand) const -> void +{ + std::size_t shapes = 0; + std::size_t patterns = 0; + for (const ShapeSlot& slot : shapes_) { + if (slot.key == 0 || static_cast((slot.key >> 50) - 1) != trick || + static_cast((slot.key >> 48) & 3) != hand) { + continue; + } + ++shapes; + patterns += slot.tree ? slot.tree->size : 0; + } + fout << "Trick " << trick << " hand " << hand << ": " << shapes + << " shapes, " << patterns << " patterns\n"; +} + + +auto TransTableP::print_all_suits(std::ofstream& fout) const -> void +{ + for (int t = 0; t < MaxTricks; ++t) { + for (int h = 0; h < DDS_HANDS; ++h) { + print_suits(fout, t, h); + } + } +} + + +auto TransTableP::print_suit_stats(std::ofstream& fout, const int trick, const int hand) const + -> void +{ + print_suits(fout, trick, hand); +} + + +auto TransTableP::print_all_suit_stats(std::ofstream& fout) const -> void +{ + print_all_suits(fout); +} + + +auto TransTableP::print_summary_suit_stats(std::ofstream& fout) const -> void +{ + fout << "Shapes: " << shape_count_ << "\n"; +} + + +auto TransTableP::print_entries_dist( + std::ofstream& fout, const int trick, const int hand, const int hand_dist[]) const -> void +{ + const std::size_t slot = find_shape(shape_key(trick, hand, hand_dist)); + fout << "Trick " << trick << " hand " << hand << ": " + << (slot == NoSlot || !shapes_[slot].tree ? 0 : shapes_[slot].tree->size) << " patterns\n"; +} + + +auto TransTableP::print_entries_dist_and_cards( + std::ofstream& fout, + const int trick, + const int hand, + const unsigned short /*aggr_target*/[], + const int hand_dist[]) const -> void +{ + print_entries_dist(fout, trick, hand, hand_dist); +} + + +auto TransTableP::print_entries(std::ofstream& fout, const int trick, const int hand) const + -> void +{ + print_suits(fout, trick, hand); +} + + +auto TransTableP::print_all_entries(std::ofstream& fout) const -> void +{ + print_all_suits(fout); +} + + +auto TransTableP::print_entry_stats(std::ofstream& fout, const int trick, const int hand) const + -> void +{ + print_suits(fout, trick, hand); +} + + +auto TransTableP::print_all_entry_stats(std::ofstream& fout) const -> void +{ + print_all_suits(fout); +} + + +auto TransTableP::print_summary_entry_stats(std::ofstream& fout) const -> void +{ + fout << "Patterns: " << node_count() << ", shapes: " << shape_count_ + << ", memory KB: " << memory_in_use() << "\n"; +} + + +auto TransTableP::print_reset_stats(std::ofstream& fout) const -> void +{ + for (int r = 0; r < ResetReasonCount; ++r) { + fout << "Reset reason " << r << ": " << reset_counts_[r] << "\n"; + } +} diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp new file mode 100644 index 000000000..0845e1a7e --- /dev/null +++ b/library/src/trans_table/trans_table_p.hpp @@ -0,0 +1,231 @@ +/* + DDS, a bridge double dummy solver. + + Copyright (C) 2006-2014 by Bo Haglund / + 2014-2018 by Bo Haglund & Soren Hein. + + See LICENSE and README. +*/ + +#pragma once + +#include +#include +#include +#include + +#include + +/// \brief Transposition table organised as shape → relative-rank patterns. +/// +/// This implementation follows the "shape → pattern" cache of macroxue's +/// bridge-solver. A position is keyed by its suit-length shape (plus trick +/// count and hand to play). Under each shape the cached results are +/// *patterns*: the relative-rank ownership of the cards that mattered for the +/// result (the cards at or above the lowest winning rank in each suit), with +/// trick bounds. A lookup position matches a pattern when it agrees with the +/// pattern on every relevant card. +/// +/// Compared with \ref TransTableL, a shape may hold any number of patterns +/// (no fixed per-shape capacity forces older entries out), the patterns of a +/// shape are ordered most general first (fewest relevant cards), since those +/// match the most positions and so give the earliest cut-offs, and they are +/// partitioned into buckets by the owner of the top card of the first suit +/// with a relevant card, so that a lookup only scans the buckets it can +/// possibly match. +/// +/// Memory grows on demand up to the configured maximum; when exhausted the +/// whole table is cleared (\ref ResetReason::MemoryExhausted) and filling +/// resumes. +/// +/// \par Thread Safety +/// Not thread-safe. Must be accessed from a single thread. +class TransTableP : public TransTable +{ + public: + TransTableP(); + ~TransTableP() override; + + auto init(const int hand_lookup[][15]) -> void override; + auto set_memory_default(int megabytes) -> void override; + auto set_memory_maximum(int megabytes) -> void override; + auto make_tt() -> void override; + auto reset_memory(ResetReason reason) -> void override; + auto return_all_memory() -> void override; + auto memory_in_use() const -> double override; + + auto lookup( + int trick, + int hand, + const unsigned short aggr_target[], + const int hand_dist[], + int limit, + bool& lower_flag) -> NodeCards const* override; + + auto add( + int trick, + int hand, + const unsigned short aggr_target[], + const unsigned short win_ranks[], + const NodeCards& first, + bool flag) -> void override; + + auto print_suits(std::ofstream& fout, int trick, int hand) const -> void override; + auto print_all_suits(std::ofstream& fout) const -> void override; + auto print_suit_stats(std::ofstream& fout, int trick, int hand) const -> void override; + auto print_all_suit_stats(std::ofstream& fout) const -> void override; + auto print_summary_suit_stats(std::ofstream& fout) const -> void override; + auto print_entries_dist( + std::ofstream& fout, int trick, int hand, const int hand_dist[]) const -> void override; + auto print_entries_dist_and_cards( + std::ofstream& fout, + int trick, + int hand, + const unsigned short aggr_target[], + const int hand_dist[]) const -> void override; + auto print_entries(std::ofstream& fout, int trick, int hand) const -> void override; + auto print_all_entries(std::ofstream& fout) const -> void override; + auto print_entry_stats(std::ofstream& fout, int trick, int hand) const -> void override; + auto print_all_entry_stats(std::ofstream& fout) const -> void override; + auto print_summary_entry_stats(std::ofstream& fout) const -> void override; + auto print_reset_stats(std::ofstream& fout) const -> void override; + + /// \brief Number of stored patterns (white-box diagnostics and tests). + auto node_count() const -> std::size_t; + + /// \brief Number of distinct (trick, hand, shape) keys with stored patterns. + auto shape_count() const -> std::size_t; + + private: + /// Relative-rank ownership uses 2 bits per card and 4 cards per suit per + /// word; three words cover the top twelve cards of every suit. The + /// thirteenth card is implied by the shape and the other twelve. + static constexpr int PatternWords = 3; + static constexpr int MaxTricks = 13; + static constexpr std::size_t InitialShapes = 1024; + static constexpr std::size_t InitialTreeNodes = 8; + static constexpr std::size_t NoSlot = static_cast(-1); + static constexpr std::size_t CacheLine = 64; + + /// Patterns are partitioned by their first relevant suit and the owner + /// of that suit's top card (bucket 1 + 4 * suit + owner); patterns with + /// no relevant card go in bucket 0. A position can only match patterns + /// in bucket 0 or, per suit, in the bucket of the hand holding its own + /// top card of that suit, so a lookup scans 5 of the 17 buckets. + static constexpr int BucketCount = 1 + DDS_SUITS * DDS_HANDS; + + /// One word covers four relative cards per suit (2 bits each): `set` + /// holds the owners, `mask` which of those cards are relevant. Set and + /// mask are interleaved so that the first word's test, which decides + /// almost every mismatch, touches eight contiguous bytes. + struct PatternWord + { + std::uint32_t set; + std::uint32_t mask; + }; + + struct PatternKey + { + PatternWord word[PatternWords]; + }; + + /// One stored pattern; two fit in a cache line. + struct PatternNode + { + PatternKey key; + NodeCards cards; + }; + + /// A shape's patterns: one heap block headed by this struct, followed by + /// the nodes, bucket by bucket. The header is padded to whole cache + /// lines so that the nodes are line-aligned. Nodes are trivially + /// copyable, so the block is managed with plain memory moves. + struct alignas(CacheLine) PatternTree + { + std::uint32_t size; + std::uint32_t capacity; + std::uint32_t bucket_end[BucketCount]; ///< End offset of each bucket. + + auto nodes() -> PatternNode* { return reinterpret_cast(this + 1); } + auto nodes() const -> const PatternNode* + { + return reinterpret_cast(this + 1); + } + auto operator[](std::size_t i) -> PatternNode& { return nodes()[i]; } + auto operator[](std::size_t i) const -> const PatternNode& { return nodes()[i]; } + auto bucket_begin(int bucket) const -> std::size_t + { + return bucket == 0 ? 0 : bucket_end[bucket - 1]; + } + static auto bytes_for(std::size_t capacity) -> std::size_t + { + return sizeof(PatternTree) + capacity * sizeof(PatternNode); + } + auto insert(std::size_t at, const PatternNode& node) -> void; + }; + + struct ShapeSlot + { + std::uint64_t key = 0; ///< 0 marks an empty slot. + PatternTree* tree = nullptr; ///< Null until the first pattern is added. + }; + + /// Ownership encoding of one 13-bit remaining-cards set, per suit and word. + struct Ownership + { + std::uint32_t set[DDS_SUITS][PatternWords]; + }; + + /// Tree blocks come in power-of-two capacities; released blocks are kept + /// per size class for reuse so that the search never touches the heap + /// allocator in steady state. + static constexpr int SizeClasses = 24; + + std::vector ownership_; + std::vector shapes_; + std::vector spare_trees_[SizeClasses]; + std::size_t shape_count_ = 0; + std::size_t node_count_ = 0; + std::size_t tree_bytes_ = 0; ///< All tree blocks, in use or spare. + std::uint64_t last_key_[MaxTricks][DDS_HANDS] = {}; + std::size_t last_slot_[MaxTricks][DDS_HANDS] = {}; + std::size_t default_bytes_ = 0; + std::size_t maximum_bytes_ = 0; + int reset_counts_[ResetReasonCount] = {}; + + static auto shape_key(int trick, int hand, const int hand_dist[]) -> std::uint64_t; + static auto mask_word(int suit, int relevant, int word) -> std::uint32_t; + static auto same_pattern(const PatternKey& a, const PatternKey& b) -> bool; + static auto matches(const PatternKey& pattern, const std::uint32_t set[]) -> bool; + static auto weight_of(const PatternKey& key) -> std::uint32_t; + static auto bucket_of(const PatternKey& key) -> int; + + auto position_set(const unsigned short aggr_target[], std::uint32_t set[]) const -> void; + auto make_pattern( + const unsigned short aggr_target[], + const unsigned short win_ranks[], + PatternKey& key, + NodeCards& cards) const -> void; + + auto dynamic_bytes() const -> std::size_t; + auto reserve_one_more(ShapeSlot& slot) -> bool; + auto acquire_tree(std::size_t capacity) -> PatternTree*; + auto release_tree(PatternTree* tree) -> void; + auto release_trees() -> void; + auto free_spare_trees() -> void; + static auto size_class(std::size_t capacity) -> int; + + auto find_shape(std::uint64_t key) const -> std::size_t; + auto find_or_insert_shape(std::uint64_t key) -> std::size_t; + auto grow_shapes() -> void; + + static auto find_cut( + const PatternTree& tree, + std::size_t begin, + std::size_t end, + const std::uint32_t set[], + int limit, + bool& lower_flag) -> NodeCards const*; + + static auto tighten(NodeCards& stored, const NodeCards& cards, bool flag) -> void; +}; diff --git a/library/tests/dds_c_api_test.cpp b/library/tests/dds_c_api_test.cpp index dfcccf2a3..0faaba4f5 100644 --- a/library/tests/dds_c_api_test.cpp +++ b/library/tests/dds_c_api_test.cpp @@ -191,7 +191,8 @@ class DdsCApiConfiguredContext : public testing::TestWithParam {}; TEST_P(DdsCApiConfiguredContext, SolvesReferenceBoard) { - // tt_kind 0 = Small, 1 = Large; both must produce a usable context. + // tt_kind 0 = Small, 1 = Large, 2 = Pattern; all must produce a usable + // context. DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext(GetParam(), 0, 0); ASSERT_NE(ctx, nullptr); @@ -200,8 +201,22 @@ TEST_P(DdsCApiConfiguredContext, SolvesReferenceBoard) dds_c_destroy_solvercontext(ctx); } -INSTANTIATE_TEST_SUITE_P(BothTtKinds, DdsCApiConfiguredContext, - testing::Values(0, 1)); +INSTANTIATE_TEST_SUITE_P(AllTtKinds, DdsCApiConfiguredContext, + testing::Values(0, 1, 2)); + +TEST(DdsCApiTtConfiguration, ReconfiguringToPatternKindKeepsSolving) +{ + DDS_C_SOLVER_CTX ctx = dds_c_create_solvercontext_default(); + ASSERT_NE(ctx, nullptr); + ASSERT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_configure_tt(ctx, 2, 8, 16); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + dds_c_clear_tt(ctx); + EXPECT_EQ(SolveReference(ctx), kExpectedTricks); + + dds_c_destroy_solvercontext(ctx); +} TEST(DdsCApiTtConfiguration, ContextRemainsUsableAfterReconfiguration) { diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 4c093faee..1d31b24a0 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -4,33 +4,118 @@ /// Validates SolverContext configure_tt() behavior for resizing, /// switching kinds, and lazy initialization of transposition tables. +#include + #include #include -#include #include +#include +#include namespace { +struct ScopedEnv +{ + ScopedEnv(const char* name, const char* value) : name_(name) + { + setenv(name, value, 1); + } + ~ScopedEnv() + { + unsetenv(name_); + } + const char* name_; +}; + +auto kind_of(const TransTable* tt) -> TTKind +{ + if (dynamic_cast(tt) != nullptr) return TTKind::Small; + if (dynamic_cast(tt) != nullptr) return TTKind::Pattern; + return TTKind::Large; +} + +TEST(ConfigureTtApiTest, DefaultConfigurationUsesThePatternTable) +{ + // Arrange: no explicit kind anywhere (and no environment override). + unsetenv("DDS_TT_KIND"); + SolverConfig cfg; + SolverContext configured(cfg); + SolverContext bare; + + // Act & Assert + EXPECT_EQ(cfg.tt_kind_, TTKind::Pattern); + EXPECT_NE(nullptr, dynamic_cast(configured.trans_table())); + EXPECT_NE(nullptr, dynamic_cast(bare.trans_table())); +} + +TEST(ConfigureTtApiTest, PatternKindCreatesPatternTable) +{ + // Arrange + SolverConfig cfg; + cfg.tt_kind_ = TTKind::Pattern; + SolverContext ctx(cfg); + + // Act + auto* tt = ctx.trans_table(); + + // Assert + ASSERT_NE(tt, nullptr); + EXPECT_NE(nullptr, dynamic_cast(tt)); +} + +TEST(ConfigureTtApiTest, SwitchingToPatternRecreatesAndResizingKeepsInstance) +{ + // Arrange: start from the Large table. + SolverConfig cfg; + cfg.tt_kind_ = TTKind::Large; + SolverContext ctx(cfg); + auto* large = ctx.trans_table(); + ASSERT_NE(nullptr, dynamic_cast(large)); + + // Act + ctx.configure_tt(TTKind::Pattern, /*defMB=*/8, /*maxMB=*/16); + auto* pattern = ctx.maybe_trans_table(); + ctx.configure_tt(TTKind::Pattern, /*defMB=*/16, /*maxMB=*/32); + auto* resized = ctx.maybe_trans_table(); + + // Assert + ASSERT_NE(pattern, nullptr); + EXPECT_NE(nullptr, dynamic_cast(pattern)); + EXPECT_EQ(pattern, resized) << "same kind: resize in place"; + ctx.configure_tt(TTKind::Large, 8, 16); + EXPECT_NE(nullptr, dynamic_cast(ctx.maybe_trans_table())); +} + +TEST(ConfigureTtApiTest, EnvironmentOverridesTableKind) +{ + // Arrange + ScopedEnv env("DDS_TT_KIND", "pattern"); + SolverConfig cfg; + cfg.tt_kind_ = TTKind::Small; + SolverContext ctx(cfg); + + // Act & Assert + EXPECT_NE(nullptr, dynamic_cast(ctx.trans_table())); + ScopedEnv env2("DDS_TT_KIND", "large"); + ctx.dispose_trans_table(); + EXPECT_NE(nullptr, dynamic_cast(ctx.trans_table())); +} + TEST(ConfigureTtApiTest, SwitchKindRecreatesTable) { - // Default context: Large TT by default (unless env overrides) + // Default context (whatever kind that is, env overrides included). SolverContext ctx; auto* tt1 = ctx.trans_table(); ASSERT_NE(tt1, nullptr); - // Determine current kind via RTTI - const bool was_small = dynamic_cast(tt1) != nullptr; - // Flip kind - const TTKind new_kind = was_small ? TTKind::Large : TTKind::Small; + // Flip to a different kind + const TTKind new_kind = kind_of(tt1) == TTKind::Small ? TTKind::Large : TTKind::Small; ctx.configure_tt(new_kind, /*defMB=*/8, /*maxMB=*/8); auto* tt2 = ctx.maybe_trans_table(); ASSERT_NE(tt2, nullptr); - if (new_kind == TTKind::Small) - EXPECT_NE(nullptr, dynamic_cast(tt2)); - else - EXPECT_NE(nullptr, dynamic_cast(tt2)); + EXPECT_EQ(kind_of(tt2), new_kind); } TEST(ConfigureTtApiTest, ResizeInPlaceWhenKindUnchanged) @@ -38,9 +123,7 @@ TEST(ConfigureTtApiTest, ResizeInPlaceWhenKindUnchanged) SolverContext ctx; auto* tt1 = ctx.trans_table(); ASSERT_NE(tt1, nullptr); - // Determine current kind via RTTI - const bool is_small = dynamic_cast(tt1) != nullptr; - const TTKind same_kind = is_small ? TTKind::Small : TTKind::Large; + const TTKind same_kind = kind_of(tt1); // Resize should not replace the instance when kind does not change ctx.configure_tt(same_kind, /*defMB=*/16, /*maxMB=*/32); diff --git a/library/tests/trans_table/BUILD.bazel b/library/tests/trans_table/BUILD.bazel index b7374c98d..800b1dc98 100644 --- a/library/tests/trans_table/BUILD.bazel +++ b/library/tests/trans_table/BUILD.bazel @@ -26,6 +26,7 @@ cc_test( "trans_table_base_test.cpp", "trans_table_s_test.cpp", "trans_table_l_test.cpp", + "trans_table_p_test.cpp", ], deps = [ "//library/src/trans_table:testable_trans_table", diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp new file mode 100644 index 000000000..088f3f939 --- /dev/null +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -0,0 +1,851 @@ +/// @file trans_table_p_test.cpp +/// @brief White-box tests for TransTableP, the shape → pattern transposition table. +/// +/// TransTableP stores, per (tricks, hand, suit-length shape), relative-rank +/// ownership patterns ordered by generality (bridge-solver's "shape → +/// pattern" cache). These tests pin down the matching semantics, the +/// bound-tightening and ordering rules, memory limits, and equivalence of +/// cut decisions with the legacy TransTableL for small workloads. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace { + +constexpr const char* AllRanks = "AKQJT98765432"; + +auto rank_of(char c) -> int +{ + switch (c) { + case 'A': return 14; + case 'K': return 13; + case 'Q': return 12; + case 'J': return 11; + case 'T': return 10; + default: return c - '0'; + } +} + +auto seat_of(char c) -> int +{ + switch (c) { + case 'N': return 0; + case 'E': return 1; + case 'S': return 2; + default: return 3; + } +} + +/// Bitmask over ranks 2..14 (bit r-2) for the listed rank characters. +auto ranks(const std::string& text) -> unsigned short +{ + unsigned short bits = 0; + for (char c : text) { + bits = static_cast(bits | (1u << (rank_of(c) - 2))); + } + return bits; +} + +/// A deal expressed as, per suit, the owner (N/E/S/W) of each rank from A down to 2. +struct TestDeal +{ + int hand_lookup[DDS_SUITS][15] = {}; + + static auto from_owners( + const std::string& spades, + const std::string& hearts, + const std::string& diamonds, + const std::string& clubs) -> TestDeal + { + TestDeal deal; + const std::string* suits[DDS_SUITS] = {&spades, &hearts, &diamonds, &clubs}; + for (int s = 0; s < DDS_SUITS; ++s) { + for (int i = 0; i < 13; ++i) { + deal.hand_lookup[s][14 - i] = seat_of((*suits[s])[static_cast(i)]); + } + } + return deal; + } + + /// Every rank r in every suit s is held by seat (r + s) % 4 (13 cards each). + static auto rotating() -> TestDeal + { + TestDeal deal; + for (int s = 0; s < DDS_SUITS; ++s) { + for (int r = 2; r <= 14; ++r) { + deal.hand_lookup[s][r] = (r + s) % DDS_HANDS; + } + } + return deal; + } + + static auto random(std::mt19937& rng) -> TestDeal + { + std::vector cards(52); + std::iota(cards.begin(), cards.end(), 0); + std::shuffle(cards.begin(), cards.end(), rng); + TestDeal deal; + for (size_t i = 0; i < cards.size(); ++i) { + const int s = cards[i] / 13; + const int r = 2 + cards[i] % 13; + deal.hand_lookup[s][r] = static_cast(i / 13); + } + return deal; + } +}; + +/// The remaining cards of a position, with the derived TT key inputs. +struct TestPosition +{ + unsigned short aggr[DDS_SUITS] = {}; + int hand_dist[DDS_HANDS] = {}; + int tricks = 0; + + static auto remaining( + const TestDeal& deal, + const std::string& spades, + const std::string& hearts, + const std::string& diamonds, + const std::string& clubs) -> TestPosition + { + TestPosition pos; + pos.aggr[0] = ranks(spades); + pos.aggr[1] = ranks(hearts); + pos.aggr[2] = ranks(diamonds); + pos.aggr[3] = ranks(clubs); + pos.finish(deal); + return pos; + } + + void finish(const TestDeal& deal) + { + int length[DDS_HANDS][DDS_SUITS] = {}; + int total = 0; + for (int s = 0; s < DDS_SUITS; ++s) { + for (int r = 2; r <= 14; ++r) { + if (aggr[s] & (1u << (r - 2))) { + ++length[deal.hand_lookup[s][r]][s]; + ++total; + } + } + } + for (int h = 0; h < DDS_HANDS; ++h) { + hand_dist[h] = (length[h][0] << 8) | (length[h][1] << 4) | length[h][2]; + } + tricks = total / 4 - 1; + } +}; + +auto full_deal_position(const TestDeal& deal) -> TestPosition +{ + return TestPosition::remaining(deal, AllRanks, AllRanks, AllRanks, AllRanks); +} + +auto node(int lower, int upper, int best_suit = 0, int best_rank = 0) -> NodeCards +{ + NodeCards cards{}; + cards.lower_bound = static_cast(lower); + cards.upper_bound = static_cast(upper); + cards.best_move_suit = static_cast(best_suit); + cards.best_move_rank = static_cast(best_rank); + return cards; +} + +struct WinRanks +{ + unsigned short ranks[DDS_SUITS] = {}; +}; + +auto win(const std::string& spades, + const std::string& hearts = "", + const std::string& diamonds = "", + const std::string& clubs = "") -> WinRanks +{ + WinRanks w; + w.ranks[0] = ranks(spades); + w.ranks[1] = ranks(hearts); + w.ranks[2] = ranks(diamonds); + w.ranks[3] = ranks(clubs); + return w; +} + +class TransTablePTest : public ::testing::Test +{ +protected: + void SetUp() override + { + tt_.set_memory_default(16); + tt_.set_memory_maximum(32); + tt_.make_tt(); + } + + void init(const TestDeal& deal) + { + tt_.init(deal.hand_lookup); + } + + /// Runs lookup() then add() the way ab_search_0 does for a fresh node. + void store(const TestPosition& pos, int hand, const WinRanks& w, const NodeCards& cards, + bool flag = true) + { + bool lower_flag = false; + (void)tt_.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, -1, lower_flag); + tt_.add(pos.tricks, hand, pos.aggr, w.ranks, cards, flag); + } + + auto lookup(const TestPosition& pos, int hand, int limit, bool& lower_flag) -> NodeCards const* + { + lower_flag = false; + return tt_.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, limit, lower_flag); + } + + TransTableP tt_; +}; + +// --------------------------------------------------------------------------- +// Basic hit / miss semantics +// --------------------------------------------------------------------------- + +TEST_F(TransTablePTest, LookupOnEmptyTableMisses) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + bool lower_flag = true; + + // Act + NodeCards const* hit = lookup(pos, 0, 5, lower_flag); + + // Assert + EXPECT_EQ(hit, nullptr); + EXPECT_EQ(tt_.node_count(), 0u); +} + +TEST_F(TransTablePTest, StoredPositionIsFoundWithLowerFlagWhenLowerBoundExceedsLimit) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(7, 12)); + bool lower_flag = false; + + // Act + NodeCards const* hit = lookup(pos, 0, 6, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->lower_bound, 7); + EXPECT_EQ(hit->upper_bound, 12); + EXPECT_EQ(tt_.node_count(), 1u); +} + +TEST_F(TransTablePTest, StoredPositionIsFoundWithoutLowerFlagWhenUpperBoundWithinLimit) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(2, 5)); + bool lower_flag = true; + + // Act + NodeCards const* hit = lookup(pos, 0, 5, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_FALSE(lower_flag); +} + +TEST_F(TransTablePTest, StoredPositionMissesWhenLimitFallsStrictlyInsideBounds) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(3, 8)); + bool lower_flag = false; + + // Act & Assert + EXPECT_EQ(lookup(pos, 0, 3, lower_flag), nullptr); // lower == limit: no cut + EXPECT_EQ(lookup(pos, 0, 7, lower_flag), nullptr); // upper > limit: no cut + EXPECT_NE(lookup(pos, 0, 2, lower_flag), nullptr); + EXPECT_NE(lookup(pos, 0, 8, lower_flag), nullptr); +} + +TEST_F(TransTablePTest, DifferentHandTricksOrShapeDoNotMatch) +{ + // Arrange: the same deal with one trick of low cards played, twice, in two + // ways that give different shapes. + const auto deal = TestDeal::rotating(); + init(deal); + const auto full = full_deal_position(deal); + // Trick one: 5432 of spades (owners 3,2,1,0 for the rotating deal). + const auto after_spade_trick = + TestPosition::remaining(deal, "AKQJT9876", AllRanks, AllRanks, AllRanks); + // Alternative first trick: 5432 of hearts. + const auto after_heart_trick = + TestPosition::remaining(deal, AllRanks, "AKQJT9876", AllRanks, AllRanks); + ASSERT_EQ(after_spade_trick.tricks, after_heart_trick.tricks); + ASSERT_NE(after_spade_trick.hand_dist[0], after_heart_trick.hand_dist[0]); + store(after_spade_trick, 1, win("A"), node(6, 6)); + bool lower_flag = false; + + // Act & Assert + EXPECT_NE(lookup(after_spade_trick, 1, 5, lower_flag), nullptr); + EXPECT_EQ(lookup(after_spade_trick, 2, 5, lower_flag), nullptr) << "hand differs"; + EXPECT_EQ(lookup(after_heart_trick, 1, 5, lower_flag), nullptr) << "shape differs"; + EXPECT_EQ(lookup(full, 1, 5, lower_flag), nullptr) << "trick count differs"; +} + +// --------------------------------------------------------------------------- +// Relative-rank pattern generalisation +// --------------------------------------------------------------------------- + +TEST_F(TransTablePTest, PositionDifferingOnlyInIrrelevantCardsHits) +{ + // Arrange: in spades North holds A and 4, East holds K and 3, the rest are + // irrelevant. Two positions with the same shape whose spade holdings differ + // only below the lowest winning rank (the king). + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + // Remaining spades A K Q J 5 4 3 2 (owners N E S W E S W N) ... + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + // ... and A K T 9 7 6 4 3 (owners N E S W E N S W): same shape, same top + // four owners, different owners further down. + const auto pos_b = TestPosition::remaining(deal, "AKT97643", "AKQJ", "AKQJ", "AKQJ"); + ASSERT_EQ(std::memcmp(pos_a.hand_dist, pos_b.hand_dist, sizeof(pos_a.hand_dist)), 0); + store(pos_a, 0, win("J"), node(4, 4)); + bool lower_flag = false; + + // Act + NodeCards const* hit = lookup(pos_b, 0, 3, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); +} + +TEST_F(TransTablePTest, PositionDifferingInARelevantCardMisses) +{ + // Arrange: same spade layout as above, but now the third-highest spade is + // relevant and is held by different seats in the two positions. + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + // A K J T 9 8 7 4 (owners N E W S W N E S): same shape as pos_a but the + // third-highest spade now belongs to West, not South. + const auto pos_c = TestPosition::remaining(deal, "AKJT9874", "AKQJ", "AKQJ", "AKQJ"); + ASSERT_EQ(std::memcmp(pos_a.hand_dist, pos_c.hand_dist, sizeof(pos_a.hand_dist)), 0); + store(pos_a, 0, win("Q"), node(4, 4)); + bool lower_flag = false; + + // Act & Assert + EXPECT_EQ(lookup(pos_c, 0, 3, lower_flag), nullptr); + EXPECT_NE(lookup(pos_a, 0, 3, lower_flag), nullptr); +} + +TEST_F(TransTablePTest, ZeroWinRanksMakesEverySameShapePositionMatch) +{ + // Arrange + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + const auto pos_c = TestPosition::remaining(deal, "AKJT9874", "AKQJ", "AKQJ", "AKQJ"); + store(pos_a, 0, win(""), node(0, 2)); + bool lower_flag = true; + + // Act + NodeCards const* hit = lookup(pos_c, 0, 2, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_FALSE(lower_flag); + for (int s = 0; s < DDS_SUITS; ++s) { + EXPECT_EQ(hit->least_win[s], 0); + } +} + +TEST_F(TransTablePTest, LeastWinEncodesLowestRelevantRankPerSuit) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("AK", "", "Q", "2"), node(9, 9)); + bool lower_flag = false; + + // Act + NodeCards const* hit = lookup(pos, 0, 8, lower_flag); + + // Assert: least_win = 15 - lowest relevant absolute rank, 0 when unused. + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->least_win[0], 15 - 13); + EXPECT_EQ(hit->least_win[1], 0); + EXPECT_EQ(hit->least_win[2], 15 - 12); + EXPECT_EQ(hit->least_win[3], 15 - 2); +} + +// --------------------------------------------------------------------------- +// Bounds merging, best move, subsumption and deduplication +// --------------------------------------------------------------------------- + +TEST_F(TransTablePTest, ReAddingTheSamePatternIntersectsBounds) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(2, 10)); + store(pos, 0, win("A"), node(5, 12)); + bool lower_flag = false; + + // Act + NodeCards const* hit = lookup(pos, 0, 4, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->lower_bound, 5); + EXPECT_EQ(hit->upper_bound, 10); + EXPECT_EQ(tt_.node_count(), 1u); +} + +TEST_F(TransTablePTest, BestMoveIsKeptOnlyWhenTheStoreIsFlaggedAsACutoff) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + bool lower_flag = false; + + // Act & Assert: an exhaustive (flag == false) store has no best move ... + store(pos, 0, win("A"), node(0, 3, 2, 11), false); + NodeCards const* hit = lookup(pos, 0, 3, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->best_move_suit, 0); + EXPECT_EQ(hit->best_move_rank, 0); + + // ... while a cutoff store records it, also when merging into the entry. + store(pos, 0, win("A"), node(1, 3, 2, 11), true); + hit = lookup(pos, 0, 3, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_EQ(hit->best_move_suit, 2); + EXPECT_EQ(hit->best_move_rank, 11); +} + +TEST_F(TransTablePTest, PatternsWithDifferentRelevantCardsAreStoredSeparately) +{ + // Arrange: a generic pattern (ace only) already bounds the position. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(3, 9)); + + // Act: a more specific pattern (AKQ relevant) for the same position, + // looser below but tighter above. + store(pos, 0, win("Q"), node(2, 7)); + + // Assert: the two patterns are distinct entries, each keeping its own + // bounds; re-adding the generic one tightens only the generic one. + EXPECT_EQ(tt_.node_count(), 2u); + store(pos, 0, win("A"), node(5, 9)); + EXPECT_EQ(tt_.node_count(), 2u); + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 4, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->lower_bound, 5); + EXPECT_EQ(hit->least_win[0], 1); + hit = lookup(pos, 0, 9, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_FALSE(lower_flag); + EXPECT_EQ(hit->upper_bound, 9); + EXPECT_EQ(hit->least_win[0], 1); + hit = lookup(pos, 0, 7, lower_flag); + ASSERT_NE(hit, nullptr) << "only the specific pattern's upper bound cuts here"; + EXPECT_FALSE(lower_flag); + EXPECT_EQ(hit->upper_bound, 7); + EXPECT_EQ(hit->least_win[0], 3); +} + +TEST_F(TransTablePTest, MoreSpecificPatternWithTighterBoundsIsStoredAndFound) +{ + // Arrange + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + const auto pos_c = TestPosition::remaining(deal, "AKJT9874", "AKQJ", "AKQJ", "AKQJ"); + store(pos_a, 0, win("K"), node(3, 9)); // matches pos_a and pos_c + store(pos_a, 0, win("Q"), node(6, 9)); // matches only pos_a + bool lower_flag = false; + + // Act & Assert + EXPECT_EQ(tt_.node_count(), 2u); + NodeCards const* hit_a = lookup(pos_a, 0, 5, lower_flag); + ASSERT_NE(hit_a, nullptr) << "specific pattern must cut at its tighter bound"; + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit_a->lower_bound, 6); + EXPECT_EQ(lookup(pos_c, 0, 5, lower_flag), nullptr) << "generic bound alone does not cut"; + EXPECT_NE(lookup(pos_c, 0, 2, lower_flag), nullptr) << "generic bound still applies"; +} + +TEST_F(TransTablePTest, GenericPatternAddedAfterSpecificOnesCoversThemAll) +{ + // Arrange: two specific patterns on different positions of one shape. + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + const auto pos_c = TestPosition::remaining(deal, "AKJT9874", "AKQJ", "AKQJ", "AKQJ"); + store(pos_a, 0, win("Q"), node(6, 8)); // top three spades: N E S + store(pos_c, 0, win("J"), node(2, 4)); // top three spades: N E W + + // Act: a generic pattern (top two spades: N E) covering both. + store(pos_a, 0, win("K"), node(1, 9)); + + // Assert: three entries; each position cuts on its own specific bound + // and both share the generic one. + EXPECT_EQ(tt_.node_count(), 3u); + bool lower_flag = false; + NodeCards const* hit_a = lookup(pos_a, 0, 5, lower_flag); + ASSERT_NE(hit_a, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit_a->lower_bound, 6); + NodeCards const* hit_c = lookup(pos_c, 0, 5, lower_flag); + ASSERT_NE(hit_c, nullptr); + EXPECT_FALSE(lower_flag); + EXPECT_EQ(hit_c->upper_bound, 4); + EXPECT_EQ(lookup(pos_a, 0, 0, lower_flag)->least_win[0], 2); + EXPECT_EQ(lookup(pos_c, 0, 0, lower_flag)->least_win[0], 2); +} + +TEST_F(TransTablePTest, TighteningAPatternDoesNotTouchOtherPatterns) +{ + // Arrange: a specific pattern alongside a generic one. + const auto deal = TestDeal::from_owners( + "NESWSWNE" "NESW" "N", + "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto pos_a = TestPosition::remaining(deal, "AKQJ5432", "AKQJ", "AKQJ", "AKQJ"); + store(pos_a, 0, win("K"), node(0, 12)); + store(pos_a, 0, win("Q"), node(5, 12)); + ASSERT_EQ(tt_.node_count(), 2u); + + // Act: the generic pattern learns an upper bound of 6. + store(pos_a, 0, win("K"), node(0, 6)); + + // Assert + EXPECT_EQ(tt_.node_count(), 2u); + bool lower_flag = true; + NodeCards const* hit = lookup(pos_a, 0, 6, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_FALSE(lower_flag); + EXPECT_EQ(hit->upper_bound, 6); + EXPECT_EQ(hit->least_win[0], 2); + hit = lookup(pos_a, 0, 4, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->lower_bound, 5); + EXPECT_EQ(hit->upper_bound, 12); + EXPECT_EQ(hit->least_win[0], 3); +} + +TEST_F(TransTablePTest, IncomparableMatchingPatternsAreTriedMostGenericFirst) +{ + // Arrange: two patterns that both match the position but constrain + // disjoint cards. The one with fewer relevant cards is more general and + // so more likely to match future positions; it should be found first + // regardless of insertion order. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("K", "", "K"), node(7, 7, 2, 13)); // 4 relevant cards + store(pos, 0, win("Q", "Q"), node(7, 7, 0, 14)); // 6 relevant cards + ASSERT_EQ(tt_.node_count(), 2u); + + // Act + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 6, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->best_move_suit, 2); + EXPECT_EQ(hit->least_win[2], 2); + EXPECT_EQ(hit->least_win[1], 0); +} + +TEST_F(TransTablePTest, PatternsWhoseFirstRelevantSuitDiffersAreAllFound) +{ + // Arrange: one pattern per suit, each relevant only in that suit, plus a + // pattern with no relevant cards at all. Every position of the shape + // must be checked against all of them. + const auto deal = TestDeal::rotating(); + init(deal); + // In the rotating deal the spade owners are S E N W S E N W S E N W S + // from the ace down; both positions below have one spade gone per hand. + const auto pos = TestPosition::remaining(deal, "KQJT65432", AllRanks, AllRanks, AllRanks); + const auto other = TestPosition::remaining(deal, "A98765432", AllRanks, AllRanks, AllRanks); + ASSERT_EQ(pos.tricks, other.tricks); + ASSERT_TRUE(std::equal(pos.hand_dist, pos.hand_dist + DDS_HANDS, other.hand_dist)); + store(pos, 0, win("K"), node(1, 12, 0, 0)); + store(pos, 0, win("", "K"), node(2, 12, 1, 0)); + store(pos, 0, win("", "", "K"), node(3, 12, 2, 0)); + store(pos, 0, win("", "", "", "K"), node(4, 12, 3, 0)); + store(pos, 0, win(""), node(0, 11, 0, 5)); + ASSERT_EQ(tt_.node_count(), 5u); + + // Act / Assert: raising the limit knocks the patterns out one by one. + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 3, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->best_move_suit, 3); + hit = lookup(pos, 0, 2, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_GE(hit->lower_bound, 3); + hit = lookup(pos, 0, 11, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_FALSE(lower_flag); + EXPECT_EQ(hit->best_move_rank, 5); + EXPECT_EQ(lookup(pos, 0, 4, lower_flag), nullptr); + EXPECT_EQ(lookup(pos, 0, 10, lower_flag), nullptr); + + // The other position differs from pos only in who holds the top spades, + // so it misses the spade pattern but still matches the heart, diamond, + // club and wildcard patterns. + hit = lookup(other, 0, 3, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->best_move_suit, 3); + hit = lookup(other, 0, 0, lower_flag); // only the spade pattern's [1, 12] bound + ASSERT_NE(hit, nullptr); // is useless here; another must cut + EXPECT_NE(hit->best_move_suit, 0); + EXPECT_EQ(hit->least_win[0], 0); +} + +// --------------------------------------------------------------------------- +// Memory management and lifecycle +// --------------------------------------------------------------------------- + +TEST_F(TransTablePTest, ResetMemoryForgetsEverythingButKeepsTheTableUsable) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(7, 12)); + + // Act + tt_.reset_memory(ResetReason::NewDeal); + + // Assert + bool lower_flag = false; + EXPECT_EQ(lookup(pos, 0, 6, lower_flag), nullptr); + EXPECT_EQ(tt_.node_count(), 0u); + store(pos, 0, win("A"), node(7, 12)); + EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); +} + +TEST_F(TransTablePTest, ReturnAllMemoryThenMakeTtStartsFresh) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(7, 12)); + + // Act + tt_.return_all_memory(); + tt_.make_tt(); + init(deal); + + // Assert + bool lower_flag = false; + EXPECT_EQ(lookup(pos, 0, 6, lower_flag), nullptr); + store(pos, 0, win("A"), node(7, 12)); + EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); +} + +TEST(TransTablePMemoryTest, StaysWithinTheMaximumAndResetsWhenExhausted) +{ + // Arrange: a tiny table and a stream of distinct positions/patterns. + TransTableP tt; + tt.set_memory_default(1); + tt.set_memory_maximum(2); + tt.make_tt(); + std::mt19937 rng(7); + const auto deal = TestDeal::random(rng); + tt.init(deal.hand_lookup); + const double baseline_kb = tt.memory_in_use(); + std::uniform_int_distribution pick_hand(0, 3); + std::uniform_int_distribution pick_bound(0, 13); + size_t max_nodes_seen = 0; + bool shrank_at_some_point = false; + + // Act + for (int i = 0; i < 200000; ++i) { + // Random legal position: play whole tricks of random cards. + TestPosition pos; + for (int s = 0; s < DDS_SUITS; ++s) pos.aggr[s] = 0x1fff; + const int tricks_played = 1 + (i % 6); + for (int t = 0; t < tricks_played; ++t) { + for (int h = 0; h < DDS_HANDS; ++h) { + std::vector> held; + for (int s = 0; s < DDS_SUITS; ++s) + for (int r = 2; r <= 14; ++r) + if ((pos.aggr[s] & (1u << (r - 2))) && deal.hand_lookup[s][r] == h) + held.emplace_back(s, r); + const auto [s, r] = held[std::uniform_int_distribution(0, held.size() - 1)(rng)]; + pos.aggr[s] = static_cast(pos.aggr[s] & ~(1u << (r - 2))); + } + } + pos.finish(deal); + WinRanks w; + for (int s = 0; s < DDS_SUITS; ++s) { + w.ranks[s] = static_cast(pos.aggr[s] & std::uniform_int_distribution(0, 0x1fff)(rng)); + } + const int lo = pick_bound(rng); + const int hand = pick_hand(rng); + bool lower_flag = false; + (void)tt.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, -1, lower_flag); + const size_t before = tt.node_count(); + tt.add(pos.tricks, hand, pos.aggr, w.ranks, node(lo, 13), true); + if (tt.node_count() < before) shrank_at_some_point = true; + max_nodes_seen = std::max(max_nodes_seen, tt.node_count()); + ASSERT_LE(tt.memory_in_use(), baseline_kb + 2 * 1024.0 + 1.0); + } + + // Assert + EXPECT_TRUE(shrank_at_some_point) << "the table never hit its limit"; + EXPECT_GT(max_nodes_seen, 1000u); +} + +// --------------------------------------------------------------------------- +// Equivalence with TransTableL on small workloads +// --------------------------------------------------------------------------- + +/// For workloads small enough that TransTableL never evicts, both tables must +/// take identical cut decisions on every lookup. Bounds are generated to be +/// consistent per (tricks, hand) so that intersections never become empty. +TEST(TransTablePEquivalenceTest, CutDecisionsMatchTransTableLOnSmallWorkloads) +{ + for (unsigned seed = 1; seed <= 12; ++seed) { + // Arrange + std::mt19937 rng(seed); + const auto deal = TestDeal::random(rng); + TransTableL large; + large.set_memory_default(16); + large.set_memory_maximum(32); + large.make_tt(); + large.init(deal.hand_lookup); + TransTableP pattern; + pattern.set_memory_default(16); + pattern.set_memory_maximum(32); + pattern.make_tt(); + pattern.init(deal.hand_lookup); + + int hidden_value[13][DDS_HANDS]; + for (auto& row : hidden_value) + for (int& v : row) v = std::uniform_int_distribution(2, 8)(rng); + + std::vector seen; + auto random_position = [&]() { + TestPosition pos; + for (int s = 0; s < DDS_SUITS; ++s) pos.aggr[s] = 0x1fff; + // TransTableL indexes tricks 0..11, so at least one trick is played. + const int tricks_played = std::uniform_int_distribution(1, 3)(rng); + for (int t = 0; t < tricks_played; ++t) { + for (int h = 0; h < DDS_HANDS; ++h) { + std::vector> held; + for (int s = 0; s < DDS_SUITS; ++s) + for (int r = 2; r <= 14; ++r) + if ((pos.aggr[s] & (1u << (r - 2))) && deal.hand_lookup[s][r] == h) + held.emplace_back(s, r); + // Prefer low cards so that shapes and top cards repeat often. + std::sort(held.begin(), held.end(), + [](auto a, auto b) { return a.second < b.second; }); + const size_t idx = std::min(held.size() - 1, + static_cast(std::uniform_int_distribution(0, 5)(rng))); + const auto [s, r] = held[idx]; + pos.aggr[s] = static_cast(pos.aggr[s] & ~(1u << (r - 2))); + } + } + pos.finish(deal); + return pos; + }; + + int hits = 0; + // Act & Assert + for (int step = 0; step < 400; ++step) { + TestPosition pos = (!seen.empty() && step % 3 == 0) + ? seen[std::uniform_int_distribution(0, seen.size() - 1)(rng)] + : random_position(); + seen.push_back(pos); + const int hand = std::uniform_int_distribution(0, 3)(rng); + const int limit = std::uniform_int_distribution(-1, 13)(rng); + + bool lower_l = false; + bool lower_p = false; + NodeCards const* hit_l = + large.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, limit, lower_l); + NodeCards const* hit_p = + pattern.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, limit, lower_p); + ASSERT_EQ(hit_l != nullptr, hit_p != nullptr) + << "seed " << seed << " step " << step << " limit " << limit; + if (hit_l != nullptr) { + ++hits; + EXPECT_EQ(lower_l, lower_p) << "seed " << seed << " step " << step; + continue; + } + + // Store a pattern whose relevant cards are the top few of one or two suits. + WinRanks w; + for (int s = 0; s < DDS_SUITS; ++s) { + if (std::uniform_int_distribution(0, 2)(rng) != 0) continue; + const int keep = std::uniform_int_distribution(1, 3)(rng); + unsigned short bits = pos.aggr[s]; + int count = 0; + for (int r = 14; r >= 2 && count < keep; --r) { + if (bits & (1u << (r - 2))) { + ++count; + if (count == keep) w.ranks[s] = static_cast(1u << (r - 2)); + } + } + } + const int v = hidden_value[pos.tricks][hand]; + const int lo = v - std::uniform_int_distribution(0, 3)(rng); + const int hi = v + std::uniform_int_distribution(0, 3)(rng); + const bool flag = std::uniform_int_distribution(0, 1)(rng) == 1; + const auto cards = node(std::max(lo, 0), std::min(hi, 13), 1, 12); + large.add(pos.tricks, hand, pos.aggr, w.ranks, cards, flag); + pattern.add(pos.tricks, hand, pos.aggr, w.ranks, cards, flag); + } + EXPECT_GT(hits, 20) << "seed " << seed << ": workload produced too few hits to be meaningful"; + } +} + +} // namespace diff --git a/specs/solver-context.md b/specs/solver-context.md index 6ab48b08c..e714a2a39 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -40,7 +40,7 @@ the opaque handle. See [dds-public-api](dds-public-api.md). reuse comes from reusing the *same context* across solves, not from a shared global. See [transposition-table](transposition-table.md). - **TT configuration is `SolverConfig` + optional env overrides.** `SolverConfig` - carries `tt_kind_` (`TTKind::{Small,Large}`, default `Large`) and default/max MB. + carries `tt_kind_` (`TTKind::{Small,Large,Pattern}`, default `Pattern`) and default/max MB. `configure_tt(kind, defMB, maxMB)` persists a new config and applies it to an existing TT (resize in place, or recreate if the kind changes). Env overrides when > 0: `DDS_TT_DEFAULT_MB` **replaces** the configured default MB; `DDS_TT_LIMIT_MB` caps the maximum. diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 9e4a07ef8..67ca3759c 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -1,14 +1,14 @@ --- capability: transposition-table owners: [trans_table] -last-updated: 2026-07-18 +last-updated: 2026-09-08 --- # Transposition Table > **Specs vs. doxygen.** The `TransTable` interface, `NodeCards` layout, and each > method's contract are documented inline in `trans_table.hpp`. This spec records -> the capability-wide facts: the two implementations, the memory/reset model, and +> the capability-wide facts: the three implementations, the memory/reset model, and > how the table relates to the owning context. ## Purpose @@ -18,18 +18,31 @@ and move-ordering hints for a position) so the alpha-beta search avoids re-solving positions it has already seen. It is the single biggest memory consumer in a solve and the main reason reusing a [solver-context](solver-context.md) across solves is worthwhile. This capability provides the abstract table interface and -its two concrete strategies, trading memory against speed. +its three concrete strategies, trading memory against speed. ## Behaviour & invariants > Per-method signatures live in the header doxygen; these are the whole-table > guarantees. -- **One interface, two implementations.** `TransTable` is an abstract base; - `TransTableL` (large) is the full-featured, faster, paged-memory table with - harvesting, and `TransTableS` (small) is the pool-based, lower-memory, somewhat - slower table. Which one a context uses is chosen by `TTKind::{Large,Small}` in - `SolverConfig` (default `Large`) — see [solver-context](solver-context.md). +- **One interface, three implementations.** `TransTable` is an abstract base. + `TransTableP` (pattern, the default) keys a position by its suit-length shape + and stores, under each shape, the *relative-rank patterns* that decided the + result (the cards at or above the lowest winning rank per suit, by owner), an + approach taken from macroxue's bridge-solver. A shape holds any number of + patterns, ordered most general first and bucketed by the owner of the first + relevant suit's top card, so a lookup scans only the buckets it can match. + `TransTableL` (large) is the paged-memory table with harvesting and a fixed + per-shape entry capacity; `TransTableS` (small) is the pool-based, lower-memory, + somewhat slower table. Which one a context uses is chosen by + `TTKind::{Pattern,Large,Small}` in `SolverConfig` (default `Pattern`) — see + [solver-context](solver-context.md). The env var `DDS_TT_KIND` + (`small|large|pattern`) overrides the configured kind. +- **Pattern vs. Large.** On random deals the two are at parity; on void-heavy + ("freak") deals and under tight memory limits Pattern is markedly faster, + because Large's fixed per-shape blocks overflow and its lookups degrade to + long linear scans, while Pattern's unbounded per-shape lists and generic-first + ordering keep lookups short. Both produce identical results. - **Not thread-safe.** A table instance must be accessed from a single solver thread. Concurrency comes from one table per context/worker, never a shared table under a lock. @@ -41,6 +54,11 @@ its two concrete strategies, trading memory against speed. `set_memory_default` is a soft limit (may briefly exceed, triggering cleanup/harvesting) and `set_memory_maximum` is a hard cap. On `TransTableS`, `set_memory_default` is a **no-op**; only `set_memory_maximum` is enforced. + `TransTableP` likewise enforces only the maximum (the default merely floors + it): it grows on demand and, when the next allocation would exceed the + maximum, clears the whole table (`ResetReason::MemoryExhausted`) and refills + rather than harvesting. Freed blocks are pooled per size class, so a reset + does not return memory to the allocator until `return_all_memory()`. The header documents `0` as "unlimited" for the default limit, but `TransTableL` does not implement it that way — `set_memory_default(0)` yields `pages_default_ == 0`, and the next `reset_memory` then frees *every* pooled @@ -78,6 +96,7 @@ its two concrete strategies, trading memory against speed. - `library/src/trans_table/trans_table.hpp` — `TransTable` abstract interface, `NodeCards`, `ResetReason`. Doxygen documents every method. +- `library/src/trans_table/trans_table_p.{hpp,cpp}` — `TransTableP` (pattern, default). - `library/src/trans_table/trans_table_l.{hpp,cpp}` — `TransTableL` (large/paged). - `library/src/trans_table/trans_table_s.{hpp,cpp}` — `TransTableS` (small/pool). - Build targets: `//library/src/trans_table:{trans_table,testable_trans_table}`. From d69ca37ed1ec7b7ec21111a3ab4d87acc4e52607 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 9 Sep 2026 22:21:45 +0200 Subject: [PATCH 02/12] Spell out PatternTree's cache-line padding to silence MSVC C4324. alignas(CacheLine) rounded the 76-byte header up to 128 bytes implicitly, which MSVC reports as C4324 and /WX turns into an error. Make the padding an explicit member and static_assert the resulting layout, so the block header is identical on every compiler. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.hpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index 0845e1a7e..d3059fd3a 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -140,11 +140,20 @@ class TransTableP : public TransTable /// the nodes, bucket by bucket. The header is padded to whole cache /// lines so that the nodes are line-aligned. Nodes are trivially /// copyable, so the block is managed with plain memory moves. + /// + /// The padding is spelled out rather than left to `alignas` so that the + /// layout is identical on every compiler (and MSVC's C4324 stays quiet). + static constexpr std::size_t TreeHeaderBytes = 2 * sizeof(std::uint32_t) + + BucketCount * sizeof(std::uint32_t); + static constexpr std::size_t TreePaddingBytes = + (CacheLine - TreeHeaderBytes % CacheLine) % CacheLine; + struct alignas(CacheLine) PatternTree { std::uint32_t size; std::uint32_t capacity; std::uint32_t bucket_end[BucketCount]; ///< End offset of each bucket. + std::uint8_t padding[TreePaddingBytes]; ///< Rounds the header up to whole lines. auto nodes() -> PatternNode* { return reinterpret_cast(this + 1); } auto nodes() const -> const PatternNode* @@ -163,6 +172,11 @@ class TransTableP : public TransTable } auto insert(std::size_t at, const PatternNode& node) -> void; }; + static_assert(sizeof(PatternTree) % CacheLine == 0 && + sizeof(PatternTree) == TreeHeaderBytes + TreePaddingBytes, + "PatternTree header must fill whole cache lines with no implicit padding"); + static_assert(sizeof(PatternNode) == 32 && CacheLine % sizeof(PatternNode) == 0, + "two PatternNodes must fit exactly in a cache line"); struct ShapeSlot { From 3e508acf40cbb9c26adc395867a8a59687465679 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 9 Sep 2026 22:36:38 +0200 Subject: [PATCH 03/12] Set environment variables portably in configure_tt_api_test. setenv/unsetenv do not exist on MSVC; use _putenv_s there, as args_test already does. Co-authored-by: Cursor --- .../tests/system/configure_tt_api_test.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 1d31b24a0..5af1f8869 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -15,15 +15,28 @@ namespace { +/// Sets an environment variable portably; a null or empty value removes it. +void set_env_var(const char* name, const char* value) +{ +#ifdef _WIN32 + _putenv_s(name, value != nullptr ? value : ""); +#else + if (value == nullptr || value[0] == '\0') + unsetenv(name); + else + setenv(name, value, 1); +#endif +} + struct ScopedEnv { ScopedEnv(const char* name, const char* value) : name_(name) { - setenv(name, value, 1); + set_env_var(name, value); } ~ScopedEnv() { - unsetenv(name_); + set_env_var(name_, nullptr); } const char* name_; }; @@ -38,7 +51,7 @@ auto kind_of(const TransTable* tt) -> TTKind TEST(ConfigureTtApiTest, DefaultConfigurationUsesThePatternTable) { // Arrange: no explicit kind anywhere (and no environment override). - unsetenv("DDS_TT_KIND"); + set_env_var("DDS_TT_KIND", nullptr); SolverConfig cfg; SolverContext configured(cfg); SolverContext bare; From 0e848b568dbb4798675a6844a59833eca38a7c1e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 04:32:53 +0200 Subject: [PATCH 04/12] Enforce a lowered TT maximum at once and tighten before reserving. set_memory_maximum() on a live TransTableP that is already above the new limit now clears the table immediately instead of leaving it over budget until the next block allocation; inserts into blocks with spare capacity never consult the budget, so without this a lowered hard cap could go unenforced indefinitely. add() now searches the bucket for an identical pattern before reserving capacity. Re-adding an existing pattern to a full block used to double the block, or at the memory limit clear the whole table, for an update that needed no allocation. Spec: distinguish ordinary resets, which pool the pattern blocks, from MemoryExhausted resets, which also free the pool. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 42 +++-- .../tests/trans_table/trans_table_p_test.cpp | 149 ++++++++++++++---- specs/transposition-table.md | 9 +- 3 files changed, 156 insertions(+), 44 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index ceea08f6d..2d34633dc 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -107,6 +107,12 @@ auto TransTableP::set_memory_default(const int megabytes) -> void auto TransTableP::set_memory_maximum(const int megabytes) -> void { maximum_bytes_ = static_cast(std::max(megabytes, 0)) * MiB; + // A hard cap applies at once: a live table already over the new limit + // is cleared now rather than the next time a block is allocated, since + // inserts into blocks with spare capacity never consult the budget. + if (maximum_bytes_ != 0 && !shapes_.empty() && dynamic_bytes() > maximum_bytes_) { + reset_memory(ResetReason::MemoryExhausted); + } } @@ -540,29 +546,35 @@ auto TransTableP::add( if (slot == NoSlot || slot >= shapes_.size() || shapes_[slot].key != key) { slot = find_or_insert_shape(key); } - if (!reserve_one_more(shapes_[slot])) { - return; // the table was just reset; drop this entry - } - PatternTree& tree = *shapes_[slot].tree; // Within its bucket the pattern goes before the first one with more // relevant cards; an identical pattern can only sit among those with - // exactly as many. + // exactly as many. An existing pattern is tightened in place, which + // needs no capacity, so the search precedes any reservation. const int bucket = bucket_of(pattern); const std::uint32_t weight = weight_of(pattern); - std::size_t at = tree.bucket_begin(bucket); - const std::size_t end = tree.bucket_end[bucket]; - for (; at < end; ++at) { - const std::uint32_t stored = weight_of(tree[at].key); - if (stored > weight) { - break; - } - if (stored == weight && same_pattern(tree[at].key, pattern)) { - tighten(tree[at].cards, cards, flag); - return; + std::size_t at = 0; + if (PatternTree* existing = shapes_[slot].tree) { + at = existing->bucket_begin(bucket); + const std::size_t end = existing->bucket_end[bucket]; + for (; at < end; ++at) { + PatternNode& stored = (*existing)[at]; + const std::uint32_t stored_weight = weight_of(stored.key); + if (stored_weight > weight) { + break; + } + if (stored_weight == weight && same_pattern(stored.key, pattern)) { + tighten(stored.cards, cards, flag); + return; + } } } + // Growing a block copies the nodes in order, so `at` stays valid. + if (!reserve_one_more(shapes_[slot])) { + return; // the table was just reset; drop this entry + } + PatternTree& tree = *shapes_[slot].tree; tree.insert(at, PatternNode{pattern, cards}); for (int b = bucket; b < BucketCount; ++b) { ++tree.bucket_end[b]; diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index 088f3f939..44f982349 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -153,6 +153,27 @@ auto full_deal_position(const TestDeal& deal) -> TestPosition return TestPosition::remaining(deal, AllRanks, AllRanks, AllRanks, AllRanks); } +/// A random legal position of `deal`: `tricks_played` whole tricks of random +/// cards have been removed. +auto random_position(const TestDeal& deal, std::mt19937& rng, int tricks_played) -> TestPosition +{ + TestPosition pos; + for (int s = 0; s < DDS_SUITS; ++s) pos.aggr[s] = 0x1fff; + for (int t = 0; t < tricks_played; ++t) { + for (int h = 0; h < DDS_HANDS; ++h) { + std::vector> held; + for (int s = 0; s < DDS_SUITS; ++s) + for (int r = 2; r <= 14; ++r) + if ((pos.aggr[s] & (1u << (r - 2))) && deal.hand_lookup[s][r] == h) + held.emplace_back(s, r); + const auto [s, r] = held[std::uniform_int_distribution(0, held.size() - 1)(rng)]; + pos.aggr[s] = static_cast(pos.aggr[s] & ~(1u << (r - 2))); + } + } + pos.finish(deal); + return pos; +} + auto node(int lower, int upper, int best_suit = 0, int best_rank = 0) -> NodeCards { NodeCards cards{}; @@ -181,6 +202,29 @@ auto win(const std::string& spades, return w; } +/// Random winning ranks drawn from the cards still in play. +auto random_win_ranks(const TestPosition& pos, std::mt19937& rng) -> WinRanks +{ + WinRanks w; + for (int s = 0; s < DDS_SUITS; ++s) { + w.ranks[s] = static_cast( + pos.aggr[s] & std::uniform_int_distribution(0, 0x1fff)(rng)); + } + return w; +} + +/// One lookup-then-add of a random position, the way the search does it. +void add_random_entry(TransTableP& tt, const TestDeal& deal, std::mt19937& rng, int i) +{ + const auto pos = random_position(deal, rng, 1 + (i % 6)); + const auto w = random_win_ranks(pos, rng); + const int hand = std::uniform_int_distribution(0, 3)(rng); + const int lo = std::uniform_int_distribution(0, 13)(rng); + bool lower_flag = false; + (void)tt.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, -1, lower_flag); + tt.add(pos.tricks, hand, pos.aggr, w.ranks, node(lo, 13), true); +} + class TransTablePTest : public ::testing::Test { protected: @@ -701,39 +745,13 @@ TEST(TransTablePMemoryTest, StaysWithinTheMaximumAndResetsWhenExhausted) const auto deal = TestDeal::random(rng); tt.init(deal.hand_lookup); const double baseline_kb = tt.memory_in_use(); - std::uniform_int_distribution pick_hand(0, 3); - std::uniform_int_distribution pick_bound(0, 13); size_t max_nodes_seen = 0; bool shrank_at_some_point = false; // Act for (int i = 0; i < 200000; ++i) { - // Random legal position: play whole tricks of random cards. - TestPosition pos; - for (int s = 0; s < DDS_SUITS; ++s) pos.aggr[s] = 0x1fff; - const int tricks_played = 1 + (i % 6); - for (int t = 0; t < tricks_played; ++t) { - for (int h = 0; h < DDS_HANDS; ++h) { - std::vector> held; - for (int s = 0; s < DDS_SUITS; ++s) - for (int r = 2; r <= 14; ++r) - if ((pos.aggr[s] & (1u << (r - 2))) && deal.hand_lookup[s][r] == h) - held.emplace_back(s, r); - const auto [s, r] = held[std::uniform_int_distribution(0, held.size() - 1)(rng)]; - pos.aggr[s] = static_cast(pos.aggr[s] & ~(1u << (r - 2))); - } - } - pos.finish(deal); - WinRanks w; - for (int s = 0; s < DDS_SUITS; ++s) { - w.ranks[s] = static_cast(pos.aggr[s] & std::uniform_int_distribution(0, 0x1fff)(rng)); - } - const int lo = pick_bound(rng); - const int hand = pick_hand(rng); - bool lower_flag = false; - (void)tt.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, -1, lower_flag); const size_t before = tt.node_count(); - tt.add(pos.tricks, hand, pos.aggr, w.ranks, node(lo, 13), true); + add_random_entry(tt, deal, rng, i); if (tt.node_count() < before) shrank_at_some_point = true; max_nodes_seen = std::max(max_nodes_seen, tt.node_count()); ASSERT_LE(tt.memory_in_use(), baseline_kb + 2 * 1024.0 + 1.0); @@ -744,6 +762,83 @@ TEST(TransTablePMemoryTest, StaysWithinTheMaximumAndResetsWhenExhausted) EXPECT_GT(max_nodes_seen, 1000u); } +TEST(TransTablePMemoryTest, LoweringTheMaximumBelowCurrentUsageIsEnforcedImmediately) +{ + // Arrange: a roomy table filled well past the 1 MB it is about to be given. + TransTableP tt; + tt.set_memory_default(16); + tt.set_memory_maximum(32); + tt.make_tt(); + std::mt19937 rng(11); + const auto deal = TestDeal::random(rng); + tt.init(deal.hand_lookup); + const double baseline_kb = tt.memory_in_use(); + int i = 0; + while (tt.memory_in_use() < baseline_kb + 3 * 1024.0 && i < 400000) { + add_random_entry(tt, deal, rng, i++); + } + ASSERT_GT(tt.memory_in_use(), baseline_kb + 3 * 1024.0) << "could not fill the table"; + + // Act + tt.set_memory_maximum(1); + + // Assert: over-budget contents are reclaimed at once, and the new cap holds + // for later inserts, including those that fit into existing blocks. + EXPECT_LE(tt.memory_in_use(), baseline_kb + 1024.0 + 1.0); + for (int j = 0; j < 100000; ++j) { + add_random_entry(tt, deal, rng, j); + ASSERT_LE(tt.memory_in_use(), baseline_kb + 1024.0 + 1.0); + } +} + +TEST(TransTablePMemoryTest, LoweringTheMaximumWhileStillWithinItKeepsTheContents) +{ + // Arrange + TransTableP tt; + tt.set_memory_default(16); + tt.set_memory_maximum(32); + tt.make_tt(); + std::mt19937 rng(13); + const auto deal = TestDeal::random(rng); + tt.init(deal.hand_lookup); + for (int i = 0; i < 2000; ++i) add_random_entry(tt, deal, rng, i); + const size_t nodes_before = tt.node_count(); + ASSERT_GT(nodes_before, 0u); + ASSERT_LT(tt.memory_in_use(), 4 * 1024.0); + + // Act + tt.set_memory_maximum(4); + + // Assert + EXPECT_EQ(tt.node_count(), nodes_before); +} + +TEST_F(TransTablePTest, ReAddingAPatternToAFullTreeTightensInPlaceWithoutGrowingIt) +{ + // Arrange: exactly fill a fresh tree (InitialTreeNodes = 8) with distinct + // patterns of one shape. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + const char* spades[] = {"A", "AK", "AKQ", "AKQJ", "AKQJT", "AKQJT9", "AKQJT98", "AKQJT987"}; + for (const char* s : spades) store(pos, 0, win(s), node(7, 12)); + ASSERT_EQ(tt_.node_count(), 8u); + const double before_kb = tt_.memory_in_use(); + + // Act: re-add the first pattern with tighter bounds. + store(pos, 0, win("A"), node(8, 11)); + + // Assert: tightened in place; no block was grown (or the table reset). + EXPECT_EQ(tt_.node_count(), 8u); + EXPECT_EQ(tt_.memory_in_use(), before_kb); + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 7, lower_flag); + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->lower_bound, 8); + EXPECT_EQ(hit->upper_bound, 11); +} + // --------------------------------------------------------------------------- // Equivalence with TransTableL on small workloads // --------------------------------------------------------------------------- diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 67ca3759c..d91f4d9f7 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -57,8 +57,9 @@ its three concrete strategies, trading memory against speed. `TransTableP` likewise enforces only the maximum (the default merely floors it): it grows on demand and, when the next allocation would exceed the maximum, clears the whole table (`ResetReason::MemoryExhausted`) and refills - rather than harvesting. Freed blocks are pooled per size class, so a reset - does not return memory to the allocator until `return_all_memory()`. + rather than harvesting. The maximum is a hard cap that applies at once: + `set_memory_maximum` on a live table already above the new limit clears it + immediately rather than waiting for the next allocation. The header documents `0` as "unlimited" for the default limit, but `TransTableL` does not implement it that way — `set_memory_default(0)` yields `pages_default_ == 0`, and the next `reset_memory` then frees *every* pooled @@ -73,6 +74,10 @@ its three concrete strategies, trading memory against speed. structures for reuse; `return_all_memory()` deallocates everything and the table **must** be re-created with `make_tt()` before further use — `init()` does not reallocate. + `TransTableP` refines the "retains structures" rule by reason: an ordinary + reset returns its pattern blocks to a per-size-class pool for reuse, whereas a + `MemoryExhausted` reset (including the one triggered by lowering the maximum) + also frees the pooled blocks, since the table is by definition over budget. `ResetReason` (`TooManyNodes`, `NewDeal`, `NewTrump`, `MemoryExhausted`, `FreeMemory`, …) records *why* a reset happened, accumulating a per-reason histogram for diagnostics. `TransTableL` keeps its From a418a14ce272ee8502eea0883f9fb688922cc628 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:02:50 +0200 Subject: [PATCH 05/12] Free TT blocks directly on exhaustion and release everything on teardown. A MemoryExhausted reset used to push every active block into the spare pool, which may allocate, only to delete the pool immediately. It now deletes the blocks outright, so the over-budget recovery path never allocates. make_tt() uses the same path. return_all_memory() now also drops the ownership table and the pool vectors' capacity, so memory_in_use() is zero afterwards as the base contract requires; init() rebuilds the ownership table per deal anyway. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 40 +++++++++++++++--- library/src/trans_table/trans_table_p.hpp | 4 +- .../tests/trans_table/trans_table_p_test.cpp | 41 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 2d34633dc..092c61589 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -126,7 +126,10 @@ auto TransTableP::make_tt() -> void } maximum_bytes_ = std::max(maximum_bytes_, default_bytes_); - return_all_memory(); + // Start from an empty table; the ownership table, if init() has already + // built it, is deal-specific rather than size-specific and is kept. + delete_trees(); + free_spare_trees(); shapes_.assign(InitialShapes, ShapeSlot{}); } @@ -138,9 +141,13 @@ auto TransTableP::reset_memory(const ResetReason reason) -> void } ++reset_counts_[static_cast(reason)]; - release_trees(); if (reason == ResetReason::MemoryExhausted) { + // Over budget: give the blocks back outright. Pooling them first + // could itself allocate, which is the one thing this path must not do. + delete_trees(); free_spare_trees(); + } else { + release_trees(); } std::vector fresh(InitialShapes); shapes_.swap(fresh); @@ -149,9 +156,13 @@ auto TransTableP::reset_memory(const ResetReason reason) -> void auto TransTableP::return_all_memory() -> void { - release_trees(); + delete_trees(); free_spare_trees(); + for (auto& spares : spare_trees_) { + std::vector().swap(spares); + } std::vector().swap(shapes_); + std::vector().swap(ownership_); // init() rebuilds it per deal } @@ -353,12 +364,31 @@ auto TransTableP::release_trees() -> void } +auto TransTableP::delete_tree(PatternTree* tree) -> void +{ + tree_bytes_ -= PatternTree::bytes_for(tree->capacity); + ::operator delete(tree, std::align_val_t{CacheLine}); +} + + +auto TransTableP::delete_trees() -> void +{ + for (ShapeSlot& slot : shapes_) { + if (slot.tree) { + delete_tree(slot.tree); + slot.tree = nullptr; + } + } + shape_count_ = 0; + node_count_ = 0; +} + + auto TransTableP::free_spare_trees() -> void { for (auto& spares : spare_trees_) { for (PatternTree* tree : spares) { - tree_bytes_ -= PatternTree::bytes_for(tree->capacity); - ::operator delete(tree, std::align_val_t{CacheLine}); + delete_tree(tree); } spares.clear(); } diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index d3059fd3a..85a05624c 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -224,8 +224,10 @@ class TransTableP : public TransTable auto dynamic_bytes() const -> std::size_t; auto reserve_one_more(ShapeSlot& slot) -> bool; auto acquire_tree(std::size_t capacity) -> PatternTree*; - auto release_tree(PatternTree* tree) -> void; + auto release_tree(PatternTree* tree) -> void; ///< To the pool (may allocate). auto release_trees() -> void; + auto delete_tree(PatternTree* tree) -> void; ///< To the allocator (never allocates). + auto delete_trees() -> void; auto free_spare_trees() -> void; static auto size_class(std::size_t capacity) -> int; diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index 44f982349..c28389c6f 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -734,6 +734,47 @@ TEST_F(TransTablePTest, ReturnAllMemoryThenMakeTtStartsFresh) EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); } +TEST_F(TransTablePTest, ReturnAllMemoryLeavesNothingAllocated) +{ + // Arrange: a table with patterns, pooled blocks and the ownership table. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + const char* spades[] = {"A", "AK", "AKQ", "AKQJ", "AKQJT", "AKQJT9", "AKQJT98", "AKQJT987", "AKQJT9876"}; + for (const char* s : spades) store(pos, 0, win(s), node(7, 12)); // grows a block → one pooled + ASSERT_GT(tt_.memory_in_use(), 0.0); + + // Act + tt_.return_all_memory(); + + // Assert + EXPECT_EQ(tt_.memory_in_use(), 0.0); + EXPECT_EQ(tt_.node_count(), 0u); + EXPECT_EQ(tt_.shape_count(), 0u); +} + +TEST_F(TransTablePTest, MemoryExhaustedResetReturnsToTheEmptyTableFootprint) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const double empty_kb = tt_.memory_in_use(); + const auto pos = full_deal_position(deal); + const char* spades[] = {"A", "AK", "AKQ", "AKQJ", "AKQJT", "AKQJT9", "AKQJT98", "AKQJT987", "AKQJT9876"}; + for (const char* s : spades) store(pos, 0, win(s), node(7, 12)); + ASSERT_GT(tt_.memory_in_use(), empty_kb); + + // Act + tt_.reset_memory(ResetReason::MemoryExhausted); + + // Assert: no active and no pooled blocks remain, only the empty shape table. + EXPECT_EQ(tt_.memory_in_use(), empty_kb); + EXPECT_EQ(tt_.node_count(), 0u); + store(pos, 0, win("A"), node(7, 12)); + bool lower_flag = false; + EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); +} + TEST(TransTablePMemoryTest, StaysWithinTheMaximumAndResetsWhenExhausted) { // Arrange: a tiny table and a stream of distinct positions/patterns. From 55af56c974301a6f54b8a5ff3aa4153497b8f350 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:10:06 +0200 Subject: [PATCH 06/12] Keep TT resets and rehashes within the hard cap; restore env in tests. reset_memory() now frees the old shape table before allocating the fresh one, and grow_shapes() budgets the peak of old and new tables together, so neither path allocates beyond the configured maximum. configure_tt_api_test's ScopedEnv restores the previous value of the variable (or its absence) instead of always unsetting it, and the default-kind test uses it rather than unsetting DDS_TT_KIND for the rest of the process. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 9 +++-- .../tests/system/configure_tt_api_test.cpp | 35 +++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 092c61589..7e9b2293f 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -149,8 +149,10 @@ auto TransTableP::reset_memory(const ResetReason reason) -> void } else { release_trees(); } - std::vector fresh(InitialShapes); - shapes_.swap(fresh); + // Free the old shape table before allocating the fresh one, so that a + // reset never allocates on top of the storage it is about to drop. + std::vector().swap(shapes_); + shapes_.resize(InitialShapes); } @@ -437,7 +439,8 @@ auto TransTableP::find_shape(const std::uint64_t key) const -> std::size_t auto TransTableP::grow_shapes() -> void { const std::size_t new_size = shapes_.size() * 2; - if (new_size * sizeof(ShapeSlot) + tree_bytes_ > maximum_bytes_) { + // Old and new tables are both live during the rehash, so budget the peak. + if (dynamic_bytes() + new_size * sizeof(ShapeSlot) > maximum_bytes_) { reset_memory(ResetReason::MemoryExhausted); return; } diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 5af1f8869..17f1ee3f0 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -5,6 +5,7 @@ /// switching kinds, and lazy initialization of transposition tables. #include +#include #include @@ -28,17 +29,25 @@ void set_env_var(const char* name, const char* value) #endif } +/// Overrides (or, with a null value, removes) an environment variable for +/// the lifetime of the guard and then restores whatever was there before. struct ScopedEnv { ScopedEnv(const char* name, const char* value) : name_(name) { + if (const char* old = std::getenv(name)) { + had_old_ = true; + old_ = old; + } set_env_var(name, value); } ~ScopedEnv() { - set_env_var(name_, nullptr); + set_env_var(name_, had_old_ ? old_.c_str() : nullptr); } const char* name_; + bool had_old_ = false; + std::string old_; }; auto kind_of(const TransTable* tt) -> TTKind @@ -48,10 +57,32 @@ auto kind_of(const TransTable* tt) -> TTKind return TTKind::Large; } +TEST(ConfigureTtApiTest, ScopedEnvRestoresThePreviousValueAndAbsence) +{ + // Arrange + const char* name = "DDS_TEST_SCOPED_ENV"; + set_env_var(name, "before"); + + // Act & Assert: an override is undone, and so is a removal. + { + ScopedEnv overridden(name, "during"); + EXPECT_STREQ(std::getenv(name), "during"); + } + EXPECT_STREQ(std::getenv(name), "before"); + { + ScopedEnv removed(name, nullptr); + EXPECT_EQ(std::getenv(name), nullptr); + } + EXPECT_STREQ(std::getenv(name), "before"); + + set_env_var(name, nullptr); + EXPECT_EQ(std::getenv(name), nullptr); +} + TEST(ConfigureTtApiTest, DefaultConfigurationUsesThePatternTable) { // Arrange: no explicit kind anywhere (and no environment override). - set_env_var("DDS_TT_KIND", nullptr); + ScopedEnv no_override("DDS_TT_KIND", nullptr); SolverConfig cfg; SolverContext configured(cfg); SolverContext bare; From 4b6ed69dda4bc0156e9277dde6e6aed76f5fe5d9 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:17:52 +0200 Subject: [PATCH 07/12] Make TransTableP non-copyable and leak-free when pooling throws. The table owns raw pattern blocks, so the implicit copy operations would alias and later double-free them; delete them. reserve_one_more() now attaches the grown block to its slot before pooling the old one, and deletes the old block if pooling throws, so an allocation failure at that point leaks nothing and keeps the byte accounting exact. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 11 +++++++++-- library/src/trans_table/trans_table_p.hpp | 4 ++++ library/tests/trans_table/trans_table_p_test.cpp | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 7e9b2293f..ffd7173a1 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -414,9 +414,16 @@ auto TransTableP::reserve_one_more(ShapeSlot& slot) -> bool if (old) { std::memcpy(fresh, old, PatternTree::bytes_for(old->size)); fresh->capacity = static_cast(wanted); - release_tree(old); } - slot.tree = fresh; + slot.tree = fresh; // committed: the slot owns the new block from here + if (old) { + try { + release_tree(old); // pooling may allocate and so may throw + } catch (...) { + delete_tree(old); + throw; + } + } return true; } diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index 85a05624c..d026b2006 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -46,6 +46,10 @@ class TransTableP : public TransTable TransTableP(); ~TransTableP() override; + /// Owns raw pattern blocks; copying would alias and then double-free them. + TransTableP(const TransTableP&) = delete; + auto operator=(const TransTableP&) -> TransTableP& = delete; + auto init(const int hand_lookup[][15]) -> void override; auto set_memory_default(int megabytes) -> void override; auto set_memory_maximum(int megabytes) -> void override; diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index c28389c6f..a82342831 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -695,6 +696,11 @@ TEST_F(TransTablePTest, PatternsWhoseFirstRelevantSuitDiffersAreAllFound) // Memory management and lifecycle // --------------------------------------------------------------------------- +// The table owns raw pattern blocks; an implicit copy would alias and then +// double-free them. +static_assert(!std::is_copy_constructible_v, "TransTableP must not be copyable"); +static_assert(!std::is_copy_assignable_v, "TransTableP must not be copy-assignable"); + TEST_F(TransTablePTest, ResetMemoryForgetsEverythingButKeepsTheTableUsable) { // Arrange From 0338f368c760224764e20d73cb6851e5529bdee6 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:27:10 +0200 Subject: [PATCH 08/12] Budget the TT pool's pointer storage and fix the equal-weight ordering note. dynamic_bytes() now includes the capacity of the spare-block pointer vectors, and free_spare_trees() (over-budget and teardown paths) releases that capacity as well, so nothing the table retains escapes the hard cap. Patterns of equal weight are scanned oldest first, as the insertion code has always done; the file comment claimed newest first. A test now pins the actual order. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 15 +++++--- .../tests/trans_table/trans_table_p_test.cpp | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index ffd7173a1..6b8404ffd 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -22,7 +22,7 @@ The patterns of a shape live in one contiguous array, grouped into buckets by the owner of the top card of the pattern's first relevant suit, and - within a bucket ordered by generality (fewest relevant cards first, newest + within a bucket ordered by generality (fewest relevant cards first, oldest first among equals): general patterns match the most positions, so trying them first gives the earliest cut-offs. A lookup scans, with a fixed stride, only the buckets its own top cards allow. @@ -160,9 +160,6 @@ auto TransTableP::return_all_memory() -> void { delete_trees(); free_spare_trees(); - for (auto& spares : spare_trees_) { - std::vector().swap(spares); - } std::vector().swap(shapes_); std::vector().swap(ownership_); // init() rebuilds it per deal } @@ -170,7 +167,11 @@ auto TransTableP::return_all_memory() -> void auto TransTableP::dynamic_bytes() const -> std::size_t { - return tree_bytes_ + shapes_.capacity() * sizeof(ShapeSlot); + std::size_t pool_bytes = 0; + for (const auto& spares : spare_trees_) { + pool_bytes += spares.capacity() * sizeof(PatternTree*); + } + return tree_bytes_ + pool_bytes + shapes_.capacity() * sizeof(ShapeSlot); } @@ -388,11 +389,13 @@ auto TransTableP::delete_trees() -> void auto TransTableP::free_spare_trees() -> void { + // Used only on over-budget and teardown paths, so the pointer storage + // goes too; it counts against the budget like everything else. for (auto& spares : spare_trees_) { for (PatternTree* tree : spares) { delete_tree(tree); } - spares.clear(); + std::vector().swap(spares); } } diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index a82342831..366d794cb 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -643,6 +643,27 @@ TEST_F(TransTablePTest, IncomparableMatchingPatternsAreTriedMostGenericFirst) EXPECT_EQ(hit->least_win[1], 0); } +TEST_F(TransTablePTest, AmongEquallyGenericPatternsTheOlderIsTriedFirst) +{ + // Arrange: two incomparable patterns of equal weight in the same bucket + // (same first relevant suit and top-card owner), both matching `pos`. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A", "A"), node(7, 12)); // older + store(pos, 0, win("AK"), node(8, 12)); // newer + ASSERT_EQ(tt_.node_count(), 2u); + + // Act: both cut at this limit; the first one scanned is returned. + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 6, lower_flag); + + // Assert + ASSERT_NE(hit, nullptr); + EXPECT_TRUE(lower_flag); + EXPECT_EQ(hit->lower_bound, 7); +} + TEST_F(TransTablePTest, PatternsWhoseFirstRelevantSuitDiffersAreAllFound) { // Arrange: one pattern per suit, each relevant only in that suit, plus a @@ -740,6 +761,23 @@ TEST_F(TransTablePTest, ReturnAllMemoryThenMakeTtStartsFresh) EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); } +TEST_F(TransTablePTest, PooledBlockPointerStorageCountsTowardsMemoryInUse) +{ + // Arrange: one shape with a full block and nothing pooled yet. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + const char* spades[] = {"A", "AK", "AKQ", "AKQJ", "AKQJT", "AKQJT9", "AKQJT98", "AKQJT987"}; + for (const char* s : spades) store(pos, 0, win(s), node(7, 12)); + const double before_kb = tt_.memory_in_use(); + + // Act: an ordinary reset pools the block; the shape table keeps its size. + tt_.reset_memory(ResetReason::NewDeal); + + // Assert: the pool's pointer storage is part of the footprint. + EXPECT_GT(tt_.memory_in_use(), before_kb); +} + TEST_F(TransTablePTest, ReturnAllMemoryLeavesNothingAllocated) { // Arrange: a table with patterns, pooled blocks and the ownership table. From 2dec1fe47a99727551bed42f54be5971b51fa80c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:37:50 +0200 Subject: [PATCH 09/12] Budget the TT pool's pointer growth before pooling a block. release_tree() now checks, when the pointer vector would have to grow, that the growth fits under the hard cap; otherwise the block is returned to the allocator instead of pooled. With the growth reserved up front, push_back cannot throw. A strict-cap test asserts memory_in_use() <= maximum after every add on a deep, block-heavy workload with no slack for pool bookkeeping. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 15 +++++++++- .../tests/trans_table/trans_table_p_test.cpp | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 6b8404ffd..f682d3587 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -350,7 +350,20 @@ auto TransTableP::acquire_tree(const std::size_t capacity) -> PatternTree* auto TransTableP::release_tree(PatternTree* tree) -> void { - spare_trees_[size_class(tree->capacity)].push_back(tree); + auto& spares = spare_trees_[size_class(tree->capacity)]; + if (spares.size() == spares.capacity()) { + // The pool's pointer storage counts against the budget too. If + // growing it would breach the cap, the block goes back to the + // allocator instead of the pool. + const std::size_t grown = std::max(4, 2 * spares.capacity()); + const std::size_t growth = (grown - spares.capacity()) * sizeof(PatternTree*); + if (dynamic_bytes() + growth > maximum_bytes_) { + delete_tree(tree); + return; + } + spares.reserve(grown); + } + spares.push_back(tree); } diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index 366d794cb..bfd1efa3a 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -876,6 +877,34 @@ TEST(TransTablePMemoryTest, LoweringTheMaximumBelowCurrentUsageIsEnforcedImmedia } } +TEST(TransTablePMemoryTest, PoolingOutgrownBlocksNeverExceedsTheMaximum) +{ + // Arrange: a tiny cap and deep positions, so that many shapes hold small + // blocks that keep outgrowing (and pooling) their storage near the cap. + TransTableP tt; + tt.set_memory_default(1); + tt.set_memory_maximum(2); + tt.make_tt(); + std::mt19937 rng(17); + const auto deal = TestDeal::random(rng); + tt.init(deal.hand_lookup); + const double cap_kb = tt.memory_in_use() + 2 * 1024.0; + size_t max_shapes = 0; + + // Act & Assert: the hard cap holds after every single add, with no slack + // for the pool's own bookkeeping. + for (int i = 0; i < 300000; ++i) { + const auto pos = random_position(deal, rng, 1 + (i % 12)); + const auto w = random_win_ranks(pos, rng); + bool lower_flag = false; + (void)tt.lookup(pos.tricks, 0, pos.aggr, pos.hand_dist, -1, lower_flag); + tt.add(pos.tricks, 0, pos.aggr, w.ranks, node(0, 13), true); + max_shapes = std::max(max_shapes, tt.shape_count()); + ASSERT_LE(tt.memory_in_use(), cap_kb) << "after add " << i; + } + EXPECT_GT(max_shapes, 4000u) << "not enough blocks to make pooling costly"; +} + TEST(TransTablePMemoryTest, LoweringTheMaximumWhileStillWithinItKeepsTheContents) { // Arrange From b640aea353be5bd6a7f34a7f473290facfa8d924 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 05:45:42 +0200 Subject: [PATCH 10/12] Compare configure_tt against the effective TT kind; document TransTableP. configure_tt() now resolves the DDS_TT_KIND override before deciding whether to recreate the table, as creation does, so a request that leaves the effective kind unchanged resizes in place and a changed override recreates even when the configured kind is the same. The TransTable base doxygen now lists all three implementations and their memory strategies. Co-authored-by: Cursor --- library/src/solver_context/solver_context.cpp | 5 ++-- library/src/trans_table/trans_table.hpp | 21 ++++++++----- .../tests/system/configure_tt_api_test.cpp | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index ca58d6760..1c42ee823 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -286,8 +286,9 @@ auto SolverContext::configure_tt(TTKind kind, int defMB, int maxMB) -> void auto* tt = search_.maybe_trans_table(); if (!tt) return; // Nothing to apply now; will take effect on lazy creation. - // If kind changes, dispose and recreate now to ensure effect is applied. - if (tt_kind_of(tt) != kind) { + // If the effective kind (environment override included, as at creation) + // changes, dispose and recreate now to ensure effect is applied. + if (tt_kind_of(tt) != tt_kind_from_environment(kind)) { dispose_trans_table(); // Force immediate creation with new config to keep behavior explicit. (void)trans_table(); diff --git a/library/src/trans_table/trans_table.hpp b/library/src/trans_table/trans_table.hpp index 88e64c119..bfcaf57b8 100644 --- a/library/src/trans_table/trans_table.hpp +++ b/library/src/trans_table/trans_table.hpp @@ -8,9 +8,11 @@ */ /* - This is the parent class of TransTableS and TransTableL. - Those two are different implementations. The S version has a - much smaller memory and a somewhat slower execution time. + This is the parent class of TransTableP, TransTableL and TransTableS. + They are different implementations of the same interface: P (the + default) stores shape-keyed relative-rank patterns, L is the paged + table with harvesting, and S has a much smaller memory footprint and a + somewhat slower execution time. */ #pragma once @@ -78,14 +80,17 @@ struct NodeCards // 8 bytes /// /// TransTable defines the interface for managing cached positions during /// double dummy analysis. The transposition table stores previously computed -/// results to avoid redundant search work. Two implementations are provided: -/// - TransTableS: Memory-efficient small transposition table +/// results to avoid redundant search work. Three implementations are provided: +/// - TransTableP: Shape-keyed relative-rank patterns (the default) /// - TransTableL: Full-featured large transposition table with paging +/// - TransTableS: Memory-efficient small transposition table /// /// \par Memory Management Strategy -/// Implementations use different memory strategies. TransTableS uses a pool-based -/// approach with malloc/calloc, while TransTableL uses paged memory with -/// harvesting. Both support configurable memory limits and graceful degradation. +/// Implementations use different memory strategies. TransTableP grows on +/// demand and clears itself when the next allocation would exceed the maximum, +/// TransTableL uses paged memory with harvesting, and TransTableS uses a +/// pool-based approach with malloc/calloc. All support configurable memory +/// limits and graceful degradation. /// /// \par Thread Safety /// Not thread-safe. The transposition table must be accessed from a single diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 17f1ee3f0..c3af918bc 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -146,6 +146,36 @@ TEST(ConfigureTtApiTest, EnvironmentOverridesTableKind) EXPECT_NE(nullptr, dynamic_cast(ctx.trans_table())); } +TEST(ConfigureTtApiTest, ConfigureTtComparesTheEnvironmentResolvedKind) +{ + // Arrange: the environment pins the effective kind to Pattern. + ScopedEnv env("DDS_TT_KIND", "pattern"); + SolverContext ctx; + auto* before = ctx.trans_table(); + ASSERT_NE(nullptr, dynamic_cast(before)); + // Give the live instance state a recreated one would not have (pointer + // equality alone is unreliable: a recreated object may reuse the address). + const int hand_lookup[DDS_SUITS][15] = {}; + before->init(hand_lookup); + const double marked_kb = before->memory_in_use(); + + // Act: asking for Small changes nothing effective, so the instance must + // survive (resized in place) rather than be destroyed and recreated. + ctx.configure_tt(TTKind::Small, /*defMB=*/8, /*maxMB=*/8); + + // Assert + ASSERT_NE(nullptr, ctx.maybe_trans_table()); + EXPECT_EQ(ctx.maybe_trans_table()->memory_in_use(), marked_kb); + + // Act: a new override that differs from the live table must recreate it, + // even though the configured kind (Small) has not changed. + ScopedEnv env2("DDS_TT_KIND", "small"); + ctx.configure_tt(TTKind::Small, /*defMB=*/8, /*maxMB=*/8); + + // Assert + EXPECT_NE(nullptr, dynamic_cast(ctx.maybe_trans_table())); +} + TEST(ConfigureTtApiTest, SwitchKindRecreatesTable) { // Default context (whatever kind that is, env overrides included). From eb567308131306f3e8b0e9b45331fb6013126605 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 07:11:09 +0200 Subject: [PATCH 11/12] Isolate explicit-kind TT tests from an ambient DDS_TT_KIND override. The tests that configure a specific kind and assert on it now clear the override for their duration (and restore it afterwards), so the suite passes with DDS_TT_KIND set to small, large or pattern. Override behaviour itself remains covered by the environment tests. Co-authored-by: Cursor --- library/tests/system/configure_tt_api_test.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index c3af918bc..e1bdc1cde 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -95,7 +95,8 @@ TEST(ConfigureTtApiTest, DefaultConfigurationUsesThePatternTable) TEST(ConfigureTtApiTest, PatternKindCreatesPatternTable) { - // Arrange + // Arrange: explicit kind, isolated from any ambient override. + ScopedEnv no_override("DDS_TT_KIND", nullptr); SolverConfig cfg; cfg.tt_kind_ = TTKind::Pattern; SolverContext ctx(cfg); @@ -110,7 +111,8 @@ TEST(ConfigureTtApiTest, PatternKindCreatesPatternTable) TEST(ConfigureTtApiTest, SwitchingToPatternRecreatesAndResizingKeepsInstance) { - // Arrange: start from the Large table. + // Arrange: start from the Large table, isolated from any ambient override. + ScopedEnv no_override("DDS_TT_KIND", nullptr); SolverConfig cfg; cfg.tt_kind_ = TTKind::Large; SolverContext ctx(cfg); @@ -178,7 +180,9 @@ TEST(ConfigureTtApiTest, ConfigureTtComparesTheEnvironmentResolvedKind) TEST(ConfigureTtApiTest, SwitchKindRecreatesTable) { - // Default context (whatever kind that is, env overrides included). + // Default context, isolated from any ambient override (override behaviour + // is covered by the Environment* tests). + ScopedEnv no_override("DDS_TT_KIND", nullptr); SolverContext ctx; auto* tt1 = ctx.trans_table(); ASSERT_NE(tt1, nullptr); From 58552a7d53473e578512d1f90c58ed6b222584f7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 10 Sep 2026 07:30:07 +0200 Subject: [PATCH 12/12] Trim the TransTableP memory tests so the suite fits the sanitizer budget. The random-fill loops ran for hundreds of thousands of positions each, which timed out under MemorySanitizer. Smaller counts still reach the cap, still trigger resets, and still produce thousands of blocks. Co-authored-by: Cursor --- library/tests/trans_table/trans_table_p_test.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index bfd1efa3a..c711d929c 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -835,7 +835,7 @@ TEST(TransTablePMemoryTest, StaysWithinTheMaximumAndResetsWhenExhausted) bool shrank_at_some_point = false; // Act - for (int i = 0; i < 200000; ++i) { + for (int i = 0; i < 50000; ++i) { const size_t before = tt.node_count(); add_random_entry(tt, deal, rng, i); if (tt.node_count() < before) shrank_at_some_point = true; @@ -871,7 +871,7 @@ TEST(TransTablePMemoryTest, LoweringTheMaximumBelowCurrentUsageIsEnforcedImmedia // Assert: over-budget contents are reclaimed at once, and the new cap holds // for later inserts, including those that fit into existing blocks. EXPECT_LE(tt.memory_in_use(), baseline_kb + 1024.0 + 1.0); - for (int j = 0; j < 100000; ++j) { + for (int j = 0; j < 10000; ++j) { add_random_entry(tt, deal, rng, j); ASSERT_LE(tt.memory_in_use(), baseline_kb + 1024.0 + 1.0); } @@ -893,7 +893,7 @@ TEST(TransTablePMemoryTest, PoolingOutgrownBlocksNeverExceedsTheMaximum) // Act & Assert: the hard cap holds after every single add, with no slack // for the pool's own bookkeeping. - for (int i = 0; i < 300000; ++i) { + for (int i = 0; i < 40000; ++i) { const auto pos = random_position(deal, rng, 1 + (i % 12)); const auto w = random_win_ranks(pos, rng); bool lower_flag = false;