Skip to main content

optirs_core/parameter_groups/
mod.rs

1// Parameter groups for different learning rates and configurations
2//
3// This module provides support for parameter groups, allowing different
4// sets of parameters to have different hyperparameters (learning rate,
5// weight decay, etc.) within the same optimizer.
6
7mod linalg;
8mod nuclear_norm;
9
10use crate::error::{OptimError, Result};
11use crate::optimizers::Optimizer;
12use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
13use scirs2_core::numeric::Float;
14use std::collections::HashMap;
15use std::fmt::Debug;
16use std::path::Path;
17
18use linalg::{
19    is_orthonormal, modified_gram_schmidt, power_iteration_spectral_norm,
20    project_positive_definite, to_matrix_2d, write_matrix_2d,
21};
22
23pub use nuclear_norm::{
24    nuclear_norm_of_matrix, nuclear_norm_prox, project_onto_nuclear_norm_ball,
25    truncated_svd_power_iteration, TruncatedSvd,
26};
27
28/// Parameter constraints that can be applied to parameter groups
29#[derive(Debug, Clone)]
30pub enum ParameterConstraint<A: Float> {
31    /// Clip values to a range [min, max]
32    ValueClip {
33        /// Minimum allowed value
34        min: A,
35        /// Maximum allowed value
36        max: A,
37    },
38    /// Constrain L2 norm to a maximum value
39    L2NormConstraint {
40        /// Maximum allowed L2 norm
41        maxnorm: A,
42    },
43    /// Constrain L1 norm to a maximum value
44    L1NormConstraint {
45        /// Maximum allowed L1 norm
46        maxnorm: A,
47    },
48    /// Ensure all values are non-negative
49    NonNegative,
50    /// Constrain to unit sphere (normalize to unit L2 norm)
51    UnitSphere,
52    /// Constrain parameters to be within a probability simplex (sum to 1, all non-negative)
53    Simplex,
54    /// Constrain matrix parameters to be orthogonal
55    Orthogonal {
56        /// Tolerance for orthogonality check
57        tolerance: A,
58    },
59    /// Constrain symmetric matrices to be positive definite
60    PositiveDefinite {
61        /// Minimum eigenvalue to ensure positive definiteness
62        mineigenvalue: A,
63    },
64    /// Spectral norm constraint (maximum singular value)
65    SpectralNorm {
66        /// Maximum allowed spectral norm
67        maxnorm: A,
68    },
69    /// Nuclear norm constraint (sum of singular values)
70    NuclearNorm {
71        /// Maximum allowed nuclear norm
72        maxnorm: A,
73    },
74    /// Custom constraint function
75    Custom {
76        /// Name of the custom constraint
77        name: String,
78    },
79}
80
81impl<A: Float + Send + Sync> ParameterConstraint<A> {
82    /// Apply the constraint to a parameter array
83    pub fn apply<D: Dimension>(&self, params: &mut Array<A, D>) -> Result<()>
84    where
85        A: ScalarOperand,
86    {
87        match self {
88            ParameterConstraint::ValueClip { min, max } => {
89                params.mapv_inplace(|x| {
90                    if x < *min {
91                        *min
92                    } else if x > *max {
93                        *max
94                    } else {
95                        x
96                    }
97                });
98            }
99            ParameterConstraint::L2NormConstraint { maxnorm } => {
100                let norm = params.mapv(|x| x * x).sum().sqrt();
101                if norm > *maxnorm {
102                    let scale = *maxnorm / norm;
103                    params.mapv_inplace(|x| x * scale);
104                }
105            }
106            ParameterConstraint::L1NormConstraint { maxnorm } => {
107                let norm = params.mapv(|x| x.abs()).sum();
108                if norm > *maxnorm {
109                    let scale = *maxnorm / norm;
110                    params.mapv_inplace(|x| x * scale);
111                }
112            }
113            ParameterConstraint::NonNegative => {
114                params.mapv_inplace(|x| if x < A::zero() { A::zero() } else { x });
115            }
116            ParameterConstraint::UnitSphere => {
117                let norm = params.mapv(|x| x * x).sum().sqrt();
118                if norm > A::zero() {
119                    let scale = A::one() / norm;
120                    params.mapv_inplace(|x| x * scale);
121                }
122            }
123            ParameterConstraint::Simplex => {
124                // First make all values non-negative
125                params.mapv_inplace(|x| if x < A::zero() { A::zero() } else { x });
126
127                // Then normalize to sum to 1
128                let sum = params.sum();
129                if sum > A::zero() {
130                    let scale = A::one() / sum;
131                    params.mapv_inplace(|x| x * scale);
132                } else {
133                    // If all values are zero, set to uniform distribution
134                    let uniform_val = A::one() / A::from(params.len()).unwrap_or(A::one());
135                    params.fill(uniform_val);
136                }
137            }
138            ParameterConstraint::Orthogonal { tolerance } => {
139                // Orthonormalize the columns of a 2D matrix via modified Gram-Schmidt.
140                if params.ndim() == 2 {
141                    let matrix = to_matrix_2d(params)?;
142                    let (rows, cols) = matrix.dim();
143
144                    // Skip work if the columns are already orthonormal within tolerance.
145                    if rows > 0 && cols > 0 && is_orthonormal(&matrix, *tolerance) {
146                        return Ok(());
147                    }
148
149                    let orthonormal = modified_gram_schmidt(&matrix);
150                    write_matrix_2d(params, &orthonormal)?;
151                } else {
152                    return Err(OptimError::InvalidConfig(
153                        "Orthogonal constraint only applies to 2D arrays (matrices)".to_string(),
154                    ));
155                }
156            }
157            ParameterConstraint::PositiveDefinite { mineigenvalue } => {
158                // Symmetrize, eigendecompose (cyclic Jacobi), clamp eigenvalues, reconstruct.
159                if params.ndim() != 2 {
160                    return Err(OptimError::InvalidConfig(
161                        "Positive definite constraint only applies to 2D arrays (matrices)"
162                            .to_string(),
163                    ));
164                }
165                let matrix = to_matrix_2d(params)?;
166                let (rows, cols) = matrix.dim();
167                if rows != cols {
168                    return Err(OptimError::InvalidConfig(
169                        "Positive definite constraint requires a square matrix".to_string(),
170                    ));
171                }
172
173                let projected = project_positive_definite(&matrix, *mineigenvalue);
174                write_matrix_2d(params, &projected)?;
175            }
176            ParameterConstraint::SpectralNorm { maxnorm } => {
177                // Bound the largest singular value via power iteration on MᵀM.
178                if params.ndim() != 2 {
179                    return Err(OptimError::InvalidConfig(
180                        "Spectral norm constraint only applies to 2D arrays (matrices)".to_string(),
181                    ));
182                }
183                let matrix = to_matrix_2d(params)?;
184                let sigma_max = power_iteration_spectral_norm(&matrix);
185                if sigma_max > *maxnorm && sigma_max > A::zero() {
186                    let scale = *maxnorm / sigma_max;
187                    params.mapv_inplace(|x| x * scale);
188                }
189            }
190            ParameterConstraint::NuclearNorm { maxnorm } => {
191                // Project onto the nuclear-norm ball. The nuclear norm is the sum
192                // of the singular values, so the projection soft-thresholds the
193                // *singular values* (via a truncated SVD) — it is not entrywise
194                // L1 shrinkage, which would give a different matrix entirely.
195                if params.ndim() != 2 {
196                    return Err(OptimError::InvalidConfig(
197                        "Nuclear norm constraint only applies to 2D arrays (matrices)".to_string(),
198                    ));
199                }
200                let matrix = to_matrix_2d(params)?;
201                let projected = project_onto_nuclear_norm_ball(&matrix, *maxnorm);
202                write_matrix_2d(params, &projected)?;
203            }
204            ParameterConstraint::Custom { name } => {
205                return Err(OptimError::InvalidConfig(format!(
206                    "Custom constraint '{name}' not implemented"
207                )));
208            }
209        }
210        Ok(())
211    }
212}
213
214/// Configuration for a parameter group
215#[derive(Debug, Clone)]
216pub struct ParameterGroupConfig<A: Float> {
217    /// Learning rate for this group
218    pub learning_rate: Option<A>,
219    /// Weight decay for this group
220    pub weight_decay: Option<A>,
221    /// Momentum for this group (if applicable)
222    pub momentum: Option<A>,
223    /// Parameter constraints for this group
224    pub constraints: Vec<ParameterConstraint<A>>,
225    /// Custom parameters as key-value pairs
226    pub custom_params: HashMap<String, A>,
227}
228
229impl<A: Float + Send + Sync> Default for ParameterGroupConfig<A> {
230    fn default() -> Self {
231        Self {
232            learning_rate: None,
233            weight_decay: None,
234            momentum: None,
235            constraints: Vec::new(),
236            custom_params: HashMap::new(),
237        }
238    }
239}
240
241impl<A: Float + Send + Sync> ParameterGroupConfig<A> {
242    /// Create a new parameter group configuration
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Set learning rate
248    pub fn with_learning_rate(mut self, lr: A) -> Self {
249        self.learning_rate = Some(lr);
250        self
251    }
252
253    /// Set weight decay
254    pub fn with_weight_decay(mut self, wd: A) -> Self {
255        self.weight_decay = Some(wd);
256        self
257    }
258
259    /// Set momentum
260    pub fn with_momentum(mut self, momentum: A) -> Self {
261        self.momentum = Some(momentum);
262        self
263    }
264
265    /// Add custom parameter
266    pub fn with_custom_param(mut self, key: String, value: A) -> Self {
267        self.custom_params.insert(key, value);
268        self
269    }
270
271    /// Add a parameter constraint
272    pub fn with_constraint(mut self, constraint: ParameterConstraint<A>) -> Self {
273        self.constraints.push(constraint);
274        self
275    }
276
277    /// Add value clipping constraint
278    pub fn with_value_clip(mut self, min: A, max: A) -> Self {
279        self.constraints
280            .push(ParameterConstraint::ValueClip { min, max });
281        self
282    }
283
284    /// Add L2 norm constraint
285    pub fn with_l2_norm_constraint(mut self, maxnorm: A) -> Self {
286        self.constraints
287            .push(ParameterConstraint::L2NormConstraint { maxnorm });
288        self
289    }
290
291    /// Add L1 norm constraint
292    pub fn with_l1_norm_constraint(mut self, maxnorm: A) -> Self {
293        self.constraints
294            .push(ParameterConstraint::L1NormConstraint { maxnorm });
295        self
296    }
297
298    /// Add non-negativity constraint
299    pub fn with_non_negative(mut self) -> Self {
300        self.constraints.push(ParameterConstraint::NonNegative);
301        self
302    }
303
304    /// Add unit sphere constraint
305    pub fn with_unit_sphere(mut self) -> Self {
306        self.constraints.push(ParameterConstraint::UnitSphere);
307        self
308    }
309
310    /// Add simplex constraint (sum to 1, all non-negative)
311    pub fn with_simplex(mut self) -> Self {
312        self.constraints.push(ParameterConstraint::Simplex);
313        self
314    }
315
316    /// Add orthogonal constraint for matrices
317    pub fn with_orthogonal(mut self, tolerance: A) -> Self {
318        self.constraints
319            .push(ParameterConstraint::Orthogonal { tolerance });
320        self
321    }
322
323    /// Add positive definite constraint for symmetric matrices
324    pub fn with_positive_definite(mut self, mineigenvalue: A) -> Self {
325        self.constraints
326            .push(ParameterConstraint::PositiveDefinite { mineigenvalue });
327        self
328    }
329
330    /// Add spectral norm constraint
331    pub fn with_spectral_norm(mut self, maxnorm: A) -> Self {
332        self.constraints
333            .push(ParameterConstraint::SpectralNorm { maxnorm });
334        self
335    }
336
337    /// Add nuclear norm constraint
338    pub fn with_nuclear_norm(mut self, maxnorm: A) -> Self {
339        self.constraints
340            .push(ParameterConstraint::NuclearNorm { maxnorm });
341        self
342    }
343
344    /// Add custom constraint
345    pub fn with_custom_constraint(mut self, name: String) -> Self {
346        self.constraints.push(ParameterConstraint::Custom { name });
347        self
348    }
349}
350
351/// A parameter group with its own configuration
352#[derive(Debug)]
353pub struct ParameterGroup<A: Float, D: Dimension> {
354    /// Unique identifier for this group
355    pub id: usize,
356    /// Parameters in this group
357    pub params: Vec<Array<A, D>>,
358    /// Configuration for this group
359    pub config: ParameterGroupConfig<A>,
360    /// Internal state for optimization (optimizer-specific)
361    pub state: HashMap<String, Vec<Array<A, D>>>,
362}
363
364impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> ParameterGroup<A, D> {
365    /// Create a new parameter group
366    pub fn new(id: usize, params: Vec<Array<A, D>>, config: ParameterGroupConfig<A>) -> Self {
367        Self {
368            id,
369            params,
370            config,
371            state: HashMap::new(),
372        }
373    }
374
375    /// Get the number of parameters in this group
376    pub fn num_params(&self) -> usize {
377        self.params.len()
378    }
379
380    /// Get learning rate for this group
381    pub fn learning_rate(&self, default: A) -> A {
382        self.config.learning_rate.unwrap_or(default)
383    }
384
385    /// Get weight decay for this group
386    pub fn weight_decay(&self, default: A) -> A {
387        self.config.weight_decay.unwrap_or(default)
388    }
389
390    /// Get momentum for this group
391    pub fn momentum(&self, default: A) -> A {
392        self.config.momentum.unwrap_or(default)
393    }
394
395    /// Get custom parameter
396    pub fn get_custom_param(&self, key: &str, default: A) -> A {
397        self.config
398            .custom_params
399            .get(key)
400            .copied()
401            .unwrap_or(default)
402    }
403
404    /// Apply constraints to all parameters in this group
405    pub fn apply_constraints(&mut self) -> Result<()>
406    where
407        A: ScalarOperand + Send + Sync,
408    {
409        for constraint in &self.config.constraints {
410            for param in &mut self.params {
411                constraint.apply(param)?;
412            }
413        }
414        Ok(())
415    }
416
417    /// Apply constraints to a specific parameter
418    pub fn apply_constraints_to_param(&self, param: &mut Array<A, D>) -> Result<()>
419    where
420        A: ScalarOperand + Send + Sync,
421    {
422        for constraint in &self.config.constraints {
423            constraint.apply(param)?;
424        }
425        Ok(())
426    }
427
428    /// Get the constraints for this group
429    pub fn constraints(&self) -> &[ParameterConstraint<A>] {
430        &self.config.constraints
431    }
432}
433
434/// Optimizer with parameter group support
435pub trait GroupedOptimizer<A: Float + ScalarOperand + Debug, D: Dimension>:
436    Optimizer<A, D>
437{
438    /// Add a parameter group
439    fn add_group(
440        &mut self,
441        params: Vec<Array<A, D>>,
442        config: ParameterGroupConfig<A>,
443    ) -> Result<usize>;
444
445    /// Get parameter group by ID
446    fn get_group(&self, groupid: usize) -> Result<&ParameterGroup<A, D>>;
447
448    /// Get mutable parameter group by ID
449    fn get_group_mut(&mut self, groupid: usize) -> Result<&mut ParameterGroup<A, D>>;
450
451    /// Get all parameter groups
452    fn groups(&self) -> &[ParameterGroup<A, D>];
453
454    /// Get all parameter groups mutably
455    fn groups_mut(&mut self) -> &mut [ParameterGroup<A, D>];
456
457    /// Step for a specific group
458    fn step_group(
459        &mut self,
460        group_id: usize,
461        gradients: &[Array<A, D>],
462    ) -> Result<Vec<Array<A, D>>>;
463
464    /// Set learning rate for a specific group
465    fn set_group_learning_rate(&mut self, groupid: usize, lr: A) -> Result<()>;
466
467    /// Set weight decay for a specific group
468    fn set_group_weight_decay(&mut self, groupid: usize, wd: A) -> Result<()>;
469}
470
471/// Helper struct for managing parameter groups
472#[derive(Debug)]
473pub struct GroupManager<A: Float, D: Dimension> {
474    groups: Vec<ParameterGroup<A, D>>,
475    next_id: usize,
476}
477
478impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> Default for GroupManager<A, D> {
479    fn default() -> Self {
480        Self {
481            groups: Vec::new(),
482            next_id: 0,
483        }
484    }
485}
486
487impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> GroupManager<A, D> {
488    /// Create a new group manager
489    pub fn new() -> Self {
490        Self::default()
491    }
492
493    /// Add a new parameter group
494    pub fn add_group(
495        &mut self,
496        params: Vec<Array<A, D>>,
497        config: ParameterGroupConfig<A>,
498    ) -> usize {
499        let id = self.next_id;
500        self.next_id += 1;
501        self.groups.push(ParameterGroup::new(id, params, config));
502        id
503    }
504
505    /// Get group by ID
506    pub fn get_group(&self, id: usize) -> Result<&ParameterGroup<A, D>> {
507        self.groups
508            .iter()
509            .find(|g| g.id == id)
510            .ok_or_else(|| OptimError::InvalidConfig(format!("Group {id} not found")))
511    }
512
513    /// Get mutable group by ID
514    pub fn get_group_mut(&mut self, id: usize) -> Result<&mut ParameterGroup<A, D>> {
515        self.groups
516            .iter_mut()
517            .find(|g| g.id == id)
518            .ok_or_else(|| OptimError::InvalidConfig(format!("Group {id} not found")))
519    }
520
521    /// Get all groups
522    pub fn groups(&self) -> &[ParameterGroup<A, D>] {
523        &self.groups
524    }
525
526    /// Get all groups mutably
527    pub fn groups_mut(&mut self) -> &mut [ParameterGroup<A, D>] {
528        &mut self.groups
529    }
530
531    /// Get total number of parameters across all groups
532    pub fn total_params(&self) -> usize {
533        self.groups.iter().map(|g| g.num_params()).sum()
534    }
535}
536
537/// State checkpointing for parameter management
538pub mod checkpointing {
539    use super::*;
540
541    /// Checkpoint data for optimizer state
542    #[derive(Debug, Clone)]
543    pub struct OptimizerCheckpoint<A: Float, D: Dimension> {
544        /// Step number
545        pub step: usize,
546        /// Parameter groups
547        pub groups: Vec<ParameterGroupCheckpoint<A, D>>,
548        /// Global optimizer state
549        pub global_state: HashMap<String, String>,
550        /// Metadata
551        pub metadata: CheckpointMetadata,
552    }
553
554    /// Checkpoint data for a parameter group
555    #[derive(Debug, Clone)]
556    pub struct ParameterGroupCheckpoint<A: Float, D: Dimension> {
557        /// Group ID
558        pub id: usize,
559        /// Parameters
560        pub params: Vec<Array<A, D>>,
561        /// Group configuration
562        pub config: ParameterGroupConfig<A>,
563        /// Optimizer-specific state for this group
564        pub state: HashMap<String, Vec<Array<A, D>>>,
565    }
566
567    /// Metadata for checkpoints
568    #[derive(Debug, Clone)]
569    pub struct CheckpointMetadata {
570        /// Timestamp when checkpoint was created
571        pub timestamp: String,
572        /// Version of the optimizer
573        pub optimizerversion: String,
574        /// Custom metadata
575        pub custom: HashMap<String, String>,
576    }
577
578    impl CheckpointMetadata {
579        /// Create new metadata with current timestamp
580        pub fn new(optimizerversion: String) -> Self {
581            use std::time::{SystemTime, UNIX_EPOCH};
582
583            let timestamp = SystemTime::now()
584                .duration_since(UNIX_EPOCH)
585                .unwrap_or_default()
586                .as_secs()
587                .to_string();
588
589            Self {
590                timestamp,
591                optimizerversion,
592                custom: HashMap::new(),
593            }
594        }
595
596        /// Add custom metadata
597        pub fn with_custom(mut self, key: String, value: String) -> Self {
598            self.custom.insert(key, value);
599            self
600        }
601    }
602
603    /// Trait for optimizers that support checkpointing
604    pub trait Checkpointable<
605        A: Float + ToString + std::fmt::Display + std::str::FromStr,
606        D: Dimension,
607    >
608    {
609        /// Create a checkpoint of the current optimizer state
610        fn create_checkpoint(&self) -> Result<OptimizerCheckpoint<A, D>>;
611
612        /// Restore optimizer state from a checkpoint
613        fn restore_checkpoint(&mut self, checkpoint: &OptimizerCheckpoint<A, D>) -> Result<()>;
614
615        /// Save checkpoint to file (simple text format)
616        fn save_checkpoint<P: AsRef<Path>>(&self, path: P) -> Result<()> {
617            use std::fs::File;
618            use std::io::{BufWriter, Write};
619
620            let checkpoint = self.create_checkpoint()?;
621            let path = path.as_ref();
622
623            // Create the file
624            let file = File::create(path).map_err(|e| {
625                OptimError::InvalidConfig(format!("Failed to create checkpoint file: {e}"))
626            })?;
627            let mut writer = BufWriter::new(file);
628
629            // Write header
630            writeln!(writer, "# ScirS2 Optimizer Checkpoint v1.0").map_err(|e| {
631                OptimError::InvalidConfig(format!("Failed to write checkpoint header: {e}"))
632            })?;
633            writeln!(writer, "# Timestamp: {}", checkpoint.metadata.timestamp).map_err(|e| {
634                OptimError::InvalidConfig(format!("Failed to write timestamp: {e}"))
635            })?;
636            writeln!(
637                writer,
638                "# Optimizer Version: {}",
639                checkpoint.metadata.optimizerversion
640            )
641            .map_err(|e| OptimError::InvalidConfig(format!("Failed to write version: {e}")))?;
642            writeln!(writer, "# Step: {}", checkpoint.step)
643                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write step: {e}")))?;
644            writeln!(writer)
645                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write newline: {e}")))?;
646
647            // Write custom metadata
648            writeln!(writer, "[METADATA]").map_err(|e| {
649                OptimError::InvalidConfig(format!("Failed to write metadata section: {e}"))
650            })?;
651            for (key, value) in &checkpoint.metadata.custom {
652                writeln!(writer, "{}={}", key, value).map_err(|e| {
653                    OptimError::InvalidConfig(format!("Failed to write metadata entry: {e}"))
654                })?;
655            }
656            writeln!(writer)
657                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write newline: {e}")))?;
658
659            // Write global state
660            writeln!(writer, "[GLOBAL_STATE]").map_err(|e| {
661                OptimError::InvalidConfig(format!("Failed to write global state section: {e}"))
662            })?;
663            for (key, value) in &checkpoint.global_state {
664                writeln!(writer, "{}={}", key, value).map_err(|e| {
665                    OptimError::InvalidConfig(format!("Failed to write global state entry: {e}"))
666                })?;
667            }
668            writeln!(writer)
669                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write newline: {e}")))?;
670
671            // Write parameter groups
672            writeln!(writer, "[GROUPS]").map_err(|e| {
673                OptimError::InvalidConfig(format!("Failed to write groups section: {e}"))
674            })?;
675            writeln!(writer, "count={}", checkpoint.groups.len()).map_err(|e| {
676                OptimError::InvalidConfig(format!("Failed to write group count: {e}"))
677            })?;
678            writeln!(writer)
679                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write newline: {e}")))?;
680
681            for group in &checkpoint.groups {
682                // Write group header
683                writeln!(writer, "[GROUP_{}]", group.id).map_err(|e| {
684                    OptimError::InvalidConfig(format!("Failed to write group header: {e}"))
685                })?;
686
687                // Write group config
688                writeln!(
689                    writer,
690                    "learning_rate={}",
691                    group
692                        .config
693                        .learning_rate
694                        .map(|lr| lr.to_string())
695                        .unwrap_or_else(|| "None".to_string())
696                )
697                .map_err(|e| {
698                    OptimError::InvalidConfig(format!("Failed to write learning rate: {e}"))
699                })?;
700                writeln!(
701                    writer,
702                    "weight_decay={}",
703                    group
704                        .config
705                        .weight_decay
706                        .map(|wd| wd.to_string())
707                        .unwrap_or_else(|| "None".to_string())
708                )
709                .map_err(|e| {
710                    OptimError::InvalidConfig(format!("Failed to write weight decay: {e}"))
711                })?;
712                writeln!(
713                    writer,
714                    "momentum={}",
715                    group
716                        .config
717                        .momentum
718                        .map(|m| m.to_string())
719                        .unwrap_or_else(|| "None".to_string())
720                )
721                .map_err(|e| OptimError::InvalidConfig(format!("Failed to write momentum: {e}")))?;
722
723                // Write custom params
724                writeln!(
725                    writer,
726                    "custom_params_count={}",
727                    group.config.custom_params.len()
728                )
729                .map_err(|e| {
730                    OptimError::InvalidConfig(format!("Failed to write custom params count: {e}"))
731                })?;
732                for (key, value) in &group.config.custom_params {
733                    writeln!(writer, "custom_{}={}", key, value).map_err(|e| {
734                        OptimError::InvalidConfig(format!("Failed to write custom param: {e}"))
735                    })?;
736                }
737
738                // Write parameters
739                writeln!(writer, "param_count={}", group.params.len()).map_err(|e| {
740                    OptimError::InvalidConfig(format!("Failed to write param count: {e}"))
741                })?;
742                for (i, param) in group.params.iter().enumerate() {
743                    writeln!(writer, "param_{}shape={:?}", i, param.shape()).map_err(|e| {
744                        OptimError::InvalidConfig(format!("Failed to write param shape: {e}"))
745                    })?;
746                    write!(writer, "param_{}_data=", i).map_err(|e| {
747                        OptimError::InvalidConfig(format!("Failed to write param data label: {e}"))
748                    })?;
749
750                    // Write array data as space-separated values
751                    for (j, &val) in param.iter().enumerate() {
752                        if j > 0 {
753                            write!(writer, " ").map_err(|e| {
754                                OptimError::InvalidConfig(format!("Failed to write space: {e}"))
755                            })?;
756                        }
757                        write!(writer, "{}", val).map_err(|e| {
758                            OptimError::InvalidConfig(format!("Failed to write value: {e}"))
759                        })?;
760                    }
761                    writeln!(writer).map_err(|e| {
762                        OptimError::InvalidConfig(format!("Failed to write newline: {e}"))
763                    })?;
764                }
765
766                // Write optimizer state
767                writeln!(writer, "state_count={}", group.state.len()).map_err(|e| {
768                    OptimError::InvalidConfig(format!("Failed to write state count: {e}"))
769                })?;
770                for (state_name, state_arrays) in &group.state {
771                    writeln!(writer, "state_name={}", state_name).map_err(|e| {
772                        OptimError::InvalidConfig(format!("Failed to write state name: {e}"))
773                    })?;
774                    writeln!(writer, "state_array_count={}", state_arrays.len()).map_err(|e| {
775                        OptimError::InvalidConfig(format!("Failed to write state array count: {e}"))
776                    })?;
777                    for (i, array) in state_arrays.iter().enumerate() {
778                        writeln!(writer, "state_{}shape={:?}", i, array.shape()).map_err(|e| {
779                            OptimError::InvalidConfig(format!("Failed to write state shape: {e}"))
780                        })?;
781                        write!(writer, "state_{}_data=", i).map_err(|e| {
782                            OptimError::InvalidConfig(format!(
783                                "Failed to write state data label: {}",
784                                e
785                            ))
786                        })?;
787
788                        // Write array data
789                        for (j, &val) in array.iter().enumerate() {
790                            if j > 0 {
791                                write!(writer, " ").map_err(|e| {
792                                    OptimError::InvalidConfig(format!(
793                                        "Failed to write space: {}",
794                                        e
795                                    ))
796                                })?;
797                            }
798                            write!(writer, "{}", val).map_err(|e| {
799                                OptimError::InvalidConfig(format!("Failed to write value: {e}"))
800                            })?;
801                        }
802                        writeln!(writer).map_err(|e| {
803                            OptimError::InvalidConfig(format!("Failed to write newline: {e}"))
804                        })?;
805                    }
806                }
807
808                writeln!(writer).map_err(|e| {
809                    OptimError::InvalidConfig(format!("Failed to write newline: {e}"))
810                })?;
811            }
812
813            writer.flush().map_err(|e| {
814                OptimError::InvalidConfig(format!("Failed to flush checkpoint file: {e}"))
815            })?;
816
817            Ok(())
818        }
819
820        /// Load checkpoint from file (simple text format)
821        fn load_checkpoint<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
822            use std::fs::File;
823            use std::io::{BufRead, BufReader};
824
825            let path = path.as_ref();
826            let file = File::open(path).map_err(|e| {
827                OptimError::InvalidConfig(format!("Failed to open checkpoint file: {e}"))
828            })?;
829            let reader = BufReader::new(file);
830            let mut lines = reader.lines();
831
832            // Read header
833            let mut step = 0;
834            let mut optimizerversion = String::new();
835            let mut timestamp = String::new();
836
837            while let Some(Ok(line)) = lines.next() {
838                if line.starts_with("# Step: ") {
839                    step = line.trim_start_matches("# Step: ").parse().map_err(|_| {
840                        OptimError::InvalidConfig("Invalid step format".to_string())
841                    })?;
842                } else if line.starts_with("# Optimizer Version: ") {
843                    optimizerversion = line.trim_start_matches("# Optimizer Version: ").to_string();
844                } else if line.starts_with("# Timestamp: ") {
845                    timestamp = line.trim_start_matches("# Timestamp: ").to_string();
846                } else if line.starts_with("[METADATA]") {
847                    break;
848                }
849            }
850
851            // Read metadata
852            let mut custom_metadata = HashMap::new();
853            while let Some(Ok(line)) = lines.next() {
854                if line.is_empty() || line.starts_with("[") {
855                    if line.starts_with("[GLOBAL_STATE]") {
856                        break;
857                    }
858                    continue;
859                }
860                if let Some((key, value)) = line.split_once('=') {
861                    custom_metadata.insert(key.to_string(), value.to_string());
862                }
863            }
864
865            // Read global state
866            let mut global_state = HashMap::new();
867            while let Some(Ok(line)) = lines.next() {
868                if line.is_empty() || line.starts_with("[") {
869                    if line.starts_with("[GROUPS]") {
870                        break;
871                    }
872                    continue;
873                }
874                if let Some((key, value)) = line.split_once('=') {
875                    global_state.insert(key.to_string(), value.to_string());
876                }
877            }
878
879            // Read groups count
880            let mut group_count = 0;
881            while let Some(Ok(line)) = lines.next() {
882                if line.starts_with("count=") {
883                    group_count = line.trim_start_matches("count=").parse().map_err(|_| {
884                        OptimError::InvalidConfig("Invalid group count".to_string())
885                    })?;
886                    break;
887                }
888            }
889
890            // Read parameter groups
891            let mut groups = Vec::new();
892            for _ in 0..group_count {
893                // Skip to group header
894                let mut group_id = 0;
895                while let Some(Ok(line)) = lines.next() {
896                    if line.starts_with("[GROUP_") {
897                        let id_str = line.trim_start_matches("[GROUP_").trim_end_matches(']');
898                        group_id = id_str.parse().map_err(|_| {
899                            OptimError::InvalidConfig("Invalid group ID".to_string())
900                        })?;
901                        break;
902                    }
903                }
904
905                // Read group config
906                let mut learning_rate = None;
907                let mut weight_decay = None;
908                let mut momentum = None;
909                let mut custom_params = HashMap::new();
910                let mut _custom_params_count = 0;
911
912                while let Some(Ok(line)) = lines.next() {
913                    if line.starts_with("learning_rate=") {
914                        let val_str = line.trim_start_matches("learning_rate=");
915                        if val_str != "None" {
916                            learning_rate = Some(A::from_str(val_str).map_err(|_| {
917                                OptimError::InvalidConfig("Invalid learning rate".to_string())
918                            })?);
919                        }
920                    } else if line.starts_with("weight_decay=") {
921                        let val_str = line.trim_start_matches("weight_decay=");
922                        if val_str != "None" {
923                            weight_decay = Some(A::from_str(val_str).map_err(|_| {
924                                OptimError::InvalidConfig("Invalid weight decay".to_string())
925                            })?);
926                        }
927                    } else if line.starts_with("momentum=") {
928                        let val_str = line.trim_start_matches("momentum=");
929                        if val_str != "None" {
930                            momentum = Some(A::from_str(val_str).map_err(|_| {
931                                OptimError::InvalidConfig("Invalid momentum".to_string())
932                            })?);
933                        }
934                    } else if line.starts_with("custom_params_count=") {
935                        _custom_params_count = line
936                            .trim_start_matches("custom_params_count=")
937                            .parse()
938                            .map_err(|_| {
939                                OptimError::InvalidConfig("Invalid custom params count".to_string())
940                            })?;
941                    } else if line.starts_with("custom_") {
942                        if let Some((key_with_prefix, value)) = line.split_once('=') {
943                            let key = key_with_prefix.trim_start_matches("custom_");
944                            custom_params.insert(
945                                key.to_string(),
946                                A::from_str(value).map_err(|_| {
947                                    OptimError::InvalidConfig(
948                                        "Invalid custom param value".to_string(),
949                                    )
950                                })?,
951                            );
952                        }
953                    } else if line.starts_with("param_count=") {
954                        break;
955                    }
956                }
957
958                // Create group config
959                let config = ParameterGroupConfig {
960                    learning_rate,
961                    weight_decay,
962                    momentum,
963                    constraints: Vec::new(), // Constraints are not persisted in this simple format
964                    custom_params,
965                };
966
967                // Read parameters
968                let param_count: usize = lines
969                    .next()
970                    .ok_or_else(|| OptimError::InvalidConfig("Missing param count".to_string()))?
971                    .map_err(|e| OptimError::InvalidConfig(format!("Failed to read line: {e}")))?
972                    .trim_start_matches("param_count=")
973                    .parse()
974                    .map_err(|_| OptimError::InvalidConfig("Invalid param count".to_string()))?;
975
976                let mut params = Vec::new();
977                for i in 0..param_count {
978                    // Read shape
979                    let shape_line = lines
980                        .next()
981                        .ok_or_else(|| {
982                            OptimError::InvalidConfig("Missing param shape".to_string())
983                        })?
984                        .map_err(|e| {
985                            OptimError::InvalidConfig(format!("Failed to read line: {e}"))
986                        })?;
987
988                    let shape_str = shape_line
989                        .trim_start_matches(&format!("param_{}shape=", i))
990                        .trim_start_matches('[')
991                        .trim_end_matches(']');
992
993                    let shape: Vec<usize> = shape_str
994                        .split(", ")
995                        .map(|s| {
996                            s.parse()
997                                .map_err(|_| OptimError::InvalidConfig("Invalid shape".to_string()))
998                        })
999                        .collect::<Result<Vec<_>>>()?;
1000
1001                    // Read data
1002                    let data_line = lines
1003                        .next()
1004                        .ok_or_else(|| OptimError::InvalidConfig("Missing param data".to_string()))?
1005                        .map_err(|e| {
1006                            OptimError::InvalidConfig(format!("Failed to read line: {e}"))
1007                        })?;
1008
1009                    let data_str = data_line.trim_start_matches(&format!("param_{}_data=", i));
1010                    let data: Vec<A> = data_str
1011                        .split(' ')
1012                        .filter(|s| !s.is_empty())
1013                        .map(|s| {
1014                            A::from_str(s).map_err(|_| {
1015                                OptimError::InvalidConfig("Invalid data value".to_string())
1016                            })
1017                        })
1018                        .collect::<Result<Vec<_>>>()?;
1019
1020                    // Create array from shape and data with dynamic dimensions
1021                    let array: Array<A, scirs2_core::ndarray::IxDyn> =
1022                        Array::from_shape_vec(shape, data).map_err(|e| {
1023                            OptimError::InvalidConfig(format!("Failed to create array: {e}"))
1024                        })?;
1025                    params.push(array);
1026                }
1027
1028                // Read optimizer state
1029                let state_count: usize = lines
1030                    .next()
1031                    .ok_or_else(|| OptimError::InvalidConfig("Missing state count".to_string()))?
1032                    .map_err(|e| OptimError::InvalidConfig(format!("Failed to read line: {e}")))?
1033                    .trim_start_matches("state_count=")
1034                    .parse()
1035                    .map_err(|_| OptimError::InvalidConfig("Invalid state count".to_string()))?;
1036
1037                let mut state = HashMap::new();
1038                for _ in 0..state_count {
1039                    let state_name = lines
1040                        .next()
1041                        .ok_or_else(|| OptimError::InvalidConfig("Missing state name".to_string()))?
1042                        .map_err(|e| {
1043                            OptimError::InvalidConfig(format!("Failed to read line: {e}"))
1044                        })?
1045                        .trim_start_matches("state_name=")
1046                        .to_string();
1047
1048                    let array_count: usize = lines
1049                        .next()
1050                        .ok_or_else(|| {
1051                            OptimError::InvalidConfig("Missing state array count".to_string())
1052                        })?
1053                        .map_err(|e| {
1054                            OptimError::InvalidConfig(format!("Failed to read line: {e}"))
1055                        })?
1056                        .trim_start_matches("state_array_count=")
1057                        .parse()
1058                        .map_err(|_| {
1059                            OptimError::InvalidConfig("Invalid state array count".to_string())
1060                        })?;
1061
1062                    let mut state_arrays = Vec::new();
1063                    for i in 0..array_count {
1064                        // Read shape
1065                        let shape_line = lines
1066                            .next()
1067                            .ok_or_else(|| {
1068                                OptimError::InvalidConfig("Missing state shape".to_string())
1069                            })?
1070                            .map_err(|e| {
1071                                OptimError::InvalidConfig(format!("Failed to read line: {e}"))
1072                            })?;
1073
1074                        let shape_str = shape_line
1075                            .trim_start_matches(&format!("state_{}shape=", i))
1076                            .trim_start_matches('[')
1077                            .trim_end_matches(']');
1078
1079                        let shape: Vec<usize> = shape_str
1080                            .split(", ")
1081                            .map(|s| {
1082                                s.parse().map_err(|_| {
1083                                    OptimError::InvalidConfig("Invalid state shape".to_string())
1084                                })
1085                            })
1086                            .collect::<Result<Vec<_>>>()?;
1087
1088                        // Read data
1089                        let data_line = lines
1090                            .next()
1091                            .ok_or_else(|| {
1092                                OptimError::InvalidConfig("Missing state data".to_string())
1093                            })?
1094                            .map_err(|e| {
1095                                OptimError::InvalidConfig(format!("Failed to read line: {e}"))
1096                            })?;
1097
1098                        let data_str = data_line.trim_start_matches(&format!("state_{}_data=", i));
1099                        let data: Vec<A> = data_str
1100                            .split(' ')
1101                            .filter(|s| !s.is_empty())
1102                            .map(|s| {
1103                                A::from_str(s).map_err(|_| {
1104                                    OptimError::InvalidConfig("Invalid state value".to_string())
1105                                })
1106                            })
1107                            .collect::<Result<Vec<_>>>()?;
1108
1109                        // Create array with dynamic dimensions
1110                        let array = Array::from_shape_vec(shape, data).map_err(|e| {
1111                            OptimError::InvalidConfig(format!("Failed to create state array: {e}"))
1112                        })?;
1113                        state_arrays.push(array);
1114                    }
1115
1116                    state.insert(state_name, state_arrays);
1117                }
1118
1119                // Create group checkpoint
1120                groups.push(ParameterGroupCheckpoint {
1121                    id: group_id,
1122                    params,
1123                    config,
1124                    state,
1125                });
1126            }
1127
1128            // Create checkpoint metadata
1129            let mut metadata = CheckpointMetadata::new(optimizerversion);
1130            metadata.timestamp = timestamp;
1131            metadata.custom = custom_metadata;
1132
1133            // Create the checkpoint with dynamic dimensions
1134            let _dyn_checkpoint = OptimizerCheckpoint::<A, scirs2_core::ndarray::IxDyn> {
1135                step,
1136                groups,
1137                global_state,
1138                metadata,
1139            };
1140
1141            // Dimension conversion from IxDyn to D is a known limitation
1142            // Checkpoints are saved with dynamic dimensions (IxDyn) for flexibility,
1143            // but loading requires compile-time dimension type D.
1144            //
1145            // DESIGN NOTE: This is intentional for v1.0.0 to maintain type safety.
1146            // Users should use save_checkpoint() and create a new optimizer instance
1147            // rather than load_checkpoint() for cross-session restoration.
1148            //
1149            // For same-session checkpoint restoration, use CheckpointManager's
1150            // in-memory storage which preserves dimension types.
1151            //
1152            // Future enhancement (v1.1.0+): Add dimension-specific load methods
1153            // or provide a type-erased checkpoint interface.
1154            Err(OptimError::InvalidConfig(
1155                "Checkpoint loading from file with dimension type conversion is not supported in v1.0.0. \
1156                 Use CheckpointManager for in-memory checkpoints, or save/load with consistent dimension types. \
1157                 See documentation for checkpoint best practices.".to_string(),
1158            ))
1159        }
1160    }
1161
1162    /// In-memory checkpoint manager
1163    #[derive(Debug)]
1164    pub struct CheckpointManager<A: Float, D: Dimension> {
1165        checkpoints: HashMap<String, OptimizerCheckpoint<A, D>>,
1166        _maxcheckpoints: usize,
1167        checkpoint_keys: Vec<String>, // To maintain order for LRU eviction
1168    }
1169
1170    impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> CheckpointManager<A, D> {
1171        /// Create a new checkpoint manager
1172        pub fn new() -> Self {
1173            Self {
1174                checkpoints: HashMap::new(),
1175                _maxcheckpoints: 10,
1176                checkpoint_keys: Vec::new(),
1177            }
1178        }
1179
1180        /// Create a new checkpoint manager with maximum number of checkpoints
1181        pub fn with_max_checkpoints(_maxcheckpoints: usize) -> Self {
1182            Self {
1183                checkpoints: HashMap::new(),
1184                _maxcheckpoints,
1185                checkpoint_keys: Vec::new(),
1186            }
1187        }
1188
1189        /// Store a checkpoint with a given key
1190        pub fn store_checkpoint(&mut self, key: String, checkpoint: OptimizerCheckpoint<A, D>) {
1191            // If key already exists, update it
1192            if self.checkpoints.contains_key(&key) {
1193                self.checkpoints.insert(key.clone(), checkpoint);
1194                return;
1195            }
1196
1197            // If we're at capacity, remove oldest checkpoint
1198            if self.checkpoints.len() >= self._maxcheckpoints {
1199                if let Some(oldest_key) = self.checkpoint_keys.first().cloned() {
1200                    self.checkpoints.remove(&oldest_key);
1201                    self.checkpoint_keys.retain(|k| k != &oldest_key);
1202                }
1203            }
1204
1205            // Add new checkpoint
1206            self.checkpoints.insert(key.clone(), checkpoint);
1207            self.checkpoint_keys.push(key);
1208        }
1209
1210        /// Retrieve a checkpoint by key
1211        pub fn get_checkpoint(&self, key: &str) -> Option<&OptimizerCheckpoint<A, D>> {
1212            self.checkpoints.get(key)
1213        }
1214
1215        /// Remove a checkpoint by key
1216        pub fn remove_checkpoint(&mut self, key: &str) -> Option<OptimizerCheckpoint<A, D>> {
1217            self.checkpoint_keys.retain(|k| k != key);
1218            self.checkpoints.remove(key)
1219        }
1220
1221        /// List all checkpoint keys
1222        pub fn list_checkpoints(&self) -> &[String] {
1223            &self.checkpoint_keys
1224        }
1225
1226        /// Clear all checkpoints
1227        pub fn clear(&mut self) {
1228            self.checkpoints.clear();
1229            self.checkpoint_keys.clear();
1230        }
1231
1232        /// Get number of stored checkpoints
1233        pub fn len(&self) -> usize {
1234            self.checkpoints.len()
1235        }
1236
1237        /// Check if manager is empty
1238        pub fn is_empty(&self) -> bool {
1239            self.checkpoints.is_empty()
1240        }
1241    }
1242
1243    impl<A: Float + ScalarOperand + Debug, D: Dimension + Send + Sync> Default
1244        for CheckpointManager<A, D>
1245    {
1246        fn default() -> Self {
1247            Self::new()
1248        }
1249    }
1250
1251    /// Utility functions for checkpointing
1252    pub mod utils {
1253        use super::*;
1254
1255        /// Create a checkpoint from parameter groups
1256        pub fn create_checkpoint_from_groups<A: Float + ScalarOperand + Debug, D: Dimension>(
1257            step: usize,
1258            groups: &[ParameterGroup<A, D>],
1259            global_state: HashMap<String, String>,
1260            optimizerversion: String,
1261        ) -> OptimizerCheckpoint<A, D> {
1262            let group_checkpoints = groups
1263                .iter()
1264                .map(|group| ParameterGroupCheckpoint {
1265                    id: group.id,
1266                    params: group.params.clone(),
1267                    config: group.config.clone(),
1268                    state: group.state.clone(),
1269                })
1270                .collect();
1271
1272            OptimizerCheckpoint {
1273                step,
1274                groups: group_checkpoints,
1275                global_state,
1276                metadata: CheckpointMetadata::new(optimizerversion),
1277            }
1278        }
1279
1280        /// Validate checkpoint compatibility
1281        pub fn validate_checkpoint<A: Float, D: Dimension>(
1282            checkpoint: &OptimizerCheckpoint<A, D>,
1283            expected_groups: usize,
1284        ) -> Result<()> {
1285            if checkpoint.groups.len() != expected_groups {
1286                return Err(OptimError::InvalidConfig(format!(
1287                    "Checkpoint has {} groups, expected {expected_groups}",
1288                    checkpoint.groups.len()
1289                )));
1290            }
1291
1292            // Validate that all group IDs are unique
1293            let mut ids = std::collections::HashSet::new();
1294            for group in &checkpoint.groups {
1295                if !ids.insert(group.id) {
1296                    return Err(OptimError::InvalidConfig(format!(
1297                        "Duplicate group ID {} in checkpoint",
1298                        group.id
1299                    )));
1300                }
1301            }
1302
1303            Ok(())
1304        }
1305
1306        /// Get checkpoint summary information
1307        pub fn checkpoint_summary<A: Float, D: Dimension>(
1308            checkpoint: &OptimizerCheckpoint<A, D>,
1309        ) -> String {
1310            let total_params: usize = checkpoint
1311                .groups
1312                .iter()
1313                .map(|g| g.params.iter().map(|p| p.len()).sum::<usize>())
1314                .sum();
1315
1316            format!(
1317                "Checkpoint at step {}: {} groups, {} total parameters, created at {}",
1318                checkpoint.step,
1319                checkpoint.groups.len(),
1320                total_params,
1321                checkpoint.metadata.timestamp
1322            )
1323        }
1324    }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::linalg::jacobi_eigen_symmetric;
1330    use super::*;
1331    use scirs2_core::ndarray::{Array1, Array2};
1332
1333    #[test]
1334    fn test_parameter_group_config() {
1335        let config = ParameterGroupConfig::new()
1336            .with_learning_rate(0.01)
1337            .with_weight_decay(0.0001)
1338            .with_momentum(0.9)
1339            .with_custom_param("beta1".to_string(), 0.9)
1340            .with_custom_param("beta2".to_string(), 0.999);
1341
1342        assert_eq!(config.learning_rate, Some(0.01));
1343        assert_eq!(config.weight_decay, Some(0.0001));
1344        assert_eq!(config.momentum, Some(0.9));
1345        assert_eq!(config.custom_params.get("beta1"), Some(&0.9));
1346        assert_eq!(config.custom_params.get("beta2"), Some(&0.999));
1347    }
1348
1349    #[test]
1350    fn test_parameter_group() {
1351        let params = vec![Array1::zeros(5), Array1::ones(3)];
1352        let config = ParameterGroupConfig::new().with_learning_rate(0.01);
1353
1354        let group = ParameterGroup::new(0, params, config);
1355
1356        assert_eq!(group.id, 0);
1357        assert_eq!(group.num_params(), 2);
1358        assert_eq!(group.learning_rate(0.001), 0.01);
1359        assert_eq!(group.weight_decay(0.0), 0.0);
1360    }
1361
1362    #[test]
1363    fn test_group_manager() {
1364        let mut manager: GroupManager<f64, scirs2_core::ndarray::Ix1> = GroupManager::new();
1365
1366        // Add first group
1367        let params1 = vec![Array1::zeros(5)];
1368        let config1 = ParameterGroupConfig::new().with_learning_rate(0.01);
1369        let id1 = manager.add_group(params1, config1);
1370
1371        // Add second group
1372        let params2 = vec![Array1::ones(3), Array1::zeros(4)];
1373        let config2 = ParameterGroupConfig::new().with_learning_rate(0.001);
1374        let id2 = manager.add_group(params2, config2);
1375
1376        assert_eq!(id1, 0);
1377        assert_eq!(id2, 1);
1378        assert_eq!(manager.groups().len(), 2);
1379        assert_eq!(manager.total_params(), 3);
1380
1381        // Test group access
1382        let group1 = manager
1383            .get_group(id1)
1384            .expect("manager.get_group succeeds in test_group_manager");
1385        assert_eq!(group1.learning_rate(0.0), 0.01);
1386
1387        let group2 = manager
1388            .get_group(id2)
1389            .expect("manager.get_group succeeds in test_group_manager");
1390        assert_eq!(group2.learning_rate(0.0), 0.001);
1391    }
1392
1393    #[test]
1394    fn test_parameter_constraints() {
1395        use approx::assert_relative_eq;
1396
1397        // Test value clipping
1398        let mut params = Array1::from_vec(vec![-2.0, 0.5, 3.0]);
1399        let clip_constraint = ParameterConstraint::ValueClip { min: 0.0, max: 1.0 };
1400        clip_constraint
1401            .apply(&mut params)
1402            .expect("clip_constraint.apply succeeds in test_parameter_constraints");
1403        assert_eq!(
1404            params
1405                .as_slice()
1406                .expect("params.as_slice succeeds in test_parameter_constraints"),
1407            &[0.0, 0.5, 1.0]
1408        );
1409
1410        // Test L2 norm constraint
1411        let mut params = Array1::from_vec(vec![3.0, 4.0]); // norm = 5
1412        let l2_constraint = ParameterConstraint::L2NormConstraint { maxnorm: 2.0 };
1413        l2_constraint
1414            .apply(&mut params)
1415            .expect("l2_constraint.apply succeeds in test_parameter_constraints");
1416        let new_norm = params.mapv(|x| x * x).sum().sqrt();
1417        assert_relative_eq!(new_norm, 2.0, epsilon = 1e-6);
1418
1419        // Test non-negativity constraint
1420        let mut params = Array1::from_vec(vec![-1.0, 2.0, -3.0]);
1421        let non_neg_constraint = ParameterConstraint::NonNegative;
1422        non_neg_constraint
1423            .apply(&mut params)
1424            .expect("apply succeeds in test_parameter_constraints");
1425        assert_eq!(
1426            params
1427                .as_slice()
1428                .expect("params.as_slice succeeds in test_parameter_constraints"),
1429            &[0.0, 2.0, 0.0]
1430        );
1431
1432        // Test unit sphere constraint
1433        let mut params = Array1::from_vec(vec![3.0, 4.0]); // norm = 5
1434        let unit_sphere_constraint = ParameterConstraint::UnitSphere;
1435        unit_sphere_constraint
1436            .apply(&mut params)
1437            .expect("apply succeeds in test_parameter_constraints");
1438        let new_norm = params.mapv(|x| x * x).sum().sqrt();
1439        assert_relative_eq!(new_norm, 1.0, epsilon = 1e-6);
1440    }
1441
1442    #[test]
1443    fn test_parameter_group_with_constraints() {
1444        let params = vec![Array1::from_vec(vec![-2.0, 3.0])];
1445        let config = ParameterGroupConfig::new()
1446            .with_learning_rate(0.01)
1447            .with_value_clip(0.0, 1.0);
1448
1449        let mut group = ParameterGroup::new(0, params, config);
1450
1451        // Apply constraints
1452        group
1453            .apply_constraints()
1454            .expect("group.apply_constraints succeeds in test_parameter_group_with_constraints");
1455
1456        // Check that constraints were applied
1457        assert_eq!(
1458            group.params[0]
1459                .as_slice()
1460                .expect("as_slice succeeds in test_parameter_group_with_constraints"),
1461            &[0.0, 1.0]
1462        );
1463    }
1464
1465    #[test]
1466    fn test_parameter_config_builder() {
1467        let config = ParameterGroupConfig::new()
1468            .with_learning_rate(0.01)
1469            .with_l2_norm_constraint(1.0)
1470            .with_non_negative()
1471            .with_custom_param("beta".to_string(), 0.9);
1472
1473        assert_eq!(config.learning_rate, Some(0.01));
1474        assert_eq!(config.constraints.len(), 2);
1475        assert_eq!(config.custom_params.get("beta"), Some(&0.9));
1476    }
1477
1478    #[test]
1479    fn test_simplex_constraint() {
1480        use approx::assert_relative_eq;
1481
1482        // Test simplex constraint with positive values
1483        let mut params = Array1::from_vec(vec![2.0, 3.0, 5.0]);
1484        let simplex_constraint = ParameterConstraint::Simplex;
1485        simplex_constraint
1486            .apply(&mut params)
1487            .expect("apply succeeds in test_simplex_constraint");
1488
1489        // Check that values sum to 1 and are non-negative
1490        let sum: f64 = params.sum();
1491        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
1492        assert!(params.iter().all(|&x| x >= 0.0));
1493
1494        // Values should be proportional to original
1495        assert_relative_eq!(params[0], 0.2, epsilon = 1e-6); // 2/10
1496        assert_relative_eq!(params[1], 0.3, epsilon = 1e-6); // 3/10
1497        assert_relative_eq!(params[2], 0.5, epsilon = 1e-6); // 5/10
1498    }
1499
1500    #[test]
1501    fn test_simplex_constraint_with_negatives() {
1502        use approx::assert_relative_eq;
1503
1504        // Test simplex constraint with negative values
1505        let mut params = Array1::from_vec(vec![-1.0, 2.0, 3.0]);
1506        let simplex_constraint = ParameterConstraint::Simplex;
1507        simplex_constraint
1508            .apply(&mut params)
1509            .expect("apply succeeds in test_simplex_constraint_with_negatives");
1510
1511        // Check that values sum to 1 and are non-negative
1512        let sum: f64 = params.sum();
1513        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
1514        assert!(params.iter().all(|&x| x >= 0.0));
1515
1516        // Negative value should become 0, others normalized
1517        assert_relative_eq!(params[0], 0.0, epsilon = 1e-6);
1518        assert_relative_eq!(params[1], 0.4, epsilon = 1e-6); // 2/5
1519        assert_relative_eq!(params[2], 0.6, epsilon = 1e-6); // 3/5
1520    }
1521
1522    #[test]
1523    fn test_simplex_constraint_all_zeros() {
1524        use approx::assert_relative_eq;
1525
1526        // Test simplex constraint with all zeros
1527        let mut params = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1528        let simplex_constraint = ParameterConstraint::Simplex;
1529        simplex_constraint
1530            .apply(&mut params)
1531            .expect("apply succeeds in test_simplex_constraint_all_zeros");
1532
1533        // Should result in uniform distribution
1534        let sum: f64 = params.sum();
1535        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
1536        for &val in params.iter() {
1537            assert_relative_eq!(val, 1.0 / 3.0, epsilon = 1e-6);
1538        }
1539    }
1540
1541    #[test]
1542    fn test_spectral_norm_constraint() {
1543        use approx::assert_relative_eq;
1544        use scirs2_core::ndarray::arr2;
1545
1546        // A 1x2 matrix has a single nonzero singular value σ_max = ‖row‖ = 5.
1547        let mut params = arr2(&[[3.0, 4.0]]);
1548        let spectral_constraint = ParameterConstraint::SpectralNorm { maxnorm: 2.0 };
1549        spectral_constraint
1550            .apply(&mut params)
1551            .expect("apply succeeds in test_spectral_norm_constraint");
1552
1553        // After scaling by 2/5 the spectral norm equals the cap.
1554        let sigma = power_iteration_spectral_norm(&params);
1555        assert_relative_eq!(sigma, 2.0, epsilon = 1e-6);
1556    }
1557
1558    #[test]
1559    fn test_nuclear_norm_constraint() {
1560        use approx::assert_relative_eq;
1561        use scirs2_core::ndarray::arr2;
1562
1563        // Diagonal matrix ⇒ singular values are |diagonal|: {3, 4, 2}, ‖·‖_* = 9.
1564        let mut params = arr2(&[[3.0, 0.0, 0.0], [0.0, -4.0, 0.0], [0.0, 0.0, 2.0]]);
1565        let nuclear_constraint = ParameterConstraint::NuclearNorm { maxnorm: 3.0 };
1566        nuclear_constraint
1567            .apply(&mut params)
1568            .expect("apply succeeds in test_nuclear_norm_constraint");
1569
1570        // Projection onto the L1 ball of the spectrum {4, 3, 2} with radius 3
1571        // uses θ = 2, leaving {2, 1, 0}. Entrywise L1 scaling would instead have
1572        // produced 3/9 · [3, -4, 2] = [1, -1.333, 0.667].
1573        let new_nuclear_norm = nuclear_norm_of_matrix(&params);
1574        assert_relative_eq!(new_nuclear_norm, 3.0, epsilon = 1e-6);
1575        assert_relative_eq!(params[[0, 0]], 1.0, epsilon = 1e-6);
1576        assert_relative_eq!(params[[1, 1]], -2.0, epsilon = 1e-6);
1577        assert_relative_eq!(params[[2, 2]], 0.0, epsilon = 1e-6);
1578    }
1579
1580    #[test]
1581    fn test_nuclear_norm_constraint_rejects_non_matrix() {
1582        // The nuclear norm is only defined for matrices; a 1-D array errors out.
1583        let mut params = Array1::from_vec(vec![3.0, -4.0, 2.0]);
1584        let nuclear_constraint = ParameterConstraint::NuclearNorm { maxnorm: 3.0 };
1585
1586        match nuclear_constraint.apply(&mut params) {
1587            Ok(()) => panic!("nuclear norm constraint must reject 1-D parameters"),
1588            Err(err) => assert!(err.to_string().contains("2D arrays")),
1589        }
1590    }
1591
1592    #[test]
1593    fn test_orthogonal_constraint_error() {
1594        // Test that orthogonal constraint returns appropriate error
1595        let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1596        let orthogonal_constraint = ParameterConstraint::Orthogonal { tolerance: 1e-6 };
1597        let result = orthogonal_constraint.apply(&mut params);
1598
1599        assert!(result.is_err());
1600        assert!(result.unwrap_err().to_string().contains("2D arrays"));
1601    }
1602
1603    #[test]
1604    fn test_positive_definite_constraint_error() {
1605        // A 1D array is not a matrix, so the positive-definite constraint errors.
1606        let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1607        let pd_constraint = ParameterConstraint::PositiveDefinite {
1608            mineigenvalue: 0.01,
1609        };
1610        let result = pd_constraint.apply(&mut params);
1611
1612        assert!(result.is_err());
1613        assert!(result.unwrap_err().to_string().contains("2D arrays"));
1614    }
1615
1616    #[test]
1617    fn test_enhanced_config_builder() {
1618        let config = ParameterGroupConfig::new()
1619            .with_learning_rate(0.01)
1620            .with_simplex()
1621            .with_spectral_norm(2.0)
1622            .with_nuclear_norm(1.5)
1623            .with_custom_constraint("my_constraint".to_string());
1624
1625        assert_eq!(config.learning_rate, Some(0.01));
1626        assert_eq!(config.constraints.len(), 4);
1627
1628        // Check that the right constraint types were added
1629        match &config.constraints[0] {
1630            ParameterConstraint::Simplex => (),
1631            _ => panic!("Expected Simplex constraint"),
1632        }
1633
1634        match &config.constraints[1] {
1635            ParameterConstraint::SpectralNorm { maxnorm } => {
1636                assert_eq!(*maxnorm, 2.0);
1637            }
1638            _ => panic!("Expected SpectralNorm constraint"),
1639        }
1640    }
1641
1642    #[test]
1643    fn test_constraint_combination() {
1644        use approx::assert_relative_eq;
1645
1646        // Test applying multiple constraints in sequence
1647        let params = vec![Array1::from_vec(vec![-1.0, 2.0, 3.0])];
1648        let config = ParameterGroupConfig::new()
1649            .with_learning_rate(0.01)
1650            .with_non_negative()
1651            .with_simplex();
1652
1653        let mut group = ParameterGroup::new(0, params, config);
1654
1655        // Apply constraints
1656        group
1657            .apply_constraints()
1658            .expect("group.apply_constraints succeeds in test_constraint_combination");
1659
1660        // Check that both non-negative and simplex constraints were applied
1661        let result = &group.params[0];
1662        let sum: f64 = result.sum();
1663        assert_relative_eq!(sum, 1.0, epsilon = 1e-6);
1664        assert!(result.iter().all(|&x| x >= 0.0));
1665
1666        // Should be [0, 0.4, 0.6] after non-negative then simplex
1667        assert_relative_eq!(result[0], 0.0, epsilon = 1e-6);
1668        assert_relative_eq!(result[1], 0.4, epsilon = 1e-6);
1669        assert_relative_eq!(result[2], 0.6, epsilon = 1e-6);
1670    }
1671
1672    // -----------------------------------------------------------------------
1673    // Matrix constraints: Orthogonal, SpectralNorm, PositiveDefinite.
1674    // -----------------------------------------------------------------------
1675
1676    /// Compute MᵀM for a 2D array (used to verify orthonormal columns).
1677    fn gram_matrix(m: &Array2<f64>) -> Array2<f64> {
1678        let (rows, cols) = m.dim();
1679        let mut g = Array2::<f64>::zeros((cols, cols));
1680        for i in 0..cols {
1681            for j in 0..cols {
1682                let mut dot = 0.0;
1683                for k in 0..rows {
1684                    dot += m[[k, i]] * m[[k, j]];
1685                }
1686                g[[i, j]] = dot;
1687            }
1688        }
1689        g
1690    }
1691
1692    #[test]
1693    fn test_orthogonal_constraint_square() {
1694        use approx::assert_abs_diff_eq;
1695        use scirs2_core::ndarray::arr2;
1696
1697        // Non-orthonormal 3x3 matrix.
1698        let mut params = arr2(&[[1.0, 2.0, 0.0], [0.0, 1.0, 1.0], [1.0, 0.0, 1.0]]);
1699        let constraint = ParameterConstraint::Orthogonal { tolerance: 1e-10 };
1700        constraint.apply(&mut params).expect("constraint failed");
1701
1702        // Columns must be orthonormal: MᵀM ≈ I.
1703        let g = gram_matrix(&params);
1704        for i in 0..3 {
1705            for j in 0..3 {
1706                let target = if i == j { 1.0 } else { 0.0 };
1707                assert_abs_diff_eq!(g[[i, j]], target, epsilon = 1e-9);
1708            }
1709        }
1710    }
1711
1712    #[test]
1713    fn test_orthogonal_constraint_tall() {
1714        use approx::assert_abs_diff_eq;
1715        use scirs2_core::ndarray::arr2;
1716
1717        // Non-square 4x2 matrix: orthonormalize the 2 columns.
1718        let mut params = arr2(&[[1.0, 1.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]);
1719        let constraint = ParameterConstraint::Orthogonal { tolerance: 1e-10 };
1720        constraint.apply(&mut params).expect("constraint failed");
1721
1722        // MᵀM (2x2) must be the identity.
1723        let g = gram_matrix(&params);
1724        for i in 0..2 {
1725            for j in 0..2 {
1726                let target = if i == j { 1.0 } else { 0.0 };
1727                assert_abs_diff_eq!(g[[i, j]], target, epsilon = 1e-9);
1728            }
1729        }
1730    }
1731
1732    #[test]
1733    fn test_orthogonal_constraint_already_orthonormal_unchanged() {
1734        use approx::assert_abs_diff_eq;
1735        use scirs2_core::ndarray::arr2;
1736
1737        // Identity is already orthonormal; must be left untouched (early return).
1738        let mut params = arr2(&[[1.0, 0.0], [0.0, 1.0]]);
1739        let original = params.clone();
1740        let constraint = ParameterConstraint::Orthogonal { tolerance: 1e-8 };
1741        constraint.apply(&mut params).expect("constraint failed");
1742
1743        for (a, b) in params.iter().zip(original.iter()) {
1744            assert_abs_diff_eq!(*a, *b, epsilon = 1e-12);
1745        }
1746    }
1747
1748    #[test]
1749    fn test_orthogonal_constraint_1d_errors() {
1750        use scirs2_core::ndarray::Array1;
1751        let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1752        let constraint = ParameterConstraint::Orthogonal { tolerance: 1e-6 };
1753        let result = constraint.apply(&mut params);
1754        assert!(result.is_err());
1755        assert!(result.unwrap_err().to_string().contains("2D arrays"));
1756    }
1757
1758    #[test]
1759    fn test_spectral_norm_constraint_matrix() {
1760        use scirs2_core::ndarray::arr2;
1761
1762        // Diagonal matrix with singular values {5, 1}; cap below the larger one.
1763        let mut params = arr2(&[[5.0, 0.0], [0.0, 1.0]]);
1764        let maxnorm = 2.0;
1765        let constraint = ParameterConstraint::SpectralNorm { maxnorm };
1766        constraint.apply(&mut params).expect("constraint failed");
1767
1768        // Recompute the spectral norm (largest singular value) and verify ≤ cap.
1769        let sigma = power_iteration_spectral_norm(&params);
1770        assert!(
1771            sigma <= maxnorm + 1e-6,
1772            "spectral norm {sigma} exceeds cap {maxnorm}"
1773        );
1774        // It should be scaled to (approximately) the cap, not collapsed.
1775        assert!(
1776            sigma > maxnorm - 1e-3,
1777            "spectral norm {sigma} undershot cap"
1778        );
1779    }
1780
1781    #[test]
1782    fn test_spectral_norm_constraint_nondiagonal() {
1783        use scirs2_core::ndarray::arr2;
1784
1785        // A non-diagonal matrix whose true σ_max is well above the cap.
1786        let mut params = arr2(&[[3.0, 1.0], [1.0, 3.0], [2.0, -2.0]]);
1787        let maxnorm = 1.5;
1788        let constraint = ParameterConstraint::SpectralNorm { maxnorm };
1789        constraint.apply(&mut params).expect("constraint failed");
1790
1791        let sigma = power_iteration_spectral_norm(&params);
1792        assert!(
1793            sigma <= maxnorm + 1e-5,
1794            "spectral norm {sigma} exceeds cap {maxnorm}"
1795        );
1796    }
1797
1798    #[test]
1799    fn test_spectral_norm_constraint_under_cap_unchanged() {
1800        use approx::assert_abs_diff_eq;
1801        use scirs2_core::ndarray::arr2;
1802
1803        // σ_max here is 1.0 (identity-like); cap of 10 leaves it untouched.
1804        let mut params = arr2(&[[1.0, 0.0], [0.0, 1.0]]);
1805        let original = params.clone();
1806        let constraint = ParameterConstraint::SpectralNorm { maxnorm: 10.0 };
1807        constraint.apply(&mut params).expect("constraint failed");
1808
1809        for (a, b) in params.iter().zip(original.iter()) {
1810            assert_abs_diff_eq!(*a, *b, epsilon = 1e-12);
1811        }
1812    }
1813
1814    #[test]
1815    fn test_positive_definite_constraint_indefinite() {
1816        use scirs2_core::ndarray::arr2;
1817
1818        // Symmetric indefinite matrix: eigenvalues are {3, -1}.
1819        let mut params = arr2(&[[1.0, 2.0], [2.0, 1.0]]);
1820        let min_eig = 0.0;
1821        let constraint = ParameterConstraint::PositiveDefinite {
1822            mineigenvalue: min_eig,
1823        };
1824        constraint.apply(&mut params).expect("constraint failed");
1825
1826        // Verify all eigenvalues of the result are ≥ min_eig via Jacobi.
1827        let (eigvals, _) = jacobi_eigen_symmetric(&params);
1828        for &lambda in eigvals.iter() {
1829            assert!(
1830                lambda >= min_eig - 1e-8,
1831                "eigenvalue {lambda} below floor {min_eig}"
1832            );
1833        }
1834
1835        // And xᵀMx ≥ 0 for several probe vectors (PSD check).
1836        let probes = [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, -1.0], [2.0, -3.0]];
1837        for p in probes.iter() {
1838            let mut quad = 0.0;
1839            for i in 0..2 {
1840                for j in 0..2 {
1841                    quad += p[i] * params[[i, j]] * p[j];
1842                }
1843            }
1844            assert!(quad >= -1e-8, "xᵀMx = {quad} is negative");
1845        }
1846    }
1847
1848    #[test]
1849    fn test_positive_definite_constraint_positive_floor() {
1850        use scirs2_core::ndarray::arr2;
1851
1852        // Same indefinite matrix, but require a strictly positive floor.
1853        let mut params = arr2(&[[0.0, 1.0], [1.0, 0.0]]); // eigenvalues {1, -1}
1854        let min_eig = 0.5;
1855        let constraint = ParameterConstraint::PositiveDefinite {
1856            mineigenvalue: min_eig,
1857        };
1858        constraint.apply(&mut params).expect("constraint failed");
1859
1860        let (eigvals, _) = jacobi_eigen_symmetric(&params);
1861        for &lambda in eigvals.iter() {
1862            assert!(
1863                lambda >= min_eig - 1e-8,
1864                "eigenvalue {lambda} below floor {min_eig}"
1865            );
1866        }
1867    }
1868
1869    #[test]
1870    fn test_positive_definite_constraint_already_pd_unchanged() {
1871        use approx::assert_abs_diff_eq;
1872        use scirs2_core::ndarray::arr2;
1873
1874        // Already PD (eigenvalues {3, 1}); a floor of 0 must leave it ~unchanged.
1875        let mut params = arr2(&[[2.0, 1.0], [1.0, 2.0]]);
1876        let original = params.clone();
1877        let constraint = ParameterConstraint::PositiveDefinite { mineigenvalue: 0.0 };
1878        constraint.apply(&mut params).expect("constraint failed");
1879
1880        for (a, b) in params.iter().zip(original.iter()) {
1881            assert_abs_diff_eq!(*a, *b, epsilon = 1e-8);
1882        }
1883    }
1884
1885    #[test]
1886    fn test_positive_definite_constraint_non_square_errors() {
1887        use scirs2_core::ndarray::arr2;
1888        let mut params = arr2(&[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
1889        let constraint = ParameterConstraint::PositiveDefinite { mineigenvalue: 0.0 };
1890        let result = constraint.apply(&mut params);
1891        assert!(result.is_err());
1892        assert!(result.unwrap_err().to_string().contains("square"));
1893    }
1894}