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}