pons 0.9.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
//! The instinct bidder: a keyless floor for off-book auctions
//!
//! Competitive auctions cannot be enumerated — interference multiplies
//! sequences combinatorially, and a book that stops mid-auction leaves the
//! driver to pass by default.  The worst of those defaults is passing
//! partner's takeout double on a worthless hand, turning a routine advance
//! into a doubled partscore for the opponents.
//!
//! [`instinct()`] is the floor under the book: one context-driven [`Rules`]
//! ladder that answers *every* auction with a sane natural action.  Attach it
//! as a root [`Always`][super::fallback::Always] fallback — as
//! [`two_over_one()`][crate::bidding::two_over_one::two_over_one] does for its
//! competitive and defensive books — and the system never falls off the book.
//! By [`Trie::resolve`][super::Trie::resolve] precedence the root is reached
//! last, so instinct can never override an authored rule, only catch what
//! falls past all of them.
//!
//! # Everything is natural
//!
//! Instinct fires precisely where the book has no agreement, so partner's
//! continuation is usually off-book too — decoded by *partner's* instinct.
//! The two halves stay coherent because every instinct call is natural:
//! bids show the bid suit, raises show support, doubles are takeout.  No
//! conventional calls (in particular no strength-showing cue-bids) belong
//! here until both sides of the convention are authored.
//!
//! # The forced advance
//!
//! The one situation instinct must *not* treat as optional is partner's live
//! takeout double: the auction ends `… (bid) X (Pass)` with their suit bid at
//! the three level or below doubled by partner.  There, the pass rules are
//! replaced by an advance ladder — penalty pass only with a genuine trump
//! stack, a major-suit game jump or 3NT with values, the longest unbid suit
//! at the cheapest level, and a notrump escape so that *some* action is
//! always available.  The interpretation of the double is deliberately
//! mechanical: a classifier may know its system, and instinct's system is
//! plain standard.
//!
//! # Observability
//!
//! Instinct activations are visible in the
//! [`Provenance`][super::trie::Provenance] returned by
//! [`Trie::resolve`][super::Trie::resolve]: `depth == 0` with
//! `fallback == Some(_)` is the floor firing.  In simulation, count these —
//! the most-hit auctions are the next nodes worth authoring properly.

use super::Rules;
use super::constraint::{
    Cons, Constraint, balanced, hcp, len, min_level_is, partner_suit_is, pred,
    short_in_their_suits, stopper_in_their_suits, support, they_bid,
};
use super::context::Context;
use contract_bridge::auction::Call;
use contract_bridge::{Bid, Hand, Penalty, Rank, Strain, Suit};

/// Partner's takeout double is live: the auction ends `… (bid) X (Pass)`
///
/// Mechanically: the last two calls are partner's double and RHO's pass, and
/// the doubled contract is their suit bid at the three level or below —
/// doubles of notrump or of game-level contracts read as penalty, not as a
/// request to act.
fn forced_advance() -> Cons<impl Constraint + Clone> {
    pred(|_: Hand, context: &Context<'_>| {
        let auction = context.auction();
        let n = auction.len();
        n >= 2
            && auction[n - 1] == Call::Pass
            && auction[n - 2] == Call::Double
            && context
                .last_bid()
                .is_some_and(|bid| bid.strain.suit().is_some() && bid.level.get() <= 3)
    })
}

/// A trump stack in the doubled suit: four-plus cards with two top honors
///
/// The one holding that converts partner's takeout double into penalties.
fn doubled_suit_stack() -> Cons<impl Constraint + Clone> {
    pred(|hand: Hand, context: &Context<'_>| {
        context
            .last_bid()
            .and_then(|bid| bid.strain.suit())
            .is_some_and(|suit| {
                let holding = hand[suit];
                let honors = [Rank::A, Rank::K, Rank::Q]
                    .into_iter()
                    .filter(|&rank| holding.contains(rank))
                    .count();
                holding.len() >= 4 && honors >= 2
            })
    })
}

/// Our side has not bid yet (doubles and passes do not count)
///
/// The anchor for overcall-shaped actions: once we have shown a suit or
/// notrump, instinct competes by raising or doubling instead.
fn we_have_not_bid() -> Cons<impl Constraint + Clone> {
    pred(|_: Hand, context: &Context<'_>| {
        !Suit::ASC
            .into_iter()
            .map(Strain::from)
            .chain([Strain::Notrump])
            .any(|strain| context.we_bid(strain))
    })
}

/// The opponents' undoubled suit bid at most `level` is the call to beat
///
/// This is the legality *and* sanity anchor for instinct doubles: the last
/// non-pass call is an opposing suit bid, not yet doubled, low enough that a
/// double still reads as takeout.
fn their_live_bid_at_most(level: u8) -> Cons<impl Constraint + Clone> {
    pred(move |_: Hand, context: &Context<'_>| {
        context.penalty() == Penalty::Undoubled
            && context
                .last_bid()
                .is_some_and(|bid| bid.strain.suit().is_some() && bid.level.get() <= level)
            && context
                .auction()
                .iter()
                .rposition(|&call| call != Call::Pass)
                .is_some_and(|index| (context.auction().len() - index) % 2 == 1)
    })
}

/// The strain is still biddable at or below the given level
fn level_available(level: u8, strain: Strain) -> Cons<impl Constraint + Clone> {
    pred(move |_: Hand, context: &Context<'_>| {
        context
            .min_level(strain)
            .is_some_and(|min| min.get() <= level)
    })
}

/// The opening bid (first non-pass call) and its index, if it is a bid
fn opening_bid(auction: &[Call]) -> Option<(usize, Bid)> {
    let index = auction.iter().position(|&call| call != Call::Pass)?;
    match auction[index] {
        Call::Bid(bid) => Some((index, bid)),
        _ => None,
    }
}

/// Our side opened a strong notrump of `level`, and the player to act is its
/// opener (`partner == false`) or its responder (`partner == true`)
///
/// This is the one place instinct reads a *convention*: a strong notrump
/// opening is the anchor for completing transfers and refusing to pass below a
/// forced game, the deep conventional structures the book may not author.
fn our_strong_notrump(context: &Context<'_>, level: u8, partner: bool) -> bool {
    let auction = context.auction();
    let Some((index, bid)) = opening_bid(auction) else {
        return false;
    };
    // Our side owns the indices sharing the player-to-act's parity.
    if index % 2 != auction.len() % 2 {
        return false;
    }
    if bid.strain != Strain::Notrump || bid.level.get() != level {
        return false;
    }
    // Seats four apart are the same player; two apart are partners.
    match (auction.len() - index) % 4 {
        0 => !partner,
        2 => partner,
        _ => false,
    }
}

/// Partner's call immediately before ours, if it was a bid
fn partner_last_call(auction: &[Call]) -> Option<Bid> {
    match auction.len().checked_sub(2).map(|i| auction[i]) {
        Some(Call::Bid(bid)) => Some(bid),
        _ => None,
    }
}

/// The current contract is below game: no bid, or a partscore-level suit bid
fn below_game() -> Cons<impl Constraint + Clone> {
    pred(|_: Hand, context: &Context<'_>| {
        context.last_bid().is_none_or(|bid| {
            let level = bid.level.get();
            level <= 2 || (level == 3 && bid.strain != Strain::Notrump)
        })
    })
}

/// Partner opened a strong notrump of `level` (we are the responder)
fn partner_strong_notrump(level: u8) -> Cons<impl Constraint + Clone> {
    pred(move |_: Hand, context: &Context<'_>| our_strong_notrump(context, level, true))
}

/// We opened a strong notrump and partner forced past invitation with a
/// three-level suit bid — so passing below game is wrong
fn opener_forced_to_game() -> Cons<impl Constraint + Clone> {
    pred(|_: Hand, context: &Context<'_>| {
        (our_strong_notrump(context, 1, false) || our_strong_notrump(context, 2, false))
            && partner_last_call(context.auction())
                .is_some_and(|bid| bid.level.get() == 3 && bid.strain != Strain::Notrump)
    })
}

/// We opened the strong notrump of `nt_level` and partner just transferred with
/// the call `from` — the cue to complete the transfer
fn partner_transferred(from: Bid, nt_level: u8) -> Cons<impl Constraint + Clone> {
    pred(move |_: Hand, context: &Context<'_>| {
        our_strong_notrump(context, nt_level, false)
            && partner_last_call(context.auction()) == Some(from)
    })
}

/// Build the instinct ladder: a sane natural action for any auction
///
/// Forced (partner's live takeout double — see the [module docs][self]):
/// penalty pass on a trump stack, a major-suit game jump or 3NT with values,
/// the longest unbid suit at the cheapest level (majors and five-card suits
/// preferred), and a cheapest-notrump escape as the guaranteed action.
///
/// Otherwise: raise partner's suit with three-card support and rising
/// strength per level, overcall notrump (15–18 balanced with stoppers) or a
/// five-card suit if we have not bid, double their low suit bid for takeout
/// on shape (or any 17+), and pass.
///
/// The unconditioned pass at weight `-5` is the absolute last resort: it
/// keeps the logits finite when every action is illegal, while sitting far
/// enough below every forced action (≥ 3 nats) that sampling drivers never
/// pass a forced auction by accident.
#[must_use]
pub fn instinct() -> Rules {
    let mut rules = Rules::new()
        // Forced: a trump stack sits for partner's takeout double.
        .rule(Call::Pass, 1.5, forced_advance() & doubled_suit_stack())
        // Forced: 3NT to play with game values and their suits stopped.
        .rule(
            Bid::new(3, Strain::Notrump),
            1.3,
            forced_advance()
                & hcp(13..)
                & stopper_in_their_suits()
                & level_available(3, Strain::Notrump),
        )
        // Unforced default; the -5 entry below is the absolute last resort.
        .rule(Call::Pass, 0.0, !forced_advance())
        .rule(Call::Pass, -5.0, hcp(0..));

    // Forced: jump to a major-suit game with four-plus cards and values —
    // in an unbid major, never in the suit partner asked us to take out of.
    for major in [Suit::Hearts, Suit::Spades] {
        let strain = Strain::from(major);
        rules = rules.rule(
            Bid::new(4, strain),
            1.45,
            forced_advance()
                & len(major, 4..)
                & hcp(11..)
                & level_available(4, strain)
                & !they_bid(strain),
        );
    }

    for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
        let strain = Strain::from(suit);
        let major_bonus = if matches!(suit, Suit::Hearts | Suit::Spades) {
            0.05
        } else {
            0.0
        };

        // Forced: a new suit at the cheapest level; longer suits and majors
        // are preferred.  Bidding their suit would be a cue-bid — excluded.
        for level in 1u8..=4 {
            rules = rules
                .rule(
                    Bid::new(level, strain),
                    1.0 + major_bonus,
                    forced_advance()
                        & min_level_is(level, strain)
                        & len(suit, 4..)
                        & !they_bid(strain),
                )
                .rule(
                    Bid::new(level, strain),
                    1.1 + major_bonus,
                    forced_advance()
                        & min_level_is(level, strain)
                        & len(suit, 5..)
                        & !they_bid(strain),
                );
        }

        // Raise partner's suit with three-card support; each level up asks
        // for more strength, so competitive raises terminate by themselves.
        for (level, threshold) in [(2u8, 6u8), (3, 10), (4, 13)] {
            rules = rules.rule(
                Bid::new(level, strain),
                1.2,
                partner_suit_is(suit)
                    & min_level_is(level, strain)
                    & support(3..)
                    & hcp(threshold..),
            );
        }

        // Overcall a five-card suit if we have not bid; the strength floor
        // rises with the level and stronger hands double first.
        for (level, floor) in [(1u8, 8u8), (2, 10), (3, 13)] {
            rules = rules.rule(
                Bid::new(level, strain),
                1.0 + major_bonus,
                we_have_not_bid()
                    & min_level_is(level, strain)
                    & len(suit, 5..)
                    & hcp(floor..=16)
                    & !they_bid(strain),
            );
        }
    }

    for level in 1u8..=4 {
        // Forced: the notrump escape guarantees an action — no fit, no
        // stopper, no four-card suit outside theirs still has a call.
        rules = rules.rule(
            Bid::new(level, Strain::Notrump),
            0.3,
            forced_advance() & min_level_is(level, Strain::Notrump),
        );
    }

    for level in 1u8..=3 {
        // Notrump overcall: 15–18 balanced with their suits stopped.
        rules = rules.rule(
            Bid::new(level, Strain::Notrump),
            1.05,
            we_have_not_bid()
                & min_level_is(level, Strain::Notrump)
                & balanced()
                & hcp(15..=18)
                & stopper_in_their_suits(),
        );
    }

    // Opposite our own strong notrump: complete partner's transfer.  Standard
    // Jacoby (2♦/2♥, 3♦/3♥ over 2NT) and South African Texas (4♣/4♦); the book
    // authors these where it can, so this only catches off-book and competitive
    // continuations.  Bid the suit just above partner's artificial call.
    let transfers = [
        (
            1u8,
            Bid::new(2, Strain::Diamonds),
            Bid::new(2, Strain::Hearts),
        ),
        (1, Bid::new(2, Strain::Hearts), Bid::new(2, Strain::Spades)),
        (1, Bid::new(4, Strain::Clubs), Bid::new(4, Strain::Hearts)),
        (
            1,
            Bid::new(4, Strain::Diamonds),
            Bid::new(4, Strain::Spades),
        ),
        (
            2,
            Bid::new(3, Strain::Diamonds),
            Bid::new(3, Strain::Hearts),
        ),
        (2, Bid::new(3, Strain::Hearts), Bid::new(3, Strain::Spades)),
    ];
    for (nt_level, from, to) in transfers {
        rules = rules.rule(
            to,
            1.5,
            partner_transferred(from, nt_level) & level_available(to.level.get(), to.strain),
        );
    }

    // Forced to game opposite our strong notrump: a responder with game-going
    // values (10+ opposite a 15–17 1NT, 5+ opposite a 20–21 2NT), or an opener
    // whom partner has forced past invitation.  Below game, pick the cheapest
    // game — a six-card major, else 3NT — rather than pass the auction out.
    let forced_game = (partner_strong_notrump(1) & hcp(10..))
        | (partner_strong_notrump(2) & hcp(5..))
        | opener_forced_to_game();
    rules = rules.rule(
        Bid::new(3, Strain::Notrump),
        1.40,
        forced_game.clone() & below_game() & level_available(3, Strain::Notrump),
    );
    for major in [Suit::Hearts, Suit::Spades] {
        let strain = Strain::from(major);
        rules = rules.rule(
            Bid::new(4, strain),
            1.45,
            forced_game.clone() & below_game() & len(major, 6..) & level_available(4, strain),
        );
    }

    // Takeout double of their low suit bid: shape with opening values, or
    // any strong hand planning to bid again.
    rules
        .rule(
            Call::Double,
            0.9,
            their_live_bid_at_most(3) & short_in_their_suits() & hcp(12..),
        )
        .rule(Call::Double, 0.8, their_live_bid_at_most(3) & hcp(17..))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bidding::trie::Classifier;
    use contract_bridge::auction::RelativeVulnerability;

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

    /// The highest-logit instinct call for a hand in an auction
    fn best(auction: &[Call], hand: &str) -> Call {
        let hand: Hand = hand.parse().expect("valid test hand");
        let context = Context::new(RelativeVulnerability::NONE, auction);
        let logits = instinct().classify(hand, &context);
        (&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")
    }

    #[test]
    fn forced_advance_never_passes() {
        // Partner doubled their 3♣ for takeout; a worthless hand still bids
        // its five-card suit instead of converting to penalties.
        let auction = [call(3, Strain::Clubs), Call::Double, Call::Pass];
        assert_eq!(best(&auction, "96432.J85.9742.2"), call(3, Strain::Spades));
        // A yarborough whose only length is in their suit escapes to the
        // cheapest notrump.
        assert_eq!(best(&auction, "964.J85.974.9632"), call(3, Strain::Notrump));
    }

    #[test]
    fn trump_stack_converts_to_penalties() {
        // KQ92 behind the 2♠ bidder sits for partner's takeout double.
        let auction = [call(2, Strain::Spades), Call::Double, Call::Pass];
        assert_eq!(best(&auction, "KQ92.A532.J42.96"), Call::Pass);
    }

    #[test]
    fn forced_advance_bids_game_with_values() {
        let auction = [call(2, Strain::Spades), Call::Double, Call::Pass];
        // 13 HCP with their suit stopped: 3NT to play.
        assert_eq!(best(&auction, "KJ92.A53.KQ42.96"), call(3, Strain::Notrump));
        // 11 HCP with four hearts: jump to the major-suit game.
        assert_eq!(best(&auction, "92.AQ53.KQ42.962"), call(4, Strain::Hearts));
    }

    #[test]
    fn unforced_raise_with_fit() {
        // Partner opened 1♠ and they overcalled 2♥: raise with three-card
        // support and 8 HCP.
        let auction = [call(1, Strain::Spades), call(2, Strain::Hearts)];
        assert_eq!(best(&auction, "Q32.953.A964.Q92"), call(2, Strain::Spades));
    }

    #[test]
    fn unforced_takeout_double_on_shape() {
        // Their 3♦ preempt: 13 HCP, short in diamonds, no five-card suit.
        let auction = [call(3, Strain::Diamonds)];
        assert_eq!(best(&auction, "KQ32.AJ53.2.A942"), Call::Double);
    }

    #[test]
    fn unforced_pass_without_values() {
        // Nothing to say over their 3♦: too weak to act at the three level.
        let auction = [call(3, Strain::Diamonds)];
        assert_eq!(best(&auction, "Q5432.J53.942.92"), Call::Pass);
    }

    #[test]
    fn doubles_only_their_live_bids() {
        // The call to beat is our own 2♠ (partner raised our overcall):
        // doubling our side is never on the table.
        let auction = [
            call(1, Strain::Hearts),
            call(1, Strain::Spades),
            Call::Pass,
            call(2, Strain::Spades),
            Call::Pass,
        ];
        let hand: Hand = "92.K53.AQJ42.962".parse().expect("valid test hand");
        let context = Context::new(RelativeVulnerability::NONE, &auction);
        let logits = instinct().classify(hand, &context);
        assert_eq!(*logits.0.get(Call::Double), f32::NEG_INFINITY);
    }

    #[test]
    fn completes_partners_transfer_over_notrump() {
        // We opened 1NT and partner transferred 2♦ (hearts): complete with 2♥,
        // even off-book, rather than passing or raising the artificial diamonds.
        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_to_game_opposite_strong_notrump() {
        // Partner opened 1NT; after an artificial 2NT super-accept of our heart
        // transfer a game-forced 12-count bids 3NT, never passing below game.
        let auction = [
            call(1, Strain::Notrump),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
            call(2, Strain::Notrump),
            Call::Pass,
        ];
        assert_eq!(best(&auction, "KQ52.AQ984.J6.32"), call(3, Strain::Notrump));
    }

    #[test]
    fn keeps_passing_with_a_weak_responder() {
        // Partner opened 1NT but we are too weak to force game: still pass when
        // off-book (the forced-to-game floor must not fire on invitational-or-less).
        let auction = [
            call(1, Strain::Notrump),
            Call::Pass,
            call(2, Strain::Diamonds),
            Call::Pass,
            call(2, Strain::Notrump),
            Call::Pass,
        ];
        assert_eq!(best(&auction, "8632.J9842.96.42"), Call::Pass);
    }
}