Skip to main content

sklears_svm/
decomposition.rs

1//! Decomposition methods for large-scale SVM optimization
2//!
3//! This module provides decomposition algorithms for solving large SVM problems
4//! by breaking them down into smaller sub-problems that can be solved efficiently.
5//! The main approaches include:
6//! - Chunked Sequential Minimal Optimization (SMO)
7//! - Matrix decomposition strategies
8//! - Hierarchical decomposition
9//! - Working set selection algorithms
10
11use crate::kernels::Kernel;
12#[cfg(feature = "parallel")]
13#[allow(unused_imports)]
14use rayon::prelude::*;
15use scirs2_core::ndarray::{Array1, Array2};
16use sklears_core::{error::Result, types::Float};
17use std::collections::HashMap;
18
19/// Configuration for decomposition algorithms
20#[derive(Debug, Clone)]
21pub struct DecompositionConfig {
22    /// Maximum working set size
23    pub max_working_set_size: usize,
24    /// Minimum working set size
25    pub min_working_set_size: usize,
26    /// Number of decomposition levels
27    pub decomposition_levels: usize,
28    /// Overlap between working sets
29    pub working_set_overlap: usize,
30    /// Strategy for working set selection
31    pub selection_strategy: WorkingSetSelectionStrategy,
32    /// Maximum iterations per decomposition step
33    pub max_iterations_per_step: usize,
34    /// Convergence tolerance
35    pub tolerance: Float,
36    /// Whether to use hierarchical decomposition
37    pub use_hierarchical: bool,
38    /// Cache size for kernel evaluations
39    pub kernel_cache_size: usize,
40}
41
42impl Default for DecompositionConfig {
43    fn default() -> Self {
44        Self {
45            max_working_set_size: 2000,
46            min_working_set_size: 100,
47            decomposition_levels: 3,
48            working_set_overlap: 50,
49            selection_strategy: WorkingSetSelectionStrategy::MaximalViolating,
50            max_iterations_per_step: 1000,
51            tolerance: 1e-6,
52            use_hierarchical: true,
53            kernel_cache_size: 256,
54        }
55    }
56}
57
58/// Strategies for selecting working sets in decomposition
59#[derive(Debug, Clone, Copy)]
60pub enum WorkingSetSelectionStrategy {
61    /// Select samples with maximum KKT condition violations
62    MaximalViolating,
63    /// Random selection with gradient-based weighting
64    WeightedRandom,
65    /// Steepest feasible direction
66    SteepestFeasible,
67    /// Hybrid approach combining multiple strategies
68    Hybrid,
69    /// Block-wise selection for structured problems
70    BlockWise,
71}
72
73/// Decomposition-based SVM solver
74pub struct DecompositionSolver {
75    config: DecompositionConfig,
76    kernel: Box<dyn Kernel>,
77    working_sets: Vec<WorkingSet>,
78    #[allow(dead_code)] // intentionally deferred: kernel cache lookup not yet implemented
79    kernel_cache: HashMap<(usize, usize), Float>,
80    convergence_history: Vec<Float>,
81}
82
83impl DecompositionSolver {
84    /// Create a new decomposition solver
85    pub fn new(kernel: Box<dyn Kernel>, config: DecompositionConfig) -> Self {
86        let cache_capacity = config.kernel_cache_size * config.kernel_cache_size;
87        Self {
88            config,
89            kernel,
90            working_sets: Vec::new(),
91            kernel_cache: HashMap::with_capacity(cache_capacity),
92            convergence_history: Vec::new(),
93        }
94    }
95
96    /// Solve the SVM problem using decomposition
97    pub fn solve(
98        &mut self,
99        x: &Array2<Float>,
100        y: &Array1<Float>,
101        c: Float,
102        initial_alpha: Option<&Array1<Float>>,
103    ) -> Result<(Array1<Float>, Float)> {
104        let n_samples = x.nrows();
105
106        // Initialize alpha coefficients
107        let mut alpha = initial_alpha
108            .cloned()
109            .unwrap_or_else(|| Array1::zeros(n_samples));
110
111        // Create initial decomposition
112        self.create_initial_decomposition(n_samples)?;
113
114        // Main decomposition loop
115        let mut iteration = 0;
116        let mut converged = false;
117
118        while !converged
119            && iteration < self.config.max_iterations_per_step * self.config.decomposition_levels
120        {
121            // Solve each working set
122            let mut global_change = 0.0;
123
124            // Process working sets one by one to avoid borrowing conflicts
125            for i in 0..self.working_sets.len() {
126                if !self.working_sets[i].active || self.working_sets[i].indices.len() < 2 {
127                    continue;
128                }
129
130                // Extract working set data
131                let ws_size = self.working_sets[i].indices.len();
132                let mut ws_x = Array2::zeros((ws_size, x.ncols()));
133                let mut ws_y = Array1::zeros(ws_size);
134                let mut ws_alpha = Array1::zeros(ws_size);
135
136                for (j, &idx) in self.working_sets[i].indices.iter().enumerate() {
137                    ws_x.row_mut(j).assign(&x.row(idx));
138                    ws_y[j] = y[idx];
139                    ws_alpha[j] = alpha[idx];
140                }
141
142                // Solve the working-set sub-problem with the analytic SMO update
143                // (2-variable SVC dual) over the extracted block.
144                let new_alpha = self.optimize_working_set(&ws_x, &ws_y, &ws_alpha, c);
145
146                // Compute change in alpha
147                let change = (&new_alpha - &ws_alpha).mapv(|x| x.abs()).sum();
148
149                // Update global alpha
150                for (j, &idx) in self.working_sets[i].indices.iter().enumerate() {
151                    alpha[idx] = new_alpha[j];
152                }
153
154                self.working_sets[i].last_change = change;
155                global_change += change;
156            }
157
158            // Update working sets based on convergence
159            self.update_working_sets(&alpha, x, y, c)?;
160
161            // Check convergence
162            self.convergence_history.push(global_change);
163            converged = self.check_convergence(global_change);
164
165            iteration += 1;
166        }
167
168        // Compute bias term
169        let bias = self.compute_bias(&alpha, x, y, c)?;
170
171        Ok((alpha, bias))
172    }
173
174    /// Optimize a single working-set sub-problem with Sequential Minimal
175    /// Optimization (the analytic 2-variable SVC dual update).
176    ///
177    /// The SVC dual restricted to the working set is:
178    /// ```text
179    /// maximize:  Σ α_i - ½ Σ_i Σ_j α_i α_j y_i y_j K(x_i, x_j)
180    /// subject to: Σ α_i y_i = 0,   0 ≤ α_i ≤ C
181    /// ```
182    /// On each inner iteration we pick the maximal-violating pair `(i, j)` from
183    /// the gradient and apply the closed-form two-variable update that respects
184    /// both the box constraints and the equality constraint `Σ α y = 0`.
185    ///
186    /// Returns the updated alpha vector for the working set (other coordinates
187    /// of the global problem are held fixed by the caller).
188    fn optimize_working_set(
189        &self,
190        ws_x: &Array2<Float>,
191        ws_y: &Array1<Float>,
192        ws_alpha: &Array1<Float>,
193        c: Float,
194    ) -> Array1<Float> {
195        let n = ws_alpha.len();
196        if n < 2 {
197            return ws_alpha.clone();
198        }
199
200        // Precompute the working-set kernel matrix.
201        let mut k = Array2::<Float>::zeros((n, n));
202        for i in 0..n {
203            for j in i..n {
204                let val = self
205                    .kernel
206                    .compute(ws_x.row(i).to_owned().view(), ws_x.row(j).to_owned().view());
207                k[[i, j]] = val;
208                k[[j, i]] = val;
209            }
210        }
211
212        let mut alpha = ws_alpha.clone();
213        let tol = self.config.tolerance;
214
215        // Gradient of the dual objective w.r.t. alpha:
216        // g_i = 1 - y_i Σ_j α_j y_j K(i,j). For the maximal-violating-pair
217        // selection we use the "f" values f_i = -y_i + Σ_j α_j y_j K(i,j) as in
218        // libsvm; we maintain it incrementally.
219        let mut f = Array1::<Float>::zeros(n);
220        for i in 0..n {
221            let mut acc = -ws_y[i];
222            for j in 0..n {
223                if alpha[j] != 0.0 {
224                    acc += alpha[j] * ws_y[j] * k[[i, j]];
225                }
226            }
227            f[i] = acc;
228        }
229
230        let max_inner = self.config.max_iterations_per_step.max(1);
231
232        for _iter in 0..max_inner {
233            // Maximal violating pair selection (libsvm WSS1):
234            //   i_up  = argmax_{i in I_up}  -y_i f_i
235            //   j_low = argmin_{j in I_low} -y_j f_j
236            // where I_up = {i: (y_i=+1, α_i<C) or (y_i=-1, α_i>0)} and
237            //       I_low = {i: (y_i=+1, α_i>0) or (y_i=-1, α_i<C)}.
238            let mut i_up = None;
239            let mut g_max = Float::NEG_INFINITY;
240            let mut j_low = None;
241            let mut g_min = Float::INFINITY;
242
243            for t in 0..n {
244                let yt = ws_y[t];
245                let in_up = (yt > 0.0 && alpha[t] < c - tol) || (yt < 0.0 && alpha[t] > tol);
246                let in_low = (yt > 0.0 && alpha[t] > tol) || (yt < 0.0 && alpha[t] < c - tol);
247                let grad = -yt * f[t];
248                if in_up && grad > g_max {
249                    g_max = grad;
250                    i_up = Some(t);
251                }
252                if in_low && grad < g_min {
253                    g_min = grad;
254                    j_low = Some(t);
255                }
256            }
257
258            if g_max - g_min < tol {
259                break;
260            }
261
262            let (i, j) = match (i_up, j_low) {
263                (Some(i), Some(j)) if i != j => (i, j),
264                _ => break,
265            };
266
267            let yi = ws_y[i];
268            let yj = ws_y[j];
269            let ai_old = alpha[i];
270            let aj_old = alpha[j];
271
272            // Curvature of the 2-variable subproblem.
273            let eta = k[[i, i]] + k[[j, j]] - 2.0 * k[[i, j]];
274            if eta <= 1e-12 {
275                break;
276            }
277
278            // Unconstrained update of alpha_j along the equality constraint.
279            // f_i - f_j is the gradient difference in the chosen direction.
280            let aj_unc = aj_old + yj * (f[i] - f[j]) / eta;
281
282            // Box bounds for alpha_j depend on whether labels agree.
283            let (low, high) = if yi != yj {
284                let diff = aj_old - ai_old;
285                (diff.max(0.0), c + (aj_old - ai_old).min(0.0))
286            } else {
287                let sum = ai_old + aj_old;
288                ((sum - c).max(0.0), sum.min(c))
289            };
290
291            let aj_new = aj_unc.clamp(low, high);
292            // Equality constraint y_i Δα_i + y_j Δα_j = 0 fixes Δα_i.
293            let ai_new = ai_old + yi * yj * (aj_old - aj_new);
294
295            let d_ai = ai_new - ai_old;
296            let d_aj = aj_new - aj_old;
297
298            if d_ai.abs() < 1e-12 && d_aj.abs() < 1e-12 {
299                break;
300            }
301
302            alpha[i] = ai_new;
303            alpha[j] = aj_new;
304
305            // Incrementally update f for all working-set coordinates.
306            for t in 0..n {
307                f[t] += yi * d_ai * k[[t, i]] + yj * d_aj * k[[t, j]];
308            }
309        }
310
311        alpha
312    }
313
314    /// Create initial decomposition into working sets
315    fn create_initial_decomposition(&mut self, n_samples: usize) -> Result<()> {
316        self.working_sets.clear();
317
318        match self.config.selection_strategy {
319            WorkingSetSelectionStrategy::BlockWise => {
320                self.create_block_decomposition(n_samples)?;
321            }
322            _ => {
323                self.create_overlapping_decomposition(n_samples)?;
324            }
325        }
326
327        Ok(())
328    }
329
330    /// Create block-wise decomposition
331    fn create_block_decomposition(&mut self, n_samples: usize) -> Result<()> {
332        let block_size = self.config.max_working_set_size;
333        let mut start = 0;
334
335        while start < n_samples {
336            let end = (start + block_size).min(n_samples);
337            let indices: Vec<usize> = (start..end).collect();
338
339            self.working_sets.push(WorkingSet {
340                indices,
341                active: true,
342                last_change: Float::INFINITY,
343                priority: 1.0,
344            });
345
346            start = end;
347        }
348
349        Ok(())
350    }
351
352    /// Create overlapping decomposition
353    fn create_overlapping_decomposition(&mut self, n_samples: usize) -> Result<()> {
354        let step_size = self.config.max_working_set_size - self.config.working_set_overlap;
355        let mut start = 0;
356
357        while start < n_samples {
358            let end = (start + self.config.max_working_set_size).min(n_samples);
359            let indices: Vec<usize> = (start..end).collect();
360
361            if indices.len() >= self.config.min_working_set_size {
362                self.working_sets.push(WorkingSet {
363                    indices,
364                    active: true,
365                    last_change: Float::INFINITY,
366                    priority: 1.0,
367                });
368            }
369
370            start += step_size;
371        }
372
373        Ok(())
374    }
375
376    /// Solve a single working set using SMO
377    #[allow(dead_code)] // intentionally deferred: working set solver not yet called
378    fn solve_working_set(
379        &mut self,
380        working_set: &mut WorkingSet,
381        x: &Array2<Float>,
382        y: &Array1<Float>,
383        alpha: &mut Array1<Float>,
384        c: Float,
385    ) -> Result<Float> {
386        if !working_set.active || working_set.indices.len() < 2 {
387            return Ok(0.0);
388        }
389
390        // Extract working set data
391        let ws_size = working_set.indices.len();
392        let mut ws_x = Array2::zeros((ws_size, x.ncols()));
393        let mut ws_y = Array1::zeros(ws_size);
394        let mut ws_alpha = Array1::zeros(ws_size);
395
396        for (i, &idx) in working_set.indices.iter().enumerate() {
397            ws_x.row_mut(i).assign(&x.row(idx));
398            ws_y[i] = y[idx];
399            ws_alpha[i] = alpha[idx];
400        }
401
402        // Solve the working-set sub-problem with the analytic SMO update over
403        // the extracted block, using the solver's kernel.
404        let new_alpha = self.optimize_working_set(&ws_x, &ws_y, &ws_alpha, c);
405
406        // Compute change in alpha
407        let change = (&new_alpha - &ws_alpha).mapv(|x| x.abs()).sum();
408
409        // Update global alpha
410        for (i, &idx) in working_set.indices.iter().enumerate() {
411            alpha[idx] = new_alpha[i];
412        }
413
414        working_set.last_change = change;
415        Ok(change)
416    }
417
418    /// Compute cached kernel matrix for working set
419    #[allow(dead_code)] // intentionally deferred: cached kernel matrix computation pending
420    fn compute_cached_kernel_matrix(
421        &mut self,
422        x: &Array2<Float>,
423        indices: &[usize],
424    ) -> Array2<Float> {
425        let n = x.nrows();
426        let mut kernel_matrix = Array2::zeros((n, n));
427
428        for i in 0..n {
429            for j in i..n {
430                let key = (indices[i].min(indices[j]), indices[i].max(indices[j]));
431
432                let k_val = if let Some(&cached_val) = self.kernel_cache.get(&key) {
433                    cached_val
434                } else {
435                    let val = self
436                        .kernel
437                        .compute(x.row(i).to_owned().view(), x.row(j).to_owned().view());
438
439                    // Cache management - remove oldest entries if cache is full
440                    if self.kernel_cache.len()
441                        >= self.config.kernel_cache_size * self.config.kernel_cache_size
442                    {
443                        // Simple LRU-like eviction (remove random entry)
444                        if let Some(key_to_remove) = self.kernel_cache.keys().next().copied() {
445                            self.kernel_cache.remove(&key_to_remove);
446                        }
447                    }
448
449                    self.kernel_cache.insert(key, val);
450                    val
451                };
452
453                kernel_matrix[[i, j]] = k_val;
454                kernel_matrix[[j, i]] = k_val;
455            }
456        }
457
458        kernel_matrix
459    }
460
461    /// Update working sets based on convergence and violation patterns
462    fn update_working_sets(
463        &mut self,
464        alpha: &Array1<Float>,
465        x: &Array2<Float>,
466        y: &Array1<Float>,
467        c: Float,
468    ) -> Result<()> {
469        // Compute KKT violations for each sample
470        let violations = self.compute_kkt_violations(alpha, x, y, c)?;
471
472        // Update working set priorities and activity
473        for working_set in &mut self.working_sets {
474            let avg_violation: Float = working_set
475                .indices
476                .iter()
477                .map(|&i| violations[i])
478                .sum::<Float>()
479                / working_set.indices.len() as Float;
480
481            working_set.priority = avg_violation;
482            working_set.active = working_set.last_change > self.config.tolerance * 0.1
483                || avg_violation > self.config.tolerance;
484        }
485
486        // Optionally create new working sets for high-violation regions
487        if self.config.use_hierarchical {
488            self.create_adaptive_working_sets(&violations)?;
489        }
490
491        Ok(())
492    }
493
494    /// Compute KKT condition violations for all samples
495    fn compute_kkt_violations(
496        &self,
497        alpha: &Array1<Float>,
498        x: &Array2<Float>,
499        y: &Array1<Float>,
500        c: Float,
501    ) -> Result<Array1<Float>> {
502        let n_samples = x.nrows();
503        let mut violations = Array1::zeros(n_samples);
504
505        // Compute decision function values
506        let mut decision_values: Array1<Float> = Array1::zeros(n_samples);
507        for i in 0..n_samples {
508            for j in 0..n_samples {
509                if alpha[j] > 0.0 {
510                    let k_val = self
511                        .kernel
512                        .compute(x.row(i).to_owned().view(), x.row(j).to_owned().view());
513                    decision_values[i] += alpha[j] * y[j] * k_val;
514                }
515            }
516        }
517
518        // Compute KKT violations
519        for i in 0..n_samples {
520            let yi_f: Float = y[i] * decision_values[i];
521
522            let one = 1.0 as Float;
523            let zero = 0.0 as Float;
524
525            violations[i] = if alpha[i] < 1e-8 {
526                // At lower bound
527                (one - yi_f).max(zero)
528            } else if alpha[i] > c - 1e-8 {
529                // At upper bound
530                (yi_f - one).max(zero)
531            } else {
532                // Free variable
533                (one - yi_f).abs()
534            };
535        }
536
537        Ok(violations)
538    }
539
540    /// Create adaptive working sets for high-violation regions
541    fn create_adaptive_working_sets(&mut self, violations: &Array1<Float>) -> Result<()> {
542        let n_samples = violations.len();
543        let threshold = violations.iter().fold(0.0, |acc, &x| acc + x) / n_samples as Float;
544
545        // Find high-violation samples
546        let high_violation_indices: Vec<usize> = (0..n_samples)
547            .filter(|&i| violations[i] > threshold * 2.0)
548            .collect();
549
550        if high_violation_indices.len() >= self.config.min_working_set_size {
551            // Create new working set for high-violation samples
552            let mut new_working_set = WorkingSet {
553                indices: high_violation_indices,
554                active: true,
555                last_change: Float::INFINITY,
556                priority: threshold * 2.0,
557            };
558
559            // Limit size if too large
560            if new_working_set.indices.len() > self.config.max_working_set_size {
561                new_working_set
562                    .indices
563                    .truncate(self.config.max_working_set_size);
564            }
565
566            self.working_sets.push(new_working_set);
567        }
568
569        Ok(())
570    }
571
572    /// Check global convergence
573    fn check_convergence(&self, change: Float) -> bool {
574        if self.convergence_history.len() < 3 {
575            return false;
576        }
577
578        // Check if change is below tolerance
579        if change < self.config.tolerance {
580            return true;
581        }
582
583        // Check if convergence has stagnated
584        let recent_changes: Float =
585            self.convergence_history.iter().rev().take(3).sum::<Float>() / 3.0;
586
587        recent_changes < self.config.tolerance * 10.0
588    }
589
590    /// Compute bias term from support vectors
591    fn compute_bias(
592        &self,
593        alpha: &Array1<Float>,
594        x: &Array2<Float>,
595        y: &Array1<Float>,
596        c: Float,
597    ) -> Result<Float> {
598        let n_samples = x.nrows();
599        let mut bias_sum = 0.0;
600        let mut n_free_sv = 0;
601
602        for i in 0..n_samples {
603            if alpha[i] > 1e-8 && alpha[i] < c - 1e-8 {
604                // Free support vector
605                let mut decision_value = 0.0;
606                for j in 0..n_samples {
607                    if alpha[j] > 1e-8 {
608                        let k_val = self
609                            .kernel
610                            .compute(x.row(i).to_owned().view(), x.row(j).to_owned().view());
611                        decision_value += alpha[j] * y[j] * k_val;
612                    }
613                }
614                bias_sum += y[i] - decision_value;
615                n_free_sv += 1;
616            }
617        }
618
619        if n_free_sv > 0 {
620            Ok(bias_sum / n_free_sv as Float)
621        } else {
622            Ok(0.0)
623        }
624    }
625
626    /// Get convergence statistics
627    pub fn get_convergence_history(&self) -> &[Float] {
628        &self.convergence_history
629    }
630
631    /// Get number of active working sets
632    pub fn get_active_working_sets(&self) -> usize {
633        self.working_sets.iter().filter(|ws| ws.active).count()
634    }
635}
636
637/// Working set for decomposition algorithm
638#[derive(Debug, Clone)]
639struct WorkingSet {
640    /// Indices of samples in this working set
641    indices: Vec<usize>,
642    /// Whether this working set is active
643    active: bool,
644    /// Last change in objective function
645    last_change: Float,
646    /// Priority for selection
647    priority: Float,
648}
649
650/// Hierarchical decomposition for very large problems
651pub struct HierarchicalDecomposer {
652    levels: Vec<DecompositionLevel>,
653    config: DecompositionConfig,
654}
655
656impl HierarchicalDecomposer {
657    /// Create new hierarchical decomposer
658    pub fn new(config: DecompositionConfig) -> Self {
659        Self {
660            levels: Vec::new(),
661            config,
662        }
663    }
664
665    /// Decompose problem hierarchically
666    pub fn decompose(&mut self, n_samples: usize) -> Result<()> {
667        self.levels.clear();
668
669        let mut current_size = n_samples;
670        let mut level = 0;
671
672        while current_size > self.config.max_working_set_size
673            && level < self.config.decomposition_levels
674        {
675            let reduction_factor =
676                (self.config.max_working_set_size as Float / current_size as Float).sqrt();
677            let new_size = (current_size as Float * reduction_factor).ceil() as usize;
678
679            self.levels.push(DecompositionLevel {
680                level,
681                original_size: current_size,
682                reduced_size: new_size,
683                reduction_factor,
684                mapping: self.create_level_mapping(current_size, new_size)?,
685            });
686
687            current_size = new_size;
688            level += 1;
689        }
690
691        Ok(())
692    }
693
694    /// Create mapping between levels
695    fn create_level_mapping(&self, from_size: usize, to_size: usize) -> Result<Vec<Vec<usize>>> {
696        let cluster_size = (from_size as Float / to_size as Float).ceil() as usize;
697        let mut mapping = Vec::with_capacity(to_size);
698
699        for i in 0..to_size {
700            let start = i * cluster_size;
701            let end = ((i + 1) * cluster_size).min(from_size);
702            mapping.push((start..end).collect());
703        }
704
705        Ok(mapping)
706    }
707}
708
709/// Single level in hierarchical decomposition
710#[derive(Debug, Clone)]
711struct DecompositionLevel {
712    #[allow(dead_code)] // intentionally deferred: level index readout pending
713    level: usize,
714    #[allow(dead_code)] // intentionally deferred: size tracking pending
715    original_size: usize,
716    #[allow(dead_code)] // intentionally deferred: size tracking pending
717    reduced_size: usize,
718    #[allow(dead_code)] // intentionally deferred: reduction metrics pending
719    reduction_factor: Float,
720    #[allow(dead_code)] // intentionally deferred: index mapping readout pending
721    mapping: Vec<Vec<usize>>,
722}
723
724#[allow(non_snake_case)]
725#[cfg(test)]
726mod tests {
727    use super::*;
728    use crate::kernels::{LinearKernel, RbfKernel};
729
730    #[test]
731    fn test_decomposition_solver_creation() {
732        let kernel = Box::new(LinearKernel);
733        let config = DecompositionConfig::default();
734        let solver = DecompositionSolver::new(kernel, config);
735        assert_eq!(solver.working_sets.len(), 0);
736    }
737
738    #[test]
739    fn test_block_decomposition() {
740        let kernel = Box::new(RbfKernel::new(1.0));
741        let config = DecompositionConfig {
742            max_working_set_size: 100,
743            selection_strategy: WorkingSetSelectionStrategy::BlockWise,
744            ..DecompositionConfig::default()
745        };
746
747        let mut solver = DecompositionSolver::new(kernel, config);
748        solver
749            .create_initial_decomposition(250)
750            .expect("operation should succeed");
751
752        assert_eq!(solver.working_sets.len(), 3); // 250 / 100 = 2.5 -> 3 blocks
753    }
754
755    #[test]
756    fn test_hierarchical_decomposer() {
757        let config = DecompositionConfig::default();
758        let mut decomposer = HierarchicalDecomposer::new(config);
759
760        decomposer
761            .decompose(10000)
762            .expect("operation should succeed");
763        assert!(!decomposer.levels.is_empty());
764    }
765}