use contract_bridge::auction::{Auction, Call, RelativeVulnerability};
use contract_bridge::eval::{self, HandEvaluator, SimpleEvaluator};
use contract_bridge::{
AbsoluteVulnerability, Bid, Contract, FullDeal, Hand, Penalty, Seat, Strain, Suit,
};
use ddss::{NonEmptyStrainFlags, Solver};
use pons::bidding::{Context, Inferences, Range, Relative, american, sample_layouts};
use pons::scoring::{final_contract, ns_score_contract};
use pons::{Pair, Table};
use rand::SeedableRng;
use rand::rngs::StdRng;
const GAME: &[(u8, Strain)] = &[
(3, Strain::Notrump),
(4, Strain::Hearts),
(4, Strain::Spades),
];
const PART: &[(u8, Strain)] = &[
(1, Strain::Notrump),
(2, Strain::Notrump),
(2, Strain::Hearts),
(3, Strain::Hearts),
(2, Strain::Spades),
(3, Strain::Spades),
];
fn raw_hcp(hand: Hand) -> u8 {
SimpleEvaluator(eval::hcp::<u8>).eval(hand)
}
fn longest(hand: Hand) -> usize {
Suit::ASC
.into_iter()
.map(|suit| hand[suit].len())
.max()
.unwrap_or(0)
}
fn is_game_or_better(bid: Bid) -> bool {
let level = bid.level.get();
match bid.strain {
Strain::Notrump => level >= 3,
Strain::Spades | Strain::Hearts => level >= 4,
Strain::Diamonds | Strain::Clubs => level >= 5,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Verdict {
Pass,
Inv,
Fg,
}
impl Verdict {
const ALL: [Verdict; 3] = [Verdict::Pass, Verdict::Inv, Verdict::Fg];
fn label(self) -> &'static str {
match self {
Verdict::Pass => "PASS",
Verdict::Inv => "INV ",
Verdict::Fg => "FG ",
}
}
}
fn verdict(good_lower: bool, good_upper: bool) -> Verdict {
match (good_lower, good_upper) {
(true, true) => Verdict::Fg,
(false, true) => Verdict::Inv,
(true, false) => Verdict::Fg, (false, false) => Verdict::Pass,
}
}
fn total_score(
level: u8,
strain: Strain,
declarer: Seat,
tables: &[ddss::TrickCountTable],
vul: AbsoluteVulnerability,
) -> i64 {
let contract = Contract {
bid: Bid::new(level, strain),
penalty: Penalty::Undoubled,
};
tables
.iter()
.map(|table| ns_score_contract(Some((contract, declarer)), table, vul))
.sum()
}
fn game_is_good(tables: &[ddss::TrickCountTable], vul: AbsoluteVulnerability) -> bool {
let best = |set: &[(u8, Strain)]| {
set.iter()
.flat_map(|&(level, strain)| {
[Seat::North, Seat::South]
.map(|declarer| total_score(level, strain, declarer, tables, vul))
})
.max()
.unwrap_or(i64::MIN)
};
best(GAME) > best(PART)
}
fn reaches_game_frac(
table: &Table<impl pons::System, impl pons::System>,
deals: &[FullDeal],
seed: &Auction,
dealer: Seat,
) -> f64 {
let reached = deals
.iter()
.filter(|deal| {
let auction = table.bid_out_from(deal, seed.clone());
matches!(final_contract(&auction, dealer), Some((c, _)) if is_game_or_better(c.bid))
})
.count();
reached as f64 / deals.len() as f64
}
fn band(hcp: u8) -> &'static str {
match hcp {
0..=5 => "0-5",
6..=7 => "6-7",
8..=9 => "8-9",
10..=11 => "10-11",
_ => "12+",
}
}
fn main() {
assert!(is_game_or_better(Bid::new(3, Strain::Notrump)));
assert!(!is_game_or_better(Bid::new(2, Strain::Notrump)));
assert!(is_game_or_better(Bid::new(4, Strain::Hearts)));
assert!(!is_game_or_better(Bid::new(3, Strain::Spades)));
let mut argv = std::env::args().skip(1);
let hands: usize = argv.next().and_then(|s| s.parse().ok()).unwrap_or(200);
let layouts: usize = argv.next().and_then(|s| s.parse().ok()).unwrap_or(80);
let seed: u64 = argv.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let min_layouts = layouts / 2;
let prior = [Call::Bid(Bid::new(1, Strain::Notrump)), Call::Pass];
let context = Context::new(RelativeVulnerability::NONE, &prior);
let inf = Inferences::read(&context);
let full = inf.partner().points; assert!(full.max > full.min, "1NT shows a splittable point range");
let mid = (full.min + full.max) / 2;
let lower = inf.narrowed_points(Relative::Partner, Range::new(full.min, mid));
let upper = inf.narrowed_points(Relative::Partner, Range::new(mid + 1, full.max));
let mut seed_auction = Auction::new();
seed_auction
.try_extend(prior.iter().copied())
.expect("1NT–Pass is a legal prior auction");
let dealer = Seat::North;
let book: Pair = american();
let vuls = [
("none", AbsoluteVulnerability::NONE),
("both", AbsoluteVulnerability::ALL),
];
let mut confusion = [[[0u32; 3]; 3]; 2];
let mut by_band: std::collections::BTreeMap<(usize, &'static str), (u32, u32)> =
Default::default();
let mut under_reach: Vec<(usize, Hand, u8, bool, Verdict, Verdict)> = Vec::new();
let mut rng = StdRng::seed_from_u64(seed);
let mut skipped = 0u32;
for _ in 0..hands {
let responder = contract_bridge::deck::full_deal(&mut rng)[Seat::South];
let deals_lo = sample_layouts(responder, Seat::South, &lower, &mut rng, layouts);
let deals_hi = sample_layouts(responder, Seat::South, &upper, &mut rng, layouts);
if deals_lo.len() < min_layouts || deals_hi.len() < min_layouts {
skipped += 1;
continue;
}
let tables_lo = Solver::lock().solve_deals(&deals_lo, NonEmptyStrainFlags::ALL);
let tables_hi = Solver::lock().solve_deals(&deals_hi, NonEmptyStrainFlags::ALL);
let hcp = raw_hcp(responder);
let shapely = longest(responder) >= 5;
for (vi, &(_, vul)) in vuls.iter().enumerate() {
let oracle = verdict(game_is_good(&tables_lo, vul), game_is_good(&tables_hi, vul));
let table = Table::of_pairs(&book, &book, dealer, vul);
let frac_lo = reaches_game_frac(&table, &deals_lo, &seed_auction, dealer);
let frac_hi = reaches_game_frac(&table, &deals_hi, &seed_auction, dealer);
let booked = verdict(frac_lo >= 0.5, frac_hi >= 0.5);
confusion[vi][oracle as usize][booked as usize] += 1;
let entry = by_band.entry((vi, band(hcp))).or_default();
entry.0 += 1;
if oracle != booked {
entry.1 += 1;
}
if (oracle as usize) > (booked as usize) {
under_reach.push((vi, responder, hcp, shapely, oracle, booked));
}
}
}
let scored = hands as u32 - skipped;
println!(
"=== 1NT range-split: {scored} responder hands ({skipped} skipped), \
{layouts} layouts/half, opener {}-{} split at {mid} (lower {}-{}, upper {}-{}) ===",
full.min,
full.max,
full.min,
mid,
mid + 1,
full.max
);
for (vi, (name, _)) in vuls.iter().enumerate() {
println!("\n-- vulnerability {name}: oracle (rows) × book (cols) --");
println!(" {:>5} {:>5} {:>5}", "PASS", "INV", "FG");
let mut disagree = 0u32;
let mut total = 0u32;
for o in Verdict::ALL {
let c = &confusion[vi][o as usize];
println!(" {} {:>5} {:>5} {:>5}", o.label(), c[0], c[1], c[2]);
for b in Verdict::ALL {
let n = confusion[vi][o as usize][b as usize];
total += n;
if o != b {
disagree += n;
}
}
}
let rate = 100.0 * f64::from(disagree) / f64::from(total.max(1));
println!(" disagreement: {disagree}/{total} ({rate:.1}%)");
}
println!("\n-- disagreement by responder HCP band --");
for ((vi, b), (tot, dis)) in &by_band {
let rate = 100.0 * f64::from(*dis) / f64::from((*tot).max(1));
println!(
" {:<4} {:<6} {dis:4}/{tot:<4} ({rate:.1}%)",
vuls[*vi].0, b
);
}
println!(
"\n-- under-reach: oracle rates the hand above what the book reaches ({} cases) --",
under_reach.len()
);
for (vi, hand, hcp, shapely, oracle, booked) in under_reach.iter().take(40) {
println!(
" {:<4} {hcp:2} HCP {} oracle={} book={} {hand}",
vuls[*vi].0,
if *shapely { "shapely" } else { "flat " },
oracle.label().trim(),
booked.label().trim(),
);
}
if under_reach.len() > 40 {
println!(" … and {} more", under_reach.len() - 40);
}
}