Skip to main content

solverforge_bridge/
score.rs

1//! Dynamic score support for binding models.
2
3use std::cell::Cell;
4use std::fmt;
5use std::ops::{Add, Neg, Sub};
6
7use solverforge_core::score::{ParseableScore, Score, ScoreLevel, ScoreParseError};
8
9/// Declared host-language score family.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
11pub enum DynamicScoreFamily {
12    Soft,
13    HardSoft,
14    HardSoftDecimal,
15    #[default]
16    HardMediumSoft,
17}
18
19thread_local! {
20    static ACTIVE_SCORE_FAMILY: Cell<DynamicScoreFamily> =
21        const { Cell::new(DynamicScoreFamily::HardMediumSoft) };
22}
23
24/// Runs `callback` with a thread-local dynamic score family.
25///
26/// The family decides only how scores are presented and parsed: `Display`
27/// renders the family's levels and `ParseableScore::parse` reads the family's
28/// units. Score values and arithmetic do not depend on it, so scoring on any
29/// thread, including parallel evaluation's worker threads, needs no scope.
30/// Bindings enter the declared family around the solve so its output and
31/// configured score limits use the host model's family.
32pub fn scoped_dynamic_score_family<T>(
33    family: DynamicScoreFamily,
34    callback: impl FnOnce() -> T,
35) -> T {
36    ACTIVE_SCORE_FAMILY.with(|active| {
37        let _guard = ActiveScoreFamilyGuard {
38            previous: active.replace(family),
39            active,
40        };
41        callback()
42    })
43}
44
45/// The family the calling thread presents and parses scores with.
46pub fn active_dynamic_score_family() -> DynamicScoreFamily {
47    ACTIVE_SCORE_FAMILY.with(Cell::get)
48}
49
50struct ActiveScoreFamilyGuard<'a> {
51    previous: DynamicScoreFamily,
52    active: &'a Cell<DynamicScoreFamily>,
53}
54
55impl Drop for ActiveScoreFamilyGuard<'_> {
56    fn drop(&mut self) {
57        self.active.set(self.previous);
58    }
59}
60
61/// Dynamic score used by host-language bindings.
62///
63/// The static `Score` trait requires a static level count, so every family
64/// shares one three-level value: unused levels stay zero, and
65/// `HardSoftDecimal` levels hold values scaled by 100 000. Equality, ordering,
66/// and hashing compare the levels only. The declared family is a property of
67/// the solve, not of the value; see [`scoped_dynamic_score_family`].
68#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct DynamicScore {
70    pub hard: i64,
71    pub medium: i64,
72    pub soft: i64,
73}
74
75impl DynamicScore {
76    pub const ZERO: Self = Self::of(0, 0, 0);
77
78    pub const fn of(hard: i64, medium: i64, soft: i64) -> Self {
79        Self { hard, medium, soft }
80    }
81
82    pub const fn soft(soft: i64) -> Self {
83        Self::of(0, 0, soft)
84    }
85
86    pub const fn hard_soft(hard: i64, soft: i64) -> Self {
87        Self::of(hard, 0, soft)
88    }
89
90    pub fn family_levels(self, family: DynamicScoreFamily) -> Vec<i64> {
91        match family {
92            DynamicScoreFamily::Soft => vec![self.soft],
93            DynamicScoreFamily::HardSoft | DynamicScoreFamily::HardSoftDecimal => {
94                vec![self.hard, self.soft]
95            }
96            DynamicScoreFamily::HardMediumSoft => vec![self.hard, self.medium, self.soft],
97        }
98    }
99}
100
101impl fmt::Debug for DynamicScore {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(
104            f,
105            "DynamicScore({}, {}, {})",
106            self.hard, self.medium, self.soft
107        )
108    }
109}
110
111impl fmt::Display for DynamicScore {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match active_dynamic_score_family() {
114            DynamicScoreFamily::Soft => write!(f, "{}", self.soft),
115            DynamicScoreFamily::HardSoft => write!(f, "{}hard/{}soft", self.hard, self.soft),
116            DynamicScoreFamily::HardSoftDecimal => write!(
117                f,
118                "{}hard/{}soft",
119                format_decimal_score_part(self.hard),
120                format_decimal_score_part(self.soft)
121            ),
122            DynamicScoreFamily::HardMediumSoft => write!(
123                f,
124                "{}hard/{}medium/{}soft",
125                self.hard, self.medium, self.soft
126            ),
127        }
128    }
129}
130
131impl Add for DynamicScore {
132    type Output = Self;
133
134    fn add(self, rhs: Self) -> Self::Output {
135        Self::of(
136            self.hard + rhs.hard,
137            self.medium + rhs.medium,
138            self.soft + rhs.soft,
139        )
140    }
141}
142
143impl Sub for DynamicScore {
144    type Output = Self;
145
146    fn sub(self, rhs: Self) -> Self::Output {
147        Self::of(
148            self.hard - rhs.hard,
149            self.medium - rhs.medium,
150            self.soft - rhs.soft,
151        )
152    }
153}
154
155impl Neg for DynamicScore {
156    type Output = Self;
157
158    fn neg(self) -> Self::Output {
159        Self::of(-self.hard, -self.medium, -self.soft)
160    }
161}
162
163impl Score for DynamicScore {
164    fn is_feasible(&self) -> bool {
165        self.hard >= 0
166    }
167
168    fn zero() -> Self {
169        Self::ZERO
170    }
171
172    fn levels_count() -> usize {
173        3
174    }
175
176    fn level_number(&self, index: usize) -> i64 {
177        match index {
178            0 => self.hard,
179            1 => self.medium,
180            2 => self.soft,
181            _ => panic!("DynamicScore has 3 levels, got index {index}"),
182        }
183    }
184
185    fn from_level_numbers(levels: &[i64]) -> Self {
186        match levels {
187            [soft] => Self::soft(*soft),
188            [hard, soft] => Self::hard_soft(*hard, *soft),
189            [hard, medium, soft] => Self::of(*hard, *medium, *soft),
190            _ => panic!("DynamicScore requires 1, 2, or 3 levels"),
191        }
192    }
193
194    fn multiply(&self, multiplicand: f64) -> Self {
195        Self::of(
196            (self.hard as f64 * multiplicand).round() as i64,
197            (self.medium as f64 * multiplicand).round() as i64,
198            (self.soft as f64 * multiplicand).round() as i64,
199        )
200    }
201
202    fn divide(&self, divisor: f64) -> Self {
203        self.multiply(1.0 / divisor)
204    }
205
206    fn abs(&self) -> Self {
207        Self::of(self.hard.abs(), self.medium.abs(), self.soft.abs())
208    }
209
210    fn to_scalar(&self) -> f64 {
211        self.hard as f64 * 1_000_000.0 + self.medium as f64 * 1_000.0 + self.soft as f64
212    }
213
214    fn level_label(index: usize) -> ScoreLevel {
215        match index {
216            0 => ScoreLevel::Hard,
217            1 => ScoreLevel::Medium,
218            2 => ScoreLevel::Soft,
219            _ => panic!("DynamicScore has 3 levels, got index {index}"),
220        }
221    }
222}
223
224impl ParseableScore for DynamicScore {
225    /// Parses a score in the active family's units. A decimal literal
226    /// (`1.5soft`) is always scaled; whole-number parts are scaled too when
227    /// the active family is `HardSoftDecimal`, so parsing inverts `Display`.
228    fn parse(s: &str) -> Result<Self, ScoreParseError> {
229        let decimal_family = active_dynamic_score_family() == DynamicScoreFamily::HardSoftDecimal;
230        let mut hard = (0, false);
231        let mut medium = None;
232        let mut soft = (0, false);
233        let mut has_decimal_literal = false;
234        for part in s.split('/') {
235            if let Some(raw) = part.strip_suffix("hard") {
236                hard = parse_score_part(raw, "hard")?;
237                has_decimal_literal |= hard.1;
238            } else if let Some(raw) = part.strip_suffix("medium") {
239                let (value, decimal) = parse_score_part(raw, "medium")?;
240                medium = Some(value);
241                has_decimal_literal |= decimal;
242            } else if let Some(raw) = part.strip_suffix("soft") {
243                soft = parse_score_part(raw, "soft")?;
244                has_decimal_literal |= soft.1;
245            } else if let Ok(value) = part.parse::<i64>() {
246                soft = (value, false);
247            } else {
248                return Err(ScoreParseError {
249                    message: format!("invalid dynamic score `{s}`"),
250                });
251            }
252        }
253        if let Some(medium) = medium {
254            return Ok(Self::of(hard.0, medium, soft.0));
255        }
256        let scale = |(value, decimal): (i64, bool)| {
257            if (decimal_family || has_decimal_literal) && !decimal {
258                value * DECIMAL_SCALE
259            } else {
260                value
261            }
262        };
263        Ok(Self::hard_soft(scale(hard), scale(soft)))
264    }
265
266    fn to_string_repr(&self) -> String {
267        self.to_string()
268    }
269}
270
271const DECIMAL_SCALE: i64 = 100_000;
272
273fn format_decimal_score_part(scaled: i64) -> String {
274    if scaled % DECIMAL_SCALE == 0 {
275        return (scaled / DECIMAL_SCALE).to_string();
276    }
277    let value = scaled as f64 / DECIMAL_SCALE as f64;
278    format!("{value:.6}")
279        .trim_end_matches('0')
280        .trim_end_matches('.')
281        .to_string()
282}
283
284fn parse_score_part(raw: &str, label: &str) -> Result<(i64, bool), ScoreParseError> {
285    if raw.contains('.') {
286        let value = raw.parse::<f64>().map_err(|_| ScoreParseError {
287            message: format!("invalid {label} score `{raw}`"),
288        })?;
289        return Ok(((value * DECIMAL_SCALE as f64).round() as i64, true));
290    }
291    raw.parse::<i64>()
292        .map(|value| (value, false))
293        .map_err(|_| ScoreParseError {
294            message: format!("invalid {label} score `{raw}`"),
295        })
296}