use super::Rules;
use super::constraint::Constraint;
use super::context::Context;
use contract_bridge::auction::RelativeVulnerability;
use contract_bridge::deck::full_deal;
use contract_bridge::{Hand, Seat};
use rand::Rng;
const MAX_COUNTEREXAMPLES: usize = 16;
#[must_use]
pub fn accepts(constraint: &impl Constraint, hand: Hand, context: &Context<'_>) -> bool {
constraint.eval(hand, context) > f32::NEG_INFINITY
}
pub fn predicate<'a>(
constraint: &'a impl Constraint,
context: &'a Context<'a>,
) -> impl Fn(Hand) -> bool + 'a {
move |hand| accepts(constraint, hand, context)
}
#[derive(Clone, Debug)]
pub struct Report {
pub tested: usize,
pub agreed: usize,
pub reference_accepts: usize,
pub candidate_accepts: usize,
pub disagreements: Vec<Hand>,
}
impl Report {
#[must_use]
pub fn agrees(&self) -> bool {
self.disagreements.is_empty()
}
}
fn random_hands(rng: &mut impl Rng) -> impl Iterator<Item = Hand> + '_ {
core::iter::repeat_with(move || full_deal(rng))
.flat_map(|deal| Seat::ALL.map(|seat| deal[seat]))
}
pub fn compare(
reference: impl Fn(Hand) -> bool,
candidate: impl Fn(Hand) -> bool,
rng: &mut impl Rng,
n: usize,
) -> Report {
let mut report = Report {
tested: 0,
agreed: 0,
reference_accepts: 0,
candidate_accepts: 0,
disagreements: Vec::new(),
};
for hand in random_hands(rng).take(n) {
let want = reference(hand);
let got = candidate(hand);
report.tested += 1;
report.reference_accepts += usize::from(want);
report.candidate_accepts += usize::from(got);
if want == got {
report.agreed += 1;
} else if report.disagreements.len() < MAX_COUNTEREXAMPLES {
report.disagreements.push(hand);
}
}
report
}
#[must_use]
pub fn check_examples(
constraint: &impl Constraint,
context: &Context<'_>,
examples: &[(Hand, bool)],
) -> Vec<Hand> {
examples
.iter()
.filter(|&&(hand, want)| accepts(constraint, hand, context) != want)
.map(|&(hand, _)| hand)
.collect()
}
#[must_use]
pub fn empty_context() -> Context<'static> {
Context::new(RelativeVulnerability::NONE, &[])
}
#[must_use]
pub fn compare_against_rules(
candidate: &impl Constraint,
reference: &Rules,
context: &Context<'_>,
rng: &mut impl Rng,
n: usize,
) -> Vec<Report> {
reference
.rules()
.iter()
.map(|rule| {
compare(
|hand| rule.eval(hand, context).is_finite(),
|hand| accepts(candidate, hand, context),
rng,
n,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bidding::constraint::{and, described, hcp, len, or, points};
use contract_bridge::Suit;
use rand::SeedableRng;
use rand::rngs::StdRng;
fn hand(text: &str) -> Hand {
text.parse().expect("valid test hand")
}
const N: usize = 4000;
fn rng() -> StdRng {
StdRng::seed_from_u64(0xC0FFEE)
}
#[test]
fn accepts_matches_crisp_eval() {
let ctx = empty_context();
assert!(accepts(&hcp(15..=17), hand("AKQ2.K53.QJ4.T92"), &ctx));
assert!(!accepts(&hcp(18..), hand("AKQ2.K53.QJ4.T92"), &ctx));
}
#[test]
fn identical_constraints_agree() {
let ctx = empty_context();
let reference = points(12..=21) & len(Suit::Hearts, 5..);
let candidate = points(12..=21) & len(Suit::Hearts, 5..);
let report = compare(
predicate(&reference, &ctx),
predicate(&candidate, &ctx),
&mut rng(),
N,
);
assert_eq!(report.tested, N);
assert!(report.agrees(), "a faithful recompile must not disagree");
assert_eq!(report.agreed, N);
assert!(report.reference_accepts > 0, "5+ hearts openers do occur");
}
#[test]
fn off_by_one_suit_length_is_caught() {
let ctx = empty_context();
let reference = len(Suit::Hearts, 5..);
let candidate = len(Suit::Hearts, 4..);
let report = compare(
predicate(&reference, &ctx),
predicate(&candidate, &ctx),
&mut rng(),
N,
);
assert!(!report.agrees(), "4+ vs 5+ hearts must disagree");
for &witness in &report.disagreements {
assert_eq!(witness[Suit::Hearts].len(), 4, "{witness}");
}
assert!(report.candidate_accepts > report.reference_accepts);
}
#[test]
fn off_by_one_strength_is_caught() {
let ctx = empty_context();
let report = compare(
predicate(&hcp(15..=17), &ctx),
predicate(&hcp(15..=18), &ctx),
&mut rng(),
N,
);
assert!(!report.agrees(), "15–17 vs 15–18 HCP must disagree");
assert!(report.candidate_accepts > report.reference_accepts);
}
#[test]
fn wrong_combinator_is_caught() {
let ctx = empty_context();
let reference = hcp(15..=17) & len(Suit::Spades, 5..);
let candidate = hcp(15..=17) | len(Suit::Spades, 5..);
let report = compare(
predicate(&reference, &ctx),
predicate(&candidate, &ctx),
&mut rng(),
N,
);
assert!(!report.agrees(), "AND vs OR must disagree");
}
#[test]
fn broken_described_closure_is_caught() {
let ctx = empty_context();
let reference = described("prefers diamonds", |hand: Hand, _: &Context<'_>| {
hand[Suit::Diamonds].len() >= hand[Suit::Clubs].len()
});
let candidate = described("prefers diamonds", |hand: Hand, _: &Context<'_>| {
hand[Suit::Diamonds].len() > hand[Suit::Clubs].len()
});
assert_eq!(reference.describe(), candidate.describe());
let report = compare(
predicate(&reference, &ctx),
predicate(&candidate, &ctx),
&mut rng(),
N,
);
assert!(!report.agrees(), "≥ vs > on equal lengths must disagree");
for &witness in &report.disagreements {
assert_eq!(
witness[Suit::Diamonds].len(),
witness[Suit::Clubs].len(),
"{witness}"
);
}
}
#[test]
fn check_examples_flags_the_mislabeled_hand() {
let ctx = empty_context();
let strong_notrump = hcp(15..=17);
let examples = [
(hand("AKQ2.K53.QJ4.T92"), true), (hand("AKQJ.AKQ.QJ4.T92"), true), (hand("98432.K53.QJ4.92"), false), ];
let failures = check_examples(&strong_notrump, &ctx, &examples);
assert_eq!(failures.len(), 1, "exactly the 20-HCP mislabel fails");
assert_eq!(failures[0], hand("AKQJ.AKQ.QJ4.T92"));
}
#[test]
fn compare_against_rules_isolates_the_matching_rule() {
use crate::bidding::Rules;
use crate::bidding::constraint::balanced;
use contract_bridge::auction::Call;
use contract_bridge::{Bid, Strain};
let ctx = empty_context();
let book = Rules::new()
.rule(Bid::new(1, Strain::Notrump), 1.0, hcp(15..=17) & balanced())
.rule(Call::Pass, 0.0, hcp(..15));
let candidate = hcp(15..=17) & balanced();
let reports = compare_against_rules(&candidate, &book, &ctx, &mut rng(), N);
assert_eq!(reports.len(), 2);
assert!(reports[0].agrees(), "matches the 1NT rule it recompiles");
assert!(!reports[1].agrees(), "is not the Pass rule");
let broken = hcp(15..=18) & balanced();
let reports = compare_against_rules(&broken, &book, &ctx, &mut rng(), N);
assert!(!reports[0].agrees(), "an off-by-one band is caught");
}
#[test]
fn determinism_same_seed_same_report() {
let ctx = empty_context();
let a = compare(
predicate(&hcp(15..=17), &ctx),
predicate(&hcp(15..=18), &ctx),
&mut rng(),
N,
);
let b = compare(
predicate(&hcp(15..=17), &ctx),
predicate(&hcp(15..=18), &ctx),
&mut rng(),
N,
);
assert_eq!(a.tested, b.tested);
assert_eq!(a.agreed, b.agreed);
assert_eq!(a.disagreements, b.disagreements);
}
#[test]
fn projection_contains_every_accepted_hand() {
use crate::bidding::constraint::{Constraint, point_count};
use crate::bidding::inference::Inference;
fn within(envelope: &Inference, hand: Hand) -> bool {
Suit::ASC.into_iter().all(|suit| {
let length = u8::try_from(hand[suit].len()).expect("holding fits u8");
envelope.length(suit).contains(length)
}) && envelope.points.contains(point_count(hand))
}
let ctx = empty_context();
let battery: [Box<dyn Constraint>; 11] = [
Box::new(len(Suit::Hearts, 5..)),
Box::new(points(8..=16)),
Box::new(hcp(15..=17)),
Box::new(len(Suit::Hearts, 5..) & points(8..)),
Box::new(
(len(Suit::Hearts, 5..) & len(Suit::Spades, 4..))
| (len(Suit::Hearts, 4..) & len(Suit::Spades, 5..)),
),
Box::new(len(Suit::Clubs, 5..) | len(Suit::Diamonds, 5..)),
Box::new(len(Suit::Spades, ..4) & points(8..)),
Box::new(described("opaque", |_: Hand, _: &Context<'_>| true)),
Box::new(and([Suit::Hearts, Suit::Spades], 4..)),
Box::new(
and([Suit::Hearts, Suit::Spades], 4..) & or([Suit::Hearts, Suit::Spades], 5..),
),
Box::new(
or([Suit::Hearts, Suit::Spades], 6..) & and([Suit::Clubs, Suit::Diamonds], ..=4),
),
];
let mut rng = rng();
for constraint in &battery {
let envelope = constraint.project(&ctx);
for hand in random_hands(&mut rng).take(N) {
if constraint.eval(hand, &ctx) > f32::NEG_INFINITY {
assert!(
within(&envelope, hand),
"projection unsound: {hand} accepted but outside {envelope:?}"
);
}
}
}
}
#[test]
fn projection_reproduces_the_declarative_readers() {
use crate::american;
use crate::bidding::Family;
use crate::bidding::american::{set_landy, set_leaping_michaels};
use crate::bidding::inference::{Inferences, Range, Relative, authored_reading};
use contract_bridge::auction::{Call, RelativeVulnerability};
use contract_bridge::{Bid, Level, Strain};
let bid = |level, strain| {
Call::Bid(Bid {
level: Level::new(level),
strain,
})
};
let full = Range::new(0, 37);
let agree = |auction: &[Call], who: Relative, suits: &[(Suit, Range)], points: Range| {
let stance = american().against(Family::NATURAL);
let ctx = stance.prefixed_context(RelativeVulnerability::NONE, auction);
let reader = *Inferences::read(&ctx).get(who);
let projected = *authored_reading(&ctx).get(who);
for &(suit, want) in suits {
assert_eq!(
reader.length(suit),
want,
"reader oracle drifted on {suit:?}"
);
assert_eq!(
projected.length(suit),
want,
"projection diverged from reader on {suit:?}"
);
}
assert_eq!(reader.points, points, "reader points oracle drifted");
assert_eq!(projected.points, points, "projection points diverged");
};
agree(
&[
bid(1, Strain::Notrump),
Call::Pass,
bid(2, Strain::Diamonds),
Call::Pass,
bid(2, Strain::Hearts),
Call::Pass,
],
Relative::Me,
&[(Suit::Hearts, Range::new(5, 13))],
full,
);
set_leaping_michaels(true);
agree(
&[bid(2, Strain::Hearts), bid(4, Strain::Clubs), Call::Pass],
Relative::Partner,
&[
(Suit::Clubs, Range::new(5, 13)),
(Suit::Spades, Range::new(5, 13)),
],
Range::new(14, 37),
);
set_leaping_michaels(false);
set_landy(Some((8, 15)));
agree(
&[bid(1, Strain::Notrump), bid(2, Strain::Clubs), Call::Pass],
Relative::Partner,
&[
(Suit::Hearts, Range::new(4, 13)),
(Suit::Spades, Range::new(4, 13)),
],
Range::new(8, 37),
);
set_landy(None);
}
}