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}