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};
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)
})
}
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
})
})
}
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))
})
}
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)
})
}
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)
})
}
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,
}
}
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;
};
if index % 2 != auction.len() % 2 {
return false;
}
if bid.strain != Strain::Notrump || bid.level.get() != level {
return false;
}
match (auction.len() - index) % 4 {
0 => !partner,
2 => partner,
_ => false,
}
}
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,
}
}
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)
})
})
}
fn partner_strong_notrump(level: u8) -> Cons<impl Constraint + Clone> {
pred(move |_: Hand, context: &Context<'_>| our_strong_notrump(context, level, true))
}
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)
})
}
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)
})
}
#[must_use]
pub fn instinct() -> Rules {
let mut rules = Rules::new()
.rule(Call::Pass, 1.5, forced_advance() & doubled_suit_stack())
.rule(
Bid::new(3, Strain::Notrump),
1.3,
forced_advance()
& hcp(13..)
& stopper_in_their_suits()
& level_available(3, Strain::Notrump),
)
.rule(Call::Pass, 0.0, !forced_advance())
.rule(Call::Pass, -5.0, hcp(0..));
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
};
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),
);
}
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..),
);
}
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 {
rules = rules.rule(
Bid::new(level, Strain::Notrump),
0.3,
forced_advance() & min_level_is(level, Strain::Notrump),
);
}
for level in 1u8..=3 {
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(),
);
}
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),
);
}
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),
);
}
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))
}
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() {
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(3, Strain::Notrump));
}
#[test]
fn trump_stack_converts_to_penalties() {
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];
assert_eq!(best(&auction, "KJ92.A53.KQ42.96"), call(3, Strain::Notrump));
assert_eq!(best(&auction, "92.AQ53.KQ42.962"), call(4, Strain::Hearts));
}
#[test]
fn unforced_raise_with_fit() {
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() {
let auction = [call(3, Strain::Diamonds)];
assert_eq!(best(&auction, "KQ32.AJ53.2.A942"), Call::Double);
}
#[test]
fn unforced_pass_without_values() {
let auction = [call(3, Strain::Diamonds)];
assert_eq!(best(&auction, "Q5432.J53.942.92"), Call::Pass);
}
#[test]
fn doubles_only_their_live_bids() {
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() {
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() {
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() {
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);
}
}