Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions library/src/belief_evaluation/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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,
)
54 changes: 54 additions & 0 deletions library/src/belief_evaluation/declarer_strategy.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#pragma once

#include <functional>

#include <belief_evaluation/types.hpp>

/// 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<Card(ObservationState const&, BeliefView const&)> 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<StateKey(ObservationState const&, BeliefView const&)> state_key;
};
40 changes: 40 additions & 0 deletions library/src/belief_evaluation/defender_strategy.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#pragma once

#include <functional>
#include <vector>

#include <belief_evaluation/types.hpp>

/// 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<std::vector<WeightedCard>(DefenderQuery const&)>;
14 changes: 14 additions & 0 deletions library/src/belief_evaluation/kahan.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#include <belief_evaluation/kahan.hpp>

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_;
}
15 changes: 15 additions & 0 deletions library/src/belief_evaluation/kahan.hpp
Original file line number Diff line number Diff line change
@@ -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;
};
26 changes: 26 additions & 0 deletions library/src/belief_evaluation/layout_key.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#include <belief_evaluation/layout_key.hpp>

#include <utility/constants.h>

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);
}
19 changes: 19 additions & 0 deletions library/src/belief_evaluation/layout_key.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

#include <cstdint>

#include <api/dll.h>

/// 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;
26 changes: 26 additions & 0 deletions library/src/belief_evaluation/layout_source.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#pragma once

#include <cstdint>
#include <optional>

#include <api/dll.h>

/// 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<std::uint64_t> = 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;
};
56 changes: 56 additions & 0 deletions library/src/belief_evaluation/position.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#include <belief_evaluation/position.hpp>

auto legal_plays(Position const& position, int hand, int led_suit)
-> std::array<unsigned, DDS_SUITS>
{
std::array<unsigned, DDS_SUITS> 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<int, 4> const& suit_played,
std::array<int, 4> 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);
}
38 changes: 38 additions & 0 deletions library/src/belief_evaluation/position.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#pragma once

#include <array>

#include <utility/constants.h>

/// 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<std::array<unsigned, DDS_SUITS>, 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<unsigned, DDS_SUITS>;

/// 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<int, 4> const& suit_played,
std::array<int, 4> 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;
Loading