Skip to main content

wowlab_common/sim/
sentinel_config.rs

1//! Sentinel config TOML build/parse.
2
3use serde::{Deserialize, Serialize};
4#[cfg(feature = "wasm")]
5use tsify::Tsify;
6
7const DEFAULT_TARGET_ERROR: f64 = 0.05;
8const DEFAULT_ITERATIONS: i32 = 10_000;
9const DEFAULT_MIN_ITERATIONS: u32 = 100;
10const DEFAULT_MAX_ITERATIONS: u32 = 50_000;
11
12/// Operation being performed when Sentinel configuration handling failed.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum SentinelConfigOperation {
16    Parse,
17    Serialize,
18}
19
20/// Failure to parse or serialize Sentinel configuration TOML.
21#[derive(Debug, thiserror::Error)]
22#[error("{kind}")]
23#[non_exhaustive]
24pub struct SentinelConfigError {
25    #[source]
26    kind: SentinelConfigErrorKind,
27}
28
29#[derive(Debug, thiserror::Error)]
30enum SentinelConfigErrorKind {
31    #[error("TOML parse error: {0}")]
32    Deserialize(#[source] toml::de::Error),
33    #[error("TOML serialization error: {0}")]
34    Serialize(#[source] toml::ser::Error),
35}
36
37impl SentinelConfigError {
38    fn deserialize(source: toml::de::Error) -> Self {
39        Self {
40            kind: SentinelConfigErrorKind::Deserialize(source),
41        }
42    }
43
44    fn serialize(source: toml::ser::Error) -> Self {
45        Self {
46            kind: SentinelConfigErrorKind::Serialize(source),
47        }
48    }
49
50    /// Return the operation that failed.
51    #[must_use]
52    pub const fn operation(&self) -> SentinelConfigOperation {
53        match self.kind {
54            SentinelConfigErrorKind::Deserialize(_) => SentinelConfigOperation::Parse,
55            SentinelConfigErrorKind::Serialize(_) => SentinelConfigOperation::Serialize,
56        }
57    }
58}
59
60impl From<toml::de::Error> for SentinelConfigError {
61    fn from(source: toml::de::Error) -> Self {
62        Self::deserialize(source)
63    }
64}
65
66impl From<toml::ser::Error> for SentinelConfigError {
67    fn from(source: toml::ser::Error) -> Self {
68        Self::serialize(source)
69    }
70}
71
72/// Configuration consumed by the sentinel scheduler, stored as TOML in `jobs.sentinel_config`.
73#[derive(Clone, Debug, Deserialize, Serialize)]
74pub struct SentinelConfig {
75    #[serde(default = "default_strategy")]
76    pub strategy: String,
77    pub iterations: i32,
78    #[serde(default)]
79    pub max_chunks: i32,
80    #[serde(default)]
81    pub priority: i32,
82    #[serde(default = "default_target_error")]
83    pub target_error: f64,
84    #[serde(default = "default_min_iterations")]
85    pub min_iterations: u32,
86    #[serde(default = "default_max_iterations")]
87    pub max_iterations: u32,
88    #[serde(default)]
89    pub target_nodes: Vec<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub tournament: Option<TournamentConfig>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub factorial: Option<FactorialConfig>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub screening: Option<ScreeningConfig>,
96}
97
98/// Factorial-screening phase configuration.
99#[derive(Clone, Debug, Deserialize, Serialize)]
100pub struct FactorialConfig {
101    #[serde(default = "default_main_iters")]
102    pub main_iters: u32,
103    #[serde(default = "default_interaction_iters")]
104    pub interaction_iters: u32,
105    #[serde(default = "default_top_k_per_slot")]
106    pub top_k_per_slot: u32,
107}
108
109impl Default for FactorialConfig {
110    fn default() -> Self {
111        Self {
112            main_iters: default_main_iters(),
113            interaction_iters: default_interaction_iters(),
114            top_k_per_slot: default_top_k_per_slot(),
115        }
116    }
117}
118
119/// Screening phase configuration.
120#[derive(Clone, Debug, Deserialize, Serialize)]
121pub struct ScreeningConfig {
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub keep_pct: Option<f64>,
124    #[serde(default = "default_keep_min")]
125    pub keep_min: u32,
126}
127
128const DEFAULT_MAIN_ITERS: u32 = 2_000;
129const DEFAULT_INTERACTION_ITERS: u32 = 1_500;
130const DEFAULT_TOP_K_PER_SLOT: u32 = 4;
131const DEFAULT_KEEP_MIN: u32 = 100;
132
133const fn default_main_iters() -> u32 {
134    DEFAULT_MAIN_ITERS
135}
136
137const fn default_interaction_iters() -> u32 {
138    DEFAULT_INTERACTION_ITERS
139}
140
141const fn default_top_k_per_slot() -> u32 {
142    DEFAULT_TOP_K_PER_SLOT
143}
144
145const fn default_keep_min() -> u32 {
146    DEFAULT_KEEP_MIN
147}
148
149impl Default for ScreeningConfig {
150    fn default() -> Self {
151        Self {
152            keep_pct: None,
153            keep_min: default_keep_min(),
154        }
155    }
156}
157
158/// Optional tournament-specific settings.
159#[derive(Clone, Debug, Deserialize, Serialize)]
160pub struct TournamentConfig {
161    #[serde(default)]
162    pub phases: Vec<TournamentPhaseConfig>,
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub slot_candidates: Vec<SlotCandidatesConfig>,
165}
166
167/// One elimination phase for the tournament strategy.
168#[derive(Clone, Debug, Deserialize, Serialize)]
169pub struct TournamentPhaseConfig {
170    pub iterations: u32,
171    pub keep_fraction_x100: u32,
172}
173
174/// Candidate items for a single gear slot in a tournament job.
175#[derive(Clone, Debug, Deserialize, Serialize)]
176#[cfg_attr(feature = "wasm", derive(Tsify))]
177pub struct SlotCandidatesConfig {
178    pub slot: String,
179    #[serde(default)]
180    pub items: Vec<CandidateItemConfig>,
181}
182
183/// One candidate item for a slot.
184#[derive(Clone, Debug, Deserialize, Serialize)]
185#[cfg_attr(feature = "wasm", derive(Tsify))]
186pub struct CandidateItemConfig {
187    pub item_id: u32,
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub bonus_ids: Vec<u32>,
190    #[serde(default, skip_serializing_if = "is_zero_u32")]
191    pub enchant_id: u32,
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub gem_ids: Vec<u32>,
194}
195
196#[expect(
197    clippy::trivially_copy_pass_by_ref,
198    reason = "serde skip_serializing_if predicates receive field references"
199)]
200const fn is_zero_u32(v: &u32) -> bool {
201    *v == 0
202}
203
204fn default_strategy() -> String {
205    "single".to_owned()
206}
207
208const fn default_target_error() -> f64 {
209    DEFAULT_TARGET_ERROR
210}
211
212const fn default_min_iterations() -> u32 {
213    DEFAULT_MIN_ITERATIONS
214}
215
216const fn default_max_iterations() -> u32 {
217    DEFAULT_MAX_ITERATIONS
218}
219
220impl Default for SentinelConfig {
221    fn default() -> Self {
222        Self {
223            strategy: default_strategy(),
224            iterations: DEFAULT_ITERATIONS,
225            max_chunks: 0,
226            priority: 0,
227            target_error: default_target_error(),
228            min_iterations: default_min_iterations(),
229            max_iterations: default_max_iterations(),
230            target_nodes: Vec::new(),
231            tournament: None,
232            factorial: None,
233            screening: None,
234        }
235    }
236}
237
238/// Serialize a [`SentinelConfig`] to its canonical pretty-printed TOML form.
239///
240/// # Errors
241///
242/// Returns an error when TOML serialization fails.
243pub fn build_sentinel_config(config: &SentinelConfig) -> Result<String, SentinelConfigError> {
244    Ok(toml::to_string_pretty(config)?)
245}
246
247/// Deserialize a [`SentinelConfig`] from its TOML representation.
248///
249/// # Errors
250///
251/// Returns an error when the TOML does not satisfy the [`SentinelConfig`] schema.
252pub fn parse_sentinel_config(toml_str: &str) -> Result<SentinelConfig, SentinelConfigError> {
253    Ok(toml::from_str(toml_str)?)
254}
255
256#[cfg(test)]
257mod tests {
258    use std::error::Error as _;
259
260    use googletest::prelude::*;
261
262    use super::*;
263
264    #[gtest]
265    fn test_roundtrip() -> Result<()> {
266        let config = SentinelConfig {
267            strategy: "single".into(),
268            iterations: 100_000,
269            max_chunks: 10,
270            priority: 2,
271            target_error: 0.01,
272            min_iterations: 100,
273            max_iterations: 50_000,
274            target_nodes: vec!["node-abc".into()],
275            tournament: None,
276            factorial: None,
277            screening: None,
278        };
279        let toml_str = build_sentinel_config(&config).or_fail()?;
280        let parsed = parse_sentinel_config(&toml_str).or_fail()?;
281
282        verify_that!(
283            parsed,
284            matches_pattern!(SentinelConfig {
285                iterations: eq(&100_000),
286                max_chunks: eq(&10),
287                priority: eq(&2),
288                target_error: near(0.01, f64::EPSILON),
289                target_nodes: elements_are!["node-abc"],
290                tournament: none(),
291                ..
292            })
293        )
294    }
295
296    #[gtest]
297    fn test_defaults() -> Result<()> {
298        let toml_str = "iterations = 50000\n";
299        let parsed = parse_sentinel_config(toml_str).or_fail()?;
300
301        verify_that!(
302            parsed,
303            matches_pattern!(SentinelConfig {
304                iterations: eq(&50_000),
305                max_chunks: eq(&0),
306                priority: eq(&0),
307                target_error: near(0.05, f64::EPSILON),
308                min_iterations: eq(&100),
309                max_iterations: eq(&50_000),
310                target_nodes: is_empty(),
311                tournament: none(),
312                ..
313            })
314        )
315    }
316
317    #[gtest]
318    fn malformed_and_empty_toml_preserve_parse_sources() -> Result<()> {
319        for source in ["[", ""] {
320            let error = parse_sentinel_config(source).err().or_fail()?;
321
322            verify_that!(error.operation(), eq(SentinelConfigOperation::Parse))?;
323            verify_that!(error.to_string(), starts_with("TOML parse error:"))?;
324            verify_true!(
325                error
326                    .source()
327                    .and_then(std::error::Error::source)
328                    .is_some_and(<dyn std::error::Error>::is::<toml::de::Error>)
329            )?;
330        }
331
332        Ok(())
333    }
334
335    #[gtest]
336    fn unicode_values_roundtrip_unchanged() -> Result<()> {
337        let config = SentinelConfig {
338            strategy: "épreuve-世界".to_string(),
339            target_nodes: vec!["nœud-🦊".to_string()],
340            ..SentinelConfig::default()
341        };
342
343        let serialized = build_sentinel_config(&config).or_fail()?;
344        let parsed = parse_sentinel_config(&serialized).or_fail()?;
345
346        verify_that!(parsed.strategy, eq(&config.strategy))?;
347
348        verify_that!(parsed.target_nodes, eq(&config.target_nodes))
349    }
350
351    #[gtest]
352    fn test_tournament_section_roundtrip() -> Result<()> {
353        let config = SentinelConfig {
354            strategy: "tournament".into(),
355            iterations: 250_000,
356            priority: 1,
357            tournament: Some(TournamentConfig {
358                phases: vec![
359                    TournamentPhaseConfig {
360                        iterations: 2_000,
361                        keep_fraction_x100: 25,
362                    },
363                    TournamentPhaseConfig {
364                        iterations: 10_000,
365                        keep_fraction_x100: 0,
366                    },
367                ],
368                slot_candidates: vec![],
369            }),
370            ..SentinelConfig::default()
371        };
372
373        let toml_str = build_sentinel_config(&config).or_fail()?;
374        let parsed = parse_sentinel_config(&toml_str).or_fail()?;
375        let tournament = parsed.tournament.or_fail()?;
376
377        verify_that!(
378            tournament.phases,
379            elements_are![
380                pat!(TournamentPhaseConfig {
381                    iterations: eq(&2_000),
382                    keep_fraction_x100: eq(&25),
383                }),
384                pat!(TournamentPhaseConfig {
385                    iterations: eq(&10_000),
386                    keep_fraction_x100: eq(&0),
387                })
388            ]
389        )
390    }
391
392    #[gtest]
393    fn test_factorial_section_roundtrip() -> Result<()> {
394        let config = SentinelConfig {
395            strategy: "tournament".into(),
396            factorial: Some(FactorialConfig {
397                main_iters: 3_000,
398                interaction_iters: 1_750,
399                top_k_per_slot: 5,
400            }),
401            screening: Some(ScreeningConfig {
402                keep_pct: Some(2.5),
403                keep_min: 250,
404            }),
405            ..SentinelConfig::default()
406        };
407
408        let toml_str = build_sentinel_config(&config).or_fail()?;
409        let parsed = parse_sentinel_config(&toml_str).or_fail()?;
410        let factorial = parsed.factorial.or_fail()?;
411
412        verify_that!(
413            factorial,
414            matches_pattern!(FactorialConfig {
415                main_iters: eq(&3_000),
416                interaction_iters: eq(&1_750),
417                top_k_per_slot: eq(&5),
418            })
419        )?;
420        let screening = parsed.screening.or_fail()?;
421
422        verify_that!(
423            screening,
424            matches_pattern!(ScreeningConfig {
425                keep_pct: some(eq(&2.5)),
426                keep_min: eq(&250),
427            })
428        )
429    }
430
431    #[gtest]
432    fn test_factorial_defaults() -> Result<()> {
433        let fc = FactorialConfig::default();
434
435        verify_that!(
436            fc,
437            matches_pattern!(FactorialConfig {
438                main_iters: eq(&2_000),
439                interaction_iters: eq(&1_500),
440                top_k_per_slot: eq(&4),
441            })
442        )
443    }
444
445    #[gtest]
446    fn test_screening_defaults_match_serde_defaults() -> Result<()> {
447        let default = ScreeningConfig::default();
448        let parsed: ScreeningConfig = toml::from_str("").or_fail()?;
449
450        verify_that!(parsed.keep_pct, eq(default.keep_pct))?;
451        verify_that!(parsed.keep_min, eq(default.keep_min))?;
452
453        verify_that!(default.keep_min, eq(DEFAULT_KEEP_MIN))
454    }
455}