Skip to main content

solverforge_bridge/
score.rs

1//! Dynamic score support for binding models.
2
3use std::cell::Cell;
4use std::cmp::Ordering;
5use std::fmt;
6use std::ops::{Add, Neg, Sub};
7
8use solverforge_core::score::{ParseableScore, Score, ScoreLevel, ScoreParseError};
9
10/// Declared host-language score family.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
12pub enum DynamicScoreFamily {
13    Soft,
14    HardSoft,
15    HardSoftDecimal,
16    #[default]
17    HardMediumSoft,
18}
19
20thread_local! {
21    static ACTIVE_SCORE_FAMILY: Cell<DynamicScoreFamily> =
22        const { Cell::new(DynamicScoreFamily::HardMediumSoft) };
23}
24
25/// Runs `callback` with a thread-local dynamic score family.
26///
27/// This keeps the internal static three-level score contract available to the
28/// solver while allowing binding-driven solves to present zero and derived
29/// scores with the host model's declared score family.
30pub fn scoped_dynamic_score_family<T>(
31    family: DynamicScoreFamily,
32    callback: impl FnOnce() -> T,
33) -> T {
34    ACTIVE_SCORE_FAMILY.with(|active| {
35        let _guard = ActiveScoreFamilyGuard {
36            previous: active.replace(family),
37            active,
38        };
39        callback()
40    })
41}
42
43fn active_score_family() -> DynamicScoreFamily {
44    ACTIVE_SCORE_FAMILY.with(Cell::get)
45}
46
47struct ActiveScoreFamilyGuard<'a> {
48    previous: DynamicScoreFamily,
49    active: &'a Cell<DynamicScoreFamily>,
50}
51
52impl Drop for ActiveScoreFamilyGuard<'_> {
53    fn drop(&mut self) {
54        self.active.set(self.previous);
55    }
56}
57
58/// Dynamic score used by host-language bindings.
59///
60/// The static `Score` trait requires a static level count. The bridge therefore
61/// stores one concrete three-level representation internally and carries the
62/// declared host-language family for presentation and host-boundary conversion.
63/// Binding crates must validate that a solve uses one declared family
64/// consistently.
65#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
66pub struct DynamicScore {
67    pub hard: i64,
68    pub medium: i64,
69    pub soft: i64,
70    pub family: DynamicScoreFamily,
71}
72
73impl DynamicScore {
74    pub const ZERO: Self = Self {
75        hard: 0,
76        medium: 0,
77        soft: 0,
78        family: DynamicScoreFamily::HardMediumSoft,
79    };
80
81    pub const fn of(hard: i64, medium: i64, soft: i64) -> Self {
82        Self {
83            hard,
84            medium,
85            soft,
86            family: DynamicScoreFamily::HardMediumSoft,
87        }
88    }
89
90    pub const fn with_family(
91        hard: i64,
92        medium: i64,
93        soft: i64,
94        family: DynamicScoreFamily,
95    ) -> Self {
96        Self {
97            hard,
98            medium,
99            soft,
100            family,
101        }
102    }
103
104    pub const fn soft(soft: i64) -> Self {
105        Self::with_family(0, 0, soft, DynamicScoreFamily::Soft)
106    }
107
108    pub const fn hard_soft(hard: i64, soft: i64) -> Self {
109        Self::with_family(hard, 0, soft, DynamicScoreFamily::HardSoft)
110    }
111
112    pub const fn hard_soft_decimal(hard_scaled: i64, soft_scaled: i64) -> Self {
113        Self::with_family(
114            hard_scaled,
115            0,
116            soft_scaled,
117            DynamicScoreFamily::HardSoftDecimal,
118        )
119    }
120
121    pub const fn hard_medium_soft(hard: i64, medium: i64, soft: i64) -> Self {
122        Self::with_family(hard, medium, soft, DynamicScoreFamily::HardMediumSoft)
123    }
124
125    pub const fn zero_for_family(family: DynamicScoreFamily) -> Self {
126        Self::with_family(0, 0, 0, family)
127    }
128
129    pub fn family_levels(self, family: DynamicScoreFamily) -> Vec<i64> {
130        match family {
131            DynamicScoreFamily::Soft => vec![self.soft],
132            DynamicScoreFamily::HardSoft | DynamicScoreFamily::HardSoftDecimal => {
133                vec![self.hard, self.soft]
134            }
135            DynamicScoreFamily::HardMediumSoft => vec![self.hard, self.medium, self.soft],
136        }
137    }
138
139    fn is_zero(self) -> bool {
140        self.hard == 0 && self.medium == 0 && self.soft == 0
141    }
142
143    fn combined_family(self, rhs: Self) -> DynamicScoreFamily {
144        if self.family == rhs.family {
145            return self.family;
146        }
147        if self.is_zero() {
148            return rhs.family;
149        }
150        if rhs.is_zero() {
151            return self.family;
152        }
153        DynamicScoreFamily::HardMediumSoft
154    }
155}
156
157impl fmt::Debug for DynamicScore {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(
160            f,
161            "DynamicScore({}, {}, {}, {:?})",
162            self.hard, self.medium, self.soft, self.family
163        )
164    }
165}
166
167impl fmt::Display for DynamicScore {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self.family {
170            DynamicScoreFamily::Soft => write!(f, "{}", self.soft),
171            DynamicScoreFamily::HardSoft => write!(f, "{}hard/{}soft", self.hard, self.soft),
172            DynamicScoreFamily::HardSoftDecimal => write!(
173                f,
174                "{}hard/{}soft",
175                format_decimal_score_part(self.hard),
176                format_decimal_score_part(self.soft)
177            ),
178            DynamicScoreFamily::HardMediumSoft => write!(
179                f,
180                "{}hard/{}medium/{}soft",
181                self.hard, self.medium, self.soft
182            ),
183        }
184    }
185}
186
187impl Ord for DynamicScore {
188    fn cmp(&self, other: &Self) -> Ordering {
189        (self.hard, self.medium, self.soft).cmp(&(other.hard, other.medium, other.soft))
190    }
191}
192
193impl PartialOrd for DynamicScore {
194    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
195        Some(self.cmp(other))
196    }
197}
198
199impl Add for DynamicScore {
200    type Output = Self;
201
202    fn add(self, rhs: Self) -> Self::Output {
203        Self::with_family(
204            self.hard + rhs.hard,
205            self.medium + rhs.medium,
206            self.soft + rhs.soft,
207            self.combined_family(rhs),
208        )
209    }
210}
211
212impl Sub for DynamicScore {
213    type Output = Self;
214
215    fn sub(self, rhs: Self) -> Self::Output {
216        Self::with_family(
217            self.hard - rhs.hard,
218            self.medium - rhs.medium,
219            self.soft - rhs.soft,
220            self.combined_family(rhs),
221        )
222    }
223}
224
225impl Neg for DynamicScore {
226    type Output = Self;
227
228    fn neg(self) -> Self::Output {
229        Self::with_family(-self.hard, -self.medium, -self.soft, self.family)
230    }
231}
232
233impl Score for DynamicScore {
234    fn is_feasible(&self) -> bool {
235        self.hard >= 0
236    }
237
238    fn zero() -> Self {
239        Self::zero_for_family(active_score_family())
240    }
241
242    fn levels_count() -> usize {
243        3
244    }
245
246    fn level_number(&self, index: usize) -> i64 {
247        match index {
248            0 => self.hard,
249            1 => self.medium,
250            2 => self.soft,
251            _ => panic!("DynamicScore has 3 levels, got index {index}"),
252        }
253    }
254
255    fn from_level_numbers(levels: &[i64]) -> Self {
256        match (active_score_family(), levels) {
257            (DynamicScoreFamily::Soft, [soft]) => Self::soft(*soft),
258            (DynamicScoreFamily::Soft, [_, _, soft]) => Self::soft(*soft),
259            (DynamicScoreFamily::HardSoft, [hard, soft]) => Self::hard_soft(*hard, *soft),
260            (DynamicScoreFamily::HardSoft, [hard, _, soft]) => Self::hard_soft(*hard, *soft),
261            (DynamicScoreFamily::HardSoftDecimal, [hard, soft]) => {
262                Self::hard_soft_decimal(*hard, *soft)
263            }
264            (DynamicScoreFamily::HardSoftDecimal, [hard, _, soft]) => {
265                Self::hard_soft_decimal(*hard, *soft)
266            }
267            (DynamicScoreFamily::HardMediumSoft, [soft]) => Self::soft(*soft),
268            (DynamicScoreFamily::HardMediumSoft, [hard, soft]) => Self::hard_soft(*hard, *soft),
269            (DynamicScoreFamily::HardMediumSoft, [hard, medium, soft]) => {
270                Self::hard_medium_soft(*hard, *medium, *soft)
271            }
272            _ => panic!("DynamicScore requires 1, 2, or 3 levels"),
273        }
274    }
275
276    fn multiply(&self, multiplicand: f64) -> Self {
277        Self::with_family(
278            (self.hard as f64 * multiplicand).round() as i64,
279            (self.medium as f64 * multiplicand).round() as i64,
280            (self.soft as f64 * multiplicand).round() as i64,
281            self.family,
282        )
283    }
284
285    fn divide(&self, divisor: f64) -> Self {
286        self.multiply(1.0 / divisor)
287    }
288
289    fn abs(&self) -> Self {
290        Self::with_family(
291            self.hard.abs(),
292            self.medium.abs(),
293            self.soft.abs(),
294            self.family,
295        )
296    }
297
298    fn to_scalar(&self) -> f64 {
299        self.hard as f64 * 1_000_000.0 + self.medium as f64 * 1_000.0 + self.soft as f64
300    }
301
302    fn level_label(index: usize) -> ScoreLevel {
303        match index {
304            0 => ScoreLevel::Hard,
305            1 => ScoreLevel::Medium,
306            2 => ScoreLevel::Soft,
307            _ => panic!("DynamicScore has 3 levels, got index {index}"),
308        }
309    }
310}
311
312impl ParseableScore for DynamicScore {
313    fn parse(s: &str) -> Result<Self, ScoreParseError> {
314        let mut hard = 0;
315        let mut medium = 0;
316        let mut soft = 0;
317        let mut has_hard = false;
318        let mut has_medium = false;
319        let mut has_soft = false;
320        let mut hard_is_decimal = false;
321        let mut soft_is_decimal = false;
322        let mut is_decimal = false;
323        for part in s.split('/') {
324            if let Some(raw) = part.strip_suffix("hard") {
325                has_hard = true;
326                let (value, decimal) = parse_score_part(raw, "hard")?;
327                hard = value;
328                hard_is_decimal = decimal;
329                is_decimal |= decimal;
330            } else if let Some(raw) = part.strip_suffix("medium") {
331                has_medium = true;
332                let (value, decimal) = parse_score_part(raw, "medium")?;
333                medium = value;
334                is_decimal |= decimal;
335            } else if let Some(raw) = part.strip_suffix("soft") {
336                has_soft = true;
337                let (value, decimal) = parse_score_part(raw, "soft")?;
338                soft = value;
339                soft_is_decimal = decimal;
340                is_decimal |= decimal;
341            } else if let Ok(value) = part.parse::<i64>() {
342                soft = value;
343            } else {
344                return Err(ScoreParseError {
345                    message: format!("invalid dynamic score `{s}`"),
346                });
347            }
348        }
349        if has_medium {
350            Ok(Self::hard_medium_soft(hard, medium, soft))
351        } else if has_hard || has_soft {
352            if is_decimal {
353                if has_hard && !hard_is_decimal {
354                    hard *= DECIMAL_SCALE;
355                }
356                if has_soft && !soft_is_decimal {
357                    soft *= DECIMAL_SCALE;
358                }
359                Ok(Self::hard_soft_decimal(hard, soft))
360            } else {
361                Ok(Self::hard_soft(hard, soft))
362            }
363        } else {
364            Ok(Self::soft(soft))
365        }
366    }
367
368    fn to_string_repr(&self) -> String {
369        self.to_string()
370    }
371}
372
373const DECIMAL_SCALE: i64 = 100_000;
374
375fn format_decimal_score_part(scaled: i64) -> String {
376    if scaled % DECIMAL_SCALE == 0 {
377        return (scaled / DECIMAL_SCALE).to_string();
378    }
379    let value = scaled as f64 / DECIMAL_SCALE as f64;
380    format!("{value:.6}")
381        .trim_end_matches('0')
382        .trim_end_matches('.')
383        .to_string()
384}
385
386fn parse_score_part(raw: &str, label: &str) -> Result<(i64, bool), ScoreParseError> {
387    if raw.contains('.') {
388        let value = raw.parse::<f64>().map_err(|_| ScoreParseError {
389            message: format!("invalid {label} score `{raw}`"),
390        })?;
391        return Ok(((value * DECIMAL_SCALE as f64).round() as i64, true));
392    }
393    raw.parse::<i64>()
394        .map(|value| (value, false))
395        .map_err(|_| ScoreParseError {
396            message: format!("invalid {label} score `{raw}`"),
397        })
398}