domino_lib/classify/mod.rs
1use crate::{utils::get_n, DominoError, Puzzle};
2pub use complexity_class::ComplexityClass;
3
4mod complexity_class;
5
6const NUMBER_OF_CLASSES: usize = 3;
7
8/// Classifies the given puzzle and returns its complexity as a `ComplexityClass`.
9///
10/// This function retrieves the puzzle's dimension, calculates a derived length `l` based on whether the dimension
11/// is even or odd, and determines if the puzzle is planar (n ≤ 3). Depending on planarity, it computes a maximum
12/// allowed hole length (`max_hole`). Holes in the puzzle are then detected, and if none are found, a
13/// `DominoError::InvalidLength` is returned. If the entire puzzle consists of empty tiles, a `DominoError::EmptyPuzzle`
14/// is thrown. Otherwise, the puzzle's complexity ComplexityClass is computed.
15///
16/// # Arguments
17///
18/// * `puzzle` - A reference to the puzzle, represented as a `Vec<Option<Tile>>`.
19///
20/// # Returns
21///
22/// * `Ok(ComplexityClass)` containing the computed ComplexityClass, or
23/// * `Err(DominoError)` if an error occurs (for example, if no holes are detected, the puzzle is empty, or if `get_n(puzzle)` fails).
24pub fn classify_puzzle(puzzle: &Puzzle) -> Result<ComplexityClass, DominoError> {
25 // Check if the puzzle consists entirely of empty tiles
26 if puzzle.iter().all(|tile| tile.is_none()) {
27 return Err(DominoError::EmptyPuzzle); // Throw error if all tiles are empty
28 }
29
30 // Retrieve the dimension of the puzzle (n) and propagate errors if any.
31 let n: usize = get_n(puzzle)? as usize;
32
33 // Calculate a derived length `l` based on the puzzle dimension.
34 // For even n: l = (n + 1) * (n + 2) / 2; for odd n: l = (n + 1) * (n + 1) / 2.
35 let l: usize = if n % 2 == 0 {
36 (n + 1) * (n + 2) / 2
37 } else {
38 (n + 1) * (n + 1) / 2
39 };
40
41 // Determine if the puzzle is planar (planar if n <= 3).
42 let is_planar = n <= 3;
43
44 // Compute the maximum allowed hole length in a generic puzzle leaving it valid.
45 // If planar, subtract floor(n/2) from l; otherwise, subtract (n + 1) from l.
46 let max_hole = if is_planar {
47 l as f32 - (n as f32 / 2.0).floor()
48 } else {
49 l as f32 - (n as f32 + 1.0)
50 };
51
52 // Detect holes within the puzzle. Each hole is represented as a tuple (start_index, end_index).
53 let holes: Vec<(usize, usize)> = detect_holes(puzzle);
54
55 // Return an error if no holes are detected.
56 if holes.len() == 0 {
57 return Err(DominoError::InvalidLength);
58 }
59
60 // Compute and return the complexity ComplexityClass based on the detected holes and derived metrics.
61 let class = compute_complexity(holes, max_hole, l, is_planar, n);
62 class
63}
64
65/// Returns the range of the number of tiles to remove in a puzzle
66/// to match the specified complexity class, based on the classification system and puzzle size `n`.
67///
68/// # Arguments
69///
70/// * `class` - A `ComplexityClass` representing the puzzle's difficulty level.
71/// * `n` - The puzzle's dimension.
72///
73/// # Returns
74///
75/// A tuple `(usize, usize)`, where:
76/// - The first value is the minimum number of tiles to remove (always ≥ 1).
77/// - The second value is the maximum number of tiles to remove.
78///
79/// # Panics
80///
81/// This function panics if an invalid `ComplexityClass` is provided.
82pub fn tiles_to_remove_range(class: ComplexityClass, n: usize) -> (usize, usize) {
83 // Compute the derived length `l` based on the puzzle's dimension `n`
84 let l = if n % 2 == 0 {
85 (n + 1) * (n + 2) / 2
86 } else {
87 (n + 1) * (n + 1) / 2
88 } as f32;
89
90 // Determine whether the puzzle is planar (n <= 3)
91 let is_planar = n <= 3;
92
93 // Compute the maximum allowed hole size.
94 let max_hole = if is_planar {
95 l - (n as f32 / 2.0).floor()
96 } else {
97 l - (n as f32 + 1.0)
98 };
99
100 // The forward classification formula is:
101 // class = floor(2 * relative_complexity + 1)
102 // For a given class `c`, valid relative_complexity values lie in:
103 // [ (c - 1)/2, c/2 )
104 let lower_relative = (class.0 as f32 - 1.0) / 2.0;
105 let upper_relative = class.0 as f32 / 2.0;
106
107 // Invert the formula to compute the number of tiles to remove:
108 // t = relative_complexity * max_hole
109 // so t must lie in:
110 // [ lower_relative * max_hole, upper_relative * max_hole )
111 let lower_bound_float = lower_relative * max_hole;
112 let upper_bound_float = upper_relative * max_hole;
113
114 // The minimum valid number of tiles is the ceiling of the lower bound.
115 // However, we ensure it is at least 1 (since a valid puzzle must have at least one removed tile).
116 let min_tiles = std::cmp::max(1, lower_bound_float.ceil() as usize);
117
118 // The maximum valid number of tiles is the largest integer strictly less than the upper bound.
119 let max_tiles = if upper_bound_float.fract() == 0.0 {
120 (upper_bound_float as usize).saturating_sub(1)
121 } else {
122 upper_bound_float.floor() as usize
123 };
124
125 // Ensure that the maximum is not below the minimum.
126 let max_tiles = if max_tiles < min_tiles {
127 min_tiles
128 } else {
129 max_tiles
130 };
131
132 (min_tiles, max_tiles)
133}
134
135/// Computes the overall complexity ComplexityClass of a puzzle.
136///
137/// The complexity is derived by first computing an absolute complexity based on the detected holes,
138/// then normalizing that value to obtain a relative complexity, and finally converting it into an integer
139/// ComplexityClass.
140///
141/// # Arguments
142///
143/// * `holes` - A vector of tuples, each representing the start and end indices of a detected hole.
144/// * `max_hole` - The maximum allowed hole length for normalization purposes.
145/// * `len` - The derived length `l` computed from the puzzle's dimension.
146/// * `is_planar` - A boolean indicating whether the puzzle is planar (n ≤ 3).
147/// * `n` - The puzzle's dimension.
148///
149/// # Returns
150///
151/// A `ComplexityClass` representing the puzzle's complexity.
152fn compute_complexity(
153 holes: Vec<(usize, usize)>,
154 max_hole: f32,
155 len: usize,
156 is_planar: bool,
157 n: usize,
158) -> Result<ComplexityClass, DominoError> {
159 // Calculate the absolute complexity from the detected holes.
160 let absolute_complexity = compute_absolute_complexity(holes.clone(), max_hole);
161
162 // Normalize the absolute complexity to obtain a relative complexity score.
163 let relative_complexity =
164 normalize_complexity(absolute_complexity, len, holes, max_hole, is_planar, n);
165
166 // Convert the relative complexity into an integer ComplexityClass.
167 let class = (relative_complexity * 2.0 + 1.0).floor() as usize;
168 ComplexityClass::new(class)
169}
170
171/// Computes the absolute complexity of a puzzle based on its holes.
172///
173/// This is done by combining a factor that penalizes the total number of holes and a factor
174/// that sums the squared normalized lengths of each hole.
175///
176/// # Arguments
177///
178/// * `holes` - A vector of tuples representing the start and end indices of each hole.
179/// * `max_hole` - The maximum allowed hole length used to normalize each hole's length.
180///
181/// # Returns
182///
183/// The absolute complexity as a floating-point value.
184fn compute_absolute_complexity(holes: Vec<(usize, usize)>, max_hole: f32) -> f32 {
185 // Calculate a factor that decreases as the number of holes increases.
186 let number_of_holes_factor = 1.0 / ((holes.len() as f32).powf(0.1));
187
188 // Sum the squared normalized lengths of each hole.
189 let length_factor = holes
190 .into_iter()
191 .map(|hole| {
192 let hole_length = hole.1.saturating_sub(hole.0) as f32;
193 (hole_length / max_hole).powf(2.0)
194 })
195 .sum::<f32>();
196
197 // The absolute complexity is the product of the two factors.
198 number_of_holes_factor * length_factor
199}
200
201/// Normalizes the absolute complexity to yield a relative complexity score.
202///
203/// The normalization takes into account a base measure derived from the puzzle length and the number of holes,
204/// adjusting for whether the puzzle is planar or not.
205///
206/// # Arguments
207///
208/// * `num` - The absolute complexity value computed from the puzzle's holes.
209/// * `len` - The derived length `l` from the puzzle's dimension.
210/// * `holes` - A vector of tuples representing the detected holes.
211/// * `max_hole` - The maximum allowed hole length used for normalization.
212/// * `is_planar` - A boolean indicating whether the puzzle is planar (n ≤ 3).
213/// * `n` - The puzzle's dimension.
214///
215/// # Returns
216///
217/// A normalized (relative) complexity as a floating-point value.
218fn normalize_complexity(
219 num: f32,
220 len: usize,
221 holes: Vec<(usize, usize)>,
222 max_hole: f32,
223 is_planar: bool,
224 n: usize,
225) -> f32 {
226 // Calculate a base measure 's' that depends on planarity.
227 // For planar puzzles, s = len - (number of holes).
228 // For non-planar puzzles, s = len - (n + 2).
229 let s = if is_planar {
230 len - holes.len()
231 } else {
232 len - (n + 2)
233 };
234
235 // Determine the number of complete `max_hole` segments that fit into s.
236 let n0 = (s as f32 / max_hole).floor();
237
238 // Calculate the remainder after accounting for the complete segments.
239 let r = s as f32 - (n0 * max_hole);
240
241 // Compute two candidate normalization factors:
242 // 1. Based on the squared ratio of the remainder to max_hole.
243 // 2. Based solely on the number of complete segments (n0).
244 let max = f32::max(
245 (n0 + (r / max_hole).powf(2.0)) / (n0 + 1.0).powf(0.1),
246 n0.powf(0.9),
247 );
248
249 // Normalize the absolute complexity by the maximum candidate factor.
250 num / max
251}
252
253/// Detects holes in the given puzzle and returns their index ranges.
254///
255/// A "hole" is defined as a contiguous sequence of missing tiles (`None` values)
256/// whose boundaries are determined by the presence of a tile (`Some`) on either side.
257/// This function treats the puzzle as cyclic; that is, the neighbor of the first element
258/// is the last element, and the neighbor of the last element is the first element.
259///
260/// # Arguments
261///
262/// * `puzzle` - A reference to the puzzle represented as a `Vec<Option<Tile>>`, where `Tile` is a tuple struct.
263///
264/// # Returns
265///
266/// A vector of tuples `(usize, usize)` where each tuple represents a detected hole:
267/// - The first element is the starting index (inclusive) of the hole.
268/// - The second element is the index immediately after the last missing tile (exclusive).
269pub fn detect_holes(puzzle: &Puzzle) -> Vec<(usize, usize)> {
270 let len = puzzle.len();
271 let mut holes = Vec::new();
272 let mut maybe_start: Option<usize> = None;
273
274 for i in 0..len {
275 if puzzle[i].is_none() {
276 // If we haven't marked the start of a hole yet, do so now.
277 if maybe_start.is_none() {
278 maybe_start = Some(i);
279 }
280 } else if let Some(start) = maybe_start.take() {
281 // If we reach a present tile and had a started hole, store the hole range.
282 holes.push((start, i));
283 }
284 }
285
286 // Handle wrap-around case where the hole extends to the end and connects to the beginning.
287 if let Some(start) = maybe_start {
288 if !holes.is_empty() && holes[0].0 == 0 {
289 // Merge wrap-around hole with the first detected hole.
290 holes[0] = (start, holes[0].1);
291 } else {
292 // Otherwise, add the hole separately.
293 holes.push((start, len));
294 }
295 }
296
297 holes
298}
299
300#[cfg(test)]
301mod tests {
302 use super::classify_puzzle;
303 use crate::{Puzzle, Tile};
304
305 #[test]
306 fn test_empty_puzzle_should_return_error() {
307 // Empty puzzle should be classified as error
308 let puzzle: Vec<Option<Tile>> = vec![];
309 let complexity = super::classify_puzzle(&puzzle);
310 assert!(complexity.is_err());
311 }
312
313 #[test]
314 fn test_puzzle_with_invalid_length_should_return_error() {
315 // Puzzle with invalid length should be classified as error
316 let puzzle: Vec<Option<Tile>> = vec![None, None, None, None, None, None, None];
317 let complexity = super::classify_puzzle(&puzzle);
318 assert!(complexity.is_err());
319 }
320
321 #[test]
322 fn test_puzzle_with_all_none_tiles_should_return_error() {
323 // Puzzle with only all None tiles should be classified as error
324 let puzzle: Vec<Option<Tile>> = vec![None, None, None, None, None, None, None, None];
325 let complexity = super::classify_puzzle(&puzzle);
326 assert!(complexity.is_err());
327 }
328
329 #[test]
330 fn test_puzzle_with_all_some_tiles_should_return_error() {
331 // Puzzle with only all Some tiles should be classified as error
332 let puzzle: Vec<Option<Tile>> = vec![
333 Some(Tile(0, 1)),
334 Some(Tile(1, 1)),
335 Some(Tile(1, 2)),
336 Some(Tile(2, 2)),
337 Some(Tile(2, 3)),
338 Some(Tile(3, 3)),
339 Some(Tile(3, 0)),
340 Some(Tile(0, 0)),
341 ];
342 let complexity = super::classify_puzzle(&puzzle);
343 assert!(complexity.is_err());
344 }
345
346 /// Computes the expected puzzle length for a given puzzle dimension `n`
347 /// using the same formulas as in `classify_puzzle`.
348 fn puzzle_length(n: usize) -> usize {
349 if n % 2 == 0 {
350 (n + 1) * (n + 2) / 2
351 } else {
352 (n + 1) * (n + 1) / 2
353 }
354 }
355
356 /// Builds a puzzle (Vec<Option<Tile>>) for a given puzzle dimension `n`
357 /// and inserts a single contiguous hole of length `hole_length` starting
358 /// at index `hole_start` (wrap-around is supported).
359 fn build_puzzle_with_hole(n: usize, hole_start: usize, hole_length: usize) -> Puzzle {
360 let len = puzzle_length(n);
361 // Fill with dummy Some(Tile) values.
362 let mut puzzle: Vec<Option<Tile>> =
363 (0..len).map(|i| Some(Tile(i as i32, i as i32))).collect();
364
365 // Insert the hole (set the specified contiguous indices to None).
366 for j in 0..hole_length {
367 let idx = (hole_start + j) % len;
368 puzzle[idx] = None;
369 }
370 puzzle
371 }
372
373 // --- Tests for planar puzzles (n <= 3) ---
374
375 // n = 2 (planar): length = (2+1)*(2+2)/2 = 6, max_hole = 6 - floor(2/2) = 5.
376 mod n2 {
377 use super::*;
378 const N: usize = 2;
379
380 #[test]
381 fn test_classification_class1_n2() {
382 // Minimal hole (length = 1) yields relative complexity = (1/5)² ≈ 0.04.
383 let puzzle = build_puzzle_with_hole(N, 1, 1);
384 let classification = classify_puzzle(&puzzle)
385 .expect("Puzzle with one small hole should classify successfully");
386 assert_eq!(
387 classification.0, 1,
388 "n=2: Minimal hole should be classified as 1"
389 );
390 }
391
392 #[test]
393 fn test_classification_class2_n2() {
394 // Moderate hole (length = 4) gives (4/5)² = 0.64 → floor(2*0.64 + 1) = 2.
395 let puzzle = build_puzzle_with_hole(N, 1, 4);
396 let classification = classify_puzzle(&puzzle)
397 .expect("Puzzle with one moderate hole should classify successfully");
398 assert_eq!(
399 classification.0, 2,
400 "n=2: Moderate hole should be classified as 2"
401 );
402 }
403
404 #[test]
405 fn test_classification_class3_n2() {
406 // Maximum hole (length = 5) gives (5/5)² = 1 → floor(2*1 + 1) = 3.
407 let puzzle = build_puzzle_with_hole(N, 1, 5);
408 let classification = classify_puzzle(&puzzle)
409 .expect("Puzzle with one large hole should classify successfully");
410 assert_eq!(
411 classification.0, 3,
412 "n=2: Maximum hole should be classified as 3"
413 );
414 }
415 }
416
417 // n = 3 (planar): length = (3+1)²/2 = 8, max_hole = 8 - floor(3/2) = 7.
418 mod n3 {
419 use super::*;
420 const N: usize = 3;
421
422 #[test]
423 fn test_classification_class1_n3() {
424 // Minimal hole (length = 1) yields (1/7)² ≈ 0.02.
425 let puzzle = build_puzzle_with_hole(N, 1, 1);
426 let classification = classify_puzzle(&puzzle)
427 .expect("Puzzle with one small hole should classify successfully");
428 assert_eq!(
429 classification.0, 1,
430 "n=3: Minimal hole should be classified as 1"
431 );
432 }
433
434 #[test]
435 fn test_classification_class2_n3() {
436 // Moderate hole (length = 5) yields (5/7)² ≈ 0.51.
437 let puzzle = build_puzzle_with_hole(N, 1, 5);
438 let classification = classify_puzzle(&puzzle)
439 .expect("Puzzle with one moderate hole should classify successfully");
440 assert_eq!(
441 classification.0, 2,
442 "n=3: Moderate hole should be classified as 2"
443 );
444 }
445
446 #[test]
447 fn test_classification_class3_n3() {
448 // Maximum hole (length = 7) yields (7/7)² = 1.
449 let puzzle = build_puzzle_with_hole(N, 1, 7);
450 let classification = classify_puzzle(&puzzle)
451 .expect("Puzzle with one large hole should classify successfully");
452 assert_eq!(
453 classification.0, 3,
454 "n=3: Maximum hole should be classified as 3"
455 );
456 }
457 }
458
459 // --- Tests for non-planar puzzles (n > 3) ---
460
461 // n = 4 (non-planar): length = (4+1)*(4+2)/2 = 15, max_hole = 15 - (4+1) = 10.
462 mod n4 {
463 use super::*;
464 const N: usize = 4;
465
466 #[test]
467 fn test_classification_class1_n4() {
468 // Minimal hole (length = 1) yields a very small relative complexity.
469 let puzzle = build_puzzle_with_hole(N, 2, 1);
470 let classification = classify_puzzle(&puzzle)
471 .expect("Puzzle with one small hole should classify successfully");
472 assert_eq!(
473 classification.0, 1,
474 "n=4: Minimal hole should be classified as 1"
475 );
476 }
477
478 #[test]
479 fn test_classification_class2_n4() {
480 // Moderate hole (length = 8) gives a relative complexity in the range for classification 2.
481 let puzzle = build_puzzle_with_hole(N, 2, 8);
482 let classification = classify_puzzle(&puzzle)
483 .expect("Puzzle with one moderate hole should classify successfully");
484 assert_eq!(
485 classification.0, 2,
486 "n=4: Moderate hole should be classified as 2"
487 );
488 }
489
490 #[test]
491 fn test_classification_class3_n4() {
492 // Maximum hole (length = 10) gives a relative complexity of 1, yielding classification 3.
493 let puzzle = build_puzzle_with_hole(N, 2, 10);
494 let classification = classify_puzzle(&puzzle)
495 .expect("Puzzle with one large hole should classify successfully");
496 assert_eq!(
497 classification.0, 3,
498 "n=4: Maximum hole should be classified as 3"
499 );
500 }
501 }
502
503 // n = 5 (non-planar): length = (5+1)²/2 = 18, max_hole = 18 - (5+1) = 12.
504 mod n5 {
505 use super::*;
506 const N: usize = 5;
507
508 #[test]
509 fn test_classification_class1_n5() {
510 // Minimal hole (length = 1)
511 let puzzle = build_puzzle_with_hole(N, 3, 1);
512 let classification = classify_puzzle(&puzzle)
513 .expect("Puzzle with one small hole should classify successfully");
514 assert_eq!(
515 classification.0, 1,
516 "n=5: Minimal hole should be classified as 1"
517 );
518 }
519
520 #[test]
521 fn test_classification_class2_n5() {
522 // Moderate hole (length = 8) yields a relative complexity just above the threshold.
523 let puzzle = build_puzzle_with_hole(N, 3, 8);
524 let classification = classify_puzzle(&puzzle)
525 .expect("Puzzle with one moderate hole should classify successfully");
526 assert_eq!(
527 classification.0, 2,
528 "n=5: Moderate hole should be classified as 2"
529 );
530 }
531
532 #[test]
533 fn test_classification_class3_n5() {
534 // Maximum hole (length = 12)
535 let puzzle = build_puzzle_with_hole(N, 3, 12);
536 let classification = classify_puzzle(&puzzle)
537 .expect("Puzzle with one large hole should classify successfully");
538 assert_eq!(
539 classification.0, 3,
540 "n=5: Maximum hole should be classified as 3"
541 );
542 }
543 }
544
545 // n = 6 (non-planar): length = (6+1)*(6+2)/2 = 28, max_hole = 28 - (6+1) = 21.
546 mod n6 {
547 use super::*;
548 const N: usize = 6;
549
550 #[test]
551 fn test_classification_class1_n6() {
552 // Minimal hole (length = 1)
553 let puzzle = build_puzzle_with_hole(N, 4, 1);
554 let classification = classify_puzzle(&puzzle)
555 .expect("Puzzle with one small hole should classify successfully");
556 assert_eq!(
557 classification.0, 1,
558 "n=6: Minimal hole should be classified as 1"
559 );
560 }
561
562 #[test]
563 fn test_classification_class2_n6() {
564 // For n=6, choose a moderate hole length.
565 // Calculations indicate that a hole length around 15 gives a relative complexity in the [0.5, 1) range.
566 let puzzle = build_puzzle_with_hole(N, 4, 15);
567 let classification = classify_puzzle(&puzzle)
568 .expect("Puzzle with one moderate hole should classify successfully");
569 assert_eq!(
570 classification.0, 2,
571 "n=6: Moderate hole should be classified as 2"
572 );
573 }
574
575 #[test]
576 fn test_classification_class3_n6() {
577 // Maximum hole (length = 21)
578 let puzzle = build_puzzle_with_hole(N, 4, 21);
579 let classification = classify_puzzle(&puzzle)
580 .expect("Puzzle with one large hole should classify successfully");
581 assert_eq!(
582 classification.0, 3,
583 "n=6: Maximum hole should be classified as 3"
584 );
585 }
586 }
587}