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