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/ 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 90116387..c9ed8a58 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,43 @@ auto calc_all_boards_n( int max_threads = 0, bool difficulty_sort = true) -> int; +// 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 +{ + +// 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 + auto calc_single_common_internal( SolverContext& ctx, @@ -63,13 +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++) { - 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; + 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]; @@ -223,6 +277,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 +287,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 +379,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 +390,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]); } } } @@ -414,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++) { - const int hint = (k == 2 ? fut.score[0] : 13 - fut.score[0]); deal.first = k; - res = solve_same_board(ctx, deal, &fut, hint); + 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]; @@ -528,6 +597,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 +605,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/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/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..aea2297e --- /dev/null +++ b/library/tests/system/calc_dd_table_partial_test.cpp @@ -0,0 +1,109 @@ +/// @file calc_dd_table_partial_test.cpp +/// @brief CalcDDtable must report tricks out of remaining cards, not always 13. + +#include +#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); +} + +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); +} diff --git a/python/utilities/src/create_list_for_dtest.py b/python/utilities/src/create_list_for_dtest.py index 85eb77a7..96ae89d4 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] @@ -446,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-card deals per second." ) @@ -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..079858b3 100644 --- a/python/utilities/tests/create_list_for_dtest_test.py +++ b/python/utilities/tests/create_list_for_dtest_test.py @@ -446,5 +446,61 @@ 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) + + 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 new file mode 100755 index 00000000..3d269fca --- /dev/null +++ b/utilities/src/generate_partial_hands_lists.sh @@ -0,0 +1,24 @@ +#!/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 are mainly used for benchmarking. + +set -euo pipefail + +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" +done + +# 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" diff --git a/utilities/src/regenerate_hand_lists.sh b/utilities/src/regenerate_hand_lists.sh index f9e998ab..9228f7e7 100755 --- a/utilities/src/regenerate_hand_lists.sh +++ b/utilities/src/regenerate_hand_lists.sh @@ -7,9 +7,28 @@ # # 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) + if [[ $# -lt 2 ]]; then + echo "Missing value for --cards (expected 1–13)" >&2 + exit 1 + fi + 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 +66,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" -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"