From 2c6ed05549c4142401cfaa8b6971a370c915ddd7 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 15:43:50 +0200 Subject: [PATCH 01/13] Add --cards parameter to create_list_for_dtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows generating deals with 1–13 cards per hand instead of always 13. The play generator now derives the total card count from the deal rather than hard-coding 52. Co-authored-by: Cursor --- python/utilities/src/create_list_for_dtest.py | 29 ++++++++++++----- .../tests/create_list_for_dtest_test.py | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/python/utilities/src/create_list_for_dtest.py b/python/utilities/src/create_list_for_dtest.py index 85eb77a7..ff9c8064 100644 --- a/python/utilities/src/create_list_for_dtest.py +++ b/python/utilities/src/create_list_for_dtest.py @@ -65,10 +65,12 @@ class DealSpec: cards: str # e.g. N:AKQ.... ... -def _deal_cards(rng: random.Random) -> str: +def _deal_cards(rng: random.Random, *, cards_per_hand: int = 13) -> str: """Return a PBN remainCards string starting at North (``N:...``).""" deck = [(suit, rank) for suit in range(_SUITS) for rank in range(13)] rng.shuffle(deck) + total_cards = cards_per_hand * _HANDS + deck = deck[:total_cards] hands: list[list[list[str]]] = [[[] for _ in range(_SUITS)] for _ in range(_HANDS)] for i, (suit, rank_idx) in enumerate(deck): hands[i % _HANDS][suit].append(_RANKS[rank_idx]) @@ -76,14 +78,15 @@ def _deal_cards(rng: random.Random) -> str: for hand in hands: suit_strs = [] for suit in range(_SUITS): - # Keep A-K-Q-... order within each suit. ordered = sorted(hand[suit], key=lambda r: _RANKS.index(r)) suit_strs.append("".join(ordered)) parts.append(".".join(suit_strs)) return "N:" + " ".join(parts) -def iter_deals(count: int, *, seed: int) -> Iterator[DealSpec]: +def iter_deals( + count: int, *, seed: int, cards_per_hand: int = 13 +) -> Iterator[DealSpec]: """Yield ``count`` random deals.""" if count <= 0: raise ValueError("count must be positive") @@ -94,7 +97,7 @@ def iter_deals(count: int, *, seed: int) -> Iterator[DealSpec]: vul=rng.randrange(4), trump=rng.randrange(5), first=rng.randrange(4), - cards=_deal_cards(rng), + cards=_deal_cards(rng, cards_per_hand=cards_per_hand), ) @@ -271,17 +274,18 @@ def generate_dd_play( *, context: Any | None = None, ) -> str: - """Return a 52-card DD-optimal play string (suit+rank pairs).""" + """Return a DD-optimal play string (suit+rank pairs) for all cards.""" hands = _parse_remain_cards(remain_cards) + total_cards = sum(len(h) for h in hands) play: list[str] = [] leader = first trick: list[tuple[int, int]] = [] ctx = SolverContext() if context is None else context - for _ in range(52): + for _ in range(total_cards): player = (leader + len(trick)) % 4 if not hands[player]: - raise RuntimeError(f"hand {player} empty before 52 cards") + raise RuntimeError(f"hand {player} empty before {total_cards} cards") cur_suits = [0, 0, 0] cur_ranks = [0, 0, 0] @@ -468,6 +472,12 @@ def _build_parser() -> argparse.ArgumentParser: default=1, help="RNG seed for reproducibility (default: 1)", ) + p.add_argument( + "--cards", + type=int, + default=13, + help="Number of cards in each hand (1–13, default: 13)", + ) p.add_argument( "-o", "--output", @@ -485,6 +495,8 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.error("count must be positive") if args.count > _MAX_DEALS: parser.error(f"count must not exceed {_MAX_DEALS}") + if args.cards < 1 or args.cards > 13: + parser.error("cards must be between 1 and 13 inclusive") return args @@ -502,7 +514,8 @@ def main(argv: list[str] | None = None) -> int: try: stream.write(f"NUMBER {args.count} \n") for i, deal in enumerate( - iter_deals(args.count, seed=args.seed), start=1 + iter_deals(args.count, seed=args.seed, cards_per_hand=args.cards), + start=1, ): if i == 1: print("Generating and solving deals…", file=sys.stderr) diff --git a/python/utilities/tests/create_list_for_dtest_test.py b/python/utilities/tests/create_list_for_dtest_test.py index f8c0a5de..1ab3a61e 100644 --- a/python/utilities/tests/create_list_for_dtest_test.py +++ b/python/utilities/tests/create_list_for_dtest_test.py @@ -446,5 +446,36 @@ def test_main_writes_one_filled_deal_to_output_file(self): self.assertIn(f"Wrote 1 deals -> {out}", err.getvalue()) + def test_parse_args_default_cards_is_13(self): + args = cld._parse_args(["--seed", "1"]) + self.assertEqual(args.cards, 13) + + def test_parse_args_accepts_cards_in_valid_range(self): + for n in (1, 7, 13): + args = cld._parse_args(["--seed", "1", "--cards", str(n)]) + self.assertEqual(args.cards, n) + + def test_parse_args_rejects_cards_below_1(self): + with self.assertRaises(SystemExit): + cld._parse_args(["--seed", "1", "--cards", "0"]) + + def test_parse_args_rejects_cards_above_13(self): + with self.assertRaises(SystemExit): + cld._parse_args(["--seed", "1", "--cards", "14"]) + + def test_deal_cards_with_fewer_than_13(self): + rng = cld.random.Random(42) + pbn = cld._deal_cards(rng, cards_per_hand=5) + # Should start with "N:" + self.assertTrue(pbn.startswith("N:")) + hands = pbn[2:].split(" ") + self.assertEqual(len(hands), 4) + for hand in hands: + suits = hand.split(".") + self.assertEqual(len(suits), 4) + total_cards = sum(len(s) for s in suits) + self.assertEqual(total_cards, 5) + + if __name__ == "__main__": unittest.main() From ff16a520bf634412c772b49a113043e1c33531ad Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Wed, 19 Aug 2026 21:46:57 +0200 Subject: [PATCH 02/13] Forward --cards to create_list_for_dtest in regenerate_hand_lists.sh Allows regenerating hand lists with fewer than 13 cards per hand. Co-authored-by: Cursor --- utilities/src/regenerate_hand_lists.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/utilities/src/regenerate_hand_lists.sh b/utilities/src/regenerate_hand_lists.sh index f9e998ab..b869a2ee 100755 --- a/utilities/src/regenerate_hand_lists.sh +++ b/utilities/src/regenerate_hand_lists.sh @@ -7,9 +7,24 @@ # # Usage: # ./utilities/src/regenerate_hand_lists.sh +# ./utilities/src/regenerate_hand_lists.sh --cards 5 # OUT_DIR=/tmp/my-lists ./utilities/src/regenerate_hand_lists.sh set -euo pipefail +CARDS="" +while [[ $# -gt 0 ]]; do + case "$1" in + --cards) + CARDS="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" HANDS_DIR="$ROOT/hands" @@ -47,7 +62,7 @@ GEN="$ROOT/bazel-bin/python/utilities/create_list_for_dtest" for n in "${counts[@]}"; do out="$OUT_DIR/list${n}.txt" echo "Generating list${n}.txt (--seed ${n}) -> $out" - "$GEN" -n "$n" --seed "$n" -o "$out" + "$GEN" -n "$n" --seed "$n" ${CARDS:+--cards "$CARDS"} -o "$out" done echo "Done. Wrote ${#counts[@]} files to $OUT_DIR" From 07bfd9dfa736cbb5a80159f062f854329d55b0f4 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 08:11:08 +0200 Subject: [PATCH 03/13] Clarify deal-generation throughput warning for thirteen-card deals. Co-authored-by: Cursor --- python/utilities/src/create_list_for_dtest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/utilities/src/create_list_for_dtest.py b/python/utilities/src/create_list_for_dtest.py index ff9c8064..dc5c117d 100644 --- a/python/utilities/src/create_list_for_dtest.py +++ b/python/utilities/src/create_list_for_dtest.py @@ -450,7 +450,7 @@ def _large_count_warning(count: int) -> str | None: return ( f"Warning: generating {count} deals may take a long time " f"(each deal runs many DDS solves.)\n" - f"A fast machine can produce ~20 deals per second." + f"A fast machine can produce ~20 thirteen-carddeals per second." ) From 906eb585e0ea6828e2ca9419a0302b10d823c3fd Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 08:12:35 +0200 Subject: [PATCH 04/13] Fix missing space in thirteen-card deals warning. Co-authored-by: Cursor --- python/utilities/src/create_list_for_dtest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/utilities/src/create_list_for_dtest.py b/python/utilities/src/create_list_for_dtest.py index dc5c117d..96ae89d4 100644 --- a/python/utilities/src/create_list_for_dtest.py +++ b/python/utilities/src/create_list_for_dtest.py @@ -450,7 +450,7 @@ def _large_count_warning(count: int) -> str | None: return ( f"Warning: generating {count} deals may take a long time " f"(each deal runs many DDS solves.)\n" - f"A fast machine can produce ~20 thirteen-carddeals per second." + f"A fast machine can produce ~20 thirteen-card deals per second." ) From 2e1e916ca6fceab2892fbafef937772debc45761 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 17:58:53 +0200 Subject: [PATCH 05/13] Fix CalcDDtable trick counts for deals with fewer than 13 cards. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 61 ++++++++++--- library/src/solver_if.cpp | 14 ++- library/tests/system/BUILD.bazel | 11 +++ .../system/calc_dd_table_partial_test.cpp | 86 +++++++++++++++++++ 4 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 library/tests/system/calc_dd_table_partial_test.cpp diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 90116387..cfdbc814 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,34 @@ auto calc_all_boards_n( int max_threads = 0, bool difficulty_sort = true) -> int; +namespace +{ + +// Match SolveBoard's remaining-trick count from remainCards alone. +auto remaining_tricks_from_holdings( + unsigned int const cards[DDS_HANDS][DDS_SUITS]) -> int +{ + int card_count = 0; + for (int h = 0; h < DDS_HANDS; h++) + { + for (int s = 0; s < DDS_SUITS; s++) + card_count += count_table[cards[h][s] >> 2]; + } + + if (card_count % 4) + return ((card_count - 4) >> 2) + 2; + return ((card_count - 4) >> 2) + 1; +} + +auto declarer_tricks_from_leader_score( + int remaining_tricks, + int leader_side_score) -> int +{ + return remaining_tricks - leader_side_score; +} + +} // namespace + auto calc_single_common_internal( SolverContext& ctx, @@ -65,11 +94,16 @@ auto calc_single_common_internal( // for calculation consistency and fixes a previous consistency bug. for (int k = 1; k < DDS_HANDS; k++) { - int hint = (k == 2 ? fut.score[0] : 13 - fut.score[0]); - - deal.first = k; // Next declarer - - res = solve_same_board(ctx, deal, &fut, hint); + deal.first = k; + // Use a full solve_board per leader. solve_same_board's null-window + // reuse is incorrect for deals with fewer than 13 cards per hand. + res = solve_board( + ctx, + deal, + bds.target[bno], + bds.solutions[bno], + bds.mode[bno], + &fut); if (res == 1) solved.solved_board[bno].score[k] = fut.score[0]; @@ -223,6 +257,7 @@ int STDCALL CalcDDtableN( if (res != 1) return res; + const int tricks = remaining_tricks_from_holdings(tableDeal.cards); for (int index = 0; index < DDS_STRAINS; index++) { int strain = bo.deals[index].trump; @@ -232,7 +267,8 @@ int STDCALL CalcDDtableN( for (int first = 0; first < DDS_HANDS; first++) { tablep->res_table[strain][ rho[first] ] = - 13 - solved.solved_board[index].score[first]; + declarer_tricks_from_leader_score( + tricks, solved.solved_board[index].score[first]); } } return RETURN_NO_FAULT; @@ -323,6 +359,7 @@ int STDCALL CalcAllTablesN( for (int m = 0; m < dealsp->no_of_tables; m++) { + const int tricks = remaining_tricks_from_holdings(dealsp->deals[m].cards); for (int strainIndex = 0; strainIndex < count; strainIndex++) { int index = m * count + strainIndex; @@ -333,7 +370,8 @@ int STDCALL CalcAllTablesN( for (int first = 0; first < DDS_HANDS; first++) { resp->results[m].res_table[strain][ rho[first] ] = - 13 - solved.solved_board[index].score[first]; + declarer_tricks_from_leader_score( + tricks, solved.solved_board[index].score[first]); } } } @@ -416,9 +454,9 @@ auto calc_single_deal_scores( for (int k = 1; k < DDS_HANDS; k++) { - const int hint = (k == 2 ? fut.score[0] : 13 - fut.score[0]); deal.first = k; - res = solve_same_board(ctx, deal, &fut, hint); + // Full solve per leader: solve_same_board is wrong for partial deals. + res = solve_board(ctx, deal, target, solutions, mode, &fut); if (res != RETURN_NO_FAULT) return res; scores[k] = fut.score[0]; @@ -528,6 +566,7 @@ int STDCALL CalcAllTablesX( for (int m = 0; m < numDeals; m++) { + const int tricks = remaining_tricks_from_holdings(deals[m].cards); for (int strainIndex = 0; strainIndex < included; strainIndex++) { const int index = m * included + strainIndex; @@ -535,7 +574,9 @@ int STDCALL CalcAllTablesX( for (int first = 0; first < DDS_HANDS; first++) { results[m].res_table[strain][rho[first]] = - 13 - scores[static_cast(index)][static_cast(first)]; + declarer_tricks_from_leader_score( + tricks, + scores[static_cast(index)][static_cast(first)]); } } } diff --git a/library/src/solver_if.cpp b/library/src/solver_if.cpp index f8fa0553..6bf8c471 100644 --- a/library/src/solver_if.cpp +++ b/library/src/solver_if.cpp @@ -728,9 +728,21 @@ auto solve_same_board( ctx.move_gen().reinit(trick, dl.first); + // ini_depth == cardCount - 4 (see solve_board). Bound the null-window + // search by remaining tricks so partial deals cannot report scores > 13 + // leftovers from a full-hand upper bound. + const int card_count = ini_depth + 4; + const int remaining_tricks = (card_count % 4) + ? ((card_count - 4) >> 2) + 2 + : ((card_count - 4) >> 2) + 1; + int guess = hint; + if (guess < 0) + guess = 0; + if (guess > remaining_tricks) + guess = remaining_tricks; int lowerbound = 0; - int upperbound = 13; + int upperbound = remaining_tricks; do { diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index 54c1cc86..86abb141 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -82,6 +82,17 @@ cc_test( ], ) +cc_test( + name = "calc_dd_table_partial_test", + size = "small", + srcs = ["calc_dd_table_partial_test.cpp"], + deps = [ + "//library/src:testable_dds", + "//library/src/api:api_definitions", + "@googletest//:gtest_main", + ], +) + cc_test( name = "worker_context_reuse_test", size = "small", diff --git a/library/tests/system/calc_dd_table_partial_test.cpp b/library/tests/system/calc_dd_table_partial_test.cpp new file mode 100644 index 00000000..a2db4a0c --- /dev/null +++ b/library/tests/system/calc_dd_table_partial_test.cpp @@ -0,0 +1,86 @@ +/// @file calc_dd_table_partial_test.cpp +/// @brief CalcDDtable must report tricks out of remaining cards, not always 13. + +#include +#include + +#include + +namespace +{ + +// One card each: NS hold ♠AK, EW hold ♠QJ. NS take the single trick as either +// declarer; EW take none. +constexpr const char* kOneTrickSpadesPbn = "N:A... Q... K... J..."; + +void expect_ns_take_all_remaining(const DdTableResults& table, int tricks) +{ + for (int strain = 0; strain < DDS_STRAINS; strain++) + { + EXPECT_EQ(table.res_table[strain][0], tricks) << "strain=" << strain << " North"; + EXPECT_EQ(table.res_table[strain][1], 0) << "strain=" << strain << " East"; + EXPECT_EQ(table.res_table[strain][2], tricks) << "strain=" << strain << " South"; + EXPECT_EQ(table.res_table[strain][3], 0) << "strain=" << strain << " West"; + } +} + +} // namespace + +TEST(CalcDdTablePartial, OneCardPerHandUsesRemainingTricksNotThirteen) +{ + InitializeStaticMemory(); + + DdTableDealPBN deal{}; + std::strncpy(deal.cards, kOneTrickSpadesPbn, sizeof(deal.cards) - 1); + deal.cards[sizeof(deal.cards) - 1] = '\0'; + + DdTableResults table{}; + ASSERT_EQ(CalcDDtablePBN(deal, &table), RETURN_NO_FAULT); + + // Regression: before the fix, every entry was 13 or 12 (hardcoded 13 - score). + for (int strain = 0; strain < DDS_STRAINS; strain++) + for (int hand = 0; hand < DDS_HANDS; hand++) + { + EXPECT_GE(table.res_table[strain][hand], 0); + EXPECT_LE(table.res_table[strain][hand], 1) + << "strain=" << strain << " hand=" << hand; + } + + expect_ns_take_all_remaining(table, /*tricks=*/1); +} + +TEST(CalcAllTablesPartial, OneCardPerHandUsesRemainingTricksNotThirteen) +{ + InitializeStaticMemory(); + + DdTableDealsPBN deals{}; + deals.no_of_tables = 1; + std::strncpy(deals.deals[0].cards, kOneTrickSpadesPbn, sizeof(deals.deals[0].cards) - 1); + deals.deals[0].cards[sizeof(deals.deals[0].cards) - 1] = '\0'; + + int trump_filter[DDS_STRAINS] = {0, 0, 0, 0, 0}; + DdTablesRes resp{}; + AllParResults par{}; + ASSERT_EQ( + CalcAllTablesPBN(&deals, /*mode=*/-1, trump_filter, &resp, &par), + RETURN_NO_FAULT); + + expect_ns_take_all_remaining(resp.results[0], /*tricks=*/1); +} + +TEST(CalcAllTablesXPartial, OneCardPerHandUsesRemainingTricksNotThirteen) +{ + InitializeStaticMemory(); + + DdTableDealPBN deal{}; + std::strncpy(deal.cards, kOneTrickSpadesPbn, sizeof(deal.cards) - 1); + deal.cards[sizeof(deal.cards) - 1] = '\0'; + + int trump_filter[DDS_STRAINS] = {0, 0, 0, 0, 0}; + DdTableResults result{}; + ASSERT_EQ( + CalcAllTablesPBNX(1, &deal, /*mode=*/-1, trump_filter, &result, nullptr, 1), + RETURN_NO_FAULT); + + expect_ns_take_all_remaining(result, /*tricks=*/1); +} From 0e1a40ccd5e872c869aa33f1a420e07b83c2cd77 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 18:11:52 +0200 Subject: [PATCH 06/13] Add script to generate partial-hand dtest list files. Co-authored-by: Cursor --- utilities/src/generate_partial_hands_lists.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100755 utilities/src/generate_partial_hands_lists.sh diff --git a/utilities/src/generate_partial_hands_lists.sh b/utilities/src/generate_partial_hands_lists.sh new file mode 100755 index 00000000..aa2e0141 --- /dev/null +++ b/utilities/src/generate_partial_hands_lists.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# This script generates a complete set of listNNN.txt files +# containing deals of fewer than 13 cards per hand. +# The files aremainly used for benchmarking. + +export PARENT_DIR=./hands/partial + +mkdir -p $PARENT_DIR + +for CARDS in $(seq -w 1 12); do + OUT_DIR="${PARENT_DIR}/${CARDS}_cards" \ + ./utilities/src/regenerate_hand_lists.sh \ + --cards $CARDS +done + +mv ${PARENT_DIR}/01_cards/ ${PARENT_DIR}/01_card/ From cfe89e4c77f4efa692996e4c86a42ddd3f3c3470 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 22:56:41 +0200 Subject: [PATCH 07/13] Ignore generated hands/partial/ list files. They are recreated by regenerate_hand_lists.sh / generate_partial_hands_lists.sh when needed. Co-authored-by: Cursor --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e53a6c18..bcfbf7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,7 @@ doxygen_output/ # Claude Code local settings written by cce init .claude/settings.local.json /dotnet/DDS_Core/.github/copilot-instructions.md + +# No need to clutter the repo with these. +# regenerate_hand_lists.sh will recreate them whenever needed. +hands/partial/ From 7f9d1b2422efdf93eb3500eeaa13bf0a24364441 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 23:12:39 +0200 Subject: [PATCH 08/13] Pass --cards via an argv array in regenerate_hand_lists.sh Avoids bash treating quoted expansions from ${CARDS:+...} as literal characters in the generator arguments. Co-authored-by: Cursor --- utilities/src/regenerate_hand_lists.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/utilities/src/regenerate_hand_lists.sh b/utilities/src/regenerate_hand_lists.sh index b869a2ee..aa77429b 100755 --- a/utilities/src/regenerate_hand_lists.sh +++ b/utilities/src/regenerate_hand_lists.sh @@ -62,7 +62,12 @@ GEN="$ROOT/bazel-bin/python/utilities/create_list_for_dtest" for n in "${counts[@]}"; do out="$OUT_DIR/list${n}.txt" echo "Generating list${n}.txt (--seed ${n}) -> $out" - "$GEN" -n "$n" --seed "$n" ${CARDS:+--cards "$CARDS"} -o "$out" + gen_args=(-n "$n" --seed "$n") + if [[ -n "$CARDS" ]]; then + gen_args+=(--cards "$CARDS") + fi + gen_args+=(-o "$out") + "$GEN" "${gen_args[@]}" done echo "Done. Wrote ${#counts[@]} files to $OUT_DIR" From 19e945e2cbf676db30e334bd53a7b378a3478d41 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 23:13:52 +0200 Subject: [PATCH 09/13] Harden partial-hands scripts and cover --cards PLAY output Fix the generate_partial_hands_lists.sh typo, resolve repo root with quoted paths, and add a main() regression test that PLAY has 4*N cards. Co-authored-by: Cursor --- .../tests/create_list_for_dtest_test.py | 25 +++++++++++++++++++ utilities/src/generate_partial_hands_lists.sh | 15 +++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/python/utilities/tests/create_list_for_dtest_test.py b/python/utilities/tests/create_list_for_dtest_test.py index 1ab3a61e..079858b3 100644 --- a/python/utilities/tests/create_list_for_dtest_test.py +++ b/python/utilities/tests/create_list_for_dtest_test.py @@ -476,6 +476,31 @@ def test_deal_cards_with_fewer_than_13(self): total_cards = sum(len(s) for s in suits) self.assertEqual(total_cards, 5) + def test_main_with_cards_writes_play_line_with_four_times_cards(self): + cards_per_hand = 5 + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "list_partial.txt" + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = cld.main( + [ + "-n", + "1", + "--seed", + "1", + "--cards", + str(cards_per_hand), + "-o", + str(out), + ] + ) + self.assertEqual(rc, 0) + play_line = next( + line for line in out.read_text(encoding="utf-8").splitlines() + if line.startswith("PLAY ") + ) + self.assertRegex(play_line, rf'^PLAY {4 * cards_per_hand} "') + if __name__ == "__main__": unittest.main() diff --git a/utilities/src/generate_partial_hands_lists.sh b/utilities/src/generate_partial_hands_lists.sh index aa2e0141..68477702 100755 --- a/utilities/src/generate_partial_hands_lists.sh +++ b/utilities/src/generate_partial_hands_lists.sh @@ -2,16 +2,21 @@ # This script generates a complete set of listNNN.txt files # containing deals of fewer than 13 cards per hand. -# The files aremainly used for benchmarking. +# The files are mainly used for benchmarking. -export PARENT_DIR=./hands/partial +set -euo pipefail -mkdir -p $PARENT_DIR +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$ROOT" + +PARENT_DIR="${ROOT}/hands/partial" +mkdir -p "$PARENT_DIR" for CARDS in $(seq -w 1 12); do OUT_DIR="${PARENT_DIR}/${CARDS}_cards" \ ./utilities/src/regenerate_hand_lists.sh \ - --cards $CARDS + --cards "$CARDS" done -mv ${PARENT_DIR}/01_cards/ ${PARENT_DIR}/01_card/ +mv "${PARENT_DIR}/01_cards/" "${PARENT_DIR}/01_card/" From 44a70eb88384fddf769924830e31b00344af263c Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 23:26:31 +0200 Subject: [PATCH 10/13] Keep solve_same_board for full CalcDDtable deals Use full solve_board only when remaining tricks are fewer than 13 so bulk 13-card CalcDDtable/CalcAllTables keep the fast null-window path. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 53 +++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index cfdbc814..53942256 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -60,6 +60,15 @@ auto declarer_tricks_from_leader_score( return remaining_tricks - leader_side_score; } +// solve_same_board's null-window reuse assumes a full 13-trick deal. +constexpr int kFullDealRemainingTricks = 13; + +auto is_full_thirteen_trick_deal( + unsigned int const cards[DDS_HANDS][DDS_SUITS]) -> bool +{ + return remaining_tricks_from_holdings(cards) == kFullDealRemainingTricks; +} + } // namespace @@ -92,18 +101,29 @@ auto calc_single_common_internal( // for subsequent same-board solves to ensure all declarers on the same // board share the same transposition table state, which is important // for calculation consistency and fixes a previous consistency bug. + const bool reuse_same_board = + is_full_thirteen_trick_deal(deal.remainCards); for (int k = 1; k < DDS_HANDS; k++) { deal.first = k; - // Use a full solve_board per leader. solve_same_board's null-window - // reuse is incorrect for deals with fewer than 13 cards per hand. - res = solve_board( - ctx, - deal, - bds.target[bno], - bds.solutions[bno], - bds.mode[bno], - &fut); + if (reuse_same_board) + { + // Fast path for full deals: null-window reuse with a partner/opponent hint. + const int hint = + (k == 2 ? fut.score[0] : kFullDealRemainingTricks - fut.score[0]); + res = solve_same_board(ctx, deal, &fut, hint); + } + else + { + // Partial deals: solve_same_board hints/reuse are incorrect; full solve. + res = solve_board( + ctx, + deal, + bds.target[bno], + bds.solutions[bno], + bds.mode[bno], + &fut); + } if (res == 1) solved.solved_board[bno].score[k] = fut.score[0]; @@ -452,11 +472,22 @@ auto calc_single_deal_scores( return res; scores[0] = fut.score[0]; + const bool reuse_same_board = + is_full_thirteen_trick_deal(deal.remainCards); for (int k = 1; k < DDS_HANDS; k++) { deal.first = k; - // Full solve per leader: solve_same_board is wrong for partial deals. - res = solve_board(ctx, deal, target, solutions, mode, &fut); + if (reuse_same_board) + { + const int hint = + (k == 2 ? fut.score[0] : kFullDealRemainingTricks - fut.score[0]); + res = solve_same_board(ctx, deal, &fut, hint); + } + else + { + // Partial deals: solve_same_board is wrong; use a full solve per leader. + res = solve_board(ctx, deal, target, solutions, mode, &fut); + } if (res != RETURN_NO_FAULT) return res; scores[k] = fut.score[0]; From ec67105fc31ba68ccb01a9b45e097b4630005bd2 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Fri, 21 Aug 2026 23:29:27 +0200 Subject: [PATCH 11/13] Reject bare --cards in regenerate_hand_lists.sh Give a clear error when --cards has no value instead of an unbound variable failure under set -u. Co-authored-by: Cursor --- utilities/src/regenerate_hand_lists.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utilities/src/regenerate_hand_lists.sh b/utilities/src/regenerate_hand_lists.sh index aa77429b..9228f7e7 100755 --- a/utilities/src/regenerate_hand_lists.sh +++ b/utilities/src/regenerate_hand_lists.sh @@ -15,6 +15,10 @@ CARDS="" while [[ $# -gt 0 ]]; do case "$1" in --cards) + if [[ $# -lt 2 ]]; then + echo "Missing value for --cards (expected 1–13)" >&2 + exit 1 + fi CARDS="$2" shift 2 ;; From 2e309bfac9e42e275f24ffd141f15b8de839edbf Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 22 Aug 2026 00:06:10 +0200 Subject: [PATCH 12/13] Make partial-hands 01_card rename safe to rerun Remove any existing 01_card directory before renaming 01_cards so a second run does not nest directories. Co-authored-by: Cursor --- utilities/src/generate_partial_hands_lists.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utilities/src/generate_partial_hands_lists.sh b/utilities/src/generate_partial_hands_lists.sh index 68477702..3d269fca 100755 --- a/utilities/src/generate_partial_hands_lists.sh +++ b/utilities/src/generate_partial_hands_lists.sh @@ -19,4 +19,6 @@ for CARDS in $(seq -w 1 12); do --cards "$CARDS" done -mv "${PARENT_DIR}/01_cards/" "${PARENT_DIR}/01_card/" +# Idempotent rename: replace 01_card if a previous run left it behind. +rm -rf "${PARENT_DIR}/01_card" +mv "${PARENT_DIR}/01_cards" "${PARENT_DIR}/01_card" From 761a22a8f6f1115a03dae4090c9df3624dbd0ca1 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Sat, 22 Aug 2026 08:10:47 +0200 Subject: [PATCH 13/13] Fix C++ calc_dd_table trick counts for partial deals Share remaining-tricks helpers with calc_tables and add a C++ API regression test so partial-hand deals no longer use hardcoded 13 - score. Co-authored-by: Cursor --- library/src/calc_dd_table.cpp | 4 +++- library/src/calc_tables.cpp | 6 ++--- library/src/calc_tables.hpp | 8 +++++++ .../system/calc_dd_table_partial_test.cpp | 23 +++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/library/src/calc_dd_table.cpp b/library/src/calc_dd_table.cpp index 435250b8..5318958f 100644 --- a/library/src/calc_dd_table.cpp +++ b/library/src/calc_dd_table.cpp @@ -61,6 +61,7 @@ auto calc_dd_table( return res; // Populate result table from solved boards + const int tricks = remaining_tricks_from_holdings(table_deal.cards); for (int index = 0; index < DDS_STRAINS; index++) { int strain = bo.deals[index].trump; @@ -68,7 +69,8 @@ auto calc_dd_table( for (int first = 0; first < DDS_HANDS; first++) { table_results->res_table[strain][ rho[first] ] = - 13 - solved.solved_board[index].score[first]; + declarer_tricks_from_leader_score( + tricks, solved.solved_board[index].score[first]); } } return RETURN_NO_FAULT; diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 53942256..c9ed8a58 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -34,9 +34,6 @@ auto calc_all_boards_n( int max_threads = 0, bool difficulty_sort = true) -> int; -namespace -{ - // Match SolveBoard's remaining-trick count from remainCards alone. auto remaining_tricks_from_holdings( unsigned int const cards[DDS_HANDS][DDS_SUITS]) -> int @@ -60,6 +57,9 @@ auto declarer_tricks_from_leader_score( return remaining_tricks - leader_side_score; } +namespace +{ + // solve_same_board's null-window reuse assumes a full 13-trick deal. constexpr int kFullDealRemainingTricks = 13; diff --git a/library/src/calc_tables.hpp b/library/src/calc_tables.hpp index 70a23592..249c3b5b 100644 --- a/library/src/calc_tables.hpp +++ b/library/src/calc_tables.hpp @@ -54,3 +54,11 @@ auto detect_calc_duplicates( const Boards& bds, std::vector& uniques, std::vector& crossrefs) -> void; + +// Match SolveBoard's remaining-trick count from remainCards alone. +auto remaining_tricks_from_holdings( + unsigned int const cards[DDS_HANDS][DDS_SUITS]) -> int; + +auto declarer_tricks_from_leader_score( + int remaining_tricks, + int leader_side_score) -> int; diff --git a/library/tests/system/calc_dd_table_partial_test.cpp b/library/tests/system/calc_dd_table_partial_test.cpp index a2db4a0c..aea2297e 100644 --- a/library/tests/system/calc_dd_table_partial_test.cpp +++ b/library/tests/system/calc_dd_table_partial_test.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace @@ -84,3 +85,25 @@ TEST(CalcAllTablesXPartial, OneCardPerHandUsesRemainingTricksNotThirteen) expect_ns_take_all_remaining(result, /*tricks=*/1); } + +TEST(CalcDdTablePartialCpp, OneCardPerHandUsesRemainingTricksNotThirteen) +{ + InitializeStaticMemory(); + + DdTableDealPBN deal_pbn{}; + std::strncpy(deal_pbn.cards, kOneTrickSpadesPbn, sizeof(deal_pbn.cards) - 1); + deal_pbn.cards[sizeof(deal_pbn.cards) - 1] = '\0'; + + DdTableResults table{}; + ASSERT_EQ(calc_dd_table_pbn(deal_pbn, &table), RETURN_NO_FAULT); + + for (int strain = 0; strain < DDS_STRAINS; strain++) + for (int hand = 0; hand < DDS_HANDS; hand++) + { + EXPECT_GE(table.res_table[strain][hand], 0); + EXPECT_LE(table.res_table[strain][hand], 1) + << "strain=" << strain << " hand=" << hand; + } + + expect_ns_take_all_remaining(table, /*tricks=*/1); +}