Skip to main content

dbe_ct/
marshallian.rs

1use super::pref::{FalliblePreference, Preference, PreferenceError, StandardConfig};
2
3/// A linear budget constraint: p * x <= income
4pub struct BudgetConstraint {
5    /// Prices for each good, must match the dimensionality of the Preference bounds.
6    pub prices: Vec<f64>,
7    /// Total income available to the consumer.
8    pub income: f64,
9}
10
11/// Configurable options for the interior-point bundle optimisation.
12#[derive(Clone, Debug)]
13pub struct OptimConfig {
14    /// Initial barrier weight.
15    pub mu_init: f64,
16    /// Multiplicative decay applied to mu per outer iteration (e.g. `0.1`).
17    pub mu_decay: f64,
18    /// Number of outer iterations (mu reductions).
19    pub outer_iters: usize,
20    /// Number of gradient ascent steps per outer iteration.
21    pub inner_iters: usize,
22    /// Initial step size for gradient ascent.
23    pub step_size: f64,
24    /// Convergence tolerance: stops if the gradient norm falls below this.
25    pub tol: f64,
26}
27
28impl Default for OptimConfig {
29    fn default() -> Self {
30        let standard = StandardConfig::get();
31        Self {
32            mu_init: standard.optimisation.optim_mu_init,
33            mu_decay: standard.optimisation.optim_mu_decay,
34            outer_iters: standard.optimisation.optim_outer_iters,
35            inner_iters: standard.optimisation.optim_inner_iters,
36            step_size: standard.optimisation.optim_step_size,
37            tol: standard.optimisation.optim_tol,
38        }
39    }
40}
41
42/// Finds the optimal consumption bundle that maximises utility subject to a
43/// budget constraint.
44///
45/// Uses an interior-point log-barrier method with backtracking line search.
46/// The barrier objective is:
47///
48/// `B(x, mu) = U(x) + mu * log(I - p * x) + mu * SUM(log(x_i - lb_i) + mu * SUM(log(ub_i - x_i))`
49///
50/// As mu -> 0 across outer iterations the solution converges to the constrained
51/// optimum.
52///
53/// # Arguments
54/// * `pref` - A validated `Preference` whose rationality axioms are already
55///   enforced
56/// * `constraint` - The budget constraint `p * x <= income`
57/// * `config` - Optimisation hyperparameters
58pub fn optimal_bundle<F: Fn(&[f64]) -> f64>(
59    pref: &Preference<F>,
60    constraint: &BudgetConstraint,
61    config: OptimConfig,
62) -> Result<Vec<f64>, String> {
63    let lb = pref.min_bounds();
64    let ub = pref.max_bounds();
65    let dims = lb.len();
66    let prices = &constraint.prices;
67    let income = constraint.income;
68
69    if prices.len() != dims {
70        return Err("prices length must match the number of goods in the Preference".into());
71    }
72    if income <= 0.0 {
73        return Err("income must be positive".into());
74    }
75    if prices.iter().any(|&p| p <= 0.0) {
76        return Err("all prices must be positive".into());
77    }
78
79    // Distribute income equally across goods (equal expenditure share), then
80    // pull 50% toward the lower bound to ensure strict interior feasibility.
81    let mut x: Vec<f64> = (0..dims)
82        .map(|i| {
83            let equal_share = income / (dims as f64 * prices[i]);
84            lb[i] + 0.5 * (equal_share.min(ub[i]) - lb[i])
85        })
86        .collect();
87
88    if !is_feasible(&x, lb, ub, prices, income) {
89        return Err("Could not find a feasible interior starting point".into());
90    }
91
92    let mut mu = config.mu_init;
93
94    for _ in 0..config.outer_iters {
95        for _ in 0..config.inner_iters {
96            let grad = barrier_gradient(pref, &x, lb, ub, prices, income, mu);
97
98            // Check convergence: gradient norm
99            let grad_norm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
100            if grad_norm < config.tol {
101                return Ok(x);
102            }
103
104            // Backtracking line search: halve step until the move stays feasible
105            // and the barrier objective improves
106            let b_current = barrier_value(pref, &x, lb, ub, prices, income, mu);
107            let mut step = config.step_size;
108            let x_new = loop {
109                let candidate: Vec<f64> =
110                    x.iter().zip(&grad).map(|(xi, gi)| xi + step * gi).collect();
111
112                if is_feasible(&candidate, lb, ub, prices, income) {
113                    let b_new = barrier_value(pref, &candidate, lb, ub, prices, income, mu);
114                    if b_new > b_current {
115                        break candidate;
116                    }
117                }
118
119                step *= 0.5;
120
121                // step approaches zero -> at a local maximum -> stop inner loop
122                if step < 1e-15 {
123                    break x.clone();
124                }
125            };
126
127            x = x_new;
128        }
129
130        mu *= config.mu_decay;
131    }
132
133    Ok(x)
134}
135
136/// Fallible variant of [`optimal_bundle`] for callback-driven frontends.
137///
138/// This function mirrors [`optimal_bundle`] but propagates utility-evaluation
139/// errors from the supplied preference instead of assuming the utility
140/// function is infallible.
141pub fn optimal_bundle_fallible<F, E>(
142    pref: &FalliblePreference<F, E>,
143    constraint: &BudgetConstraint,
144    config: OptimConfig,
145) -> Result<Vec<f64>, PreferenceError<E>>
146where
147    F: Fn(&[f64]) -> Result<f64, E>,
148{
149    let lb = pref.min_bounds();
150    let ub = pref.max_bounds();
151    let dims = lb.len();
152    let prices = &constraint.prices;
153    let income = constraint.income;
154
155    if prices.len() != dims {
156        return Err(PreferenceError::Config(
157            "prices length must match the number of goods in the Preference".into(),
158        ));
159    }
160    if income <= 0.0 {
161        return Err(PreferenceError::Config("income must be positive".into()));
162    }
163    if prices.iter().any(|&p| p <= 0.0) {
164        return Err(PreferenceError::Config(
165            "all prices must be positive".into(),
166        ));
167    }
168
169    let mut x: Vec<f64> = (0..dims)
170        .map(|i| {
171            let equal_share = income / (dims as f64 * prices[i]);
172            lb[i] + 0.5 * (equal_share.min(ub[i]) - lb[i])
173        })
174        .collect();
175
176    if !is_feasible(&x, lb, ub, prices, income) {
177        return Err(PreferenceError::Config(
178            "Could not find a feasible interior starting point".into(),
179        ));
180    }
181
182    let mut mu = config.mu_init;
183
184    for _ in 0..config.outer_iters {
185        for _ in 0..config.inner_iters {
186            let grad = barrier_gradient_fallible(pref, &x, lb, ub, prices, income, mu)?;
187            let grad_norm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
188            if grad_norm < config.tol {
189                return Ok(x);
190            }
191
192            let b_current = barrier_value_fallible(pref, &x, lb, ub, prices, income, mu)?;
193            let mut step = config.step_size;
194            let x_new = loop {
195                let candidate: Vec<f64> =
196                    x.iter().zip(&grad).map(|(xi, gi)| xi + step * gi).collect();
197
198                if is_feasible(&candidate, lb, ub, prices, income) {
199                    let b_new =
200                        barrier_value_fallible(pref, &candidate, lb, ub, prices, income, mu)?;
201                    if b_new > b_current {
202                        break candidate;
203                    }
204                }
205
206                step *= 0.5;
207                if step < 1e-15 {
208                    break x.clone();
209                }
210            };
211
212            x = x_new;
213        }
214
215        mu *= config.mu_decay;
216    }
217
218    Ok(x)
219}
220
221/// Evaluates the log-barrier objective at a given point.
222fn barrier_value<F: Fn(&[f64]) -> f64>(
223    pref: &Preference<F>,
224    x: &[f64],
225    lb: &[f64],
226    ub: &[f64],
227    prices: &[f64],
228    income: f64,
229    mu: f64,
230) -> f64 {
231    let budget_slack = income - dot(prices, x);
232    let lb_slack: f64 = x.iter().zip(lb).map(|(xi, li)| (xi - li).ln()).sum();
233    let ub_slack: f64 = x.iter().zip(ub).map(|(xi, ui)| (ui - xi).ln()).sum();
234    pref.get_utility(x) + mu * (budget_slack.ln() + lb_slack + ub_slack)
235}
236
237/// Computes the gradient of the barrier objective via numerical MU +
238/// analytical constraint terms.
239fn barrier_gradient<F: Fn(&[f64]) -> f64>(
240    pref: &Preference<F>,
241    x: &[f64],
242    lb: &[f64],
243    ub: &[f64],
244    prices: &[f64],
245    income: f64,
246    mu: f64,
247) -> Vec<f64> {
248    let budget_slack = income - dot(prices, x);
249    (0..x.len())
250        .map(|i| {
251            let du = pref.get_mu(x, i);
252            let budget_term = -mu * prices[i] / budget_slack;
253            let lb_term = mu / (x[i] - lb[i]);
254            let ub_term = -mu / (ub[i] - x[i]);
255            du + budget_term + lb_term + ub_term
256        })
257        .collect()
258}
259
260fn barrier_value_fallible<F, E>(
261    pref: &FalliblePreference<F, E>,
262    x: &[f64],
263    lb: &[f64],
264    ub: &[f64],
265    prices: &[f64],
266    income: f64,
267    mu: f64,
268) -> Result<f64, PreferenceError<E>>
269where
270    F: Fn(&[f64]) -> Result<f64, E>,
271{
272    let budget_slack = income - dot(prices, x);
273    let lb_slack: f64 = x.iter().zip(lb).map(|(xi, li)| (xi - li).ln()).sum();
274    let ub_slack: f64 = x.iter().zip(ub).map(|(xi, ui)| (ui - xi).ln()).sum();
275    Ok(pref.get_utility(x)? + mu * (budget_slack.ln() + lb_slack + ub_slack))
276}
277
278fn barrier_gradient_fallible<F, E>(
279    pref: &FalliblePreference<F, E>,
280    x: &[f64],
281    lb: &[f64],
282    ub: &[f64],
283    prices: &[f64],
284    income: f64,
285    mu: f64,
286) -> Result<Vec<f64>, PreferenceError<E>>
287where
288    F: Fn(&[f64]) -> Result<f64, E>,
289{
290    let budget_slack = income - dot(prices, x);
291    (0..x.len())
292        .map(|i| {
293            let du = pref.get_mu(x, i)?;
294            let budget_term = -mu * prices[i] / budget_slack;
295            let lb_term = mu / (x[i] - lb[i]);
296            let ub_term = -mu / (ub[i] - x[i]);
297            Ok(du + budget_term + lb_term + ub_term)
298        })
299        .collect()
300}
301
302/// Returns true if x is strictly interior to all constraints.
303fn is_feasible(x: &[f64], lb: &[f64], ub: &[f64], prices: &[f64], income: f64) -> bool {
304    let budget_ok = dot(prices, x) < income;
305    let bounds_ok = x
306        .iter()
307        .zip(lb)
308        .zip(ub)
309        .all(|((xi, li), ui)| xi > li && xi < ui);
310    budget_ok && bounds_ok
311}
312
313fn dot(a: &[f64], b: &[f64]) -> f64 {
314    a.iter().zip(b).map(|(ai, bi)| ai * bi).sum()
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::pref::Preference;
321
322    // Cobb-Douglas: U(x, y) = sqrt(x * y)
323    fn cobb_douglas(bundle: &[f64]) -> f64 {
324        bundle.iter().product::<f64>().sqrt()
325    }
326
327    // Linear: U(x, y) = x + y
328    fn linear(bundle: &[f64]) -> f64 {
329        bundle.iter().sum()
330    }
331
332    #[test]
333    fn test_optimal_bundle_cobb_douglas_returns_ok() {
334        // For U = sqrt(xy), p = [1, 1], I = 10:
335        // Analytical solution: x* = y* = I / (2p) = 5.0
336        let pref = Preference::new(cobb_douglas, vec![0.0, 0.0], vec![20.0, 20.0]).unwrap();
337        let constraint = BudgetConstraint {
338            prices: vec![1.0, 1.0],
339            income: 10.0,
340        };
341
342        let result = optimal_bundle(&pref, &constraint, OptimConfig::default());
343
344        assert!(result.is_ok(), "Optimisation failed: {:?}", result);
345        let x = result.unwrap();
346        assert!((x[0] - 5.0).abs() < 0.1, "Expected x ~= 5.0, got {}", x[0]);
347        assert!((x[1] - 5.0).abs() < 0.1, "Expected y ~= 5.0, got {}", x[1]);
348    }
349
350    #[test]
351    fn test_optimal_bundle_mismatched_prices_raises_err() {
352        let pref = Preference::new(linear, vec![0.0, 0.0], vec![10.0, 10.0]).unwrap();
353        let constraint = BudgetConstraint {
354            prices: vec![1.0],
355            income: 10.0,
356        };
357
358        let result = optimal_bundle(&pref, &constraint, OptimConfig::default());
359
360        assert!(result.is_err());
361        assert!(result.unwrap_err().contains("prices length"));
362    }
363
364    #[test]
365    fn test_optimal_bundle_zero_income_raises_err() {
366        let pref = Preference::new(linear, vec![0.0, 0.0], vec![10.0, 10.0]).unwrap();
367        let constraint = BudgetConstraint {
368            prices: vec![1.0, 1.0],
369            income: 0.0,
370        };
371
372        let result = optimal_bundle(&pref, &constraint, OptimConfig::default());
373
374        assert!(result.is_err());
375        assert!(result.unwrap_err().contains("income must be positive"));
376    }
377
378    #[test]
379    fn test_optimal_bundle_solution_satisfies_budget_constraint() {
380        let pref = Preference::new(cobb_douglas, vec![0.0, 0.0], vec![20.0, 20.0]).unwrap();
381        let prices = vec![2.0, 3.0];
382        let income = 12.0;
383        let constraint = BudgetConstraint {
384            prices: prices.clone(),
385            income,
386        };
387
388        let x = optimal_bundle(&pref, &constraint, OptimConfig::default()).unwrap();
389
390        let expenditure: f64 = x.iter().zip(&prices).map(|(xi, pi)| xi * pi).sum();
391        assert!(
392            expenditure <= income + 1e-6,
393            "Budget constraint violated: expenditure {} > income {}",
394            expenditure,
395            income
396        );
397    }
398}