Skip to main content

dbe_ct/
indifference.rs

1use super::pref::{FalliblePreference, Preference, PreferenceError, StandardConfig};
2
3/// Configurable options for indifference curve tracing.
4#[derive(Clone, Debug)]
5pub struct IndiffConfig {
6    /// Number of points to trace along the curve (must be >= 2).
7    pub n_points: usize,
8    /// Bisection tolerance for solving U(x) = target_utility (e.g. `1e-10`).
9    pub tol: f64,
10}
11
12impl Default for IndiffConfig {
13    fn default() -> Self {
14        let standard = StandardConfig::get();
15        Self {
16            n_points: standard.indifference.indiff_n_points,
17            tol: standard.indifference.indiff_tol,
18        }
19    }
20}
21
22/// Traces an indifference curve in the plane of two goods.
23///
24/// Grids `good_i` across its bounds and for each value uses bisection to find
25/// the value of `good_j` such that U(x) = `target_utility`. All other goods
26/// are held fixed at the midpoint of their bounds.
27///
28/// Points where no solution exists within the bounds of `good_j` are silently
29/// skipped. Returns an error only if no points at all could be found.
30///
31/// # Arguments
32/// * `pref` - A validated `Preference` whose rationality axioms are already
33///   enforced
34/// * `target_utility` - The utility level U* to trace
35/// * `good_i` - Index of the good on the x-axis (gridded)
36/// * `good_j` - Index of the good on the y-axis (solved via bisection)
37/// * `config` - Curve tracing options
38pub fn trace_2d<F: Fn(&[f64]) -> f64>(
39    pref: &Preference<F>,
40    target_utility: f64,
41    good_i: usize,
42    good_j: usize,
43    config: IndiffConfig,
44) -> Result<Vec<(f64, f64)>, String> {
45    let lb = pref.min_bounds();
46    let ub = pref.max_bounds();
47    let dims = lb.len();
48
49    if good_i >= dims {
50        return Err(format!(
51            "good_i ({}) is out of bounds for a {}-good preference",
52            good_i, dims
53        ));
54    }
55    if good_j >= dims {
56        return Err(format!(
57            "good_j ({}) is out of bounds for a {}-good preference",
58            good_j, dims
59        ));
60    }
61    if good_i == good_j {
62        return Err("good_i and good_j must be different goods".into());
63    }
64    if config.n_points < 2 {
65        return Err("n_points must be at least 2".into());
66    }
67
68    // Fix all goods at their midpoints;
69    // good_i and good_j are overridden per iteration
70    let base: Vec<f64> = (0..dims).map(|d| (lb[d] + ub[d]) / 2.0).collect();
71
72    let i_min = lb[good_i];
73    let i_max = ub[good_i];
74    let step = (i_max - i_min) / (config.n_points - 1) as f64;
75
76    let mut points = Vec::with_capacity(config.n_points);
77
78    for k in 0..config.n_points {
79        let xi = i_min + k as f64 * step;
80        let state = BisectState {
81            base: &base,
82            good_i,
83            xi,
84            good_j,
85            target: target_utility,
86        };
87
88        if let Some(xj) = bisect(pref, &state, lb[good_j], ub[good_j], config.tol) {
89            points.push((xi, xj));
90        }
91    }
92
93    if points.is_empty() {
94        return Err(format!(
95            "No points found on the indifference curve for target utility {}. \
96            Check that the target utility is reachable within the bounds.",
97            target_utility
98        ));
99    }
100
101    Ok(points)
102}
103
104/// Fallible variant of [`trace_2d`] for callback-driven frontends.
105///
106/// This function mirrors [`trace_2d`] but propagates utility-evaluation
107/// errors from the supplied preference instead of assuming the utility
108/// function is infallible.
109pub fn trace_2d_fallible<F, E>(
110    pref: &FalliblePreference<F, E>,
111    target_utility: f64,
112    good_i: usize,
113    good_j: usize,
114    config: IndiffConfig,
115) -> Result<Vec<(f64, f64)>, PreferenceError<E>>
116where
117    F: Fn(&[f64]) -> Result<f64, E>,
118{
119    let lb = pref.min_bounds();
120    let ub = pref.max_bounds();
121    let dims = lb.len();
122
123    if good_i >= dims {
124        return Err(PreferenceError::Config(format!(
125            "good_i ({}) is out of bounds for a {}-good preference",
126            good_i, dims
127        )));
128    }
129    if good_j >= dims {
130        return Err(PreferenceError::Config(format!(
131            "good_j ({}) is out of bounds for a {}-good preference",
132            good_j, dims
133        )));
134    }
135    if good_i == good_j {
136        return Err(PreferenceError::Config(
137            "good_i and good_j must be different goods".into(),
138        ));
139    }
140    if config.n_points < 2 {
141        return Err(PreferenceError::Config(
142            "n_points must be at least 2".into(),
143        ));
144    }
145
146    let base: Vec<f64> = (0..dims).map(|d| (lb[d] + ub[d]) / 2.0).collect();
147    let i_min = lb[good_i];
148    let i_max = ub[good_i];
149    let step = (i_max - i_min) / (config.n_points - 1) as f64;
150    let mut points = Vec::with_capacity(config.n_points);
151
152    for k in 0..config.n_points {
153        let xi = i_min + k as f64 * step;
154        let state = BisectState {
155            base: &base,
156            good_i,
157            xi,
158            good_j,
159            target: target_utility,
160        };
161        if let Some(xj) = bisect_fallible(pref, &state, lb[good_j], ub[good_j], config.tol)? {
162            points.push((xi, xj));
163        }
164    }
165
166    if points.is_empty() {
167        return Err(PreferenceError::Config(format!(
168            "No points found on the indifference curve for target utility {}. \
169            Check that the target utility is reachable within the bounds.",
170            target_utility
171        )));
172    }
173
174    Ok(points)
175}
176
177struct BisectState<'a> {
178    base: &'a [f64],
179    good_i: usize,
180    xi: f64,
181    good_j: usize,
182    target: f64,
183}
184
185/// Bisects on `good_j` in `[j_lo, j_hi]` to find xj such that
186/// U(bundle) - target = 0. Returns None if the target is not bracketed.
187fn bisect<F: Fn(&[f64]) -> f64>(
188    pref: &Preference<F>,
189    state: &BisectState<'_>,
190    j_lo: f64,
191    j_hi: f64,
192    tol: f64,
193) -> Option<f64> {
194    let eval = |xj: f64| -> f64 {
195        let mut bundle = state.base.to_vec();
196        bundle[state.good_i] = state.xi;
197        bundle[state.good_j] = xj;
198        pref.get_utility(&bundle) - state.target
199    };
200
201    let mut lo = j_lo;
202    let mut hi = j_hi;
203    let mut f_lo = eval(lo);
204    let f_hi = eval(hi);
205
206    // Target must be bracketed between lo and hi
207    if f_lo * f_hi > 0.0 {
208        return None;
209    }
210
211    for _ in 0..200 {
212        let mid = (lo + hi) / 2.0;
213        let f_mid = eval(mid);
214
215        if f_mid.abs() < tol || (hi - lo) / 2.0 < tol {
216            return Some(mid);
217        }
218
219        if f_lo * f_mid < 0.0 {
220            hi = mid;
221        } else {
222            lo = mid;
223            f_lo = f_mid;
224        }
225    }
226
227    Some((lo + hi) / 2.0)
228}
229
230fn bisect_fallible<F, E>(
231    pref: &FalliblePreference<F, E>,
232    state: &BisectState<'_>,
233    j_lo: f64,
234    j_hi: f64,
235    tol: f64,
236) -> Result<Option<f64>, PreferenceError<E>>
237where
238    F: Fn(&[f64]) -> Result<f64, E>,
239{
240    let eval = |xj: f64| -> Result<f64, PreferenceError<E>> {
241        let mut bundle = state.base.to_vec();
242        bundle[state.good_i] = state.xi;
243        bundle[state.good_j] = xj;
244        Ok(pref.get_utility(&bundle)? - state.target)
245    };
246
247    let mut lo = j_lo;
248    let mut hi = j_hi;
249    let mut f_lo = eval(lo)?;
250    let f_hi = eval(hi)?;
251
252    if f_lo * f_hi > 0.0 {
253        return Ok(None);
254    }
255
256    for _ in 0..200 {
257        let mid = (lo + hi) / 2.0;
258        let f_mid = eval(mid)?;
259
260        if f_mid.abs() < tol || (hi - lo) / 2.0 < tol {
261            return Ok(Some(mid));
262        }
263
264        if f_lo * f_mid < 0.0 {
265            hi = mid;
266        } else {
267            lo = mid;
268            f_lo = f_mid;
269        }
270    }
271
272    Ok(Some((lo + hi) / 2.0))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::pref::Preference;
279
280    // Cobb-Douglas: U(x, y) = sqrt(x * y)
281    fn cobb_douglas(bundle: &[f64]) -> f64 {
282        bundle.iter().product::<f64>().sqrt()
283    }
284
285    // Linear: U(x, y) = x + y
286    fn linear(bundle: &[f64]) -> f64 {
287        bundle.iter().sum()
288    }
289
290    #[test]
291    fn test_trace_2d_cobb_douglas_points_lie_on_curve() {
292        // For U = sqrt(xy) = U*, every returned point must satisfy U(xi, xj) ~= U*
293        let pref = Preference::new(cobb_douglas, vec![0.1, 0.1], vec![10.0, 10.0]).unwrap();
294        let target = 2.0;
295        let config = IndiffConfig::default();
296
297        let points = trace_2d(&pref, target, 0, 1, config).unwrap();
298
299        assert!(!points.is_empty(), "Expected at least one point");
300        for (xi, xj) in &points {
301            let u = cobb_douglas(&[*xi, *xj]);
302            assert!(
303                (u - target).abs() < 1e-6,
304                "Point ({}, {}) has utility {} ~= {}",
305                xi,
306                xj,
307                u,
308                target
309            );
310        }
311    }
312
313    #[test]
314    fn test_trace_2d_linear_returns_expected_n_points() {
315        // For U(x, y) = x + y = target, every xi in [0, target] has a valid xj
316        let pref = Preference::new(linear, vec![0.0, 0.0], vec![10.0, 10.0]).unwrap();
317        let config = IndiffConfig {
318            n_points: 50,
319            tol: 1e-10,
320        };
321
322        let points = trace_2d(&pref, 8.0, 0, 1, config).unwrap();
323
324        assert!(
325            points.len() >= 40,
326            "Expected close to 50 points, got {}",
327            points.len()
328        );
329    }
330
331    #[test]
332    fn test_trace_2d_same_good_raises_err() {
333        let pref = Preference::new(cobb_douglas, vec![0.1, 0.1], vec![10.0, 10.0]).unwrap();
334
335        let result = trace_2d(&pref, 2.0, 0, 0, IndiffConfig::default());
336
337        assert!(result.is_err());
338        assert!(result.unwrap_err().contains("must be different"));
339    }
340
341    #[test]
342    fn test_trace_2d_out_of_bounds_good_raises_err() {
343        let pref = Preference::new(cobb_douglas, vec![0.1, 0.1], vec![10.0, 10.0]).unwrap();
344
345        let result = trace_2d(&pref, 2.0, 0, 5, IndiffConfig::default());
346
347        assert!(result.is_err());
348        assert!(result.unwrap_err().contains("out of bounds"));
349    }
350
351    #[test]
352    fn test_trace_2d_unreachable_utility_raises_err() {
353        // U = sqrt(xy) <= sqrt(10*10) = 10, so target 999 is unreachable
354        let pref = Preference::new(cobb_douglas, vec![0.1, 0.1], vec![10.0, 10.0]).unwrap();
355
356        let result = trace_2d(&pref, 999.0, 0, 1, IndiffConfig::default());
357
358        assert!(result.is_err());
359        assert!(result.unwrap_err().contains("No points found"));
360    }
361
362    #[test]
363    fn test_trace_2d_n_points_less_than_2_raises_err() {
364        let pref = Preference::new(cobb_douglas, vec![0.1, 0.1], vec![10.0, 10.0]).unwrap();
365        let config = IndiffConfig {
366            n_points: 1,
367            tol: 1e-10,
368        };
369
370        let result = trace_2d(&pref, 2.0, 0, 1, config);
371
372        assert!(result.is_err());
373        assert!(result.unwrap_err().contains("n_points must be at least 2"));
374    }
375}