1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum SentinelConfigOperation {
16 Parse,
17 Serialize,
18}
19
20#[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 #[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#[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#[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#[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#[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#[derive(Clone, Debug, Deserialize, Serialize)]
169pub struct TournamentPhaseConfig {
170 pub iterations: u32,
171 pub keep_fraction_x100: u32,
172}
173
174#[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#[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
238pub fn build_sentinel_config(config: &SentinelConfig) -> Result<String, SentinelConfigError> {
244 Ok(toml::to_string_pretty(config)?)
245}
246
247pub 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}