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}