Skip to main content

optimization_job_api/
result.rs

1use crate::{JobSpec, Request, Result, artifact, legacy, safe_name, storage};
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::Path,
7};
8
9/// Result manifests written by this build.
10pub const SCHEMA: &str = "optimization.result/0.2";
11/// Result manifests `compare` reads. A 0.1 manifest is a valid 0.2 manifest
12/// without evidence, objective bounds or the claims added in 0.2.
13pub const SCHEMAS: [&str; 2] = ["optimization.result/0.1", SCHEMA];
14
15/// Every claim. A claim states what the worker asserts about the task's
16/// answer; `evidence` states how much of that was verified.
17pub const CLAIMS: [&str; 12] = [
18    "none",
19    "feasible",
20    "best_found",
21    "numerical_optimum",
22    "first_order",
23    "certified_optimum",
24    "non_dominated_set",
25    "elite_archive",
26    "infeasible",
27    "unbounded",
28    "infeasible_or_unbounded",
29    "locally_infeasible",
30];
31/// Claims about one returned point, which must be feasible.
32const POINT_CLAIMS: [&str; 6] = [
33    "feasible",
34    "best_found",
35    "numerical_optimum",
36    "first_order",
37    "certified_optimum",
38    "elite_archive",
39];
40/// Point claims that also need the point's (scalar) objective. A feasible
41/// point of a multi-objective or constraint-only model has none.
42const OBJECTIVE_CLAIMS: [&str; 5] = [
43    "best_found",
44    "numerical_optimum",
45    "first_order",
46    "certified_optimum",
47    "elite_archive",
48];
49/// Claims that rule out a feasible returned point: the problem has none
50/// (`infeasible`), or the point is a local minimizer of the infeasibility
51/// (`locally_infeasible`). `infeasible_or_unbounded` allows one: with a
52/// feasible point, the problem is unbounded if the claim holds.
53const NO_FEASIBLE_POINT: [&str; 2] = ["infeasible", "locally_infeasible"];
54/// Claims about the whole problem that no recomputation of returned points
55/// can confirm: their evidence is `claimed` or `certified`, never `rechecked`.
56const PROBLEM_CLAIMS: [&str; 3] = ["infeasible", "unbounded", "infeasible_or_unbounded"];
57/// Relative tolerance of the objective-bound rules of [`Outcome::check`]:
58/// two objective values `a` and `b` agree, and a dual bound may lie beyond
59/// the primal bound, by at most `BOUND_TOLERANCE * max(1, |a|, |b|)`. Workers
60/// with tighter tolerances decide more strictly on their own and keep a
61/// rejected value in `details`.
62pub const BOUND_TOLERANCE: f64 = 1e-6;
63/// Evidence levels, weakest first.
64pub const LEVELS: [&str; 3] = ["claimed", "rechecked", "certified"];
65/// Properties a worker recomputes independently of the solver.
66pub const CHECKS: [&str; 7] = [
67    "objective",
68    "bounds",
69    "constraints",
70    "integrality",
71    "kkt",
72    "non_dominance",
73    "niche",
74];
75/// Why an independent check refuted the solver's claim; the solver
76/// competitions' wrong-answer categories.
77pub const VERDICTS: [&str; 5] = [
78    "infeasible_point",
79    "objective_mismatch",
80    "false_optimality",
81    "false_infeasibility",
82    "invalid_certificate",
83];
84
85#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
86#[serde(deny_unknown_fields)]
87pub struct Outcome {
88    /// Why the task stopped, in the solver's words (`evaluation_limit`,
89    /// `optimal`, `converged`, `cancelled`, ...). Never a claim.
90    pub termination: String,
91    /// One of [`CLAIMS`]. A non_dominated_set describes a set of points in
92    /// `details` and has no single `x` or `objective`.
93    pub claim: String,
94    pub feasible: Option<bool>,
95    pub objective: Option<f64>,
96    pub x: Option<Vec<f64>>,
97    pub details: Value,
98    /// How the claim is supported. Workers of protocol 0.2 always report it;
99    /// a record without it predates 0.2 and is only `claimed`.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub evidence: Option<Evidence>,
102    /// Best objective of a feasible point (primal) and a proven bound on the
103    /// optimum (dual), in the model's own sense.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub objective_bounds: Option<ObjectiveBounds>,
106    /// The engine reported success only at relaxed accuracy. Qualifies the
107    /// claim; never set together with `none`.
108    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109    pub imprecise: bool,
110}
111#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
112#[serde(deny_unknown_fields)]
113pub struct Evidence {
114    /// One of [`LEVELS`]: `claimed` (the solver's report alone), `rechecked`
115    /// (returned points recomputed from the registered model, as `checks`
116    /// list) or `certified` (a proof verified by an independent checker).
117    pub level: String,
118    /// Properties recomputed independently of the solver, from [`CHECKS`].
119    #[serde(default)]
120    pub checks: Vec<String>,
121    /// The solver's claim that an independent check rejected. `claim` then
122    /// states only what survived the check.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub refuted: Option<Refutation>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub certificate: Option<Certificate>,
127}
128#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
129#[serde(deny_unknown_fields)]
130pub struct Refutation {
131    /// The rejected claim.
132    pub claim: String,
133    /// One of [`VERDICTS`].
134    pub verdict: String,
135    pub message: String,
136}
137#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
138#[serde(deny_unknown_fields)]
139pub struct Certificate {
140    /// Proof format, for example `vipr`, `lrat`, `veripb` or `farkas`.
141    pub format: String,
142    /// File name of the proof in the attempt directory, if it was kept.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub artifact: Option<String>,
145    /// SHA-256 of the proof bytes.
146    pub sha256: String,
147    /// Name and version of the checker that judged it.
148    pub checker: String,
149    /// `verified` or `rejected`.
150    pub verdict: String,
151}
152#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
153#[serde(deny_unknown_fields)]
154pub struct ObjectiveBounds {
155    /// `minimize` or `maximize`: the dual bound is a lower bound when
156    /// minimizing and an upper bound when maximizing.
157    pub sense: String,
158    /// Objective of the best feasible point, if one was found.
159    pub primal: Option<f64>,
160    /// Proven bound on the optimal objective, if the solver proved one.
161    pub dual: Option<f64>,
162}
163
164/// Whether objective values `a` and `b` agree within [`BOUND_TOLERANCE`].
165pub fn objectives_agree(a: f64, b: f64) -> bool {
166    (a - b).abs() <= BOUND_TOLERANCE * 1f64.max(a.abs()).max(b.abs())
167}
168
169impl ObjectiveBounds {
170    /// Whether the bounds are in order: when both exist, the dual bound lies
171    /// below the primal one when minimizing and above it when maximizing,
172    /// within [`BOUND_TOLERANCE`]. A dual bound beyond the objective of a
173    /// feasible point contradicts it: one of the two is wrong.
174    pub fn ordered(&self) -> bool {
175        let (Some(primal), Some(dual)) = (self.primal, self.dual) else {
176            return true;
177        };
178        let slack = BOUND_TOLERANCE * 1f64.max(primal.abs()).max(dual.abs());
179        match self.sense.as_str() {
180            "minimize" => dual <= primal + slack,
181            "maximize" => dual >= primal - slack,
182            _ => false,
183        }
184    }
185}
186
187impl Evidence {
188    /// The solver's report alone; `checks` may list recomputations of
189    /// returned points that the claim does not depend on.
190    pub fn claimed(checks: &[&str]) -> Self {
191        Self {
192            level: "claimed".into(),
193            checks: checks.iter().map(|c| c.to_string()).collect(),
194            ..Self::default()
195        }
196    }
197    /// Returned points recomputed independently: `checks` names what.
198    pub fn rechecked(checks: &[&str]) -> Self {
199        Self {
200            level: "rechecked".into(),
201            ..Self::claimed(checks)
202        }
203    }
204    /// Records that the checks rejected the solver's `claim`.
205    pub fn refuting(mut self, claim: &str, verdict: &str, message: impl Into<String>) -> Self {
206        self.refuted = Some(Refutation {
207            claim: claim.into(),
208            verdict: verdict.into(),
209            message: message.into(),
210        });
211        self
212    }
213}
214
215fn lowercase_sha256(s: &str) -> bool {
216    s.len() == 64
217        && s.bytes()
218            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
219}
220
221impl Outcome {
222    /// Checks the rules of the result contract (the guide's section
223    /// [Termination, claim and evidence](https://github.com/dietmarwo/optimization-jobs/blob/main/docs/job-api.md#termination-claim-and-evidence)).
224    /// The runner applies them to every journal record.
225    pub fn check(&self) -> std::result::Result<(), String> {
226        let claim = self.claim.as_str();
227        if self.termination.is_empty() {
228            return Err("empty termination".into());
229        }
230        if !CLAIMS.contains(&claim) {
231            return Err(format!("unknown claim {claim:?}"));
232        }
233        if self.objective.is_some_and(|v| !v.is_finite())
234            || self
235                .x
236                .as_ref()
237                .is_some_and(|v| v.is_empty() || v.iter().any(|x| !x.is_finite()))
238        {
239            return Err("objective and x must be finite and x nonempty".into());
240        }
241        if self.objective.is_some() && self.x.is_none() {
242            return Err("an objective needs its point x".into());
243        }
244        let set = claim == "non_dominated_set";
245        if self.feasible == Some(true) && self.x.is_none() && !set {
246            return Err("a feasible outcome needs its point x".into());
247        }
248        if POINT_CLAIMS.contains(&claim) && (self.feasible != Some(true) || self.x.is_none()) {
249            return Err(format!("{claim} needs a feasible point x"));
250        }
251        if OBJECTIVE_CLAIMS.contains(&claim) && self.objective.is_none() {
252            return Err(format!("{claim} needs an objective"));
253        }
254        // A set claim lives in details: no single point or objective.
255        if set && (self.feasible != Some(true) || self.x.is_some()) {
256            return Err("non_dominated_set needs feasible true and no single point".into());
257        }
258        if NO_FEASIBLE_POINT.contains(&claim) && self.feasible == Some(true) {
259            return Err(format!("{claim} contradicts a feasible point"));
260        }
261        if self.imprecise && claim == "none" {
262            return Err("imprecise qualifies a claim; none has nothing to qualify".into());
263        }
264        if let Some(b) = &self.objective_bounds
265            && (!matches!(b.sense.as_str(), "minimize" | "maximize")
266                || b.primal.is_some_and(|v| !v.is_finite())
267                || b.dual.is_some_and(|v| !v.is_finite()))
268        {
269            return Err("objective_bounds need a sense and finite (or null) bounds".into());
270        }
271        if let Some(b) = &self.objective_bounds {
272            // The primal bound is the objective of the returned feasible
273            // point, not of an incumbent the outcome does not show.
274            if let Some(primal) = b.primal
275                && (self.feasible != Some(true)
276                    || self.objective.is_none_or(|v| !objectives_agree(v, primal)))
277            {
278                return Err(format!(
279                    "objective_bounds.primal {primal} must be the objective of the returned feasible point"
280                ));
281            }
282            if !b.ordered() {
283                return Err(format!(
284                    "the dual bound {} contradicts the primal bound {} of a {} problem",
285                    b.dual.unwrap_or_default(),
286                    b.primal.unwrap_or_default(),
287                    b.sense
288                ));
289            }
290        }
291        let level = self
292            .evidence
293            .as_ref()
294            .map_or("claimed", |e| e.level.as_str());
295        if claim == "certified_optimum" && level != "certified" {
296            return Err("certified_optimum needs certified evidence".into());
297        }
298        if PROBLEM_CLAIMS.contains(&claim) && level == "rechecked" {
299            return Err(format!(
300                "{claim} concerns the whole problem; it cannot be rechecked"
301            ));
302        }
303        let Some(e) = &self.evidence else {
304            return Ok(());
305        };
306        let mut seen = BTreeSet::new();
307        if !LEVELS.contains(&level)
308            || e.checks
309                .iter()
310                .any(|c| !CHECKS.contains(&c.as_str()) || !seen.insert(c))
311        {
312            return Err("unknown evidence level, or unknown or repeated check".into());
313        }
314        if level == "rechecked" && e.checks.is_empty() {
315            return Err("rechecked evidence must name its checks".into());
316        }
317        if let Some(c) = &e.certificate
318            && (!safe_name(&c.format)
319                || !safe_name(&c.checker)
320                || !lowercase_sha256(&c.sha256)
321                || c.artifact.as_deref().is_some_and(|a| !safe_name(a))
322                || !matches!(c.verdict.as_str(), "verified" | "rejected"))
323        {
324            return Err("certificate needs format, checker, lowercase SHA-256 and verdict verified or rejected".into());
325        }
326        let verified = e
327            .certificate
328            .as_ref()
329            .is_some_and(|c| c.verdict == "verified");
330        if level == "certified" && !verified {
331            return Err("certified evidence needs a verified certificate".into());
332        }
333        if let Some(r) = &e.refuted {
334            let rejected = e
335                .certificate
336                .as_ref()
337                .is_some_and(|c| c.verdict == "rejected");
338            if !CLAIMS.contains(&r.claim.as_str())
339                || r.claim == "none"
340                || r.claim == claim
341                || !VERDICTS.contains(&r.verdict.as_str())
342                // A rejected certificate may accompany another refutation;
343                // invalid_certificate needs one.
344                || (r.verdict == "invalid_certificate" && !rejected)
345                || (r.verdict != "invalid_certificate" && level != "rechecked")
346            {
347                return Err(
348                    "a refutation names another claim, a known verdict and the check that failed"
349                        .into(),
350                );
351            }
352        }
353        Ok(())
354    }
355}
356
357#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct Record {
360    pub task_id: usize,
361    pub spec_digest: String,
362    pub elapsed_seconds: f64,
363    pub work: BTreeMap<String, u64>,
364    pub outcome: Outcome,
365}
366pub fn replay(dir: &Path, spec: &JobSpec) -> Result<Vec<Record>> {
367    let path = dir.join("results.jsonl");
368    if !path.exists() {
369        return Ok(vec![]);
370    }
371    let mut records: Vec<Record> =
372        storage::read_checked(&path, 1).map_err(|e| format!("{}: {e}", path.display()))?;
373    let digest = spec.digest()?;
374    let mut ids = BTreeSet::new();
375    for r in &records {
376        let invalid = |why: String| {
377            format!(
378                "{}: invalid, mismatched or duplicate result record (task {}): {why}",
379                path.display(),
380                r.task_id
381            )
382        };
383        if r.task_id >= spec.task_count.ok_or("unresolved task_count")?
384            || !ids.insert(r.task_id)
385            || r.spec_digest != digest
386            || !r.elapsed_seconds.is_finite()
387            || r.elapsed_seconds < 0.0
388            || r.work.keys().any(|k| !safe_name(k))
389        {
390            return Err(invalid("task, digest, elapsed time or work counters".into()).into());
391        }
392        r.outcome.check().map_err(invalid)?;
393    }
394    records.sort_by_key(|r| r.task_id);
395    Ok(records)
396}
397pub fn counts(records: &[Record]) -> Result<BTreeMap<String, u64>> {
398    let mut counts = BTreeMap::<String, u64>::new();
399    for r in records {
400        for (k, v) in &r.work {
401            let n = counts.entry(k.clone()).or_default();
402            *n = n.checked_add(*v).ok_or("work count overflow")?;
403        }
404    }
405    Ok(counts)
406}
407pub fn manifest(
408    dir: &Path,
409    spec: &Request,
410    status: &str,
411    complete: bool,
412    elapsed: f64,
413    worker_digest: &str,
414) -> Result<Value> {
415    let s = match spec {
416        Request::Legacy(s) => {
417            return legacy::result::manifest(dir, s, status, complete, elapsed, worker_digest);
418        }
419        Request::Job(s) => s,
420    };
421    let records = replay(dir, s)?;
422    let known = counts(&records)?;
423    // Declared artifacts are indexed with their digests; a problem with them
424    // is reported, never a reason to distrust the results.
425    let (artifacts, index, errors) = artifact::sealed(dir);
426    let mut run = json!({
427        "schema_version":SCHEMA, "status":status,
428        "worker":s.worker,"payload_schema":s.payload_schema,"elapsed_seconds":elapsed,
429        "completed_tasks":records.len(),"requested_tasks":s.task_count,
430        "work":if complete { Some(&known) } else { None },"known_work_lower_bound":known,
431        "results":records,
432        "artifacts":artifacts,
433        "runner":{"profile":"optimization.runner/0.1","spec_digest":s.digest()?,"worker_digest":worker_digest,"accounting_complete":complete,"resources":s.resources,"recovery":"journal_only_no_resume","artifact_index":index}
434    });
435    if !errors.is_empty() {
436        run["runner"]["artifact_errors"] = json!(errors);
437    }
438    Ok(run)
439}
440/// The manifest of an attempt whose journal cannot be replayed (a corrupt or
441/// invalid record before its final line). No record is trusted, so there are
442/// no results and no work totals; `runner.journal_error` says why.
443pub fn unreadable_manifest(
444    spec: &Request,
445    elapsed: f64,
446    worker_digest: &str,
447    error: &str,
448) -> Value {
449    match spec {
450        Request::Legacy(_) => json!({
451            "schema_version":1,"status":"failed","actual_evaluations":null,
452            "elapsed_seconds":elapsed,
453            "runner":{"worker_digest":worker_digest,"accounting_complete":false,"journal_error":error}
454        }),
455        Request::Job(s) => json!({
456            "schema_version":SCHEMA, "status":"failed",
457            "worker":s.worker,"payload_schema":s.payload_schema,"elapsed_seconds":elapsed,
458            "completed_tasks":null,"requested_tasks":s.task_count,
459            "work":null,"known_work_lower_bound":null,"results":[],"artifacts":{},
460            "runner":{"profile":"optimization.runner/0.1","spec_digest":s.digest().ok(),"worker_digest":worker_digest,"accounting_complete":false,"resources":s.resources,"recovery":"journal_only_no_resume","journal_error":error}
461        }),
462    }
463}
464pub fn progress(dir: &Path, s: &Request) -> Result<Value> {
465    match s {
466        Request::Legacy(s) => {
467            let r = legacy::result::replay(dir, s)?;
468            Ok(
469                json!({"completed_retries":r.len(),"known_evaluations_lower_bound":r.iter().map(|r|r.actual_evaluations).sum::<u64>()}),
470            )
471        }
472        Request::Job(s) => {
473            let r = replay(dir, s)?;
474            let (index, errors) = artifact::progress(dir);
475            let mut v = json!({"completed_tasks":r.len(),"requested_tasks":s.task_count,"known_work_lower_bound":counts(&r)?,"artifact_index":index});
476            if !errors.is_empty() {
477                v["artifact_errors"] = json!(errors);
478            }
479            Ok(v)
480        }
481    }
482}
483pub fn repair(dir: &Path, s: &Request) -> Result<()> {
484    match s {
485        Request::Legacy(s) => {
486            legacy::result::replay(dir, s)?;
487            let p = dir.join("retries.jsonl");
488            if p.exists() {
489                storage::repair_tail::<legacy::result::RetryRecord>(&p, 1)?;
490            }
491        }
492        Request::Job(s) => {
493            replay(dir, s)?;
494            let p = dir.join("results.jsonl");
495            if p.exists() {
496                storage::repair_tail::<Record>(&p, 1)?;
497            }
498        }
499    }
500    Ok(())
501}
502pub fn worker_outcome(dir: &Path, s: &Request) -> Result<Option<(String, f64)>> {
503    if !dir.join("run.json").exists() {
504        return Ok(None);
505    }
506    let r: Value = serde_json::from_slice(&std::fs::read(dir.join("run.json"))?)?;
507    let status = r["status"].as_str().unwrap_or_default();
508    if r["runner"]["spec_digest"] != s.digest()?
509        || r["runner"]["accounting_complete"] != true
510        || !matches!(status, "completed" | "cancelled" | "timed_out")
511    {
512        return Ok(None);
513    }
514    let (done, total, valid) = match s {
515        Request::Legacy(s) => {
516            let rr = legacy::result::replay(dir, s)?;
517            (
518                rr.len(),
519                s.budget.retries,
520                r["actual_evaluations"].as_u64()
521                    == Some(rr.iter().map(|r| r.actual_evaluations).sum()),
522            )
523        }
524        Request::Job(s) => {
525            let rr = replay(dir, s)?;
526            (
527                rr.len(),
528                s.task_count.ok_or("unresolved task count")?,
529                // Compared as records, not as JSON text: an absent optional
530                // field and an explicit null mean the same.
531                r["work"] == serde_json::to_value(counts(&rr)?)?
532                    && serde_json::from_value::<Vec<Record>>(r["results"].clone())
533                        .is_ok_and(|reported| reported == rr),
534            )
535        }
536    };
537    if !valid {
538        return Ok(None);
539    }
540    if status == "completed" && done != total {
541        return Err("worker completed without all tasks".into());
542    }
543    let elapsed = r["elapsed_seconds"]
544        .as_f64()
545        .filter(|v| v.is_finite() && *v >= 0.0)
546        .ok_or("invalid worker elapsed time")?;
547    Ok(Some((status.into(), elapsed)))
548}
549pub fn compare(runs: &[Value]) -> Result<Value> {
550    let common = |r: &Value| {
551        r["schema_version"]
552            .as_str()
553            .is_some_and(|v| SCHEMAS.contains(&v))
554    };
555    if !runs.iter().any(common) {
556        return legacy::result::compare(runs);
557    }
558    if runs.iter().any(|r| {
559        !common(r)
560            || r["payload_schema"] != runs[0]["payload_schema"]
561            || r["runner"]["spec_digest"] != runs[0]["runner"]["spec_digest"]
562    }) {
563        return Err("comparison requires the same resolved job content and profile; LP and SQP claims are not interchangeable".into());
564    }
565    let mut warnings = vec![json!(
566        "No automatic winner; inspect mathematical outcomes, worker hashes and complete accounting."
567    )];
568    if runs
569        .iter()
570        .any(|r| r["schema_version"] != runs[0]["schema_version"])
571    {
572        warnings.push(json!(
573            "Arms use different result schemas: 0.1 records carry no evidence and report infeasibility as claim none."
574        ));
575    }
576    Ok(json!({"arms":runs,"warnings":warnings}))
577}