Skip to main content

optimization_job_api/legacy/
spec.rs

1use crate::Result;
2use serde::{Deserialize, Serialize};
3macro_rules! contract { ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
4    #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
5    #[serde(deny_unknown_fields)]
6    pub struct $name { $(pub $field: $ty),* }
7}; }
8contract!(JobSpec {
9    schema_version: String,
10    request_key: String,
11    problem: Problem,
12    optimizer: Optimizer,
13    execution: Execution,
14    budget: Budget,
15    outputs: Outputs
16});
17contract!(Problem {
18    id: String,
19    version: String,
20    config: ProblemConfig,
21    bounds: Bounds
22});
23contract!(ProblemConfig { dimension: usize });
24contract!(Bounds { lower: Vec<f64>, upper: Vec<f64>, broadcast: bool });
25contract!(Optimizer {
26    kind: String,
27    parameters: Parameters
28});
29#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
30#[serde(deny_unknown_fields)]
31pub struct Parameters {
32    pub population: Option<usize>,
33}
34contract!(Execution {
35    strategy: String,
36    seed: u64,
37    workers: usize,
38    retry_wave_size: usize
39});
40/// Evaluation and time budget of one job.
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
42#[serde(deny_unknown_fields)]
43pub struct Budget {
44    pub retries: usize,
45    pub evaluations_per_retry: u64,
46    pub evaluation_limit_mode: String,
47    /// No new retry wave starts after this execution time.
48    pub wall_time_seconds: f64,
49    /// Declared interval between the wall-time deadline and killing the
50    /// worker. A retry wave still running after it is lost and the job's
51    /// accounting becomes incomplete.
52    #[serde(default = "default_grace_seconds")]
53    pub grace_seconds: f64,
54}
55fn default_grace_seconds() -> f64 {
56    1.0
57}
58contract!(Outputs { progress: String });
59impl Bounds {
60    /// Expands broadcast bounds to `n` coordinates and checks that every
61    /// bound is finite with `lower < upper` and a finite span.
62    pub fn resolve(mut self, n: usize) -> Result<Self> {
63        if self.broadcast {
64            if self.lower.len() != 1 || self.upper.len() != 1 {
65                return Err("broadcast requires scalar bounds".into());
66            }
67            self.lower = vec![self.lower[0]; n];
68            self.upper = vec![self.upper[0]; n];
69            self.broadcast = false;
70        }
71        if self.lower.len() != n
72            || self.upper.len() != n
73            || self
74                .lower
75                .iter()
76                .zip(&self.upper)
77                .any(|(l, u)| !l.is_finite() || !u.is_finite() || l >= u || !(u - l).is_finite())
78        {
79            return Err("invalid bounds".into());
80        }
81        Ok(self)
82    }
83    /// Whether every coordinate lies inside the box `[lower, upper]`.
84    pub fn within(&self, lower: &[f64], upper: &[f64]) -> bool {
85        self.lower.len() == lower.len()
86            && self.upper.len() == upper.len()
87            && self.lower.iter().zip(lower).all(|(l, c)| l >= c)
88            && self.upper.iter().zip(upper).all(|(u, c)| u <= c)
89    }
90}
91impl Problem {
92    /// Validates a registered scalar problem (version, dimension, identifier
93    /// and bounds) and expands broadcast bounds. Used by `fcmaes.job/0.1` and
94    /// by the scalar retry profile of the common fcmaes worker.
95    pub fn resolve(mut self) -> Result<Self> {
96        if self.version != "1" {
97            return Err("unsupported schema/problem version".into());
98        }
99        let n = self.config.dimension;
100        if !(1..=10000).contains(&n) {
101            return Err("dimension must be 1..10000".into());
102        }
103        match self.id.as_str() {
104            "benchmark.sphere" => (),
105            "benchmark.rosenbrock" if n >= 2 => (),
106            "gtop.cassini1" if n == 6 => (),
107            _ => return Err("unknown problem or invalid dimension".into()),
108        }
109        self.bounds = self.bounds.resolve(n)?;
110        if self.id == "gtop.cassini1" && !self.bounds.within(&CASSINI1_LOWER, &CASSINI1_UPPER) {
111            return Err("gtop.cassini1 bounds must lie inside the canonical Cassini1 box".into());
112        }
113        Ok(self)
114    }
115}
116/// Largest population x dimension of a BiteOpt or DE run: each of the
117/// optimizer's population tables then holds at most 80 MB of coordinates.
118/// The default BiteOpt population, 9 + 3 * dimension, exceeds it from
119/// dimension 1825 on; set a smaller population there.
120pub const MAX_POPULATION_CELLS: usize = 10_000_000;
121/// Canonical Cassini1 box; job bounds must lie inside it.
122pub const CASSINI1_LOWER: [f64; 6] = [-1000.0, 30.0, 100.0, 30.0, 400.0, 1000.0];
123/// Canonical Cassini1 box; job bounds must lie inside it.
124pub const CASSINI1_UPPER: [f64; 6] = [0.0, 400.0, 470.0, 400.0, 2000.0, 6000.0];
125impl JobSpec {
126    pub fn resolve(mut self) -> Result<Self> {
127        if self.schema_version != "fcmaes.job/0.1" || self.problem.version != "1" {
128            return Err("unsupported schema/problem version".into());
129        }
130        if self.request_key.is_empty() || self.request_key.len() > 200 {
131            return Err("request_key must be 1..200 bytes".into());
132        }
133        self.problem = self.problem.resolve()?;
134        let n = self.problem.config.dimension;
135        let e = &self.execution;
136        let k = &self.budget;
137        if e.strategy != "independent_retry"
138            || e.workers == 0
139            || e.retry_wave_size == 0
140            || k.retries == 0
141            || k.evaluations_per_retry == 0
142        {
143            return Err(
144                "positive explicit workers, retries, wave size and evaluations required".into(),
145            );
146        }
147        if !k.wall_time_seconds.is_finite()
148            || k.wall_time_seconds <= 0.0
149            || k.wall_time_seconds > 31_536_000.0
150        {
151            return Err("wall_time_seconds must be in (0,31536000]".into());
152        }
153        if !k.grace_seconds.is_finite() || !(0.0..=86_400.0).contains(&k.grace_seconds) {
154            return Err("grace_seconds must be in [0,86400]".into());
155        }
156        if self.outputs.progress != "completed_retries" {
157            return Err("only completed_retries progress is supported".into());
158        }
159        let p = self
160            .optimizer
161            .parameters
162            .population
163            .unwrap_or(if self.optimizer.kind == "de" {
164                31
165            } else {
166                9 + 3 * n
167            });
168        if !(4..=1_000_000).contains(&p) {
169            return Err("population must be 4..1000000".into());
170        }
171        // The optimizer keeps several population x dimension tables; the
172        // evaluation budget does not bound them, as they are allocated first.
173        if p.saturating_mul(n) > MAX_POPULATION_CELLS {
174            return Err(format!(
175                "population * dimension must be at most {MAX_POPULATION_CELLS} (population {p}, dimension {n}); set a smaller optimizer.parameters.population"
176            )
177            .into());
178        }
179        match (
180            self.optimizer.kind.as_str(),
181            k.evaluation_limit_mode.as_str(),
182        ) {
183            ("biteopt", "hard") | ("de", "soft") => (),
184            _ => {
185                return Err(
186                    "supported paths: biteopt/hard, de/soft; DE hard cap is unavailable".into(),
187                );
188            }
189        }
190        self.optimizer.parameters.population = Some(p);
191        self.requested()?;
192        self.maximum_evaluations()?;
193        Ok(self)
194    }
195    pub fn requested(&self) -> Result<u64> {
196        (self.budget.retries as u64)
197            .checked_mul(self.budget.evaluations_per_retry)
198            .ok_or_else(|| "requested evaluations overflow".into())
199    }
200    pub fn maximum_evaluations(&self) -> Result<u64> {
201        let extra = if self.optimizer.kind == "de" {
202            2 * self.optimizer.parameters.population.unwrap_or(31) as u64 - 1
203        } else {
204            0
205        };
206        self.budget
207            .evaluations_per_retry
208            .checked_add(extra)
209            .and_then(|x| x.checked_mul(self.budget.retries as u64))
210            .ok_or_else(|| "actual budget bound overflow".into())
211    }
212    /// Digest of the execution specification. The request key is excluded:
213    /// it names a submission, not an experiment, so identical experiments
214    /// submitted under different keys share a digest.
215    pub fn digest(&self) -> Result<String> {
216        let mut value = serde_json::to_value(self)?;
217        if let Some(object) = value.as_object_mut() {
218            object.remove("request_key");
219        }
220        Ok(crate::storage::digest(&serde_json::to_vec(&value)?))
221    }
222}
223pub fn discovery() -> serde_json::Value {
224    serde_json::json!({
225        "protocol":"fcmaes.worker/0.1", "problems":[
226        {"id":"benchmark.sphere","version":"1","dimensions":"1..10000"},
227        {"id":"benchmark.rosenbrock","version":"1","dimensions":"2..10000"},
228        {"id":"gtop.cassini1","version":"1","dimensions":6,
229        "lower":CASSINI1_LOWER,"upper":CASSINI1_UPPER,"bounds":"must lie inside lower/upper"}],
230        "optimizers":{"biteopt":{"limit":"hard"},"de":{"limit":"soft","maximum_overshoot_per_retry":"2*population-1"}},
231        "population":"4..1000000; population * dimension <= 1e7 (default: biteopt 9 + 3 * dimension, de 31)",
232        "parallelism":"independent retries; one evaluation thread per retry"
233    })
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use serde_json::json;
240
241    fn base() -> serde_json::Value {
242        json!({
243            "schema_version": "fcmaes.job/0.1",
244            "request_key": "k1",
245            "problem": {"id": "benchmark.sphere", "version": "1", "config": {"dimension": 3},
246                        "bounds": {"lower": [-5.0], "upper": [5.0], "broadcast": true}},
247            "optimizer": {"kind": "biteopt", "parameters": {}},
248            "execution": {"strategy": "independent_retry", "seed": 1, "workers": 2, "retry_wave_size": 2},
249            "budget": {"retries": 4, "evaluations_per_retry": 100, "evaluation_limit_mode": "hard",
250                       "wall_time_seconds": 10.0},
251            "outputs": {"progress": "completed_retries"}
252        })
253    }
254    fn resolve(v: serde_json::Value) -> Result<JobSpec> {
255        serde_json::from_value::<JobSpec>(v)?.resolve()
256    }
257
258    #[test]
259    fn resolves_defaults_and_broadcast_bounds() {
260        let s = resolve(base()).unwrap();
261        assert_eq!(s.problem.bounds.lower, vec![-5.0; 3]);
262        assert!(!s.problem.bounds.broadcast);
263        assert_eq!(s.budget.grace_seconds, 1.0);
264        assert_eq!(s.optimizer.parameters.population, Some(9 + 3 * 3));
265        assert_eq!(s.requested().unwrap(), 400);
266        assert_eq!(s.maximum_evaluations().unwrap(), 400);
267    }
268
269    #[test]
270    fn population_tables_are_bounded_before_allocation() {
271        // Review FC-01: 10000 x 1000000 coordinates would be 80 GB per table.
272        let mut v = base();
273        v["problem"]["config"]["dimension"] = json!(10000);
274        v["optimizer"] = json!({"kind": "de", "parameters": {"population": 1000000}});
275        v["budget"]["evaluation_limit_mode"] = json!("soft");
276        v["budget"]["evaluations_per_retry"] = json!(1);
277        let error = resolve(v.clone()).unwrap_err().to_string();
278        assert!(error.contains("population * dimension"), "{error}");
279        // The limit itself is accepted, one cell more is not.
280        v["optimizer"]["parameters"]["population"] = json!(1000);
281        assert!(resolve(v.clone()).is_ok());
282        v["problem"]["config"]["dimension"] = json!(10001);
283        assert!(resolve(v.clone()).is_err());
284        // The default BiteOpt population: dimension 1824 fits, 1825 does not.
285        let mut v = base();
286        v["problem"]["config"]["dimension"] = json!(1824);
287        assert_eq!(
288            resolve(v.clone()).unwrap().optimizer.parameters.population,
289            Some(9 + 3 * 1824)
290        );
291        v["problem"]["config"]["dimension"] = json!(1825);
292        assert!(resolve(v).is_err());
293    }
294
295    #[test]
296    fn de_is_soft_with_declared_overshoot_bound() {
297        let mut v = base();
298        v["optimizer"] = json!({"kind": "de", "parameters": {"population": 10}});
299        assert!(resolve(v.clone()).is_err(), "DE must reject a hard cap");
300        v["budget"]["evaluation_limit_mode"] = json!("soft");
301        let s = resolve(v).unwrap();
302        assert_eq!(s.maximum_evaluations().unwrap(), 4 * (100 + 2 * 10 - 1));
303    }
304
305    #[test]
306    fn rejects_zero_unknown_and_overflowing_inputs() {
307        for (path, value) in [
308            (vec!["execution", "workers"], json!(0)),
309            (vec!["execution", "retry_wave_size"], json!(0)),
310            (vec!["budget", "retries"], json!(0)),
311            (vec!["budget", "evaluations_per_retry"], json!(0)),
312            (vec!["budget", "grace_seconds"], json!(-1.0)),
313            (vec!["budget", "wall_time_seconds"], json!(0.0)),
314        ] {
315            let mut v = base();
316            v[path[0]][path[1]] = value;
317            assert!(resolve(v).is_err(), "{path:?} accepted");
318        }
319        let mut v = base();
320        v["budget"]["total"] = json!(5);
321        assert!(resolve(v).is_err(), "unknown field accepted");
322        let mut v = base();
323        v["budget"]["retries"] = json!(usize::MAX);
324        v["budget"]["evaluations_per_retry"] = json!(u64::MAX);
325        assert!(resolve(v).is_err(), "overflow accepted");
326    }
327
328    #[test]
329    fn cassini1_bounds_must_stay_in_the_canonical_box() {
330        let mut v = base();
331        v["problem"] = json!({"id": "gtop.cassini1", "version": "1", "config": {"dimension": 6},
332            "bounds": {"lower": CASSINI1_LOWER, "upper": CASSINI1_UPPER, "broadcast": false}});
333        assert!(resolve(v.clone()).is_ok());
334        v["problem"]["bounds"]["upper"][5] = json!(7000.0);
335        assert!(resolve(v.clone()).is_err());
336        v["problem"]["bounds"] = json!({"lower": [-1e6], "upper": [1e6], "broadcast": true});
337        assert!(resolve(v).is_err());
338    }
339
340    #[test]
341    fn digest_ignores_request_key_but_not_the_experiment() {
342        let a = resolve(base()).unwrap();
343        let mut v = base();
344        v["request_key"] = json!("another-key");
345        let b = resolve(v).unwrap();
346        assert_eq!(a.digest().unwrap(), b.digest().unwrap());
347        let mut v = base();
348        v["execution"]["seed"] = json!(2);
349        assert_ne!(a.digest().unwrap(), resolve(v).unwrap().digest().unwrap());
350    }
351}