Skip to main content

dds_bridge/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3//!
4//! # Panic policy
5//!
6//! The solver entry points in this crate — [`calculate_par`],
7//! [`calculate_pars`], [`Solver::solve_deal`], [`Solver::solve_board`],
8//! [`solve_deals`], [`solve_boards`], [`analyse_play`], and [`analyse_plays`]
9//! — are not expected to panic.  They map DDS status codes through an
10//! internal helper that panics on error, but reaching that panic means
11//! either invalid input slipped past a safe constructor or DDS itself
12//! misbehaved. Either case is a bug — please report it.
13//!
14//! This policy does not cover validator panics from safe constructors
15//! (e.g. [`TrickCountRow::new`]), which panic by design on out-of-range
16//! inputs and have `try_*` counterparts for fallible construction.
17
18mod board;
19mod ffi;
20mod par;
21mod play;
22mod strain_flags;
23mod system_info;
24mod tricks;
25mod vulnerability;
26
27pub use board::*;
28pub use par::*;
29pub use play::*;
30pub use strain_flags::*;
31pub use system_info::*;
32pub use tricks::*;
33pub use vulnerability::*;
34
35use contract_bridge::deal::FullDeal;
36use contract_bridge::seat::Seat;
37
38use dds_bridge_sys as sys;
39use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
40
41use core::ffi::c_int;
42use core::mem::MaybeUninit;
43use core::ptr::NonNull;
44use std::sync::LazyLock;
45
46/// Panics if `status` is negative, which indicates an error in DDS.  The panic
47/// message is a human-readable description of the error code returned by DDS.
48const fn check(status: i32) {
49    let msg: &[u8] = match status {
50        0.. => return,
51        sys::RETURN_ZERO_CARDS => sys::TEXT_ZERO_CARDS,
52        sys::RETURN_TARGET_TOO_HIGH => sys::TEXT_TARGET_TOO_HIGH,
53        sys::RETURN_DUPLICATE_CARDS => sys::TEXT_DUPLICATE_CARDS,
54        sys::RETURN_TARGET_WRONG_LO => sys::TEXT_TARGET_WRONG_LO,
55        sys::RETURN_TARGET_WRONG_HI => sys::TEXT_TARGET_WRONG_HI,
56        sys::RETURN_SOLNS_WRONG_LO => sys::TEXT_SOLNS_WRONG_LO,
57        sys::RETURN_SOLNS_WRONG_HI => sys::TEXT_SOLNS_WRONG_HI,
58        sys::RETURN_TOO_MANY_CARDS => sys::TEXT_TOO_MANY_CARDS,
59        sys::RETURN_SUIT_OR_RANK => sys::TEXT_SUIT_OR_RANK,
60        sys::RETURN_PLAYED_CARD => sys::TEXT_PLAYED_CARD,
61        sys::RETURN_CARD_COUNT => sys::TEXT_CARD_COUNT,
62        sys::RETURN_THREAD_INDEX => sys::TEXT_THREAD_INDEX,
63        sys::RETURN_MODE_WRONG_LO => sys::TEXT_MODE_WRONG_LO,
64        sys::RETURN_MODE_WRONG_HI => sys::TEXT_MODE_WRONG_HI,
65        sys::RETURN_TRUMP_WRONG => sys::TEXT_TRUMP_WRONG,
66        sys::RETURN_FIRST_WRONG => sys::TEXT_FIRST_WRONG,
67        sys::RETURN_PLAY_FAULT => sys::TEXT_PLAY_FAULT,
68        sys::RETURN_PBN_FAULT => sys::TEXT_PBN_FAULT,
69        sys::RETURN_TOO_MANY_BOARDS => sys::TEXT_TOO_MANY_BOARDS,
70        sys::RETURN_THREAD_CREATE => sys::TEXT_THREAD_CREATE,
71        sys::RETURN_THREAD_WAIT => sys::TEXT_THREAD_WAIT,
72        sys::RETURN_THREAD_MISSING => sys::TEXT_THREAD_MISSING,
73        sys::RETURN_NO_SUIT => sys::TEXT_NO_SUIT,
74        sys::RETURN_TOO_MANY_TABLES => sys::TEXT_TOO_MANY_TABLES,
75        sys::RETURN_CHUNK_SIZE => sys::TEXT_CHUNK_SIZE,
76        _ => sys::TEXT_UNKNOWN_FAULT,
77    };
78    // SAFETY: Error messages are ASCII literals in the C++ code of DDS.
79    panic!("{}", unsafe { core::str::from_utf8_unchecked(msg) });
80}
81
82/// Calculate par score and contracts for a deal
83///
84/// - `tricks`: The number of tricks each seat can take as declarer for each strain
85/// - `vul`: The vulnerability of pairs
86/// - `dealer`: The dealer of the deal
87///
88/// # Panics
89///
90/// Not expected — panics here are bugs. See the module-level panic policy.
91#[must_use]
92pub fn calculate_par(tricks: TrickCountTable, vul: Vulnerability, dealer: Seat) -> Par {
93    let mut par = sys::ParResultsMaster::default();
94    let status =
95        unsafe { sys::DealerParBin(&tricks.into(), &raw mut par, vul.to_sys(), dealer as c_int) };
96    check(status);
97    par.into()
98}
99
100/// Calculate par scores for both pairs
101///
102/// - `tricks`: The number of tricks each seat can take as declarer for each strain
103/// - `vul`: The vulnerability of pairs
104///
105/// # Panics
106///
107/// Not expected — panics here are bugs. See the module-level panic policy.
108#[must_use]
109pub fn calculate_pars(tricks: TrickCountTable, vul: Vulnerability) -> [Par; 2] {
110    let mut pars = [sys::ParResultsMaster::default(); 2];
111    // SAFE: calculating par is reentrant
112    let status = unsafe { sys::SidesParBin(&tricks.into(), &raw mut pars[0], vul.to_sys()) };
113    check(status);
114    pars.map(Into::into)
115}
116
117/// Kind of transposition table to allocate inside a [`Solver`]
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119pub enum TtKind {
120    /// Small TT — lower memory footprint
121    Small,
122    /// Large TT — higher memory footprint, faster on bigger search trees
123    Large,
124}
125
126impl TtKind {
127    #[allow(clippy::cast_possible_wrap)]
128    const fn to_sys(self) -> c_int {
129        match self {
130            Self::Small => sys::DDS_TT_KIND_SMALL as c_int,
131            Self::Large => sys::DDS_TT_KIND_LARGE as c_int,
132        }
133    }
134}
135
136/// Configuration for a [`Solver`]
137///
138/// `0` for either memory field means "use the upstream default".
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
140pub struct SolverConfig {
141    /// Kind of transposition table to allocate
142    pub tt_kind: TtKind,
143    /// Default (initial) TT size in MiB; `0` for the upstream default
144    pub tt_mem_default_mb: u32,
145    /// Maximum TT size in MiB; `0` for the upstream default
146    pub tt_mem_maximum_mb: u32,
147}
148
149impl Default for SolverConfig {
150    fn default() -> Self {
151        Self {
152            tt_kind: TtKind::Large,
153            tt_mem_default_mb: 0,
154            tt_mem_maximum_mb: 0,
155        }
156    }
157}
158
159impl SolverConfig {
160    fn to_sys(self) -> sys::DdsSolverConfig {
161        sys::DdsSolverConfig {
162            tt_kind: self.tt_kind.to_sys(),
163            tt_mem_default_mb: c_int::try_from(self.tt_mem_default_mb)
164                .expect("tt_mem_default_mb fits in c_int"),
165            tt_mem_maximum_mb: c_int::try_from(self.tt_mem_maximum_mb)
166                .expect("tt_mem_maximum_mb fits in c_int"),
167        }
168    }
169}
170
171/// Owned handle to a DDS solver context
172///
173/// One `Solver` owns one DDS `SolverContext` (private solver state:
174/// thread-local memory, transposition table, search state) and is the
175/// upstream-recommended way to drive DDS in parallel: one `Solver` per OS
176/// thread, never shared.
177///
178/// The handle is [`Send`] (work-stealing pools may move it between threads
179/// as long as no two threads access it at once) but not [`Sync`] — upstream
180/// forbids concurrent access from multiple threads to a single context.
181///
182/// The transposition table is preserved across calls on the same `Solver`,
183/// so reusing one over a batch of related queries amortizes setup cost.
184/// For batches of unrelated queries the free helpers [`solve_deals`] and
185/// [`solve_boards`] hand the whole batch to a per-worker-context pool inside
186/// `dds-bridge-sys`.
187///
188/// [`Drop`] calls `dds_solver_context_free`.
189pub struct Solver {
190    handle: NonNull<sys::DdsSolverContext>,
191}
192
193// SAFETY: ownership is single-threaded at any one time. Sending the handle
194// across threads is fine; concurrent shared access is not (hence !Sync).
195unsafe impl Send for Solver {}
196
197impl Default for Solver {
198    /// Construct a new solver with the [`SolverConfig::default`] configuration.
199    fn default() -> Self {
200        Self::new(SolverConfig::default())
201    }
202}
203
204impl Solver {
205    /// Construct a new solver with the given configuration
206    ///
207    /// # Panics
208    ///
209    /// If the C++ allocator returns a null pointer.
210    #[must_use]
211    pub fn new(config: SolverConfig) -> Self {
212        let cfg = config.to_sys();
213        // SAFETY: cfg is a valid, properly-initialized struct.
214        let raw = unsafe { sys::dds_solver_context_new(&raw const cfg) };
215        let handle = NonNull::new(raw).expect("dds_solver_context_new returned null");
216        Self { handle }
217    }
218
219    /// Solve a single deal for all strains and all declarers
220    ///
221    /// Resets internal search state before solving so the result does not
222    /// depend on previous solves on this `Solver` (the transposition table
223    /// is preserved across calls).
224    ///
225    /// # Panics
226    ///
227    /// Not expected — panics here are bugs. See the module-level panic policy.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use contract_bridge::{FullDeal, Seat, Strain};
233    /// use dds_bridge::Solver;
234    ///
235    /// # fn main() -> Result<(), Box<dyn core::error::Error>> {
236    /// // Each player holds a 13-card straight flush in one suit.
237    /// let deal: FullDeal = "N:AKQJT98765432... .AKQJT98765432.. \
238    ///                       ..AKQJT98765432. ...AKQJT98765432".parse()?;
239    /// let mut solver = Solver::default();
240    /// let tricks = solver.solve_deal(deal);
241    /// // North holds all the spades, so North or South declaring spades
242    /// // draws trumps and takes every trick.
243    /// assert_eq!(u8::from(tricks[Strain::Spades].get(Seat::North)), 13);
244    /// # Ok(())
245    /// # }
246    /// ```
247    #[must_use]
248    pub fn solve_deal(&mut self, deal: FullDeal) -> TrickCountTable {
249        let table_deal = tricks::dd_table_deal_from(deal);
250        let mut result = sys::DdTableResults::default();
251        // SAFETY: handle is non-null and owned by self; pointers are valid
252        // for the duration of the call.
253        let status = unsafe {
254            sys::dds_solver_context_reset_for_solve(self.handle.as_ptr());
255            sys::dds_calc_dd_table(self.handle.as_ptr(), &raw const table_deal, &raw mut result)
256        };
257        check(status);
258        result.into()
259    }
260
261    /// Solve a single board against an [`Objective`]
262    ///
263    /// Resets internal search state before solving so the result does not
264    /// depend on previous solves on this `Solver` (the transposition table
265    /// is preserved across calls).
266    ///
267    /// # Panics
268    ///
269    /// Not expected — panics here are bugs. See the module-level panic policy.
270    #[must_use]
271    pub fn solve_board(&mut self, objective: &Objective) -> FoundPlays {
272        let deal = sys::Deal::from(objective.board.clone());
273        let mut result = sys::FutureTricks::default();
274        // SAFETY: handle is non-null and owned by self; pointers are valid
275        // for the duration of the call.
276        let status = unsafe {
277            sys::dds_solver_context_reset_for_solve(self.handle.as_ptr());
278            sys::dds_solve_board(
279                self.handle.as_ptr(),
280                &raw const deal,
281                objective.target.target(),
282                objective.target.solutions(),
283                0,
284                &raw mut result,
285            )
286        };
287        check(status);
288        FoundPlays::from(result)
289    }
290}
291
292impl Drop for Solver {
293    fn drop(&mut self) {
294        // SAFETY: handle was returned by dds_solver_context_new and has not
295        // been freed yet (Drop runs at most once).
296        unsafe { sys::dds_solver_context_free(self.handle.as_ptr()) };
297    }
298}
299
300/// Get information about the underlying DDS library
301#[must_use]
302pub fn system_info() -> SystemInfo {
303    let mut inner = MaybeUninit::uninit();
304    unsafe { sys::GetDDSInfo(inner.as_mut_ptr()) };
305    SystemInfo(unsafe { inner.assume_init() })
306}
307
308/// Solve a slice of deals in parallel
309///
310/// Hands the whole batch to the `dds-bridge-sys` batched FFI, which owns an
311/// internal worker pool sized to `std::thread::hardware_concurrency()`.  Each
312/// worker owns one [`SolverContext`](sys::DdsSolverContext) (with TT reuse
313/// across the deals it processes) and pulls work via an atomic counter.
314///
315/// # Panics
316///
317/// Not expected — panics here are bugs. See the module-level panic policy.
318#[must_use]
319pub fn solve_deals(deals: &[FullDeal]) -> Vec<TrickCountTable> {
320    if deals.is_empty() {
321        return Vec::new();
322    }
323    let sys_deals: Vec<sys::DdTableDeal> = deals
324        .iter()
325        .copied()
326        .map(tricks::dd_table_deal_from)
327        .collect();
328    let mut results: Vec<sys::DdTableResults> = vec![sys::DdTableResults::default(); deals.len()];
329    let cfg = SolverConfig::default().to_sys();
330    // SAFETY: sys_deals and results are valid, disjoint, and length-matched
331    // for the duration of the call; the FFI owns the worker pool and per-worker
332    // SolverContexts internally.
333    let status = unsafe {
334        sys::dds_calc_dd_tables_batched(
335            c_int::try_from(deals.len()).expect("deals.len() fits in c_int"),
336            sys_deals.as_ptr(),
337            results.as_mut_ptr(),
338            0,
339            &raw const cfg,
340        )
341    };
342    check(status);
343    results.into_iter().map(Into::into).collect()
344}
345
346/// Solve a slice of boards in parallel
347///
348/// Hands the whole batch to the `dds-bridge-sys` batched FFI; each worker
349/// owns one [`SolverContext`](sys::DdsSolverContext) and pulls work via an
350/// atomic counter, with `mode = 0` matching [`Solver::solve_board`].
351///
352/// # Panics
353///
354/// Not expected — panics here are bugs. See the module-level panic policy.
355#[must_use]
356pub fn solve_boards(args: &[Objective]) -> Vec<FoundPlays> {
357    if args.is_empty() {
358        return Vec::new();
359    }
360    let deals: Vec<sys::Deal> = args
361        .iter()
362        .map(|o| sys::Deal::from(o.board.clone()))
363        .collect();
364    let targets: Vec<c_int> = args.iter().map(|o| o.target.target()).collect();
365    let solutions: Vec<c_int> = args.iter().map(|o| o.target.solutions()).collect();
366    let modes: Vec<c_int> = vec![0; args.len()];
367    let mut results: Vec<sys::FutureTricks> = vec![sys::FutureTricks::default(); args.len()];
368    let cfg = SolverConfig::default().to_sys();
369    // SAFETY: all five input arrays and results are length-matched and valid
370    // for the duration of the call; the FFI owns the worker pool internally.
371    let status = unsafe {
372        sys::dds_solve_boards_batched(
373            c_int::try_from(args.len()).expect("args.len() fits in c_int"),
374            deals.as_ptr(),
375            targets.as_ptr(),
376            solutions.as_ptr(),
377            modes.as_ptr(),
378            results.as_mut_ptr(),
379            0,
380            &raw const cfg,
381        )
382    };
383    check(status);
384    results.into_iter().map(FoundPlays::from).collect()
385}
386
387/// One-shot initialization of the legacy DDS thread pool, needed only by the
388/// `AnalysePlayBin` path (the modern [`Solver`] context manages its own
389/// threads).
390static INIT_LEGACY_POOL: LazyLock<()> = LazyLock::new(|| unsafe { sys::SetMaxThreads(0) });
391
392/// Trace DD trick counts before and after each played card with
393/// [`sys::AnalysePlayBin`]
394///
395/// # Panics
396///
397/// Not expected — panics here are bugs. See the module-level panic policy.
398#[must_use]
399pub fn analyse_play(trace: &PlayTrace) -> PlayAnalysis {
400    LazyLock::force(&INIT_LEGACY_POOL);
401    let mut result = sys::SolvedPlay::default();
402    let play = PlayTraceBin::from(&trace.cards);
403    let status =
404        unsafe { sys::AnalysePlayBin(trace.board.clone().into(), play.0, &raw mut result, 0) };
405    check(status);
406    PlayAnalysis::from(result)
407}
408
409/// Trace DD trick counts for many plays in parallel
410///
411/// Fans out across rayon workers, each calling [`sys::AnalysePlayBin`] with
412/// `threadIndex = 0`.  Per the `dds-bridge-sys` documentation, this entry
413/// point is safe for concurrent invocation across threads, though each call
414/// pays the full setup cost of a fresh internal solver context (no TT
415/// reuse across traces — the modern shim does not yet expose a context
416/// variant of `analyse_play`).
417///
418/// # Panics
419///
420/// Not expected — panics here are bugs. See the module-level panic policy.
421#[must_use]
422pub fn analyse_plays(traces: &[PlayTrace]) -> Vec<PlayAnalysis> {
423    traces.par_iter().map(analyse_play).collect()
424}