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 std::sync::OnceLock;
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/// Per-worker stack size for the solver thread pool (`solver_pool`).
273///
274/// The alpha-beta search recurses up to ~52 plies with large per-frame
275/// working sets (hence the `large_stack_*` allows in `lib.rs`), so a single
276/// solve can want several MiB of stack — more than rayon's ~2 MiB default
277/// worker stack, which it overflows. This is virtual address space; only
278/// each worker's high-water mark is committed.
279const SOLVER_STACK_SIZE: usize = 16 * 1024 * 1024;
280
281/// Process-wide thread pool for batch solving, built once on first use.
282///
283/// Dedicated rather than rayon's global pool for two reasons: its workers
284/// get the large `SOLVER_STACK_SIZE` stacks the search needs, and owning the
285/// pool keeps each worker's persistent [`Solver`] (and its warm
286/// transposition table) alive across calls. Thread count follows rayon's
287/// usual default (`RAYON_NUM_THREADS`, else the available parallelism).
288fn solver_pool() -> &'static rayon::ThreadPool {
289 static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
290 POOL.get_or_init(|| {
291 rayon::ThreadPoolBuilder::new()
292 .stack_size(SOLVER_STACK_SIZE)
293 .thread_name(|i| format!("pons-dds-solver-{i}"))
294 .build()
295 .expect("failed to build pons-dds solver thread pool")
296 })
297}
298
299/// Whether a strain's tasks should be dispatched ahead of the rest.
300///
301/// Notrump carries by far the heaviest solve-time tail: with no trump there
302/// is no forced cross-ruff ending for the quick-/later-tricks heuristics to
303/// claim, so its worst cases blow the search up hardest. Per-strain *means*
304/// are nearly equal, so this changes makespan, not total work — starting the
305/// tail-risky tasks first keeps a long notrump solve from landing last and
306/// defining the finish time. Tune against `examples/par_balance.rs` on the
307/// target host.
308const fn dispatch_first(strain_idx: usize) -> bool {
309 matches!(STRAINS[strain_idx], Strain::Notrump)
310}
311
312/// Drive `deals` through `solver_pool` with an explicit per-thread
313/// transposition-table budget, returning one [`TrickCountTable`] per deal.
314///
315/// The work unit is one **(deal, strain)** pair — the 4 declarers of a
316/// strain stay together so the per-strain table warms across them. Tasks are
317/// ordered tail-risky-first (`dispatch_first`), then split into a bounded
318/// number of work-stealing chunks. Bounding the chunk count caps rayon's
319/// split-recursion depth (independent of batch size), so the deep search runs
320/// from a shallow rayon stack — in a plain loop within each chunk — and worker
321/// stack use does not grow with the batch. Work-stealing across the chunks
322/// balances the cores without a contended shared counter.
323fn solve_deals_pooled(deals: &[FullDeal], default_mb: u32, max_mb: u32) -> Vec<TrickCountTable> {
324 use rayon::iter::ParallelIterator;
325 use rayon::slice::ParallelSlice;
326 use std::cell::RefCell;
327
328 // Per-worker solver, parked in thread-local storage so it stays off the
329 // deep search stack and warms across calls. The budget rides alongside it
330 // so a worker rebuilds its table only when the budget changes.
331 thread_local! {
332 static SOLVER: RefCell<Option<(u32, u32, Solver)>> = const { RefCell::new(None) };
333 }
334
335 let mut tasks: Vec<(usize, usize)> = (0..deals.len())
336 .flat_map(|d| (0..STRAINS.len()).map(move |s| (d, s)))
337 .collect();
338 // Stable: tail-risky strains first, deal order preserved within a rank.
339 tasks.sort_by_key(|&(_, s)| core::cmp::Reverse(dispatch_first(s)));
340
341 let pool = solver_pool();
342 // Enough chunks for work-stealing to balance, few enough to keep rayon's
343 // split depth (hence the search's rayon-stack nesting) bounded.
344 let target_chunks = pool.current_num_threads().saturating_mul(8).max(1);
345 let chunk_size = tasks.len().div_ceil(target_chunks).max(1);
346
347 let collected: Vec<Vec<(usize, usize, [u8; 4])>> = pool.install(|| {
348 tasks
349 .par_chunks(chunk_size)
350 .map(|chunk| {
351 SOLVER.with(|cell| {
352 let mut slot = cell.borrow_mut();
353 // Rebuild only when the requested budget changed.
354 if !matches!(slot.as_ref(), Some(&(d_mb, m_mb, _)) if d_mb == default_mb && m_mb == max_mb)
355 {
356 *slot = None;
357 }
358 let solver = &mut slot
359 .get_or_insert_with(|| {
360 (
361 default_mb,
362 max_mb,
363 Solver::with_memory(Strain::Notrump, default_mb, max_mb),
364 )
365 })
366 .2;
367
368 let mut rows = Vec::with_capacity(chunk.len());
369 for &(d, s) in chunk {
370 solver.set_strain(STRAINS[s]);
371 rows.push((d, s, solver.solve(deals[d])));
372 }
373 rows
374 })
375 })
376 .collect()
377 });
378
379 // Scatter results back via each task's (deal, strain) pair.
380 let mut tables = vec![TrickCountTable::default(); deals.len()];
381 for (d, s, row) in collected.into_iter().flatten() {
382 tables[d].tricks[s] = row;
383 }
384 tables
385}
386
387/// Solve a batch of deals in parallel.
388///
389/// The unit of work is a single **(deal, strain)** pair: a one-deal batch
390/// spreads its 5 strains across workers, and a large batch yields
391/// `5 × deals.len()` tasks for fine-grained load balancing. The 4 declarers
392/// of a strain stay on one task so the per-strain transposition table warms
393/// across them (see [`Solver::solve`]).
394///
395/// Solving runs on a dedicated, persistent thread pool whose workers each
396/// keep a warm [`Solver`] across calls; tasks are self-scheduled
397/// tail-risky-first. Order of results matches the order of `deals`.
398///
399/// This is the recommended entry point for solving many deals at once; for
400/// low-latency solving of a single deal see [`solve_deal`].
401#[must_use]
402pub fn solve_deals(deals: &[FullDeal]) -> Vec<TrickCountTable> {
403 solve_deals_pooled(
404 deals,
405 crate::tt::DEFAULT_MEMORY_MB,
406 crate::tt::MAX_MEMORY_MB,
407 )
408}
409
410/// Solve a batch of deals in parallel with an explicit per-thread
411/// transposition-table memory budget, in MiB.
412///
413/// Identical in result to [`solve_deals`], but each pool worker builds its
414/// [`Solver`] with [`Solver::with_memory`] (`default_mb` / `max_mb`) instead
415/// of the built-in defaults. Use it to **cap per-thread memory** in highly
416/// parallel runs — the table is per-thread, so the aggregate footprint is
417/// roughly `threads × max_mb` MiB — or to sweep the budget for tuning (see
418/// `examples/tt_sweep.rs`).
419///
420/// Like [`solve_deals`], each worker parks its [`Solver`] in `thread_local`
421/// storage and reuses it across the tasks routed to it — and across calls,
422/// so long as the requested budget is unchanged. A worker rebuilds its
423/// table only when `default_mb` / `max_mb` differ from its previous call
424/// (e.g. between sweep rows). For repeated batches at the default budget,
425/// prefer [`solve_deals`].
426#[must_use]
427pub fn solve_deals_with_memory(
428 deals: &[FullDeal],
429 default_mb: u32,
430 max_mb: u32,
431) -> Vec<TrickCountTable> {
432 solve_deals_pooled(deals, default_mb, max_mb)
433}
434
435/// Solve a single deal, spreading its 5 strains across rayon workers.
436///
437/// The recommended way to solve one deal. Where a single per-strain
438/// [`Solver`] would run the 5 strains sequentially on one thread, this
439/// fans them out so a single deal can use up to 5 cores — markedly faster
440/// on a multi-core machine, and what keeps the pure-Rust solver
441/// competitive with the FFI engines (whose own single-deal calls are
442/// internally threaded). For many deals at once, prefer [`solve_deals`].
443#[must_use]
444pub fn solve_deal(deal: FullDeal) -> TrickCountTable {
445 solve_deals(std::slice::from_ref(&deal))
446 .pop()
447 .unwrap_or_default()
448}
449
450/// Solve a single deal sequentially on `solver`, returning the full
451/// 5 × 4 [`TrickCountTable`].
452///
453/// The deterministic single-thread counterpart to [`solve_deal`]: it
454/// drives one per-strain [`Solver`] across all 5 strains in turn, on the
455/// calling thread, so the solver's engine diagnostics
456/// ([`Solver::search_stats`], [`Solver::bisection_stats`]) accumulate over
457/// the whole table. Reuse the same `solver` across deals to amortise its
458/// transposition-table allocation and gather corpus-wide statistics. For
459/// throughput-oriented solving, prefer the parallel [`solve_deal`] /
460/// [`solve_deals`].
461#[must_use]
462pub fn solve_deal_on(solver: &mut Solver, deal: FullDeal) -> TrickCountTable {
463 let mut table = TrickCountTable::default();
464 for (i, strain) in STRAINS.iter().enumerate() {
465 solver.set_strain(*strain);
466 table.tricks[i] = solver.solve(deal);
467 }
468 table
469}
470
471// ---------------------------------------------------------------------
472// Tests
473// ---------------------------------------------------------------------
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use contract_bridge::deal::Builder;
479 use contract_bridge::hand::{Hand, Holding};
480
481 /// Solve a full deal on a fresh per-strain [`Solver`] — the
482 /// deterministic single-thread reference the parallel free functions
483 /// are checked against.
484 fn solve_deal_sequential(deal: FullDeal) -> TrickCountTable {
485 solve_deal_on(&mut Solver::new(Strain::Notrump), deal)
486 }
487
488 /// Build a deal where each seat holds exactly one full 13-card suit:
489 /// North = spades, East = hearts, South = diamonds, West = clubs.
490 fn each_hand_holds_one_suit_deal() -> FullDeal {
491 let full = Holding::ALL;
492 let empty = Holding::EMPTY;
493 let n_hand = Hand::new(empty, empty, empty, full); // C,D,H,S → only spades
494 let e_hand = Hand::new(empty, empty, full, empty); // hearts
495 let s_hand = Hand::new(empty, full, empty, empty); // diamonds
496 let w_hand = Hand::new(full, empty, empty, empty); // clubs
497
498 Builder::new()
499 .north(n_hand)
500 .east(e_hand)
501 .south(s_hand)
502 .west(w_hand)
503 .build_full()
504 .expect("each-suit fixture should be a valid full deal")
505 }
506
507 /// Pos conversion: each hand holds exactly one suit at full strength
508 /// → that suit's bitmap is the DDS "all 13 ranks set" pattern
509 /// (`0x1FFF`) for one hand and zero for the other three.
510 #[test]
511 fn pos_from_deal_each_hand_one_suit() {
512 // contract_bridge → DDS suit mapping reminder:
513 // Suit::Clubs (0) -> DDS suit 3
514 // Suit::Diamonds (1) -> DDS suit 2
515 // Suit::Hearts (2) -> DDS suit 1
516 // Suit::Spades (3) -> DDS suit 0
517 //
518 // DDS bit layout: rank `r` at bit `r-2`, so `Holding::ALL`
519 // (0x7FFC, bits 2..=14) shifts to 0x1FFF (bits 0..=12).
520 const DDS_ALL: u16 = 0x1FFF;
521
522 let deal = each_hand_holds_one_suit_deal();
523 let pos = pos_from_deal(&deal);
524
525 // N (hand 0) holds spades → DDS suit 0.
526 assert_eq!(pos.rank_in_suit[0][0], DDS_ALL);
527 assert_eq!(pos.rank_in_suit[0][1], 0);
528 assert_eq!(pos.rank_in_suit[0][2], 0);
529 assert_eq!(pos.rank_in_suit[0][3], 0);
530 // E (hand 1) holds hearts → DDS suit 1.
531 assert_eq!(pos.rank_in_suit[1][1], DDS_ALL);
532 // S (hand 2) holds diamonds → DDS suit 2.
533 assert_eq!(pos.rank_in_suit[2][2], DDS_ALL);
534 // W (hand 3) holds clubs → DDS suit 3.
535 assert_eq!(pos.rank_in_suit[3][3], DDS_ALL);
536 }
537
538 /// Notrump table for the each-hand-holds-one-suit fixture.
539 ///
540 /// In NT, the opening leader must lead from their own suit; whoever
541 /// of declarer / dummy can ruff (no one — notrump) takes only when
542 /// the led suit is their own. With each suit fully held by one seat:
543 ///
544 /// * If declarer leads their own suit (= holds it), they have all
545 /// 13 cards and run them all → 13 tricks for declarer.
546 /// * BUT the opening lead is by declarer's LHO. The LHO must lead
547 /// from one of their suits (= the LHO's only suit). Since the
548 /// suits are disjoint, the LHO's lead is in a suit neither
549 /// declarer nor dummy holds → declarer/dummy must discard.
550 ///
551 /// Walking it through trick by trick: every trick is won by the
552 /// leader (since no one else has the suit and there's no trump).
553 /// The lead rotates only when the winner is on a different side.
554 ///
555 /// In this fixture, the LHO leads first; the LHO wins (they have
556 /// all the cards in their suit), so they lead again. They keep
557 /// winning every trick until they run out (13 tricks). So the
558 /// opening leader wins all 13.
559 ///
560 /// * Declarer N: LHO = E. E wins 13. Declarer N → 0.
561 /// * Declarer E: LHO = S. S wins 13. Declarer E → 0.
562 /// * Declarer S: LHO = W. W wins 13. Declarer S → 0.
563 /// * Declarer W: LHO = N. N wins 13. Declarer W → 0.
564 ///
565 /// So the entire NT row is zeros.
566 #[test]
567 fn solve_deal_each_hand_one_suit_notrump() {
568 let deal = each_hand_holds_one_suit_deal();
569 let table = solve_deal_sequential(deal);
570
571 // Notrump row: declarer always makes 0.
572 for seat in Seat::ALL {
573 assert_eq!(
574 table.get(Strain::Notrump, seat),
575 0,
576 "declarer {seat} at NT should make 0 tricks (LHO runs their suit)"
577 );
578 }
579 }
580
581 /// Trump-table analytic check for the each-hand-holds-one-suit
582 /// fixture.
583 ///
584 /// With every suit a perfect 13-card holding in one hand, the
585 /// "trump suit" picks a winner that takes everything it has and
586 /// ruffs all 13 cards from any other lead. The result:
587 ///
588 /// * The seat holding the trump suit always wins every trick — they
589 /// either lead the trump suit (their hand) or ruff a non-trump
590 /// lead. So that seat takes 13 tricks regardless of who declares.
591 ///
592 /// Translating into the table: for trump strain `X`, the only seat
593 /// that wins any tricks is the one that holds suit `X`. If declarer
594 /// IS that seat, declarer makes 13. If declarer is on the same side
595 /// (partner), declarer-side makes 13 → declarer makes 13. Otherwise
596 /// declarer makes 0.
597 ///
598 /// Suit ownership in this fixture:
599 /// spades → N, hearts → E, diamonds → S, clubs → W
600 ///
601 /// So:
602 /// * Spades trump: N and S (= NS) win 13; E and W (= EW) win 0.
603 /// * Hearts trump: E and W (= EW) win 13; N and S (= NS) win 0.
604 /// * Diamonds trump: same as spades (S holds them → NS wins 13).
605 /// * Clubs trump: same as hearts (W holds them → EW wins 13).
606 #[test]
607 fn solve_deal_each_hand_one_suit_trump_tables() {
608 let deal = each_hand_holds_one_suit_deal();
609 let table = solve_deal_sequential(deal);
610
611 // (strain, ns_makes, ew_makes)
612 let cases = [
613 (Strain::Spades, 13, 0), // N owns spades → NS wins
614 (Strain::Hearts, 0, 13), // E owns hearts → EW wins
615 (Strain::Diamonds, 13, 0), // S owns diamonds → NS wins
616 (Strain::Clubs, 0, 13), // W owns clubs → EW wins
617 ];
618 for (strain, ns, ew) in cases {
619 assert_eq!(table.get(strain, Seat::North), ns, "N declaring {strain}");
620 assert_eq!(table.get(strain, Seat::South), ns, "S declaring {strain}");
621 assert_eq!(table.get(strain, Seat::East), ew, "E declaring {strain}");
622 assert_eq!(table.get(strain, Seat::West), ew, "W declaring {strain}");
623 }
624 }
625
626 /// Batch solver returns the same table as a sequential per-deal
627 /// solve, and preserves input order.
628 #[test]
629 fn solve_deals_matches_single_deal_solver() {
630 let deal_a = each_hand_holds_one_suit_deal();
631 // Second deal: rotate by swapping NS and EW to verify ordering.
632 // We just reuse the same deal twice — sufficient for ordering /
633 // parity.
634 let deals = vec![deal_a, deal_a];
635
636 let expected_a = solve_deal_sequential(deal_a);
637
638 let parallel = solve_deals(&deals);
639 assert_eq!(parallel.len(), 2);
640 assert_eq!(parallel[0], expected_a);
641 assert_eq!(parallel[1], expected_a);
642 }
643
644 /// The free `solve_deal` fans the 5 strains across rayon workers but
645 /// must return the same table as the sequential single-thread solve.
646 #[test]
647 fn solve_deal_matches_single_deal_solver() {
648 let deal = each_hand_holds_one_suit_deal();
649 assert_eq!(solve_deal(deal), solve_deal_sequential(deal));
650 }
651
652 /// Cross-check against a hand-verified reference table.
653 ///
654 /// The expected double-dummy table for the PBN deal
655 ///
656 /// ```text
657 /// N:.63.AKQ987.A9732 A8654.KQ5.T.QJT6 J973.J98742.3.K4 KQT2.AT.J6542.85
658 /// ```
659 ///
660 /// was generated by the FFI-backed `ddss::Solver` (which wraps the
661 /// upstream DDS C++ reference). Both partnerships and all five
662 /// strains are covered, so any sign error or off-by-one in the
663 /// `FullDeal → Pos` conversion / opening-leader assignment will
664 /// surface here.
665 #[test]
666 fn solve_deal_matches_reference_pbn() {
667 let pbn = "N:.63.AKQ987.A9732 A8654.KQ5.T.QJT6 \
668 J973.J98742.3.K4 KQT2.AT.J6542.85";
669 let deal: FullDeal = pbn.parse().expect("reference PBN parses");
670
671 let got = solve_deal_sequential(deal);
672
673 // Reference rows in (N, E, S, W) order — verified against
674 // ddss::Solver::lock().solve_deal(deal).
675 let expected = TrickCountTable {
676 tricks: [
677 [8, 5, 8, 5], // ♣
678 [8, 5, 8, 5], // ♦
679 [6, 5, 6, 6], // ♥
680 [4, 9, 4, 9], // ♠
681 [5, 8, 5, 8], // NT
682 ],
683 };
684
685 assert_eq!(got, expected, "DD table mismatch for reference deal");
686 }
687}