Skip to main content

fx_durable_ga/models/
distribution.rs

1use crate::models::{Gene, Morphology};
2use serde::{Deserialize, Serialize};
3use tracing::instrument;
4
5/// Strategy for generating initial populations in genetic algorithms.
6///
7/// The distribution strategy determines how the initial population is spread across
8/// the search space. Different strategies have distinct characteristics that can
9/// significantly impact optimization performance, especially during early generations.
10///
11/// # Strategy Comparison
12///
13/// **Latin Hypercube Sampling**: Provides superior space coverage by ensuring
14/// each parameter dimension is evenly sampled. This typically leads to better
15/// initial diversity and faster convergence.
16///
17/// **Random Sampling**: Simple uniform random distribution across the search space.
18/// May result in clustered solutions or gaps in coverage, but requires no additional
19/// computation.
20///
21/// # When to Choose Each Strategy
22///
23/// - **Latin Hypercube**: Recommended for most optimization problems, especially when:
24///   - You have limited population budget (small population sizes)
25///   - The search space is large or complex
26///   - You need consistent, reproducible space coverage
27///   - You want faster initial convergence
28///
29/// - **Random**: Consider when:
30///   - You have very large population sizes (>1000)
31///   - Computational efficiency is critical
32///   - You're comparing against baseline random methods
33///   - The search space has unusual constraints that Latin Hypercube might not handle well
34///
35/// # Performance Impact
36///
37/// Latin Hypercube sampling typically provides 10-30% better initial population
38/// quality (measured by best fitness found) compared to random sampling, with
39/// the advantage being most pronounced for smaller populations (20-200 individuals).
40///
41/// # Examples
42///
43/// ```rust
44/// use fx_durable_ga::models::Distribution;
45///
46/// // Recommended: Latin Hypercube for better space coverage
47/// let lhc_dist = Distribution::latin_hypercube(50);
48///
49/// // Simple random distribution
50/// let random_dist = Distribution::random(50);
51///
52/// // Large population where random is acceptable
53/// let large_random = Distribution::random(1000);
54///
55/// // Small population where Latin Hypercube shines
56/// let small_lhc = Distribution::latin_hypercube(20);
57/// ```
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub enum Distribution {
60    /// Latin Hypercube sampling ensures even coverage across all parameter dimensions.
61    ///
62    /// This method divides each parameter's range into equal intervals and ensures
63    /// exactly one sample falls in each interval. The intervals are then randomly
64    /// shuffled across dimensions to decorrelate parameters while maintaining
65    /// uniform marginal distributions.
66    ///
67    /// **Advantages**:
68    /// - Superior space coverage with fewer samples
69    /// - More consistent results across runs
70    /// - Better representation of parameter interactions
71    /// - Typically finds better initial solutions
72    ///
73    /// **Best for**: Small to medium population sizes (10-500), complex search spaces
74    LatinHypercube {
75        /// Number of individuals in the initial population.
76        /// Each individual will be positioned to maximize overall space coverage.
77        population_size: u32,
78    },
79
80    /// Pure random sampling generates individuals uniformly at random across the search space.
81    ///
82    /// Each parameter is independently sampled from its allowed range without
83    /// coordination between dimensions or individuals. This can result in
84    /// uneven coverage with potential clusters and gaps.
85    ///
86    /// **Advantages**:
87    /// - Simple and fast generation
88    /// - No assumptions about search space structure
89    /// - Familiar and well-understood behavior
90    ///
91    /// **Best for**: Very large populations (>1000), simple search spaces, baseline comparisons
92    Random {
93        /// Number of individuals to generate randomly.
94        /// Larger populations help compensate for potential clustering and coverage gaps.
95        population_size: u32,
96    },
97}
98
99impl Distribution {
100    /// Creates a Latin Hypercube distribution strategy for superior space coverage.
101    ///
102    /// Latin Hypercube sampling provides better initial population diversity by ensuring
103    /// that each parameter dimension is sampled evenly across its range. This typically
104    /// results in better optimization performance, especially for smaller populations.
105    ///
106    /// # Parameters
107    /// - `population_size`: Number of individuals to generate. For best results, use 10-500.
108    ///   Very small populations (<10) may not fully benefit from the structured sampling,
109    ///   while very large populations (>1000) may not justify the additional computation.
110    ///
111    /// # When to Use
112    /// - **Recommended default** for most optimization problems
113    /// - Complex, multi-dimensional search spaces
114    /// - When population budget is limited
115    /// - When you need consistent, reproducible coverage
116    ///
117    /// # Examples
118    /// ```rust
119    /// use fx_durable_ga::models::Distribution;
120    ///
121    /// // Standard population size with excellent coverage
122    /// let dist = Distribution::latin_hypercube(50);
123    ///
124    /// // Small population where coverage is critical  
125    /// let small_dist = Distribution::latin_hypercube(20);
126    ///
127    /// // Medium population for complex problems
128    /// let medium_dist = Distribution::latin_hypercube(200);
129    /// ```
130    pub fn latin_hypercube(population_size: u32) -> Self {
131        Distribution::LatinHypercube { population_size }
132    }
133
134    /// Creates a random distribution strategy for simple uniform sampling.
135    ///
136    /// Random sampling generates each individual independently by sampling each
137    /// parameter uniformly from its allowed range. While simple, this can result
138    /// in uneven coverage with potential clustering or gaps in the search space.
139    ///
140    /// # Parameters  
141    /// - `population_size`: Number of individuals to generate. Larger populations
142    ///   (>100) help compensate for potential clustering effects.
143    ///
144    /// # When to Use
145    /// - Very large populations (>1000) where coverage gaps are less significant
146    /// - Computational efficiency is critical
147    /// - Comparing against baseline random methods
148    /// - Simple, low-dimensional search spaces
149    /// - When search space has constraints that might interfere with structured sampling
150    ///
151    /// # Examples
152    /// ```rust
153    /// use fx_durable_ga::models::Distribution;
154    ///
155    /// // Large population where random sampling is acceptable
156    /// let large_dist = Distribution::random(1000);
157    ///
158    /// // Baseline comparison distribution
159    /// let baseline_dist = Distribution::random(50);
160    ///
161    /// // High-speed generation for simple problems
162    /// let fast_dist = Distribution::random(100);
163    /// ```
164    pub fn random(population_size: u32) -> Self {
165        Distribution::Random { population_size }
166    }
167
168    /// Generates initial population genomes according to this distribution strategy.
169    #[instrument(level = "debug", skip(self, morphology), fields(distribution = ?self, morphology_dimensions = morphology.gene_bounds.len()))]
170    pub(crate) fn distribute(&self, morphology: &Morphology) -> Vec<Vec<Gene>> {
171        match self {
172            Distribution::LatinHypercube { population_size } => {
173                latin_hypercube(*population_size as usize, morphology)
174            }
175            Distribution::Random { population_size } => {
176                random_distribution(*population_size as usize, morphology)
177            }
178        }
179    }
180}
181
182/// Generates genomes using pure random sampling across the search space.
183///
184/// Each genome is created by independently sampling each gene from its corresponding
185/// parameter bounds. This provides no coordination between individuals or parameters,
186/// which can result in clustering or coverage gaps but is computationally efficient.
187///
188/// # Algorithm
189/// 1. For each individual in the population:
190///    - Generate each gene independently using the morphology's random() method
191///    - Each gene is sampled uniformly from [0, steps-1] for that parameter
192/// 2. Return all generated genomes
193///
194/// # Performance
195/// - Time complexity: O(n_samples * n_dimensions)
196/// - Space complexity: O(n_samples * n_dimensions)
197/// - Very fast generation with no inter-individual coordination
198#[instrument(level = "debug", skip(morphology), fields(n_samples = n_samples, n_dimensions = morphology.gene_bounds.len()))]
199fn random_distribution(n_samples: usize, morphology: &Morphology) -> Vec<Vec<Gene>> {
200    let mut genomes = Vec::with_capacity(n_samples);
201
202    for _ in 0..n_samples {
203        let genome = morphology.random();
204        genomes.push(genome);
205    }
206
207    genomes
208}
209
210/// Generates genomes using Latin Hypercube sampling for superior space coverage.
211///
212/// Latin Hypercube sampling ensures that each parameter dimension is evenly sampled
213/// by dividing each parameter's range into n_samples intervals and placing exactly
214/// one sample in each interval. The intervals are then randomly shuffled across
215/// dimensions to decorrelate parameters while maintaining uniform marginal distributions.
216///
217/// # Algorithm
218/// 1. For each parameter dimension:
219///    - Create n_samples evenly spaced intervals: [0, 1/n), [1/n, 2/n), ..., [(n-1)/n, 1]
220///    - Sample from the center of each interval: 0.5/n, 1.5/n, ..., (n-0.5)/n
221///    - Shuffle these samples to decorrelate with other dimensions
222///    - Convert normalized samples to discrete gene indices using from_sample()
223/// 2. Transpose the results to create genomes (each genome gets one gene from each dimension)
224///
225/// # Properties
226/// - **Even marginal distributions**: Each parameter dimension has exactly uniform coverage
227/// - **Decorrelated dimensions**: Shuffling ensures parameter independence
228/// - **No clustering**: Guaranteed even spacing within each dimension
229/// - **Deterministic coverage**: Same quality regardless of random seed (only ordering varies)
230///
231/// # Performance
232/// - Time complexity: O(n_samples * n_dimensions * log(n_samples)) due to shuffling
233/// - Space complexity: O(n_samples * n_dimensions)
234/// - Slightly slower than random due to shuffling, but provides superior coverage
235#[instrument(level = "debug", skip(morphology), fields(n_samples = n_samples, n_dimensions = morphology.gene_bounds.len()))]
236fn latin_hypercube(n_samples: usize, morphology: &Morphology) -> Vec<Vec<Gene>> {
237    use rand::seq::SliceRandom;
238
239    let n_dimensions = morphology.gene_bounds.len();
240    let mut rng = rand::rng();
241
242    // Create n_samples genomes
243    let mut genomes = Vec::with_capacity(n_samples);
244
245    // For each dimension, create Latin Hypercube sampling
246    for dim_idx in 0..n_dimensions {
247        let gene_bound = &morphology.gene_bounds[dim_idx];
248
249        // Create evenly spaced intervals and shuffle for decorrelation
250        let mut intervals: Vec<f64> = (0..n_samples)
251            .map(|i| (i as f64 + 0.5) / n_samples as f64) // Center of each interval
252            .collect();
253        intervals.shuffle(&mut rng);
254
255        // Convert normalized samples to actual gene values
256        let gene_values: Vec<Gene> = intervals
257            .iter()
258            .map(|&sample| gene_bound.from_sample(sample))
259            .collect();
260
261        // Assign gene values to genomes (transpose operation)
262        for (genome_idx, &gene_value) in gene_values.iter().enumerate() {
263            if dim_idx == 0 {
264                // First dimension: create new genomes
265                genomes.push(vec![gene_value]);
266            } else {
267                // Subsequent dimensions: append to existing genomes
268                genomes[genome_idx].push(gene_value);
269            }
270        }
271    }
272
273    genomes
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::models::GeneBounds;
280
281    // Helper to create test morphology
282    fn create_test_morphology(gene_bounds: Vec<GeneBounds>) -> Morphology {
283        Morphology::new("test", 1, gene_bounds)
284    }
285
286    #[test]
287    fn test_latin_hypercube_2d_simple() {
288        let morphology = create_test_morphology(vec![
289            GeneBounds::integer(0, 3, 4).unwrap(), // 4 steps: [0, 1, 2, 3]
290            GeneBounds::integer(0, 3, 4).unwrap(), // 4 steps: [0, 1, 2, 3]
291        ]);
292
293        let dist = Distribution::latin_hypercube(4);
294        let genomes = dist.distribute(&morphology);
295
296        // Should have 4 genomes, each with 2 genes
297        assert_eq!(genomes.len(), 4);
298        assert!(genomes.iter().all(|genome| genome.len() == 2));
299
300        // Extract dimensions for easier checking
301        let dim1: Vec<i64> = genomes.iter().map(|g| g[0]).collect();
302        let dim2: Vec<i64> = genomes.iter().map(|g| g[1]).collect();
303
304        // Each dimension should contain exactly one of each value [0, 1, 2, 3]
305        let mut dim1_sorted = dim1.clone();
306        dim1_sorted.sort();
307        assert_eq!(dim1_sorted, vec![0, 1, 2, 3]);
308
309        let mut dim2_sorted = dim2.clone();
310        dim2_sorted.sort();
311        assert_eq!(dim2_sorted, vec![0, 1, 2, 3]);
312    }
313
314    #[test]
315    fn test_latin_hypercube_3d_simple() {
316        // 3D test: 3 samples in 3 dimensions
317        let morphology = create_test_morphology(vec![
318            GeneBounds::integer(0, 2, 3).unwrap(), // 3 steps: [0, 1, 2]
319            GeneBounds::integer(0, 2, 3).unwrap(), // 3 steps: [0, 1, 2]
320            GeneBounds::integer(0, 2, 3).unwrap(), // 3 steps: [0, 1, 2]
321        ]);
322
323        let dist = Distribution::latin_hypercube(3);
324        let genomes = dist.distribute(&morphology);
325
326        // Should have 3 genomes, each with 3 genes
327        assert_eq!(genomes.len(), 3);
328        assert!(genomes.iter().all(|genome| genome.len() == 3));
329
330        // Each dimension should contain exactly [0, 1, 2] in some order
331        for dim in 0..3 {
332            let mut values: Vec<i64> = genomes.iter().map(|g| g[dim]).collect();
333            values.sort();
334            assert_eq!(values, vec![0, 1, 2]);
335        }
336    }
337
338    #[test]
339    fn test_latin_hypercube_single_sample() {
340        // Edge case: single sample should work
341        let morphology = create_test_morphology(vec![
342            GeneBounds::integer(0, 9, 10).unwrap(),
343            GeneBounds::integer(0, 9, 10).unwrap(),
344        ]);
345
346        let dist = Distribution::latin_hypercube(1);
347        let genomes = dist.distribute(&morphology);
348
349        // Basic structure should work with single sample
350        assert_eq!(genomes.len(), 1);
351        assert_eq!(genomes[0].len(), 2);
352
353        // Values should be within bounds
354        assert!((0..=9).contains(&genomes[0][0]));
355        assert!((0..=9).contains(&genomes[0][1]));
356    }
357
358    #[test]
359    fn test_distribution_distribute_random() {
360        let morphology = create_test_morphology(vec![
361            GeneBounds::integer(0, 5000, 1000).unwrap(),
362            GeneBounds::integer(0, 3000, 1000).unwrap(),
363        ]);
364
365        let dist = Distribution::random(5);
366
367        // Generate multiple distributions to test randomness
368        let genomes1 = dist.distribute(&morphology);
369        let genomes2 = dist.distribute(&morphology);
370
371        // Basic structure validation
372        assert_eq!(genomes1.len(), 5);
373        assert!(genomes1.iter().all(|genome| genome.len() == 2));
374
375        // The key test: random distributions should be different
376        assert_ne!(genomes1, genomes2);
377    }
378}