dds-bridge 0.20.0

Rusty API for DDS, the double dummy solver for bridge
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
//!
//! # Panic policy
//!
//! The solver entry points in this crate — [`calculate_par`],
//! [`calculate_pars`], [`Solver::solve_deal`], [`Solver::solve_board`],
//! [`solve_deals`], [`solve_boards`], [`analyse_play`], and [`analyse_plays`]
//! — are not expected to panic.  They map DDS status codes through an
//! internal helper that panics on error, but reaching that panic means
//! either invalid input slipped past a safe constructor or DDS itself
//! misbehaved. Either case is a bug — please report it.
//!
//! This policy does not cover validator panics from safe constructors
//! (e.g. [`TrickCountRow::new`]), which panic by design on out-of-range
//! inputs and have `try_*` counterparts for fallible construction.

mod board;
mod ffi;
mod par;
mod play;
mod strain_flags;
mod system_info;
mod tricks;
mod vulnerability;

pub use board::*;
pub use par::*;
pub use play::*;
pub use strain_flags::*;
pub use system_info::*;
pub use tricks::*;
pub use vulnerability::*;

use contract_bridge::deal::FullDeal;
use contract_bridge::seat::Seat;

use dds_bridge_sys as sys;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};

use core::ffi::c_int;
use core::mem::MaybeUninit;
use core::ptr::NonNull;
use std::sync::LazyLock;

/// Panics if `status` is negative, which indicates an error in DDS.  The panic
/// message is a human-readable description of the error code returned by DDS.
const fn check(status: i32) {
    let msg: &[u8] = match status {
        0.. => return,
        sys::RETURN_ZERO_CARDS => sys::TEXT_ZERO_CARDS,
        sys::RETURN_TARGET_TOO_HIGH => sys::TEXT_TARGET_TOO_HIGH,
        sys::RETURN_DUPLICATE_CARDS => sys::TEXT_DUPLICATE_CARDS,
        sys::RETURN_TARGET_WRONG_LO => sys::TEXT_TARGET_WRONG_LO,
        sys::RETURN_TARGET_WRONG_HI => sys::TEXT_TARGET_WRONG_HI,
        sys::RETURN_SOLNS_WRONG_LO => sys::TEXT_SOLNS_WRONG_LO,
        sys::RETURN_SOLNS_WRONG_HI => sys::TEXT_SOLNS_WRONG_HI,
        sys::RETURN_TOO_MANY_CARDS => sys::TEXT_TOO_MANY_CARDS,
        sys::RETURN_SUIT_OR_RANK => sys::TEXT_SUIT_OR_RANK,
        sys::RETURN_PLAYED_CARD => sys::TEXT_PLAYED_CARD,
        sys::RETURN_CARD_COUNT => sys::TEXT_CARD_COUNT,
        sys::RETURN_THREAD_INDEX => sys::TEXT_THREAD_INDEX,
        sys::RETURN_MODE_WRONG_LO => sys::TEXT_MODE_WRONG_LO,
        sys::RETURN_MODE_WRONG_HI => sys::TEXT_MODE_WRONG_HI,
        sys::RETURN_TRUMP_WRONG => sys::TEXT_TRUMP_WRONG,
        sys::RETURN_FIRST_WRONG => sys::TEXT_FIRST_WRONG,
        sys::RETURN_PLAY_FAULT => sys::TEXT_PLAY_FAULT,
        sys::RETURN_PBN_FAULT => sys::TEXT_PBN_FAULT,
        sys::RETURN_TOO_MANY_BOARDS => sys::TEXT_TOO_MANY_BOARDS,
        sys::RETURN_THREAD_CREATE => sys::TEXT_THREAD_CREATE,
        sys::RETURN_THREAD_WAIT => sys::TEXT_THREAD_WAIT,
        sys::RETURN_THREAD_MISSING => sys::TEXT_THREAD_MISSING,
        sys::RETURN_NO_SUIT => sys::TEXT_NO_SUIT,
        sys::RETURN_TOO_MANY_TABLES => sys::TEXT_TOO_MANY_TABLES,
        sys::RETURN_CHUNK_SIZE => sys::TEXT_CHUNK_SIZE,
        _ => sys::TEXT_UNKNOWN_FAULT,
    };
    // SAFETY: Error messages are ASCII literals in the C++ code of DDS.
    panic!("{}", unsafe { core::str::from_utf8_unchecked(msg) });
}

/// Calculate par score and contracts for a deal
///
/// - `tricks`: The number of tricks each seat can take as declarer for each strain
/// - `vul`: The vulnerability of pairs
/// - `dealer`: The dealer of the deal
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn calculate_par(tricks: TrickCountTable, vul: Vulnerability, dealer: Seat) -> Par {
    let mut par = sys::ParResultsMaster::default();
    let status =
        unsafe { sys::DealerParBin(&tricks.into(), &raw mut par, vul.to_sys(), dealer as c_int) };
    check(status);
    par.into()
}

/// Calculate par scores for both pairs
///
/// - `tricks`: The number of tricks each seat can take as declarer for each strain
/// - `vul`: The vulnerability of pairs
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn calculate_pars(tricks: TrickCountTable, vul: Vulnerability) -> [Par; 2] {
    let mut pars = [sys::ParResultsMaster::default(); 2];
    // SAFE: calculating par is reentrant
    let status = unsafe { sys::SidesParBin(&tricks.into(), &raw mut pars[0], vul.to_sys()) };
    check(status);
    pars.map(Into::into)
}

/// Kind of transposition table to allocate inside a [`Solver`]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TtKind {
    /// Small TT — lower memory footprint
    Small,
    /// Large TT — higher memory footprint, faster on bigger search trees
    Large,
}

impl TtKind {
    #[allow(clippy::cast_possible_wrap)]
    const fn to_sys(self) -> c_int {
        match self {
            Self::Small => sys::DDS_TT_KIND_SMALL as c_int,
            Self::Large => sys::DDS_TT_KIND_LARGE as c_int,
        }
    }
}

/// Configuration for a [`Solver`]
///
/// `0` for either memory field means "use the upstream default".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SolverConfig {
    /// Kind of transposition table to allocate
    pub tt_kind: TtKind,
    /// Default (initial) TT size in MiB; `0` for the upstream default
    pub tt_mem_default_mb: u32,
    /// Maximum TT size in MiB; `0` for the upstream default
    pub tt_mem_maximum_mb: u32,
}

impl Default for SolverConfig {
    fn default() -> Self {
        Self {
            tt_kind: TtKind::Large,
            tt_mem_default_mb: 0,
            tt_mem_maximum_mb: 0,
        }
    }
}

impl SolverConfig {
    fn to_sys(self) -> sys::DdsSolverConfig {
        sys::DdsSolverConfig {
            tt_kind: self.tt_kind.to_sys(),
            tt_mem_default_mb: c_int::try_from(self.tt_mem_default_mb)
                .expect("tt_mem_default_mb fits in c_int"),
            tt_mem_maximum_mb: c_int::try_from(self.tt_mem_maximum_mb)
                .expect("tt_mem_maximum_mb fits in c_int"),
        }
    }
}

/// Owned handle to a DDS solver context
///
/// One `Solver` owns one DDS `SolverContext` (private solver state:
/// thread-local memory, transposition table, search state) and is the
/// upstream-recommended way to drive DDS in parallel: one `Solver` per OS
/// thread, never shared.
///
/// The handle is [`Send`] (work-stealing pools may move it between threads
/// as long as no two threads access it at once) but not [`Sync`] — upstream
/// forbids concurrent access from multiple threads to a single context.
///
/// The transposition table is preserved across calls on the same `Solver`,
/// so reusing one over a batch of related queries amortizes setup cost.
/// For batches of unrelated queries the free helpers [`solve_deals`] and
/// [`solve_boards`] hand the whole batch to a per-worker-context pool inside
/// `dds-bridge-sys`.
///
/// [`Drop`] calls `dds_solver_context_free`.
pub struct Solver {
    handle: NonNull<sys::DdsSolverContext>,
}

// SAFETY: ownership is single-threaded at any one time. Sending the handle
// across threads is fine; concurrent shared access is not (hence !Sync).
unsafe impl Send for Solver {}

impl Default for Solver {
    /// Construct a new solver with the [`SolverConfig::default`] configuration.
    fn default() -> Self {
        Self::new(SolverConfig::default())
    }
}

impl Solver {
    /// Construct a new solver with the given configuration
    ///
    /// # Panics
    ///
    /// If the C++ allocator returns a null pointer.
    #[must_use]
    pub fn new(config: SolverConfig) -> Self {
        let cfg = config.to_sys();
        // SAFETY: cfg is a valid, properly-initialized struct.
        let raw = unsafe { sys::dds_solver_context_new(&raw const cfg) };
        let handle = NonNull::new(raw).expect("dds_solver_context_new returned null");
        Self { handle }
    }

    /// Solve a single deal for all strains and all declarers
    ///
    /// Resets internal search state before solving so the result does not
    /// depend on previous solves on this `Solver` (the transposition table
    /// is preserved across calls).
    ///
    /// # Panics
    ///
    /// Not expected — panics here are bugs. See the module-level panic policy.
    ///
    /// # Examples
    ///
    /// ```
    /// use contract_bridge::{FullDeal, Seat, Strain};
    /// use dds_bridge::Solver;
    ///
    /// # fn main() -> Result<(), Box<dyn core::error::Error>> {
    /// // Each player holds a 13-card straight flush in one suit.
    /// let deal: FullDeal = "N:AKQJT98765432... .AKQJT98765432.. \
    ///                       ..AKQJT98765432. ...AKQJT98765432".parse()?;
    /// let mut solver = Solver::default();
    /// let tricks = solver.solve_deal(deal);
    /// // North holds all the spades, so North or South declaring spades
    /// // draws trumps and takes every trick.
    /// assert_eq!(u8::from(tricks[Strain::Spades].get(Seat::North)), 13);
    /// # Ok(())
    /// # }
    /// ```
    #[must_use]
    pub fn solve_deal(&mut self, deal: FullDeal) -> TrickCountTable {
        let table_deal = tricks::dd_table_deal_from(deal);
        let mut result = sys::DdTableResults::default();
        // SAFETY: handle is non-null and owned by self; pointers are valid
        // for the duration of the call.
        let status = unsafe {
            sys::dds_solver_context_reset_for_solve(self.handle.as_ptr());
            sys::dds_calc_dd_table(self.handle.as_ptr(), &raw const table_deal, &raw mut result)
        };
        check(status);
        result.into()
    }

    /// Solve a single board against an [`Objective`]
    ///
    /// Resets internal search state before solving so the result does not
    /// depend on previous solves on this `Solver` (the transposition table
    /// is preserved across calls).
    ///
    /// # Panics
    ///
    /// Not expected — panics here are bugs. See the module-level panic policy.
    #[must_use]
    pub fn solve_board(&mut self, objective: &Objective) -> FoundPlays {
        let deal = sys::Deal::from(objective.board.clone());
        let mut result = sys::FutureTricks::default();
        // SAFETY: handle is non-null and owned by self; pointers are valid
        // for the duration of the call.
        let status = unsafe {
            sys::dds_solver_context_reset_for_solve(self.handle.as_ptr());
            sys::dds_solve_board(
                self.handle.as_ptr(),
                &raw const deal,
                objective.target.target(),
                objective.target.solutions(),
                0,
                &raw mut result,
            )
        };
        check(status);
        FoundPlays::from(result)
    }
}

impl Drop for Solver {
    fn drop(&mut self) {
        // SAFETY: handle was returned by dds_solver_context_new and has not
        // been freed yet (Drop runs at most once).
        unsafe { sys::dds_solver_context_free(self.handle.as_ptr()) };
    }
}

/// Get information about the underlying DDS library
#[must_use]
pub fn system_info() -> SystemInfo {
    let mut inner = MaybeUninit::uninit();
    unsafe { sys::GetDDSInfo(inner.as_mut_ptr()) };
    SystemInfo(unsafe { inner.assume_init() })
}

/// Solve a slice of deals in parallel
///
/// Hands the whole batch to the `dds-bridge-sys` batched FFI, which owns an
/// internal worker pool sized to `std::thread::hardware_concurrency()`.  Each
/// worker owns one [`SolverContext`](sys::DdsSolverContext) (with TT reuse
/// across the deals it processes) and pulls work via an atomic counter.
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn solve_deals(deals: &[FullDeal]) -> Vec<TrickCountTable> {
    if deals.is_empty() {
        return Vec::new();
    }
    let sys_deals: Vec<sys::DdTableDeal> = deals
        .iter()
        .copied()
        .map(tricks::dd_table_deal_from)
        .collect();
    let mut results: Vec<sys::DdTableResults> = vec![sys::DdTableResults::default(); deals.len()];
    let cfg = SolverConfig::default().to_sys();
    // SAFETY: sys_deals and results are valid, disjoint, and length-matched
    // for the duration of the call; the FFI owns the worker pool and per-worker
    // SolverContexts internally.
    let status = unsafe {
        sys::dds_calc_dd_tables_batched(
            c_int::try_from(deals.len()).expect("deals.len() fits in c_int"),
            sys_deals.as_ptr(),
            results.as_mut_ptr(),
            0,
            &raw const cfg,
        )
    };
    check(status);
    results.into_iter().map(Into::into).collect()
}

/// Solve a slice of boards in parallel
///
/// Hands the whole batch to the `dds-bridge-sys` batched FFI; each worker
/// owns one [`SolverContext`](sys::DdsSolverContext) and pulls work via an
/// atomic counter, with `mode = 0` matching [`Solver::solve_board`].
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn solve_boards(args: &[Objective]) -> Vec<FoundPlays> {
    if args.is_empty() {
        return Vec::new();
    }
    let deals: Vec<sys::Deal> = args
        .iter()
        .map(|o| sys::Deal::from(o.board.clone()))
        .collect();
    let targets: Vec<c_int> = args.iter().map(|o| o.target.target()).collect();
    let solutions: Vec<c_int> = args.iter().map(|o| o.target.solutions()).collect();
    let modes: Vec<c_int> = vec![0; args.len()];
    let mut results: Vec<sys::FutureTricks> = vec![sys::FutureTricks::default(); args.len()];
    let cfg = SolverConfig::default().to_sys();
    // SAFETY: all five input arrays and results are length-matched and valid
    // for the duration of the call; the FFI owns the worker pool internally.
    let status = unsafe {
        sys::dds_solve_boards_batched(
            c_int::try_from(args.len()).expect("args.len() fits in c_int"),
            deals.as_ptr(),
            targets.as_ptr(),
            solutions.as_ptr(),
            modes.as_ptr(),
            results.as_mut_ptr(),
            0,
            &raw const cfg,
        )
    };
    check(status);
    results.into_iter().map(FoundPlays::from).collect()
}

/// One-shot initialization of the legacy DDS thread pool, needed only by the
/// `AnalysePlayBin` path (the modern [`Solver`] context manages its own
/// threads).
static INIT_LEGACY_POOL: LazyLock<()> = LazyLock::new(|| unsafe { sys::SetMaxThreads(0) });

/// Trace DD trick counts before and after each played card with
/// [`sys::AnalysePlayBin`]
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn analyse_play(trace: &PlayTrace) -> PlayAnalysis {
    LazyLock::force(&INIT_LEGACY_POOL);
    let mut result = sys::SolvedPlay::default();
    let play = PlayTraceBin::from(&trace.cards);
    let status =
        unsafe { sys::AnalysePlayBin(trace.board.clone().into(), play.0, &raw mut result, 0) };
    check(status);
    PlayAnalysis::from(result)
}

/// Trace DD trick counts for many plays in parallel
///
/// Fans out across rayon workers, each calling [`sys::AnalysePlayBin`] with
/// `threadIndex = 0`.  Per the `dds-bridge-sys` documentation, this entry
/// point is safe for concurrent invocation across threads, though each call
/// pays the full setup cost of a fresh internal solver context (no TT
/// reuse across traces — the modern shim does not yet expose a context
/// variant of `analyse_play`).
///
/// # Panics
///
/// Not expected — panics here are bugs. See the module-level panic policy.
#[must_use]
pub fn analyse_plays(traces: &[PlayTrace]) -> Vec<PlayAnalysis> {
    traces.par_iter().map(analyse_play).collect()
}