pons 0.10.0

Rust package for contract 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
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
//! Gated live double-dummy search bidder — AI-bidder M2.3.
//!
//! This is "simulations in action": the floor *thinks* before it bids.  Where
//! [`NeuralFloor`][crate::bidding::neural_floor::NeuralFloor] returns the distilled net's
//! judgement in one forward pass,
//! [`SearchFloor`][crate::bidding::search_floor::SearchFloor] uses that net only
//! as a *prior* — to propose which calls are worth simulating — then scores the
//! shortlist by cardplay over sampled layouts (the M2.2 [`ev_all`] evaluator) and
//! bids the highest-EV call.  "Net proposes, search disposes."
//!
//! It wears the **same deterministic safety shell** as the neural floor, so the
//! §0.4 forced rails are preserved by construction:
//!
//! - **Forced** — when `forced` reports an auction-determined forced situation
//!   (partner's live takeout double, a prior call committing us to game, a
//!   just-made transfer over our strong notrump), it returns the deterministic
//!   [`instinct`][instinct()] answer verbatim.  The net is never trusted on the rails, and
//!   neither is the search.
//! - **Judgement** — otherwise it runs the search:
//!   1. evaluate the net prior and mask the illegal calls (exactly the neural
//!      floor's judgement path);
//!   2. shortlist the top-`shortlist` legal calls by
//!      that prior;
//!   3. price each over `layouts` sampled deals with
//!      [`ev_all`] under the continuation policy (our own distilled net
//!      bidding all four seats — self-play);
//!   4. re-seat the evaluated calls onto an EV-ranked band above the prior tail,
//!      so the driver's arg-max is the best-EV call while every legal call keeps
//!      a sane fallback logit and `Pass` stays finite (invariant §0.2).
//!
//! # Determinism
//!
//! [`Classifier::classify`][crate::bidding::trie::Classifier::classify] must be a pure function (invariant §0.5), yet the
//! rollout samples layouts.  The shell reconciles this by seeding the rollout
//! RNG *from the decision itself* (a hash of the feature vector, which is a
//! deterministic function of the hand, the auction, vulnerability, and seat): the
//! same decision always draws the same layouts, so the same EVs, so the same
//! logits.  No randomness escapes into the floor.
//!
//! # Seat canonicalization
//!
//! A [`Classifier`][crate::bidding::trie::Classifier] receives only the hand and a [`Context`] (relative
//! vulnerability + the raw auction); it never learns the actor's absolute seat,
//! which [`ev_all`] needs.  Because an EV is computed entirely *relative* to the
//! actor — the sampler, [the dealer placement][ev_all], and the scoring sign all
//! key off it — the absolute choice is free, so the shell pins the actor to
//! [`Seat::North`][contract_bridge::Seat::North] and rebuilds the absolute vulnerability from the relative one
//! (the inverse of [`relative`][crate::bidding::context::relative] at North: we ↔ NS,
//! they ↔ EW).

use super::american::{american_neural_search, american_search};
use super::array::Logits;
use super::context::Context;
use super::ev::ev_all;
use super::instinct::{forced, instinct};
use super::neural_floor::mask_illegal;
use super::trie::Classifier;
use super::{Family, Rules, Stance, System, features, neural};
use contract_bridge::auction::{AbsoluteVulnerability, Call, RelativeVulnerability};
use contract_bridge::{Hand, Seat};
use rand::SeedableRng;
use rand::rngs::StdRng;
use std::sync::LazyLock;

/// The deterministic ladder, built once; the forced path reuses it per board.
static LADDER: LazyLock<Rules> = LazyLock::new(instinct);

/// The continuation policy that finishes every rollout auction
///
/// Our search-target distilled floor ([`american_neural_search`] — the M3.2
/// round-1 net) bound for self-play: the rollout assumes all four seats bid as we
/// would.  This is the policy M3.2 iterates — "feed the improved net back into the
/// continuations" — so each round's targets are scored by the previous round's
/// policy.  Round 1 used the teacher-distilled `american_neural`; this is the
/// round-2 continuation.  Built once and shared across decisions.
static POLICY: LazyLock<Stance> =
    LazyLock::new(|| american_neural_search().against(Family::NATURAL));

/// How far above the un-evaluated prior tail the EV band sits, in nats
///
/// Every call the search actually evaluated outranks every legal call it did
/// not, by at least this margin — the driver's arg-max is therefore always a
/// searched call.  Three nats matches the books' strength-gap convention, so the
/// un-evaluated tail keeps a small but non-zero softmax mass as a fallback.
const EV_BAND_GAP: f32 = 3.0;

/// The gated live-search floor, made safe to attach under the book
///
/// A [`Classifier`] drop-in for [`instinct`] and
/// [`NeuralFloor`][super::neural_floor::NeuralFloor]: the deterministic rails,
/// then a double-dummy search over the net's shortlist in the judgement middle.
/// See the [module docs][self].  The knobs default to *strength, not latency*
/// (the standing M2.3 decision); shrink them for a faster, noisier bidder.
#[derive(Clone, Copy, Debug)]
pub struct SearchFloor {
    /// Layouts sampled and solved per decision (the rollout count)
    pub layouts: usize,
    /// Top-k legal calls, by the net prior, actually scored by EV
    pub shortlist: usize,
    /// EV temperature in points per nat: a larger value flattens the EV band
    pub temperature: f32,
}

impl Default for SearchFloor {
    fn default() -> Self {
        // Strength, not latency (the standing M2.3 decision): 128 layouts keep
        // the EV estimates tight enough that the 8-call shortlist's extra
        // candidates help rather than inject noise — `n` and `k` rise together.
        // ~1.4 s per decision; shrink both for a faster, noisier bidder.
        Self {
            layouts: 128,
            shortlist: 8,
            temperature: 100.0,
        }
    }
}

impl Classifier for SearchFloor {
    fn classify(&self, hand: Hand, context: &Context<'_>) -> Logits {
        if forced(context) {
            // Rails: trust the deterministic floor, never the net or the search.
            return LADDER.classify(hand, context);
        }

        // The net prior, legality-masked — the neural floor's judgement path.
        let feats = features::features(hand, context);
        let mut prior = neural::classify(&feats);
        mask_illegal(&mut prior, context.auction());

        // Shortlist the net's top-k legal calls to actually simulate, then price
        // them by cardplay EV and re-seat the best above the prior tail.
        let shortlist = shortlist(&prior, self.shortlist);
        if shortlist.is_empty() {
            // Only -∞ logits (no legal call); leave the prior for the driver.
            return prior;
        }
        price_and_blend(&mut prior, &shortlist, hand, context, &feats, self);
        prior
    }
}

/// Price `candidates` by cardplay EV and re-seat them onto `base`
///
/// The reusable core shared by [`SearchFloor`] (prior = the net) and
/// [`SearchBook`] (prior = the authored book leaf ∪ the net): sample layouts,
/// solve once, [`ev_all`]-price every candidate over the shared solves under the
/// continuation [`POLICY`], then [`blend`] the EV-ranked band above the prior
/// tail.  `feats` is the decision's feature vector — passed in (both callers
/// already have it) so the RNG seed stays a pure function of the decision
/// (determinism, §0.5) without recomputing the features.  A no-op on an empty
/// candidate set; an all-`NaN` slate leaves `base` untouched (degrade to the bare
/// prior).
fn price_and_blend(
    base: &mut Logits,
    candidates: &[Call],
    hand: Hand,
    context: &Context<'_>,
    feats: &[f32],
    knobs: &SearchFloor,
) {
    if candidates.is_empty() {
        return;
    }
    let vul = absolute_vul(context.vul());
    let mut rng = StdRng::seed_from_u64(seed_from_features(feats));
    let evs = ev_all(
        hand,
        Seat::North,
        vul,
        context,
        candidates,
        &*POLICY,
        &mut rng,
        knobs.layouts,
    );
    blend(base, candidates, &evs, knobs.temperature);
}

/// The 2/1 search bidder that prices **authored book leaves** by DD too — M7.0
///
/// Where [`SearchFloor`] runs the double-dummy search only where the book is
/// silent (the contested fallback floor), `SearchBook` widens it to every
/// non-forced *book* leaf: the leaf's authored logits become the search *prior*
/// instead of the final word, so cardplay re-judges among the calls the rule
/// proposed — unioned with the net's natural alternatives — and bids the
/// highest-EV one.  "Rules propose, DD disposes", at every leaf (see
/// [`05-search-at-every-leaf.md`]).  The authored *constraints* (meaning) stay;
/// only the authored *weights* (judgement) are overridden.
///
/// It wraps a **bound** [`Stance`] (so build it with
/// [`american_search_book`], which seats the live-search floor underneath it),
/// and inherits every §0 safety invariant verbatim:
///
/// - **Forced rails first** — a `forced` auction delegates to the wrapped
///   stance (book leaf on-book, the [`SearchFloor`]'s `forced → instinct()`
///   off-book); the search never runs on the rails.
/// - **Floors already handled** — an auction that falls past the book to a
///   fallback floor (the [`SearchFloor`] on contested, the deterministic
///   [`instinct`][instinct()] ladder on constructive) is returned as that floor
///   gave it; only a real authored leaf (provenance `fallback: None`, with mass)
///   is re-priced.
/// - **Legality + determinism** — the same `mask_illegal` / `blend` masking and
///   RNG seeding as [`SearchFloor`].
///
/// This is the **treatment arm** of the M7 A/B against [`american_search`] (DD
/// off-book only); strong but even slower (it searches every non-forced on-book
/// decision).  Gated behind the `search` feature; [`american`][super::american::american]
/// and [`instinct`][instinct()] are untouched and stay the default.
///
/// [`05-search-at-every-leaf.md`]: https://github.com/Chen-Pang-He/pons/blob/main/docs/ai-bidder/05-search-at-every-leaf.md
/// [`american_search`]: super::american::american_search
// ponytail: american_search_book is the M7 treatment arm; collapse into
// american_search (default-on knob) on a measured win, or gate per book if it
// splits contested vs constructive.
#[derive(Clone, Debug)]
pub struct SearchBook {
    stance: Stance,
    knobs: SearchFloor,
}

impl SearchBook {
    /// Wrap a bound [`Stance`] with default search knobs
    #[must_use]
    pub fn new(stance: Stance) -> Self {
        Self::with(stance, SearchFloor::default())
    }

    /// Wrap a bound [`Stance`] with explicit search knobs
    ///
    /// The knobs (`layouts`/`shortlist`/`temperature`) are the same
    /// [`SearchFloor`] bundle, so data-generation and tuning runs can trade
    /// strength for speed without re-wiring.
    #[must_use]
    pub const fn with(stance: Stance, knobs: SearchFloor) -> Self {
        Self { stance, knobs }
    }
}

impl System for SearchBook {
    fn classify(&self, hand: Hand, vul: RelativeVulnerability, auction: &[Call]) -> Option<Logits> {
        // Prefix the context with the stance's trie so `features` and the EV
        // sampler's `Inferences::read` project artificial prior calls off their
        // authored rules (M6.2c).  `forced` ignores prefixes, so the rails are
        // unaffected.
        let context = self.stance.prefixed_context(vul, auction);
        if forced(&context) {
            // Rails: the wrapped stance's deterministic answer, never the search.
            return self.stance.classify(hand, vul, auction);
        }

        let (mut book, provenance) = self.stance.classify_with_provenance(hand, vul, auction)?;
        if provenance.fallback.is_some() {
            // Fell past the book to a fallback floor — the SearchFloor (contested)
            // or the instinct ladder (constructive) already produced this; leave it.
            return Some(book);
        }

        // A real authored book leaf with mass: price it by DD instead of trusting
        // its fixed weights.  Candidate set = the rule's own calls ∪ the net's
        // top-k natural alternatives, so DD can override a one-call rule.
        mask_illegal(&mut book, auction);
        let feats = features::features(hand, &context);
        let mut net = neural::classify(&feats);
        mask_illegal(&mut net, auction);
        let candidates = union_dedup(finite_calls(&book), shortlist(&net, self.knobs.shortlist));
        price_and_blend(&mut book, &candidates, hand, &context, &feats, &self.knobs);
        Some(book)
    }
}

/// The 2/1 pair as a leaf-pricing [`SearchBook`], bound against `them` — M7.0
///
/// Builds the M7 treatment arm: [`american_search`] (the live-search floor under
/// the bare authored books) bound against the opposing [`Family`], then wrapped
/// so every non-forced book leaf is DD-priced too.  The named handle the
/// `search-book` A/B uses; see [`SearchBook`] for the lifecycle of this name.
/// Gated behind the `search` feature.
#[must_use]
pub fn american_search_book(them: Family) -> SearchBook {
    SearchBook::new(american_search().against(them))
}

/// The top-`k` legal calls of a masked prior, highest logit first
///
/// Illegal calls are already `-∞`, so filtering to finite logits keeps only the
/// legal ones; `Pass` is always among them, so a non-empty auction never yields
/// an empty shortlist.
fn shortlist(prior: &Logits, k: usize) -> Vec<Call> {
    let mut ranked: Vec<(Call, f32)> = prior
        .iter()
        .map(|(call, &logit)| (call, logit))
        .filter(|&(_, logit)| logit.is_finite())
        .collect();
    ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("a masked prior is never NaN"));
    ranked.into_iter().take(k).map(|(call, _)| call).collect()
}

/// The legal (finite-logit) calls of a masked `logits`, in array order
///
/// For an authored book leaf this is the calls the rule actually proposes — the
/// conventional candidates [`SearchBook`] unions with the net's natural
/// alternatives before pricing them by DD.
fn finite_calls(logits: &Logits) -> Vec<Call> {
    logits
        .iter()
        .filter(|&(_, &logit)| logit.is_finite())
        .map(|(call, _)| call)
        .collect()
}

/// `a` followed by the entries of `b` not already present, order preserved
///
/// The candidate slates are a handful of calls each, so the linear membership
/// scan is free.
fn union_dedup(mut a: Vec<Call>, b: Vec<Call>) -> Vec<Call> {
    for call in b {
        if !a.contains(&call) {
            a.push(call);
        }
    }
    a
}

/// Re-seat the EV-scored shortlist onto a band above the net's prior tail
///
/// The masked `prior` is the base distribution; each shortlisted call with a
/// finite EV is moved to `prior_max + EV_BAND_GAP + (ev − best_ev)/temperature`,
/// so the highest-EV call tops the distribution (the driver bids it) and the
/// rest fall by their EV deficit.  Un-evaluated legal calls keep their prior
/// logit as a fallback, and `Pass` stays finite.  An all-`NaN` slate — no layout
/// could be sampled — leaves the prior untouched, so the floor degrades exactly
/// to the bare net.
fn blend(prior: &mut Logits, shortlist: &[Call], evs: &[f32], temperature: f32) {
    let best = evs
        .iter()
        .copied()
        .filter(|ev| ev.is_finite())
        .fold(f32::NEG_INFINITY, f32::max);
    if best == f32::NEG_INFINITY {
        return; // no signal: keep the bare net prior
    }

    let prior_max = prior
        .values()
        .copied()
        .filter(|logit| logit.is_finite())
        .fold(f32::NEG_INFINITY, f32::max);
    let band_base = prior_max + EV_BAND_GAP;

    for (&call, &ev) in shortlist.iter().zip(evs) {
        if ev.is_finite() {
            *prior.get_mut(call) = band_base + (ev - best) / temperature;
        }
    }
}

/// The absolute vulnerability matching a relative one read at North's seat
///
/// The actor is canonicalized to [`Seat::North`], so "we" is North/South and
/// "they" is East/West.  This is the inverse of
/// [`relative`][super::context::relative]`(_, North)`.
fn absolute_vul(vul: RelativeVulnerability) -> AbsoluteVulnerability {
    let mut out = AbsoluteVulnerability::NONE;
    out.set(
        AbsoluteVulnerability::NS,
        vul.contains(RelativeVulnerability::WE),
    );
    out.set(
        AbsoluteVulnerability::EW,
        vul.contains(RelativeVulnerability::THEY),
    );
    out
}

/// A deterministic RNG seed from the decision's feature vector
///
/// The feature vector is a pure function of the hand, the auction,
/// vulnerability, and seat (see [`features`]), so an FNV-1a fold over its bit
/// patterns is a stable per-decision seed — the same decision always draws the
/// same layouts.  Collisions are harmless: two decisions sharing a stream still
/// each sample within their own ranges.
fn seed_from_features(feats: &[f32]) -> u64 {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for &value in feats {
        for byte in value.to_bits().to_le_bytes() {
            hash ^= u64::from(byte);
            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
        }
    }
    hash
}

#[cfg(test)]
mod tests {
    use super::*;
    use contract_bridge::{Bid, Strain};

    const fn call(level: u8, strain: Strain) -> Call {
        Call::Bid(Bid::new(level, strain))
    }

    /// A fast test floor: a handful of layouts and a small shortlist keep the
    /// double-dummy solves cheap while still exercising the search path.
    fn floor() -> SearchFloor {
        SearchFloor {
            layouts: 8,
            shortlist: 3,
            ..SearchFloor::default()
        }
    }

    /// The shelled bidder's logits for a hand in an auction
    fn shelled(auction: &[Call], hand: &str) -> Logits {
        let hand: Hand = hand.parse().expect("valid test hand");
        let context = Context::new(RelativeVulnerability::NONE, auction);
        floor().classify(hand, &context)
    }

    /// The shelled bidder's highest-logit call
    fn best(auction: &[Call], hand: &str) -> Call {
        let logits = shelled(auction, hand);
        (&logits.0)
            .into_iter()
            .max_by(|(_, a), (_, b)| a.partial_cmp(b).expect("logits are never NaN"))
            .map(|(call, _)| call)
            .expect("array is never empty")
    }

    // The five §0.4 safety properties, enforced by the same shell as the neural
    // floor.  The four forced rails short-circuit to `instinct()` before any
    // search, so they reproduce its tested calls exactly; the legality rail
    // exercises the net + search + mask.

    #[test]
    fn advancing_a_double_delegates_to_instinct() {
        // The shell short-circuits to instinct, reproducing its calls — advance a
        // bust with an outside suit, defend with length behind their suit.
        let auction = [call(3, Strain::Clubs), Call::Double, Call::Pass];
        assert_eq!(best(&auction, "96432.J85.9742.2"), call(3, Strain::Spades));
        assert_eq!(best(&auction, "964.J85.974.9632"), Call::Pass);
    }

    #[test]
    fn forced_to_game_never_passes_below_game() {
        let auction = [
            call(2, Strain::Clubs),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
            call(2, Strain::Notrump),
            Call::Pass,
        ];
        assert_eq!(best(&auction, "QJ52.K43.T62.J32"), call(3, Strain::Notrump));
        assert_eq!(best(&auction, "3.QJ9854.K32.J32"), call(4, Strain::Hearts));
    }

    #[test]
    fn completes_partners_transfer_over_notrump() {
        let auction = [
            call(1, Strain::Notrump),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
        ];
        assert_eq!(best(&auction, "AQ32.KJ5.KQ4.Q92"), call(2, Strain::Hearts));
    }

    #[test]
    fn forced_game_steps_aside_when_penalizing() {
        let auction = [
            call(2, Strain::Clubs),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
            call(2, Strain::Notrump),
            call(3, Strain::Diamonds),
            Call::Double,
            Call::Pass,
        ];
        let chosen = best(&auction, "K3.KQ4.65.QJ8765");
        assert_eq!(chosen, call(4, Strain::Clubs));
        assert_ne!(chosen, call(3, Strain::Notrump));
    }

    #[test]
    fn doubles_only_their_live_bids() {
        // Not a forced auction → the net + search + legality mask.  Doubling our
        // own raised overcall is illegal, so the mask zeroes it (and the search
        // never lifts an illegal candidate), while `Pass` stays finite.
        let auction = [
            call(1, Strain::Hearts),
            call(1, Strain::Spades),
            Call::Pass,
            call(2, Strain::Spades),
            Call::Pass,
        ];
        let logits = shelled(&auction, "92.K53.AQJ42.962");
        assert_eq!(*logits.0.get(Call::Double), f32::NEG_INFINITY);
        assert!(logits.0.get(Call::Pass).is_finite());
    }

    /// Determinism (invariant §0.5): the floor seeds its rollout RNG from the
    /// decision, so the same hand and auction reproduce the same logits exactly.
    #[test]
    fn deterministic_given_a_decision() {
        let auction = [
            call(1, Strain::Hearts),
            call(1, Strain::Spades),
            Call::Pass,
            call(2, Strain::Spades),
            Call::Pass,
        ];
        let a = shelled(&auction, "92.K53.AQJ42.962");
        let b = shelled(&auction, "92.K53.AQJ42.962");
        assert_eq!(a, b);
    }

    /// The search must beat the raw net prior on a hand where cardplay sees
    /// through it: an evaluated call always outranks every un-evaluated legal
    /// call (the EV band sits above the prior tail).
    #[test]
    fn evaluated_calls_outrank_the_prior_tail() {
        // A routine balanced opener; the search runs and re-seats its shortlist.
        let auction = [call(1, Strain::Spades), Call::Double];
        let logits = shelled(&auction, "K92.AQ4.KJ32.Q92");

        // The chosen call is finite and legal; a distribution exists.
        let chosen = best(&auction, "K92.AQ4.KJ32.Q92");
        assert!(logits.0.get(chosen).is_finite());
        assert!(logits.0.get(Call::Pass).is_finite());
    }

    // M7.0 — the leaf-pricing bidder.  Small knobs keep the DD solves cheap; the
    // wrapped stance is the live-search pair bound against a natural opponent.

    /// A fast book-search bidder over the bound 2/1 stance.
    fn book() -> (Stance, SearchBook) {
        let stance = american_search().against(Family::NATURAL);
        let knobs = SearchFloor {
            layouts: 8,
            shortlist: 3,
            ..SearchFloor::default()
        };
        (stance.clone(), SearchBook::with(stance, knobs))
    }

    /// The book-search bidder's highest-logit call for a hand in an auction
    fn book_best(book: &SearchBook, auction: &[Call], hand: &str) -> Call {
        let hand: Hand = hand.parse().expect("valid test hand");
        let logits = book
            .classify(hand, RelativeVulnerability::NONE, auction)
            .expect("a covered auction");
        (&logits.0)
            .into_iter()
            .max_by(|(_, a), (_, b)| a.partial_cmp(b).expect("logits are never NaN"))
            .map(|(call, _)| call)
            .expect("array is never empty")
    }

    /// Rails (§0.4): on a forced auction the wrapper delegates to the stance
    /// verbatim — the search never re-prices the rails, so the logits are
    /// identical.  (If `forced` failed to fire, the search would mutate them.)
    #[test]
    fn forced_leaf_delegates_to_the_stance() {
        let (stance, book) = book();
        // 2♣ opening, forced to game: a forced-to-game responder.
        let auction = [
            call(2, Strain::Clubs),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
            call(2, Strain::Notrump),
            Call::Pass,
        ];
        let hand: Hand = "3.QJ9854.K32.J32".parse().expect("valid test hand");
        assert_eq!(
            book.classify(hand, RelativeVulnerability::NONE, &auction),
            stance.classify(hand, RelativeVulnerability::NONE, &auction),
        );
    }

    /// Legality (§0.4): a real, non-forced book leaf gets DD-priced and yields a
    /// finite, legal arg-max.  The authored leaf still owns the *meaning*: an
    /// opening forbids `Pass` (`-∞`), and the wrapper faithfully keeps it that way
    /// — DD re-judges only among the calls the rule (∪ the net) proposes, it never
    /// resurrects a call the agreement forbade.
    #[test]
    fn priced_leaf_arg_max_is_legal() {
        let (_, book) = book();
        // First seat, a clear opener: the constructive opening is a book leaf.
        let auction = [];
        let hand: Hand = "AKQ2.KQ5.AQJ4.92".parse().expect("valid test hand");
        let logits = book
            .classify(hand, RelativeVulnerability::NONE, &auction)
            .expect("the opening leaf answers");
        assert!(logits.has_mass(), "the leaf gives the hand a finite call");
        let chosen = book_best(&book, &auction, "AKQ2.KQ5.AQJ4.92");
        assert!(logits.0.get(chosen).is_finite());
        assert_ne!(chosen, Call::Pass, "a 21-count never passes the opening");
    }

    /// Determinism (§0.5): the wrapper seeds its rollout RNG from the decision,
    /// so the same hand and auction reproduce the same logits exactly.
    #[test]
    fn priced_leaf_is_deterministic() {
        let (_, book) = book();
        let auction = [];
        let hand: Hand = "AKQ2.KQ5.AQJ4.92".parse().expect("valid test hand");
        let a = book.classify(hand, RelativeVulnerability::NONE, &auction);
        let b = book.classify(hand, RelativeVulnerability::NONE, &auction);
        assert_eq!(a, b);
    }
}