shuyi_core/bots/weigh.rs
1//! Weighted features: a bot's score as the game's named counts times
2//! weights from a file, and personas (how a bot thinks and what it cares
3//! about) as data.
4//!
5//! A game declares a fixed list of feature names (`["won", "lost", "hp
6//! lost"]`) and a function that counts each of them between the state a
7//! decision began in and where a copy got to. [`Weights`] holds what one
8//! kind of player cares about each, read by name, and its
9//! [`score`](Weights::score) is the sum of count × weight, in integers
10//! that saturate rather than wrap. [`Weighted`] pairs the two into an
11//! [`Evaluate`] for the bots that plan. What a feature means and how it is
12//! counted stay the game's.
13//!
14//! A [`Persona`] is one kind of player: its [`PlanSettings`], its weights
15//! by name, and whatever the game adds of its own (a game mode, a
16//! difficulty). A map of them by name is a personas file:
17//!
18//! ```ron
19//! {
20//! "careful": (
21//! settings: (width: 4, candidates: 10, depth: 6, samples: 2, temperature: 0),
22//! weights: {"won": 1000, "hp lost": -4},
23//! game: (mode: Classic),
24//! ),
25//! }
26//! ```
27//!
28//! A weight naming a feature the game didn't declare is an error
29//! ([`UnknownFeature`]); a feature with no weight weighs 0.
30
31use alloc::collections::BTreeMap;
32use alloc::string::String;
33use core::fmt;
34
35use serde::{Deserialize, Serialize};
36
37use super::Sides;
38use super::determinize::Evaluate;
39use super::plan::PlanSettings;
40
41/// What a kind of player cares about each of a game's `N` features, in
42/// the order of the game's names.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Weights<const N: usize>(pub [i32; N]);
45
46impl<const N: usize> Weights<N> {
47 /// The weights of `by_name`, put in the order of `names`. A feature
48 /// `by_name` leaves out weighs 0.
49 ///
50 /// # Errors
51 ///
52 /// [`UnknownFeature`] for the first name of `by_name` (in its order)
53 /// that isn't one of `names`.
54 pub fn from_named(
55 names: &[&str; N],
56 by_name: &BTreeMap<String, i32>,
57 ) -> Result<Self, UnknownFeature> {
58 let mut weights = [0; N];
59 for (name, &weight) in by_name {
60 let Some(index) = names.iter().position(|known| known == name) else {
61 return Err(UnknownFeature {
62 name: name.clone(),
63 known: names.join(", "),
64 });
65 };
66 weights[index] = weight;
67 }
68 Ok(Self(weights))
69 }
70
71 /// The sum of each count times its weight, clamped into `i32`: never
72 /// a wrap. It allocates nothing.
73 pub fn score(&self, counts: &[i64; N]) -> i32 {
74 // In i128 a product (under 2^94) never overflows and a sum of any
75 // real number of them neither, so a large term that a later one
76 // cancels still counts in full; the adds saturate all the same.
77 let sum = counts
78 .iter()
79 .zip(self.0)
80 .fold(0_i128, |sum, (&count, weight)| {
81 sum.saturating_add(i128::from(count) * i128::from(weight))
82 });
83 let clamped = sum.clamp(i128::from(i32::MIN), i128::from(i32::MAX));
84 i32::try_from(clamped).unwrap_or_default()
85 }
86
87 /// The weight of the feature `name` among `names`, or `None` when it
88 /// isn't one of them.
89 pub fn get(&self, names: &[&str; N], name: &str) -> Option<i32> {
90 names
91 .iter()
92 .position(|known| *known == name)
93 .map(|index| self.0[index])
94 }
95}
96
97/// A weight naming a feature the game didn't declare.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct UnknownFeature {
100 /// The name that isn't a feature.
101 pub name: String,
102 /// The game's feature names, joined with ", ".
103 pub known: String,
104}
105
106impl fmt::Display for UnknownFeature {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 write!(f, "no feature {:?} (there: {})", self.name, self.known)
109 }
110}
111
112impl core::error::Error for UnknownFeature {}
113
114/// An [`Evaluate`] made of the game's counts and a kind of player's
115/// weights: it scores `weights.score(&counts(start, end, side))`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Weighted<F, const N: usize> {
118 /// What the player cares about each feature.
119 pub weights: Weights<N>,
120 /// The game's counts of its features, between the state a decision
121 /// began in and where a copy got to, for a side.
122 pub counts: F,
123}
124
125impl<R, F, const N: usize> Evaluate<R> for Weighted<F, N>
126where
127 R: Sides,
128 F: Fn(&R, &R, R::Side) -> [i64; N],
129{
130 fn score(&self, start: &R, end: &R, side: R::Side) -> i32 {
131 self.weights.score(&(self.counts)(start, end, side))
132 }
133}
134
135/// One kind of player: how it thinks, what it cares about, and the
136/// game's own extras `X` (a game mode, a difficulty; `()` for none).
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(
139 deny_unknown_fields,
140 bound(
141 serialize = "X: Serialize",
142 deserialize = "X: Default + Deserialize<'de>"
143 )
144)]
145pub struct Persona<X> {
146 /// How its planner thinks.
147 pub settings: PlanSettings,
148 /// What it cares about each feature, by the game's names. A feature
149 /// left out weighs 0.
150 #[serde(default)]
151 pub weights: BTreeMap<String, i32>,
152 /// The game's own extras; their default when left out.
153 #[serde(default)]
154 pub game: X,
155}
156
157impl<X> Persona<X> {
158 /// Its weights over the game's feature `names`.
159 ///
160 /// # Errors
161 ///
162 /// [`UnknownFeature`] when a weight names a feature not in `names`.
163 pub fn weights<const N: usize>(&self, names: &[&str; N]) -> Result<Weights<N>, UnknownFeature> {
164 Weights::from_named(names, &self.weights)
165 }
166}
167
168#[cfg(test)]
169mod tests;