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;
static LADDER: LazyLock<Rules> = LazyLock::new(instinct);
static POLICY: LazyLock<Stance> =
LazyLock::new(|| american_neural_search().against(Family::NATURAL));
const EV_BAND_GAP: f32 = 3.0;
#[derive(Clone, Copy, Debug)]
pub struct SearchFloor {
pub layouts: usize,
pub shortlist: usize,
pub temperature: f32,
}
impl Default for SearchFloor {
fn default() -> Self {
Self {
layouts: 128,
shortlist: 8,
temperature: 100.0,
}
}
}
impl Classifier for SearchFloor {
fn classify(&self, hand: Hand, context: &Context<'_>) -> Logits {
if forced(context) {
return LADDER.classify(hand, context);
}
let feats = features::features(hand, context);
let mut prior = neural::classify(&feats);
mask_illegal(&mut prior, context.auction());
let shortlist = shortlist(&prior, self.shortlist);
if shortlist.is_empty() {
return prior;
}
price_and_blend(&mut prior, &shortlist, hand, context, &feats, self);
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);
}
#[derive(Clone, Debug)]
pub struct SearchBook {
stance: Stance,
knobs: SearchFloor,
}
impl SearchBook {
#[must_use]
pub fn new(stance: Stance) -> Self {
Self::with(stance, SearchFloor::default())
}
#[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> {
let context = self.stance.prefixed_context(vul, auction);
if forced(&context) {
return self.stance.classify(hand, vul, auction);
}
let (mut book, provenance) = self.stance.classify_with_provenance(hand, vul, auction)?;
if provenance.fallback.is_some() {
return Some(book);
}
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)
}
}
#[must_use]
pub fn american_search_book(them: Family) -> SearchBook {
SearchBook::new(american_search().against(them))
}
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()
}
fn finite_calls(logits: &Logits) -> Vec<Call> {
logits
.iter()
.filter(|&(_, &logit)| logit.is_finite())
.map(|(call, _)| call)
.collect()
}
fn union_dedup(mut a: Vec<Call>, b: Vec<Call>) -> Vec<Call> {
for call in b {
if !a.contains(&call) {
a.push(call);
}
}
a
}
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; }
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;
}
}
}
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
}
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))
}
fn floor() -> SearchFloor {
SearchFloor {
layouts: 8,
shortlist: 3,
..SearchFloor::default()
}
}
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)
}
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")
}
#[test]
fn advancing_a_double_delegates_to_instinct() {
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() {
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());
}
#[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);
}
#[test]
fn evaluated_calls_outrank_the_prior_tail() {
let auction = [call(1, Strain::Spades), Call::Double];
let logits = shelled(&auction, "K92.AQ4.KJ32.Q92");
let chosen = best(&auction, "K92.AQ4.KJ32.Q92");
assert!(logits.0.get(chosen).is_finite());
assert!(logits.0.get(Call::Pass).is_finite());
}
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))
}
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")
}
#[test]
fn forced_leaf_delegates_to_the_stance() {
let (stance, book) = book();
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),
);
}
#[test]
fn priced_leaf_arg_max_is_legal() {
let (_, book) = book();
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");
}
#[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);
}
}