Skip to main content

oximo_solver/
reconstruct.rs

1//! Reversible coordinate changes and the common result contract.
2//!
3//! Adapters must establish native solution quality before supplying points,
4//! duals or global bounds.
5
6use std::collections::HashMap;
7use std::hash::{BuildHasher, Hash};
8
9use oximo_core::VarId;
10use rustc_hash::FxHashMap;
11
12use crate::{DualStatus, PrimalStatus, SolverResult, TerminationStatus};
13
14/// One native row's contribution to an original multiplier. Store `None` in
15/// the native row map when no reversible multiplier transformation is known.
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct DualProjection<K> {
18    pub source: K,
19    pub scale: f64,
20}
21
22impl<K: Copy + Eq + Hash> DualProjection<K> {
23    pub fn accumulate<S: BuildHasher>(
24        self,
25        map: &mut HashMap<K, f64, S>,
26        value: f64,
27        objective_sign: f64,
28    ) {
29        accumulate_dual(map, self.source, value, self.scale * objective_sign);
30    }
31}
32
33/// Maps a native objective or bound to the original model convention.
34/// A native solver that already includes the constant must use offset zero.
35#[derive(Clone, Copy, Debug)]
36pub struct ObjectiveTransform {
37    pub sign: f64,
38    pub offset: f64,
39}
40
41impl ObjectiveTransform {
42    pub fn restore(self, value: f64) -> Option<f64> {
43        let mapped = self.sign * value + self.offset;
44        (value.is_finite() && mapped.is_finite()).then_some(mapped)
45    }
46}
47
48/// Project a native column vector, excluding auxiliaries (`None`). Every
49/// original variable must occur exactly once and have a finite value.
50pub fn project_primal(
51    values: &[f64],
52    columns: &[Option<VarId>],
53    num_variables: usize,
54) -> Option<FxHashMap<VarId, f64>> {
55    if values.len() != columns.len() {
56        return None;
57    }
58    let mut primal = FxHashMap::default();
59    for (&value, &column) in values.iter().zip(columns) {
60        if let Some(id) = column
61            && (id.index() >= num_variables
62                || !value.is_finite()
63                || primal.insert(id, value).is_some())
64        {
65            return None;
66        }
67    }
68    (primal.len() == num_variables).then_some(primal)
69}
70
71/// Use when native columns are original variables in model order.
72pub fn project_dense_primal(values: &[f64], num_variables: usize) -> Option<FxHashMap<VarId, f64>> {
73    if values.len() != num_variables {
74        return None;
75    }
76    values
77        .iter()
78        .enumerate()
79        .map(|(index, &value)| {
80            let id = VarId(u32::try_from(index).ok()?);
81            value.is_finite().then_some((id, value))
82        })
83        .collect()
84}
85
86/// Accumulate a known native multiplier with its adapter-supplied coordinate
87/// factor. Missing values must not call this helper with a fabricated zero.
88pub fn accumulate_dual<K: Eq + Hash, S: BuildHasher>(
89    map: &mut HashMap<K, f64, S>,
90    id: K,
91    value: f64,
92    factor: f64,
93) {
94    *map.entry(id).or_insert(0.0) += value * factor;
95}
96
97/// Overflow-resistant symmetric relative gap in original objective units.
98pub fn relative_gap(primal: Option<f64>, bound: Option<f64>) -> Option<f64> {
99    let (p, b) = (primal?, bound?);
100    if !p.is_finite() || !b.is_finite() {
101        return None;
102    }
103    let scale = p.abs().max(b.abs()) + 1e-10;
104    Some((p / scale - b / scale).abs())
105}
106
107/// Enforce the public result contract after the adapter has established native
108/// evidence and restored coordinates. Does not infer feasibility from a stop
109/// reason, or re-check feasibility using an unrelated common tolerance.
110/// An explicit `FeasiblePoint` status caps the point's quality even if native
111/// termination was `Optimal`. Invalid incumbents cause the same downgrade for
112/// surviving pool points. Normalizing an already normalized result is safe.
113pub fn normalize_result(mut result: SolverResult, num_variables: usize) -> SolverResult {
114    let model_id = result.model_id;
115    for point in &mut result.solutions {
116        point.model_id = model_id;
117    }
118    let mut first = true;
119    let mut lost_incumbent = false;
120    result.solutions.retain_mut(|point| {
121        point.primal.retain(|id, value| id.index() < num_variables && value.is_finite());
122        point.objective = point.objective.filter(|v| v.is_finite());
123        let valid = point.primal.len() == num_variables;
124        if first {
125            lost_incumbent = !valid;
126            first = false;
127        }
128        valid
129    });
130    // A surviving pool point does not inherit the discarded incumbent's
131    // optimality certificate, so we preserve an explicit feasibility-only
132    // status on subsequent normalization too.
133    let optimal_point = result.termination == TerminationStatus::Optimal
134        && !lost_incumbent
135        && result.primal_status != PrimalStatus::FeasiblePoint;
136    result.primal_status = if result.solutions.is_empty() {
137        PrimalStatus::NoSolution
138    } else if optimal_point {
139        PrimalStatus::OptimalPoint
140    } else {
141        PrimalStatus::FeasiblePoint
142    };
143    if lost_incumbent {
144        result.dual_status = DualStatus::Unknown;
145        result.gap = None;
146    }
147    if result.dual_status != DualStatus::FeasiblePoint {
148        result.dual.clear();
149        result.soc_dual.clear();
150        result.reduced_costs.clear();
151    }
152    result.dual.retain(|_, v| v.is_finite());
153    result.soc_dual.retain(|_, v| v.is_finite() && *v >= 0.0);
154    result.reduced_costs.retain(|_, v| v.is_finite());
155    result.best_bound = result.best_bound.filter(|v| v.is_finite());
156    if result.primal_status == PrimalStatus::OptimalPoint && result.best_bound.is_none() {
157        result.best_bound = result.objective();
158    }
159    result.gap = result.gap.filter(|v| v.is_finite() && *v >= 0.0);
160    if result.primal_status == PrimalStatus::NoSolution {
161        result.gap = None;
162    }
163    result
164}