optimization_job_api/legacy/
spec.rs1use 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#[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 pub wall_time_seconds: f64,
49 #[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 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 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 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}
116pub const MAX_POPULATION_CELLS: usize = 10_000_000;
121pub const CASSINI1_LOWER: [f64; 6] = [-1000.0, 30.0, 100.0, 30.0, 400.0, 1000.0];
123pub 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 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 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 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 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 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}