use crate::bidding::{Alert, Rules, Trie};
use contract_bridge::auction::Call;
use contract_bridge::{Bid, Rank, Strain, Suit};
use core::ops::RangeBounds;
use std::sync::Arc;
pub(super) const RKCB: Alert = Alert("rkcb");
use super::{insert_uncontested, uncontested};
use crate::bidding::constraint::{described, hcp};
use crate::bidding::trie::Classifier;
use contract_bridge::Hand;
fn insert_arc_all_seats(
book: &mut Trie,
suffix: &[Call],
max_passes: usize,
f: &Arc<dyn Classifier>,
) {
for n in 0..=max_passes {
let key: Vec<Call> = core::iter::repeat_n(Call::Pass, n)
.chain(suffix.iter().copied())
.collect();
book.insert_arc(&key, Arc::clone(f));
}
}
pub(in crate::bidding) fn count_keycards(hand: Hand, trump: Suit) -> usize {
let aces = Suit::ASC
.into_iter()
.filter(|&s| hand[s].contains(Rank::A))
.count();
let trump_king = usize::from(hand[trump].contains(Rank::K));
aces + trump_king
}
fn count_kings_outside(hand: Hand, trump: Suit) -> usize {
Suit::ASC
.into_iter()
.filter(|&s| s != trump && hand[s].contains(Rank::K))
.count()
}
fn count_label(range: &impl RangeBounds<usize>, noun: &str) -> String {
use core::ops::Bound;
let lo = match range.start_bound() {
Bound::Included(&x) => Some(x),
Bound::Excluded(&x) => Some(x + 1),
Bound::Unbounded => None,
};
let hi = match range.end_bound() {
Bound::Included(&x) => Some(x),
Bound::Excluded(&x) => Some(x.saturating_sub(1)),
Bound::Unbounded => None,
};
match (lo, hi) {
(Some(a), Some(b)) if a == b => format!("exactly {a} {noun}"),
(Some(a), Some(b)) => format!("{a}–{b} {noun}"),
(Some(a), None) => format!("{a}+ {noun}"),
(None, Some(b)) => format!("≤{b} {noun}"),
(None, None) => noun.to_string(),
}
}
fn keycards(
trump: Suit,
range: impl RangeBounds<usize> + Clone + Send + Sync + 'static,
) -> crate::bidding::constraint::Cons<impl crate::bidding::constraint::Constraint + Clone> {
described(
count_label(&range, "keycards"),
move |hand: Hand, _: &crate::bidding::context::Context<'_>| {
range.contains(&count_keycards(hand, trump))
},
)
}
fn has_trump_queen(
trump: Suit,
) -> crate::bidding::constraint::Cons<impl crate::bidding::constraint::Constraint + Clone> {
described(
format!("holds the {trump} queen"),
move |hand: Hand, _: &crate::bidding::context::Context<'_>| hand[trump].contains(Rank::Q),
)
}
fn kings_outside(
trump: Suit,
range: impl RangeBounds<usize> + Clone + Send + Sync + 'static,
) -> crate::bidding::constraint::Cons<impl crate::bidding::constraint::Constraint + Clone> {
described(
count_label(&range, "kings outside trumps"),
move |hand: Hand, _: &crate::bidding::context::Context<'_>| {
range.contains(&count_kings_outside(hand, trump))
},
)
}
fn rkcb_answers(trump: Suit) -> Rules {
Rules::new()
.rule(
Bid::new(5, Strain::Clubs),
1.0,
keycards(trump, 1..=1) | keycards(trump, 4..=4),
)
.alert(RKCB)
.rule(
Bid::new(5, Strain::Diamonds),
1.0,
keycards(trump, 0..=0) | keycards(trump, 3..=3),
)
.alert(RKCB)
.rule(
Bid::new(5, Strain::Hearts),
1.0,
(keycards(trump, 2..=2) | keycards(trump, 5..=5)) & !has_trump_queen(trump),
)
.alert(RKCB)
.rule(
Bid::new(5, Strain::Spades),
1.0,
(keycards(trump, 2..=2) | keycards(trump, 5..=5)) & has_trump_queen(trump),
)
.alert(RKCB)
}
fn asker_after_5c(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(Bid::new(5, Strain::Notrump), 1.4, keycards(trump, 4..=4))
.alert(RKCB)
.rule(Bid::new(6, t), 1.0, keycards(trump, 3..=3))
.rule(Bid::new(5, t), 0.5, hcp(0..))
}
fn asker_after_5d(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(
Bid::new(6, t),
1.0,
keycards(trump, 2..=2) | keycards(trump, 4..),
)
.rule(Bid::new(5, t), 0.5, hcp(0..))
}
fn asker_after_5h(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(Bid::new(6, t), 1.0, keycards(trump, 3..))
.rule(Bid::new(5, t), 0.5, hcp(0..))
}
fn asker_after_5s(trump: Suit) -> Rules {
let t = Strain::from(trump);
let mut rules = Rules::new()
.rule(
Bid::new(5, Strain::Notrump),
1.4,
keycards(trump, 3..) & kings_outside(trump, 2..),
)
.alert(RKCB)
.rule(Bid::new(6, t), 1.0, keycards(trump, 2..))
.rule(Bid::new(5, t), 0.5, hcp(0..));
if trump == Suit::Hearts {
rules = rules.rule(Bid::new(6, t), 0.3, hcp(0..));
}
rules
}
fn asker_after_5c_minor(trump: Suit) -> Rules {
let t = Strain::from(trump);
let rules = Rules::new().rule(Bid::new(6, t), 1.0, keycards(trump, 3..));
if trump == Suit::Diamonds {
rules.rule(Bid::new(5, t), 0.5, hcp(0..))
} else {
rules.rule(Call::Pass, 0.5, hcp(0..))
}
}
fn asker_after_5d_minor(trump: Suit) -> Rules {
let t = Strain::from(trump);
if trump == Suit::Diamonds {
Rules::new()
.rule(
Bid::new(6, t),
1.0,
keycards(trump, 2..=2) | keycards(trump, 4..),
)
.rule(Call::Pass, 0.5, hcp(0..))
} else {
no_room_six(trump)
}
}
fn no_room_six(trump: Suit) -> Rules {
Rules::new().rule(Bid::new(6, Strain::from(trump)), 1.0, hcp(0..))
}
fn king_answers(trump: Suit) -> Rules {
let mut rules = Rules::new()
.rule(Bid::new(6, Strain::Clubs), 1.0, kings_outside(trump, 0..=0))
.alert(RKCB)
.rule(
Bid::new(6, Strain::Diamonds),
1.0,
kings_outside(trump, 1..=1),
)
.alert(RKCB);
match trump {
Suit::Spades => {
rules = rules
.rule(
Bid::new(6, Strain::Hearts),
1.0,
kings_outside(trump, 2..=2),
)
.alert(RKCB)
.rule(Bid::new(6, Strain::Spades), 0.5, hcp(0..));
}
Suit::Hearts => {
rules = rules.rule(Bid::new(6, Strain::Hearts), 0.5, hcp(0..));
}
_ => unreachable!("the 5NT king ask is major-only; minors never install it"),
}
rules
}
fn asker_after_6c(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(Bid::new(7, t), 1.0, kings_outside(trump, 3..))
.rule(Bid::new(6, t), 0.5, hcp(0..))
}
fn asker_after_6d(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(Bid::new(7, t), 1.0, kings_outside(trump, 2..))
.rule(Bid::new(6, t), 0.5, hcp(0..))
}
fn asker_after_6h(trump: Suit) -> Rules {
let t = Strain::from(trump);
Rules::new()
.rule(Bid::new(7, t), 1.0, kings_outside(trump, 1..))
.rule(Bid::new(6, t), 0.5, hcp(0..))
}
pub(super) fn install_rkcb(book: &mut Trie, our_calls: &[Call], trump: Suit) {
let c_4nt = Call::Bid(Bid::new(4, Strain::Notrump));
let ans_5c = Call::Bid(Bid::new(5, Strain::Clubs));
let ans_5d = Call::Bid(Bid::new(5, Strain::Diamonds));
let ans_5h = Call::Bid(Bid::new(5, Strain::Hearts));
let ans_5s = Call::Bid(Bid::new(5, Strain::Spades));
let c_5nt = Call::Bid(Bid::new(5, Strain::Notrump));
let extend = |tail: &[Call]| -> Vec<Call> {
our_calls
.iter()
.copied()
.chain(core::iter::once(c_4nt))
.chain(tail.iter().copied())
.collect()
};
insert_uncontested(book, &extend(&[]), rkcb_answers(trump));
let (after_5c, after_5d, after_5h, after_5s) = if matches!(trump, Suit::Hearts | Suit::Spades) {
(
Arc::new(asker_after_5c(trump)) as Arc<dyn Classifier>,
Arc::new(asker_after_5d(trump)) as Arc<dyn Classifier>,
Arc::new(asker_after_5h(trump)) as Arc<dyn Classifier>,
Arc::new(asker_after_5s(trump)) as Arc<dyn Classifier>,
)
} else {
(
Arc::new(asker_after_5c_minor(trump)) as Arc<dyn Classifier>,
Arc::new(asker_after_5d_minor(trump)) as Arc<dyn Classifier>,
Arc::new(no_room_six(trump)) as Arc<dyn Classifier>,
Arc::new(no_room_six(trump)) as Arc<dyn Classifier>,
)
};
let suffix_5c = uncontested(&extend(&[ans_5c]));
let suffix_5d = uncontested(&extend(&[ans_5d]));
let suffix_5h = uncontested(&extend(&[ans_5h]));
let suffix_5s = uncontested(&extend(&[ans_5s]));
insert_arc_all_seats(book, &suffix_5c, 3, &after_5c);
insert_arc_all_seats(book, &suffix_5d, 3, &after_5d);
insert_arc_all_seats(book, &suffix_5h, 3, &after_5h);
insert_arc_all_seats(book, &suffix_5s, 3, &after_5s);
if matches!(trump, Suit::Clubs | Suit::Diamonds) {
return;
}
let shared_king_answers = Arc::new(king_answers(trump)) as Arc<dyn Classifier>;
for &ans in &[ans_5c, ans_5d, ans_5h, ans_5s] {
let king_path = uncontested(&extend(&[ans, c_5nt]));
insert_arc_all_seats(book, &king_path, 3, &shared_king_answers);
}
let kans_6c = Call::Bid(Bid::new(6, Strain::Clubs));
let kans_6d = Call::Bid(Bid::new(6, Strain::Diamonds));
let kans_6h = Call::Bid(Bid::new(6, Strain::Hearts));
let shared_after_6c = Arc::new(asker_after_6c(trump)) as Arc<dyn Classifier>;
let shared_after_6d = Arc::new(asker_after_6d(trump)) as Arc<dyn Classifier>;
for &ans in &[ans_5c, ans_5d, ans_5h, ans_5s] {
let suffix_6c = uncontested(&extend(&[ans, c_5nt, kans_6c]));
insert_arc_all_seats(book, &suffix_6c, 3, &shared_after_6c);
let suffix_6d = uncontested(&extend(&[ans, c_5nt, kans_6d]));
insert_arc_all_seats(book, &suffix_6d, 3, &shared_after_6d);
if trump == Suit::Spades {
let suffix_6h = uncontested(&extend(&[ans, c_5nt, kans_6h]));
let after_6h = Arc::new(asker_after_6h(trump)) as Arc<dyn Classifier>;
insert_arc_all_seats(book, &suffix_6h, 3, &after_6h);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bidding::System;
use contract_bridge::auction::RelativeVulnerability;
use contract_bridge::{Hand, Strain};
fn rkcb_trie() -> Trie {
let mut trie = Trie::new();
let our_calls = [
Call::Bid(Bid::new(1, Strain::Spades)),
Call::Bid(Bid::new(2, Strain::Notrump)),
Call::Bid(Bid::new(3, Strain::Clubs)),
];
install_rkcb(&mut trie, &our_calls, Suit::Spades);
trie
}
fn best(trie: &Trie, auction: &[Call], hand: &str) -> Call {
let hand: Hand = hand.parse().expect("valid test hand");
let logits = trie
.classify(hand, RelativeVulnerability::NONE, auction)
.expect("trie covers this auction");
(&logits.0)
.into_iter()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).expect("logits are never NaN"))
.map(|(call, _)| call)
.expect("logits array is never empty")
}
const ANS_AUCTION: &[Call] = &[
Call::Bid(Bid::new(1, Strain::Spades)),
Call::Pass,
Call::Bid(Bid::new(2, Strain::Notrump)),
Call::Pass,
Call::Bid(Bid::new(3, Strain::Clubs)),
Call::Pass,
Call::Bid(Bid::new(4, Strain::Notrump)),
Call::Pass,
];
#[test]
fn answers_keycard_counts() {
let trie = rkcb_trie();
assert_eq!(
best(&trie, ANS_AUCTION, "KQ732.K53.Q42.92"),
Call::Bid(Bid::new(5, Strain::Clubs)),
"1 keycard → 5♣"
);
assert_eq!(
best(&trie, ANS_AUCTION, "QJ732.K53.Q42.Q2"),
Call::Bid(Bid::new(5, Strain::Diamonds)),
"0 keycards → 5♦"
);
assert_eq!(
best(&trie, ANS_AUCTION, "AK732.A53.842.92"),
Call::Bid(Bid::new(5, Strain::Diamonds)),
"3 keycards → 5♦"
);
assert_eq!(
best(&trie, ANS_AUCTION, "AQ732.A53.842.92"),
Call::Bid(Bid::new(5, Strain::Spades)),
"2 keycards + trump Q → 5♠"
);
assert_eq!(
best(&trie, ANS_AUCTION, "A8732.A53.842.92"),
Call::Bid(Bid::new(5, Strain::Hearts)),
"2 keycards, no trump Q → 5♥"
);
assert_eq!(
best(&trie, ANS_AUCTION, "AK732.A53.A42.A2"),
Call::Bid(Bid::new(5, Strain::Hearts)),
"5 keycards, no trump Q → 5♥"
);
assert_eq!(
best(&trie, ANS_AUCTION, "AKQ32.A53.A42.A2"),
Call::Bid(Bid::new(5, Strain::Spades)),
"5 keycards, with trump Q → 5♠"
);
}
#[test]
fn asker_after_5d_response() {
let trie = rkcb_trie();
let auction: Vec<Call> = ANS_AUCTION
.iter()
.copied()
.chain([Call::Bid(Bid::new(5, Strain::Diamonds)), Call::Pass])
.collect();
assert_eq!(
best(&trie, &auction, "KQ52.AK76.A72.93"),
Call::Bid(Bid::new(5, Strain::Spades)),
"asker with 3 keycards after 5♦ → knows 0, sign off 5♠"
);
assert_eq!(
best(&trie, &auction, "Q852.AK76.K72.A3"),
Call::Bid(Bid::new(6, Strain::Spades)),
"asker with 2 keycards after 5♦ → assumes 3, bid 6♠"
);
}
#[test]
fn king_ask_after_5c() {
let trie = rkcb_trie();
let auction: Vec<Call> = ANS_AUCTION
.iter()
.copied()
.chain([Call::Bid(Bid::new(5, Strain::Clubs)), Call::Pass])
.collect();
assert_eq!(
best(&trie, &auction, "AQ52.A876.A72.A3"),
Call::Bid(Bid::new(5, Strain::Notrump)),
"asker with 4 keycards after 5♣ → 5NT king ask"
);
}
#[test]
fn king_answer_after_5nt() {
let trie = rkcb_trie();
let auction: Vec<Call> = ANS_AUCTION
.iter()
.copied()
.chain([
Call::Bid(Bid::new(5, Strain::Clubs)),
Call::Pass,
Call::Bid(Bid::new(5, Strain::Notrump)),
Call::Pass,
])
.collect();
assert_eq!(
best(&trie, &auction, "K9732.K53.942.92"),
Call::Bid(Bid::new(6, Strain::Diamonds)),
"1 outside king → 6♦"
);
}
fn minor_trie(trump: Suit) -> Trie {
let mut trie = Trie::new();
let m = Strain::from(trump);
let our_calls = [Call::Bid(Bid::new(1, m)), Call::Bid(Bid::new(2, m))];
install_rkcb(&mut trie, &our_calls, trump);
trie
}
fn minor_ans_auction(trump: Suit) -> Vec<Call> {
let m = Strain::from(trump);
vec![
Call::Bid(Bid::new(1, m)),
Call::Pass,
Call::Bid(Bid::new(2, m)),
Call::Pass,
Call::Bid(Bid::new(4, Strain::Notrump)),
Call::Pass,
]
}
fn after_minor_answer(trump: Suit, answer: Bid) -> Vec<Call> {
let mut a = minor_ans_auction(trump);
a.push(Call::Bid(answer));
a.push(Call::Pass);
a
}
#[test]
fn minor_answers_keycard_counts() {
let trie = minor_trie(Suit::Clubs);
let auction = minor_ans_auction(Suit::Clubs);
assert_eq!(
best(&trie, &auction, "A654.832.K65.987"),
Call::Bid(Bid::new(5, Strain::Clubs)),
"1 keycard → 5♣"
);
assert_eq!(
best(&trie, &auction, "Q654.832.K65.J87"),
Call::Bid(Bid::new(5, Strain::Diamonds)),
"0 keycards → 5♦"
);
assert_eq!(
best(&trie, &auction, "A654.A32.65.J987"),
Call::Bid(Bid::new(5, Strain::Hearts)),
"2 keycards, no trump Q → 5♥"
);
assert_eq!(
best(&trie, &auction, "A654.A32.65.Q987"),
Call::Bid(Bid::new(5, Strain::Spades)),
"2 keycards with trump Q → 5♠"
);
}
#[test]
fn clubs_after_5c_signoff_is_pass() {
let trie = minor_trie(Suit::Clubs);
let auction = after_minor_answer(Suit::Clubs, Bid::new(5, Strain::Clubs));
assert_eq!(
best(&trie, &auction, "A654.A32.65.KQ87"),
Call::Bid(Bid::new(6, Strain::Clubs)),
"asker 3 keycards after 5♣ → 6♣"
);
assert_eq!(
best(&trie, &auction, "A654.832.K65.987"),
Call::Pass,
"asker ≤2 keycards after 5♣ → Pass (play 5♣)"
);
}
#[test]
fn clubs_no_room_always_six() {
let trie = minor_trie(Suit::Clubs);
for answer in [
Bid::new(5, Strain::Diamonds),
Bid::new(5, Strain::Hearts),
Bid::new(5, Strain::Spades),
] {
let auction = after_minor_answer(Suit::Clubs, answer);
for hand in ["A654.A32.65.KQ87", "Q654.Q32.Q65.Q98"] {
let call = best(&trie, &auction, hand);
assert_eq!(
call,
Call::Bid(Bid::new(6, Strain::Clubs)),
"clubs after {answer:?}, hand {hand}: must be 6♣ (no room to stop)"
);
}
}
}
#[test]
fn diamonds_after_5c_signoff_is_5d() {
let trie = minor_trie(Suit::Diamonds);
let auction = after_minor_answer(Suit::Diamonds, Bid::new(5, Strain::Clubs));
assert_eq!(
best(&trie, &auction, "A654.A32.K65.987"),
Call::Bid(Bid::new(6, Strain::Diamonds)),
"asker 3 keycards after 5♣ → 6♦"
);
assert_eq!(
best(&trie, &auction, "A654.832.J65.987"),
Call::Bid(Bid::new(5, Strain::Diamonds)),
"asker ≤2 keycards after 5♣ → 5♦ signoff"
);
}
#[test]
fn diamonds_after_5d_signoff_is_pass() {
let trie = minor_trie(Suit::Diamonds);
let auction = after_minor_answer(Suit::Diamonds, Bid::new(5, Strain::Diamonds));
assert_eq!(
best(&trie, &auction, "A654.A32.K65.987"),
Call::Pass,
"asker 3 keycards after 5♦ → Pass (play 5♦)"
);
assert_eq!(
best(&trie, &auction, "A654.A32.J65.987"),
Call::Bid(Bid::new(6, Strain::Diamonds)),
"asker 2 keycards after 5♦ → 6♦"
);
}
#[test]
fn minors_never_bid_5nt() {
for trump in [Suit::Clubs, Suit::Diamonds] {
let trie = minor_trie(trump);
for answer in [
Bid::new(5, Strain::Clubs),
Bid::new(5, Strain::Diamonds),
Bid::new(5, Strain::Hearts),
Bid::new(5, Strain::Spades),
] {
let auction = after_minor_answer(trump, answer);
for hand in ["A654.A32.AK5.AQ8", "Q654.Q32.Q65.Q98"] {
assert_ne!(
best(&trie, &auction, hand),
Call::Bid(Bid::new(5, Strain::Notrump)),
"{trump:?} after {answer:?}, hand {hand}: must never bid 5NT"
);
}
}
}
}
#[test]
fn minor_king_ask_node_absent() {
let trie = minor_trie(Suit::Clubs);
let mut auction = after_minor_answer(Suit::Clubs, Bid::new(5, Strain::Clubs));
auction.push(Call::Bid(Bid::new(5, Strain::Notrump)));
auction.push(Call::Pass);
let hand: Hand = "A654.A32.65.KQ87".parse().unwrap();
assert!(
trie.classify(hand, RelativeVulnerability::NONE, &auction)
.is_none(),
"no king-answer table should exist for a minor trump"
);
}
}