fts_solver/impls/
clarabel.rs

1use crate::{AuctionOutcome, HashMap, PortfolioOutcome, ProductOutcome, Solver, Submission};
2use clarabel::{algebra::*, solver::*};
3use std::hash::Hash;
4
5/// A solver implementation that uses the Clarabel interior point method
6/// for quadratic programming to solve the market clearing problem.
7///
8/// This solver is generally more accurate but can be slower than ADMM-based
9/// solvers for large problems. It's a good choice when high precision is needed.
10pub struct ClarabelSolver(DefaultSettings<f64>);
11
12impl Default for ClarabelSolver {
13    fn default() -> Self {
14        let mut settings = DefaultSettings::default();
15        settings.verbose = false;
16        Self(settings)
17    }
18}
19
20impl Solver for ClarabelSolver {
21    type Settings = DefaultSettings<f64>;
22    type Status = SolverStatus;
23
24    fn new(settings: Self::Settings) -> Self {
25        Self(settings)
26    }
27
28    fn solve<
29        T,
30        BidderId: Eq + Hash + Clone + Ord,
31        PortfolioId: Eq + Hash + Clone + Ord,
32        ProductId: Eq + Hash + Clone + Ord,
33    >(
34        &self,
35        auction: &T,
36        // TODO: warm-starts with the prices
37    ) -> Result<AuctionOutcome<BidderId, PortfolioId, ProductId>, Self::Status>
38    where
39        for<'t> &'t T: IntoIterator<Item = (&'t BidderId, &'t Submission<PortfolioId, ProductId>)>,
40    {
41        let (products, ncosts) = super::prepare(auction);
42
43        if products.len() == 0 {
44            return Ok(AuctionOutcome::default());
45        }
46
47        // The trade and bid constraints are all (something) = 0, we need to
48        // know how many of these there are in order to handle the box
49        // constraints for each decision variable
50        let nzero = products.len() + ncosts;
51
52        // Our quadratic term is diagonal, so we build the matrix by defining its diagonal
53        let mut p = Vec::new();
54        // and these are the linear terms
55        let mut q = Vec::new();
56
57        // Clarabel handles constraints via a cone specification, e.g. Ax + s = b, where s is a cone.
58        // The first `nzero` of b and s are just =0, so we do that work upfront.
59        let mut b = vec![0.0; nzero];
60        let mut s = vec![ZeroConeT(nzero)];
61
62        // Clarabel's matrix input is in the form of CSC, so we handle the memory representation
63        // carefully.
64        let mut a_nzval = Vec::new();
65        let mut a_rowval = Vec::new();
66        let mut a_colptr = Vec::new();
67
68        // These will help us figure out the correct row index as we iterate.
69        let mut group_offset = products.len();
70
71        // We begin by setting up the portfolio variables
72        for (_, submission) in auction.into_iter() {
73            for (id, portfolio) in submission.portfolios.iter() {
74                // portfolio variables contribute nothing to the objective
75                p.push(0.0);
76                q.push(0.0);
77
78                // start a new column in the constraint matrix
79                a_colptr.push(a_nzval.len());
80
81                // We copy the portfolio definition into the matrix
82                for (product, &weight) in portfolio.iter() {
83                    a_nzval.push(weight);
84                    a_rowval.push(products.get_index_of(product).unwrap());
85                }
86
87                // Now we embed the weights from each group. This loop is a little wonky;
88                // in matrix terms, the representation is transposed in the wrong way.
89                // However, we expect the number of groups to be fairly small, so simply
90                // searching every group for every portfolio in the submission is not so bad.
91                for (offset, (group, _)) in submission.demand_curves.iter().enumerate() {
92                    if let Some(&weight) = group.get(id) {
93                        a_nzval.push(weight);
94                        a_rowval.push(group_offset + offset);
95                    }
96                }
97            }
98
99            group_offset += submission.demand_curves.len();
100        }
101
102        // Now we setup the segment variables
103        group_offset = products.len();
104        for (_, submission) in auction.into_iter() {
105            for (_, curve) in submission.demand_curves.iter() {
106                for segment in curve.iter() {
107                    let (m, pzero) = segment.slope_intercept();
108
109                    // Setup the contributions to the objective
110                    p.push(-m);
111                    q.push(-pzero);
112
113                    // Insert a new column
114                    a_colptr.push(a_nzval.len());
115
116                    // Ensure it counts towards the group
117                    a_nzval.push(-1.0);
118                    a_rowval.push(group_offset);
119
120                    // Setup the box constraints
121                    // x0 <= y <= x1 ==> -y + s == -x0 and y + s == x1
122                    if segment.q0.is_finite() {
123                        a_nzval.push(-1.0);
124                        a_rowval.push(b.len());
125                        b.push(-segment.q0);
126                    }
127                    if segment.q1.is_finite() {
128                        a_nzval.push(1.0);
129                        a_rowval.push(b.len());
130                        b.push(segment.q1);
131                    }
132                }
133
134                // Advance the group offset for the next bid/constraint
135                group_offset += 1;
136            }
137        }
138
139        // We need to polish off the CSC matrix
140        a_colptr.push(a_nzval.len());
141
142        let a_matrix = CscMatrix {
143            m: b.len(),
144            n: p.len(),
145            colptr: a_colptr,
146            rowval: a_rowval,
147            nzval: a_nzval,
148        };
149
150        assert!(a_matrix.check_format().is_ok()); // TODO: maybe remove this
151
152        // We also need to cleanup the cone specification
153        s.push(NonnegativeConeT(b.len() - nzero));
154
155        // Finally, we need to convert our p spec into a csc matrix
156        let p_matrix = {
157            let m = p.len();
158            let n = p.len();
159
160            CscMatrix {
161                m,
162                n,
163                colptr: (0..=n).collect(),
164                rowval: (0..n).collect(),
165                nzval: p,
166            }
167        };
168
169        // Now we can solve!
170        let mut solver = DefaultSolver::new(&p_matrix, &q, &a_matrix, &b, &s, self.0.clone());
171        solver.solve();
172        match solver.solution.status {
173            SolverStatus::Solved => {}
174            SolverStatus::AlmostSolved => {
175                tracing::warn!(status = ?solver.solution.status, "convergence issues");
176            }
177            status => {
178                return Err(status);
179            }
180        };
181
182        // We get the raw optimization output
183
184        let mut product_outcomes: HashMap<ProductId, ProductOutcome> = products
185            .into_iter()
186            .zip(solver.solution.z.iter())
187            .map(|(product, &price)| (product, ProductOutcome { price, trade: 0.0 }))
188            .collect();
189
190        let mut trades = solver.solution.x.iter();
191
192        let submission_outcomes = auction
193            .into_iter()
194            .map(|(bidder_id, submission)| {
195                let outcome = submission
196                    .portfolios
197                    .iter()
198                    .map(|(id, portfolio)| {
199                        // Safe, because we necessarily have every portfolio represented in the solution
200                        let trade = *trades.next().unwrap();
201
202                        let mut price = 0.0;
203
204                        for (product_id, weight) in portfolio.iter() {
205                            // Safe, because product outcomes contains all referenced products
206                            let product_outcome = product_outcomes.get_mut(product_id).unwrap();
207
208                            // We report the trade (in an absolute sense), to be halved later
209                            product_outcome.trade += (weight * trade).abs();
210
211                            // We also compute the effective price of the portfolio
212                            price += weight * product_outcome.price;
213
214                            // TODO: consider special summation algorithms (i.e. Kahan) for the above dot products
215                        }
216
217                        (id.clone(), PortfolioOutcome { price, trade })
218                    })
219                    .collect::<HashMap<_, _>>();
220
221                (bidder_id.clone(), outcome)
222            })
223            .collect::<HashMap<_, _>>();
224
225        // We have double-counted the trade for each product, so we halve it
226        for outcome in product_outcomes.values_mut() {
227            outcome.trade *= 0.5;
228        }
229
230        // TODO:
231        // We have assigned the products prices straight from the solver
232        // (and computed the portfolio prices from those).
233        // Under pathological circumstances, the price may not be unique
234        // (either when there is no trade, or the supply exactly matches the demand).
235        // We should think about injecting an auxiliary solve for choosing a canonical
236        // price and/or for detecting when there is such a degeneracy.
237
238        // TODO:
239        // When there are "flat" demand curves, it is possible for nonuniqueness
240        // in the traded outcomes. The convex regularization is to minimize the L2 norm
241        // of the trades as a tie-break. We should think about the best way to regularize
242        // the solve accordingly.
243
244        Ok(AuctionOutcome {
245            submissions: submission_outcomes,
246            products: product_outcomes,
247        })
248    }
249}