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}