diff --git a/library/src/belief_evaluation/BUILD.bazel b/library/src/belief_evaluation/BUILD.bazel new file mode 100644 index 000000000..f21b3be2e --- /dev/null +++ b/library/src/belief_evaluation/BUILD.bazel @@ -0,0 +1,54 @@ +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") +load("@rules_cc//cc:defs.bzl", "cc_library") + +cc_library( + name = "belief_evaluation", + srcs = [ + "kahan.cpp", + "layout_key.cpp", + "rank_map.cpp", + "renumber.cpp", + "validation.cpp", + ], + hdrs = [ + "declarer_strategy.hpp", + "defender_strategy.hpp", + "kahan.hpp", + "layout_key.hpp", + "layout_source.hpp", + "rank_map.hpp", + "renumber.hpp", + "types.hpp", + "validation.hpp", + ], + visibility = ["//visibility:public"], + deps = [ + "//library/src/lookup_tables", + "//library/src/api:api_definitions", + ], + include_prefix = "belief_evaluation", + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, +) + +# Position, legal_plays(), trick_winner() and play_card() are a minimal +# trick-mechanics helper that exists solely to state and test the +# renumbering isomorphism (see position.hpp's own doxygen). Nothing in the +# public belief_evaluation surface depends on it, so it is kept out of that +# library's srcs/hdrs and exposed only to its own test package, mirroring +# the testable_* pattern elsewhere in this tree (e.g. +# //library/src/heuristic_sorting:testable_heuristic_sorting). +cc_library( + name = "position", + srcs = ["position.cpp"], + hdrs = ["position.hpp"], + visibility = ["//library/tests/belief_evaluation:__pkg__"], + deps = [ + "//library/src/utility:constants", + ], + include_prefix = "belief_evaluation", + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, +) diff --git a/library/src/belief_evaluation/declarer_strategy.hpp b/library/src/belief_evaluation/declarer_strategy.hpp new file mode 100644 index 000000000..7371e54dd --- /dev/null +++ b/library/src/belief_evaluation/declarer_strategy.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +/// Declarer's decision strategy. A struct rather than a bare callable because +/// it needs somewhere to hang an identity (`id`) and somewhere to declare +/// what it depends on beyond position (`state_key`). +/// +/// The evaluator calls `play` once per card: whenever the seat on play is +/// declarer or dummy (derivable from `ObservationState`), never with two +/// cards bundled for a trick boundary. At a boundary where declarer wins and +/// leads next, `play` is simply called twice with different states. +struct DeclarerStrategy +{ + StrategyId id; ///< unique among strategies compared together + + /// Chooses declarer's or dummy's next card, whichever seat is on play. + /// + /// **Must be a pure function of its arguments.** The evaluator walks the + /// tree in its own order and revisits sibling subtrees; a strategy that + /// accumulates state across calls will silently return different cards + /// for the same node and corrupt the result. This library only supports + /// deterministic strategies. + /// + /// The most likely accidental violation is a *seeded* strategy: drawing + /// from one PRNG stream across the whole search makes the card returned + /// at a node depend on how many decisions preceded it in traversal + /// order, rather than on the node itself. That breaks silently under + /// early cuts (which change how many draws precede a given node), under + /// any future cache keyed on `state_key`, and makes `state_key` + /// impossible to write honestly in the first place. The remedy is to + /// derive the choice from the state instead of from a stream — + /// `choice = hash(seed, state) mod n` — which gives the same card for + /// the same state regardless of traversal order, cuts, caching, or + /// sibling evaluations. + /// + /// The returned rank is **absolute**, never relative to the node's + /// outstanding-card pool. A strategy reasoning in relative terms + /// converts with one `RankMap::to_absolute` call before returning. + std::function play; + + /// Optional. Declares what `play` consults *beyond* the position the + /// evaluator already keys on — nothing more, since the evaluator + /// supplies the position component of any cache key itself. + /// + /// Unset disables reuse for this strategy entirely. Set but returning an + /// empty key is the strongest declaration available: "nothing beyond the + /// position", i.e. maximal reuse. A key coarser than `play`'s real + /// dependence produces a wrong probability, not a slow one, so this + /// function must be pure on the same terms as `play`. + std::function state_key; +}; diff --git a/library/src/belief_evaluation/defender_strategy.hpp b/library/src/belief_evaluation/defender_strategy.hpp new file mode 100644 index 000000000..14e297a6c --- /dev/null +++ b/library/src/belief_evaluation/defender_strategy.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include + +/// One card a defender might play, and the probability the defender model +/// assigns to it in this layout. +struct WeightedCard +{ + Card card; + Probability probability; +}; + +/// Everything one call to a defender strategy needs. `layout` is the actual +/// layout — perfect information — because version one models +/// perfect-information defenders only. +struct DefenderQuery +{ + Deal const& layout; + int seat; ///< which defender is being asked + ObservationState const& state; ///< what is commonly known +}; + +/// The defender model. Both defenders use the same strategy but feed it +/// different information (`DefenderQuery::seat`) to reach a decision. +/// +/// Returns a distribution rather than a single card, so that randomisation +/// between double-dummy-equivalent cards (restricted choice) is expressible. +/// Every returned card must be held by `seat` in `layout` and legal there; +/// probabilities must be strictly positive and sum to one within tolerance. +/// A card the strategy will never play must be omitted, not given zero +/// probability — the evaluator treats "probability > 0" as the survival +/// test for a layout. +/// +/// Does not receive declarer's strategy or belief space. That independence +/// is what licenses evaluating each node of the search in isolation. +using DefenderStrategy = + std::function(DefenderQuery const&)>; diff --git a/library/src/belief_evaluation/kahan.cpp b/library/src/belief_evaluation/kahan.cpp new file mode 100644 index 000000000..1e52e7cf2 --- /dev/null +++ b/library/src/belief_evaluation/kahan.cpp @@ -0,0 +1,14 @@ +#include + +auto KahanAccumulator::add(double value) -> void +{ + double const y = value - compensation_; + double const t = sum_ + y; + compensation_ = (t - sum_) - y; + sum_ = t; +} + +auto KahanAccumulator::value() const -> double +{ + return sum_; +} diff --git a/library/src/belief_evaluation/kahan.hpp b/library/src/belief_evaluation/kahan.hpp new file mode 100644 index 000000000..6a7a66ca3 --- /dev/null +++ b/library/src/belief_evaluation/kahan.hpp @@ -0,0 +1,15 @@ +#pragma once + +/// Compensated (Kahan) summation. Tracks a running compensation term for the +/// low-order bits lost to each addition's rounding, so that a long running +/// sum accumulates far less error than naive `sum += value`. +class KahanAccumulator +{ +public: + auto add(double value) -> void; + auto value() const -> double; + +private: + double sum_ = 0.0; + double compensation_ = 0.0; +}; diff --git a/library/src/belief_evaluation/layout_key.cpp b/library/src/belief_evaluation/layout_key.cpp new file mode 100644 index 000000000..716b94fb4 --- /dev/null +++ b/library/src/belief_evaluation/layout_key.cpp @@ -0,0 +1,26 @@ +#include + +#include + +namespace +{ + constexpr std::uint64_t ThirteenBitMask = (std::uint64_t{1} << 13) - 1; +} + +auto layout_key(Deal const& deal, int defender_seat) -> std::uint64_t +{ + if (defender_seat < 0 || defender_seat >= DDS_HANDS) + { + return 0; + } + + auto const& holding = deal.remainCards[defender_seat]; + // Each suit is masked to its 13 significant bits after the >> 2 shift: a + // well-formed Deal never sets a remainCards bit above rank 14, but + // nothing in the type enforces that, and an unmasked stray bit would + // shift straight into the next suit's field of the packed key. + return ((std::uint64_t(holding[0]) >> 2) & ThirteenBitMask) + | (((std::uint64_t(holding[1]) >> 2) & ThirteenBitMask) << 13) + | (((std::uint64_t(holding[2]) >> 2) & ThirteenBitMask) << 26) + | (((std::uint64_t(holding[3]) >> 2) & ThirteenBitMask) << 39); +} diff --git a/library/src/belief_evaluation/layout_key.hpp b/library/src/belief_evaluation/layout_key.hpp new file mode 100644 index 000000000..fa16214b5 --- /dev/null +++ b/library/src/belief_evaluation/layout_key.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +/// Exact identity of a layout within one belief node: `defender_seat`'s +/// holding, packed as four 13-bit suits (52 significant bits). Unique only +/// among layouts that share a node's outstanding-card pool — every layout at +/// a node has bit-identical declarer and dummy holdings and differs only in +/// how the pool splits between the two defenders, so one defender's holding +/// determines the other's by complement and identifies the layout completely +/// within that node. This is **not** a cross-node or global layout identity. +/// +/// `defender_seat` outside `0..DDS_HANDS` returns 0 rather than indexing out +/// of bounds. Each suit's holding is masked to its 13 significant bits after +/// shifting, so a malformed `Deal` with stray bits set above rank 14 cannot +/// leak into an adjacent suit's field of the packed key. +auto layout_key(Deal const& deal, int defender_seat) -> std::uint64_t; diff --git a/library/src/belief_evaluation/layout_source.hpp b/library/src/belief_evaluation/layout_source.hpp new file mode 100644 index 000000000..de2b3de74 --- /dev/null +++ b/library/src/belief_evaluation/layout_source.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include + +/// A dumb, ordered, restartable index space over candidate layouts. Knows +/// nothing about observations, weights, or the current search node — the +/// evaluator filters candidates against path history and a node's exclusion +/// set itself, scanning from index 0 until enough matches are found. +class LayoutSource +{ +public: + virtual ~LayoutSource() = default; + + /// Total layouts in the space, or nullopt if not enumerable. A source + /// that cannot report a size can never let a search node establish that + /// it holds the whole remaining belief space rather than a sample of it. + virtual auto size() const -> std::optional = 0; + + /// The layout at `index` in this source's fixed order. Repeated calls + /// with the same index must return the same layout — determinism here is + /// what makes sampling reproducible. + virtual auto at(std::uint64_t index) const -> Deal = 0; +}; diff --git a/library/src/belief_evaluation/position.cpp b/library/src/belief_evaluation/position.cpp new file mode 100644 index 000000000..907fd9a5b --- /dev/null +++ b/library/src/belief_evaluation/position.cpp @@ -0,0 +1,56 @@ +#include + +auto legal_plays(Position const& position, int hand, int led_suit) + -> std::array +{ + std::array result{}; + + if (led_suit != -1 && position.holding[hand][led_suit] != 0) + { + result[led_suit] = position.holding[hand][led_suit]; + return result; + } + + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + result[suit] = position.holding[hand][suit]; + } + return result; +} + +auto trick_winner( + int trump, + int leader, + std::array const& suit_played, + std::array const& bit_played) -> int +{ + int const led_suit = suit_played[0]; + int best_index = 0; + bool best_is_trump = (trump != DDS_NOTRUMP && suit_played[0] == trump); + + for (int i = 1; i < 4; ++i) + { + bool const is_trump = (trump != DDS_NOTRUMP && suit_played[i] == trump); + + if (is_trump && ! best_is_trump) + { + best_index = i; + best_is_trump = true; + } + else if (is_trump == best_is_trump) + { + bool const comparable = is_trump || suit_played[i] == led_suit; + if (comparable && bit_played[i] > bit_played[best_index]) + { + best_index = i; + } + } + } + + return (leader + best_index) % DDS_HANDS; +} + +auto play_card(Position& position, int hand, int suit, int bit_position) -> void +{ + position.holding[hand][suit] &= ~(1u << bit_position); +} diff --git a/library/src/belief_evaluation/position.hpp b/library/src/belief_evaluation/position.hpp new file mode 100644 index 000000000..012d372cf --- /dev/null +++ b/library/src/belief_evaluation/position.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include + +/// A minimal trick-play position for stating the renumbering isomorphism: +/// each hand's outstanding holding per suit, in dds's aggregate bit +/// convention (bit i = the (i+2)-th absolute rank), the trump suit, and the +/// hand on lead for the next trick. Carries no strategy and computes no +/// result — it exists only to let the renumbering isomorphism property be +/// stated and tested. +struct Position +{ + std::array, DDS_HANDS> holding; + int trump; ///< 0..3, or DDS_NOTRUMP + int leader; ///< hand (0..3) on lead for the next trick +}; + +/// Legal cards `hand` may play, one bitmask per suit, given the suit led to +/// the current trick so far (`led_suit`, or -1 if `hand` is leading). A hand +/// must follow `led_suit` if it holds any card there; otherwise every held +/// card is legal. +auto legal_plays(Position const& position, int hand, int led_suit) + -> std::array; + +/// The hand (0..3) that wins a trick of four plays, each given as +/// `(suit, bit_position)`, indexed by the order played starting from +/// `leader`. Highest trump wins if any trump was played; otherwise highest +/// card of the led suit. +auto trick_winner( + int trump, + int leader, + std::array const& suit_played, + std::array const& bit_played) -> int; + +/// Removes one card from `hand`'s holding in `position`, in place. +auto play_card(Position& position, int hand, int suit, int bit_position) -> void; diff --git a/library/src/belief_evaluation/rank_map.cpp b/library/src/belief_evaluation/rank_map.cpp new file mode 100644 index 000000000..b6fff9752 --- /dev/null +++ b/library/src/belief_evaluation/rank_map.cpp @@ -0,0 +1,56 @@ +#include + +#include +#include + +auto RankMap::to_relative(int suit, int rank) const -> int +{ + // Out-of-range suit/rank cannot be outstanding, so 0 ("not outstanding") + // is the correct answer as well as the safe one — without this guard, + // an out-of-range suit indexes aggr out of bounds and an out-of-range + // rank indexes rel_rank's second dimension out of bounds, both undefined + // behaviour. RankMap is a public type callback authors read directly, so + // it must fail safely rather than assume its callers validated first. + if (suit < 0 || suit >= DDS_SUITS || rank < 2 || rank > 14) + { + return 0; + } + return rel_rank[aggr[suit]][rank]; +} + +auto RankMap::to_absolute(int suit, int ordinal) const -> int +{ + // ordinal is a count of top cards to keep, valid over 0..13 (win_ranks' + // second dimension); see win_ranks' doxygen in lookup_tables.hpp. Guard + // both ends and the suit for the same reason as to_relative above. + if (suit < 0 || suit >= DDS_SUITS || ordinal <= 0 || ordinal > 13) + { + return 0; + } + unsigned short const top_n = win_ranks[aggr[suit]][ordinal]; + unsigned short const top_n_minus_one = win_ranks[aggr[suit]][ordinal - 1]; + return highest_rank[top_n ^ top_n_minus_one]; +} + +auto make_rank_map(Deal const& deal) -> RankMap +{ + constexpr unsigned ThirteenBitMask = 0x1FFFu; + + RankMap map{}; + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + unsigned pool = 0; + for (int hand = 0; hand < DDS_HANDS; ++hand) + { + pool |= deal.remainCards[hand][suit]; + } + // remainCards sets bit r for absolute rank r; aggr and the lookup + // tables use the compacted convention, bit r-2 for absolute rank r. + // A well-formed Deal never sets a remainCards bit above rank 14, but + // nothing in the type enforces that, and an unmasked stray bit here + // would push aggr[suit] past 0x1FFF — out of bounds for every + // rel_rank/win_ranks/highest_rank lookup that indexes by aggr. + map.aggr[suit] = (pool >> 2) & ThirteenBitMask; + } + return map; +} diff --git a/library/src/belief_evaluation/rank_map.hpp b/library/src/belief_evaluation/rank_map.hpp new file mode 100644 index 000000000..2c64fc18b --- /dev/null +++ b/library/src/belief_evaluation/rank_map.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include + +/// Builds a RankMap from a Deal: aggr[s] is the OR of remainCards[h][s] +/// across all four hands, for each suit s, converted from Deal's public bit +/// convention (bit r = absolute rank r) to the compacted convention the +/// lookup tables use (bit r-2 = absolute rank r), masked to 13 significant +/// bits so a malformed Deal with a stray bit set above rank 14 cannot push +/// an aggr entry out of the lookup tables' valid index range. +auto make_rank_map(Deal const& deal) -> RankMap; diff --git a/library/src/belief_evaluation/renumber.cpp b/library/src/belief_evaluation/renumber.cpp new file mode 100644 index 000000000..1dfe49723 --- /dev/null +++ b/library/src/belief_evaluation/renumber.cpp @@ -0,0 +1,20 @@ +#include + +auto renumber(unsigned holding, unsigned pool) -> unsigned +{ + unsigned result = 0; + unsigned out_bit = 1; + for (unsigned in_bit = 1; pool != 0 && in_bit != 0; in_bit <<= 1) + { + if ((pool & in_bit) != 0) + { + if ((holding & in_bit) != 0) + { + result |= out_bit; + } + out_bit <<= 1; + pool &= ~in_bit; + } + } + return result; +} diff --git a/library/src/belief_evaluation/renumber.hpp b/library/src/belief_evaluation/renumber.hpp new file mode 100644 index 000000000..040adc4ad --- /dev/null +++ b/library/src/belief_evaluation/renumber.hpp @@ -0,0 +1,7 @@ +#pragma once + +/// Compress `holding` to consecutive low bits, keeping only the positions +/// set in `pool`. Bit 0 of the result is the lowest outstanding card in the +/// suit. Both arguments use dds's aggregate bit convention: bit i is +/// absolute rank i + 2. +auto renumber(unsigned holding, unsigned pool) -> unsigned; diff --git a/library/src/belief_evaluation/types.hpp b/library/src/belief_evaluation/types.hpp new file mode 100644 index 000000000..f0929ea84 --- /dev/null +++ b/library/src/belief_evaluation/types.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +/// A single card. Suits and ranks are otherwise passed as separate `int`s +/// throughout dds; this exists purely as a convenient callback return/param. +struct Card +{ + int suit; ///< 0=S, 1=H, 2=D, 3=C + int rank; ///< 2..14 +}; + +/// Identifies a declarer strategy among strategies compared together. +/// Unused beyond distinctness validation in version one; load-bearing once +/// strategy comparison is added. +using StrategyId = std::uint32_t; + +/// Opaque byte string a strategy uses to declare what `play` consults beyond +/// the position the evaluator already keys on. The evaluator never +/// interprets the contents; equal keys assert identical future play for +/// identical remaining position. See `DeclarerStrategy::state_key`. +using StateKey = std::string; + +/// p — probability the defenders played a given card sequence in one layout, +/// or (for BeliefEntry) a normalised posterior. Kept as a distinct alias from +/// SampleWeight because the evaluation note keeps the two distinct in the +/// notation even though both are `double` underneath. +using Probability = double; + +/// kappa — the sample weight carried along a search path; 1/M at the root of +/// a sampled evaluation. Distinct from Probability for the same reason. +using SampleWeight = double; + +/// Outstanding-card pool and the absolute/relative rank mapping over it, for +/// one belief-evaluation node. Layout-invariant across the node: every +/// layout in a node shares the same outstanding cards per suit and differs +/// only in how the defenders' cards are split. See rank_map.hpp for the +/// method bodies and how `aggr` is built from a Deal. +struct RankMap +{ + std::array aggr; ///< outstanding pool per suit + + /// Absolute rank -> relative, 1 = highest, 0 if not outstanding. Also 0, + /// rather than undefined behaviour, for a `suit` outside `0..DDS_SUITS` + /// or a `rank` outside `2..14` — indistinguishable from "not + /// outstanding" by design, since no valid holding could contain either. + auto to_relative(int suit, int rank) const -> int; + + /// Relative ordinal (1 = highest) -> absolute rank. Also 0, rather than + /// undefined behaviour, for a `suit` outside `0..DDS_SUITS` or an + /// `ordinal` outside `1..13`. + auto to_absolute(int suit, int ordinal) const -> int; +}; + +/// The commonly-known part of a belief-evaluation node: identical in every +/// layout of the belief space, and everything a declarer strategy may +/// condition on directly (as opposed to through the belief view). +struct ObservationState +{ + int trump; + int first; ///< seat on lead at the root + PlayTraceBin history; ///< every card played so far, in order + int declarer; ///< seat; dummy is (declarer + 2) % 4 + int tricks_needed; ///< tricks still required to make the contract + int tricks_won_by_declarer; + Deal known_holdings; ///< declarer + dummy exact; defender entries are the union pool + RankMap ranks; +}; + +/// One layout in a belief view, paired with its normalised posterior. +struct BeliefEntry +{ + Deal const& layout; + Probability posterior; ///< normalised; the entries of one BeliefView sum to 1 +}; + +/// What a declarer strategy reasons over: the belief space as declarer +/// currently knows it. Sample weights and any rescaling are the evaluator's +/// bookkeeping and never cross into this view — only the normalised +/// posterior does. +struct BeliefView +{ + std::span entries; + bool is_sample; ///< false only when this node holds the whole remaining space + std::size_t space_size; ///< layouts believed consistent, if known; 0 when unknown +}; + +/// A declarer node's per-child bookkeeping record. Version one always +/// populates exactly one entry in `p_make`, since evaluating one strategy is +/// all this plan's successors do; the map shape is load-bearing for later +/// plans that compare strategies. +struct NodeSearchInfo +{ + Deal renumbered; ///< remaining cards, gaps removed + int max_tricks; ///< strategy-independent upper bound + int min_tricks; ///< strategy-independent lower bound + std::map, Probability> p_make; ///< keyed by (strategy, tricks needed) +}; diff --git a/library/src/belief_evaluation/validation.cpp b/library/src/belief_evaluation/validation.cpp new file mode 100644 index 000000000..d5393fd83 --- /dev/null +++ b/library/src/belief_evaluation/validation.cpp @@ -0,0 +1,116 @@ +#include + +#include + +namespace +{ + constexpr double ProbabilitySumTolerance = 1e-6; + + auto is_held(Deal const& deal, int seat, Card const& card) -> bool + { + // A malformed seat/suit/rank cannot possibly be held — but without + // this guard it would index deal.remainCards out of bounds and shift + // by an out-of-range amount, both undefined behaviour, before ever + // reaching a "not held" answer. An invalid card is rejected the same + // way an absent one is: CardNotHeld, since no valid holding could + // ever contain it. + if (seat < 0 || seat >= DDS_HANDS || card.suit < 0 || card.suit >= DDS_SUITS + || card.rank < 2 || card.rank > 14) + { + return false; + } + unsigned const holding = deal.remainCards[seat][card.suit]; + return (holding & (1u << card.rank)) != 0; + } + + /// -1 if no card has yet been played to the trick in progress, else the + /// suit of the first card played (currentTrickSuit[0]). Also -1 for a + /// malformed currentTrickSuit[0] (outside 0..DDS_SUITS): deal is not a + /// user-supplied value validated here, but nothing downstream should + /// index remainCards by an unguarded suit either. + auto led_suit(Deal const& deal) -> int + { + if (deal.currentTrickRank[0] == 0) + { + return -1; + } + int const suit = deal.currentTrickSuit[0]; + if (suit < 0 || suit >= DDS_SUITS) + { + return -1; + } + return suit; + } + + /// Whether `card` is legal for the trick currently in progress in `deal`, + /// for a seat that holds `card`: must follow the led suit if `seat` + /// holds any card of it. Shared by declarer and defender validation. + auto follows_suit(Deal const& deal, int seat, Card const& card) -> bool + { + int const led = led_suit(deal); + return led == -1 || led == card.suit || deal.remainCards[seat][led] == 0; + } +} + +auto validate_declarer_card(Deal const& deal, int seat, Card const& card) -> ValidationError +{ + if (! is_held(deal, seat, card)) + { + return ValidationError::CardNotHeld; + } + + if (! follows_suit(deal, seat, card)) + { + return ValidationError::CardIllegalForTrick; + } + + return ValidationError::None; +} + +auto validate_defender_distribution( + Deal const& layout, + int seat, + std::vector const& distribution) -> ValidationError +{ + // Every entry is checked against `seat` via is_held() below, but an + // empty distribution never enters that loop — without this upfront + // check an invalid seat with no cards to report would fall through to + // ProbabilitiesDoNotSumToOne, misleadingly naming the wrong problem and + // disagreeing with validate_declarer_card, which always reports an + // invalid seat as CardNotHeld regardless of the card. + if (seat < 0 || seat >= DDS_HANDS) + { + return ValidationError::CardNotHeld; + } + + double total = 0.0; + for (auto const& entry : distribution) + { + if (! is_held(layout, seat, entry.card)) + { + return ValidationError::CardNotHeld; + } + if (! follows_suit(layout, seat, entry.card)) + { + return ValidationError::CardIllegalForTrick; + } + // NaN fails every comparison, so an unguarded `<= 0.0` check lets it + // through, and the sum-tolerance check below would too (NaN also + // fails `>`) once it has poisoned `total`. +-Inf is a positive + // number by that same `<= 0.0` test but is not a legitimate + // probability either. Reject all non-finite values up front, + // reusing ProbabilityNonPositive since none of them is one. + if (! std::isfinite(entry.probability) || entry.probability <= 0.0) + { + return ValidationError::ProbabilityNonPositive; + } + total += entry.probability; + } + + if (std::abs(total - 1.0) > ProbabilitySumTolerance) + { + return ValidationError::ProbabilitiesDoNotSumToOne; + } + + return ValidationError::None; +} diff --git a/library/src/belief_evaluation/validation.hpp b/library/src/belief_evaluation/validation.hpp new file mode 100644 index 000000000..a151e32da --- /dev/null +++ b/library/src/belief_evaluation/validation.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include + +#include +#include + +/// Distinct rejection reasons for a user-supplied callback return. A +/// user-supplied callback is an input, not an internal — violations are +/// reported, never asserted. +enum class ValidationError +{ + None, ///< the input is valid + CardNotHeld, ///< the card is not in the seat's remaining holding + CardIllegalForTrick, ///< the seat holds the led suit but the card is of another suit + ProbabilityNonPositive, ///< a WeightedCard's probability is <= 0, NaN, or +-infinite + ProbabilitiesDoNotSumToOne, ///< the distribution's probabilities do not sum to 1 within tolerance +}; + +/// Validates a card a declarer strategy returned from `play`: held by `seat` +/// in `deal`, and legal for the trick in progress (must follow suit if +/// `seat` holds the led suit). +auto validate_declarer_card(Deal const& deal, int seat, Card const& card) -> ValidationError; + +/// Validates a distribution a defender strategy returned: every card held by +/// `seat` in `layout` and legal for the trick in progress (must follow suit +/// if `seat` holds the led suit), every probability strictly positive, and +/// the probabilities summing to 1 within tolerance. A card the strategy will +/// never play must be omitted rather than given zero probability, so a +/// non-positive probability is a contract violation, not "never". +auto validate_defender_distribution( + Deal const& layout, + int seat, + std::vector const& distribution) -> ValidationError; diff --git a/library/tests/belief_evaluation/BUILD.bazel b/library/tests/belief_evaluation/BUILD.bazel new file mode 100644 index 000000000..dcce3bc0d --- /dev/null +++ b/library/tests/belief_evaluation/BUILD.bazel @@ -0,0 +1,28 @@ +load("//:CPPVARIABLES.bzl", "DDS_CPPOPTS", "DDS_LINKOPTS", "DDS_LOCAL_DEFINES") +load("@rules_cc//cc:defs.bzl", "cc_test") + +cc_test( + name = "belief_evaluation_test", + size = "small", + srcs = [ + "aggr_invariance_test.cpp", + "kahan_test.cpp", + "layout_key_test.cpp", + "position_test.cpp", + "rank_map_test.cpp", + "renumber_test.cpp", + "smoke_test.cpp", + "validation_test.cpp", + ], + deps = [ + "//library/src/belief_evaluation", + "//library/src/belief_evaluation:position", + "//library/src/lookup_tables", + "//library/src/api:api_definitions", + "//library/src/utility:constants", + "@googletest//:gtest_main", + ], + copts = DDS_CPPOPTS, + linkopts = DDS_LINKOPTS, + local_defines = DDS_LOCAL_DEFINES, +) diff --git a/library/tests/belief_evaluation/aggr_invariance_test.cpp b/library/tests/belief_evaluation/aggr_invariance_test.cpp new file mode 100644 index 000000000..d3acabd06 --- /dev/null +++ b/library/tests/belief_evaluation/aggr_invariance_test.cpp @@ -0,0 +1,55 @@ +#include + +#include + +#include +#include + +#include + +TEST(AggrInvariance, IdenticalAcrossEveryDefenderSplitOfTheSamePool) +{ + // Declarer (South, seat 2) and dummy (North, seat 0) hold fixed cards; + // East/West share the outstanding pool 0b1111 (four spades) in every + // possible two-way split. + auto const make_deal = [](unsigned east_spades, unsigned west_spades) + { + Deal deal{}; + deal.trump = DDS_NOTRUMP; + deal.first = 1; + deal.remainCards[0][0] = 1u << 6; // North (dummy): fifth spade (rank 6) + deal.remainCards[2][0] = 1u << 7; // South (declarer): sixth spade (rank 7) + deal.remainCards[1][0] = east_spades; + deal.remainCards[3][0] = west_spades; + return deal; + }; + + unsigned const pool = 0b1111 << 2; // four outstanding spades (ranks 2-5) to split + std::vector maps; + // Standard submask enumeration: every subset of `pool`, including 0 and + // `pool` itself, each visited exactly once. + unsigned east_subset = pool; + while (true) + { + unsigned const west_subset = pool & ~east_subset; + maps.push_back(make_rank_map(make_deal(east_subset, west_subset))); + if (east_subset == 0) + { + break; + } + east_subset = (east_subset - 1) & pool; + } + + ASSERT_EQ(maps.size(), 16u); + for (auto const& map : maps) + { + EXPECT_EQ(map.aggr[0], maps.front().aggr[0]); + } + for (auto const& map : maps) + { + for (int suit = 1; suit < DDS_SUITS; ++suit) + { + EXPECT_EQ(map.aggr[suit], 0u); + } + } +} diff --git a/library/tests/belief_evaluation/kahan_test.cpp b/library/tests/belief_evaluation/kahan_test.cpp new file mode 100644 index 000000000..9081e94bf --- /dev/null +++ b/library/tests/belief_evaluation/kahan_test.cpp @@ -0,0 +1,34 @@ +#include + +#include + +#include + +TEST(KahanAccumulator, RecoversPrecisionNaiveSummationLoses) +{ + constexpr int count = 100000; + constexpr double term = 0.1; + constexpr double exact = count * term; // 10000.0, mathematically + + double naive = 0.0; + KahanAccumulator kahan; + for (int i = 0; i < count; ++i) + { + naive += term; + kahan.add(term); + } + + double const naive_error = std::abs(naive - exact); + double const kahan_error = std::abs(kahan.value() - exact); + + EXPECT_GT(naive_error, 0.0) + << "test sequence should demonstrate naive precision loss; pick a " + "different sequence if this fails"; + EXPECT_LT(kahan_error, naive_error); +} + +TEST(KahanAccumulator, EmptyAccumulatorIsZero) +{ + KahanAccumulator const kahan; + EXPECT_EQ(kahan.value(), 0.0); +} diff --git a/library/tests/belief_evaluation/layout_key_test.cpp b/library/tests/belief_evaluation/layout_key_test.cpp new file mode 100644 index 000000000..991cf2f37 --- /dev/null +++ b/library/tests/belief_evaluation/layout_key_test.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include + +#include +#include + +#include + +namespace +{ + auto make_deal_with_defender_spades(int seat, unsigned spades_bits_from_rank_2) -> Deal + { + Deal deal{}; + deal.trump = DDS_NOTRUMP; + deal.first = 0; + deal.remainCards[seat][0] = spades_bits_from_rank_2 << 2; + return deal; + } +} + +TEST(LayoutKey, DistinctSplitsOverASmallPoolProduceDistinctKeys) +{ + // Every 4-bit subset held by West (seat 3) must produce a distinct key. + std::vector keys; + for (unsigned subset = 0; subset <= 0b1111; ++subset) + { + Deal const deal = make_deal_with_defender_spades(3, subset); + keys.push_back(layout_key(deal, 3)); + } + + std::sort(keys.begin(), keys.end()); + EXPECT_EQ(std::adjacent_find(keys.begin(), keys.end()), keys.end()) + << "expected all 16 keys to be distinct"; +} + +TEST(LayoutKey, IdenticalSplitsProduceIdenticalKeys) +{ + Deal const a = make_deal_with_defender_spades(1, 0b1010); + Deal const b = make_deal_with_defender_spades(1, 0b1010); + EXPECT_EQ(layout_key(a, 1), layout_key(b, 1)); +} + +TEST(LayoutKey, DifferentSuitsOccupyDisjointBitRanges) +{ + Deal spades_only{}; + spades_only.remainCards[2][0] = 0b1 << 2; // South holds deuce of spades + + Deal hearts_only{}; + hearts_only.remainCards[2][1] = 0b1 << 2; // South holds deuce of hearts + + EXPECT_NE(layout_key(spades_only, 2), layout_key(hearts_only, 2)); +} + +TEST(LayoutKey, RejectsAnOutOfRangeSeatWithoutUndefinedBehaviour) +{ + Deal const deal = make_deal_with_defender_spades(1, 0b1010); + EXPECT_EQ(layout_key(deal, DDS_HANDS), 0u); + EXPECT_EQ(layout_key(deal, -1), 0u); +} + +TEST(LayoutKey, StrayBitsAboveRankFourteenDoNotLeakIntoTheNextSuitsField) +{ + // A well-formed Deal never sets remainCards bits above bit 14 (ace), but + // nothing in the type stops it. Without masking after `>> 2`, a stray + // high bit here would shift into spades' 13-bit field of the packed key + // (hearts occupies bits 13..25, so anything above hearts' own 13 bits + // once shifted would collide with spades' field at bits 0..12... in + // practice it collides one field up, into the next suit checked below). + Deal clean{}; + clean.remainCards[2][1] = 0b1 << 2; // South: deuce of hearts only + + Deal with_stray_bits = clean; + with_stray_bits.remainCards[2][1] |= 1u << 20; // stray bit far above any legal rank + + EXPECT_EQ(layout_key(clean, 2), layout_key(with_stray_bits, 2)) + << "a stray bit above rank 14 must not change the packed key"; +} diff --git a/library/tests/belief_evaluation/position_test.cpp b/library/tests/belief_evaluation/position_test.cpp new file mode 100644 index 000000000..36beb61bd --- /dev/null +++ b/library/tests/belief_evaluation/position_test.cpp @@ -0,0 +1,376 @@ +#include + +#include + +#include + +#include +#include + +namespace +{ + // The pool (OR of all four hands' holdings) per suit, fixed for the + // whole deal — renumbering is applied once, up front, to the starting + // position, exactly as algorithm.md's "randomised array" model does. + auto pool_per_suit(Position const& position) -> std::array + { + std::array pool{}; + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + unsigned p = 0; + for (int hand = 0; hand < DDS_HANDS; ++hand) + { + p |= position.holding[hand][suit]; + } + pool[suit] = p; + } + return pool; + } + + auto renumbered_position(Position const& original, std::array const& pool) + -> Position + { + Position result{}; + result.trump = original.trump; + result.leader = original.leader; + for (int hand = 0; hand < DDS_HANDS; ++hand) + { + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + result.holding[hand][suit] = renumber(original.holding[hand][suit], pool[suit]); + } + } + return result; + } + + // Bit position of the single set bit in a one-card mask. + auto single_bit_position(unsigned mask) -> int + { + for (int bit = 0; bit < 13; ++bit) + { + if ((mask & (1u << bit)) != 0) + { + return bit; + } + } + return -1; + } + + auto total_holding(Position const& position, int hand) -> unsigned + { + unsigned total = 0; + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + total |= position.holding[hand][suit]; + } + return total; + } + + // Exhaustively walks every legal line of play from `orig` (the caller's + // position) alongside its fixed renumbering `renum`, asserting at every + // node that legal move sets correspond under `renumber` and, at every + // completed trick, that the winning hand corresponds; at the end of the + // deal, that every hand's trick count corresponds. + class IsomorphismWalk + { + public: + IsomorphismWalk(Position const& original, std::array const& pool) + : pool_(pool) + { + walk( + original, + renumbered_position(original, pool), + original.leader, + -1, + {}, + {}, + {}, + 0, + original.leader, + {}, + {}); + } + + private: + std::array pool_; + + auto walk( + Position orig, + Position renum, + int hand, + int led_suit, + std::array suits, + std::array orig_bits, + std::array renum_bits, + int count_in_trick, + int trick_leader, + std::array orig_tricks, + std::array renum_tricks) -> void + { + if (count_in_trick == 0 && total_holding(orig, hand) == 0) + { + // Deal complete: every hand's trick count must correspond. + // Also assert every hand is actually empty, not just the one + // about to lead — a starting position with unequal card + // counts per hand would otherwise let this walk terminate + // early and silently under-cover the isomorphism property, + // passing without ever having walked the whole deal. + for (int h = 0; h < DDS_HANDS; ++h) + { + EXPECT_EQ(total_holding(orig, h), 0u) + << "hand " << h << " still holds cards; the starting " + "position is unbalanced"; + EXPECT_EQ(orig_tricks[h], renum_tricks[h]); + } + return; + } + + auto const orig_legal = legal_plays(orig, hand, led_suit); + auto const renum_legal = legal_plays(renum, hand, led_suit); + + // A hand that is unexpectedly empty mid-trick (not at a trick + // boundary) returns an all-zero legal set here, and the loop + // below then has nothing to iterate — the walk would otherwise + // dead-end silently, firing no assertion at all rather than + // failing loudly, hiding the same class of unbalanced-position + // bug the trick-boundary check above guards against. + EXPECT_NE( + orig_legal[0] | orig_legal[1] | orig_legal[2] | orig_legal[3], 0u) + << "hand " << hand << " has no legal play mid-trick; the " + "starting position is unbalanced"; + + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + EXPECT_EQ(renumber(orig_legal[suit], pool_[suit]), renum_legal[suit]) + << "hand=" << hand << " suit=" << suit; + } + + for (int suit = 0; suit < DDS_SUITS; ++suit) + { + unsigned remaining = orig_legal[suit]; + while (remaining != 0) + { + int const bit = single_bit_position(remaining); + remaining &= ~(1u << bit); + + Position next_orig = orig; + play_card(next_orig, hand, suit, bit); + + int const renum_bit = + single_bit_position(renumber(1u << bit, pool_[suit])); + Position next_renum = renum; + play_card(next_renum, hand, suit, renum_bit); + + auto next_suits = suits; + auto next_orig_bits = orig_bits; + auto next_renum_bits = renum_bits; + next_suits[count_in_trick] = suit; + next_orig_bits[count_in_trick] = bit; + next_renum_bits[count_in_trick] = renum_bit; + + int const new_led_suit = (count_in_trick == 0) ? suit : led_suit; + + if (count_in_trick == 3) + { + int const orig_winner = + trick_winner(orig.trump, trick_leader, next_suits, next_orig_bits); + int const renum_winner = + trick_winner(renum.trump, trick_leader, next_suits, next_renum_bits); + EXPECT_EQ(orig_winner, renum_winner); + + auto next_orig_tricks = orig_tricks; + auto next_renum_tricks = renum_tricks; + ++next_orig_tricks[orig_winner]; + ++next_renum_tricks[renum_winner]; + + walk( + next_orig, + next_renum, + orig_winner, + -1, + {}, + {}, + {}, + 0, + orig_winner, + next_orig_tricks, + next_renum_tricks); + } + else + { + walk( + next_orig, + next_renum, + (hand + 1) % DDS_HANDS, + new_led_suit, + next_suits, + next_orig_bits, + next_renum_bits, + count_in_trick + 1, + trick_leader, + orig_tricks, + renum_tricks); + } + } + } + } + }; + + auto assert_isomorphic(Position const& original) -> void + { + IsomorphismWalk(original, pool_per_suit(original)); + } +} // namespace + +// Direct correctness checks against actual bridge rules — independent of the +// renumbering isomorphism, which is agnostic to *which* order-based rule +// trick_winner applies (an order-preserving bijection keeps "lowest wins" +// just as internally consistent as "highest wins"). These pin the rule down. + +TEST(TrickWinner, HighestCardOfLedSuitWinsWithNoTrumpInPlay) +{ + // Leader (hand 0) leads suit 0, bit 3; others follow suit with lower + // cards. No trump in play (NOTRUMP). + std::array suits{0, 0, 0, 0}; + std::array bits{3, 1, 2, 0}; + EXPECT_EQ(trick_winner(DDS_NOTRUMP, 0, suits, bits), 0); +} + +TEST(TrickWinner, HighestCardOfLedSuitWinsWhenNotTheLeader) +{ + std::array suits{0, 0, 0, 0}; + std::array bits{1, 3, 2, 0}; + // Play order is (leader, leader+1, leader+2, leader+3) = hands (2,3,0,1) + // when leader = 2. The highest bit is at play-index 1 -> hand 3. + EXPECT_EQ(trick_winner(DDS_NOTRUMP, 2, suits, bits), 3); +} + +TEST(TrickWinner, AnyTrumpBeatsAnyCardOfTheLedSuit) +{ + // Led suit 0 (spades); play-index 2 discards a low trump (suit 1). + std::array suits{0, 0, 1, 0}; + std::array bits{5, 6, 0, 4}; // trump bit 0 is the lowest card there is + EXPECT_EQ(trick_winner(/* trump = */ 1, 0, suits, bits), 2); +} + +TEST(TrickWinner, HighestTrumpWinsWhenSeveralTrumpsArePlayed) +{ + std::array suits{0, 1, 1, 0}; + std::array bits{5, 2, 7, 4}; + EXPECT_EQ(trick_winner(/* trump = */ 1, 0, suits, bits), 2); +} + +TEST(TrickWinner, DiscardsOffSuitAndNotTrumpNeverWin) +{ + // Led suit 0; play-index 3 discards suit 2 (not trump); trump is suit 1 + // but nobody plays it, so the highest card of the led suit wins. + std::array suits{0, 0, 0, 2}; + std::array bits{2, 5, 1, 12}; + EXPECT_EQ(trick_winner(/* trump = */ 1, 0, suits, bits), 1); +} + +TEST(LegalPlays, MustFollowSuitWhenHoldingTheLedSuit) +{ + Position position{}; + position.holding[0][0] = 0b101; // spades: two cards + position.holding[0][1] = 0b010; // hearts: one card + + auto const legal = legal_plays(position, 0, /* led_suit = */ 0); + + EXPECT_EQ(legal[0], 0b101u); + EXPECT_EQ(legal[1], 0u); +} + +TEST(LegalPlays, AnyHeldCardIsLegalWhenVoidInTheLedSuit) +{ + Position position{}; + position.holding[0][0] = 0; // void in spades + position.holding[0][1] = 0b010; + + auto const legal = legal_plays(position, 0, /* led_suit = */ 0); + + EXPECT_EQ(legal[0], 0u); + EXPECT_EQ(legal[1], 0b010u); +} + +TEST(LegalPlays, AnyHeldCardIsLegalWhenLeading) +{ + Position position{}; + position.holding[0][0] = 0b101; + position.holding[0][1] = 0b010; + + auto const legal = legal_plays(position, 0, /* led_suit = */ -1); + + EXPECT_EQ(legal[0], 0b101u); + EXPECT_EQ(legal[1], 0b010u); +} + +TEST(PositionIsomorphism, TwoCardsPerHandOneSuit) +{ + // Spades only: N={0,1} E={2,3} S={4,5} W={6,7}, notrump. + Position position{}; + position.trump = DDS_NOTRUMP; + position.leader = 0; + position.holding[0][0] = 0b00000011; + position.holding[1][0] = 0b00001100; + position.holding[2][0] = 0b00110000; + position.holding[3][0] = 0b11000000; + + assert_isomorphic(position); +} + +TEST(PositionIsomorphism, ThreeCardsPerHandTwoSuits) +{ + // Spades: 2 each. Hearts: 1 each. Notrump. + Position position{}; + position.trump = DDS_NOTRUMP; + position.leader = 1; + position.holding[0][0] = 0b0011; + position.holding[1][0] = 0b1100; + position.holding[2][0] = 0b0011 << 4; + position.holding[3][0] = 0b1100 << 4; + position.holding[0][1] = 0b0001; + position.holding[1][1] = 0b0010; + position.holding[2][1] = 0b0100; + position.holding[3][1] = 0b1000; + + assert_isomorphic(position); +} + +TEST(PositionIsomorphism, FiveCardsPerHandThreeSuitsWithTrumpAndAVoid) +{ + // Every hand holds exactly 5 cards. Spades (trump): 2 each, 8 of the + // suit's 13 bits. Hearts: N,E,W hold 2 each, South holds only 1 — South + // goes void in hearts after playing it, enabling a ruff. Diamonds: + // N,E,W hold 1 each, South holds 2, making up South's fifth card. + // + // (An earlier version of this test gave South 4 spades + 1 heart + + // 2 diamonds = 7 cards against 5 for every other hand. The isomorphism + // walk terminates once the hand about to lead is empty, so that + // imbalance let N/E/W's exhaustion end the walk early — South's extra + // two cards, and the tricks that would have played them, were never + // walked, silently under-covering the property this test exists to + // assert. Every hand having the same total is what makes "the hand + // about to lead is empty" a sound stand-in for "the deal is complete"; + // the walk itself now also asserts this explicitly.) + Position position{}; + position.trump = 0; // spades + position.leader = 0; + + position.holding[0][0] = 0b00000011; // N spades: bits 0,1 + position.holding[1][0] = 0b00001100; // E spades: bits 2,3 + position.holding[2][0] = 0b00110000; // S spades: bits 4,5 + position.holding[3][0] = 0b11000000; // W spades: bits 6,7 + + position.holding[0][1] = 0b0000011; // N hearts: bits 0,1 + position.holding[1][1] = 0b0001100; // E hearts: bits 2,3 + position.holding[2][1] = 0b0010000; // S hearts: bit 4 (void after played) + position.holding[3][1] = 0b1100000; // W hearts: bits 5,6 + + position.holding[0][2] = 0b00001; // N diamonds: bit 0 + position.holding[1][2] = 0b00010; // E diamonds: bit 1 + position.holding[2][2] = 0b01100; // S diamonds: bits 2,3 + position.holding[3][2] = 0b10000; // W diamonds: bit 4 + + assert_isomorphic(position); +} diff --git a/library/tests/belief_evaluation/rank_map_test.cpp b/library/tests/belief_evaluation/rank_map_test.cpp new file mode 100644 index 000000000..7fda629a6 --- /dev/null +++ b/library/tests/belief_evaluation/rank_map_test.cpp @@ -0,0 +1,98 @@ +#include + +#include +#include +#include + +#include +#include + +class RankMapTest : public ::testing::Test +{ +protected: + auto SetUp() -> void override + { + init_lookup_tables(); + } +}; + +TEST_F(RankMapTest, RoundTripHoldsForEveryOutstandingRankInEveryAggregate) +{ + for (unsigned aggregate = 1; aggregate < 8192; ++aggregate) + { + RankMap map{}; + map.aggr[0] = aggregate; + for (int rank = 2; rank <= 14; ++rank) + { + int const relative = map.to_relative(0, rank); + if (relative == 0) + { + continue; // rank not outstanding in this aggregate + } + EXPECT_EQ(map.to_absolute(0, relative), rank) + << "aggregate=" << aggregate << " rank=" << rank; + } + } +} + +TEST_F(RankMapTest, NotOutstandingRankMapsToZero) +{ + RankMap map{}; + map.aggr[0] = 0; // nothing outstanding + EXPECT_EQ(map.to_relative(0, 14), 0); +} + +TEST_F(RankMapTest, ToRelativeIsBoundsSafeForOutOfRangeSuitOrRank) +{ + RankMap map{}; + map.aggr[0] = 0b111; // deuce, three, four outstanding + + EXPECT_EQ(map.to_relative(-1, 2), 0); + EXPECT_EQ(map.to_relative(DDS_SUITS, 2), 0); + EXPECT_EQ(map.to_relative(0, 1), 0); // below the lowest legal rank + EXPECT_EQ(map.to_relative(0, 15), 0); // above the highest legal rank +} + +TEST_F(RankMapTest, ToAbsoluteIsBoundsSafeForOutOfRangeSuitOrOrdinal) +{ + RankMap map{}; + map.aggr[0] = 0b111; + + EXPECT_EQ(map.to_absolute(-1, 1), 0); + EXPECT_EQ(map.to_absolute(DDS_SUITS, 1), 0); + EXPECT_EQ(map.to_absolute(0, -1), 0); + EXPECT_EQ(map.to_absolute(0, 14), 0); // win_ranks' second dimension is 0..13 +} + +TEST_F(RankMapTest, BuildsAggrAsUnionOfAllFourHandsHoldingsPerSuit) +{ + Deal deal{}; + deal.trump = DDS_NOTRUMP; + deal.first = 0; + // remainCards uses dds's public convention: bit r = absolute rank r. + deal.remainCards[0][0] = 1u << 2; // N spades: deuce (rank 2) + deal.remainCards[1][0] = 1u << 3; // E spades: three (rank 3) + deal.remainCards[2][0] = 1u << 4; // S spades: four (rank 4) + deal.remainCards[3][0] = 0; // W spades: void + + RankMap const map = make_rank_map(deal); + + // aggr uses the compacted convention (bit 0 = deuce), so the three + // outstanding ranks 2, 3, 4 land at bits 0, 1, 2. + EXPECT_EQ(map.aggr[0], 0b0111u); +} + +TEST_F(RankMapTest, MasksAggrTo13BitsEvenWithStrayRemainCardsBitsAboveRankFourteen) +{ + // A well-formed Deal never sets a remainCards bit above rank 14, but + // nothing in the type enforces that. Without masking after `>> 2`, + // aggr[suit] could exceed 0x1FFF (8191) and later index rel_rank/ + // win_ranks/highest_rank (all sized [8192]) out of bounds. + Deal deal{}; + deal.remainCards[0][0] = (1u << 2) | (1u << 20); // deuce, plus a stray high bit + + RankMap const map = make_rank_map(deal); + + EXPECT_LE(map.aggr[0], 0x1FFFu); + EXPECT_EQ(map.aggr[0], 0b1u); // only the deuce should register +} diff --git a/library/tests/belief_evaluation/renumber_test.cpp b/library/tests/belief_evaluation/renumber_test.cpp new file mode 100644 index 000000000..3b6935b74 --- /dev/null +++ b/library/tests/belief_evaluation/renumber_test.cpp @@ -0,0 +1,45 @@ +#include + +#include + +TEST(Renumber, OrderPreservingOverAPoolWithGaps) +{ + // Pool has bits 0, 2, 4 set (three outstanding cards with gaps at 1, 3). + // Holding has the middle and top of the pool: bits 2 and 4. + unsigned const pool = 0b10101; + unsigned const holding = 0b10100; + // After gap removal the pool's three members become consecutive + // positions 0, 1, 2. Holding's bit 2 -> position 1, bit 4 -> position 2. + EXPECT_EQ(renumber(holding, pool), 0b110u); +} + +TEST(Renumber, IdempotentOnAnAlreadyDensePool) +{ + unsigned const pool = 0b111; + unsigned const holding = 0b101; + EXPECT_EQ(renumber(holding, pool), holding); +} + +TEST(Renumber, EmptyHoldingStaysEmpty) +{ + EXPECT_EQ(renumber(0u, 0b10101u), 0u); +} + +TEST(Renumber, FullPoolIsIdentity) +{ + unsigned const pool = 0x1FFFu; // all 13 ranks outstanding + unsigned const holding = 0x0A5Au; + EXPECT_EQ(renumber(holding & pool, pool), holding & pool); +} + +TEST(Renumber, DoesNotOverflowWhenPoolsHighestSetBitIsBit31) +{ + // No documented caller passes a pool wider than 13 bits (renumber.hpp's + // doxygen scopes both arguments to dds's aggregate convention), but the + // function is unsigned-typed generally rather than scoped to 13 bits by + // its type, so this pins down that the top bit doesn't shift `in_bit` + // past the width of `unsigned` in the loop that walks pool's set bits. + unsigned const pool = (1u << 31) | 0b1u; + unsigned const holding = pool; + EXPECT_EQ(renumber(holding, pool), 0b11u); +} diff --git a/library/tests/belief_evaluation/smoke_test.cpp b/library/tests/belief_evaluation/smoke_test.cpp new file mode 100644 index 000000000..9f459e84c --- /dev/null +++ b/library/tests/belief_evaluation/smoke_test.cpp @@ -0,0 +1,9 @@ +#include + +// Confirms the belief_evaluation module links into a running test binary. +// Superseded by real coverage from later steps onward; kept as a harmless +// canary rather than deleted. +TEST(BeliefEvaluationModule, LinksAndRuns) +{ + SUCCEED(); +} diff --git a/library/tests/belief_evaluation/validation_test.cpp b/library/tests/belief_evaluation/validation_test.cpp new file mode 100644 index 000000000..8e6c623ae --- /dev/null +++ b/library/tests/belief_evaluation/validation_test.cpp @@ -0,0 +1,228 @@ +#include + +#include +#include + +#include +#include + +#include + +namespace +{ + auto deal_with_south_holding_two_and_three_of_spades() -> Deal + { + Deal deal{}; + deal.trump = DDS_NOTRUMP; + deal.first = 2; + deal.remainCards[2][0] = (1u << 2) | (1u << 3); // South: 2S, 3S + deal.remainCards[2][1] = 1u << 4; // South: 4H + return deal; + } +} + +TEST(ValidateDeclarerCard, RejectsACardNotHeldBySeat) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + EXPECT_EQ( + validate_declarer_card(deal, 2, Card{0, 14}), // ace of spades, not held + ValidationError::CardNotHeld); +} + +TEST(ValidateDeclarerCard, RejectsACardIllegalForTheTrickInProgress) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 0; // spades led + deal.currentTrickRank[0] = 5; // by some other seat + EXPECT_EQ( + validate_declarer_card(deal, 2, Card{1, 4}), // 4H, but South holds spades + ValidationError::CardIllegalForTrick); +} + +TEST(ValidateDeclarerCard, AcceptsAHeldCardThatFollowsSuit) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 0; + deal.currentTrickRank[0] = 5; + EXPECT_EQ(validate_declarer_card(deal, 2, Card{0, 2}), ValidationError::None); +} + +TEST(ValidateDeclarerCard, AcceptsAnyHeldCardWhenLeading) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + EXPECT_EQ(validate_declarer_card(deal, 2, Card{1, 4}), ValidationError::None); +} + +TEST(ValidateDeclarerCard, AcceptsAnyHeldCardWhenVoidInTheLedSuit) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 2; // diamonds led; South holds none + deal.currentTrickRank[0] = 5; + EXPECT_EQ(validate_declarer_card(deal, 2, Card{1, 4}), ValidationError::None); +} + +TEST(ValidateDeclarerCard, RejectsAnOutOfRangeSuitWithoutUndefinedBehaviour) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + EXPECT_EQ(validate_declarer_card(deal, 2, Card{4, 5}), ValidationError::CardNotHeld); + EXPECT_EQ(validate_declarer_card(deal, 2, Card{-1, 5}), ValidationError::CardNotHeld); +} + +TEST(ValidateDeclarerCard, RejectsAnOutOfRangeRankWithoutUndefinedBehaviour) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + EXPECT_EQ(validate_declarer_card(deal, 2, Card{0, 15}), ValidationError::CardNotHeld); + EXPECT_EQ(validate_declarer_card(deal, 2, Card{0, 1}), ValidationError::CardNotHeld); + // Close to the width of the shift in is_held(); must not be undefined + // behaviour even though it is nowhere near a legal rank. + EXPECT_EQ(validate_declarer_card(deal, 2, Card{0, 31}), ValidationError::CardNotHeld); +} + +TEST(ValidateDeclarerCard, RejectsAnOutOfRangeSeatWithoutUndefinedBehaviour) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + EXPECT_EQ(validate_declarer_card(deal, 4, Card{0, 2}), ValidationError::CardNotHeld); + EXPECT_EQ(validate_declarer_card(deal, -1, Card{0, 2}), ValidationError::CardNotHeld); +} + +TEST(ValidateDefenderDistribution, RejectsACardNotHeldBySeat) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{0, 14}, 1.0}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::CardNotHeld); +} + +TEST(ValidateDefenderDistribution, RejectsANonPositiveProbability) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{0, 2}, 0.0}, {Card{0, 3}, 1.0}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::ProbabilityNonPositive); +} + +TEST(ValidateDefenderDistribution, RejectsANegativeProbability) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{0, 2}, -0.5}, {Card{0, 3}, 1.5}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::ProbabilityNonPositive); +} + +TEST(ValidateDefenderDistribution, RejectsProbabilitiesNotSummingToOne) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{0, 2}, 0.4}, {Card{0, 3}, 0.4}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::ProbabilitiesDoNotSumToOne); +} + +TEST(ValidateDefenderDistribution, AcceptsAValidDistribution) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{0, 2}, 0.5}, {Card{0, 3}, 0.5}}; + EXPECT_EQ(validate_defender_distribution(deal, 2, distribution), ValidationError::None); +} + +TEST(ValidateDefenderDistribution, AcceptsASingleCertainCard) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{1, 4}, 1.0}}; + EXPECT_EQ(validate_defender_distribution(deal, 2, distribution), ValidationError::None); +} + +TEST(ValidateDefenderDistribution, RejectsACardIllegalForTheTrickInProgress) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 0; // spades led + deal.currentTrickRank[0] = 5; + // South's 4H, but South holds spades and must follow suit. + std::vector const distribution{{Card{1, 4}, 1.0}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::CardIllegalForTrick); +} + +TEST(ValidateDefenderDistribution, AcceptsCardsThatFollowSuit) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 0; + deal.currentTrickRank[0] = 5; + std::vector const distribution{{Card{0, 2}, 0.5}, {Card{0, 3}, 0.5}}; + EXPECT_EQ(validate_defender_distribution(deal, 2, distribution), ValidationError::None); +} + +TEST(ValidateDefenderDistribution, AcceptsAnyHeldCardWhenVoidInTheLedSuit) +{ + Deal deal = deal_with_south_holding_two_and_three_of_spades(); + deal.currentTrickSuit[0] = 2; // diamonds led; South holds none + deal.currentTrickRank[0] = 5; + std::vector const distribution{{Card{1, 4}, 1.0}}; + EXPECT_EQ(validate_defender_distribution(deal, 2, distribution), ValidationError::None); +} + +TEST(ValidateDefenderDistribution, RejectsANaNProbability) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + // NaN fails every comparison, including `<= 0.0` and the final + // sum-tolerance check, so an unguarded NaN slips past both. + std::vector const distribution{ + {Card{0, 2}, std::numeric_limits::quiet_NaN()}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::ProbabilityNonPositive); +} + +TEST(ValidateDefenderDistribution, RejectsAPositiveInfiniteProbability) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{ + {Card{0, 2}, std::numeric_limits::infinity()}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::ProbabilityNonPositive); +} + +TEST(ValidateDefenderDistribution, RejectsAnOutOfRangeCardWithoutUndefinedBehaviour) +{ + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const distribution{{Card{9, 9}, 1.0}}; + EXPECT_EQ( + validate_defender_distribution(deal, 2, distribution), + ValidationError::CardNotHeld); +} + +TEST(ValidateDefenderDistribution, RejectsAnOutOfRangeSeatEvenForAnEmptyDistribution) +{ + // With a non-empty distribution, an invalid seat is already caught by + // is_held()'s per-entry guard. An *empty* distribution never enters that + // loop, so without an explicit upfront check it fell through to + // ProbabilitiesDoNotSumToOne (0.0 is never close to 1.0) — a misleading + // error that names the wrong problem, and inconsistent with + // validate_declarer_card, which always reports an invalid seat as + // CardNotHeld regardless of the card. + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const empty_distribution; + EXPECT_EQ( + validate_defender_distribution(deal, DDS_HANDS, empty_distribution), + ValidationError::CardNotHeld); + EXPECT_EQ( + validate_defender_distribution(deal, -1, empty_distribution), + ValidationError::CardNotHeld); +} + +TEST(ValidateDefenderDistribution, RejectsAnEmptyDistributionForAValidSeat) +{ + // A defender must return at least one card; distinct from the seat + // check above, this is still ProbabilitiesDoNotSumToOne when the seat + // itself is fine. + Deal const deal = deal_with_south_holding_two_and_three_of_spades(); + std::vector const empty_distribution; + EXPECT_EQ( + validate_defender_distribution(deal, 2, empty_distribution), + ValidationError::ProbabilitiesDoNotSumToOne); +} diff --git a/specs/replenished-belief-evaluation.md b/specs/replenished-belief-evaluation.md new file mode 100644 index 000000000..5da08ae75 --- /dev/null +++ b/specs/replenished-belief-evaluation.md @@ -0,0 +1,141 @@ +--- +capability: replenished-belief-evaluation +owners: [belief_evaluation] +last-updated: 2026-08-22 +--- + +# Replenished Belief Evaluation + +> **Specs vs. doxygen.** Per-symbol signatures, parameters and return +> encodings live in the header doxygen under `library/src/belief_evaluation/`. +> This spec records the capability-wide contracts and invariants that span +> more than one symbol — not a function reference. + +## Purpose + +This capability evaluates, for a fixed declarer strategy played against a +fixed defender strategy, the probability that a bridge contract is made, over +a *belief space* of layouts consistent with everything observed so far. It +turns declarer play — a Partially Observable Markov Decision Process over +hidden layouts — into a search over the belief space instead, following +`docs/replenished_belief_evaluation/algorithm.md`, which this spec cites +throughout for the theory and does not restate. + +This document describes the capability as it stands today: the vocabulary, +the three injection contracts, and the renumbering scheme. No evaluator or +recursive search exists yet — see "Known gaps / non-goals". + +## Behaviour & invariants + +> Per-symbol detail lives in doxygen; these are the facts that span the whole +> capability. + +- **A layout is a `Deal`** (`api/dll.h`). A belief space is a set of `Deal` + values that share declarer's holding, dummy's holding, and the cards played + so far, differing only in how the outstanding cards are split between the + two defenders. Nothing in the type system enforces this; it is a contract + the evaluator's callers and future evaluator itself must uphold. +- **Declarer strategy (`DeclarerStrategy::play`) must be a pure function of + its arguments.** A future evaluator will walk the belief-space tree in its + own order and may revisit sibling subtrees; a strategy that accumulates + state across calls returns different cards for the same node and corrupts + the result. The common accidental violation is a strategy seeded from one + PRNG stream drawn across the whole search: the card returned at a node then + depends on how many decisions preceded it in traversal order, not on the + node itself, which silently breaks under any future early-cut or caching + optimisation that changes traversal order. The fix is to derive any + randomised choice from the state directly (a hash of a seed and the state), + never from a stream position. +- **`DeclarerStrategy::play` returns an absolute rank, never a relative + one.** Relative ranks are only meaningful with respect to the current + outstanding-card pool, which changes with every card played; absolute ranks + are stable. A strategy reasoning in relative terms converts with one + `RankMap::to_absolute` call before returning. +- **`DeclarerStrategy::state_key` has three states, not two.** Unset disables + any future reuse of this strategy's results entirely. Set but returning an + empty key is the strongest declaration available: "this strategy consults + nothing beyond the position a cache would already key on", enabling maximal + reuse. A non-empty key must not be coarser than what `play` actually + consults — two states mapped to the same key on which `play` would diverge + produce a wrong probability, not merely a slow one. +- **Defender strategy (`DefenderStrategy`) returns a distribution, not a + single card**, so that randomisation between double-dummy-equivalent cards + (restricted choice, in bridge terms) is expressible. Every returned card + must be held by the queried seat in the queried layout and legal there; + every probability must be strictly positive; the probabilities for one + query must sum to one within tolerance. A card the strategy will never play + is omitted, never given zero probability — a future evaluator uses + "probability greater than zero" as the survival test for a layout. +- **The defender strategy is independent of the declarer strategy and of the + belief space.** It receives one layout (perfect information) and which + seat is asking, nothing more. This independence is what licenses evaluating + each belief-space node without reference to any other part of the search + tree — the same property `docs/replenished_belief_evaluation/algorithm.md` + identifies as necessary for treating each node as an independent + sub-problem. +- **The outstanding-card pool (`aggr`) is invariant across every layout of one + belief-space node.** Every layout at a node shares declarer's holding, + dummy's holding and the cards played, so they share the same outstanding + cards per suit and differ only in the two-way defender split. This is what + lets one renumbering apply validly to a whole node rather than to a single + layout. +- **Renumbering is exact.** Compressing a suit holding to consecutive + positions with gaps removed, keeping only cards present in the outstanding + pool, is a strictly order-preserving bijection within each suit. Every rule + of trick-taking depends only on suit membership and within-suit order, so + the game played from a renumbered position is structurally identical to the + game played from the original: legal moves, trick winners, and trick counts + all correspond under the bijection. Renumbering never crosses suits — suits + are not interchangeable while a trump suit exists. +- **Rank conventions: `1` = highest, publicly; the low-packed bitmask + internal only.** `RankMap` exposes both absolute-to-relative and + relative-to-absolute conversion with `1` meaning the highest outstanding + card, matching how a bridge player reads a suit. The compressed low-packed + bitmask `renumber()` produces is an internal key-construction detail and + never appears in a callback signature. +- **A layout identity is node-local, not global.** `layout_key()` packs one + defender's holding exactly (52 significant bits, four 13-bit suits) and is + unique only among layouts sharing one belief-space node's outstanding pool — + it is not a cross-node or whole-game layout identity. +- **Evaluation is single-threaded, callbacks run on the calling thread.** + Declarer strategy, defender strategy and any layout source are user + callbacks; a future Python binding requires the GIL for each of them, which + is far simpler to guarantee by never dispatching a callback off the calling + thread. Any double-dummy calls this capability makes for its own purposes + go through a `SolverContext` and may use that context's internal + parallelism. + +## Key entry points + +- `library/src/belief_evaluation/types.hpp` — `Card`, `StrategyId`, + `StateKey`, `ObservationState`, `BeliefEntry`, `BeliefView`, `RankMap`, + `NodeSearchInfo`, and the weight-quantity aliases. +- `library/src/belief_evaluation/declarer_strategy.hpp` — `DeclarerStrategy`. +- `library/src/belief_evaluation/defender_strategy.hpp` — `WeightedCard`, + `DefenderQuery`, `DefenderStrategy`. +- `library/src/belief_evaluation/layout_source.hpp` — `LayoutSource`. +- `library/src/belief_evaluation/renumber.hpp` — `renumber()`. +- `library/src/belief_evaluation/rank_map.hpp` — `make_rank_map()`. +- `library/src/belief_evaluation/layout_key.hpp` — `layout_key()`. +- `library/src/belief_evaluation/validation.hpp` — `ValidationError`, + `validate_declarer_card()`, `validate_defender_distribution()`. +- `library/src/belief_evaluation/kahan.hpp` — `KahanAccumulator`. + +## Known gaps / non-goals + +- **No evaluator yet.** Nothing in this capability recurses over a belief + space, samples it, or computes a probability that a contract makes. That is + future work built on the vocabulary and contracts this spec describes. +- No sampling or replenishment, and no rescaling of sample weight. +- No early cuts. +- No search over declarer strategies — this capability evaluates one fixed + strategy against one fixed defender strategy at a time. +- No lookup tables or equivalence-class collapsing beyond the exact gap + removal `renumber()` performs; in particular no small-card / `least_win` + style approximation. +- No expected-tricks variant — only the probability of making a target number + of tricks is in scope. +- No deception-capable or partial-information defender models. The defender + contract models perfect-information defenders only. +- No `dds_c_*` C-ABI shim entry, and so no Java/FFM, .NET, or WASM binding + surface for this capability.