Skip to main content

hyperopt_samplers/
cmaes.rs

1//! Covariance Matrix Adaptation Evolution Strategy (CMA-ES).
2//!
3//! CMA-ES is a strong derivative-free optimizer for continuous search spaces: it
4//! samples each generation from a multivariate normal and adapts that normal's
5//! mean, step size, and full covariance from the ranking of the points it saw.
6//! This module implements the standard (μ/μ_w, λ) CMA-ES (Hansen) from scratch —
7//! including a Jacobi symmetric-eigensolver for the covariance update — so it
8//! pulls in no dependency beyond `rand`.
9//!
10//! ## Fitting CMA-ES into define-by-run
11//!
12//! CMA-ES is inherently *generational and vector-valued*: it wants λ full
13//! parameter vectors, their objective values, then an update. The framework's
14//! [`Sampler`] trait is instead per-parameter and history-driven. [`CmaEsSampler`]
15//! bridges the two exactly the way [`crate::TpeSampler`] does — statelessly, by
16//! rebuilding from the study snapshot on demand:
17//!
18//! - The **search space** is the set of numeric parameters (`Uniform`,
19//!   `LogUniform`, `IntUniform`) common to all completed trials, taken in sorted
20//!   name order for a stable coordinate layout. Categorical parameters — and any
21//!   parameter not yet seen in a completed trial — fall back to an independent
22//!   random draw, so a mixed space still works.
23//! - Each parameter is mapped to an internal `[0, 1]` coordinate; completed
24//!   trials, sorted by number, are chunked into generations of λ and replayed
25//!   through the engine (`tell`) to reconstruct the current distribution. A new
26//!   trial's whole vector is then drawn once (`ask`), cached by trial number, and
27//!   decoded per parameter on demand.
28//! - Box constraints are handled by repairing a drawn coordinate back into
29//!   `[0, 1]` before decoding. The default [`BoundHandling::Reflect`] folds an
30//!   out-of-box draw back inside with a tent map (mirror at each bound), which
31//!   keeps the sampling density smooth across a boundary; [`BoundHandling::Clamp`]
32//!   is the simpler heuristic that snaps to the nearest bound but piles
33//!   probability mass on it (biasing the covariance for a bound-adjacent
34//!   optimum). Both keep every suggestion valid. Documented, not silent.
35//!
36//! Like TPE, the per-suggestion cost is `O(generations · n³)` for the eigensolve,
37//! negligible next to a real objective evaluation, and the rebuild makes the
38//! sampler correct under both parallel execution and reload from storage.
39
40// This module is dense matrix/vector arithmetic (evolution paths, covariance
41// updates, Jacobi rotations) where indexed loops that touch several arrays or
42// two matrix columns at once read far closer to the textbook formulas than the
43// equivalent iterator chains would.
44#![allow(clippy::needless_range_loop)]
45
46use hyperopt_core::{Direction, Distribution, Sampler, StudyState, Trial, Value};
47use rand::rngs::StdRng;
48use rand::{RngExt, SeedableRng};
49use std::collections::HashMap;
50
51use crate::random::sample_value;
52
53/// How CMA-ES repairs a drawn coordinate that lands outside a parameter's
54/// `[0, 1]` normalized box.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum BoundHandling {
57    /// Fold the coordinate back into `[0, 1]` by mirroring at each bound (a
58    /// period-2 tent map). Continuous across the boundary and free of the
59    /// boundary pile-up that clamping causes — the default.
60    Reflect,
61    /// Snap the coordinate to the nearest bound. Simple, but concentrates
62    /// probability on the boundary when the optimum sits near one.
63    Clamp,
64}
65
66impl BoundHandling {
67    /// Map an arbitrary real coordinate into `[0, 1]`.
68    fn repair(self, v: f64) -> f64 {
69        match self {
70            BoundHandling::Clamp => v.clamp(0.0, 1.0),
71            BoundHandling::Reflect => {
72                // Tent map with period 2: [0,1] is identity, (1,2) mirrors back.
73                let t = v.rem_euclid(2.0);
74                if t > 1.0 {
75                    2.0 - t
76                } else {
77                    t
78                }
79            }
80        }
81    }
82}
83
84/// A [`Sampler`] driven by CMA-ES over the study's numeric parameters.
85///
86/// Construct with [`CmaEsSampler::new`] (OS-seeded) or
87/// [`CmaEsSampler::seeded`] (reproducible). The population size λ defaults to
88/// the CMA-ES rule `4 + ⌊3·ln n⌋` and can be overridden with
89/// [`CmaEsSampler::population_size`]; the first generation is sampled at random
90/// as warmup (see [`CmaEsSampler::n_startup_trials`]).
91pub struct CmaEsSampler {
92    seed: u64,
93    rng: StdRng,
94    popsize: Option<usize>,
95    n_startup_trials: Option<usize>,
96    sigma0: f64,
97    bound_handling: BoundHandling,
98    /// Decoded candidate vector per trial number: `name -> value`.
99    cache: HashMap<usize, HashMap<String, Value>>,
100}
101
102impl CmaEsSampler {
103    /// A CMA-ES sampler seeded from OS entropy (non-deterministic across runs).
104    pub fn new() -> Self {
105        let mut seeder = rand::rng();
106        Self::seeded(seeder.random())
107    }
108
109    /// A CMA-ES sampler with a fixed seed — reproducible for tests/benchmarks.
110    pub fn seeded(seed: u64) -> Self {
111        CmaEsSampler {
112            seed,
113            rng: StdRng::seed_from_u64(seed),
114            popsize: None,
115            n_startup_trials: None,
116            sigma0: 0.2,
117            bound_handling: BoundHandling::Reflect,
118            cache: HashMap::new(),
119        }
120    }
121
122    /// Override the population size λ (default `4 + ⌊3·ln n⌋`).
123    pub fn population_size(mut self, lambda: usize) -> Self {
124        self.popsize = Some(lambda.max(2));
125        self
126    }
127
128    /// Number of initial trials drawn at random before CMA-ES takes over
129    /// (default: one population, λ). A full generation of spread-out points
130    /// makes the first covariance estimate meaningful.
131    pub fn n_startup_trials(mut self, n: usize) -> Self {
132        self.n_startup_trials = Some(n);
133        self
134    }
135
136    /// Initial step size σ₀, as a fraction of the normalized `[0, 1]` range
137    /// (default `0.2`).
138    pub fn sigma0(mut self, sigma0: f64) -> Self {
139        self.sigma0 = sigma0;
140        self
141    }
142
143    /// How to repair out-of-box draws (default [`BoundHandling::Reflect`]).
144    pub fn bound_handling(mut self, handling: BoundHandling) -> Self {
145        self.bound_handling = handling;
146        self
147    }
148
149    /// The numeric search space common to all completed trials, sorted by name.
150    /// Returns `(name, distribution)` pairs.
151    fn search_space(study_state: &StudyState) -> Vec<(String, Distribution)> {
152        let mut per_name: HashMap<String, Distribution> = HashMap::new();
153        let mut counts: HashMap<String, usize> = HashMap::new();
154        let mut n_completed = 0usize;
155
156        for t in study_state.completed_trials() {
157            n_completed += 1;
158            for p in &t.params {
159                if is_numeric(&p.distribution) {
160                    per_name.entry(p.name.clone()).or_insert_with(|| p.distribution.clone());
161                    *counts.entry(p.name.clone()).or_insert(0) += 1;
162                }
163            }
164        }
165
166        let mut space: Vec<(String, Distribution)> = per_name
167            .into_iter()
168            .filter(|(name, _)| counts.get(name).copied().unwrap_or(0) == n_completed)
169            .collect();
170        space.sort_by(|a, b| a.0.cmp(&b.0));
171        space
172    }
173
174    /// Build (or fetch) this trial's full decoded candidate vector.
175    fn candidate_for(
176        &mut self,
177        study_state: &StudyState,
178        trial: &Trial,
179    ) -> Option<HashMap<String, Value>> {
180        if let Some(cached) = self.cache.get(&trial.number) {
181            return Some(cached.clone());
182        }
183
184        let space = Self::search_space(study_state);
185        if space.is_empty() {
186            return None;
187        }
188        let n = space.len();
189
190        let popsize = self.popsize.unwrap_or_else(|| default_popsize(n));
191        let n_startup = self.n_startup_trials.unwrap_or(popsize);
192
193        // Reconstruct each completed trial's normalized vector + fitness, in
194        // trial-number order.
195        let mut samples: Vec<(Vec<f64>, f64)> = Vec::new();
196        let mut completed: Vec<&Trial> = study_state.completed_trials().collect();
197        completed.sort_by_key(|t| t.number);
198        for t in &completed {
199            if let Some(x) = encode_vector(&space, t) {
200                let fitness = match study_state.direction() {
201                    Direction::Minimize => t.value.unwrap(),
202                    Direction::Maximize => -t.value.unwrap(),
203                };
204                samples.push((x, fitness));
205            }
206        }
207
208        // Warmup: not enough completed trials to trust a covariance yet.
209        if samples.len() < n_startup {
210            return None;
211        }
212
213        // Replay full generations of λ through the engine.
214        let mut engine = Engine::new(n, self.sigma0);
215        for gen in samples.chunks(popsize) {
216            if gen.len() == popsize {
217                engine.tell(gen);
218            }
219        }
220
221        // Draw this trial's candidate deterministically from its number, so a
222        // re-suggest (or a stale parallel re-read) yields the same vector.
223        let mut ask_rng = StdRng::seed_from_u64(self.seed ^ (trial.number as u64).wrapping_mul(0x9E3779B97F4A7C15));
224        let raw = engine.ask(&mut ask_rng);
225
226        // Repair to [0, 1] (reflect or clamp) and decode.
227        let mut decoded = HashMap::new();
228        for (i, (name, dist)) in space.iter().enumerate() {
229            let z = self.bound_handling.repair(raw[i]);
230            decoded.insert(name.clone(), decode(dist, z));
231        }
232        self.cache.insert(trial.number, decoded.clone());
233        Some(decoded)
234    }
235}
236
237impl Default for CmaEsSampler {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl Sampler for CmaEsSampler {
244    fn suggest(
245        &mut self,
246        study_state: &StudyState,
247        trial: &Trial,
248        param_name: &str,
249        distribution: &Distribution,
250    ) -> Value {
251        // Non-numeric parameters are outside CMA-ES's space.
252        if is_numeric(distribution) {
253            if let Some(vector) = self.candidate_for(study_state, trial) {
254                if let Some(v) = vector.get(param_name) {
255                    // Guard: only trust the cached vector if its variant matches
256                    // the distribution requested this trial.
257                    if variant_matches(distribution, v) {
258                        return v.clone();
259                    }
260                }
261            }
262        }
263        sample_value(&mut self.rng, distribution)
264    }
265}
266
267fn is_numeric(dist: &Distribution) -> bool {
268    matches!(
269        dist,
270        Distribution::Uniform { .. }
271            | Distribution::LogUniform { .. }
272            | Distribution::IntUniform { .. }
273    )
274}
275
276fn variant_matches(dist: &Distribution, value: &Value) -> bool {
277    matches!(
278        (dist, value),
279        (Distribution::Uniform { .. }, Value::Float(_))
280            | (Distribution::LogUniform { .. }, Value::Float(_))
281            | (Distribution::IntUniform { .. }, Value::Int(_))
282    )
283}
284
285/// CMA-ES population-size default `λ = 4 + ⌊3·ln n⌋`.
286fn default_popsize(n: usize) -> usize {
287    (4.0 + (3.0 * (n as f64).ln()).floor()) as usize
288}
289
290/// Encode a completed trial's parameters into the normalized `[0, 1]^n` vector
291/// for `space`, or `None` if any parameter is missing / wrong-typed.
292fn encode_vector(space: &[(String, Distribution)], trial: &Trial) -> Option<Vec<f64>> {
293    let mut out = Vec::with_capacity(space.len());
294    for (name, dist) in space {
295        let v = trial.param_value(name)?;
296        out.push(encode(dist, v)?);
297    }
298    Some(out)
299}
300
301/// Map a parameter value to `[0, 1]`, or `None` if it can't be projected.
302fn encode(dist: &Distribution, value: &Value) -> Option<f64> {
303    match dist {
304        Distribution::Uniform { low, high } => {
305            let x = value.as_float()?;
306            unit(x, *low, *high)
307        }
308        Distribution::LogUniform { low, high } => {
309            let x = value.as_float()?;
310            if *low <= 0.0 || x <= 0.0 {
311                return None;
312            }
313            unit(x.ln(), low.ln(), high.ln())
314        }
315        Distribution::IntUniform { low, high } => {
316            let x = value.as_int()? as f64;
317            unit(x, *low as f64, *high as f64)
318        }
319        Distribution::Categorical { .. } => None,
320    }
321}
322
323/// Decode a normalized `[0, 1]` coordinate back into a parameter value.
324fn decode(dist: &Distribution, z: f64) -> Value {
325    match dist {
326        Distribution::Uniform { low, high } => Value::Float(low + z * (high - low)),
327        Distribution::LogUniform { low, high } => {
328            let l = low.ln();
329            let h = high.ln();
330            Value::Float((l + z * (h - l)).exp())
331        }
332        Distribution::IntUniform { low, high } => {
333            let raw = *low as f64 + z * (*high as f64 - *low as f64);
334            Value::Int(raw.round() as i64)
335        }
336        Distribution::Categorical { .. } => Value::Categorical(String::new()),
337    }
338}
339
340fn unit(x: f64, low: f64, high: f64) -> Option<f64> {
341    if high > low {
342        Some(((x - low) / (high - low)).clamp(0.0, 1.0))
343    } else {
344        Some(0.5) // degenerate range: everything maps to the middle
345    }
346}
347
348/// The pure CMA-ES engine, operating in normalized coordinates.
349struct Engine {
350    n: usize,
351    mean: Vec<f64>,
352    sigma: f64,
353    cov: Vec<Vec<f64>>, // C
354    p_c: Vec<f64>,
355    p_s: Vec<f64>,
356    b: Vec<Vec<f64>>, // eigenvectors of C
357    d: Vec<f64>,      // sqrt eigenvalues of C
358    generation: usize,
359    // Strategy constants.
360    mu: usize,
361    weights: Vec<f64>,
362    mu_eff: f64,
363    c_c: f64,
364    c_s: f64,
365    c_1: f64,
366    c_mu: f64,
367    damps: f64,
368    chi_n: f64,
369}
370
371impl Engine {
372    fn new(n: usize, sigma0: f64) -> Self {
373        let lambda = default_popsize(n);
374        let mu = lambda / 2;
375
376        // Recombination weights (log-decreasing), normalized to sum to 1.
377        let mut weights: Vec<f64> = (0..mu)
378            .map(|i| ((lambda as f64 + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
379            .collect();
380        let wsum: f64 = weights.iter().sum();
381        for w in &mut weights {
382            *w /= wsum;
383        }
384        let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
385
386        let nf = n as f64;
387        let c_c = (4.0 + mu_eff / nf) / (nf + 4.0 + 2.0 * mu_eff / nf);
388        let c_s = (mu_eff + 2.0) / (nf + mu_eff + 5.0);
389        let c_1 = 2.0 / ((nf + 1.3).powi(2) + mu_eff);
390        let c_mu = ((2.0 * (mu_eff - 2.0 + 1.0 / mu_eff) / ((nf + 2.0).powi(2) + mu_eff))
391            .min(1.0 - c_1))
392        .max(0.0);
393        let damps = 1.0
394            + 2.0 * (((mu_eff - 1.0) / (nf + 1.0)).sqrt() - 1.0).max(0.0)
395            + c_s;
396        let chi_n = nf.sqrt() * (1.0 - 1.0 / (4.0 * nf) + 1.0 / (21.0 * nf * nf));
397
398        Engine {
399            n,
400            mean: vec![0.5; n], // center of the normalized box
401            sigma: sigma0,
402            cov: identity(n),
403            p_c: vec![0.0; n],
404            p_s: vec![0.0; n],
405            b: identity(n),
406            d: vec![1.0; n],
407            generation: 0,
408            mu,
409            weights,
410            mu_eff,
411            c_c,
412            c_s,
413            c_1,
414            c_mu,
415            damps,
416            chi_n,
417        }
418    }
419
420    /// Draw a candidate `x = mean + sigma · B·(d ⊙ z)`, `z ~ N(0, I)`.
421    fn ask(&self, rng: &mut StdRng) -> Vec<f64> {
422        let z: Vec<f64> = (0..self.n).map(|_| standard_normal(rng)).collect();
423        let dz: Vec<f64> = (0..self.n).map(|i| self.d[i] * z[i]).collect();
424        let bdz = mat_vec(&self.b, &dz);
425        (0..self.n).map(|i| self.mean[i] + self.sigma * bdz[i]).collect()
426    }
427
428    /// Fold one full generation of `(candidate, fitness)` pairs into the
429    /// distribution. `candidates` is minimized over `fitness`.
430    fn tell(&mut self, candidates: &[(Vec<f64>, f64)]) {
431        self.generation += 1;
432        let n = self.n;
433
434        // Rank ascending by fitness and keep the best mu.
435        let mut order: Vec<usize> = (0..candidates.len()).collect();
436        order.sort_by(|&a, &b| candidates[a].1.total_cmp(&candidates[b].1));
437
438        let mean_old = self.mean.clone();
439
440        // y_i = (x_i - mean_old) / sigma for the selected candidates.
441        let ys: Vec<Vec<f64>> = order
442            .iter()
443            .take(self.mu)
444            .map(|&idx| {
445                (0..n)
446                    .map(|k| (candidates[idx].0[k] - mean_old[k]) / self.sigma)
447                    .collect()
448            })
449            .collect();
450
451        // Weighted recombination: yw = Σ w_i y_i; mean_new = mean_old + sigma·yw.
452        let mut yw = vec![0.0; n];
453        for (w, y) in self.weights.iter().zip(&ys) {
454            for k in 0..n {
455                yw[k] += w * y[k];
456            }
457        }
458        for k in 0..n {
459            self.mean[k] = mean_old[k] + self.sigma * yw[k];
460        }
461
462        // C^{-1/2} · yw  via B·diag(1/d)·Bᵀ.
463        let bt_yw = mat_vec(&transpose(&self.b), &yw);
464        let scaled: Vec<f64> = (0..n).map(|i| bt_yw[i] / self.d[i]).collect();
465        let c_inv_sqrt_yw = mat_vec(&self.b, &scaled);
466
467        // Step-size evolution path p_s.
468        let cs_factor = (self.c_s * (2.0 - self.c_s) * self.mu_eff).sqrt();
469        for k in 0..n {
470            self.p_s[k] = (1.0 - self.c_s) * self.p_s[k] + cs_factor * c_inv_sqrt_yw[k];
471        }
472        let ps_norm = norm(&self.p_s);
473
474        // Heaviside step to stall the rank-one update when the path is long.
475        let hsig = if ps_norm
476            / (1.0 - (1.0 - self.c_s).powi(2 * self.generation as i32)).sqrt()
477            / self.chi_n
478            < 1.4 + 2.0 / (n as f64 + 1.0)
479        {
480            1.0
481        } else {
482            0.0
483        };
484
485        // Covariance evolution path p_c.
486        let cc_factor = (self.c_c * (2.0 - self.c_c) * self.mu_eff).sqrt();
487        for k in 0..n {
488            self.p_c[k] = (1.0 - self.c_c) * self.p_c[k] + hsig * cc_factor * yw[k];
489        }
490
491        // Covariance update: C = (1-c1-cmu)·C + c1·(pc pcᵀ + δ·C) + cmu·Σ w_i y_i y_iᵀ.
492        let delta = (1.0 - hsig) * self.c_c * (2.0 - self.c_c);
493        for r in 0..n {
494            for c in 0..n {
495                let rank_one = self.p_c[r] * self.p_c[c] + delta * self.cov[r][c];
496                let mut rank_mu = 0.0;
497                for (w, y) in self.weights.iter().zip(&ys) {
498                    rank_mu += w * y[r] * y[c];
499                }
500                self.cov[r][c] = (1.0 - self.c_1 - self.c_mu) * self.cov[r][c]
501                    + self.c_1 * rank_one
502                    + self.c_mu * rank_mu;
503            }
504        }
505
506        // Step-size update.
507        self.sigma *= ((self.c_s / self.damps) * (ps_norm / self.chi_n - 1.0)).exp();
508        // Guard against under/overflow of the step size.
509        self.sigma = self.sigma.clamp(1e-12, 1e6);
510
511        // Refresh the eigendecomposition used by the next ask / C^{-1/2}.
512        self.update_eigen();
513    }
514
515    fn update_eigen(&mut self) {
516        // Symmetrize defensively, then eigendecompose.
517        for r in 0..self.n {
518            for c in (r + 1)..self.n {
519                let avg = 0.5 * (self.cov[r][c] + self.cov[c][r]);
520                self.cov[r][c] = avg;
521                self.cov[c][r] = avg;
522            }
523        }
524        let (evals, evecs) = jacobi_eigen(&self.cov);
525        self.b = evecs;
526        self.d = evals.iter().map(|&e| e.max(1e-20).sqrt()).collect();
527    }
528}
529
530// --- small linear-algebra helpers -----------------------------------------
531
532fn identity(n: usize) -> Vec<Vec<f64>> {
533    let mut m = vec![vec![0.0; n]; n];
534    for (i, row) in m.iter_mut().enumerate() {
535        row[i] = 1.0;
536    }
537    m
538}
539
540fn transpose(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
541    let n = m.len();
542    let mut t = vec![vec![0.0; n]; n];
543    for r in 0..n {
544        for c in 0..n {
545            t[c][r] = m[r][c];
546        }
547    }
548    t
549}
550
551fn mat_vec(m: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
552    m.iter()
553        .map(|row| row.iter().zip(v).map(|(a, b)| a * b).sum())
554        .collect()
555}
556
557fn norm(v: &[f64]) -> f64 {
558    v.iter().map(|x| x * x).sum::<f64>().sqrt()
559}
560
561/// Box–Muller standard-normal draw from a uniform RNG.
562fn standard_normal(rng: &mut StdRng) -> f64 {
563    let u1: f64 = rng.random_range(1e-12..1.0);
564    let u2: f64 = rng.random_range(0.0..1.0);
565    (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
566}
567
568/// Cyclic Jacobi eigendecomposition of a symmetric matrix. Returns
569/// `(eigenvalues, eigenvectors)` where column `k` of the eigenvector matrix is
570/// the eigenvector for `eigenvalues[k]`. Adequate and robust for the small,
571/// symmetric covariance matrices CMA-ES produces.
572fn jacobi_eigen(a_in: &[Vec<f64>]) -> (Vec<f64>, Vec<Vec<f64>>) {
573    let n = a_in.len();
574    let mut a = a_in.to_vec();
575    let mut v = identity(n);
576    if n == 1 {
577        return (vec![a[0][0]], v);
578    }
579
580    for _sweep in 0..100 {
581        // Sum of off-diagonal magnitudes; stop once negligible.
582        let mut off = 0.0;
583        for p in 0..n {
584            for q in (p + 1)..n {
585                off += a[p][q].abs();
586            }
587        }
588        if off < 1e-14 {
589            break;
590        }
591
592        for p in 0..n {
593            for q in (p + 1)..n {
594                if a[p][q].abs() < 1e-18 {
595                    continue;
596                }
597                let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
598                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
599                let c = 1.0 / (t * t + 1.0).sqrt();
600                let s = t * c;
601
602                // Rotate rows/cols p, q.
603                for k in 0..n {
604                    let akp = a[k][p];
605                    let akq = a[k][q];
606                    a[k][p] = c * akp - s * akq;
607                    a[k][q] = s * akp + c * akq;
608                }
609                for k in 0..n {
610                    let apk = a[p][k];
611                    let aqk = a[q][k];
612                    a[p][k] = c * apk - s * aqk;
613                    a[q][k] = s * apk + c * aqk;
614                }
615                // Accumulate the rotation into the eigenvectors.
616                for k in 0..n {
617                    let vkp = v[k][p];
618                    let vkq = v[k][q];
619                    v[k][p] = c * vkp - s * vkq;
620                    v[k][q] = s * vkp + c * vkq;
621                }
622            }
623        }
624    }
625
626    let evals: Vec<f64> = (0..n).map(|i| a[i][i]).collect();
627    (evals, v)
628}