From 6234595600fdcaed6ac510efbd6d2d3cc393b3dd Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 28 Jun 2026 07:45:52 -0500 Subject: [PATCH 01/17] Fix signed->unsigned cast bugs that corrupted move ordering and pruning The heuristic/quick-tricks refactor introduced static_cast wrappers on values that v2.9 used as signed, changing search behavior: - make_3 / make_3_ctx: winner[]/second_best[] .hand and .rank were cast to unsigned char, turning the -1 "no card" sentinel into 255. This broke winner[trump].hand == -1 style checks in QuickTricks, losing cutoffs. - weight_alloc_trump_void2 / _void3: rel_rank[aggr[suit]][...] indexed through static_cast(aggr[suit]), truncating the 13-bit aggregate holding to 8 bits and reading the wrong rel_rank row. - QuickTricksPartnerHand{Trump,NT}: bit_map_rank index cast the signed rank through unsigned char. With these reverted to v2.9's signed handling, the per-move-generation ordering trace now matches v2.9 exactly (0 divergences on list1), closing the residual calc gap to parity. Ordering/pruning-only change; double-dummy results are unchanged and all library tests pass. Co-authored-by: Cursor --- library/src/ab_search.cpp | 16 ++++++++-------- .../src/heuristic_sorting/heuristic_sorting.cpp | 15 +++------------ library/src/quick_tricks.cpp | 4 ++-- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/library/src/ab_search.cpp b/library/src/ab_search.cpp index 762d75f0..c5b8ee96 100644 --- a/library/src/ab_search.cpp +++ b/library/src/ab_search.cpp @@ -878,10 +878,10 @@ void make_3( int aggr = posPoint->aggr[st]; - posPoint->winner[st].rank = static_cast(thrp->rel[aggr].abs_rank[1][st].rank); - posPoint->winner[st].hand = static_cast(thrp->rel[aggr].abs_rank[1][st].hand); - posPoint->second_best[st].rank = static_cast(thrp->rel[aggr].abs_rank[2][st].rank); - posPoint->second_best[st].hand = static_cast(thrp->rel[aggr].abs_rank[2][st].hand); + posPoint->winner[st].rank = thrp->rel[aggr].abs_rank[1][st].rank; + posPoint->winner[st].hand = thrp->rel[aggr].abs_rank[1][st].hand; + posPoint->second_best[st].rank = thrp->rel[aggr].abs_rank[2][st].rank; + posPoint->second_best[st].hand = thrp->rel[aggr].abs_rank[2][st].hand; } } @@ -944,10 +944,10 @@ static void make_3_ctx( int aggr = posPoint->aggr[st]; - posPoint->winner[st].rank = static_cast(thrp->rel[aggr].abs_rank[1][st].rank); - posPoint->winner[st].hand = static_cast(thrp->rel[aggr].abs_rank[1][st].hand); - posPoint->second_best[st].rank = static_cast(thrp->rel[aggr].abs_rank[2][st].rank); - posPoint->second_best[st].hand = static_cast(thrp->rel[aggr].abs_rank[2][st].hand); + posPoint->winner[st].rank = thrp->rel[aggr].abs_rank[1][st].rank; + posPoint->winner[st].hand = thrp->rel[aggr].abs_rank[1][st].hand; + posPoint->second_best[st].rank = thrp->rel[aggr].abs_rank[2][st].rank; + posPoint->second_best[st].hand = thrp->rel[aggr].abs_rank[2][st].hand; } } diff --git a/library/src/heuristic_sorting/heuristic_sorting.cpp b/library/src/heuristic_sorting/heuristic_sorting.cpp index 88478881..8ddd0a2d 100644 --- a/library/src/heuristic_sorting/heuristic_sorting.cpp +++ b/library/src/heuristic_sorting/heuristic_sorting.cpp @@ -1213,10 +1213,7 @@ void weight_alloc_trump_void2(HeuristicContext& ctx) mply[k].rank < ctx.move1_rank) { // Don't underruff. - unsigned char aggrSuit = static_cast(tpos.aggr[suit]); - unsigned char moveRank = static_cast(mply[k].rank); - unsigned char relRankValue = static_cast(rel_rank[aggrSuit][moveRank]); - int r_rank = static_cast(relRankValue); + int r_rank = rel_rank[tpos.aggr[suit]][mply[k].rank]; suitAdd = (suitCount << 6) / 40; mply[k].weight = -32 + r_rank + suitAdd; } @@ -1386,10 +1383,7 @@ void weight_alloc_trump_void3(HeuristicContext& ctx) { for (int k = last_num_moves; k < num_moves; k++) { - int r_rank = static_cast( - static_cast( - rel_rank[static_cast(tpos.aggr[suit])] - [static_cast(mply[k].rank)])); + int r_rank = rel_rank[tpos.aggr[suit]][mply[k].rank]; if (mply[k].rank > ctx.move2_rank) mply[k].weight = 33 + r_rank; // Overruff else @@ -1404,10 +1398,7 @@ void weight_alloc_trump_void3(HeuristicContext& ctx) { for (int k = last_num_moves; k < num_moves; k++) { - int r_rank = static_cast( - static_cast( - rel_rank[static_cast(tpos.aggr[suit])] - [static_cast(mply[k].rank)])); + int r_rank = rel_rank[tpos.aggr[suit]][mply[k].rank]; mply[k].weight = 33 + r_rank; } } diff --git a/library/src/quick_tricks.cpp b/library/src/quick_tricks.cpp index 0c161406..48f37fe5 100644 --- a/library/src/quick_tricks.cpp +++ b/library/src/quick_tricks.cpp @@ -1000,7 +1000,7 @@ int QuickTricksPartnerHandTrump( if (ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].hand == partner[hand]) { tpos.win_ranks[depth][suit] |= bit_map_rank[ - static_cast(static_cast(ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].rank)) ]; + static_cast(ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].rank) ]; tpos.win_ranks[depth][commSuit] |= bit_map_rank[commRank]; @@ -1110,7 +1110,7 @@ int QuickTricksPartnerHandNT( if (ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].hand == partner[hand]) { tpos.win_ranks[depth][suit] |= bit_map_rank[ - static_cast(static_cast(ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].rank)) ]; + static_cast(ctx.thread_ptr()->rel[ranks].abs_rank[3][suit].rank) ]; qt++; if (qt >= cutoff) return qt; From b08925f881868d6a1f49771dc4923490c6b2a380 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 28 Jun 2026 16:12:18 -0500 Subject: [PATCH 02/17] Dispatch hardest boards first to shorten parallel calc tail The parallel board loop handed boards out in index order via an atomic counter, so a hard board picked near the end left one worker running long while the others sat idle. Hand out the hardest boards first (longest- processing-time-first) so the tail consists of cheap boards. parallel_all_boards_n gains an optional dispatch-order permutation: workers still pull from the same atomic counter, but the slot is mapped through the order before becoming a board number, so only the dispatch sequence changes and result placement is unaffected. The solve path passes no order and is unchanged. calc estimates per-deal difficulty with a cheap, trump-independent structural proxy (deal_fanout, mirroring Scheduler::Fanout) and sorts board indices by descending difficulty before dispatch. calc list1000 -n18: ~11.0s -> ~9.6s wall (~13%), user CPU unchanged. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 45 +++++++++++++++++++++++++- library/src/system/parallel_boards.cpp | 20 +++++++++--- library/src/system/parallel_boards.hpp | 9 +++++- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index b0446fe8..1c9df8b0 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -8,12 +8,15 @@ */ #include "calc_tables.hpp" +#include +#include #include #include #include #include #include +#include #include #include #include @@ -23,6 +26,33 @@ extern Memory memory; extern Scheduler scheduler; +namespace +{ +// Cheap structural difficulty estimate (cards only, trump-independent). Used to +// dispatch the hardest boards first so the parallel tail is short. Mirrors +// Scheduler::Fanout: per hand, sum the number of card groups per suit, with a +// bonus for voids. +auto deal_fanout(const Deal& dl) -> int +{ + int fanout = 0; + for (int h = 0; h < DDS_HANDS; h++) + { + int fanout_suit = 0; + int num_voids = 0; + for (int s = 0; s < DDS_SUITS; s++) + { + const int c = static_cast(dl.remainCards[h][s] >> 2); + fanout_suit += group_data[c].last_group_ + 1; + if (c == 0) + num_voids++; + } + fanout_suit += num_voids * fanout_suit; + fanout += fanout_suit; + } + return fanout; +} +} + // Legacy overload (creates temporary context) auto calc_all_boards_n( Boards * bop, @@ -137,11 +167,24 @@ auto calc_all_boards_n( else { std::vector contexts(static_cast(nthreads)); + + // Dispatch hardest boards first to shorten the parallel tail. + std::vector fanout(static_cast(n)); + for (int i = 0; i < n; i++) + fanout[static_cast(i)] = deal_fanout(bop->deals[i]); + std::vector order(static_cast(n)); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), + [&](const int a, const int b) { + return fanout[static_cast(a)] > fanout[static_cast(b)]; + }); + err = parallel_all_boards_n(n, nthreads, [&](const int worker_id, const int bno) -> int { return calc_single_common_internal( contexts[static_cast(worker_id)], *bop, *solvedp, bno); - }); + }, + &order); } END_BLOCK_TIMER; diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 750041e7..ebb2a0af 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -34,20 +34,29 @@ auto resolve_worker_count( auto parallel_all_boards_n( const int count, const int worker_cap, - const std::function& process_board) -> int + const std::function& process_board, + const std::vector* order) -> int { if (count <= 0) { return RETURN_NO_FAULT; } + // Map a dispatch slot to the board number to process. With an order, hand out + // boards in that sequence (e.g. hardest first); otherwise in index order. + const bool use_order = + (order != nullptr && static_cast(order->size()) == count); + auto board_of = [&](const int slot) -> int { + return use_order ? (*order)[static_cast(slot)] : slot; + }; + const int workers = resolve_worker_count(worker_cap, count); if (workers == 1) { - for (int bno = 0; bno < count; ++bno) + for (int slot = 0; slot < count; ++slot) { - const int rc = process_board(0, bno); + const int rc = process_board(0, board_of(slot)); if (rc != RETURN_NO_FAULT) { return rc; @@ -62,11 +71,12 @@ auto parallel_all_boards_n( auto worker = [&](const int worker_id) { for (;;) { - const int bno = next.fetch_add(1, std::memory_order_relaxed); - if (bno >= count || first_error.load(std::memory_order_relaxed) != RETURN_NO_FAULT) + const int slot = next.fetch_add(1, std::memory_order_relaxed); + if (slot >= count || first_error.load(std::memory_order_relaxed) != RETURN_NO_FAULT) { break; } + const int bno = board_of(slot); const int rc = process_board(worker_id, bno); if (rc != RETURN_NO_FAULT) diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 2f19b6de..01292d05 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -10,6 +10,7 @@ #pragma once #include +#include /** @@ -28,9 +29,15 @@ auto resolve_worker_count(int max_threads, int count) -> int; * @param worker_cap Maximum worker threads; <= 0 uses hardware concurrency. * @param process_board Called for each board; must return RETURN_NO_FAULT (1) * on success. Receives the worker's thread index and board number. + * @param order Optional dispatch order: a permutation of [0, count) giving the + * sequence in which board numbers are handed out (e.g. hardest first to + * shorten the tail). When null/empty, boards are dispatched in index + * order. Only the dispatch order changes; @p process_board still receives + * the real board number, so result placement is unaffected. * @return First non-success code from @p process_board, or RETURN_NO_FAULT. */ auto parallel_all_boards_n( int count, int worker_cap, - const std::function& process_board) -> int; + const std::function& process_board, + const std::vector* order = nullptr) -> int; From 2d57e8dbae36df5d3fea7d704990d29b5f884cef Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 28 Jun 2026 17:39:43 -0500 Subject: [PATCH 03/17] Skip hardest-first dispatch for single-deal calc CalcDDtableN builds one board per strain for a single deal. deal_fanout is trump-independent, so all boards share one fanout and the difficulty sort is a pure no-op there. Gate the sort behind a difficulty_sort flag (default on for batch CalcAllTablesN) and disable it for the single-deal path. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 41 +++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 1c9df8b0..01b2a79a 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -53,11 +53,14 @@ auto deal_fanout(const Deal& dl) -> int } } -// Legacy overload (creates temporary context) +// Legacy overload (creates temporary context). difficulty_sort dispatches the +// hardest boards first; it only helps across distinct deals (batch calc), so it +// is skipped for a single deal (all boards share one deal / one fanout). auto calc_all_boards_n( Boards * bop, SolvedBoards * solvedp, - int max_threads = 0) -> int; + int max_threads = 0, + bool difficulty_sort = true) -> int; auto calc_single_common_internal( @@ -140,7 +143,8 @@ auto calc_all_boards_n( auto calc_all_boards_n( Boards * bop, SolvedBoards * solvedp, - int max_threads) -> int + int max_threads, + bool difficulty_sort) -> int { const int n = bop->no_of_boards; if (n > MAXNOOFBOARDS) @@ -168,23 +172,29 @@ auto calc_all_boards_n( { std::vector contexts(static_cast(nthreads)); - // Dispatch hardest boards first to shorten the parallel tail. - std::vector fanout(static_cast(n)); - for (int i = 0; i < n; i++) - fanout[static_cast(i)] = deal_fanout(bop->deals[i]); - std::vector order(static_cast(n)); - std::iota(order.begin(), order.end(), 0); - std::stable_sort(order.begin(), order.end(), - [&](const int a, const int b) { - return fanout[static_cast(a)] > fanout[static_cast(b)]; - }); + // Dispatch hardest boards first to shorten the parallel tail. This only + // helps across distinct deals (batch calc); for a single deal every board + // shares one fanout, so the sort is skipped (it would be a no-op anyway). + std::vector order; + if (difficulty_sort) + { + std::vector fanout(static_cast(n)); + for (int i = 0; i < n; i++) + fanout[static_cast(i)] = deal_fanout(bop->deals[i]); + order.resize(static_cast(n)); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), + [&](const int a, const int b) { + return fanout[static_cast(a)] > fanout[static_cast(b)]; + }); + } err = parallel_all_boards_n(n, nthreads, [&](const int worker_id, const int bno) -> int { return calc_single_common_internal( contexts[static_cast(worker_id)], *bop, *solvedp, bno); }, - &order); + order.empty() ? nullptr : &order); } END_BLOCK_TIMER; @@ -235,7 +245,8 @@ int STDCALL CalcDDtableN( ind++; } - int res = calc_all_boards_n(&bo, &solved, maxThreads); + // Single deal: all boards share one deal, so hardest-first sorting is a no-op. + int res = calc_all_boards_n(&bo, &solved, maxThreads, /*difficulty_sort=*/false); if (res != 1) return res; From f4ed912bf19ef7c8e27322058bec28e184b1b1d5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sun, 28 Jun 2026 22:28:09 -0500 Subject: [PATCH 04/17] Validate order is a permutation in parallel_all_boards_n Only honor the optional dispatch order when it is a valid permutation of [0, count: each element in range and unique. A malformed order (duplicates or out-of-range values) now falls back to index order, preventing invalid board indices from reaching process_board. EOF ) Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index ebb2a0af..31a014de 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -31,6 +31,21 @@ auto resolve_worker_count( } +static auto is_permutation_of_range( + const std::vector& order, + const int count) -> bool +{ + std::vector seen(static_cast(count), 0); + for (const int v : order) + { + if (v < 0 || v >= count || seen[static_cast(v)]) + return false; + seen[static_cast(v)] = 1; + } + return true; +} + + auto parallel_all_boards_n( const int count, const int worker_cap, @@ -43,9 +58,13 @@ auto parallel_all_boards_n( } // Map a dispatch slot to the board number to process. With an order, hand out - // boards in that sequence (e.g. hardest first); otherwise in index order. + // boards in that sequence (e.g. hardest first); otherwise in index order. The + // order is only honored when it is a valid permutation of [0, count); a + // malformed order falls back to index order to avoid invalid board indices. const bool use_order = - (order != nullptr && static_cast(order->size()) == count); + (order != nullptr && + static_cast(order->size()) == count && + is_permutation_of_range(*order, count)); auto board_of = [&](const int slot) -> int { return use_order ? (*order)[static_cast(slot)] : slot; }; From f73386792f22c39892d6b30eaac2051607632a6f Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 3 Jul 2026 08:56:26 -0400 Subject: [PATCH 05/17] Replace manual permutation check with standard algorithms. Use std::ranges::set_intersection and std::views::iota instead of a hand-rolled seen bitmap in is_permutation_of_range. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 31a014de..3a75a2dc 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include #include @@ -35,14 +37,17 @@ static auto is_permutation_of_range( const std::vector& order, const int count) -> bool { - std::vector seen(static_cast(count), 0); - for (const int v : order) - { - if (v < 0 || v >= count || seen[static_cast(v)]) - return false; - seen[static_cast(v)] = 1; - } - return true; + std::vector sorted(order); + std::ranges::sort(sorted); + + const auto expected = std::views::iota(0, count); + + std::vector common; + common.reserve(static_cast(count)); + std::ranges::set_intersection( + sorted, expected, std::back_inserter(common)); + + return static_cast(common.size()) == count; } From fa2c84c4d09362fd793a9349974b3276540b3119 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 3 Jul 2026 09:37:15 -0400 Subject: [PATCH 06/17] Fix WASM build: avoid std::ranges in permutation check. Emscripten's libc++ lacks C++20 ranges; use iterator-based sort, iota, and set_intersection instead. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 3a75a2dc..49a89f51 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -12,7 +12,7 @@ #include #include #include -#include +#include #include #include @@ -38,14 +38,17 @@ static auto is_permutation_of_range( const int count) -> bool { std::vector sorted(order); - std::ranges::sort(sorted); + std::sort(sorted.begin(), sorted.end()); - const auto expected = std::views::iota(0, count); + std::vector expected(static_cast(count)); + std::iota(expected.begin(), expected.end(), 0); std::vector common; common.reserve(static_cast(count)); - std::ranges::set_intersection( - sorted, expected, std::back_inserter(common)); + std::set_intersection( + sorted.begin(), sorted.end(), + expected.begin(), expected.end(), + std::back_inserter(common)); return static_cast(common.size()) == count; } From 07a33cdcbdc410b42313d4e9c5bef38eeff817af Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 3 Jul 2026 15:04:59 +0100 Subject: [PATCH 07/17] Avoid reimplementing deal_fanout() Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- library/src/calc_tables.cpp | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 01b2a79a..2491975b 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -34,22 +34,7 @@ namespace // bonus for voids. auto deal_fanout(const Deal& dl) -> int { - int fanout = 0; - for (int h = 0; h < DDS_HANDS; h++) - { - int fanout_suit = 0; - int num_voids = 0; - for (int s = 0; s < DDS_SUITS; s++) - { - const int c = static_cast(dl.remainCards[h][s] >> 2); - fanout_suit += group_data[c].last_group_ + 1; - if (c == 0) - num_voids++; - } - fanout_suit += num_voids * fanout_suit; - fanout += fanout_suit; - } - return fanout; + return scheduler.Fanout(dl); } } From 5e34528a0e63e16b689076668042f96839d1edc6 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 3 Jul 2026 10:12:15 -0400 Subject: [PATCH 08/17] Extract deal_fanout as a public free function. Scheduler: :Fanout is private; move the fanout logic to a shared deal_fanout() helper so calc_tables can use it without accessing the scheduler instance. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 16 ---------------- library/src/system/scheduler.cpp | 6 ++++++ library/src/system/scheduler.hpp | 8 ++++++++ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 2491975b..08f82cf9 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -24,23 +24,7 @@ extern Memory memory; -extern Scheduler scheduler; -namespace -{ -// Cheap structural difficulty estimate (cards only, trump-independent). Used to -// dispatch the hardest boards first so the parallel tail is short. Mirrors -// Scheduler::Fanout: per hand, sum the number of card groups per suit, with a -// bonus for voids. -auto deal_fanout(const Deal& dl) -> int -{ - return scheduler.Fanout(dl); -} -} - -// Legacy overload (creates temporary context). difficulty_sort dispatches the -// hardest boards first; it only helps across distinct deals (batch calc), so it -// is skipped for a single deal (all boards share one deal / one fanout). auto calc_all_boards_n( Boards * bop, SolvedBoards * solvedp, diff --git a/library/src/system/scheduler.cpp b/library/src/system/scheduler.cpp index 1cefb850..6b6db34a 100644 --- a/library/src/system/scheduler.cpp +++ b/library/src/system/scheduler.cpp @@ -758,6 +758,12 @@ int Scheduler::Strength(const Deal& dl) const int Scheduler::Fanout(const Deal& dl) const +{ + return deal_fanout(dl); +} + + +auto deal_fanout(const Deal& dl) -> int { // The fanout for a given suit and a given player is the number // of bit groups, so KT982 has 3 groups. In a given suit the diff --git a/library/src/system/scheduler.hpp b/library/src/system/scheduler.hpp index 3e50d4ae..5f518e8f 100644 --- a/library/src/system/scheduler.hpp +++ b/library/src/system/scheduler.hpp @@ -237,4 +237,12 @@ class Scheduler }; +/** + * @brief Cheap structural difficulty estimate (cards only, trump-independent). + * + * Per hand, sum the number of card groups per suit, with a bonus for voids. + * Used to dispatch the hardest boards first in parallel batch calc. + */ +auto deal_fanout(const Deal& dl) -> int; + #endif From 6ead0edcc5bcd95bcc3fe0ee228f999b08824b3a Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 3 Jul 2026 10:32:03 -0400 Subject: [PATCH 09/17] Fix --define=scheduler=true builds. --- library/src/calc_tables.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 08f82cf9..035e73bf 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -24,6 +24,7 @@ extern Memory memory; +extern Scheduler scheduler; auto calc_all_boards_n( Boards * bop, From a64a03276d7252386a1025a6cfb693e0601b1579 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 16 Jul 2026 21:53:16 -0500 Subject: [PATCH 10/17] Move fanout calculation onto Deal Keep the structural difficulty estimate with the data it evaluates, allowing scheduler and batch calculation code to share it without exposing scheduler internals. Co-authored-by: Cursor --- library/src/api/BUILD.bazel | 7 ++ library/src/api/deal.cpp | 40 ++++++++++++ library/src/api/dll.h | 10 +++ library/src/calc_tables.cpp | 2 +- library/src/system/BUILD.bazel | 3 - library/src/system/scheduler.cpp | 38 +---------- library/src/system/scheduler.hpp | 9 --- library/tests/system/BUILD.bazel | 14 ++++ library/tests/system/deal_fanout_test.cpp | 79 +++++++++++++++++++++++ 9 files changed, 152 insertions(+), 50 deletions(-) create mode 100644 library/src/api/deal.cpp create mode 100644 library/tests/system/deal_fanout_test.cpp diff --git a/library/src/api/BUILD.bazel b/library/src/api/BUILD.bazel index b3aacc6b..28f7d4f2 100644 --- a/library/src/api/BUILD.bazel +++ b/library/src/api/BUILD.bazel @@ -3,6 +3,9 @@ load("@rules_cc//cc:defs.bzl", "cc_library") cc_library( name = "api_definitions", + srcs = [ + "deal.cpp", + ], hdrs = [ "calc_dd_table.hpp", "calc_par.hpp", @@ -16,6 +19,10 @@ cc_library( include_prefix = "api", visibility = ["//visibility:public"], deps = [ + "//library/src/lookup_tables:lookup_tables", "//library/src/utility:constants", ], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, ) diff --git a/library/src/api/deal.cpp b/library/src/api/deal.cpp new file mode 100644 index 00000000..01500792 --- /dev/null +++ b/library/src/api/deal.cpp @@ -0,0 +1,40 @@ +/* + DDS, a bridge double dummy solver. + + Copyright (C) 2006-2014 by Bo Haglund / + 2014-2018 by Bo Haglund & Soren Hein. + + See LICENSE and README. +*/ + +#include +#include + + +auto Deal::fanout() const -> int +{ + // The fanout for a given suit and a given player is the number + // of bit groups, so KT982 has 3 groups. In a given suit the + // maximum number over all four players is 13. + // A void counts as the sum of the other players' groups. + + int fanout = 0; + int fanoutSuit, numVoids, c; + + for (int h = 0; h < DDS_HANDS; h++) + { + fanoutSuit = 0; + numVoids = 0; + for (int s = 0; s < DDS_SUITS; s++) + { + c = static_cast(remainCards[h][s] >> 2); + fanoutSuit += group_data[c].last_group_ + 1; + if (c == 0) + numVoids++; + } + fanoutSuit += numVoids * fanoutSuit; + fanout += fanoutSuit; + } + + return fanout; +} diff --git a/library/src/api/dll.h b/library/src/api/dll.h index e1c3aa9c..ef4b51bf 100644 --- a/library/src/api/dll.h +++ b/library/src/api/dll.h @@ -186,6 +186,16 @@ struct Deal int currentTrickSuit[3]; int currentTrickRank[3]; unsigned int remainCards[DDS_HANDS][DDS_SUITS]; + +#ifdef __cplusplus + /** + * @brief Cheap structural difficulty estimate (cards only, trump-independent). + * + * Per hand, sum the number of card groups per suit, with a bonus for voids. + * Used to dispatch the hardest boards first in parallel batch calc. + */ + auto fanout() const -> int; +#endif }; diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 035e73bf..ca9d2399 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -150,7 +150,7 @@ auto calc_all_boards_n( { std::vector fanout(static_cast(n)); for (int i = 0; i < n; i++) - fanout[static_cast(i)] = deal_fanout(bop->deals[i]); + fanout[static_cast(i)] = bop->deals[i].fanout(); order.resize(static_cast(n)); std::iota(order.begin(), order.end(), 0); std::stable_sort(order.begin(), order.end(), diff --git a/library/src/system/BUILD.bazel b/library/src/system/BUILD.bazel index a647463c..72082070 100644 --- a/library/src/system/BUILD.bazel +++ b/library/src/system/BUILD.bazel @@ -11,7 +11,6 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", - "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", @@ -33,7 +32,6 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", - "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", @@ -53,7 +51,6 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", - "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", diff --git a/library/src/system/scheduler.cpp b/library/src/system/scheduler.cpp index 52360226..9baa7225 100644 --- a/library/src/system/scheduler.cpp +++ b/library/src/system/scheduler.cpp @@ -13,7 +13,6 @@ #include "scheduler.hpp" #include #include -#include #ifdef DDS_SCHEDULER #include @@ -270,7 +269,7 @@ void Scheduler::MakeGroups(const Boards& bds) hands[b].NTflag = (strain == 4 ? 1 : 0); hands[b].first = dl->first; hands[b].strain = strain; - hands[b].fanout = Scheduler::Fanout(* dl); + hands[b].fanout = dl->fanout(); // hands[b].strength = Scheduler::Strength(* dl); lp = &list[strain][key]; @@ -750,41 +749,6 @@ int Scheduler::Strength(const Deal& dl) const } -int Scheduler::Fanout(const Deal& dl) const -{ - return deal_fanout(dl); -} - - -auto deal_fanout(const Deal& dl) -> int -{ - // The fanout for a given suit and a given player is the number - // of bit groups, so KT982 has 3 groups. In a given suit the - // maximum number over all four players is 13. - // A void counts as the sum of the other players' groups. - - int fanout = 0; - int fanoutSuit, numVoids, c; - - for (int h = 0; h < DDS_HANDS; h++) - { - fanoutSuit = 0; - numVoids = 0; - for (int s = 0; s < DDS_SUITS; s++) - { - c = static_cast(dl.remainCards[h][s] >> 2); - fanoutSuit += group_data[c].last_group_ + 1; - if (c == 0) - numVoids++; - } - fanoutSuit += numVoids * fanoutSuit; - fanout += fanoutSuit; - } - - return fanout; -} - - schedType Scheduler::GetNumber(const int thrId) { const unsigned tu = static_cast(thrId); diff --git a/library/src/system/scheduler.hpp b/library/src/system/scheduler.hpp index 5f518e8f..a7504075 100644 --- a/library/src/system/scheduler.hpp +++ b/library/src/system/scheduler.hpp @@ -123,7 +123,6 @@ class Scheduler void SortHands(const enum RunMode mode); int Strength(const Deal& dl) const; - int Fanout(const Deal& dl) const; void Reset(); @@ -237,12 +236,4 @@ class Scheduler }; -/** - * @brief Cheap structural difficulty estimate (cards only, trump-independent). - * - * Per hand, sum the number of card groups per suit, with a bonus for voids. - * Used to dispatch the hardest boards first in parallel batch calc. - */ -auto deal_fanout(const Deal& dl) -> int; - #endif diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index 5c172fd6..970f12c6 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -59,6 +59,20 @@ cc_test( ], ) +# Deal::fanout structural difficulty estimate +cc_test( + name = "deal_fanout_test", + size = "small", + srcs = ["deal_fanout_test.cpp"], + deps = [ + "//library/src:testable_dds", + "//library/src/api:api_definitions", + "//library/src/lookup_tables:lookup_tables", + "//library/src/utility:constants", + "@googletest//:gtest_main", + ], +) + # Worker-count helper unit test cc_test( name = "worker_count_test", diff --git a/library/tests/system/deal_fanout_test.cpp b/library/tests/system/deal_fanout_test.cpp new file mode 100644 index 00000000..45101aab --- /dev/null +++ b/library/tests/system/deal_fanout_test.cpp @@ -0,0 +1,79 @@ +/// @file deal_fanout_test.cpp +/// @brief Unit tests for Deal::fanout (structural difficulty estimate). + +#include + +#include +#include +#include + +namespace +{ + +/// Pack suit holding bits into the remainCards encoding (aggregate << 2). +auto holding(const unsigned aggregate) -> unsigned +{ + return aggregate << 2; +} + +auto empty_deal() -> Deal +{ + Deal dl{}; + return dl; +} + +} // namespace + +TEST(DealFanout, EmptyDealIsZero) +{ + // Arrange + const Deal dl = empty_deal(); + + // Act / Assert + EXPECT_EQ(dl.fanout(), 0); +} + +TEST(DealFanout, SingleCardCountsAsOneGroupWithVoidBonus) +{ + // Arrange: North holds only the deuce of spades; all other holdings empty. + // That hand: 1 group + 3 voids * 1 = 4. Other hands: all voids → 0. + Deal dl = empty_deal(); + dl.remainCards[0][0] = holding(bit_map_rank[2]); + + // Act / Assert + EXPECT_EQ(dl.fanout(), 4); +} + +TEST(DealFanout, ConsecutiveRanksAreOneGroup) +{ + // Arrange: North holds KT982 of spades (K | T98 | 2 → 3 groups). + // That hand: 3 groups + 3 voids * 3 = 12. Other hands: 0. + const unsigned kt982 = + bit_map_rank[13] | // K + bit_map_rank[10] | // T + bit_map_rank[9] | // 9 + bit_map_rank[8] | // 8 + bit_map_rank[2]; // 2 + + Deal dl = empty_deal(); + dl.remainCards[0][0] = holding(kt982); + + // Act / Assert + EXPECT_EQ(group_data[kt982].last_group_ + 1, 3); + EXPECT_EQ(dl.fanout(), 12); +} + +TEST(DealFanout, SolidSuitIsOneGroup) +{ + // Arrange: North holds AKQ of hearts (one consecutive group), voids elsewhere. + // That hand: 1 + 3*1 = 4. + const unsigned akq = + bit_map_rank[14] | bit_map_rank[13] | bit_map_rank[12]; + + Deal dl = empty_deal(); + dl.remainCards[0][1] = holding(akq); + + // Act / Assert + EXPECT_EQ(group_data[akq].last_group_ + 1, 1); + EXPECT_EQ(dl.fanout(), 4); +} From b66a00c1a28a0a29b47b95de91927d6f2b962be1 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 16 Jul 2026 22:05:10 -0500 Subject: [PATCH 11/17] Restore linear permutation validation Use the seen bitmap again to validate dispatch orders with one allocation and linear work while preserving malformed-order fallback behavior. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 24 ++-- library/tests/system/BUILD.bazel | 11 ++ library/tests/system/parallel_boards_test.cpp | 123 ++++++++++++++++++ 3 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 library/tests/system/parallel_boards_test.cpp diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 49a89f51..31a014de 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -11,8 +11,6 @@ #include #include -#include -#include #include #include @@ -37,20 +35,14 @@ static auto is_permutation_of_range( const std::vector& order, const int count) -> bool { - std::vector sorted(order); - std::sort(sorted.begin(), sorted.end()); - - std::vector expected(static_cast(count)); - std::iota(expected.begin(), expected.end(), 0); - - std::vector common; - common.reserve(static_cast(count)); - std::set_intersection( - sorted.begin(), sorted.end(), - expected.begin(), expected.end(), - std::back_inserter(common)); - - return static_cast(common.size()) == count; + std::vector seen(static_cast(count), 0); + for (const int v : order) + { + if (v < 0 || v >= count || seen[static_cast(v)]) + return false; + seen[static_cast(v)] = 1; + } + return true; } diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index 970f12c6..e15da17d 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -85,6 +85,17 @@ cc_test( ], ) +cc_test( + name = "parallel_boards_test", + size = "small", + srcs = ["parallel_boards_test.cpp"], + deps = [ + "//library/src:testable_dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + # max_threads override equivalence + rename/alias initialisation test cc_test( name = "max_threads_equivalence_test", diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp new file mode 100644 index 00000000..19e63e25 --- /dev/null +++ b/library/tests/system/parallel_boards_test.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace allocation_tracking +{ + +std::atomic enabled{false}; +std::atomic allocations{0}; + +} // namespace allocation_tracking + +void* operator new(const std::size_t size) +{ + if (allocation_tracking::enabled.load(std::memory_order_relaxed)) + allocation_tracking::allocations.fetch_add(1, std::memory_order_relaxed); + + if (void* const memory = std::malloc(size)) + return memory; + throw std::bad_alloc(); +} + +void operator delete(void* const memory) noexcept +{ + std::free(memory); +} + +void operator delete(void* const memory, std::size_t) noexcept +{ + std::free(memory); +} + +namespace +{ + +auto dispatched_boards( + const int count, + const std::vector& order) -> std::vector +{ + std::vector boards; + boards.reserve(static_cast(count)); + const std::function process_board = + [&](const int, const int board) { + boards.push_back(board); + return RETURN_NO_FAULT; + }; + + const int result = + parallel_all_boards_n(count, 1, process_board, &order); + + EXPECT_EQ(result, RETURN_NO_FAULT); + return boards; +} + +} // namespace + +TEST(ParallelAllBoards, ValidPermutationControlsDispatchOrder) +{ + // Arrange + const std::vector order{3, 1, 0, 2}; + + // Act + const std::vector boards = dispatched_boards(4, order); + + // Assert + EXPECT_EQ(boards, order); +} + +TEST(ParallelAllBoards, DuplicateOrderFallsBackToIndexOrder) +{ + // Arrange + const std::vector order{0, 1, 1, 3}; + + // Act + const std::vector boards = dispatched_boards(4, order); + + // Assert + EXPECT_EQ(boards, (std::vector{0, 1, 2, 3})); +} + +TEST(ParallelAllBoards, OutOfRangeOrderFallsBackToIndexOrder) +{ + // Arrange + const std::vector order{-1, 1, 2, 4}; + + // Act + const std::vector boards = dispatched_boards(4, order); + + // Assert + EXPECT_EQ(boards, (std::vector{0, 1, 2, 3})); +} + +TEST(ParallelAllBoards, PermutationValidationUsesAtMostOneAllocation) +{ + // Arrange + const std::vector order{3, 1, 0, 2}; + std::vector boards; + boards.reserve(order.size()); + const std::function process_board = + [&](const int, const int board) { + boards.push_back(board); + return RETURN_NO_FAULT; + }; + allocation_tracking::allocations.store(0, std::memory_order_relaxed); + + // Act + allocation_tracking::enabled.store(true, std::memory_order_relaxed); + const int result = + parallel_all_boards_n(4, 1, process_board, &order); + allocation_tracking::enabled.store(false, std::memory_order_relaxed); + + // Assert + EXPECT_EQ(result, RETURN_NO_FAULT); + EXPECT_LE( + allocation_tracking::allocations.load(std::memory_order_relaxed), 1u); +} From 4d033a3e158e17bc4a9843d82ebd5dd0cf6d3f25 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 16 Jul 2026 22:20:57 -0500 Subject: [PATCH 12/17] Document dispatch order lifetime requirement Clarify that parallel workers retain read access to the optional order vector for the duration of the call. Co-authored-by: Cursor --- library/src/system/parallel_boards.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 01292d05..29ae953b 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -33,7 +33,9 @@ auto resolve_worker_count(int max_threads, int count) -> int; * sequence in which board numbers are handed out (e.g. hardest first to * shorten the tail). When null/empty, boards are dispatched in index * order. Only the dispatch order changes; @p process_board still receives - * the real board number, so result placement is unaffected. + * the real board number, so result placement is unaffected. When + * non-null, the vector must remain valid and must not be mutated until + * this function returns because worker threads read it concurrently. * @return First non-success code from @p process_board, or RETURN_NO_FAULT. */ auto parallel_all_boards_n( From 1fa58a768785e75c8d0f5c1d14894a2621adfd5b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 17 Jul 2026 10:11:46 -0500 Subject: [PATCH 13/17] Keep deal fanout internal Remove the C++ method from the public C API struct and expose the shared fanout calculation only through an internal system helper. Co-authored-by: Cursor --- library/src/api/BUILD.bazel | 7 ------- library/src/api/dll.h | 10 ---------- library/src/calc_tables.cpp | 4 +++- library/src/system/BUILD.bazel | 3 +++ .../{api/deal.cpp => system/deal_fanout.cpp} | 14 +++++++++++--- library/src/system/deal_fanout.hpp | 19 +++++++++++++++++++ library/src/system/scheduler.cpp | 3 ++- library/tests/system/BUILD.bazel | 2 +- library/tests/system/deal_fanout_test.cpp | 11 ++++++----- 9 files changed, 45 insertions(+), 28 deletions(-) rename library/src/{api/deal.cpp => system/deal_fanout.cpp} (79%) create mode 100644 library/src/system/deal_fanout.hpp diff --git a/library/src/api/BUILD.bazel b/library/src/api/BUILD.bazel index 28f7d4f2..b3aacc6b 100644 --- a/library/src/api/BUILD.bazel +++ b/library/src/api/BUILD.bazel @@ -3,9 +3,6 @@ load("@rules_cc//cc:defs.bzl", "cc_library") cc_library( name = "api_definitions", - srcs = [ - "deal.cpp", - ], hdrs = [ "calc_dd_table.hpp", "calc_par.hpp", @@ -19,10 +16,6 @@ cc_library( include_prefix = "api", visibility = ["//visibility:public"], deps = [ - "//library/src/lookup_tables:lookup_tables", "//library/src/utility:constants", ], - copts = DDS_CPPOPTS, - linkopts = DDS_LINKOPTS, - local_defines = DDS_LOCAL_DEFINES, ) diff --git a/library/src/api/dll.h b/library/src/api/dll.h index ef4b51bf..e1c3aa9c 100644 --- a/library/src/api/dll.h +++ b/library/src/api/dll.h @@ -186,16 +186,6 @@ struct Deal int currentTrickSuit[3]; int currentTrickRank[3]; unsigned int remainCards[DDS_HANDS][DDS_SUITS]; - -#ifdef __cplusplus - /** - * @brief Cheap structural difficulty estimate (cards only, trump-independent). - * - * Per hand, sum the number of card groups per suit, with a bonus for voids. - * Used to dispatch the hardest boards first in parallel batch calc. - */ - auto fanout() const -> int; -#endif }; diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index ca9d2399..690f6809 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -150,7 +151,8 @@ auto calc_all_boards_n( { std::vector fanout(static_cast(n)); for (int i = 0; i < n; i++) - fanout[static_cast(i)] = bop->deals[i].fanout(); + fanout[static_cast(i)] = + dds::internal::deal_fanout(bop->deals[i]); order.resize(static_cast(n)); std::iota(order.begin(), order.end(), 0); std::stable_sort(order.begin(), order.end(), diff --git a/library/src/system/BUILD.bazel b/library/src/system/BUILD.bazel index 72082070..a647463c 100644 --- a/library/src/system/BUILD.bazel +++ b/library/src/system/BUILD.bazel @@ -11,6 +11,7 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", + "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", @@ -32,6 +33,7 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", + "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", @@ -51,6 +53,7 @@ cc_library( includes = ["."], deps = [ "//library/src/utility:constants", + "//library/src/lookup_tables:lookup_tables", "//library/src/api:api_definitions", "//library/src/trans_table", "//library/src/moves:moves", diff --git a/library/src/api/deal.cpp b/library/src/system/deal_fanout.cpp similarity index 79% rename from library/src/api/deal.cpp rename to library/src/system/deal_fanout.cpp index 01500792..9325143b 100644 --- a/library/src/api/deal.cpp +++ b/library/src/system/deal_fanout.cpp @@ -7,11 +7,16 @@ See LICENSE and README. */ -#include +#include "deal_fanout.hpp" + #include +namespace dds +{ +namespace internal +{ -auto Deal::fanout() const -> int +auto deal_fanout(const Deal& dl) -> int { // The fanout for a given suit and a given player is the number // of bit groups, so KT982 has 3 groups. In a given suit the @@ -27,7 +32,7 @@ auto Deal::fanout() const -> int numVoids = 0; for (int s = 0; s < DDS_SUITS; s++) { - c = static_cast(remainCards[h][s] >> 2); + c = static_cast(dl.remainCards[h][s] >> 2); fanoutSuit += group_data[c].last_group_ + 1; if (c == 0) numVoids++; @@ -38,3 +43,6 @@ auto Deal::fanout() const -> int return fanout; } + +} // namespace internal +} // namespace dds diff --git a/library/src/system/deal_fanout.hpp b/library/src/system/deal_fanout.hpp new file mode 100644 index 00000000..1ade73ea --- /dev/null +++ b/library/src/system/deal_fanout.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace dds +{ +namespace internal +{ + +/** + * @brief Cheap structural difficulty estimate (cards only, trump-independent). + * + * Per hand, sum the number of card groups per suit, with a bonus for voids. + * Used to dispatch the most difficult deals first in parallel batch calculations. + */ +auto deal_fanout(const Deal& dl) -> int; + +} // namespace internal +} // namespace dds diff --git a/library/src/system/scheduler.cpp b/library/src/system/scheduler.cpp index 9baa7225..da1d12b1 100644 --- a/library/src/system/scheduler.cpp +++ b/library/src/system/scheduler.cpp @@ -10,6 +10,7 @@ #include #include +#include "deal_fanout.hpp" #include "scheduler.hpp" #include #include @@ -269,7 +270,7 @@ void Scheduler::MakeGroups(const Boards& bds) hands[b].NTflag = (strain == 4 ? 1 : 0); hands[b].first = dl->first; hands[b].strain = strain; - hands[b].fanout = dl->fanout(); + hands[b].fanout = dds::internal::deal_fanout(*dl); // hands[b].strength = Scheduler::Strength(* dl); lp = &list[strain][key]; diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index e15da17d..d8a31969 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -59,7 +59,7 @@ cc_test( ], ) -# Deal::fanout structural difficulty estimate +# Internal deal fanout structural difficulty estimate cc_test( name = "deal_fanout_test", size = "small", diff --git a/library/tests/system/deal_fanout_test.cpp b/library/tests/system/deal_fanout_test.cpp index 45101aab..a185a9b5 100644 --- a/library/tests/system/deal_fanout_test.cpp +++ b/library/tests/system/deal_fanout_test.cpp @@ -1,10 +1,11 @@ /// @file deal_fanout_test.cpp -/// @brief Unit tests for Deal::fanout (structural difficulty estimate). +/// @brief Unit tests for the internal deal fanout estimate. #include #include #include +#include #include namespace @@ -30,7 +31,7 @@ TEST(DealFanout, EmptyDealIsZero) const Deal dl = empty_deal(); // Act / Assert - EXPECT_EQ(dl.fanout(), 0); + EXPECT_EQ(dds::internal::deal_fanout(dl), 0); } TEST(DealFanout, SingleCardCountsAsOneGroupWithVoidBonus) @@ -41,7 +42,7 @@ TEST(DealFanout, SingleCardCountsAsOneGroupWithVoidBonus) dl.remainCards[0][0] = holding(bit_map_rank[2]); // Act / Assert - EXPECT_EQ(dl.fanout(), 4); + EXPECT_EQ(dds::internal::deal_fanout(dl), 4); } TEST(DealFanout, ConsecutiveRanksAreOneGroup) @@ -60,7 +61,7 @@ TEST(DealFanout, ConsecutiveRanksAreOneGroup) // Act / Assert EXPECT_EQ(group_data[kt982].last_group_ + 1, 3); - EXPECT_EQ(dl.fanout(), 12); + EXPECT_EQ(dds::internal::deal_fanout(dl), 12); } TEST(DealFanout, SolidSuitIsOneGroup) @@ -75,5 +76,5 @@ TEST(DealFanout, SolidSuitIsOneGroup) // Act / Assert EXPECT_EQ(group_data[akq].last_group_ + 1, 1); - EXPECT_EQ(dl.fanout(), 4); + EXPECT_EQ(dds::internal::deal_fanout(dl), 4); } From edb0865b16d3539937c5f33296181c20e7a15e74 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 17 Jul 2026 10:39:38 -0500 Subject: [PATCH 14/17] Handle zero-size test allocations Make the test replacement for operator new conform across platforms where malloc(0) may return null. Co-authored-by: Cursor --- library/tests/system/parallel_boards_test.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index 19e63e25..1bc266e2 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -22,7 +22,9 @@ void* operator new(const std::size_t size) if (allocation_tracking::enabled.load(std::memory_order_relaxed)) allocation_tracking::allocations.fetch_add(1, std::memory_order_relaxed); - if (void* const memory = std::malloc(size)) + // C++ requires successful zero-size allocations to return non-null; + // malloc(0) is allowed to return nullptr, so request at least one byte. + if (void* const memory = std::malloc(size == 0 ? 1 : size)) return memory; throw std::bad_alloc(); } @@ -121,3 +123,12 @@ TEST(ParallelAllBoards, PermutationValidationUsesAtMostOneAllocation) EXPECT_LE( allocation_tracking::allocations.load(std::memory_order_relaxed), 1u); } + +TEST(ParallelAllBoards, ZeroSizeNewReturnsNonNull) +{ + // C++ requires a successful zero-size allocation to return a distinct + // non-null pointer; malloc(0) is allowed to return nullptr. + void* const memory = ::operator new(0); + EXPECT_NE(memory, nullptr); + ::operator delete(memory); +} From d557f892634d4df92909ce066478c50257d578a5 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 17 Jul 2026 11:43:21 -0500 Subject: [PATCH 15/17] Remove unused lookup table include Keep calc_tables decoupled from lookup-table implementation details now owned by the internal fanout helper. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 690f6809..86585724 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include From bb1aa56ef2182b8e9526c22a8ba072c3fe11191d Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 17 Jul 2026 14:28:17 -0500 Subject: [PATCH 16/17] Avoid narrowing dispatch order size Compare order length in size_t while preserving fallback behavior for malformed dispatch orders. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 2 +- library/tests/system/parallel_boards_test.cpp | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 31a014de..c330a14c 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -63,7 +63,7 @@ auto parallel_all_boards_n( // malformed order falls back to index order to avoid invalid board indices. const bool use_order = (order != nullptr && - static_cast(order->size()) == count && + order->size() == static_cast(count) && is_permutation_of_range(*order, count)); auto board_of = [&](const int slot) -> int { return use_order ? (*order)[static_cast(slot)] : slot; diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index 1bc266e2..ba5546dc 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -99,6 +99,18 @@ TEST(ParallelAllBoards, OutOfRangeOrderFallsBackToIndexOrder) EXPECT_EQ(boards, (std::vector{0, 1, 2, 3})); } +TEST(ParallelAllBoards, WrongSizedOrderFallsBackToIndexOrder) +{ + // Arrange: valid values, but length does not match count. + const std::vector order{3, 1, 0}; + + // Act + const std::vector boards = dispatched_boards(4, order); + + // Assert + EXPECT_EQ(boards, (std::vector{0, 1, 2, 3})); +} + TEST(ParallelAllBoards, PermutationValidationUsesAtMostOneAllocation) { // Arrange From 9d56925534de7fe4fa60bde4408e6092eb75d91f Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 17 Jul 2026 14:33:54 -0500 Subject: [PATCH 17/17] Use snake case in deal fanout Align local variable names with the modernized codebase conventions. Co-authored-by: Cursor --- library/src/system/deal_fanout.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/library/src/system/deal_fanout.cpp b/library/src/system/deal_fanout.cpp index 9325143b..68b143a1 100644 --- a/library/src/system/deal_fanout.cpp +++ b/library/src/system/deal_fanout.cpp @@ -24,21 +24,21 @@ auto deal_fanout(const Deal& dl) -> int // A void counts as the sum of the other players' groups. int fanout = 0; - int fanoutSuit, numVoids, c; + int fanout_suit, num_voids, c; for (int h = 0; h < DDS_HANDS; h++) { - fanoutSuit = 0; - numVoids = 0; + fanout_suit = 0; + num_voids = 0; for (int s = 0; s < DDS_SUITS; s++) { c = static_cast(dl.remainCards[h][s] >> 2); - fanoutSuit += group_data[c].last_group_ + 1; + fanout_suit += group_data[c].last_group_ + 1; if (c == 0) - numVoids++; + num_voids++; } - fanoutSuit += numVoids * fanoutSuit; - fanout += fanoutSuit; + fanout_suit += num_voids * fanout_suit; + fanout += fanout_suit; } return fanout;