Add belief_evaluation module: types, injection contracts, and renumbering - #42
Conversation
Stand up library/src/belief_evaluation/ as a new Bazel component: an empty cc_library and a cc_test with a smoke test confirming the module links and gtest runs. No behaviour yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Declarations only, no behaviour: Card, StrategyId, StateKey, the weight-quantity aliases, ObservationState, BeliefEntry, BeliefView, RankMap (methods declared, bodies land with rank_map.cpp) and NodeSearchInfo in types.hpp; DeclarerStrategy, DefenderStrategy + DefenderQuery + WeightedCard, and LayoutSource as their own headers. DeclarerStrategy::play and ::state_key carry the purity contract in their doxygen: play must be a pure function of its arguments, the seeded-strategy trap and its hash(seed, state) remedy, and that the returned rank is absolute. state_key documents that unset disables reuse while a set-but-empty key is the strongest declaration available. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Portable loop over the set bits of pool (no PEXT intrinsic — not a hot path until a later cache exists). Compresses a holding to consecutive low bits, keeping only positions also set in pool, preserving order. Tests cover order preservation over a pool with gaps, idempotence on an already-dense pool, an empty holding, and the full-pool identity case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
RankMap::to_relative/to_absolute over dds's existing lookup tables: rel_rank gives the relative ordinal directly; to_absolute recovers the absolute rank via the win_ranks XOR identity (the mask for the top n cards XORed with the mask for the top n-1 cards isolates the nth card, and highest_rank of that single-bit mask is its absolute rank). make_rank_map(Deal) builds aggr[s] as the OR of all four hands' remainCards[.][s], right-shifted by 2: Deal::remainCards sets bit r for absolute rank r, while aggr and the lookup tables use the compacted convention (bit r-2). Confirmed by every internal consumer that reads remainCards (e.g. solver_if.cpp's own `>> 2`); missing the shift would still build an aggregate, just one silently misaligned against every table it indexes. Round trip (to_absolute(to_relative(r)) == r) is tested exhaustively over every outstanding rank in every aggregate in [1, 8192). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Position, legal_plays and trick_winner are a minimal, self-contained trick-mechanics helper scoped only to stating and testing the renumbering isomorphism (and reused by validation in a later task) — not a search engine, and no double-dummy result is computed here. The isomorphism test renumbers a starting Position once (fixed pool, matching algorithm.md's "randomised array" model) and exhaustively walks every legal line of play from both the original and renumbered positions in lockstep, asserting at every node that legal move sets correspond under renumber(), that trick winners correspond, and that final trick counts correspond. Covers two, three, and five cards per hand, the last with a trump suit and an uneven suit split that forces a void (and so a ruff). Also adds direct correctness tests for trick_winner/legal_plays against actual bridge rules, independent of the isomorphism check. This matters: an order-preserving bijection makes the isomorphism test blind to *which* order-based rule trick_winner applies (verified by temporarily flipping its comparison — the isomorphism tests kept passing while the new direct tests caught it immediately), so the isomorphism property alone does not pin down "highest card wins". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Asserts the property the whole renumbering scheme rests on directly: every defender split of a fixed outstanding pool, with declarer's and dummy's holdings held constant, produces an identical aggr. Exhaustive over all 16 splits of a 4-card pool via standard submask enumeration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Exact (not hashed) 64-bit identity for a layout within one belief node: one defender's holding packed as four 13-bit suits, using the same remainCards >> 2 shift established for RankMap. Node-local only, per its doxygen — unique among layouts sharing a node's outstanding pool, not a global layout identity. Tests cover exhaustive distinctness over all 16 splits of a 4-card pool, identical splits producing identical keys, and that different suits occupy disjoint bit ranges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
A user-supplied play or defender-strategy callback is an input, not an internal, so its return is validated and rejected with a distinct ValidationError rather than asserted. validate_declarer_card checks a returned card is held by the seat on play and, if that seat holds the suit led to the trick in progress, follows suit. Uses Deal::currentTrickSuit/currentTrickRank directly (rank 0 in a slot means unplayed) rather than the Position type from the previous task, which exists only for the renumbering isomorphism. validate_defender_distribution checks every card is held by the querying seat, every probability is strictly positive, and the probabilities sum to one within tolerance — the per-call sum-to-one identity from algorithm.md; the recursion's identity and kappa mass conservation belong to later plans. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
KahanAccumulator tracks a running compensation term for the low-order bits lost to each addition's rounding. Exists because probabilities are summed over long recursions in later plans, and the invariant assertions those plans add need a tolerance they can justify. Tested against naive double accumulation of 100000 copies of 0.1: naive summation measurably loses precision (naive_error > 0) and the Kahan accumulator's error is strictly smaller. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Documents the capability as it stands after this plan: the layout/ belief-space vocabulary, the three injection contracts (declarer strategy, defender strategy, layout source) and their independence and purity requirements, aggr invariance across a belief node, the renumbering isomorphism, rank conventions, node-local layout identity, and single-threaded callback evaluation. Cites docs/replenished_belief_evaluation/algorithm.md for the theory. No evaluator or recursion exists yet; that and everything else this capability does not yet do is listed under non-goals. Documentation-only change, exempt from TDD. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Should-fix: Position/legal_plays()/trick_winner()/play_card() existed
only to state and test the renumbering isomorphism, but were globbed
into the public belief_evaluation library with nothing outside
position.* ever depending on them. Gives them their own
package-private cc_library (visibility private, same package as the
test) instead, excluded from belief_evaluation's public srcs/hdrs.
Resolves the drift in an earlier commit's message ("reused by
validation in a later task") without rewriting history: validation.cpp
never ended up using this helper, and now the BUILD graph says so
too — the helper is honestly test-only rather than public surface with
an outdated justification.
Nits:
- renumber.cpp: `in_bit <<= 1` in the loop increment could shift past
the width of `unsigned` if pool's highest set bit were bit 31,
undefined behaviour. No documented caller passes a pool that wide,
but the function's type isn't scoped to 13 bits, so guard the loop
condition on `in_bit != 0` and add a regression test for it.
- validation.cpp: renamed kProbabilitySumTolerance to
ProbabilitySumTolerance — house style uses PascalCase for constants,
not Google-style k-prefixed camelCase.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
There was a problem hiding this comment.
🟡 Changes recommended
The new public utilities contain confirmed undefined-behavior risks (missing bounds checks / out-of-range indexing) and a validation implementation that currently does not enforce its documented legality contract.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces the initial library/src/belief_evaluation/ module as a foundation for a future replenished belief-space evaluator, including the public vocabulary types, injection contracts (declarer/defender/layout source), rank/renumbering utilities, a node-local layout identity, and supporting validation + numerical-accumulation helpers, along with comprehensive unit tests.
Changes:
- Added
belief_evaluationBazel component with public headers for strategies/types and internal test-onlyPositionmechanics to state/test the renumbering isomorphism. - Implemented core utilities (
renumber,RankMap,layout_key, validation helpers, Kahan accumulator) and added focused/exhaustive tests for correctness and invariants. - Added a capability spec document describing module-wide contracts/invariants and known non-goals.
File summaries
| File | Description |
|---|---|
| specs/replenished-belief-evaluation.md | Capability-level spec for belief evaluation contracts/invariants and scope. |
| library/src/belief_evaluation/BUILD.bazel | New Bazel targets: public belief_evaluation, private position, and test suite. |
| library/src/belief_evaluation/types.hpp | Public vocabulary types: Card, RankMap, ObservationState, belief-view structs, etc. |
| library/src/belief_evaluation/declarer_strategy.hpp | Declarer injection contract (DeclarerStrategy) and purity/state-key rules. |
| library/src/belief_evaluation/defender_strategy.hpp | Defender injection contract (DefenderStrategy, DefenderQuery, WeightedCard). |
| library/src/belief_evaluation/layout_source.hpp | Layout enumeration injection contract (LayoutSource). |
| library/src/belief_evaluation/renumber.hpp | Public renumber() declaration and bit-convention contract. |
| library/src/belief_evaluation/renumber.cpp | renumber() implementation (gap-removal bijection over a pool). |
| library/src/belief_evaluation/renumber_test.cpp | Unit tests for renumbering behavior and boundary conditions. |
| library/src/belief_evaluation/rank_map.hpp | make_rank_map() declaration and RankMap mapping overview. |
| library/src/belief_evaluation/rank_map.cpp | RankMap methods using lookup tables + make_rank_map() implementation. |
| library/src/belief_evaluation/rank_map_test.cpp | Exhaustive round-trip tests for RankMap plus make_rank_map() test. |
| library/src/belief_evaluation/layout_key.hpp | Public layout_key() declaration and node-local uniqueness contract. |
| library/src/belief_evaluation/layout_key.cpp | layout_key() implementation packing defender holding into 64 bits. |
| library/src/belief_evaluation/layout_key_test.cpp | Tests for layout-key uniqueness/stability and suit bit ranges. |
| library/src/belief_evaluation/validation.hpp | Validation API + ValidationError enum for callback-return checking. |
| library/src/belief_evaluation/validation.cpp | Validation implementations for declarer card and defender distribution. |
| library/src/belief_evaluation/validation_test.cpp | Validation behavior tests for both declarer and defender checks. |
| library/src/belief_evaluation/kahan.hpp | KahanAccumulator declaration for compensated summation. |
| library/src/belief_evaluation/kahan.cpp | KahanAccumulator implementation. |
| library/src/belief_evaluation/kahan_test.cpp | Tests demonstrating improved precision vs naive summation. |
| library/src/belief_evaluation/position.hpp | Test-only minimal Position model + trick-play helpers. |
| library/src/belief_evaluation/position.cpp | Implementations of legal_plays, trick_winner, and play_card. |
| library/src/belief_evaluation/position_test.cpp | Isomorphism walk tests + direct bridge-rule correctness tests. |
| library/src/belief_evaluation/aggr_invariance_test.cpp | Test asserting RankMap pool (aggr) invariance across defender splits. |
| library/src/belief_evaluation/smoke_test.cpp | Minimal link/run canary test for the module. |
Review details
- Files reviewed: 26/26 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three inline comments from PR #42's review, all real bugs: - validation.cpp: is_held() indexed deal.remainCards[seat][card.suit] and shifted by card.rank without validating a callback-supplied seat/suit/rank first — undefined behaviour on an out-of-range Card. Guarded and documented: an invalid card is rejected as CardNotHeld, the same as an absent one, since no valid holding could contain it. One of the new regression tests (Card{9, 9} against validate_defender_distribution) demonstrably failed before this fix, not just theoretically: the unguarded OOB read landed on nonzero memory and the card was wrongly validated as held. - validation.cpp: validate_defender_distribution() checked "held" and probability constraints but never checked trick legality, even though both its own doxygen and the spec already claimed returned cards must be "legal there". Factored the existing declarer-side follow-suit check out into a shared follows_suit() helper and used it on both paths. led_suit() also now guards a malformed currentTrickSuit[0] rather than propagating it into an unguarded array index. - rank_map.cpp: RankMap::to_absolute() only guarded ordinal <= 0; ordinal > 13 or an out-of-range suit indexed win_ranks/aggr out of bounds. to_relative() had the same gap for suit/rank. Both now guard every parameter and return 0 (the existing "not outstanding/present" sentinel) for anything out of range, documented in types.hpp as deliberate: RankMap is public surface callback authors read directly, so out-of-range input fails safely rather than assuming the caller validated first. 9 new tests (46 total, up from 37), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
There was a problem hiding this comment.
🟡 Changes recommended
Two public helpers (layout_key() and make_rank_map()) can exhibit undefined behavior or violate documented bit-width contracts when given malformed/uninitialized Deal data, and should be hardened with simple bounds/masking fixes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
library/src/belief_evaluation/rank_map.cpp:47
make_rank_map()shiftsremainCardsdown by 2 but does not mask to 13 bits. If aDealcontains any out-of-range rank bits (e.g., uninitialized memory or malformed input),aggr[suit]can exceed 8191 and later indexing into lookup tables (e.g.,rel_rank[aggr]) becomes out-of-bounds/UB. Masking keeps the function bounds-safe while preserving the intended 13-rank aggregate encoding.
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.
map.aggr[suit] = pool >> 2;
- Files reviewed: 26/26 changed files
- Comments generated: 1
- Review effort level: Lite
Fourth inline comment from PR #42's review (posted after the previous three were fixed), on layout_key.cpp: - defender_seat was never validated before indexing deal.remainCards[defender_seat] — undefined behaviour for an out-of-range seat. Guarded and documented: returns 0, matching the sentinel-on-invalid-input convention already established for RankMap::to_relative/to_absolute in the previous round of fixes. - The packed key's own contract (52 significant bits, four 13-bit suits) was not actually enforced: each suit's value was shifted into its field but never masked down to 13 bits first, so a Deal with any stray bit set above rank 14 would leak into the adjacent suit's field. A well-formed Deal never does this, but nothing in the type enforces it, and the failure mode is worse than the other UB fixed so far — not a crash, but two structurally different layouts silently colliding on the same key. The new regression test (StrayBitsAboveRankFourteenDoNotLeakIntoTheNextSuitsField) failed before this fix, confirming it live rather than theoretical. 2 new tests (48 total, up from 46), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
There was a problem hiding this comment.
🟡 Changes recommended
There are confirmed correctness/UB issues in the new module (unmasked RankMap aggregates and non-finite defender probabilities slipping through validation) that should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
library/src/belief_evaluation/validation.cpp:90
- validate_defender_distribution() does not handle NaN/+Inf probabilities:
entry.probability <= 0.0is false for NaN, andstd::abs(total - 1.0) > tolis also false when total becomes NaN, so the function can incorrectly return ValidationError::None. Treat non-finite probabilities as invalid.
if (entry.probability <= 0.0)
{
return ValidationError::ProbabilityNonPositive;
}
total += entry.probability;
library/src/belief_evaluation/validation.cpp:75
- validate_defender_distribution() only reports an out-of-range seat via the per-entry is_held() guard; for an empty distribution an invalid seat currently falls through to ProbabilitiesDoNotSumToOne, which is misleading and inconsistent with how validate_declarer_card() handles invalid seats. Add an explicit seat bounds check up front.
auto validate_defender_distribution(
Deal const& layout,
int seat,
std::vector<WeightedCard> const& distribution) -> ValidationError
{
- Files reviewed: 26/26 changed files
- Comments generated: 1
- Review effort level: Lite
Fifth inline comment from PR #42's review, on rank_map.cpp — the same class of bug as the previous layout_key() fix, at the other place this codebase converts Deal::remainCards into the compacted aggregate convention: make_rank_map() shifted pool down by 2 bits but never masked to 13 bits. A malformed Deal with a stray remainCards bit set above rank 14 could push aggr[suit] past 0x1FFF, out of bounds for every rel_rank/win_ranks/highest_rank lookup keyed on it — exactly the UB RankMap::to_relative/to_absolute were guarded against in the first round of fixes, just reachable from the construction side instead of the query side. The new regression test (MasksAggrTo13BitsEvenWithStrayRemainCardsBitsAboveRankFourteen) failed before this fix. 1 new test (49 total, up from 48), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
There was a problem hiding this comment.
🔵 Needs a closer look
The current diffs include a correctness hole in defender distribution validation (NaN/Inf acceptance) and an isomorphism test setup/termination issue that can allow tests to pass without actually playing out the full deal.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
library/src/belief_evaluation/validation.cpp:96
validate_defender_distribution()will accept NaN/Inf probabilities:entry.probability <= 0.0is false for NaN, and the finalstd::abs(total - 1.0) > tolcheck also fails whentotalbecomes NaN, so the function can incorrectly returnNonefor an invalid distribution. Guard for finite probabilities (and a finite total) before accepting the distribution.
if (entry.probability <= 0.0)
{
return ValidationError::ProbabilityNonPositive;
}
total += entry.probability;
library/src/belief_evaluation/position_test.cpp:118
- The isomorphism walk treats the deal as complete when the current hand has no cards at a trick boundary. If a test accidentally constructs hands with unequal card counts, the walk can terminate early (or silently dead-end) without checking remaining cards in other hands, making the isomorphism assertions vacuously pass. Consider checking that all hands are empty before declaring completion.
This issue also appears on line 328 of the same file.
if (count_in_trick == 0 && total_holding(orig, hand) == 0)
{
// Deal complete: every hand's trick count must correspond.
for (int h = 0; h < DDS_HANDS; ++h)
{
library/src/belief_evaluation/position_test.cpp:332
- This test is named
FiveCardsPerHand...but the South hand is currently initialized with 7 cards (4 spades + 1 heart + 2 diamonds). That can cause the isomorphism walk to stop after the other hands run out, leaving cards unplayed and reducing the coverage of the property being asserted. Adjust the holdings so each hand has 5 cards (and update the descriptive comment accordingly).
position.trump = 0; // spades
position.leader = 0;
position.holding[0][0] = 0b0000011; // N spades: bits 0,1
position.holding[1][0] = 0b0001100; // E spades: bits 2,3
- Files reviewed: 26/26 changed files
- Comments generated: 0 new
- Review effort level: Lite
…overage Sixth round of PR #42 review feedback (the "Needs a closer look" review), three items: - validation.cpp: validate_defender_distribution() let a NaN probability through as ValidationError::None — NaN fails every comparison, so both `entry.probability <= 0.0` and the final `abs(total - 1.0) > tolerance` check silently pass. Guards std::isfinite() first, rejecting NaN and +-infinity as ProbabilityNonPositive (there being no probability they legitimately describe). Confirmed live before the fix, not theoretical: both a NaN-only and an infinite-only distribution failed their new regression tests. - position_test.cpp: the isomorphism walk declared a deal complete once the hand about to lead had no cards left, without checking any other hand. A starting position with unequal card counts per hand could let this terminate early and silently under-cover the isomorphism property — passing without ever walking the whole deal. Now asserts every hand is empty at that point, and separately asserts a hand always has at least one legal play mid-trick (the same silent-dead-end risk one level down, where legal_plays() returning nothing would otherwise just stop the walk with no assertion firing at all). Verified the new assertions actually catch an imbalance by temporarily reintroducing one and confirming loud failure, then reverting. - position_test.cpp: FiveCardsPerHandThreeSuitsWithTrumpAndAVoid's South hand was 4 spades + 1 heart + 2 diamonds = 7 cards against 5 for every other hand — exactly the imbalance the walk-hardening above now catches, and the reason this test's isomorphism coverage was silently incomplete. Rebalanced to 2 spades + 1 heart + 2 diamonds = 5, preserving the trump suit and the forced void-after-one-heart ruff scenario the test was designed around. 4 new tests (51 total, up from 49), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…stributions Earlier review comment (from PR #42, not re-raised in the most recent round but still worth closing): validate_defender_distribution() only checked seat validity via is_held()'s per-entry guard, so an empty distribution — which never enters that loop — let an invalid seat fall through to ProbabilitiesDoNotSumToOne. Correct in the sense that an empty distribution is invalid regardless, but misleading: it names the wrong problem, and disagrees with validate_declarer_card, which always reports an invalid seat as CardNotHeld. Adds an explicit upfront seat check, consistent with that. Confirmed red first: an invalid seat with an empty distribution previously returned ProbabilitiesDoNotSumToOne (4), now CardNotHeld (1). A sibling test pins down that a merely-empty distribution for a *valid* seat still correctly returns ProbabilitiesDoNotSumToOne. 2 new tests (53 total, up from 51), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Every other component under library/src/ keeps its tests entirely under the mirroring library/tests/<component>/ directory — confirmed by sweeping the whole tree: zero *_test.cpp files exist anywhere under library/src/ outside belief_evaluation before this change. Co-locating belief_evaluation's tests followed plan text too literally (an acceptance criterion written as `bazel test //library/src/belief_evaluation:...`) rather than a considered reason to diverge from that convention, and one BUILD file being marginally more convenient to write isn't a good enough reason on its own to be the sole exception in the tree. Moves all 8 *_test.cpp files to library/tests/belief_evaluation/ with git mv (history preserved) and splits the BUILD graph accordingly: - library/src/belief_evaluation/BUILD.bazel keeps the public `belief_evaluation` cc_library and the package-private `position` cc_library (the renumbering-isomorphism trick-mechanics helper), now scoped to `//library/tests/belief_evaluation:__pkg__` instead of fully private, mirroring the testable_* pattern used elsewhere in this tree (e.g. testable_heuristic_sorting). - library/tests/belief_evaluation/BUILD.bazel holds the single belief_evaluation_test cc_test, combining all 8 files — the same one-binary-many-files shape library/tests/trans_table/BUILD.bazel already uses. Also switched both BUILD files from glob() to explicit srcs/hdrs lists, matching every other component's BUILD.bazel and bazel.instructions.md's own "avoid glob() unless absolutely needed" guideline, which the glob-based version didn't follow. All 53 tests pass under the new location: bazel test //library/tests/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
c8bd5f3
into
feature/replenished_belief_evaluation
Three inline comments from PR #42's review, all real bugs: - validation.cpp: is_held() indexed deal.remainCards[seat][card.suit] and shifted by card.rank without validating a callback-supplied seat/suit/rank first — undefined behaviour on an out-of-range Card. Guarded and documented: an invalid card is rejected as CardNotHeld, the same as an absent one, since no valid holding could contain it. One of the new regression tests (Card{9, 9} against validate_defender_distribution) demonstrably failed before this fix, not just theoretically: the unguarded OOB read landed on nonzero memory and the card was wrongly validated as held. - validation.cpp: validate_defender_distribution() checked "held" and probability constraints but never checked trick legality, even though both its own doxygen and the spec already claimed returned cards must be "legal there". Factored the existing declarer-side follow-suit check out into a shared follows_suit() helper and used it on both paths. led_suit() also now guards a malformed currentTrickSuit[0] rather than propagating it into an unguarded array index. - rank_map.cpp: RankMap::to_absolute() only guarded ordinal <= 0; ordinal > 13 or an out-of-range suit indexed win_ranks/aggr out of bounds. to_relative() had the same gap for suit/rank. Both now guard every parameter and return 0 (the existing "not outstanding/present" sentinel) for anything out of range, documented in types.hpp as deliberate: RankMap is public surface callback authors read directly, so out-of-range input fails safely rather than assuming the caller validated first. 9 new tests (46 total, up from 37), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fourth inline comment from PR #42's review (posted after the previous three were fixed), on layout_key.cpp: - defender_seat was never validated before indexing deal.remainCards[defender_seat] — undefined behaviour for an out-of-range seat. Guarded and documented: returns 0, matching the sentinel-on-invalid-input convention already established for RankMap::to_relative/to_absolute in the previous round of fixes. - The packed key's own contract (52 significant bits, four 13-bit suits) was not actually enforced: each suit's value was shifted into its field but never masked down to 13 bits first, so a Deal with any stray bit set above rank 14 would leak into the adjacent suit's field. A well-formed Deal never does this, but nothing in the type enforces it, and the failure mode is worse than the other UB fixed so far — not a crash, but two structurally different layouts silently colliding on the same key. The new regression test (StrayBitsAboveRankFourteenDoNotLeakIntoTheNextSuitsField) failed before this fix, confirming it live rather than theoretical. 2 new tests (48 total, up from 46), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fifth inline comment from PR #42's review, on rank_map.cpp — the same class of bug as the previous layout_key() fix, at the other place this codebase converts Deal::remainCards into the compacted aggregate convention: make_rank_map() shifted pool down by 2 bits but never masked to 13 bits. A malformed Deal with a stray remainCards bit set above rank 14 could push aggr[suit] past 0x1FFF, out of bounds for every rel_rank/win_ranks/highest_rank lookup keyed on it — exactly the UB RankMap::to_relative/to_absolute were guarded against in the first round of fixes, just reachable from the construction side instead of the query side. The new regression test (MasksAggrTo13BitsEvenWithStrayRemainCardsBitsAboveRankFourteen) failed before this fix. 1 new test (49 total, up from 48), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…overage Sixth round of PR #42 review feedback (the "Needs a closer look" review), three items: - validation.cpp: validate_defender_distribution() let a NaN probability through as ValidationError::None — NaN fails every comparison, so both `entry.probability <= 0.0` and the final `abs(total - 1.0) > tolerance` check silently pass. Guards std::isfinite() first, rejecting NaN and +-infinity as ProbabilityNonPositive (there being no probability they legitimately describe). Confirmed live before the fix, not theoretical: both a NaN-only and an infinite-only distribution failed their new regression tests. - position_test.cpp: the isomorphism walk declared a deal complete once the hand about to lead had no cards left, without checking any other hand. A starting position with unequal card counts per hand could let this terminate early and silently under-cover the isomorphism property — passing without ever walking the whole deal. Now asserts every hand is empty at that point, and separately asserts a hand always has at least one legal play mid-trick (the same silent-dead-end risk one level down, where legal_plays() returning nothing would otherwise just stop the walk with no assertion firing at all). Verified the new assertions actually catch an imbalance by temporarily reintroducing one and confirming loud failure, then reverting. - position_test.cpp: FiveCardsPerHandThreeSuitsWithTrumpAndAVoid's South hand was 4 spades + 1 heart + 2 diamonds = 7 cards against 5 for every other hand — exactly the imbalance the walk-hardening above now catches, and the reason this test's isomorphism coverage was silently incomplete. Rebalanced to 2 spades + 1 heart + 2 diamonds = 5, preserving the trump suit and the forced void-after-one-heart ruff scenario the test was designed around. 4 new tests (51 total, up from 49), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…stributions Earlier review comment (from PR #42, not re-raised in the most recent round but still worth closing): validate_defender_distribution() only checked seat validity via is_held()'s per-entry guard, so an empty distribution — which never enters that loop — let an invalid seat fall through to ProbabilitiesDoNotSumToOne. Correct in the sense that an empty distribution is invalid regardless, but misleading: it names the wrong problem, and disagrees with validate_declarer_card, which always reports an invalid seat as CardNotHeld. Adds an explicit upfront seat check, consistent with that. Confirmed red first: an invalid seat with an empty distribution previously returned ProbabilitiesDoNotSumToOne (4), now CardNotHeld (1). A sibling test pins down that a merely-empty distribution for a *valid* seat still correctly returns ProbabilitiesDoNotSumToOne. 2 new tests (53 total, up from 51), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Three inline comments from PR #42's review, all real bugs: - validation.cpp: is_held() indexed deal.remainCards[seat][card.suit] and shifted by card.rank without validating a callback-supplied seat/suit/rank first — undefined behaviour on an out-of-range Card. Guarded and documented: an invalid card is rejected as CardNotHeld, the same as an absent one, since no valid holding could contain it. One of the new regression tests (Card{9, 9} against validate_defender_distribution) demonstrably failed before this fix, not just theoretically: the unguarded OOB read landed on nonzero memory and the card was wrongly validated as held. - validation.cpp: validate_defender_distribution() checked "held" and probability constraints but never checked trick legality, even though both its own doxygen and the spec already claimed returned cards must be "legal there". Factored the existing declarer-side follow-suit check out into a shared follows_suit() helper and used it on both paths. led_suit() also now guards a malformed currentTrickSuit[0] rather than propagating it into an unguarded array index. - rank_map.cpp: RankMap::to_absolute() only guarded ordinal <= 0; ordinal > 13 or an out-of-range suit indexed win_ranks/aggr out of bounds. to_relative() had the same gap for suit/rank. Both now guard every parameter and return 0 (the existing "not outstanding/present" sentinel) for anything out of range, documented in types.hpp as deliberate: RankMap is public surface callback authors read directly, so out-of-range input fails safely rather than assuming the caller validated first. 9 new tests (46 total, up from 37), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fourth inline comment from PR #42's review (posted after the previous three were fixed), on layout_key.cpp: - defender_seat was never validated before indexing deal.remainCards[defender_seat] — undefined behaviour for an out-of-range seat. Guarded and documented: returns 0, matching the sentinel-on-invalid-input convention already established for RankMap::to_relative/to_absolute in the previous round of fixes. - The packed key's own contract (52 significant bits, four 13-bit suits) was not actually enforced: each suit's value was shifted into its field but never masked down to 13 bits first, so a Deal with any stray bit set above rank 14 would leak into the adjacent suit's field. A well-formed Deal never does this, but nothing in the type enforces it, and the failure mode is worse than the other UB fixed so far — not a crash, but two structurally different layouts silently colliding on the same key. The new regression test (StrayBitsAboveRankFourteenDoNotLeakIntoTheNextSuitsField) failed before this fix, confirming it live rather than theoretical. 2 new tests (48 total, up from 46), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fifth inline comment from PR #42's review, on rank_map.cpp — the same class of bug as the previous layout_key() fix, at the other place this codebase converts Deal::remainCards into the compacted aggregate convention: make_rank_map() shifted pool down by 2 bits but never masked to 13 bits. A malformed Deal with a stray remainCards bit set above rank 14 could push aggr[suit] past 0x1FFF, out of bounds for every rel_rank/win_ranks/highest_rank lookup keyed on it — exactly the UB RankMap::to_relative/to_absolute were guarded against in the first round of fixes, just reachable from the construction side instead of the query side. The new regression test (MasksAggrTo13BitsEvenWithStrayRemainCardsBitsAboveRankFourteen) failed before this fix. 1 new test (49 total, up from 48), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…overage Sixth round of PR #42 review feedback (the "Needs a closer look" review), three items: - validation.cpp: validate_defender_distribution() let a NaN probability through as ValidationError::None — NaN fails every comparison, so both `entry.probability <= 0.0` and the final `abs(total - 1.0) > tolerance` check silently pass. Guards std::isfinite() first, rejecting NaN and +-infinity as ProbabilityNonPositive (there being no probability they legitimately describe). Confirmed live before the fix, not theoretical: both a NaN-only and an infinite-only distribution failed their new regression tests. - position_test.cpp: the isomorphism walk declared a deal complete once the hand about to lead had no cards left, without checking any other hand. A starting position with unequal card counts per hand could let this terminate early and silently under-cover the isomorphism property — passing without ever walking the whole deal. Now asserts every hand is empty at that point, and separately asserts a hand always has at least one legal play mid-trick (the same silent-dead-end risk one level down, where legal_plays() returning nothing would otherwise just stop the walk with no assertion firing at all). Verified the new assertions actually catch an imbalance by temporarily reintroducing one and confirming loud failure, then reverting. - position_test.cpp: FiveCardsPerHandThreeSuitsWithTrumpAndAVoid's South hand was 4 spades + 1 heart + 2 diamonds = 7 cards against 5 for every other hand — exactly the imbalance the walk-hardening above now catches, and the reason this test's isomorphism coverage was silently incomplete. Rebalanced to 2 spades + 1 heart + 2 diamonds = 5, preserving the trump suit and the forced void-after-one-heart ruff scenario the test was designed around. 4 new tests (51 total, up from 49), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…stributions Earlier review comment (from PR #42, not re-raised in the most recent round but still worth closing): validate_defender_distribution() only checked seat validity via is_held()'s per-entry guard, so an empty distribution — which never enters that loop — let an invalid seat fall through to ProbabilitiesDoNotSumToOne. Correct in the sense that an empty distribution is invalid regardless, but misleading: it names the wrong problem, and disagrees with validate_declarer_card, which always reports an invalid seat as CardNotHeld. Adds an explicit upfront seat check, consistent with that. Confirmed red first: an invalid seat with an empty distribution previously returned ProbabilitiesDoNotSumToOne (4), now CardNotHeld (1). A sibling test pins down that a merely-empty distribution for a *valid* seat still correctly returns ProbabilitiesDoNotSumToOne. 2 new tests (53 total, up from 51), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Three inline comments from PR #42's review, all real bugs: - validation.cpp: is_held() indexed deal.remainCards[seat][card.suit] and shifted by card.rank without validating a callback-supplied seat/suit/rank first — undefined behaviour on an out-of-range Card. Guarded and documented: an invalid card is rejected as CardNotHeld, the same as an absent one, since no valid holding could contain it. One of the new regression tests (Card{9, 9} against validate_defender_distribution) demonstrably failed before this fix, not just theoretically: the unguarded OOB read landed on nonzero memory and the card was wrongly validated as held. - validation.cpp: validate_defender_distribution() checked "held" and probability constraints but never checked trick legality, even though both its own doxygen and the spec already claimed returned cards must be "legal there". Factored the existing declarer-side follow-suit check out into a shared follows_suit() helper and used it on both paths. led_suit() also now guards a malformed currentTrickSuit[0] rather than propagating it into an unguarded array index. - rank_map.cpp: RankMap::to_absolute() only guarded ordinal <= 0; ordinal > 13 or an out-of-range suit indexed win_ranks/aggr out of bounds. to_relative() had the same gap for suit/rank. Both now guard every parameter and return 0 (the existing "not outstanding/present" sentinel) for anything out of range, documented in types.hpp as deliberate: RankMap is public surface callback authors read directly, so out-of-range input fails safely rather than assuming the caller validated first. 9 new tests (46 total, up from 37), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fourth inline comment from PR #42's review (posted after the previous three were fixed), on layout_key.cpp: - defender_seat was never validated before indexing deal.remainCards[defender_seat] — undefined behaviour for an out-of-range seat. Guarded and documented: returns 0, matching the sentinel-on-invalid-input convention already established for RankMap::to_relative/to_absolute in the previous round of fixes. - The packed key's own contract (52 significant bits, four 13-bit suits) was not actually enforced: each suit's value was shifted into its field but never masked down to 13 bits first, so a Deal with any stray bit set above rank 14 would leak into the adjacent suit's field. A well-formed Deal never does this, but nothing in the type enforces it, and the failure mode is worse than the other UB fixed so far — not a crash, but two structurally different layouts silently colliding on the same key. The new regression test (StrayBitsAboveRankFourteenDoNotLeakIntoTheNextSuitsField) failed before this fix, confirming it live rather than theoretical. 2 new tests (48 total, up from 46), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Fifth inline comment from PR #42's review, on rank_map.cpp — the same class of bug as the previous layout_key() fix, at the other place this codebase converts Deal::remainCards into the compacted aggregate convention: make_rank_map() shifted pool down by 2 bits but never masked to 13 bits. A malformed Deal with a stray remainCards bit set above rank 14 could push aggr[suit] past 0x1FFF, out of bounds for every rel_rank/win_ranks/highest_rank lookup keyed on it — exactly the UB RankMap::to_relative/to_absolute were guarded against in the first round of fixes, just reachable from the construction side instead of the query side. The new regression test (MasksAggrTo13BitsEvenWithStrayRemainCardsBitsAboveRankFourteen) failed before this fix. 1 new test (49 total, up from 48), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…overage Sixth round of PR #42 review feedback (the "Needs a closer look" review), three items: - validation.cpp: validate_defender_distribution() let a NaN probability through as ValidationError::None — NaN fails every comparison, so both `entry.probability <= 0.0` and the final `abs(total - 1.0) > tolerance` check silently pass. Guards std::isfinite() first, rejecting NaN and +-infinity as ProbabilityNonPositive (there being no probability they legitimately describe). Confirmed live before the fix, not theoretical: both a NaN-only and an infinite-only distribution failed their new regression tests. - position_test.cpp: the isomorphism walk declared a deal complete once the hand about to lead had no cards left, without checking any other hand. A starting position with unequal card counts per hand could let this terminate early and silently under-cover the isomorphism property — passing without ever walking the whole deal. Now asserts every hand is empty at that point, and separately asserts a hand always has at least one legal play mid-trick (the same silent-dead-end risk one level down, where legal_plays() returning nothing would otherwise just stop the walk with no assertion firing at all). Verified the new assertions actually catch an imbalance by temporarily reintroducing one and confirming loud failure, then reverting. - position_test.cpp: FiveCardsPerHandThreeSuitsWithTrumpAndAVoid's South hand was 4 spades + 1 heart + 2 diamonds = 7 cards against 5 for every other hand — exactly the imbalance the walk-hardening above now catches, and the reason this test's isomorphism coverage was silently incomplete. Rebalanced to 2 spades + 1 heart + 2 diamonds = 5, preserving the trump suit and the forced void-after-one-heart ruff scenario the test was designed around. 4 new tests (51 total, up from 49), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
…stributions Earlier review comment (from PR #42, not re-raised in the most recent round but still worth closing): validate_defender_distribution() only checked seat validity via is_held()'s per-entry guard, so an empty distribution — which never enters that loop — let an invalid seat fall through to ProbabilitiesDoNotSumToOne. Correct in the sense that an empty distribution is invalid regardless, but misleading: it names the wrong problem, and disagrees with validate_declarer_card, which always reports an invalid seat as CardNotHeld. Adds an explicit upfront seat check, consistent with that. Confirmed red first: an invalid seat with an empty distribution previously returned ProbabilitiesDoNotSumToOne (4), now CardNotHeld (1). A sibling test pins down that a merely-empty distribution for a *valid* seat still correctly returns ProbabilitiesDoNotSumToOne. 2 new tests (53 total, up from 51), all passing: bazel test //library/src/belief_evaluation/... Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN1uXAQim3L9pUYy7WVpo5
Summary
Stands up
library/src/belief_evaluation/— the module, its public types, thethree declarer/defender/layout-source injection contracts, and the
renumbering scheme — with no evaluator behind any of it yet. This is the
foundation the recursive belief-space evaluator described in
docs/replenished_belief_evaluation/algorithm.mdwill be built on.
What's included
library/src/belief_evaluation/— new Bazel component (cc_library+cc_test)Card,StrategyId,StateKey,ObservationState,BeliefView/BeliefEntry,RankMap,NodeSearchInfoDeclarerStrategy,DefenderStrategy/DefenderQuery/WeightedCard,LayoutSourcerenumber()— exact gap-removal bijection over a suit's outstanding cardsRankMap— absolute/relative rank conversion over dds's existing lookuptables, exhaustively round-trip tested
Positiontype and an isomorphism test proving renumberingpreserves legal moves, trick winners, and trick counts
layout_key()— an exact, node-local 64-bit layout identitycallback is an input, not an internal — rejected with a distinct error,
never asserted)
specs/replenished-belief-evaluation.mdTesting
36 tests, all green:
bazel test //library/src/belief_evaluation/...RankMap's round trip is exhaustive over every outstanding rank in everyaggregate in
[1, 8192), not sampledcards per hand, the last with a trump suit and an uneven split that forces
a void (and so a ruff)
the isomorphism property (see note below)
Strict TDD throughout — red confirmed before every implementation.
Worth a second look
The renumbering isomorphism test is, by construction, insensitive to which
order-based rule wins a trick: an order-preserving bijection is equally
consistent with "highest card wins" or "lowest card wins". Confirmed this by
deliberately breaking the comparison and watching the isomorphism tests stay
green; added independent tests against actual bridge rules to close the gap.
Worth keeping in mind for anything else proven primarily via an
invariance/isomorphism argument.
Not in this PR
No evaluator, no recursion, no sampling or replenishment, no early cuts, and
no
dds_c_*shim entry — all deliberately out of scope here, listed asnon-goals in the new spec.
Constraints respected
dll.h,dds_api.hppanddds_c_api.hare untouched.specs/dds-public-api.md's claim that the shim "covers [the modernlayer's] full surface" was checked against this change and still holds, so
it's left as-is — no public entry point is added yet.