Skip to main content

pons_dds/
solver.rs

1//! Public solver API.
2//!
3//! Mirrors the per-instance `Solver` shape of the FFI-based
4//! [`dds-bridge`](https://crates.io/crates/dds-bridge) crate so that a
5//! `pons` migration from one to the other can be a near-mechanical swap.
6//!
7//! The canonical entry points are the free functions [`solve_deal`] (one
8//! deal, its 5 strains fanned across `rayon` workers) and [`solve_deals`]
9//! (a batch, parallelised per (deal, strain)); both return a full 5 × 4
10//! [`TrickCountTable`] per deal. [`Solver`] itself is the per-strain
11//! building block they reuse: one instance is bound to a single strain
12//! (reconfigurable via [`Solver::set_strain`]) and [`Solver::solve`]s all
13//! 4 declarers of that strain for a deal — handy for deterministic
14//! profiling or driving the solve yourself.
15
16use crate::convert::dds_suit_from_cb;
17use crate::pos::Pos;
18use crate::quick_tricks::{MAXNODE, MINNODE};
19use crate::search::Engine;
20use crate::tt::TransTable;
21use contract_bridge::{FullDeal, Seat, Strain, Suit};
22use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
23
24/// All five strains in [`TrickCountTable`] row order (Clubs, Diamonds,
25/// Hearts, Spades, Notrump). Matches `Strain::ASC`.
26const STRAINS: [Strain; 5] = Strain::ASC;
27
28/// All four seats in [`TrickCountTable`] column order (North, East,
29/// South, West). Matches `Seat::ALL`.
30const SEATS: [Seat; 4] = Seat::ALL;
31
32// ---------------------------------------------------------------------
33// FullDeal → Pos conversion
34// ---------------------------------------------------------------------
35
36/// Populate `pos.rank_in_suit` from a [`FullDeal`]. The remaining
37/// `Pos` fields (`aggr`, `length`, `hand_dist`, `winner`, `second_best`)
38/// are filled in by [`Engine::set_deal`]; this helper only writes the
39/// raw card bitmaps in DDS suit ordering.
40///
41/// Bit `r` (for `r` in 2..=14) of `rank_in_suit[h][s]` is set iff DDS
42/// hand `h` holds rank `r` in DDS suit `s`, per the vendor's
43/// [`crate::lookup::BIT_MAP_RANK`] convention. The vendor packs rank
44/// `r` at bit position `r - 2`, while `contract_bridge::Holding` packs
45/// rank `r` at bit position `r`; we shift right by 2 to translate.
46fn pos_from_deal(deal: &FullDeal) -> Pos {
47    let mut pos = Pos::default();
48    for (h, seat) in SEATS.iter().enumerate() {
49        let cb_hand = deal[*seat];
50        for cb_suit in Suit::ASC {
51            // `Holding::to_bits()` uses bits 2..=14 for ranks 2..=14;
52            // DDS uses bits 0..=12. Shift by 2 to convert.
53            let bits = cb_hand[cb_suit].to_bits() >> 2;
54            pos.rank_in_suit[h][dds_suit_from_cb(cb_suit)] = bits;
55        }
56    }
57    pos
58}
59
60// ---------------------------------------------------------------------
61// Result table
62// ---------------------------------------------------------------------
63
64/// Double-dummy result table: tricks each seat takes as declarer at
65/// each strain.
66///
67/// Indexed by `(strain, seat)`. The storage is a flat `[[u8; 4]; 5]`
68/// where the first axis is the strain in ascending order — Clubs,
69/// Diamonds, Hearts, Spades, Notrump (matching [`Strain`]'s enum integer
70/// values) — and the second is the seat in dealing order — North, East,
71/// South, West (matching [`Seat`]).
72///
73/// Each entry is in `0..=13`. A later release may upgrade this to a
74/// validated newtype that mirrors `ddss::tricks::TrickCountTable`.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct TrickCountTable {
77    /// Per-`(strain, seat)` trick count, in `0..=13`.
78    pub tricks: [[u8; 4]; 5],
79}
80
81impl TrickCountTable {
82    /// Return the number of tricks `seat` makes as declarer in `strain`.
83    #[inline]
84    #[must_use]
85    pub const fn get(&self, strain: Strain, seat: Seat) -> u8 {
86        self.tricks[strain as usize][seat as usize]
87    }
88}
89
90// ---------------------------------------------------------------------
91// Per-strain Solver
92// ---------------------------------------------------------------------
93
94/// Per-strain solver.
95///
96/// Bound to a single strain (set at [`Self::new`], retargetable via
97/// [`Self::set_strain`]) and owns a search engine and a transposition
98/// table, mirroring the per-strain `Engine`. [`Self::solve`] runs all
99/// 4 declarers of the configured strain for a deal; for a full 5 × 4
100/// table across every strain use the free [`solve_deal`] / [`solve_deals`].
101///
102/// The engine and TT are reused across calls so the TT can warm up.
103/// Solving a deal resets the TT — the cached entries from a previous
104/// deal (or strain) use a stale per-deal lookup table / trump and would
105/// produce incorrect hits.
106///
107/// `Solver` is `Send` but intentionally not `Sync`: the transposition
108/// table is per-search-context and not safe for concurrent reads or
109/// writes. Use the free [`solve_deals`] function to drive multiple
110/// solvers in parallel.
111pub struct Solver {
112    engine: Engine,
113    tt: TransTable,
114}
115
116impl Solver {
117    /// Create a fresh solver for `strain` with the default
118    /// transposition-table memory budget. Retarget the strain later with
119    /// [`Self::set_strain`].
120    #[must_use]
121    pub fn new(strain: Strain) -> Self {
122        Self {
123            engine: Engine::new(strain),
124            tt: TransTable::new(),
125        }
126    }
127
128    /// Create a solver for `strain` with an explicit transposition-table
129    /// memory budget, in MiB: `default_mb` is the size the table shrinks
130    /// back to on reset (per solve), `max_mb` the ceiling before a full
131    /// reset is forced. [`Self::new`] uses the built-in defaults
132    /// (`DEFAULT_MEMORY_MB` / `MAX_MEMORY_MB`).
133    ///
134    /// Bigger is better up to a plateau: a starved table full-resets and
135    /// re-searches, so undersizing it explodes the node count (16/32 MiB
136    /// is ~3.5× slower than the default). Correctness is unaffected at any
137    /// size — a full table just resets and rebuilds. Mainly useful for
138    /// capping per-thread memory in highly parallel runs.
139    #[must_use]
140    pub fn with_memory(strain: Strain, default_mb: u32, max_mb: u32) -> Self {
141        Self {
142            engine: Engine::new(strain),
143            tt: TransTable::with_memory(default_mb, max_mb),
144        }
145    }
146
147    /// Retarget the solver to a different strain. The next [`Self::solve`]
148    /// resets the transposition table, so no stale-trump entries survive
149    /// the change.
150    pub fn set_strain(&mut self, strain: Strain) {
151        self.engine.set_strain(strain);
152    }
153
154    /// Solve the configured strain (all 4 declarers) of `deal`, returning
155    /// the per-seat trick row in seat order (North, East, South,
156    /// West).
157    ///
158    /// Resets the transposition table for the strain's trump, then reuses
159    /// it across the 4 declarer searches: the bounds are framed relative
160    /// to seat 0's side, so they stay valid as the declarer — hence the
161    /// MAX side — rotates within a strain. This per-strain unit is the
162    /// grain of parallelism in [`solve_deals`]; keeping the 4 declarers
163    /// on one unit preserves that intra-strain TT reuse.
164    #[must_use]
165    pub fn solve(&mut self, deal: FullDeal) -> [u8; 4] {
166        // 13 tricks left → ini_depth = 48. The leader of trick 13 (the
167        // opening lead) plays at depth `ini_depth`, then each follower
168        // decrements depth by 1.
169        const INI_DEPTH: i32 = 48;
170
171        // Drop entries cached under the previous trump (or for any
172        // previous deal): the bounds stored at a given (trick, hand,
173        // aggr, hand_dist) key are computed under the active trump
174        // and would be incorrect after a strain change.
175        self.tt.reset();
176
177        let mut row = [0u8; 4];
178        for (seat_idx, declarer) in SEATS.iter().enumerate() {
179            // Opening leader = declarer's LHO; declarer plays third.
180            let leader = declarer.lho() as usize;
181
182            // MAX = the declaring side. NS declares → [MAX, MIN, MAX,
183            // MIN]; EW declares → [MIN, MAX, MIN, MAX].
184            let node_types = if matches!(declarer, Seat::North | Seat::South) {
185                [MAXNODE, MINNODE, MAXNODE, MINNODE]
186            } else {
187                [MINNODE, MAXNODE, MINNODE, MAXNODE]
188            };
189            self.engine.set_node_types(node_types);
190
191            // Rebuild Pos from scratch — cheap (~3 KiB struct) and
192            // avoids having to remember which depth-indexed history
193            // slots were touched by the previous search.
194            let mut pos = pos_from_deal(&deal);
195            pos.first[INI_DEPTH as usize] = leader as i32;
196
197            // `set_deal` fills aggr/length/hand_dist/winner/
198            // second_best from `rank_in_suit` and calls `tt.init`.
199            self.engine.set_deal(&mut pos, &mut self.tt);
200
201            let tricks = self.engine.search_target(&mut pos, &mut self.tt, INI_DEPTH);
202            debug_assert!((0..=13).contains(&tricks), "tricks out of range");
203            row[seat_idx] = tricks as u8;
204        }
205        row
206    }
207}
208
209impl Solver {
210    /// Diagnostic: total `(search_target_calls, bisection_iters)`
211    /// accumulated by this solver's engine since it was created or
212    /// [`Self::reset_bisection_stats`] was last called.
213    ///
214    /// `bisection_iters / search_target_calls` is the average number of
215    /// alpha-beta probes per bisection driver call — a value close to 1
216    /// means the TT carries bounds between probes; ≈ 4 means each probe
217    /// re-traverses the tree from scratch.
218    #[inline]
219    #[must_use]
220    pub const fn bisection_stats(&self) -> (u64, u64) {
221        (self.engine.search_target_calls, self.engine.bisection_iters)
222    }
223
224    /// Zero the bisection diagnostic counters.
225    #[inline]
226    pub const fn reset_bisection_stats(&mut self) {
227        self.engine.search_target_calls = 0;
228        self.engine.bisection_iters = 0;
229        self.engine.iter1_nanos = 0;
230        self.engine.later_nanos = 0;
231    }
232
233    /// Cumulative `(iter1_nanos, later_nanos)` — wall-clock time spent
234    /// in the first bisection iteration of each `search_target` call vs
235    /// in subsequent iterations. The ratio answers whether TT-cached
236    /// internal subtrees make later iters cheap.
237    #[inline]
238    #[must_use]
239    pub const fn bisection_timing(&self) -> (u128, u128) {
240        (self.engine.iter1_nanos, self.engine.later_nanos)
241    }
242
243    /// Cumulative per-node search instrumentation (TT hit rate,
244    /// move-ordering cutoff index, node-0 early-exit funnel).
245    ///
246    /// All fields are zero unless the crate is built with
247    /// `--features profiling`.
248    #[inline]
249    #[must_use]
250    pub const fn search_stats(&self) -> crate::search::SearchStats {
251        self.engine.stats
252    }
253
254    /// Zero the per-node search instrumentation counters.
255    #[inline]
256    pub fn reset_search_stats(&mut self) {
257        self.engine.stats = crate::search::SearchStats::default();
258    }
259}
260
261impl Default for Solver {
262    #[inline]
263    fn default() -> Self {
264        Self::new(Strain::Notrump)
265    }
266}
267
268// ---------------------------------------------------------------------
269// Parallel batch
270// ---------------------------------------------------------------------
271
272/// Solve a batch of deals in parallel.
273///
274/// The unit of work is a single **(deal, strain)** pair, not a whole
275/// deal: a one-deal batch therefore spreads its 5 strains across up to 5
276/// rayon workers, and a large batch yields `5 × deals.len()` tasks for
277/// finer load-balancing. The 4 declarers of a strain stay on one task so
278/// the per-strain transposition table still warms across them (see
279/// [`Solver::solve`]).
280///
281/// Each rayon worker amortises its own [`Solver`] (and the associated
282/// transposition-table allocation) across the tasks routed to it via a
283/// [`std::thread_local!`] handle. Order of results matches the order of
284/// `deals`.
285///
286/// This is the recommended entry point for solving many deals at once;
287/// for low-latency solving of a single deal see [`solve_deal`].
288#[must_use]
289pub fn solve_deals(deals: &[FullDeal]) -> Vec<TrickCountTable> {
290    use std::cell::RefCell;
291
292    thread_local! {
293        static SOLVER: RefCell<Solver> = RefCell::new(Solver::new(Strain::Notrump));
294    }
295
296    // Flatten to (deal, strain) work-units. The 4 declarers of a strain
297    // share one unit to preserve intra-strain TT reuse.
298    let tasks: Vec<(usize, usize)> = (0..deals.len())
299        .flat_map(|d| (0..STRAINS.len()).map(move |s| (d, s)))
300        .collect();
301
302    let rows: Vec<(usize, usize, [u8; 4])> = tasks
303        .par_iter()
304        .map(|&(d, s)| {
305            let row = SOLVER.with(|cell| {
306                let mut solver = cell.borrow_mut();
307                solver.set_strain(STRAINS[s]);
308                solver.solve(deals[d])
309            });
310            (d, s, row)
311        })
312        .collect();
313
314    // Scatter the (deal, strain) rows back into per-deal tables. Each
315    // (d, s) is unique, so order of application does not matter.
316    let mut tables = vec![TrickCountTable::default(); deals.len()];
317    for (d, s, row) in rows {
318        tables[d].tricks[s] = row;
319    }
320    tables
321}
322
323/// Solve a single deal, spreading its 5 strains across rayon workers.
324///
325/// The recommended way to solve one deal. Where a single per-strain
326/// [`Solver`] would run the 5 strains sequentially on one thread, this
327/// fans them out so a single deal can use up to 5 cores — markedly faster
328/// on a multi-core machine, and what keeps the pure-Rust solver
329/// competitive with the FFI engines (whose own single-deal calls are
330/// internally threaded). For many deals at once, prefer [`solve_deals`].
331#[must_use]
332pub fn solve_deal(deal: FullDeal) -> TrickCountTable {
333    solve_deals(std::slice::from_ref(&deal))
334        .pop()
335        .unwrap_or_default()
336}
337
338/// Solve a single deal sequentially on `solver`, returning the full
339/// 5 × 4 [`TrickCountTable`].
340///
341/// The deterministic single-thread counterpart to [`solve_deal`]: it
342/// drives one per-strain [`Solver`] across all 5 strains in turn, on the
343/// calling thread, so the solver's engine diagnostics
344/// ([`Solver::search_stats`], [`Solver::bisection_stats`]) accumulate over
345/// the whole table. Reuse the same `solver` across deals to amortise its
346/// transposition-table allocation and gather corpus-wide statistics. For
347/// throughput-oriented solving, prefer the parallel [`solve_deal`] /
348/// [`solve_deals`].
349#[must_use]
350pub fn solve_deal_on(solver: &mut Solver, deal: FullDeal) -> TrickCountTable {
351    let mut table = TrickCountTable::default();
352    for (i, strain) in STRAINS.iter().enumerate() {
353        solver.set_strain(*strain);
354        table.tricks[i] = solver.solve(deal);
355    }
356    table
357}
358
359// ---------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use contract_bridge::deal::Builder;
367    use contract_bridge::hand::{Hand, Holding};
368
369    /// Solve a full deal on a fresh per-strain [`Solver`] — the
370    /// deterministic single-thread reference the parallel free functions
371    /// are checked against.
372    fn solve_deal_sequential(deal: FullDeal) -> TrickCountTable {
373        solve_deal_on(&mut Solver::new(Strain::Notrump), deal)
374    }
375
376    /// Build a deal where each seat holds exactly one full 13-card suit:
377    /// North = spades, East = hearts, South = diamonds, West = clubs.
378    fn each_hand_holds_one_suit_deal() -> FullDeal {
379        let full = Holding::ALL;
380        let empty = Holding::EMPTY;
381        let n_hand = Hand::new(empty, empty, empty, full); // C,D,H,S → only spades
382        let e_hand = Hand::new(empty, empty, full, empty); // hearts
383        let s_hand = Hand::new(empty, full, empty, empty); // diamonds
384        let w_hand = Hand::new(full, empty, empty, empty); // clubs
385
386        Builder::new()
387            .north(n_hand)
388            .east(e_hand)
389            .south(s_hand)
390            .west(w_hand)
391            .build_full()
392            .expect("each-suit fixture should be a valid full deal")
393    }
394
395    /// Pos conversion: each hand holds exactly one suit at full strength
396    /// → that suit's bitmap is the DDS "all 13 ranks set" pattern
397    /// (`0x1FFF`) for one hand and zero for the other three.
398    #[test]
399    fn pos_from_deal_each_hand_one_suit() {
400        // contract_bridge → DDS suit mapping reminder:
401        //   Suit::Clubs (0)    -> DDS suit 3
402        //   Suit::Diamonds (1) -> DDS suit 2
403        //   Suit::Hearts (2)   -> DDS suit 1
404        //   Suit::Spades (3)   -> DDS suit 0
405        //
406        // DDS bit layout: rank `r` at bit `r-2`, so `Holding::ALL`
407        // (0x7FFC, bits 2..=14) shifts to 0x1FFF (bits 0..=12).
408        const DDS_ALL: u16 = 0x1FFF;
409
410        let deal = each_hand_holds_one_suit_deal();
411        let pos = pos_from_deal(&deal);
412
413        // N (hand 0) holds spades → DDS suit 0.
414        assert_eq!(pos.rank_in_suit[0][0], DDS_ALL);
415        assert_eq!(pos.rank_in_suit[0][1], 0);
416        assert_eq!(pos.rank_in_suit[0][2], 0);
417        assert_eq!(pos.rank_in_suit[0][3], 0);
418        // E (hand 1) holds hearts → DDS suit 1.
419        assert_eq!(pos.rank_in_suit[1][1], DDS_ALL);
420        // S (hand 2) holds diamonds → DDS suit 2.
421        assert_eq!(pos.rank_in_suit[2][2], DDS_ALL);
422        // W (hand 3) holds clubs → DDS suit 3.
423        assert_eq!(pos.rank_in_suit[3][3], DDS_ALL);
424    }
425
426    /// Notrump table for the each-hand-holds-one-suit fixture.
427    ///
428    /// In NT, the opening leader must lead from their own suit; whoever
429    /// of declarer / dummy can ruff (no one — notrump) takes only when
430    /// the led suit is their own. With each suit fully held by one seat:
431    ///
432    /// * If declarer leads their own suit (= holds it), they have all
433    ///   13 cards and run them all → 13 tricks for declarer.
434    /// * BUT the opening lead is by declarer's LHO. The LHO must lead
435    ///   from one of their suits (= the LHO's only suit). Since the
436    ///   suits are disjoint, the LHO's lead is in a suit neither
437    ///   declarer nor dummy holds → declarer/dummy must discard.
438    ///
439    /// Walking it through trick by trick: every trick is won by the
440    /// leader (since no one else has the suit and there's no trump).
441    /// The lead rotates only when the winner is on a different side.
442    ///
443    /// In this fixture, the LHO leads first; the LHO wins (they have
444    /// all the cards in their suit), so they lead again. They keep
445    /// winning every trick until they run out (13 tricks). So the
446    /// opening leader wins all 13.
447    ///
448    /// * Declarer N: LHO = E. E wins 13. Declarer N → 0.
449    /// * Declarer E: LHO = S. S wins 13. Declarer E → 0.
450    /// * Declarer S: LHO = W. W wins 13. Declarer S → 0.
451    /// * Declarer W: LHO = N. N wins 13. Declarer W → 0.
452    ///
453    /// So the entire NT row is zeros.
454    #[test]
455    fn solve_deal_each_hand_one_suit_notrump() {
456        let deal = each_hand_holds_one_suit_deal();
457        let table = solve_deal_sequential(deal);
458
459        // Notrump row: declarer always makes 0.
460        for seat in Seat::ALL {
461            assert_eq!(
462                table.get(Strain::Notrump, seat),
463                0,
464                "declarer {seat} at NT should make 0 tricks (LHO runs their suit)"
465            );
466        }
467    }
468
469    /// Trump-table analytic check for the each-hand-holds-one-suit
470    /// fixture.
471    ///
472    /// With every suit a perfect 13-card holding in one hand, the
473    /// "trump suit" picks a winner that takes everything it has and
474    /// ruffs all 13 cards from any other lead. The result:
475    ///
476    /// * The seat holding the trump suit always wins every trick — they
477    ///   either lead the trump suit (their hand) or ruff a non-trump
478    ///   lead. So that seat takes 13 tricks regardless of who declares.
479    ///
480    /// Translating into the table: for trump strain `X`, the only seat
481    /// that wins any tricks is the one that holds suit `X`. If declarer
482    /// IS that seat, declarer makes 13. If declarer is on the same side
483    /// (partner), declarer-side makes 13 → declarer makes 13. Otherwise
484    /// declarer makes 0.
485    ///
486    /// Suit ownership in this fixture:
487    ///   spades → N, hearts → E, diamonds → S, clubs → W
488    ///
489    /// So:
490    ///   * Spades trump: N and S (= NS) win 13; E and W (= EW) win 0.
491    ///   * Hearts trump: E and W (= EW) win 13; N and S (= NS) win 0.
492    ///   * Diamonds trump: same as spades (S holds them → NS wins 13).
493    ///   * Clubs trump: same as hearts (W holds them → EW wins 13).
494    #[test]
495    fn solve_deal_each_hand_one_suit_trump_tables() {
496        let deal = each_hand_holds_one_suit_deal();
497        let table = solve_deal_sequential(deal);
498
499        // (strain, ns_makes, ew_makes)
500        let cases = [
501            (Strain::Spades, 13, 0),   // N owns spades → NS wins
502            (Strain::Hearts, 0, 13),   // E owns hearts → EW wins
503            (Strain::Diamonds, 13, 0), // S owns diamonds → NS wins
504            (Strain::Clubs, 0, 13),    // W owns clubs → EW wins
505        ];
506        for (strain, ns, ew) in cases {
507            assert_eq!(table.get(strain, Seat::North), ns, "N declaring {strain}");
508            assert_eq!(table.get(strain, Seat::South), ns, "S declaring {strain}");
509            assert_eq!(table.get(strain, Seat::East), ew, "E declaring {strain}");
510            assert_eq!(table.get(strain, Seat::West), ew, "W declaring {strain}");
511        }
512    }
513
514    /// Batch solver returns the same table as a sequential per-deal
515    /// solve, and preserves input order.
516    #[test]
517    fn solve_deals_matches_single_deal_solver() {
518        let deal_a = each_hand_holds_one_suit_deal();
519        // Second deal: rotate by swapping NS and EW to verify ordering.
520        // We just reuse the same deal twice — sufficient for ordering /
521        // parity.
522        let deals = vec![deal_a, deal_a];
523
524        let expected_a = solve_deal_sequential(deal_a);
525
526        let parallel = solve_deals(&deals);
527        assert_eq!(parallel.len(), 2);
528        assert_eq!(parallel[0], expected_a);
529        assert_eq!(parallel[1], expected_a);
530    }
531
532    /// The free `solve_deal` fans the 5 strains across rayon workers but
533    /// must return the same table as the sequential single-thread solve.
534    #[test]
535    fn solve_deal_matches_single_deal_solver() {
536        let deal = each_hand_holds_one_suit_deal();
537        assert_eq!(solve_deal(deal), solve_deal_sequential(deal));
538    }
539
540    /// Cross-check against a hand-verified reference table.
541    ///
542    /// The expected double-dummy table for the PBN deal
543    ///
544    /// ```text
545    /// N:.63.AKQ987.A9732 A8654.KQ5.T.QJT6 J973.J98742.3.K4 KQT2.AT.J6542.85
546    /// ```
547    ///
548    /// was generated by the FFI-backed `ddss::Solver` (which wraps the
549    /// upstream DDS C++ reference). Both partnerships and all five
550    /// strains are covered, so any sign error or off-by-one in the
551    /// `FullDeal → Pos` conversion / opening-leader assignment will
552    /// surface here.
553    #[test]
554    fn solve_deal_matches_reference_pbn() {
555        let pbn = "N:.63.AKQ987.A9732 A8654.KQ5.T.QJT6 \
556                   J973.J98742.3.K4 KQT2.AT.J6542.85";
557        let deal: FullDeal = pbn.parse().expect("reference PBN parses");
558
559        let got = solve_deal_sequential(deal);
560
561        // Reference rows in (N, E, S, W) order — verified against
562        // ddss::Solver::lock().solve_deal(deal).
563        let expected = TrickCountTable {
564            tricks: [
565                [8, 5, 8, 5], // ♣
566                [8, 5, 8, 5], // ♦
567                [6, 5, 6, 6], // ♥
568                [4, 9, 4, 9], // ♠
569                [5, 8, 5, 8], // NT
570            ],
571        };
572
573        assert_eq!(got, expected, "DD table mismatch for reference deal");
574    }
575}