use super::context::Context;
use super::inference::{Inferences, Relative};
use crate::bidding::constraint::upgrade;
use contract_bridge::auction::RelativeVulnerability;
use contract_bridge::eval::{self, HandEvaluator, SimpleEvaluator};
use contract_bridge::{Hand, Holding, Penalty, Rank, Strain, Suit};
pub const FEATURES_VERSION: u32 = 1;
pub const FEATURES_LEN: usize = 160;
pub const FEATURES_VERSION_V2: u32 = 2;
pub const TAG_WINDOW: usize = 4;
pub const FEATURES_LEN_V2: usize = FEATURES_LEN + TAG_WINDOW * super::tags::TAG_COUNT;
pub const FEATURES_VERSION_V3: u32 = 3;
pub const LEN_HAND_V3: usize = 10;
pub const FEATURES_LEN_V3: usize = LEN_HAND_V3 + LEN_CONTEXT + LEN_INFERENCES + LEN_VUL;
pub const OFFSET_HAND: usize = 0;
pub const LEN_HAND: usize = 76;
pub const OFFSET_GLOBAL: usize = 76;
pub const LEN_GLOBAL: usize = 6;
pub const OFFSET_CONTEXT: usize = 82;
pub const LEN_CONTEXT: usize = 36;
pub const OFFSET_INFERENCES: usize = 118;
pub const LEN_INFERENCES: usize = 40;
pub const OFFSET_VUL: usize = 158;
pub const LEN_VUL: usize = 2;
const RANKS_HIGH_TO_LOW: [Rank; 13] = [
Rank::A,
Rank::K,
Rank::Q,
Rank::J,
Rank::T,
Rank::new(9),
Rank::new(8),
Rank::new(7),
Rank::new(6),
Rank::new(5),
Rank::new(4),
Rank::new(3),
Rank::new(2),
];
fn is_balanced(hand: Hand) -> bool {
let lengths = Suit::ASC.map(|suit| hand[suit].len());
lengths.iter().all(|&l| l >= 2) && lengths.iter().filter(|&&l| l == 2).count() <= 1
}
fn holding_hcp(holding: Holding) -> u8 {
4 * u8::from(holding.contains(Rank::A))
+ 3 * u8::from(holding.contains(Rank::K))
+ 2 * u8::from(holding.contains(Rank::Q))
+ u8::from(holding.contains(Rank::J))
}
fn has_stopper(holding: Holding) -> bool {
holding.contains(Rank::A)
|| (holding.contains(Rank::K) && holding.len() >= 2)
|| (holding.contains(Rank::Q) && holding.len() >= 3)
|| (holding.contains(Rank::J) && holding.len() >= 4)
}
fn push_bid_encoding(out: &mut Vec<f32>, bid: Option<contract_bridge::Bid>) {
match bid {
None => {
out.push(0.0); out.push(0.0); for _ in Strain::ASC {
out.push(0.0);
}
}
Some(b) => {
out.push(1.0); out.push(b.level.get() as f32 / 7.0);
for strain in Strain::ASC {
out.push(f32::from(b.strain == strain));
}
}
}
}
#[must_use]
pub fn features(hand: Hand, context: &Context<'_>) -> Vec<f32> {
let mut out = Vec::with_capacity(FEATURES_LEN);
for suit in Suit::ASC {
let holding = hand[suit];
let len = holding.len();
for &rank in &RANKS_HIGH_TO_LOW {
out.push(f32::from(holding.contains(rank)));
}
out.push(len as f32 / 13.0);
out.push(holding_hcp(holding) as f32 / 10.0);
let top = u8::from(holding.contains(Rank::A))
+ u8::from(holding.contains(Rank::K))
+ u8::from(holding.contains(Rank::Q));
out.push(top as f32 / 3.0);
out.push(f32::from(has_stopper(holding)));
out.push(f32::from(matches!(suit, Suit::Hearts | Suit::Spades)));
out.push(suit as u8 as f32 / 3.0);
}
let hcp = SimpleEvaluator(eval::hcp::<u8>).eval(hand);
let up = upgrade(hand);
let points = hcp + up;
out.push(hcp as f32 / 40.0);
out.push(points as f32 / 40.0);
out.push(eval::FIFTHS.eval(hand) as f32 / 40.0);
out.push(eval::cccc(hand) as f32 / 40.0);
out.push(eval::NLTC.eval(hand) as f32 / 13.0);
out.push(f32::from(is_balanced(hand)));
push_context(&mut out, context);
debug_assert_eq!(out.len(), FEATURES_LEN);
out
}
fn push_context(out: &mut Vec<f32>, context: &Context<'_>) {
for strain in Strain::ASC {
out.push(f32::from(context.we_bid(strain)));
}
for strain in Strain::ASC {
out.push(f32::from(context.they_bid(strain)));
}
push_bid_encoding(out, context.last_bid());
push_bid_encoding(out, context.partner_last_bid());
let penalty = context.penalty();
out.push(f32::from(penalty == Penalty::Undoubled));
out.push(f32::from(penalty == Penalty::Doubled));
out.push(f32::from(penalty == Penalty::Redoubled));
out.push(f32::from(context.undisturbed()));
out.push(f32::from(context.passed_hand()));
out.push(f32::from(context.partner_passed_hand()));
out.push((context.leading_passes().min(3) as f32) / 3.0);
let seat_idx = context.auction().len() % 4;
for i in 0..4 {
out.push(f32::from(i == seat_idx));
}
let we_opened = match context.opener_seat() {
Some(seat) => {
let opening_index = seat as usize - 1;
(context.auction().len() - opening_index).is_multiple_of(2)
}
None => false,
};
out.push(f32::from(we_opened));
let inf = Inferences::read(context);
for who in [
Relative::Me,
Relative::Lho,
Relative::Partner,
Relative::Rho,
] {
let player = inf.get(who);
for suit in Suit::ASC {
let range = player.length(suit);
out.push(range.min as f32 / 13.0);
out.push(range.max as f32 / 13.0);
}
out.push(player.points.min as f32 / 37.0);
out.push(player.points.max as f32 / 37.0);
}
let v = context.vul();
out.push(f32::from(v.contains(RelativeVulnerability::WE)));
out.push(f32::from(v.contains(RelativeVulnerability::THEY)));
}
#[must_use]
pub fn features_v2(hand: Hand, context: &Context<'_>) -> Vec<f32> {
use super::tags::{TAG_COUNT, derive_tags, infer_book, tag_multihot};
let mut out = features(hand, context);
out.resize(FEATURES_LEN_V2, 0.0);
let auction = context.auction();
let vul = context.vul();
for slot in 0..TAG_WINDOW {
let Some(index) = auction.len().checked_sub(slot + 1) else {
break;
};
let prefix = Context::new(vul, &auction[..index]);
let tags = derive_tags(infer_book(&prefix), auction[index], &prefix);
let start = FEATURES_LEN + slot * TAG_COUNT;
tag_multihot(&tags, &mut out[start..start + TAG_COUNT]);
}
debug_assert_eq!(out.len(), FEATURES_LEN_V2);
out
}
#[must_use]
pub fn features_v3(hand: Hand, context: &Context<'_>) -> Vec<f32> {
let mut out = Vec::with_capacity(FEATURES_LEN_V3);
for suit in Suit::ASC {
let holding = hand[suit];
out.push(holding.len() as f32 / 13.0);
out.push(holding_hcp(holding) as f32 / 10.0);
}
let hcp = SimpleEvaluator(eval::hcp::<u8>).eval(hand);
let shape = upgrade(hand);
out.push(hcp as f32 / 40.0);
out.push(shape as f32 / 2.0);
debug_assert_eq!(out.len(), LEN_HAND_V3);
push_context(&mut out, context);
debug_assert_eq!(out.len(), FEATURES_LEN_V3);
out
}
#[cfg(test)]
mod tests {
use super::*;
use contract_bridge::auction::{Call, RelativeVulnerability};
use contract_bridge::{Bid, Level, Strain};
const fn bid(level: u8, strain: Strain) -> Call {
Call::Bid(Bid {
level: Level::new(level),
strain,
})
}
fn hand(s: &str) -> Hand {
s.parse().expect("valid test hand")
}
fn empty_context() -> Context<'static> {
Context::new(RelativeVulnerability::NONE, &[])
}
#[test]
fn block_offsets_are_consistent() {
assert_eq!(OFFSET_HAND, 0);
assert_eq!(LEN_HAND, 76);
assert_eq!(OFFSET_GLOBAL, OFFSET_HAND + LEN_HAND);
assert_eq!(LEN_GLOBAL, 6);
assert_eq!(OFFSET_CONTEXT, OFFSET_GLOBAL + LEN_GLOBAL);
assert_eq!(LEN_CONTEXT, 36);
assert_eq!(OFFSET_INFERENCES, OFFSET_CONTEXT + LEN_CONTEXT);
assert_eq!(LEN_INFERENCES, 40);
assert_eq!(OFFSET_VUL, OFFSET_INFERENCES + LEN_INFERENCES);
assert_eq!(LEN_VUL, 2);
assert_eq!(OFFSET_VUL + LEN_VUL, FEATURES_LEN);
}
#[test]
fn length_is_correct_for_empty_auction() {
let ctx = empty_context();
let h = hand("AKQ32.K532.QJ4.9");
let f = features(h, &ctx);
assert_eq!(f.len(), FEATURES_LEN);
}
#[test]
fn length_is_correct_for_contested_auction() {
let auction = [
bid(1, Strain::Hearts),
bid(1, Strain::Spades),
bid(2, Strain::Hearts),
];
let ctx = Context::new(RelativeVulnerability::WE, &auction);
let h = hand("AQ32.K53.QJ4.A92");
let f = features(h, &ctx);
assert_eq!(f.len(), FEATURES_LEN);
}
#[test]
fn v3_length_and_range() {
assert_eq!(FEATURES_LEN_V3, 88);
let auction = [
bid(1, Strain::Spades),
Call::Pass,
bid(2, Strain::Clubs),
Call::Double,
];
for ctx in [
empty_context(),
Context::new(RelativeVulnerability::ALL, &auction),
] {
let f = features_v3(hand("AKQ32.K532.QJ4.9"), &ctx);
assert_eq!(f.len(), FEATURES_LEN_V3);
for (i, &v) in f.iter().enumerate() {
assert!(v.is_finite() && (0.0..=1.5).contains(&v), "v3[{i}] = {v}");
}
}
}
#[test]
fn v3_shares_context_tail_with_v1() {
let auction = [bid(1, Strain::Hearts), bid(1, Strain::Spades)];
let ctx = Context::new(RelativeVulnerability::WE, &auction);
let h = hand("AQ32.K53.QJ4.A92");
let v1 = features(h, &ctx);
let v3 = features_v3(h, &ctx);
assert_eq!(v1[OFFSET_CONTEXT..], v3[LEN_HAND_V3..]);
}
#[test]
fn all_values_are_finite_and_in_range() {
let auction = [
bid(1, Strain::Spades),
Call::Pass,
bid(2, Strain::Clubs),
Call::Double,
];
let ctx = Context::new(RelativeVulnerability::ALL, &auction);
let h = hand("AKQ32.K532.QJ4.9");
let f = features(h, &ctx);
for (i, &v) in f.iter().enumerate() {
assert!(v.is_finite(), "feature[{i}] is not finite: {v}");
assert!(v >= 0.0, "feature[{i}] is negative: {v}");
assert!(v <= 1.5, "feature[{i}] exceeds 1.5: {v}");
}
}
#[test]
fn empty_auction_known_values() {
let ctx = empty_context();
let h = hand("AKQ32.K532.QJ4.9");
let f = features(h, &ctx);
let seat_one_hot_start = OFFSET_CONTEXT + 5 + 5 + 7 + 7 + 3 + 1 + 1 + 1 + 1;
assert_eq!(f[seat_one_hot_start], 1.0, "seat index 0 should be 1.0");
assert_eq!(f[seat_one_hot_start + 1], 0.0);
assert_eq!(f[seat_one_hot_start + 2], 0.0);
assert_eq!(f[seat_one_hot_start + 3], 0.0);
assert_eq!(f[OFFSET_VUL], 0.0, "WE vul should be 0.0");
assert_eq!(f[OFFSET_VUL + 1], 0.0, "THEY vul should be 0.0");
let last_bid_start = OFFSET_CONTEXT + 5 + 5;
assert_eq!(f[last_bid_start], 0.0, "contract-to-beat present bit");
let undisturbed_offset = OFFSET_CONTEXT + 5 + 5 + 7 + 7 + 3;
assert_eq!(f[undisturbed_offset], 1.0, "undisturbed should be 1.0");
}
#[test]
fn spade_rank_bits_for_known_hand() {
let h = hand("AKQ32.K532.QJ4.9");
let ctx = empty_context();
let f = features(h, &ctx);
let spade_start = OFFSET_HAND + 3 * 19;
assert_eq!(f[spade_start], 1.0, "A of spades");
assert_eq!(f[spade_start + 1], 1.0, "K of spades");
assert_eq!(f[spade_start + 2], 1.0, "Q of spades");
assert_eq!(f[spade_start + 3], 0.0, "J of spades");
assert_eq!(f[spade_start + 4], 0.0, "T of spades");
for i in 5..11 {
assert_eq!(f[spade_start + i], 0.0, "rank bit {i} of spades");
}
assert_eq!(f[spade_start + 11], 1.0, "3 of spades");
assert_eq!(f[spade_start + 12], 1.0, "2 of spades");
assert!(
(f[spade_start + 13] - 5.0 / 13.0).abs() < 1e-6,
"spades len/13"
);
assert_eq!(f[spade_start + 16], 1.0, "spades stopper");
assert_eq!(f[spade_start + 17], 1.0, "spades is-major");
}
#[test]
fn balanced_bit_for_balanced_hand() {
let h = hand("AQ32.K53.QJ4.A92");
let ctx = empty_context();
let f = features(h, &ctx);
assert_eq!(f[OFFSET_GLOBAL + 5], 1.0, "balanced bit for 4333");
let unbalanced = hand("AKQ32.K532.QJ4.9");
let f2 = features(unbalanced, &ctx);
assert_eq!(f2[OFFSET_GLOBAL + 5], 0.0, "balanced bit for 5431");
}
#[test]
fn vulnerability_bits() {
let ctx_we = Context::new(RelativeVulnerability::WE, &[]);
let h = hand("AQ32.K53.QJ4.A92");
let f = features(h, &ctx_we);
assert_eq!(f[OFFSET_VUL], 1.0, "WE vul bit");
assert_eq!(f[OFFSET_VUL + 1], 0.0, "THEY vul bit");
let ctx_all = Context::new(RelativeVulnerability::ALL, &[]);
let f2 = features(h, &ctx_all);
assert_eq!(f2[OFFSET_VUL], 1.0);
assert_eq!(f2[OFFSET_VUL + 1], 1.0);
}
#[test]
fn we_opened_bit() {
let h = hand("AQ32.K53.QJ4.A92");
let f0 = features(h, &empty_context());
let we_opened_offset = OFFSET_CONTEXT + 35; assert_eq!(f0[we_opened_offset], 0.0, "no opener → 0.0");
let auction_they = [bid(1, Strain::Spades)];
let ctx_they = Context::new(RelativeVulnerability::NONE, &auction_they);
let f1 = features(h, &ctx_they);
assert_eq!(f1[we_opened_offset], 0.0, "they opened (RHO opened)");
let auction_we = [bid(1, Strain::Spades), Call::Pass];
let ctx_we = Context::new(RelativeVulnerability::NONE, &auction_we);
let f2 = features(h, &ctx_we);
assert_eq!(f2[we_opened_offset], 1.0, "we opened (partner opened)");
}
#[test]
fn penalty_one_hot() {
let h = hand("AQ32.K53.QJ4.A92");
let penalty_offset = OFFSET_CONTEXT + 5 + 5 + 7 + 7;
let f0 = features(h, &empty_context());
assert_eq!(f0[penalty_offset], 1.0, "undoubled");
assert_eq!(f0[penalty_offset + 1], 0.0);
assert_eq!(f0[penalty_offset + 2], 0.0);
let auction_x = [bid(1, Strain::Spades), Call::Double];
let ctx_x = Context::new(RelativeVulnerability::NONE, &auction_x);
let f1 = features(h, &ctx_x);
assert_eq!(f1[penalty_offset], 0.0);
assert_eq!(f1[penalty_offset + 1], 1.0, "doubled");
assert_eq!(f1[penalty_offset + 2], 0.0);
let auction_xx = [bid(1, Strain::Spades), Call::Double, Call::Redouble];
let ctx_xx = Context::new(RelativeVulnerability::NONE, &auction_xx);
let f2 = features(h, &ctx_xx);
assert_eq!(f2[penalty_offset], 0.0);
assert_eq!(f2[penalty_offset + 1], 0.0);
assert_eq!(f2[penalty_offset + 2], 1.0, "redoubled");
}
use super::super::tags::{TAG_COUNT, tag_index};
#[test]
fn v2_length_matches_constant() {
assert_eq!(FEATURES_LEN_V2, FEATURES_LEN + TAG_WINDOW * TAG_COUNT);
let h = hand("AKQ32.K532.QJ4.9");
assert_eq!(features_v2(h, &empty_context()).len(), FEATURES_LEN_V2);
let auction = [bid(1, Strain::Hearts), Call::Pass];
let ctx = Context::new(RelativeVulnerability::WE, &auction);
assert_eq!(features_v2(h, &ctx).len(), FEATURES_LEN_V2);
}
#[test]
fn v2_prefix_is_identical_to_v1() {
let auction = [
bid(1, Strain::Spades),
Call::Pass,
bid(2, Strain::Clubs),
Call::Double,
];
let ctx = Context::new(RelativeVulnerability::ALL, &auction);
let h = hand("AKQ32.K532.QJ4.9");
let v1 = features(h, &ctx);
let v2 = features_v2(h, &ctx);
assert_eq!(v2[..FEATURES_LEN], v1[..], "v1 prefix must be untouched");
}
#[test]
fn v2_empty_auction_has_no_tag_bits() {
let h = hand("AKQ32.K532.QJ4.9");
let f = features_v2(h, &empty_context());
for &v in &f[FEATURES_LEN..] {
assert_eq!(v, 0.0, "no prior calls → tag block all zero");
}
}
#[test]
fn v2_tag_block_reads_recent_calls_most_recent_first() {
let auction = [bid(1, Strain::Hearts), Call::Pass];
let ctx = Context::new(RelativeVulnerability::NONE, &auction);
let h = hand("K2.KQ54.A964.Q92");
let f = features_v2(h, &ctx);
let slot = |s: usize| &f[FEATURES_LEN + s * TAG_COUNT..FEATURES_LEN + (s + 1) * TAG_COUNT];
assert_eq!(slot(0)[tag_index("NF").unwrap()], 1.0, "pass → NF");
assert_eq!(slot(1)[tag_index("NAT").unwrap()], 1.0, "1♥ open → NAT");
assert!(slot(2).iter().all(|&v| v == 0.0));
assert!(slot(3).iter().all(|&v| v == 0.0));
}
#[test]
fn v2_all_values_finite_and_in_range() {
let auction = [
bid(1, Strain::Hearts),
Call::Pass,
bid(2, Strain::Clubs),
Call::Pass,
];
let ctx = Context::new(RelativeVulnerability::ALL, &auction);
let h = hand("AQ32.K53.QJ4.A92");
let f = features_v2(h, &ctx);
assert_eq!(f.len(), FEATURES_LEN_V2);
for (i, &v) in f.iter().enumerate() {
assert!(v.is_finite(), "feature[{i}] not finite: {v}");
assert!((0.0..=1.5).contains(&v), "feature[{i}] out of range: {v}");
}
}
}