From 17c2804c06426a449dbdaf2033a23b636cca0211 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Tue, 8 Sep 2026 17:20:17 +0200 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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; From cd39294fb9c98b89767d7abed070e1e198506d2b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 11 Sep 2026 20:24:16 +0200 Subject: [PATCH 13/26] Indent the new solver_context and configure_tt_api_test code with 4 spaces. Whitespace only: the new anonymous-namespace helpers and tests used the surrounding files' legacy 2-space width; the style guide asks for 4. Co-authored-by: Cursor --- library/src/solver_context/solver_context.cpp | 44 ++-- .../tests/system/configure_tt_api_test.cpp | 236 +++++++++--------- 2 files changed, 140 insertions(+), 140 deletions(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 1c42ee823..b4db13d7a 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -22,40 +22,40 @@ 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; + 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; + 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'; + 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(); + 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) || \ diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index e1bdc1cde..0ec28bb49 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -20,12 +20,12 @@ namespace { void set_env_var(const char* name, const char* value) { #ifdef _WIN32 - _putenv_s(name, value != nullptr ? value : ""); + _putenv_s(name, value != nullptr ? value : ""); #else - if (value == nullptr || value[0] == '\0') - unsetenv(name); - else - setenv(name, value, 1); + if (value == nullptr || value[0] == '\0') + unsetenv(name); + else + setenv(name, value, 1); #endif } @@ -33,149 +33,149 @@ void set_env_var(const char* name, const char* value) /// 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; + 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); } - 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_; + ~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; + 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"); + // 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); + 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())); + // 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)); + // 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())); + // 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())); + // 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())); + // 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) From bb287b37e1608c4266fb5766f8705d4f4ee5c145 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 08:35:28 +0200 Subject: [PATCH 14/26] Make a TransTableP without a deal inert, and document least_win as a count. return_all_memory() releases the deal-specific ownership table and make_tt() cannot rebuild it, so add() after `return_all_memory(); make_tt();` indexed an empty vector. add() now ignores stores until init() has run again (lookups already miss on an empty table), and the header and spec state the contract. The production context runs init() per deal, so this only affects standalone use. NodeCards::least_win was documented as an absolute-rank encoding. Both TransTableL (15 - tt_lowest_rank_table[ag] == popcount(ag)) and TransTableP store the number of cards at or above the lowest winning rank, which is what ab_search's win_ranks[aggr][least_win] expects; the dump routines' 15 - least_win is the relative rank. A test now compares the two tables' least_win for the same stores, sparse winners included. Co-authored-by: Cursor --- library/src/trans_table/trans_table.hpp | 6 +- library/src/trans_table/trans_table_p.cpp | 5 +- library/src/trans_table/trans_table_p.hpp | 9 ++ .../tests/trans_table/trans_table_p_test.cpp | 84 +++++++++++++++++++ specs/transposition-table.md | 8 +- 5 files changed, 109 insertions(+), 3 deletions(-) diff --git a/library/src/trans_table/trans_table.hpp b/library/src/trans_table/trans_table.hpp index bfcaf57b8..eae55d58b 100644 --- a/library/src/trans_table/trans_table.hpp +++ b/library/src/trans_table/trans_table.hpp @@ -57,7 +57,11 @@ struct NodeCards // 8 bytes char lower_bound; ///< Minimum tricks for side to move at this node (0-13) char best_move_suit; ///< Optimal suit index (0=S, 1=H, 2=D, 3=C; matches card_suit) char best_move_rank; ///< Absolute rank (2-14 for 2-A), 0 used as sentinel - char least_win[DDS_SUITS]; ///< Encoded lowest winning rank (0-13), used as 15 - least_win + char least_win[DDS_SUITS]; ///< Per suit, the number (0-13) of remaining cards at or + ///< above the lowest winning rank; ab_search feeds it to + ///< win_ranks[aggr][least_win] to recover those cards. Not + ///< an absolute rank: 15 - least_win is the *relative* rank + ///< of the lowest such card, as the dump routines print it. }; #ifdef _MSC_VER diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index f682d3587..d6f1b6833 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -580,7 +580,10 @@ auto TransTableP::add( const NodeCards& first, const bool flag) -> void { - if (shapes_.empty() || trick < 0 || trick >= MaxTricks) { + // Without a deal (no init() since make_tt()/return_all_memory()) there is + // no ownership table to build patterns from; the table stays empty, which + // also keeps lookup() off position_set(). + if (shapes_.empty() || ownership_.empty() || trick < 0 || trick >= MaxTricks) { return; } const std::uint64_t key = last_key_[trick][hand]; diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index d026b2006..a4be14abd 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -38,6 +38,15 @@ /// whole table is cleared (\ref ResetReason::MemoryExhausted) and filling /// resumes. /// +/// \par Lifecycle +/// `make_tt()` creates an empty table; `init(hand_lookup)` then builds the +/// deal-specific card-ownership table, which patterns are derived from. +/// `return_all_memory()` releases both, so after `return_all_memory(); +/// make_tt();` a further `init()` is required before anything can be stored; +/// until then the table is inert (lookups miss, adds are ignored). The +/// production context calls `init()` for every deal, so this only matters to +/// standalone users. +/// /// \par Thread Safety /// Not thread-safe. Must be accessed from a single thread. class TransTableP : public TransTable diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index c711d929c..21dc7361a 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -762,6 +762,31 @@ TEST_F(TransTablePTest, ReturnAllMemoryThenMakeTtStartsFresh) EXPECT_NE(lookup(pos, 0, 6, lower_flag), nullptr); } +TEST_F(TransTablePTest, TableWithoutADealIsInertUntilInit) +{ + // Arrange: return_all_memory() drops the deal-specific ownership table, + // and make_tt() cannot rebuild it. Until init() runs again the table must + // neither crash nor store anything. + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + store(pos, 0, win("A"), node(7, 12)); + tt_.return_all_memory(); + tt_.make_tt(); + + // Act + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 6, lower_flag); + store(pos, 0, win("A"), node(7, 12)); + + // Assert: inert without a deal, fully usable once init() has run. + EXPECT_EQ(hit, nullptr); + EXPECT_EQ(tt_.node_count(), 0u); + init(deal); + 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. @@ -960,6 +985,65 @@ TEST_F(TransTablePTest, ReAddingAPatternToAFullTreeTightensInPlaceWithoutGrowing /// 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. +/// Both tables must hand ab_search the same `least_win`: the number of cards +/// at or above the lowest winning rank (what `win_ranks[aggr][least_win]` +/// expects), not an absolute rank. Sparse winners (e.g. A and 9 remaining, +/// 9 the lowest winner) are where a rank encoding and a count would differ. +TEST(TransTablePEquivalenceTest, LeastWinMatchesTransTableLForTheSameStore) +{ + std::mt19937 rng(5); + 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 compared = 0; + for (int i = 0; i < 300; ++i) { + // Arrange: one position, one pattern, stored in both tables. The + // winning rank of each suit is a random remaining card, so the + // relevant cards are usually a sparse subset of the suit. + const auto pos = random_position(deal, rng, 1 + (i % 3)); + WinRanks w; + for (int s = 0; s < DDS_SUITS; ++s) { + if (pos.aggr[s] == 0 || (i + s) % 2 == 0) continue; + std::vector bits; + for (int b = 0; b < 13; ++b) + if (pos.aggr[s] & (1u << b)) bits.push_back(b); + const int b = bits[std::uniform_int_distribution(0, bits.size() - 1)(rng)]; + w.ranks[s] = static_cast(1u << b); + } + const int hand = i % DDS_HANDS; + bool lower = false; + if (large.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, 5, lower) != nullptr || + pattern.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, 5, lower) != nullptr) { + continue; // an earlier store already covers this position + } + large.add(pos.tricks, hand, pos.aggr, w.ranks, node(5, 5), true); + pattern.add(pos.tricks, hand, pos.aggr, w.ranks, node(5, 5), true); + + // Act + NodeCards const* hit_l = large.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, 5, lower); + NodeCards const* hit_p = pattern.lookup(pos.tricks, hand, pos.aggr, pos.hand_dist, 5, lower); + + // Assert + ASSERT_NE(hit_l, nullptr) << "step " << i; + ASSERT_NE(hit_p, nullptr) << "step " << i; + for (int s = 0; s < DDS_SUITS; ++s) { + EXPECT_EQ(static_cast(hit_p->least_win[s]), static_cast(hit_l->least_win[s])) + << "step " << i << " suit " << s; + } + ++compared; + } + EXPECT_GT(compared, 100); +} + TEST(TransTablePEquivalenceTest, CutDecisionsMatchTransTableLOnSmallWorkloads) { for (unsigned seed = 1; seed <= 12; ++seed) { diff --git a/specs/transposition-table.md b/specs/transposition-table.md index d91f4d9f7..14c867c02 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -73,7 +73,13 @@ its three concrete strategies, trading memory against speed. statistics, which accumulate across resets by design — but retains the allocated 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. + re-created with `make_tt()` before further use — on `TransTableL`/`S`, + `init()` does not reallocate. `TransTableP` differs: its `init(hand_lookup)` + builds the deal-specific ownership table that patterns are derived from, and + `return_all_memory()` releases it too, so after `return_all_memory(); + make_tt();` an `init()` is required before anything can be stored. Until + then the table is inert (lookups miss, adds are ignored) rather than + undefined. The production path always runs `init()` per deal. `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) From 985defdcac49cf996c52719b3c4618c86273427e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 09:20:09 +0200 Subject: [PATCH 15/26] Keep the whole-suit least_win when a 13/12-relevant pattern merges; add P to VS projects - TransTableP::tighten keeps the larger per-suit least_win, so an entry shared by a whole-suit (13 relevant) pattern and its top-twelve twin never under-reports the winning cards (test first). - List trans_table_p.{cpp,hpp} in DDS.vcxproj, dds_native.vcxproj and the filters so the Visual Studio build links the default TT kind. - specs/solver-context.md: document DDS_TT_KIND alongside the MB overrides. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 7 +++++ .../tests/trans_table/trans_table_p_test.cpp | 28 +++++++++++++++++++ solution/DDS.vcxproj | 2 ++ solution/dds_native.vcxproj | 2 ++ solution/dds_native.vcxproj.filters | 6 ++++ specs/solver-context.md | 7 +++-- 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index d6f1b6833..c88924a33 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -646,6 +646,13 @@ auto TransTableP::tighten(NodeCards& stored, const NodeCards& cards, const bool { stored.lower_bound = std::max(stored.lower_bound, cards.lower_bound); stored.upper_bound = std::min(stored.upper_bound, cards.upper_bound); + // Identical keys imply identical relevant-card counts, except that a + // whole suit (13) and its top twelve share a key: the thirteenth card's + // owner is implied by the shape. Keep the larger count so the entry never + // under-reports the winning cards that some store recorded. + for (int s = 0; s < DDS_SUITS; ++s) { + stored.least_win[s] = std::max(stored.least_win[s], cards.least_win[s]); + } if (flag) { stored.best_move_suit = cards.best_move_suit; stored.best_move_rank = cards.best_move_rank; diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index 21dc7361a..307c96853 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -456,6 +456,34 @@ TEST_F(TransTablePTest, LeastWinEncodesLowestRelevantRankPerSuit) // Bounds merging, best move, subsumption and deduplication // --------------------------------------------------------------------------- +/// The key encodes the top twelve cards of a suit; the thirteenth's owner is +/// implied by the shape, so "all 13 relevant" and "top 12 relevant" match +/// exactly the same positions and share one entry. That entry must still +/// report the full suit as winning if either store did, whatever the order. +TEST_F(TransTablePTest, WholeSuitAndTopTwelveShareAnEntryThatKeepsTheWholeSuit) +{ + const auto deal = TestDeal::rotating(); + const auto pos = full_deal_position(deal); + for (const bool whole_suit_first : {true, false}) { + // Arrange + tt_.reset_memory(ResetReason::NewDeal); + init(deal); + const WinRanks whole = win("2"); // lowest winner is the deuce: 13 relevant + const WinRanks top12 = win("3"); // lowest winner is the three: 12 relevant + store(pos, 0, whole_suit_first ? whole : top12, node(7, 12)); + + // Act + store(pos, 0, whole_suit_first ? top12 : whole, node(7, 12)); + bool lower_flag = false; + NodeCards const* hit = lookup(pos, 0, 6, lower_flag); + + // Assert + EXPECT_EQ(tt_.node_count(), 1u) << "whole_suit_first=" << whole_suit_first; + ASSERT_NE(hit, nullptr); + EXPECT_EQ(static_cast(hit->least_win[0]), 13) << "whole_suit_first=" << whole_suit_first; + } +} + TEST_F(TransTablePTest, ReAddingTheSamePatternIntersectsBounds) { // Arrange diff --git a/solution/DDS.vcxproj b/solution/DDS.vcxproj index 2cd71d537..8ba20e0f2 100644 --- a/solution/DDS.vcxproj +++ b/solution/DDS.vcxproj @@ -99,6 +99,7 @@ + @@ -146,6 +147,7 @@ + diff --git a/solution/dds_native.vcxproj b/solution/dds_native.vcxproj index 21c640ee6..7e06a5705 100644 --- a/solution/dds_native.vcxproj +++ b/solution/dds_native.vcxproj @@ -102,6 +102,7 @@ + @@ -147,6 +148,7 @@ + diff --git a/solution/dds_native.vcxproj.filters b/solution/dds_native.vcxproj.filters index 1eda0c534..e8786fe93 100644 --- a/solution/dds_native.vcxproj.filters +++ b/solution/dds_native.vcxproj.filters @@ -141,6 +141,9 @@ library\src\trans_table + + library\src\trans_table + library\src\trans_table @@ -275,6 +278,9 @@ library\src\trans_table + + library\src\trans_table + library\src\trans_table diff --git a/specs/solver-context.md b/specs/solver-context.md index e714a2a39..8308498f4 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -42,8 +42,11 @@ the opaque handle. See [dds-public-api](dds-public-api.md). - **TT configuration is `SolverConfig` + optional env overrides.** `SolverConfig` 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. + existing TT (resize in place, or recreate if the *effective* kind changes). + Env overrides: `DDS_TT_KIND=small|large|pattern` **replaces** the configured + kind (at creation and in `configure_tt`'s recreate decision); when > 0, + `DDS_TT_DEFAULT_MB` **replaces** the configured default MB and + `DDS_TT_LIMIT_MB` caps the maximum. - **Explicit, tiered reset hooks** (no-ops when no TT exists yet): `reset_for_solve()` clears a subset of search state and resets TT memory (`ResetReason::FreeMemory`) while preserving the allocation for reuse; From adf98825eb2d0cc0d0297c4dbb8b92908feca49a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 09:33:26 +0200 Subject: [PATCH 16/26] Forget remembered lookup shapes on init/make_tt/reset; budget the whole pool buffer - add() without a fresh lookup() after init(), make_tt(), reset_memory() or return_all_memory() is ignored instead of filing the pattern under the shape key a lookup() remembered for the old table or deal (test first). - release_tree() budgets the full replacement pointer buffer, since the old buffer is still live while the new one is allocated; the hard cap now bounds the transient peak as well. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 26 +++++++++++--- library/src/trans_table/trans_table_p.hpp | 1 + .../tests/trans_table/trans_table_p_test.cpp | 35 +++++++++++++++++++ specs/transposition-table.md | 4 ++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index c88924a33..9200fd4db 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -95,6 +95,7 @@ auto TransTableP::init(const int hand_lookup[][15]) -> void } } } + forget_lookups(); // shapes remembered for the previous deal no longer apply } @@ -131,6 +132,7 @@ auto TransTableP::make_tt() -> void delete_trees(); free_spare_trees(); shapes_.assign(InitialShapes, ShapeSlot{}); + forget_lookups(); } @@ -153,6 +155,7 @@ auto TransTableP::reset_memory(const ResetReason reason) -> void // reset never allocates on top of the storage it is about to drop. std::vector().swap(shapes_); shapes_.resize(InitialShapes); + forget_lookups(); } @@ -162,6 +165,17 @@ auto TransTableP::return_all_memory() -> void free_spare_trees(); std::vector().swap(shapes_); std::vector().swap(ownership_); // init() rebuilds it per deal + forget_lookups(); +} + + +auto TransTableP::forget_lookups() -> void +{ + // The shape a lookup() resolved is only valid for the following add() + // while the table and the deal it was resolved against still exist; + // an add() arriving after that without a fresh lookup() must be ignored. + std::memset(last_key_, 0, sizeof(last_key_)); + std::memset(last_slot_, 0, sizeof(last_slot_)); } @@ -352,12 +366,14 @@ 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. + // The pool's pointer storage counts against the budget too. Growing + // it allocates the whole replacement buffer while the old one (already + // in dynamic_bytes()) is still live, so that full size is what must + // fit under the cap; otherwise 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_) { + const std::size_t replacement = grown * sizeof(PatternTree*); + if (dynamic_bytes() + replacement > maximum_bytes_) { delete_tree(tree); return; } diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index a4be14abd..140beefd5 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -241,6 +241,7 @@ class TransTableP : public TransTable auto release_trees() -> void; auto delete_tree(PatternTree* tree) -> void; ///< To the allocator (never allocates). auto delete_trees() -> void; + auto forget_lookups() -> 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 307c96853..feff3ccae 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -832,6 +832,41 @@ TEST_F(TransTablePTest, PooledBlockPointerStorageCountsTowardsMemoryInUse) EXPECT_GT(tt_.memory_in_use(), before_kb); } +/// A lookup() leaves behind the shape it resolved so that the following add() +/// can reuse it. Anything that empties the table, or changes the deal, makes +/// that remembered shape meaningless; an add() that arrives without a fresh +/// lookup() afterwards must be ignored rather than filed under the old shape. +TEST_F(TransTablePTest, AddWithoutAFreshLookupAfterAResetOrNewDealIsIgnored) +{ + const auto deal = TestDeal::rotating(); + const auto pos = full_deal_position(deal); + const auto forget = [&](const char* how) { + if (how == std::string("reset")) tt_.reset_memory(ResetReason::NewDeal); + else if (how == std::string("make_tt")) tt_.make_tt(); + else if (how == std::string("return_all_memory")) { + tt_.return_all_memory(); + tt_.make_tt(); + init(deal); + } + else init(deal); // "init": a new deal on a live table + }; + for (const char* how : {"reset", "make_tt", "return_all_memory", "init"}) { + // Arrange: a lookup() has remembered the shape of pos for trick 0 / hand 0. + tt_.make_tt(); + init(deal); + bool lower_flag = false; + (void)lookup(pos, 0, 6, lower_flag); + forget(how); + + // Act: add() without a lookup() in between. + tt_.add(pos.tricks, 0, pos.aggr, win("AK").ranks, node(7, 12), true); + + // Assert + EXPECT_EQ(tt_.node_count(), 0u) << how; + EXPECT_EQ(tt_.shape_count(), 0u) << how; + } +} + TEST_F(TransTablePTest, ReturnAllMemoryLeavesNothingAllocated) { // Arrange: a table with patterns, pooled blocks and the ownership table. diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 14c867c02..784395240 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -59,7 +59,9 @@ its three concrete strategies, trading memory against speed. 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. + immediately rather than waiting for the next allocation, and it bounds the + peak footprint including transients (a pooled-block pointer buffer that is + being replaced counts twice until the old one is freed). 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 From 3f4aa11d241059c12aa11fb457256d780640298e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 09:48:52 +0200 Subject: [PATCH 17/26] Honour a maximum set without a default; card-aware P dump; vacate shape slots on release - make_tt() no longer fabricates a default limit when none was set, so a lone set_memory_maximum(1) is a 1 MB cap instead of being floored to 95 MB (test first). - print_entries_dist_and_cards() now reports how many of the shape's patterns match the given cards and prints each match's bounds, least_win, relevant card owners and best move (tests first). - release_trees() vacates every slot including its key; regression test pins that repeated solve/reset cycles recount every shape. Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 71 +++++++++- library/src/trans_table/trans_table_p.hpp | 2 + .../tests/trans_table/trans_table_p_test.cpp | 123 ++++++++++++++++++ specs/transposition-table.md | 10 +- 4 files changed, 198 insertions(+), 8 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 9200fd4db..26fad784d 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -41,6 +41,8 @@ #include #include #include +#include +#include #include @@ -119,9 +121,9 @@ auto TransTableP::set_memory_maximum(const int megabytes) -> void auto TransTableP::make_tt() -> void { - if (default_bytes_ == 0) { - default_bytes_ = static_cast(THREADMEM_LARGE_DEF_MB) * MiB; - } + // Only the hard maximum matters to this table; the default limit merely + // floors it when the caller configured one. An unset default must not be + // replaced by a built-in value, or it would lift an explicit small cap. if (maximum_bytes_ == 0) { maximum_bytes_ = static_cast(THREADMEM_LARGE_MAX_MB) * MiB; } @@ -385,11 +387,13 @@ auto TransTableP::release_tree(PatternTree* tree) -> void auto TransTableP::release_trees() -> void { + // Every slot is vacated, key included, so the counts stay exact even if + // a caller kept the slot array instead of replacing it. for (ShapeSlot& slot : shapes_) { if (slot.tree) { release_tree(slot.tree); - slot.tree = nullptr; } + slot = ShapeSlot{}; } shape_count_ = 0; node_count_ = 0; @@ -739,10 +743,65 @@ auto TransTableP::print_entries_dist_and_cards( std::ofstream& fout, const int trick, const int hand, - const unsigned short /*aggr_target*/[], + const unsigned short aggr_target[], const int hand_dist[]) const -> void { - print_entries_dist(fout, trick, hand, hand_dist); + const std::size_t slot = find_shape(shape_key(trick, hand, hand_dist)); + PatternTree const* tree = slot == NoSlot ? nullptr : shapes_[slot].tree; + const std::size_t total = tree ? tree->size : 0; + + std::uint32_t set[PatternWords] = {}; + if (!ownership_.empty()) { + position_set(aggr_target, set); + } + std::ostringstream lines; + std::size_t matched = 0; + for (std::size_t i = 0; i < total; ++i) { + const PatternNode& stored = (*tree)[i]; + if (!matches(stored.key, set)) { + continue; + } + ++matched; + lines << " [" << static_cast(stored.cards.lower_bound) << ", " + << static_cast(stored.cards.upper_bound) << "] least_win"; + for (int s = 0; s < DDS_SUITS; ++s) { + lines << ' ' << static_cast(stored.cards.least_win[s]); + } + lines << ' ' << owners_of(stored.key) << " best move " + << static_cast(stored.cards.best_move_suit) << '/' + << static_cast(stored.cards.best_move_rank) << '\n'; + } + fout << "Trick " << trick << " hand " << hand << ": " << total << " patterns, " + << matched << " match the cards\n" << lines.str(); +} + + +auto TransTableP::owners_of(const PatternKey& key) -> std::string +{ + // Per suit, the owner of each relevant card from the top down, in the + // same layout position_set() uses: one byte per suit in each word, the + // top card of the word's four in the byte's high two bits. + static constexpr char suit_letter[DDS_SUITS] = {'S', 'H', 'D', 'C'}; + static constexpr char seat_letter[DDS_HANDS] = {'N', 'E', 'S', 'W'}; + std::string text; + for (int s = 0; s < DDS_SUITS; ++s) { + text += suit_letter[s]; + text += ':'; + bool any = false; + for (int card = 0; card < 4 * PatternWords; ++card) { + const PatternWord& word = key.word[card / 4]; + const int bit = 24 - 8 * s + 6 - 2 * (card % 4); + if ((word.mask >> bit) & 3u) { + text += seat_letter[(word.set >> bit) & 3u]; + any = true; + } + } + if (!any) { + text += '-'; + } + text += ' '; + } + return text; } diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index 140beefd5..7a922d2fc 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -226,6 +227,7 @@ class TransTableP : public TransTable 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; + static auto owners_of(const PatternKey& key) -> std::string; auto position_set(const unsigned short aggr_target[], std::uint32_t set[]) const -> void; auto make_pattern( diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index feff3ccae..fea899c33 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -867,6 +869,102 @@ TEST_F(TransTablePTest, AddWithoutAFreshLookupAfterAResetOrNewDealIsIgnored) } } +/// An ordinary reset leaves no occupied shape slot behind: the same shapes +/// are counted afresh on the next solve, so the load factor stays exact and +/// the open-addressed table keeps growing when it should. +TEST_F(TransTablePTest, RepeatedSolveAndResetCyclesRecountEveryShape) +{ + // Arrange: enough distinct shapes to make the shape table grow. + std::mt19937 rng(23); + const auto deal = TestDeal::random(rng); + init(deal); + std::vector positions; + for (int i = 0; i < 3000; ++i) positions.push_back(random_position(deal, rng, 1 + (i % 12))); + std::size_t first_cycle_shapes = 0; + + for (int cycle = 0; cycle < 4; ++cycle) { + // Act: a solve's worth of stores, then the between-deals reset. + for (const auto& pos : positions) store(pos, 0, win("A"), node(0, 13)); + if (cycle == 0) first_cycle_shapes = tt_.shape_count(); + + // Assert: every shape is counted again each cycle, none linger. + EXPECT_EQ(tt_.shape_count(), first_cycle_shapes) << "cycle " << cycle; + EXPECT_GT(tt_.shape_count(), 1000u); + tt_.reset_memory(ResetReason::NewDeal); + EXPECT_EQ(tt_.shape_count(), 0u) << "cycle " << cycle; + EXPECT_EQ(tt_.node_count(), 0u) << "cycle " << cycle; + } +} + +/// Writes a diagnostic dump through the ofstream API and returns it as text. +template +auto dumped(Dump&& dump) -> std::string +{ + const std::string path = ::testing::TempDir() + "/trans_table_p_dump.txt"; + { + std::ofstream fout(path, std::ios::trunc); + dump(fout); + } + std::ifstream fin(path); + std::stringstream text; + text << fin.rdbuf(); + return text.str(); +} + +/// The card-aware dump reports which of the shape's patterns match the given +/// cards, and shows each match's bounds and the owners of its relevant cards. +TEST_F(TransTablePTest, CardAwareDumpListsOnlyThePatternsMatchingTheCards) +{ + // Arrange: two one-trick-played positions of the same shape whose top + // spades have different owners (A=N K=E ... versus T=E 9=N ...). + const auto deal = TestDeal::from_owners("NESWENSWNESWN", "NESWNESWNESWN", "ESWNESWNESWNE", "SWNESWNESWNES"); + init(deal); + const auto low_spades_played = TestPosition::remaining(deal, "AKQJT9876", AllRanks, AllRanks, AllRanks); + const auto high_spades_played = TestPosition::remaining(deal, "T98765432", AllRanks, AllRanks, AllRanks); + ASSERT_EQ(low_spades_played.tricks, high_spades_played.tricks); + ASSERT_TRUE(std::equal(std::begin(low_spades_played.hand_dist), std::end(low_spades_played.hand_dist), + std::begin(high_spades_played.hand_dist))); + store(low_spades_played, 0, win("AK"), node(3, 9)); + store(low_spades_played, 0, win("AKQ"), node(4, 8)); + + // Act + const auto same_cards = dumped([&](std::ofstream& f) { + tt_.print_entries_dist_and_cards(f, low_spades_played.tricks, 0, low_spades_played.aggr, low_spades_played.hand_dist); + }); + const auto other_cards = dumped([&](std::ofstream& f) { + tt_.print_entries_dist_and_cards(f, high_spades_played.tricks, 0, high_spades_played.aggr, high_spades_played.hand_dist); + }); + + // Assert + EXPECT_NE(same_cards.find("2 patterns"), std::string::npos) << same_cards; + EXPECT_NE(same_cards.find("2 match the cards"), std::string::npos) << same_cards; + EXPECT_NE(same_cards.find("[3, 9]"), std::string::npos) << same_cards; + EXPECT_NE(same_cards.find("[4, 8]"), std::string::npos) << same_cards; + EXPECT_NE(same_cards.find("S:NE "), std::string::npos) << same_cards; + EXPECT_NE(same_cards.find("S:NES "), std::string::npos) << same_cards; + EXPECT_NE(other_cards.find("2 patterns"), std::string::npos) << other_cards; + EXPECT_NE(other_cards.find("0 match the cards"), std::string::npos) << other_cards; + EXPECT_EQ(other_cards.find("[3, 9]"), std::string::npos) << other_cards; +} + +/// An unknown shape is reported as such rather than as an empty match list. +TEST_F(TransTablePTest, CardAwareDumpReportsAnUnknownShape) +{ + // Arrange + const auto deal = TestDeal::rotating(); + init(deal); + const auto pos = full_deal_position(deal); + + // Act + const auto text = dumped([&](std::ofstream& f) { + tt_.print_entries_dist_and_cards(f, pos.tricks, 0, pos.aggr, pos.hand_dist); + }); + + // Assert + EXPECT_NE(text.find("0 patterns"), std::string::npos) << text; + EXPECT_NE(text.find("0 match the cards"), std::string::npos) << text; +} + TEST_F(TransTablePTest, ReturnAllMemoryLeavesNothingAllocated) { // Arrange: a table with patterns, pooled blocks and the ownership table. @@ -993,6 +1091,31 @@ TEST(TransTablePMemoryTest, PoolingOutgrownBlocksNeverExceedsTheMaximum) EXPECT_GT(max_shapes, 4000u) << "not enough blocks to make pooling costly"; } +/// A caller that configures only the hard maximum gets exactly that maximum; +/// the unset default limit must not be replaced by a built-in value that then +/// floors the cap far above what was asked for. +TEST(TransTablePMemoryTest, AMaximumSetWithoutADefaultIsHonouredAsTheCap) +{ + // Arrange + TransTableP tt; + tt.set_memory_maximum(1); // 1 MiB, no set_memory_default() + tt.make_tt(); + std::mt19937 rng(31); + const auto deal = TestDeal::random(rng); + tt.init(deal.hand_lookup); + const double cap_kb = tt.memory_in_use() + 1024.0; + + // Act & Assert: several MiB worth of entries never lift the footprint above the cap. + for (int i = 0; i < 20000; ++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); + ASSERT_LE(tt.memory_in_use(), cap_kb) << "after add " << i; + } +} + TEST(TransTablePMemoryTest, LoweringTheMaximumWhileStillWithinItKeepsTheContents) { // Arrange diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 784395240..1fd3d2f5d 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -68,7 +68,10 @@ its three concrete strategies, trading memory against speed. page. Treat `0` as unsupported rather than unlimited. It does not arise on the production path: the owning [solver-context](solver-context.md) replaces `<= 0` config values with `THREADMEM_*` constants before construct. (Reconciling the - header's doxygen is out of scope here.) Env overrides: `DDS_TT_DEFAULT_MB` / + header's doxygen is out of scope here.) On `TransTableP` only the maximum is a + limit: an explicitly set default merely floors it, an unset default is ignored + (so `set_memory_maximum(1)` alone really caps at 1 MB), and an unset maximum + falls back to `THREADMEM_LARGE_MAX_MB`. Env overrides: `DDS_TT_DEFAULT_MB` / `DDS_TT_LIMIT_MB`. - **Resets are reason-tagged and tiered.** `reset_memory(ResetReason)` clears cached positions and bumps the per-reason reset counters — it does **not** clear @@ -123,4 +126,7 @@ its three concrete strategies, trading memory against speed. - The table does not choose its own size strategy — kind and limits are dictated by the owning [solver-context](solver-context.md) / config, not decided internally. - Print/diagnostic methods are for offline analysis and emit only under the - relevant debug builds; they are not part of the hot path. + relevant debug builds; they are not part of the hot path. On `TransTableP`, + `print_entries_dist_and_cards` lists, for the position's shape, how many + patterns exist and which of them match the given cards (bounds, `least_win`, + the owners of the relevant cards per suit, best move). From c8ceeea48269dd02b4a0047d43405f3a70fe7d4e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 09:59:43 +0200 Subject: [PATCH 18/26] Spec: DDS_TT_DEFAULT_MB applies at table creation only, not on in-place resize Co-authored-by: Cursor --- specs/solver-context.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specs/solver-context.md b/specs/solver-context.md index 8308498f4..a6cb95ab7 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -45,8 +45,10 @@ the opaque handle. See [dds-public-api](dds-public-api.md). existing TT (resize in place, or recreate if the *effective* kind changes). Env overrides: `DDS_TT_KIND=small|large|pattern` **replaces** the configured kind (at creation and in `configure_tt`'s recreate decision); when > 0, - `DDS_TT_DEFAULT_MB` **replaces** the configured default MB and - `DDS_TT_LIMIT_MB` caps the maximum. + `DDS_TT_LIMIT_MB` caps the maximum (at creation and on every `configure_tt`), + and `DDS_TT_DEFAULT_MB` **replaces** the configured default MB only when a + table is created (lazily, or on `configure_tt`'s recreate path) — an in-place + resize applies the explicit `defMB` as given. - **Explicit, tiered reset hooks** (no-ops when no TT exists yet): `reset_for_solve()` clears a subset of search state and resets TT memory (`ResetReason::FreeMemory`) while preserving the allocation for reuse; From c25111d8f69b6a1b1c60b1b10efd466daddace7b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 10:10:23 +0200 Subject: [PATCH 19/26] Docs: set_memory_default caveat for TransTableP; DDS_TT_LIMIT_MB is floored to the default Co-authored-by: Cursor --- library/src/trans_table/trans_table.hpp | 11 +++++++---- specs/solver-context.md | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/library/src/trans_table/trans_table.hpp b/library/src/trans_table/trans_table.hpp index eae55d58b..d23d7710b 100644 --- a/library/src/trans_table/trans_table.hpp +++ b/library/src/trans_table/trans_table.hpp @@ -125,11 +125,14 @@ class TransTable /// \brief Set the default (soft) memory limit in megabytes. /// - /// The table will try to stay below this limit but may exceed it slightly - /// during search. When the limit is exceeded, the table may invoke cleanup - /// strategies like harvesting (in TransTableL). + /// TransTableL and TransTableS try to stay below this limit but may exceed + /// it slightly during search; when it is exceeded they may invoke cleanup + /// strategies like harvesting (TransTableL). TransTableP has no soft limit: + /// the value only floors the hard maximum at make_tt(), and 0 (unset) is + /// ignored there rather than treated as unlimited. /// - /// \param megabytes Desired soft memory limit in MB (0 = unlimited) + /// \param megabytes Desired soft memory limit in MB (0 = unlimited on L/S; + /// see above for P) virtual auto set_memory_default(int megabytes) -> void = 0; /// \brief Set the maximum (hard) memory limit in megabytes. diff --git a/specs/solver-context.md b/specs/solver-context.md index a6cb95ab7..eedf77d3f 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -48,7 +48,10 @@ the opaque handle. See [dds-public-api](dds-public-api.md). `DDS_TT_LIMIT_MB` caps the maximum (at creation and on every `configure_tt`), and `DDS_TT_DEFAULT_MB` **replaces** the configured default MB only when a table is created (lazily, or on `configure_tt`'s recreate path) — an in-place - resize applies the explicit `defMB` as given. + resize applies the explicit `defMB` as given. In both paths the maximum is + then floored to the effective default (`maxMB = max(maxMB, defMB)`), so + `DDS_TT_LIMIT_MB` cannot push the maximum below the default: to cap below the + built-in default, lower the default too (`DDS_TT_DEFAULT_MB` or `configure_tt`). - **Explicit, tiered reset hooks** (no-ops when no TT exists yet): `reset_for_solve()` clears a subset of search state and resets TT memory (`ResetReason::FreeMemory`) while preserving the allocation for reuse; From c79a53dd0b9aa15901f2acfaad722f923e6df0db Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 10:24:00 +0200 Subject: [PATCH 20/26] Comment: shape_key relies on the trick-boundary precondition shared with TransTableL Co-authored-by: Cursor --- library/src/trans_table/trans_table_p.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/library/src/trans_table/trans_table_p.cpp b/library/src/trans_table/trans_table_p.cpp index 26fad784d..cb5903354 100644 --- a/library/src/trans_table/trans_table_p.cpp +++ b/library/src/trans_table/trans_table_p.cpp @@ -217,8 +217,11 @@ auto TransTableP::shape_count() const -> std::size_t 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. + // hand_dist holds 12 bits per hand (spades, hearts, diamonds). The search + // consults the table only at trick boundaries (ab_search_0), where every + // hand holds trick + 1 cards, so the club length is implied - the same + // derivation TransTableL::dist_to_lengths uses. trick + 1 keeps the key + // non-zero. return (static_cast(trick + 1) << 50) | (static_cast(hand) << 48) | (static_cast(hand_dist[0]) << 36) | From 381951838b44cae1b885cf8eaffa855a2f589a11 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 10:35:56 +0200 Subject: [PATCH 21/26] Keep a maximum-only TT configuration's cap on lazy creation; qualify the cap contract - SearchContext::trans_table() replaces unset limits one at a time and caps the built-in default by an explicit maximum, so {defMB=0, maxMB=1} yields a 1 MB table instead of the built-in Large limits (test first). - Docs: the maximum governs cache storage; the per-deal ownership table is reported by memory_in_use() but not charged. Co-authored-by: Cursor --- library/src/solver_context/solver_context.cpp | 18 +++--- library/src/trans_table/trans_table_p.hpp | 6 +- .../tests/system/configure_tt_api_test.cpp | 55 +++++++++++++++++++ specs/transposition-table.md | 7 ++- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index b4db13d7a..1bf7fae8d 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -112,16 +112,14 @@ auto SolverContext::SearchContext::trans_table() -> TransTable* { 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 - if (defMB <= 0 || maxMB <= 0) { - if (kind == TTKind::Small) { - defMB = THREADMEM_SMALL_DEF_MB; - maxMB = THREADMEM_SMALL_MAX_MB; - } else { - defMB = THREADMEM_LARGE_DEF_MB; - maxMB = THREADMEM_LARGE_MAX_MB; - } - } + // Final fallback to THREADMEM_* constants, one value at a time: an unset + // maximum gets the built-in limit, and an unset default gets the built-in + // default capped by the (possibly explicit) maximum, so that a + // maximum-only configuration keeps its cap. + const int builtin_def = kind == TTKind::Small ? THREADMEM_SMALL_DEF_MB : THREADMEM_LARGE_DEF_MB; + const int builtin_max = kind == TTKind::Small ? THREADMEM_SMALL_MAX_MB : THREADMEM_LARGE_MAX_MB; + if (maxMB <= 0) maxMB = builtin_max; + if (defMB <= 0) defMB = std::min(builtin_def, maxMB); // Optional environment overrides if (const char* s = std::getenv("DDS_TT_DEFAULT_MB")) { int v = std::atoi(s); diff --git a/library/src/trans_table/trans_table_p.hpp b/library/src/trans_table/trans_table_p.hpp index 7a922d2fc..c5c9caeb8 100644 --- a/library/src/trans_table/trans_table_p.hpp +++ b/library/src/trans_table/trans_table_p.hpp @@ -37,7 +37,11 @@ /// /// Memory grows on demand up to the configured maximum; when exhausted the /// whole table is cleared (\ref ResetReason::MemoryExhausted) and filling -/// resumes. +/// resumes. The maximum governs the cache storage (pattern blocks, the spare +/// pool and the shape table, transients included). The fixed per-deal +/// card-ownership table that `init()` builds (about 384 KB, plus a similar +/// transient while building) is reported by `memory_in_use()` but not charged +/// against the maximum. /// /// \par Lifecycle /// `make_tt()` creates an empty table; `init(hand_lookup)` then builds the diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 0ec28bb49..6f28a2af2 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -109,6 +109,61 @@ TEST(ConfigureTtApiTest, PatternKindCreatesPatternTable) EXPECT_NE(nullptr, dynamic_cast(tt)); } +/// Prepares `tt` for a deal in which rank r of suit s belongs to seat (r + s) % 4. +void init_rotating_deal(TransTable& tt) +{ + int hand_lookup[DDS_SUITS][15] = {}; + for (int s = 0; s < DDS_SUITS; ++s) + for (int r = 2; r <= 14; ++r) hand_lookup[s][r] = (r + s) % DDS_HANDS; + tt.init(hand_lookup); +} + +/// Fills a table with many distinct shapes and returns false as soon as its +/// footprint exceeds `cap_kb`. +auto stays_under(TransTable& tt, const double cap_kb) -> bool +{ + const unsigned short aggr[DDS_SUITS] = {0x1fff, 0x1fff, 0x1fff, 0x1fff}; + const unsigned short win_ranks[DDS_SUITS] = {1u << 12, 0, 0, 0}; + NodeCards cards{}; + cards.upper_bound = 13; + for (unsigned i = 0; i < 20000; ++i) { + int hand_dist[DDS_HANDS]; + for (int h = 0; h < DDS_HANDS; ++h) + hand_dist[h] = static_cast((i * 2654435761u * static_cast(h + 1)) & 0xfffu); + const int tricks = 1 + static_cast(i % 12); + bool lower_flag = false; + (void)tt.lookup(tricks, 0, aggr, hand_dist, -1, lower_flag); + tt.add(tricks, 0, aggr, win_ranks, cards, true); + if (tt.memory_in_use() > cap_kb) return false; + } + return true; +} + +/// A configuration that sets only the maximum must yield a table capped at +/// that maximum when it is created lazily; the built-in default used for the +/// unset value may not lift the cap. +TEST(ConfigureTtApiTest, AMaximumOnlyConfigurationIsHonouredOnLazyCreation) +{ + // Arrange + ScopedEnv no_kind("DDS_TT_KIND", nullptr); + ScopedEnv no_default("DDS_TT_DEFAULT_MB", nullptr); + ScopedEnv no_limit("DDS_TT_LIMIT_MB", nullptr); + SolverConfig cfg; + cfg.tt_kind_ = TTKind::Pattern; + cfg.tt_mem_default_mb_ = 0; + cfg.tt_mem_maximum_mb_ = 1; + SolverContext ctx(cfg); + + // Act + TransTable* tt = ctx.trans_table(); + ASSERT_NE(tt, nullptr); + init_rotating_deal(*tt); + const double cap_kb = tt->memory_in_use() + 1024.0; + + // Assert + EXPECT_TRUE(stays_under(*tt, cap_kb)); +} + TEST(ConfigureTtApiTest, SwitchingToPatternRecreatesAndResizingKeepsInstance) { // Arrange: start from the Large table, isolated from any ambient override. diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 1fd3d2f5d..e420b06dd 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -60,8 +60,11 @@ its three concrete strategies, trading memory against speed. 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, and it bounds the - peak footprint including transients (a pooled-block pointer buffer that is - being replaced counts twice until the old one is freed). + peak footprint of the cache storage including transients (a pooled-block + pointer buffer that is being replaced counts twice until the old one is + freed). The fixed per-deal card-ownership table built by `init()` (~384 KB, + plus a similar transient during the build) is reported by `memory_in_use()` + but is not charged against the maximum. 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 From 5490751ce67390bbaa1adc982230aff9fb8026b0 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 10:56:56 +0200 Subject: [PATCH 22/26] Docs: set_memory_default is honoured only by L, 0 is unsupported; scope the env test's outer value Co-authored-by: Cursor --- library/src/trans_table/trans_table.hpp | 15 ++++++++------- library/tests/system/configure_tt_api_test.cpp | 8 +++----- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/library/src/trans_table/trans_table.hpp b/library/src/trans_table/trans_table.hpp index d23d7710b..00bd2e174 100644 --- a/library/src/trans_table/trans_table.hpp +++ b/library/src/trans_table/trans_table.hpp @@ -125,14 +125,15 @@ class TransTable /// \brief Set the default (soft) memory limit in megabytes. /// - /// TransTableL and TransTableS try to stay below this limit but may exceed - /// it slightly during search; when it is exceeded they may invoke cleanup - /// strategies like harvesting (TransTableL). TransTableP has no soft limit: - /// the value only floors the hard maximum at make_tt(), and 0 (unset) is - /// ignored there rather than treated as unlimited. + /// Only TransTableL treats this as a soft limit: it tries to stay below it + /// but may exceed it slightly during search, harvesting when it does. + /// TransTableS ignores the value (a no-op; only the maximum is enforced). + /// TransTableP has no soft limit either: the value only floors the hard + /// maximum at make_tt(), and 0 (unset) is ignored there. /// - /// \param megabytes Desired soft memory limit in MB (0 = unlimited on L/S; - /// see above for P) + /// \param megabytes Desired soft memory limit in MB. 0 is not supported as + /// "unlimited": TransTableL would free every pooled page on the next + /// reset; pass a positive value. virtual auto set_memory_default(int megabytes) -> void = 0; /// \brief Set the maximum (hard) memory limit in megabytes. diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 6f28a2af2..492f7279a 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -59,9 +59,10 @@ auto kind_of(const TransTable* tt) -> TTKind TEST(ConfigureTtApiTest, ScopedEnvRestoresThePreviousValueAndAbsence) { - // Arrange + // Arrange: a known outer value, itself scoped so that whatever the runner + // supplied is put back when the test ends. const char* name = "DDS_TEST_SCOPED_ENV"; - set_env_var(name, "before"); + ScopedEnv outer(name, "before"); // Act & Assert: an override is undone, and so is a removal. { @@ -74,9 +75,6 @@ TEST(ConfigureTtApiTest, ScopedEnvRestoresThePreviousValueAndAbsence) 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) From c996b81841dca59cb8aa8203471484c695389d06 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 11:14:29 +0200 Subject: [PATCH 23/26] Resolve unset limits in configure_tt like creation; re-init a recreated TT on the next solve - configure_tt() runs the same fill_unset_limits() as lazy creation, so configure_tt(kind, 0, 0) on a live table no longer sets a zero maximum that clears the table on every allocation (test first). - dispose_trans_table() forgets the thread's remembered deal, so a table recreated between two solves of the same deal is init()-ed by the next solve instead of staying inert (test first, end to end via solve_board_pbn). Co-authored-by: Cursor --- library/src/solver_context/solver_context.cpp | 29 +++++--- .../tests/system/configure_tt_api_test.cpp | 72 +++++++++++++++++++ specs/solver-context.md | 12 +++- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 1bf7fae8d..bca8662d8 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,18 @@ auto make_trans_table(TTKind kind) -> std::unique_ptr return std::make_unique(); } +/// Replaces non-positive limits with the built-in THREADMEM_* values, one at +/// a time: an unset maximum gets the built-in limit, and an unset default gets +/// the built-in default capped by the (possibly explicit) maximum, so that a +/// maximum-only configuration keeps its cap. +auto fill_unset_limits(const TTKind kind, int& defMB, int& maxMB) -> void +{ + const int builtin_def = kind == TTKind::Small ? THREADMEM_SMALL_DEF_MB : THREADMEM_LARGE_DEF_MB; + const int builtin_max = kind == TTKind::Small ? THREADMEM_SMALL_MAX_MB : THREADMEM_LARGE_MAX_MB; + if (maxMB <= 0) maxMB = builtin_max; + if (defMB <= 0) defMB = std::min(builtin_def, maxMB); +} + #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() @@ -112,14 +125,7 @@ auto SolverContext::SearchContext::trans_table() -> TransTable* { 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, one value at a time: an unset - // maximum gets the built-in limit, and an unset default gets the built-in - // default capped by the (possibly explicit) maximum, so that a - // maximum-only configuration keeps its cap. - const int builtin_def = kind == TTKind::Small ? THREADMEM_SMALL_DEF_MB : THREADMEM_LARGE_DEF_MB; - const int builtin_max = kind == TTKind::Small ? THREADMEM_SMALL_MAX_MB : THREADMEM_LARGE_MAX_MB; - if (maxMB <= 0) maxMB = builtin_max; - if (defMB <= 0) defMB = std::min(builtin_def, maxMB); + fill_unset_limits(kind, defMB, maxMB); // Optional environment overrides if (const char* s = std::getenv("DDS_TT_DEFAULT_MB")) { int v = std::atoi(s); @@ -180,6 +186,10 @@ auto SolverContext::dispose_trans_table() const -> void #endif // Dispose the member-owned TT (if any) const_cast(this)->search_.dispose_trans_table(); + // A replacement table has not seen the current deal. Forget the deal the + // thread remembers so the next solve treats it as new and runs + // SetDealTables(), which init()s the table, even for the same cards. + if (thr_) std::memset(thr_->suit, 0, sizeof(thr_->suit)); } // Defaulted destructor defined out-of-line so destruction of the @@ -269,6 +279,9 @@ auto SolverContext::resize_tt(int defMB, int maxMB) const -> void auto SolverContext::configure_tt(TTKind kind, int defMB, int maxMB) -> void { + // Unset limits resolve exactly as they would on lazy creation, so that an + // in-place resize never hands a live table a zero maximum. + fill_unset_limits(tt_kind_from_environment(kind), defMB, maxMB); // Apply environment limit if present to preserve existing behavior. if (const char* s = std::getenv("DDS_TT_LIMIT_MB")) { int v = std::atoi(s); diff --git a/library/tests/system/configure_tt_api_test.cpp b/library/tests/system/configure_tt_api_test.cpp index 492f7279a..94185cffa 100644 --- a/library/tests/system/configure_tt_api_test.cpp +++ b/library/tests/system/configure_tt_api_test.cpp @@ -5,10 +5,12 @@ /// switching kinds, and lazy initialization of transposition tables. #include +#include #include #include +#include #include #include #include @@ -162,6 +164,76 @@ TEST(ConfigureTtApiTest, AMaximumOnlyConfigurationIsHonouredOnLazyCreation) EXPECT_TRUE(stays_under(*tt, cap_kb)); } +/// Solves the known deal from examples/hands.cpp (hand 0) in notrump with `ctx`. +auto solve_known_deal(SolverContext& ctx, FutureTricks& fut) -> int +{ + DealPBN dl{}; + dl.trump = 4; + dl.first = 0; + std::strcpy(dl.remainCards, "N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3"); + return solve_board_pbn(ctx, dl, /*target=*/-1, /*solutions=*/1, /*mode=*/1, &fut); +} + +/// A table recreated between two solves of the same deal has not seen that +/// deal; the next solve must initialise it again rather than run against an +/// inert (never init()-ed) cache. +TEST(ConfigureTtApiTest, ATableRecreatedBetweenSolvesOfTheSameDealIsInitialisedAgain) +{ + // Arrange: one solve, then a kind change and back, which recreates the table. + ScopedEnv no_kind("DDS_TT_KIND", nullptr); + SolverContext ctx; + FutureTricks first{}; + ASSERT_EQ(solve_known_deal(ctx, first), RETURN_NO_FAULT); + ctx.configure_tt(TTKind::Large, 8, 16); + ctx.configure_tt(TTKind::Pattern, 8, 16); + auto* recreated = dynamic_cast(ctx.maybe_trans_table()); + ASSERT_NE(recreated, nullptr); + ASSERT_EQ(recreated->node_count(), 0u); + + // Act: the same deal again. + FutureTricks again{}; + ASSERT_EQ(solve_known_deal(ctx, again), RETURN_NO_FAULT); + + // Assert: same answer, and the cache was actually in use. + EXPECT_EQ(again.score[0], first.score[0]); + EXPECT_GT(recreated->node_count(), 0u); +} + +/// Reconfiguring a live table with unset limits must resolve them the same +/// way lazy creation does, not hand the table a zero maximum. +TEST(ConfigureTtApiTest, ReconfiguringALiveTableWithUnsetLimitsResolvesThem) +{ + // Arrange + ScopedEnv no_kind("DDS_TT_KIND", nullptr); + ScopedEnv no_default("DDS_TT_DEFAULT_MB", nullptr); + ScopedEnv no_limit("DDS_TT_LIMIT_MB", nullptr); + SolverConfig cfg; + cfg.tt_kind_ = TTKind::Pattern; + SolverContext ctx(cfg); + auto* tt = dynamic_cast(ctx.trans_table()); + ASSERT_NE(tt, nullptr); + init_rotating_deal(*tt); + + // Act + ctx.configure_tt(TTKind::Pattern, /*defMB=*/0, /*maxMB=*/0); + + // Assert: a few hundred shapes fit comfortably; a zero maximum would + // clear the table on every allocation and keep it near empty. + const unsigned short aggr[DDS_SUITS] = {0x1fff, 0x1fff, 0x1fff, 0x1fff}; + const unsigned short win_ranks[DDS_SUITS] = {1u << 12, 0, 0, 0}; + NodeCards cards{}; + cards.upper_bound = 13; + for (unsigned i = 0; i < 300; ++i) { + int hand_dist[DDS_HANDS]; + for (int h = 0; h < DDS_HANDS; ++h) + hand_dist[h] = static_cast((i * 2654435761u * static_cast(h + 1)) & 0xfffu); + bool lower_flag = false; + (void)tt->lookup(1 + static_cast(i % 12), 0, aggr, hand_dist, -1, lower_flag); + tt->add(1 + static_cast(i % 12), 0, aggr, win_ranks, cards, true); + } + EXPECT_EQ(tt->node_count(), 300u); +} + TEST(ConfigureTtApiTest, SwitchingToPatternRecreatesAndResizingKeepsInstance) { // Arrange: start from the Large table, isolated from any ambient override. diff --git a/specs/solver-context.md b/specs/solver-context.md index eedf77d3f..4a6bfcbeb 100644 --- a/specs/solver-context.md +++ b/specs/solver-context.md @@ -43,6 +43,11 @@ the opaque handle. See [dds-public-api](dds-public-api.md). 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 *effective* kind changes). + Non-positive limits are resolved the same way in both `configure_tt` and lazy + creation, one value at a time: an unset maximum becomes the built-in limit + and an unset default becomes the built-in default capped by the maximum, so a + maximum-only configuration keeps its cap and a live table is never handed a + zero maximum. Env overrides: `DDS_TT_KIND=small|large|pattern` **replaces** the configured kind (at creation and in `configure_tt`'s recreate decision); when > 0, `DDS_TT_LIMIT_MB` caps the maximum (at creation and on every `configure_tt`), @@ -68,7 +73,12 @@ the opaque handle. See [dds-public-api](dds-public-api.md). because it only checks whether `tt_` is non-null — the next `lookup`/`add` read freed memory. `clear_tt()` and `dispose_trans_table()` now differ only in their log/stats trace. Guarded by `//library/tests:dds_c_api_test` - (`DdsCApiTtConfiguration.ClearTtThenSolveOnDefaultTt`). + (`DdsCApiTtConfiguration.ClearTtThenSolveOnDefaultTt`). Disposing also + forgets the deal the thread remembers (`ThreadData::suit`), so the next solve + — even of the same cards — is treated as a new deal and runs + `SetDealTables()`, which `init()`s the replacement table; otherwise a table + recreated between two solves of one deal would never see the deal (inert on + `TransTableP`). Guarded by `ConfigureTtApiTest.ATableRecreatedBetweenSolvesOfTheSameDealIsInitialisedAgain`. - **Hot-path facades are value-typed and inline-friendly, with different holds.** `MoveGenContext` holds a raw `ThreadData*` so `move_gen()` can return a value-typed facade without an atomic `shared_ptr` bump on every call. From 518674934d12bd710ed4a0772b25d3efdf79c62b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 11:27:53 +0200 Subject: [PATCH 24/26] Pin that TT entries survive init() for similar deals and still hinge on the relevant cards Co-authored-by: Cursor --- .../tests/trans_table/trans_table_p_test.cpp | 28 +++++++++++++++++++ specs/transposition-table.md | 8 ++++++ 2 files changed, 36 insertions(+) diff --git a/library/tests/trans_table/trans_table_p_test.cpp b/library/tests/trans_table/trans_table_p_test.cpp index fea899c33..06f029d38 100644 --- a/library/tests/trans_table/trans_table_p_test.cpp +++ b/library/tests/trans_table/trans_table_p_test.cpp @@ -486,6 +486,34 @@ TEST_F(TransTablePTest, WholeSuitAndTopTwelveShareAnEntryThatKeepsTheWholeSuit) } } +/// A pattern is a statement about positions - this shape, these owners of the +/// relevant cards - and a position's value does not depend on which deal it +/// came from. The solver relies on that: for a "similar" deal it re-runs +/// init() without resetting the table (as with TransTableL). Entries must +/// therefore survive init() and keep hinging on the relevant cards only. +TEST_F(TransTablePTest, PatternsSurviveASimilarDealAndStillHingeOnTheRelevantCards) +{ + // Arrange: only the spade ace is relevant; the deals below share a shape. + const auto original = TestDeal::from_owners("NESWNESWNESWN", "NESWNESWNESWN", "NESWNESWNESWN", "NESWNESWNESWN"); + const auto low_cards_swapped = TestDeal::from_owners("NESWNESWNESNW", "NESWNESWNESWN", "NESWNESWNESWN", "NESWNESWNESWN"); + const auto ace_moved = TestDeal::from_owners("ENSWNESWNESWN", "NESWNESWNESWN", "NESWNESWNESWN", "NESWNESWNESWN"); + init(original); + store(full_deal_position(original), 0, win("A"), node(7, 12)); + + // Act + init(low_cards_swapped); + bool lower_flag = false; + NodeCards const* similar_hit = lookup(full_deal_position(low_cards_swapped), 0, 6, lower_flag); + init(ace_moved); + NodeCards const* moved_ace = lookup(full_deal_position(ace_moved), 0, 6, lower_flag); + + // Assert + ASSERT_NE(similar_hit, nullptr); + EXPECT_EQ(static_cast(similar_hit->lower_bound), 7); + EXPECT_EQ(static_cast(similar_hit->upper_bound), 12); + EXPECT_EQ(moved_ace, nullptr); +} + TEST_F(TransTablePTest, ReAddingTheSamePatternIntersectsBounds) { // Arrange diff --git a/specs/transposition-table.md b/specs/transposition-table.md index e420b06dd..1357d0c8e 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -76,6 +76,14 @@ its three concrete strategies, trading memory against speed. (so `set_memory_maximum(1)` alone really caps at 1 MB), and an unset maximum falls back to `THREADMEM_LARGE_MAX_MB`. Env overrides: `DDS_TT_DEFAULT_MB` / `DDS_TT_LIMIT_MB`. +- **`init()` does not clear entries, by design.** Entries are statements about + *positions* (shape plus owners of the relevant relative-rank cards), and a + position's value does not depend on the deal it arose in. The solver relies on + this: for a "similar" deal (`solver_if.cpp`, `SIMILARDEALLIMIT`) it skips + `reset_memory()` and only re-runs `init()`, so entries carry over and keep + matching on the relevant cards alone — on `TransTableP` exactly as on + `TransTableL`. Guarded by + `TransTablePTest.PatternsSurviveASimilarDealAndStillHingeOnTheRelevantCards`. - **Resets are reason-tagged and tiered.** `reset_memory(ResetReason)` clears cached positions and bumps the per-reason reset counters — it does **not** clear statistics, which accumulate across resets by design — but retains the allocated From 47536f5185f25ef4d946c34d84d8fb8b284289b8 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 12:33:38 +0200 Subject: [PATCH 25/26] Spec: drop the stale claim that the header calls 0 unlimited Co-authored-by: Cursor --- specs/transposition-table.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/specs/transposition-table.md b/specs/transposition-table.md index 1357d0c8e..4cea11785 100644 --- a/specs/transposition-table.md +++ b/specs/transposition-table.md @@ -65,13 +65,12 @@ its three concrete strategies, trading memory against speed. freed). The fixed per-deal card-ownership table built by `init()` (~384 KB, plus a similar transient during the build) is reported by `memory_in_use()` but is not charged against the maximum. - The header documents `0` as "unlimited" for the default limit, but `TransTableL` - does not implement it that way — `set_memory_default(0)` yields + `0` is **unsupported** for the default limit, not "unlimited" (and the + `set_memory_default` doxygen says so): on `TransTableL` it yields `pages_default_ == 0`, and the next `reset_memory` then frees *every* pooled - page. Treat `0` as unsupported rather than unlimited. It does not arise on the - production path: the owning [solver-context](solver-context.md) replaces `<= 0` - config values with `THREADMEM_*` constants before construct. (Reconciling the - header's doxygen is out of scope here.) On `TransTableP` only the maximum is a + page. It does not arise on the production path: the owning + [solver-context](solver-context.md) replaces `<= 0` config values with + `THREADMEM_*` constants before construct. On `TransTableP` only the maximum is a limit: an explicitly set default merely floors it, an unset default is ignored (so `set_memory_maximum(1)` alone really caps at 1 MB), and an unset maximum falls back to `THREADMEM_LARGE_MAX_MB`. Env overrides: `DDS_TT_DEFAULT_MB` / From 6cb62eca0dd8fc30b7588e4560f51c0656e5256e Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 12 Sep 2026 12:42:18 +0200 Subject: [PATCH 26/26] Docs: reset_memory contract notes the MemoryExhausted exception on TransTableP Co-authored-by: Cursor --- library/src/trans_table/trans_table.hpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/library/src/trans_table/trans_table.hpp b/library/src/trans_table/trans_table.hpp index 00bd2e174..82f9df323 100644 --- a/library/src/trans_table/trans_table.hpp +++ b/library/src/trans_table/trans_table.hpp @@ -154,8 +154,12 @@ class TransTable /// \brief Clear the transposition table and reset memory/statistics. /// - /// Removes all cached positions and resets internal statistics. The memory - /// structures are retained for reuse. + /// Removes all cached positions and bumps the per-reason reset counters + /// (other statistics accumulate across resets). The memory structures are + /// retained for reuse, with one exception: on TransTableP a + /// ResetReason::MemoryExhausted reset returns the pattern blocks (in use + /// and pooled) to the allocator, since pooling them could itself allocate + /// while over budget; the table then regrows on demand. /// /// \param reason The reason this reset was triggered virtual auto reset_memory(ResetReason reason) -> void = 0;