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 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}