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..1c42ee823 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; @@ -250,10 +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. - bool is_small = (dynamic_cast(tt) != nullptr); - TTKind current_kind = is_small ? TTKind::Small : TTKind::Large; - if (current_kind != 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/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.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/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp new file mode 100644 index 000000000..f682d3587 --- /dev/null +++ b/library/src/trans_table/trans_table_p.cpp @@ -0,0 +1,761 @@ +/* + 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, 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. + + 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; + // 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); + } +} + + +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_); + + // 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{}); +} + + +auto TransTableP::reset_memory(const ResetReason reason) -> void +{ + if (shapes_.empty()) { + return; + } + ++reset_counts_[static_cast(reason)]; + + 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(); + } + // 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); +} + + +auto TransTableP::return_all_memory() -> void +{ + delete_trees(); + free_spare_trees(); + std::vector().swap(shapes_); + std::vector().swap(ownership_); // init() rebuilds it per deal +} + + +auto TransTableP::dynamic_bytes() const -> std::size_t +{ + 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); +} + + +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 +{ + 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); +} + + +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::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 +{ + // 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); + } + std::vector().swap(spares); + } +} + + +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); + } + 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; +} + + +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; + // 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; + } + 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); + } + + // 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. 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 = 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]; + } + ++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..d026b2006 --- /dev/null +++ b/library/src/trans_table/trans_table_p.hpp @@ -0,0 +1,251 @@ +/* + 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; + + /// 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; + 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. + /// + /// 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* + { + 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; + }; + 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 + { + 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; ///< 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; + + 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..e1bdc1cde 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -4,33 +4,196 @@ /// Validates SolverContext configure_tt() behavior for resizing, /// switching kinds, and lazy initialization of transposition tables. +#include +#include + #include #include -#include #include +#include +#include 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 +} + +/// 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_, had_old_ ? old_.c_str() : nullptr); + } + const char* name_; + bool had_old_ = false; + std::string old_; +}; + +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, 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). + ScopedEnv no_override("DDS_TT_KIND", nullptr); + 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: explicit kind, isolated from any ambient override. + ScopedEnv no_override("DDS_TT_KIND", nullptr); + 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, isolated from any ambient override. + ScopedEnv no_override("DDS_TT_KIND", nullptr); + 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, 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: Large TT by default (unless env overrides) + // 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); - // 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 +201,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..c711d929c --- /dev/null +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -0,0 +1,1060 @@ +/// @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 +#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); +} + +/// 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{}; + 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; +} + +/// 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: + 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, 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 + // 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 +// --------------------------------------------------------------------------- + +// 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 + 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_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. + 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. + 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(); + size_t max_nodes_seen = 0; + bool shrank_at_some_point = false; + + // Act + 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; + 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); +} + +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 < 10000; ++j) { + add_random_entry(tt, deal, rng, j); + ASSERT_LE(tt.memory_in_use(), baseline_kb + 1024.0 + 1.0); + } +} + +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 < 40000; ++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 + 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 +// --------------------------------------------------------------------------- + +/// 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..d91f4d9f7 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,12 @@ 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. 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 @@ -55,6 +74,10 @@ its two 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 @@ -78,6 +101,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}`.