contract-bridge 0.1.4

Data types and primitives for the game of contract bridge
Documentation
use approx::assert_ulps_eq;
use contract_bridge::eval::{
    BUMRAP, BUMRAP_PLUS, FIFTHS, HandEvaluator, NLTC, SimpleEvaluator, cccc, hcp, hcp_plus, ltc,
    shortness, zar,
};
use contract_bridge::{Hand, Holding};

/// Test point counts with four kings
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_four_kings() {
    const KXXX: Holding = Holding::from_bits_truncate(0b01000_0000_0111_00);
    const KXX: Holding = Holding::from_bits_truncate(0b01000_0000_0011_00);
    const HAND: Hand = Hand::new(KXXX, KXX, KXX, KXX);

    assert_eq!(SimpleEvaluator(hcp::<u8>).eval(HAND), 12);
    assert_ulps_eq!(FIFTHS.eval(HAND), 2.8 * 4.0);
    assert_ulps_eq!(BUMRAP.eval(HAND), 12.0);

    assert_eq!(SimpleEvaluator(ltc::<u8>).eval(HAND), 8);
    assert_ulps_eq!(NLTC.eval(HAND), 8.0);
    assert_eq!(zar::<u8>(HAND), 24);
}

/// Test a random hand from Cuebids: KJ53.K84.43.KT85
/// <https://cuebids.com/session/deal/yrBmPu9P4O20qzclHpX1>
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_random_from_cuebids() {
    const KJ53: Holding = Holding::from_bits_truncate(0b01010_0000_1010_00);
    const K84: Holding = Holding::from_bits_truncate(0b01000_0100_0100_00);
    const XX: Holding = Holding::from_bits_truncate(0b00000_0000_0110_00);
    const KT85: Holding = Holding::from_bits_truncate(0b01001_0100_1000_00);
    const HAND: Hand = Hand::new(KT85, XX, K84, KJ53);

    assert_eq!(SimpleEvaluator(hcp::<u8>).eval(HAND), 10);
    assert_eq!(SimpleEvaluator(hcp_plus::<u8>).eval(HAND), 11);
    assert_ulps_eq!(FIFTHS.eval(HAND), 9.8);
    assert_ulps_eq!(BUMRAP.eval(HAND), 10.0);
    assert_ulps_eq!(BUMRAP_PLUS.eval(HAND), 11.0);

    assert_eq!(SimpleEvaluator(ltc::<u8>).eval(HAND), 8);
    assert_ulps_eq!(NLTC.eval(HAND), 8.5);
    assert_eq!(zar::<u8>(HAND), 23);
}

/// Test zar evaluator with shortness penalties (waste deductions)
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_zar_waste() {
    // A9876.K.QJ.T5432 in S.H.D.C order
    // Hand::new takes (clubs, diamonds, hearts, spades)
    const A9876: Holding = Holding::from_bits_truncate(0b10000_1100_1100_00);
    const K: Holding = Holding::from_bits_truncate(0b01000_0000_0000_00);
    const QJ: Holding = Holding::from_bits_truncate(0b00110_0000_0000_00);
    const T5432: Holding = Holding::from_bits_truncate(0b00001_0000_1111_00);
    const HAND: Hand = Hand::new(T5432, QJ, K, A9876);

    // Spades A9876 (5 cards): A=6, no waste -> 6
    // Hearts K (1 card): K=4, waste (singleton K) -> 3
    // Diamonds QJ (2 cards): Q=2 + J=1 = 3, waste (Q/J in doubleton) -> 2
    // Clubs T5432 (5 cards): no honors -> 0
    // honors = 6 + 3 + 2 + 0 = 11
    // lengths sorted: [1, 2, 5, 5]
    // sum = 5 + 5 = 10, diff = 5 - 1 = 4
    // zar = 11 + 10 + 4 = 25
    assert_eq!(zar::<u8>(HAND), 25);
}

/// Test zar evaluator with a flat hand of aces
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_zar_aces() {
    // AXXX.AXX.AXX.AXX where X is low cards
    const AXXX: Holding = Holding::from_bits_truncate(0b10000_0000_0111_00);
    const AXX: Holding = Holding::from_bits_truncate(0b10000_0000_0011_00);
    const HAND: Hand = Hand::new(AXXX, AXX, AXX, AXX);

    // Each ace: 6 points, no waste -> 24 honors
    // lengths sorted: [3, 3, 3, 4]
    // sum = 3 + 4 = 7, diff = 4 - 3 = 1
    // zar = 24 + 7 + 1 = 32
    assert_eq!(zar::<u8>(HAND), 32);
}

/// Test eval constants with an empty hand
#[test]
fn test_empty_hand() {
    let hand = Hand::default();
    assert_eq!(SimpleEvaluator(hcp::<u8>).eval(hand), 0);
    assert_eq!(SimpleEvaluator(shortness::<u8>).eval(hand), 12);
    assert_eq!(SimpleEvaluator(hcp_plus::<u8>).eval(hand), 12);
    assert_ulps_eq!(FIFTHS.eval(hand), 0.0);
    assert_ulps_eq!(BUMRAP.eval(hand), 0.0);
    assert_ulps_eq!(BUMRAP_PLUS.eval(hand), 12.0);
    assert_eq!(SimpleEvaluator(ltc::<u8>).eval(hand), 0);
    assert_ulps_eq!(NLTC.eval(hand), 0.0);
    assert_eq!(zar::<u8>(hand), 0);
    // GIGO: four voids score their shortness, like `shortness` = 12 above.
    assert_ulps_eq!(cccc(hand), 11.0);
}

/// Test [`cccc`] against oracle-verified values
///
/// The expected values follow the 26-step algorithm reproduced by Richard
/// Pavlicek (<https://www.rpbridge.net/8j19.htm>) and were cross-checked
/// against Jeff Goldsmith's online K&R calculator.
#[test]
fn test_cccc_vectors() {
    const VECTORS: [(&str, f64); 34] = [
        ("AKQJ.AKQ.AKQ.AKQ", 35.60),
        ("5432.432.432.432", -0.50),
        ("Q2.5432.5432.432", 0.65),
        ("AQ.5432.5432.432", 4.70),
        ("KQ.5432.5432.432", 3.50),
        ("QJ.5432.5432.432", 1.10),
        ("Q.5432.5432.5432", 1.20),
        ("K.5432.5432.5432", 1.80),
        ("A.5432.5432.5432", 4.40),
        ("J.5432.5432.5432", 1.10),
        ("AJ.5432.5432.432", 4.25),
        ("QJ2.5432.5432.32", 1.90),
        ("5432.5432.5432.2", 1.00),
        ("65432.5432.32.32", 1.00),
        ("65432.5432.5432.", 2.00),
        ("AT98.432.432.432", 4.75),
        ("AK92.432.432.432", 7.50),
        ("AKT2.432.432.432", 7.95),
        ("AKJ2.432.432.432", 8.20),
        ("AKQ92.432.432.32", 10.75),
        ("AKQT2.432.432.32", 11.00),
        ("JT98.65432.432.2", 2.25),
        ("T98765.5432.32.2", 2.60),
        ("AQ32.K53.QJ4.A92", 14.90),
        ("AQJ52.K942.32.T9", 12.40),
        ("AKQJ52.AK2.A2.32", 23.90),
        ("AK98765.432.32.2", 12.60),
        ("AKQJ876.432.32.2", 15.00),
        ("AK987654.32.32.2", 15.20),
        ("AKJ87654.32.32.2", 16.50),
        ("AKQ87654.32.32.2", 17.00),
        ("AK9876543.32.2.2", 18.00),
        ("AKQ876543.32.2.2", 19.00),
        ("AKT98765.432.2.2", 15.85),
    ];

    for (text, expected) in VECTORS {
        let hand: Hand = text.parse().expect(text);
        let value = cccc(hand);
        assert!(
            approx::ulps_eq!(value, expected),
            "cccc({text}) = {value}, expected {expected}",
        );
    }
}

/// Test [`cccc`] where Goldsmith's calculator deviates from the spec
///
/// For 7-card suits missing exactly one of the queen and the jack, Jeff
/// Goldsmith's CGI omits the step-9 bonus, contradicting Pavlicek's step
/// list, both `dealer` lineages, and Goldsmith's own prose description.
/// We follow the published algorithm.
#[test]
fn test_cccc_seven_card_suits() {
    const VECTORS: [(&str, f64); 3] = [
        ("AKJ8765.432.32.2", 13.80), // Goldsmith CGI: 13.10
        ("AKQ8765.432.32.2", 15.00), // Goldsmith CGI: 14.30
        ("KQT9832.A54.32.2", 14.00), // Goldsmith CGI: 13.30
    ];

    for (text, expected) in VECTORS {
        let hand: Hand = text.parse().expect(text);
        let value = cccc(hand);
        assert!(
            approx::ulps_eq!(value, expected),
            "cccc({text}) = {value}, expected {expected}",
        );
    }
}

/// Test [`cccc`] against Kaplan's exact whole-population statistics
///
/// Richard Pavlicek published the exact distribution of CCCC over all
/// C(52, 13) = 635,013,559,600 hands (<https://www.rpbridge.net/8j19.htm>).
/// We recover the per-suit contribution of each of the 8192 holdings through
/// the public API, then convolve the four suits with dynamic programming.
/// The asserted figures (unique values, extreme buckets, mode, and the exact
/// first two moments in centipoints) are computed from Pavlicek's full
/// occurrence table; matching them proves the implementation agrees with his
/// over the entire population.  Beware that the mean (10.8724) and standard
/// deviation (4.7223) following from his table differ from his summary box
/// (10.7725 and 4.7233) — the summary appears to contain typos, as the table
/// is self-consistent with its own median, mode, and percentile columns.
#[test]
fn test_cccc_distribution() {
    // Per-suit contribution in centipoints, including own shortness:
    // an otherwise-empty hand scores 3 × 300 (voids) − 100 (global discount).
    #[allow(clippy::cast_possible_truncation)]
    let contribution = |holding: Holding| {
        let hand = Hand::new(holding, Holding::EMPTY, Holding::EMPTY, Holding::EMPTY);
        (cccc(hand) * 100.0).round() as i32 - 800
    };

    // Bucket the 8192 holdings by (length, contribution).
    let mut buckets = std::collections::HashMap::<(usize, i32), u64>::new();
    for bits in 0..0x2000u16 {
        let holding = Holding::from_bits_truncate(bits << 2);
        *buckets
            .entry((holding.len(), contribution(holding)))
            .or_insert(0) += 1;
    }

    // dp[cards][all suits so far >= 3 cards] : contribution -> hand count
    let empty = std::collections::HashMap::<i32, u128>::new();
    let mut dp = vec![[empty.clone(), empty.clone()]; 14];
    dp[0][1].insert(0, 1);

    for _ in 0..4 {
        let mut next = vec![[empty.clone(), empty.clone()]; 14];
        for (cards, flats) in dp.iter().enumerate() {
            for (flat, values) in flats.iter().enumerate() {
                for (&value, &count) in values {
                    for (&(len, delta), &ways) in &buckets {
                        if cards + len <= 13 {
                            let flat = flat & usize::from(len >= 3);
                            *next[cards + len][flat].entry(value + delta).or_insert(0) +=
                                count * u128::from(ways);
                        }
                    }
                }
            }
        }
        dp = next;
    }

    // Histogram of final values: ΣV − 100, +50 for 4333 shapes
    let mut histogram = std::collections::BTreeMap::<i32, u128>::new();
    for (flat, values) in dp[13].iter().enumerate() {
        for (&value, &count) in values {
            // SAFETY: flat is 0 or 1, so the cast is safe.
            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
            let total = value - 100 + 50 * flat as i32;
            *histogram.entry(total).or_insert(0) += count;
        }
    }

    let deals: u128 = histogram.values().sum();
    assert_eq!(deals, 635_013_559_600);
    assert_eq!(histogram.len(), 710);

    let (&min, &min_count) = histogram.first_key_value().unwrap();
    assert_eq!((min, min_count), (-50, 27_500_000));
    assert_eq!(histogram.get(&-35), Some(&44_550_000));
    assert_eq!(histogram.last_key_value(), Some((&3560, &8)));
    assert_eq!(histogram.get(&1120), Some(&2_817_112_924)); // the mode

    let moment = |power: u32| {
        histogram
            .iter()
            .map(|(&value, &count)| i128::from(value).pow(power) * i128::try_from(count).unwrap())
            .sum::<i128>()
    };

    // Exact first and second moments in centipoints over Pavlicek's table
    assert_eq!(moment(1), 690_414_769_219_640);
    assert_eq!(moment(2), 892_257_036_126_722_600);
}

/// Test [`HandEvaluator::eval_pair`] sums both hands
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_eval_pair() {
    const KXXX: Holding = Holding::from_bits_truncate(0b01000_0000_0111_00);
    const KXX: Holding = Holding::from_bits_truncate(0b01000_0000_0011_00);
    const HAND: Hand = Hand::new(KXXX, KXX, KXX, KXX);

    assert_eq!(SimpleEvaluator(hcp::<u8>).eval_pair([HAND, HAND]), 24);
}

/// Test [`BUMRAP_PLUS`] intermediate values
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn test_bumrap_plus_shortness() {
    // Singleton ace: shortness = 2, bumrap = 4.5 -> max(4.5, 2, 5.5) = 5.5
    const A: Holding = Holding::from_bits_truncate(0b10000_0000_0000_00);
    assert_ulps_eq!(BUMRAP_PLUS.0(A), 5.5);

    // Void: shortness = 3, bumrap = 0 -> max(3, 0, 2) = 3
    assert_ulps_eq!(BUMRAP_PLUS.0(Holding::EMPTY), 3.0);
}