pons 0.9.0

Rust package for contract bridge
Documentation
use contract_bridge::auction::{Call, RelativeVulnerability};
use contract_bridge::{Bid, Hand, Level, Strain};
use pons::bidding::array::Logits;
use pons::bidding::trie::classifier;
use pons::bidding::{Context, System, Trie};

const fn bid(level: u8, strain: Strain) -> Call {
    Call::Bid(Bid {
        level: Level::new(level),
        strain,
    })
}

const fn marker_logits(value: f32) -> Logits {
    let mut logits = Logits::new();
    *logits.0.get_mut(Call::Pass) = value;
    logits
}

const fn just_pass() -> Logits {
    marker_logits(0.0)
}

fn classify_at(trie: &Trie, auction: &[Call]) -> Logits {
    let f = trie
        .get(auction)
        .expect("classifier missing at exact auction");
    let context = Context::new(RelativeVulnerability::NONE, auction)
        .with_prefixes(trie.common_prefixes(auction));
    f.classify(Hand::default(), &context)
}

#[test]
fn test_trie_new_is_empty() {
    let trie = Trie::new();
    assert!(trie.is_prefix(&[]));
    assert!(trie.get(&[]).is_none());
    assert!(trie.iter().next().is_none());
    assert!(trie.longest_prefix(&[]).is_none());
}

#[test]
fn test_trie_default_constructs() {
    let trie = Trie::default();
    assert!(trie.iter().next().is_none());
}

#[test]
fn test_trie_insert_and_get_round_trip() {
    let mut trie = Trie::new();
    let auction = [bid(1, Strain::Clubs)];
    trie.insert(&auction, classifier(|_, _| marker_logits(1.0)));

    assert_eq!(classify_at(&trie, &auction), marker_logits(1.0));
}

#[test]
fn test_trie_insert_returns_previous_when_overwriting() {
    let mut trie = Trie::new();
    let auction = [bid(1, Strain::Clubs)];
    assert!(
        trie.insert(&auction, classifier(|_, _| marker_logits(1.0)))
            .is_none()
    );
    assert!(
        trie.insert(&auction, classifier(|_, _| marker_logits(2.0)))
            .is_some()
    );
    assert_eq!(classify_at(&trie, &auction), marker_logits(2.0));
}

#[test]
fn test_trie_is_prefix() {
    let mut trie = Trie::new();
    let auction = [bid(1, Strain::Clubs), Call::Pass, bid(1, Strain::Hearts)];
    trie.insert(&auction, classifier(|_, _| just_pass()));

    assert!(trie.is_prefix(&[]));
    assert!(trie.is_prefix(&auction[..1]));
    assert!(trie.is_prefix(&auction[..2]));
    assert!(trie.is_prefix(&auction));

    assert!(!trie.is_prefix(&[bid(1, Strain::Spades)]));
    assert!(!trie.is_prefix(&[bid(1, Strain::Clubs), bid(2, Strain::Clubs)]));
}

#[test]
fn test_trie_longest_prefix_picks_deepest_match() {
    let mut trie = Trie::new();
    let one_c = bid(1, Strain::Clubs);
    let one_h = bid(1, Strain::Hearts);
    let one_d = bid(1, Strain::Diamonds);

    trie.insert(&[], classifier(|_, _| marker_logits(0.0)));
    trie.insert(&[one_c], classifier(|_, _| marker_logits(1.0)));
    trie.insert(
        &[one_c, Call::Pass, one_h],
        classifier(|_, _| marker_logits(2.0)),
    );

    // Diverges after [1C, P]: deepest match is [1C].
    let query = [one_c, Call::Pass, one_d];
    let (prefix, _) = trie.longest_prefix(&query).expect("expected a match");
    assert_eq!(prefix, &[one_c]);

    // Exact match returns the deepest classifier.
    let exact = [one_c, Call::Pass, one_h];
    let (prefix, _) = trie.longest_prefix(&exact).expect("expected a match");
    assert_eq!(prefix, &exact[..]);

    // Empty query falls back to the root classifier.
    let (prefix, _) = trie.longest_prefix(&[]).expect("expected root match");
    assert!(prefix.is_empty());
}

#[test]
fn test_trie_longest_prefix_no_match_returns_none() {
    let trie = Trie::new();
    assert!(trie.longest_prefix(&[bid(1, Strain::Clubs)]).is_none());
}

#[test]
fn test_trie_longest_prefix_skips_empty_intermediate() {
    // Root has no classifier; only [1C, P, 1H] does. Querying that exact
    // auction must still return the deepest match.
    let mut trie = Trie::new();
    let one_c = bid(1, Strain::Clubs);
    let one_h = bid(1, Strain::Hearts);
    let auction = [one_c, Call::Pass, one_h];
    trie.insert(&auction, classifier(|_, _| just_pass()));

    let (prefix, _) = trie
        .longest_prefix(&auction)
        .expect("expected deepest match");
    assert_eq!(prefix, &auction[..]);

    // A sibling query yields no match because no ancestor has a classifier.
    assert!(trie.longest_prefix(&[bid(1, Strain::Spades)]).is_none());
}

#[test]
fn test_trie_suffixes_enumerate_with_correct_paths() {
    let mut trie = Trie::new();
    let one_c = bid(1, Strain::Clubs);
    let one_h = bid(1, Strain::Hearts);

    trie.insert(&[one_c], classifier(|_, _| marker_logits(1.0)));
    trie.insert(
        &[one_c, Call::Pass, one_h],
        classifier(|_, _| marker_logits(2.0)),
    );
    trie.insert(
        &[bid(1, Strain::Spades)],
        classifier(|_, _| marker_logits(3.0)),
    );

    let suffixes: Vec<Box<[Call]>> = trie.suffixes(&[one_c]).map(|(s, _)| s).collect();
    assert_eq!(suffixes.len(), 2);
    assert!(suffixes.iter().any(|s| s.is_empty()));
    assert!(suffixes.iter().any(|s| **s == [Call::Pass, one_h]));
}

#[test]
fn test_trie_suffixes_empty_when_prefix_absent() {
    let trie = Trie::new();
    assert_eq!(trie.suffixes(&[bid(1, Strain::Clubs)]).count(), 0);
}

#[test]
fn test_trie_suffixes_isolated_to_subtree() {
    let mut trie = Trie::new();
    trie.insert(&[bid(1, Strain::Clubs)], classifier(|_, _| just_pass()));
    trie.insert(&[bid(1, Strain::Hearts)], classifier(|_, _| just_pass()));

    assert_eq!(trie.suffixes(&[bid(1, Strain::Clubs)]).count(), 1);
}

#[test]
fn test_trie_suffixes_is_fused() {
    let trie = Trie::new();
    let mut iter = trie.suffixes(&[]);
    assert!(iter.next().is_none());
    assert!(iter.next().is_none());
    assert!(iter.next().is_none());
}

#[test]
fn test_trie_iter_visits_every_classifier() {
    let mut trie = Trie::new();
    trie.insert(&[bid(1, Strain::Clubs)], classifier(|_, _| just_pass()));
    trie.insert(&[bid(1, Strain::Hearts)], classifier(|_, _| just_pass()));
    trie.insert(
        &[bid(1, Strain::Hearts), Call::Pass],
        classifier(|_, _| just_pass()),
    );

    assert_eq!(trie.iter().count(), 3);
}

#[test]
fn test_trie_common_prefixes_returns_ancestors_with_classifiers() {
    let mut trie = Trie::new();
    let one_c = bid(1, Strain::Clubs);
    let one_h = bid(1, Strain::Hearts);

    trie.insert(&[], classifier(|_, _| marker_logits(0.0)));
    trie.insert(&[one_c], classifier(|_, _| marker_logits(1.0)));
    trie.insert(
        &[one_c, Call::Pass, one_h],
        classifier(|_, _| marker_logits(2.0)),
    );

    let query = [one_c, Call::Pass, one_h];
    let prefixes: Vec<Vec<Call>> = trie
        .common_prefixes(&query)
        .map(|(p, _)| p.to_vec())
        .collect();

    assert_eq!(prefixes.len(), 3);
    assert_eq!(prefixes[0], Vec::<Call>::new());
    assert_eq!(prefixes[1], vec![one_c]);
    assert_eq!(prefixes[2], vec![one_c, Call::Pass, one_h]);
}

#[test]
fn test_trie_common_prefixes_skips_uncovered_intermediate() {
    let mut trie = Trie::new();
    let one_c = bid(1, Strain::Clubs);
    let one_h = bid(1, Strain::Hearts);
    // Only the deepest node has a classifier; intermediates do not.
    trie.insert(&[one_c, Call::Pass, one_h], classifier(|_, _| just_pass()));

    let query = [one_c, Call::Pass, one_h];
    let prefixes: Vec<Vec<Call>> = trie
        .common_prefixes(&query)
        .map(|(p, _)| p.to_vec())
        .collect();
    assert_eq!(prefixes, vec![vec![one_c, Call::Pass, one_h]]);
}

#[test]
fn test_trie_common_prefixes_empty_when_diverges() {
    let mut trie = Trie::new();
    trie.insert(&[bid(1, Strain::Clubs)], classifier(|_, _| just_pass()));

    let query = [bid(1, Strain::Spades)];
    assert_eq!(trie.common_prefixes(&query).count(), 0);
}

#[test]
fn test_pass_everything_classifies_root() {
    let mut trie = Trie::new();
    trie.insert(&[], classifier(|_, _| just_pass()));

    let result = trie.classify(Hand::default(), RelativeVulnerability::NONE, &[]);
    assert_eq!(result, Some(just_pass()));
}

#[test]
fn test_system_trie_returns_none_for_unknown_auction() {
    let trie = Trie::new();
    let result = trie.classify(Hand::default(), RelativeVulnerability::NONE, &[]);
    assert!(result.is_none());
}

#[test]
fn test_merge_disjoint_fragments() {
    let one_h = bid(1, Strain::Hearts);
    let one_s = bid(1, Strain::Spades);

    let mut core = Trie::new();
    core.insert(&[one_h], classifier(|_, _| marker_logits(1.0)));

    let mut package = Trie::new();
    package.insert(&[one_s], classifier(|_, _| marker_logits(2.0)));
    package.insert(&[one_h, Call::Pass], classifier(|_, _| marker_logits(3.0)));

    assert!(core.merge(package).is_empty());
    assert_eq!(classify_at(&core, &[one_h]), marker_logits(1.0));
    assert_eq!(classify_at(&core, &[one_s]), marker_logits(2.0));
    assert_eq!(classify_at(&core, &[one_h, Call::Pass]), marker_logits(3.0));
}

#[test]
fn test_merge_reports_collisions_and_keeps_self() {
    let one_h = bid(1, Strain::Hearts);

    let mut core = Trie::new();
    core.insert(&[one_h], classifier(|_, _| marker_logits(1.0)));

    let mut other = Trie::new();
    other.insert(&[one_h], classifier(|_, _| marker_logits(2.0)));
    other.insert(&[one_h, Call::Pass], classifier(|_, _| marker_logits(3.0)));

    let collisions = core.merge(other);
    assert_eq!(collisions.len(), 1);
    assert_eq!(&*collisions[0], &[one_h]);

    // Self keeps its classifier; the non-colliding node still merges.
    assert_eq!(classify_at(&core, &[one_h]), marker_logits(1.0));
    assert_eq!(classify_at(&core, &[one_h, Call::Pass]), marker_logits(3.0));
}

#[test]
fn test_merge_concatenates_fallbacks_self_first() {
    use pons::bidding::fallback::{Always, Fallback};

    let mut core = Trie::new();
    core.fallback_at(
        &[],
        Always,
        Fallback::classify(classifier(|_, _| marker_logits(1.0))),
    );

    let mut other = Trie::new();
    other.fallback_at(
        &[],
        Always,
        Fallback::classify(classifier(|_, _| marker_logits(2.0))),
    );

    core.merge(other);
    let result = core.classify(
        Hand::default(),
        RelativeVulnerability::NONE,
        &[bid(2, Strain::Clubs)],
    );
    assert_eq!(result, Some(marker_logits(1.0)));
}