Skip to content

Add belief_evaluation module: types, injection contracts, and renumbering - #42

Merged
zzcgumn merged 17 commits into
feature/replenished_belief_evaluationfrom
plan/01-foundations
Aug 23, 2026
Merged

Add belief_evaluation module: types, injection contracts, and renumbering#42
zzcgumn merged 17 commits into
feature/replenished_belief_evaluationfrom
plan/01-foundations

Conversation

@zzcgumn

@zzcgumn zzcgumn commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

Stands up library/src/belief_evaluation/ — the module, its public types, the
three 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.md
will be built on.

What's included

  • library/src/belief_evaluation/ — new Bazel component (cc_library +
    cc_test)
  • Types: Card, StrategyId, StateKey, ObservationState,
    BeliefView/BeliefEntry, RankMap, NodeSearchInfo
  • The three injection contracts, as headers: DeclarerStrategy,
    DefenderStrategy/DefenderQuery/WeightedCard, LayoutSource
  • renumber() — exact gap-removal bijection over a suit's outstanding cards
  • RankMap — absolute/relative rank conversion over dds's existing lookup
    tables, exhaustively round-trip tested
  • A canonical Position type and an isomorphism test proving renumbering
    preserves legal moves, trick winners, and trick counts
  • layout_key() — an exact, node-local 64-bit layout identity
  • Validation helpers for declarer/defender callback returns (a user-supplied
    callback is an input, not an internal — rejected with a distinct error,
    never asserted)
  • A Kahan compensated-summation accumulator
  • specs/replenished-belief-evaluation.md

Testing

36 tests, all green: bazel test //library/src/belief_evaluation/...

  • RankMap's round trip is exhaustive over every outstanding rank in every
    aggregate in [1, 8192), not sampled
  • The renumbering isomorphism is exhaustively walked for two, three, and five
    cards per hand, the last with a trump suit and an uneven split that forces
    a void (and so a ruff)
  • Direct correctness tests for trick-winner/legal-play logic, independent of
    the isomorphism property (see note below)
  • Full coverage of the validation helpers and the Kahan accumulator

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 as
non-goals in the new spec.

Constraints respected

  • No existing public type modified — dll.h, dds_api.hpp and
    dds_c_api.h are untouched.
  • specs/dds-public-api.md's claim that the shim "covers [the modern
    layer's] full surface" was checked against this change and still holds, so
    it's left as-is — no public entry point is added yet.

Martin Nygren and others added 10 commits August 22, 2026 11:08
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
@zzcgumn zzcgumn self-assigned this Aug 22, 2026
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_evaluation Bazel component with public headers for strategies/types and internal test-only Position mechanics 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.

Comment thread library/src/belief_evaluation/validation.cpp
Comment thread library/src/belief_evaluation/validation.cpp
Comment thread library/src/belief_evaluation/rank_map.cpp
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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() shifts remainCards down by 2 but does not mask to 13 bits. If a Deal contains 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

Comment thread library/src/belief_evaluation/layout_key.cpp
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.0 is false for NaN, and std::abs(total - 1.0) > tol is 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

Comment thread library/src/belief_evaluation/rank_map.cpp Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.0 is false for NaN, and the final std::abs(total - 1.0) > tol check also fails when total becomes NaN, so the function can incorrectly return None for 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

Martin Nygren and others added 3 commits August 23, 2026 11:17
…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
@zzcgumn
zzcgumn merged commit c8bd5f3 into feature/replenished_belief_evaluation Aug 23, 2026
8 checks passed
@zzcgumn
zzcgumn deleted the plan/01-foundations branch August 23, 2026 13:16
zzcgumn pushed a commit that referenced this pull request Aug 29, 2026
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
zzcgumn pushed a commit that referenced this pull request Aug 29, 2026
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
zzcgumn pushed a commit that referenced this pull request Aug 29, 2026
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
zzcgumn pushed a commit that referenced this pull request Aug 29, 2026
…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
zzcgumn pushed a commit that referenced this pull request Aug 29, 2026
…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
zzcgumn pushed a commit that referenced this pull request Sep 1, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 1, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 1, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 1, 2026
…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
zzcgumn pushed a commit that referenced this pull request Sep 1, 2026
…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
zzcgumn pushed a commit that referenced this pull request Sep 2, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 2, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 2, 2026
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
zzcgumn pushed a commit that referenced this pull request Sep 2, 2026
…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
zzcgumn pushed a commit that referenced this pull request Sep 2, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants