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}