domino_lib/classify/
mod.rs

1use crate::{utils::get_n, DominoError, Puzzle};
2pub use complexity_class::ComplexityClass;
3use formula::{compute_threshold, find_threshold_index};
4
5mod complexity_class;
6mod formula;
7
8pub const NUMBER_OF_CLASSES: usize = 3;
9
10/// Classifies the given puzzle and returns its complexity as a `ComplexityClass`.
11///
12/// This function retrieves the puzzle's dimension, calculates a derived length `l` based on whether the dimension
13/// is even or odd, and determines if the puzzle is planar (n ≤ 3). Depending on planarity, it computes a maximum
14/// allowed hole length (`max_hole`). Holes in the puzzle are then detected, and if none are found, a
15/// `DominoError::InvalidLength` is returned. If the entire puzzle consists of empty tiles, a `DominoError::EmptyPuzzle`
16/// is thrown. Otherwise, the puzzle's complexity ComplexityClass is computed.
17///
18/// # Arguments
19///
20/// * `puzzle` - A reference to the puzzle, represented as a `Vec<Option<Tile>>`.
21///
22/// # Returns
23///
24/// * `Ok(ComplexityClass)` containing the computed ComplexityClass, or
25/// * `Err(DominoError)` if an error occurs (for example, if no holes are detected, the puzzle is empty, or if `get_n(puzzle)` fails).
26pub fn classify_puzzle(puzzle: &Puzzle) -> Result<ComplexityClass, DominoError> {
27    // Check if the puzzle consists entirely of empty tiles
28    if puzzle.0.iter().all(|tile| tile.is_none()) {
29        return Err(DominoError::EmptyPuzzle); // Throw error if all tiles are empty
30    }
31    // println!("puzzle: {puzzle:?}");
32
33    // Retrieve the dimension of the puzzle (n) and propagate errors if any.
34    let n: usize = get_n(puzzle)? as usize;
35
36    // Calculate a derived length `l` based on the puzzle dimension.
37    // For even n: l = (n + 1) * (n + 2) / 2; for odd n: l = (n + 1) * (n + 1) / 2.
38    let l: usize = if n % 2 == 0 {
39        (n + 1) * (n + 2) / 2
40    } else {
41        (n + 1) * (n + 1) / 2
42    };
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: usize = if n >= 4 { n + 1 } else { (n + 1) * 2 - 1 };
47
48    // Detect holes within the puzzle. Each hole is represented as a tuple (start_index, end_index).
49    let holes: Vec<(usize, usize)> = detect_holes(puzzle);
50
51    // Compute and return the complexity ComplexityClass based on the detected holes and derived metrics.
52    let class = compute_complexity(holes, max_hole, l);
53    class
54}
55
56/// Returns the range of the number of tiles to remove in a puzzle
57/// to match the specified complexity class, based on the classification system and puzzle size `n`.
58///
59/// # Arguments
60///
61/// * `class` - A `ComplexityClass` representing the puzzle's difficulty level.
62/// * `n` - The puzzle's dimension.
63///
64/// # Returns
65///
66/// A tuple `(usize, usize)`, where:
67/// - The first value is the minimum number of tiles to remove (always ≥ 1).
68/// - The second value is the maximum number of tiles to remove.
69///
70/// # Panics
71///
72/// This function panics if an invalid `ComplexityClass` is provided.
73#[allow(dead_code)]
74pub fn tiles_to_remove_range(class: ComplexityClass, n: usize) -> (usize, usize) {
75    // Compute the derived length `l` based on the puzzle's dimension `n`
76    let l = if n % 2 == 0 {
77        (n + 1) * (n + 2) / 2
78    } else {
79        (n + 1) * (n + 1) / 2
80    } as f32;
81
82    // Determine whether the puzzle is planar (n <= 3)
83    let is_planar = n <= 3;
84
85    // Compute the maximum allowed hole size.
86    let max_hole = if is_planar {
87        l - (n as f32 / 2.0).floor()
88    } else {
89        l - (n as f32 + 1.0)
90    };
91
92    // Inverted formula to compute min-max values within 0.0-1.0 range
93    // that can generate the current complexity
94    // The class is in [1,NUMBER_OF_CLASSES] so panics only if the ComplexityClass implementation
95    // changes and has no more the check on the values
96    let (lower_relative, upper_relative) =
97        inverse_class_mapping(class).expect("The provided class is invalid");
98    // Invert the formula to compute the number of tiles to remove:
99    //     t = relative_complexity * max_hole
100    // so t must lie in:
101    //     [ lower_relative * max_hole, upper_relative * max_hole )
102    let lower_bound_float = lower_relative * max_hole;
103    let upper_bound_float = upper_relative * max_hole;
104
105    // The minimum valid number of tiles is the flooring of the lower bound.
106    // However, we ensure it is at least 1 (since a valid puzzle must have at least one removed tile).
107    let min_tiles = std::cmp::max(1, lower_bound_float.floor() as usize);
108
109    // The maximum valid number of tiles is the largest integer strictly less than the upper bound.
110    let max_tiles = if upper_bound_float.fract() == 0.0 {
111        (upper_bound_float as usize).saturating_sub(1)
112    } else {
113        upper_bound_float.floor() as usize
114    };
115
116    // Ensure the maximum is not over the maximum number of tiles removable.
117    let mut max_tiles = std::cmp::min(max_tiles, max_hole as usize);
118
119    // If the class is equal to the max NUMBER_OF_CLASSES, ensure the maximum is the maximum number of tiles removable.
120    if class == NUMBER_OF_CLASSES {
121        max_tiles = max_hole as usize;
122    }
123
124    // Ensure that the maximum is not below the minimum.
125    let (min_tiles, max_tiles) = if max_tiles < min_tiles {
126        (max_tiles, min_tiles)
127    } else {
128        (min_tiles, max_tiles)
129    };
130
131    (min_tiles, max_tiles)
132}
133
134/// Returns the min and max decimal values that could have produced a given class.
135///
136/// This function performs an inverse mapping from the computed class (1 to NUMBER_OF_CLASSES)
137/// back to the original `x` range.
138///
139/// # Arguments
140///
141/// * `class` - The computed class value (expected to be in `[1, NUMBER_OF_CLASSES]`)
142///
143/// # Returns
144///
145/// A tuple `(min_x, max_x)` representing the range of decimal values that
146/// could have resulted in the given class, or `None` if the class is out of bounds.
147#[allow(dead_code)]
148fn inverse_class_mapping(class: ComplexityClass) -> Option<(f32, f32)> {
149    if class < 1 || class > NUMBER_OF_CLASSES {
150        return None;
151    }
152
153    let min_x = if class == 1 {
154        0.0
155    } else {
156        compute_threshold(class.0 - 1)
157    };
158    let max_x = compute_threshold(class.0);
159
160    Some((min_x, max_x))
161}
162
163/// Computes the overall complexity ComplexityClass of a puzzle.
164///
165/// The complexity is derived by first computing an absolute complexity based on the detected holes,
166/// then normalizing that value to obtain a relative complexity, and finally converting it into an integer
167/// ComplexityClass.
168///
169/// # Arguments
170///
171/// * `holes` - A vector of tuples, each representing the start and end indices of a detected hole.
172/// * `max_hole` - The maximum allowed hole length for normalization purposes.
173/// * `len` - The derived length `l` computed from the puzzle's dimension.
174/// * `is_planar` - A boolean indicating whether the puzzle is planar (n ≤ 3).
175/// * `n` - The puzzle's dimension.
176///
177/// # Returns
178///
179/// A `ComplexityClass` representing the puzzle's complexity.
180fn compute_complexity(
181    holes: Vec<(usize, usize)>,
182    max_hole: usize,
183    len: usize,
184) -> Result<ComplexityClass, DominoError> {
185    if holes.is_empty() {
186      return ComplexityClass::new(0)
187    }
188
189    // Calculate the absolute complexity from the detected holes
190    // The returned complexity is relative to the hardest case possible (1 hole with the max size).
191    // It may exceed 1.0 if multiple holes are present.
192    let absolute_complexity = compute_absolute_complexity(holes.clone(), max_hole, len);
193
194    // Normalize the absolute complexity to obtain a relative complexity score between 0.0 and 1.0.
195    let relative_complexity = absolute_complexity.clamp(0.0, 1.0);
196
197    // Convert the relative complexity into an integer ComplexityClass.
198    let class = find_threshold_index(relative_complexity);
199    ComplexityClass::new(class)
200}
201
202/// Computes the absolute complexity of a puzzle based on its holes.
203///
204/// This is done by combining a factor that penalizes the total number of holes and a factor
205/// that sums the squared normalized lengths of each hole.
206///
207/// # Arguments
208///
209/// * `holes` - A vector of tuples representing the start and end indices of each hole.
210/// * `max_hole` - The maximum allowed hole length used to normalize each hole's length.
211///
212/// # Returns
213///
214/// The absolute complexity as a floating-point value.
215fn compute_absolute_complexity(holes: Vec<(usize, usize)>, max_hole: usize, len: usize) -> f32 {
216    // Ensures we don't incour into a division by 0, returning 0.0
217    if holes.is_empty() {
218        return 0.0;
219    }
220
221    // Calculate a factor that decreases as the number of holes increases.
222    let number_of_holes_factor = 1.0 / ((holes.len() as f32).powf(0.1));
223
224    // Sum the squared normalized lengths of each hole.
225    let length_factor = holes
226        .clone()
227        .into_iter()
228        .map(|hole| {
229            let mut hole_length: usize = if hole.1 > hole.0 {
230                hole.1.saturating_sub(hole.0)
231            } else {
232                // println!("({len} - {}) + {}", hole.0, hole.1);
233                (len - hole.0) + hole.1
234            };
235            // Avoids single tile holes
236            hole_length = hole_length.saturating_sub(1);
237            // println!("hole: {hole:?}, hole_length: {hole_length}");
238            // println!("number_of_holes_factor: {number_of_holes_factor} hole_lenght: {hole_length} length_factor: {}", (hole_length as f32/ max_hole as f32).powf(2.0));
239            (hole_length as f32 / max_hole as f32).powf(2.0)
240        })
241        .sum::<f32>();
242
243    // The absolute complexity is the product of the two factors.
244    number_of_holes_factor * length_factor
245}
246
247/// Detects holes in the given puzzle and returns their index ranges.
248///
249/// A "hole" is defined as a contiguous sequence of missing tiles (`None` values)
250/// whose boundaries are determined by the presence of a tile (`Some`) on both sides.
251/// This function treats the puzzle as cyclic; that is, the neighbor of the first element
252/// is the last element, and the neighbor of the last element is the first element.
253/// If a hole spans the end and beginning of the puzzle, it is treated as a single
254/// contiguous hole rather than two separate ones.
255/// If the puzzle consists entirely of `None` values, it is considered a single hole.
256///
257/// # Arguments
258///
259/// * `puzzle` - A reference to the puzzle represented as a `Vec<Option<Tile>>`,
260///   where `Tile` is a tuple struct.
261///
262/// # Returns
263///
264/// A vector of tuples `(usize, usize)` where each tuple represents a detected hole:
265/// - The first element is the starting index (inclusive) of the hole.
266/// - The second element is the index immediately after the last missing tile (exclusive).
267pub fn detect_holes(puzzle: &Puzzle) -> Vec<(usize, usize)> {
268    let len = puzzle.0.len();
269    let mut holes = Vec::new();
270    let mut maybe_start: Option<usize> = None;
271    let mut wraps_around = false;
272    let mut has_some = false;
273
274    // Iterate through the puzzle to detect holes.
275    for i in 0..len {
276        if puzzle.0[i].is_none() {
277            // Mark the start of a new hole if not already marked.
278            if maybe_start.is_none() {
279                maybe_start = Some(i);
280            }
281        } else {
282            has_some = true; // Mark that we have at least one Some value.
283            if let Some(start) = maybe_start.take() {
284                // If a hole was being tracked and a tile (`Some`) is found, finalize the hole range.
285                holes.push((start, i));
286            }
287        }
288    }
289
290    // Handle the case where a hole extends to the end of the puzzle.
291    if let Some(start) = maybe_start {
292        if !holes.is_empty() && holes[0].0 == 0 {
293            // If the first hole starts at index 0, it means the hole wraps around.
294            wraps_around = true;
295            holes[0] = (start, holes[0].1);
296        } else {
297            // Otherwise, add the hole as a separate entry.
298            holes.push((start, len));
299        }
300    }
301
302    // Merge the last and first holes if they form a cyclic sequence.
303    if wraps_around && holes.len() > 1 {
304        let first = holes.remove(0); // Remove the first hole (which starts at 0).
305        let last = holes.pop().unwrap(); // Take the last hole.
306        holes.insert(0, (last.0, first.1)); // Merge the two as a single hole.
307    }
308
309    // If the entire puzzle is a hole (no `Some` elements exist), return it as a single hole.
310    if !has_some {
311        return vec![(0, len)];
312    }
313
314    holes
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::{ComplexityClass, DominoError, Puzzle, Tile};
321
322    /// Creates a mock puzzle based on the given parameters.
323    ///
324    /// **Input:**
325    /// - `size`: The number of tiles in the puzzle.
326    /// - `holes`: A vector of indices representing missing tiles.
327    ///
328    /// **Output:**
329    /// - A `Puzzle` with `Some(Tile(0,0))` at non-hole indices and `None` at hole indices.
330    fn mock_puzzle(size: usize, holes: Vec<usize>) -> Puzzle {
331        let mut puzzle = vec![Some(Tile(0, 0)); size];
332        for index in holes {
333            puzzle[index] = None;
334        }
335        puzzle.into()
336    }
337
338    /// Helper function to generate a list of test cases
339    fn test_cases() -> Vec<(ComplexityClass, usize, (usize, usize))> {
340        let mut cases = Vec::new();
341        for n in [3, 4, 5, 6] {
342            for class in 1..=NUMBER_OF_CLASSES {
343                let expected = tiles_to_remove_range(ComplexityClass::new(class).unwrap(), n);
344                cases.push((ComplexityClass::new(class).unwrap(), n, expected));
345            }
346        }
347        cases
348    }
349
350    #[test]
351    fn test_tiles_to_remove_range() {
352        for (class, n, expected) in test_cases() {
353            assert_eq!(
354                tiles_to_remove_range(class, n),
355                expected,
356                "Failed for class {:?} and n = {}",
357                class,
358                n
359            );
360        }
361    }
362    mod classify_puzzle_tests {
363
364        use super::*;
365
366        /// Tests classification of an empty puzzle.
367        ///
368        /// **Input:** A puzzle consisting entirely of `None` values.
369        /// **Expected Output:** `Err(DominoError::EmptyPuzzle)`.
370        #[test]
371        fn test_classify_puzzle_empty_puzzle() {
372            let puzzle = mock_puzzle(8, (0..8).collect()); // All empty tiles
373            assert_eq!(classify_puzzle(&puzzle), Err(DominoError::EmptyPuzzle));
374        }
375
376        /// Tests classification of a puzzle with no holes.
377        ///
378        /// **Input:** A puzzle containing only `Some(Tile(0,0))` values.
379        /// **Expected Output:** `Err(DominoError::InvalidLength)`.
380        #[test]
381        fn test_classify_puzzle_no_holes() {
382            let puzzle = mock_puzzle(8, vec![]); // No holes
383            assert_eq!(classify_puzzle(&puzzle), Err(DominoError::InvalidClass("The complexity class provided is not valid: 0.\nIt should be in the range [1, 3]".to_string())));
384        }
385    }
386
387    mod detect_holes_tests {
388        use super::*;
389
390        /// Tests hole detection in a puzzle.
391        ///
392        /// **Input:** A puzzle containing `Some(Tile(0,0))` and `None` values with two distinct hole regions.
393        /// **Expected Output:** A vector of hole indices correctly identifying the start and end of each hole.
394        #[test]
395        fn test_classify_detect_holes_correctly() {
396            let puzzle = mock_puzzle(8, vec![1, 2, 4]);
397            let holes = detect_holes(&puzzle);
398            assert_eq!(holes, vec![(1, 3), (4, 5)]);
399        }
400
401        /// Tests detection when there is a single hole spanning multiple positions.
402        ///
403        /// **Input:** A puzzle with a single hole spanning indices [3,6].
404        /// **Expected Output:** A single tuple (3,6).
405        #[test]
406        fn test_classify_detect_holes_single_large_hole() {
407            let puzzle = mock_puzzle(8, vec![3, 4, 5]);
408            let holes = detect_holes(&puzzle);
409            assert_eq!(holes, vec![(3, 6)]);
410        }
411
412        /// Tests detection of a hole that wraps around the end of the puzzle.
413        ///
414        /// **Input:** A puzzle where holes exist at the end and beginning.
415        /// **Expected Output:** The hole should be merged correctly as (6, 2).
416        #[test]
417        fn test_classify_detect_holes_wraparound() {
418            let puzzle = mock_puzzle(8, vec![6, 7, 0, 1]);
419            let holes = detect_holes(&puzzle);
420            assert_eq!(holes, vec![(6, 2)]);
421        }
422    }
423
424    #[cfg(test)]
425    mod compute_complexity_tests {
426        use super::*;
427
428        /// Helper function to create a puzzle with a single hole of a given length.
429        ///
430        /// **Input:**
431        /// - `n`: The dimension of the puzzle.
432        /// - `hole_size`: The number of consecutive tiles removed.
433        /// - `hole_start`: The index where the hole begins.
434        ///
435        /// **Output:**
436        /// - A `Puzzle` with `Some(Tile(0,0))` at non-hole indices and `None` at hole indices.
437        fn create_puzzle_with_hole(n: usize, hole_size: usize, hole_start: usize) -> Puzzle {
438            let total_size = if n % 2 == 0 {
439                (n + 1) * (n + 2) / 2
440            } else {
441                (n + 1) * (n + 1) / 2
442            };
443
444            let mut puzzle = vec![Some(Tile(0, 0)); total_size];
445            for i in 0..hole_size {
446                puzzle[(hole_start + i) % total_size] = None;
447            }
448            Puzzle(puzzle)
449        }
450
451        /// Tests complexity calculation for puzzles with n = 3 and various hole lengths.
452        ///
453        /// **Input:** Puzzles with `n = 3`, each having a single hole of different lengths.
454        /// **Expected Output:** `Ok(ComplexityClass)` ensuring successful complexity classification.
455        #[test]
456        fn test_compute_complexity_n3_various_holes() {
457            let n = 3;
458            let total_size = (n + 1) * (n + 1) / 2;
459            let max_hole = (n + 1) * 2 - 1;
460
461            for hole_size in 2..=max_hole as usize {
462                let puzzle = create_puzzle_with_hole(n, hole_size, 0);
463                let holes = detect_holes(&puzzle);
464                let complexity = compute_complexity(holes, max_hole, total_size);
465                let expected_abs = (hole_size.saturating_sub(1) as f32 / max_hole as f32).powf(2.0);
466                let expected_rel = expected_abs.clamp(0.0, 1.0);
467                let expected_class = find_threshold_index(expected_rel);
468
469                assert_eq!(
470                    complexity.ok(),
471                    Some(ComplexityClass(expected_class)),
472                    "Failed for hole_size = {}",
473                    hole_size
474                );
475            }
476        }
477
478        /// Tests complexity classification for a puzzle with n = 3 and a single large hole.
479        ///
480        /// **Input:** A puzzle of size `n = 3` with a single large hole spanning the maximum allowed length.
481        /// **Expected Output:** `Ok(ComplexityClass)`, verifying correct classification for large holes.
482        #[test]
483        fn test_compute_complexity_n3_large_hole() {
484            let n = 3;
485            let total_size = (n + 1) * (n + 1) / 2;
486            let max_hole = (n + 1) * 2 - 1;
487
488            let puzzle = create_puzzle_with_hole(n, max_hole as usize, 0);
489
490            let holes = detect_holes(&puzzle);
491            let complexity = compute_complexity(holes, max_hole, total_size);
492
493            assert!(complexity.is_ok(), "Failed for a single large hole");
494        }
495
496        /// Tests complexity classification for a puzzle with n = 3 and multiple small holes.
497        ///
498        /// **Input:** A puzzle of size `n = 3` with multiple small holes at different positions.
499        /// **Expected Output:** `Ok(ComplexityClass)`, verifying the handling of multiple distinct holes.
500        #[test]
501        fn test_compute_complexity_n3_multiple_small_holes() {
502            let n = 3;
503            let total_size = (n + 1) * (n + 1) / 2;
504            let max_hole = (n + 1) * 2 - 1;
505            let puzzle = mock_puzzle(total_size, vec![2, 3, 5]);
506
507            let holes = detect_holes(&puzzle);
508            let complexity = compute_complexity(holes, max_hole, total_size);
509
510            assert!(complexity.is_ok(), "Failed for multiple small holes");
511        }
512    }
513
514    /// Tests inverse class mapping for valid classes (1 to NUMBER_OF_CLASSES).
515    ///
516    /// **Input:** Valid `ComplexityClass` values from 1 to `NUMBER_OF_CLASSES`.
517    /// **Expected Output:** `(min_x, max_x)` computed using the function's logic.
518    #[test]
519    fn test_classify_inverse_class_mapping() {
520        for class_value in 1..=NUMBER_OF_CLASSES {
521            let class = ComplexityClass::new(class_value).unwrap();
522            let result = inverse_class_mapping(class);
523            assert!(result.is_some());
524            let (min_x, max_x) = result.unwrap();
525            let expected_min_x = compute_threshold(class_value - 1);
526            let expected_max_x = compute_threshold(class_value);
527
528            assert_eq!(min_x, expected_min_x);
529            assert_eq!(max_x, expected_max_x);
530        }
531    }
532}