Skip to main content

little_sorry/
rules.rs

1//! The concrete regret-update rules.
2//!
3//! Each type here is a zero-sized marker implementing [`UpdateRule`]; the
4//! algorithm lives entirely in its method bodies, which reproduce the exact
5//! floating-point arithmetic of the matching scalar matcher so a batched
6//! matcher at batch size 1 agrees bit-for-bit. The five rules span the shape
7//! space the abstraction must cover: signed vs. floored regret, weighted vs.
8//! discounted accumulation, and non-predictive (2 lanes) vs. predictive (3).
9//!
10//! Two recurring ideas, stated once:
11//!
12//! - **Discounting old regret.** Regret accumulated under early, poorly-informed
13//!   strategies is downweighted so the running totals track improving play. The
14//!   factor `d(t, e) = tᵉ / (tᵉ + 1)` rises toward 1 with `t`, so recent regret
15//!   is discounted less than old regret. (Brown & Sandholm 2019,
16//!   *Solving Imperfect-Information Games via Discounted Regret Minimization*,
17//!   arXiv:1809.04040.)
18//! - **Prediction.** If play changes smoothly, the next instantaneous regret
19//!   resembles the last one, so a predictive rule forms its strategy from the
20//!   cumulative regret *plus* that most-recent regret — reacting one step early.
21//!   The prediction is transient: it shapes the strategy but is never stored
22//!   into cumulative regret. (Farina, Kroer & Sandholm 2021, arXiv:2007.14358;
23//!   Xu et al. 2024, *Minimizing Weighted Counterfactual Regret with Optimistic
24//!   Online Mirror Descent*, arXiv:2404.13891.)
25
26use crate::discount::DiscountParams;
27use crate::probability::normalize_inplace;
28use crate::regret_minimizer::regret_match;
29use crate::update_rule::UpdateRule;
30
31/// Discount parameters for the `+`/predictive discounted rules: `alpha`
32/// discounts cumulative regret, `gamma` discounts the average-strategy
33/// contribution. (Unlike full DCFR there is no separate negative-regret
34/// exponent — flooring at zero removes negative regret outright.)
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct PlusDiscount {
37    /// Regret-discount exponent.
38    pub alpha: f32,
39    /// Average-strategy-discount exponent.
40    pub gamma: f32,
41}
42
43/// `((t-1)/t)^gamma`, the average-strategy discount the `+` rules apply once
44/// they have a previous iterate. Returns `0.0` at `t == 1`, where there is no
45/// prior strategy to carry forward (matching the scalar matchers).
46fn plus_strategy_discount(t: usize, gamma: f32) -> f32 {
47    if t > 1 {
48        ((t - 1) as f32 / t as f32).powf(gamma)
49    } else {
50        0.0
51    }
52}
53
54/// Normalize `[regret · regret_discount + last_inst]^+` into `out`. With
55/// `regret_discount = 1` and zero prediction this reduces to plain regret
56/// matching, so it serves predictive and non-predictive derivation alike.
57fn predicted_strategy(regret: &[f32], last_inst: &[f32], regret_discount: f32, out: &mut [f32]) {
58    for ((o, &r), &m) in out.iter_mut().zip(regret).zip(last_inst) {
59        *o = (r * regret_discount + m).max(0.0);
60    }
61    normalize_inplace(out);
62}
63
64// ── DCFR ─────────────────────────────────────────────────────────────────────
65
66/// Discounted CFR. Signed cumulative regret, each iteration's old positive
67/// regret scaled by `d(t, α)` and old negative regret by `d(t, β)` before the
68/// new regret is added; the average strategy is discounted by `(t/(t+1))^γ`.
69pub struct Dcfr;
70
71/// Shared per-iteration factors for [`Dcfr`].
72pub struct DcfrStep {
73    positive: f32,
74    negative: f32,
75    strategy: f32,
76}
77
78impl UpdateRule for Dcfr {
79    type Params = DiscountParams;
80    type Step = DcfrStep;
81    const LANES: usize = 2;
82
83    fn step(p: &Self::Params, t: usize) -> Self::Step {
84        DcfrStep {
85            positive: DiscountParams::discount_factor(t, p.alpha),
86            negative: DiscountParams::discount_factor(t, p.beta),
87            strategy: (t as f32 / (t as f32 + 1.0)).powf(p.gamma),
88        }
89    }
90
91    fn strategy_from_lanes(_: &Self::Params, regret: &[f32], _: &[f32], _: f32, out: &mut [f32]) {
92        regret_match(regret, out);
93    }
94
95    fn pre_discount(_: &Self::Step) -> f32 {
96        0.0
97    }
98    fn post_discount(_: &Self::Step) -> f32 {
99        0.0
100    }
101
102    fn accumulate_regret(s: &Self::Step, old: f32, reward: f32, expected: f32) -> f32 {
103        let d = if old > 0.0 { s.positive } else { s.negative };
104        old * d + (reward - expected)
105    }
106
107    fn strategy_accumulation(s: &Self::Step) -> (f32, f32) {
108        (s.strategy, 1.0)
109    }
110
111    fn regret_weight_step(s: &Self::Step, old_w: f32) -> f32 {
112        old_w * s.positive + 1.0
113    }
114    fn regret_weight_total(_: &Self::Params, _t: usize, accum_w: f32) -> f32 {
115        accum_w
116    }
117}
118
119// ── DCFR+ ─────────────────────────────────────────────────────────────────────
120
121/// Discounted CFR+. Like DCFR but regret is floored at zero *after* the discount
122/// and add, so a recovering action need not first pay back accumulated negative
123/// regret. A single discount `d(t-1, α)` applies (no negative branch, since
124/// there is no negative regret to treat).
125pub struct DcfrPlus;
126
127/// Shared per-iteration factors for [`DcfrPlus`].
128pub struct PlusStep {
129    regret: f32,
130    strategy: f32,
131}
132
133impl DcfrPlus {
134    /// Grid-searched defaults from the source paper: `α = 1.5`, `γ = 4`.
135    pub const RECOMMENDED: PlusDiscount = PlusDiscount {
136        alpha: 1.5,
137        gamma: 4.0,
138    };
139}
140
141impl UpdateRule for DcfrPlus {
142    type Params = PlusDiscount;
143    type Step = PlusStep;
144    const LANES: usize = 2;
145
146    fn step(p: &Self::Params, t: usize) -> Self::Step {
147        PlusStep {
148            // The accumulator being discounted is the *previous* iterate, so its
149            // index is t-1; nothing to discount on the first iteration.
150            regret: if t > 1 {
151                DiscountParams::discount_factor(t - 1, p.alpha)
152            } else {
153                0.0
154            },
155            strategy: plus_strategy_discount(t, p.gamma),
156        }
157    }
158
159    fn strategy_from_lanes(_: &Self::Params, regret: &[f32], _: &[f32], _: f32, out: &mut [f32]) {
160        regret_match(regret, out);
161    }
162
163    fn pre_discount(_: &Self::Step) -> f32 {
164        0.0
165    }
166    fn post_discount(_: &Self::Step) -> f32 {
167        0.0
168    }
169
170    fn accumulate_regret(s: &Self::Step, old: f32, reward: f32, expected: f32) -> f32 {
171        // Left-associative `old*d + reward - expected`, mirroring dcfr_plus.rs.
172        (old * s.regret + reward - expected).max(0.0)
173    }
174
175    fn strategy_accumulation(s: &Self::Step) -> (f32, f32) {
176        (s.strategy, 1.0)
177    }
178
179    fn regret_weight_step(s: &Self::Step, old_w: f32) -> f32 {
180        old_w * s.regret + 1.0
181    }
182    fn regret_weight_total(_: &Self::Params, _t: usize, accum_w: f32) -> f32 {
183        accum_w
184    }
185}
186
187// ── Linear CFR ────────────────────────────────────────────────────────────────
188
189/// Linear CFR. The cheapest discounting: weight iteration `t`'s regret and
190/// strategy contribution by `t`, so early iterates fade linearly. Equivalent to
191/// DCFR with α=β=γ=1, but expressed (like the scalar) in the increasing-weight
192/// form, so the stored totals grow rather than staying bounded.
193pub struct LinearCfr;
194
195/// Shared per-iteration factor for [`LinearCfr`]: the iteration weight `t`.
196pub struct LinearStep {
197    t: f32,
198}
199
200impl UpdateRule for LinearCfr {
201    type Params = ();
202    type Step = LinearStep;
203    const LANES: usize = 2;
204
205    fn step(_: &Self::Params, t: usize) -> Self::Step {
206        LinearStep { t: t as f32 }
207    }
208
209    fn strategy_from_lanes(_: &Self::Params, regret: &[f32], _: &[f32], _: f32, out: &mut [f32]) {
210        regret_match(regret, out);
211    }
212
213    fn pre_discount(_: &Self::Step) -> f32 {
214        0.0
215    }
216    fn post_discount(_: &Self::Step) -> f32 {
217        0.0
218    }
219
220    fn accumulate_regret(s: &Self::Step, old: f32, reward: f32, expected: f32) -> f32 {
221        old + s.t * (reward - expected)
222    }
223
224    fn strategy_accumulation(s: &Self::Step) -> (f32, f32) {
225        (1.0, s.t)
226    }
227
228    fn regret_weight_step(_: &Self::Step, old_w: f32) -> f32 {
229        old_w // unused; the total is a closed form of t
230    }
231    fn regret_weight_total(_: &Self::Params, t: usize, _accum_w: f32) -> f32 {
232        // Σ_{i=1}^{t} i = t(t+1)/2, the total weight applied to regret.
233        let t = t as f32;
234        t * (t + 1.0) / 2.0
235    }
236}
237
238// ── PCFR+ ─────────────────────────────────────────────────────────────────────
239
240/// Predictive CFR+. CFR+'s floored regret, but the strategy is formed from the
241/// cumulative regret plus the most-recent instantaneous regret (the
242/// prediction), and the average strategy uses quadratic (`t²`) weighting.
243pub struct PcfrPlus;
244
245/// Shared per-iteration factor for [`PcfrPlus`]: the quadratic averaging weight
246/// `t²`.
247pub struct PcfrPlusStep {
248    quadratic: f32,
249}
250
251impl UpdateRule for PcfrPlus {
252    type Params = ();
253    type Step = PcfrPlusStep;
254    const LANES: usize = 3;
255
256    fn step(_: &Self::Params, t: usize) -> Self::Step {
257        PcfrPlusStep {
258            quadratic: (t * t) as f32,
259        }
260    }
261
262    fn strategy_from_lanes(
263        _: &Self::Params,
264        regret: &[f32],
265        last: &[f32],
266        d: f32,
267        out: &mut [f32],
268    ) {
269        predicted_strategy(regret, last, d, out);
270    }
271
272    fn pre_discount(_: &Self::Step) -> f32 {
273        1.0 // undiscounted prediction: regret enters the strategy at full weight
274    }
275    fn post_discount(_: &Self::Step) -> f32 {
276        1.0
277    }
278
279    fn accumulate_regret(_: &Self::Step, old: f32, reward: f32, expected: f32) -> f32 {
280        (old + (reward - expected)).max(0.0)
281    }
282
283    fn strategy_accumulation(s: &Self::Step) -> (f32, f32) {
284        (1.0, s.quadratic)
285    }
286
287    fn regret_weight_step(_: &Self::Step, old_w: f32) -> f32 {
288        old_w // unused; total is T
289    }
290    fn regret_weight_total(_: &Self::Params, t: usize, _accum_w: f32) -> f32 {
291        t as f32
292    }
293}
294
295// ── PDCFR+ ────────────────────────────────────────────────────────────────────
296
297/// Predictive Discounted CFR+. Stores regret like DCFR+ (discounted, floored)
298/// but forms the strategy like PCFR+ (regret plus prediction) — additionally
299/// discounting the regret term by `d(t, α)` inside the prediction. The widest
300/// shape: predictive (3 lanes) *and* discounted.
301pub struct PdcfrPlus;
302
303/// Shared per-iteration factors for [`PdcfrPlus`].
304pub struct PdcfrPlusStep {
305    /// `d(t-1, α)` — discount on the previous accumulator and the pre-update
306    /// strategy's regret term.
307    previous: f32,
308    /// `d(t, α)` — discount on the post-update strategy's regret term.
309    current: f32,
310    strategy: f32,
311}
312
313impl PdcfrPlus {
314    /// Grid-searched defaults from the source paper: `α = 2.3`, `γ = 5`.
315    pub const RECOMMENDED: PlusDiscount = PlusDiscount {
316        alpha: 2.3,
317        gamma: 5.0,
318    };
319}
320
321impl UpdateRule for PdcfrPlus {
322    type Params = PlusDiscount;
323    type Step = PdcfrPlusStep;
324    const LANES: usize = 3;
325
326    fn step(p: &Self::Params, t: usize) -> Self::Step {
327        PdcfrPlusStep {
328            previous: if t > 1 {
329                DiscountParams::discount_factor(t - 1, p.alpha)
330            } else {
331                0.0
332            },
333            current: DiscountParams::discount_factor(t, p.alpha),
334            strategy: plus_strategy_discount(t, p.gamma),
335        }
336    }
337
338    fn strategy_from_lanes(
339        _: &Self::Params,
340        regret: &[f32],
341        last: &[f32],
342        d: f32,
343        out: &mut [f32],
344    ) {
345        predicted_strategy(regret, last, d, out);
346    }
347
348    fn pre_discount(s: &Self::Step) -> f32 {
349        s.previous
350    }
351    fn post_discount(s: &Self::Step) -> f32 {
352        s.current
353    }
354
355    fn accumulate_regret(s: &Self::Step, old: f32, reward: f32, expected: f32) -> f32 {
356        // Parenthesized inst, mirroring pdcfr_plus.rs.
357        (old * s.previous + (reward - expected)).max(0.0)
358    }
359
360    fn strategy_accumulation(s: &Self::Step) -> (f32, f32) {
361        (s.strategy, 1.0)
362    }
363
364    fn regret_weight_step(s: &Self::Step, old_w: f32) -> f32 {
365        old_w * s.previous + 1.0
366    }
367    fn regret_weight_total(_: &Self::Params, _t: usize, accum_w: f32) -> f32 {
368        accum_w
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::discount::DiscountParams;
376    use crate::update_rule::UpdateRule;
377
378    // ── DCFR (signed, non-predictive) ───────────────────────────────────────
379
380    #[test]
381    fn dcfr_mirrors_scalar() {
382        let p = DiscountParams::RECOMMENDED;
383        let s = Dcfr::step(&p, 3);
384        let pos = DiscountParams::discount_factor(3, p.alpha);
385        let neg = DiscountParams::discount_factor(3, p.beta);
386        let strat = (3.0f32 / 4.0).powf(p.gamma);
387
388        // Sign of the OLD regret picks the discount; instantaneous regret is
389        // added parenthesized, exactly as dcfr.rs writes it.
390        assert_eq!(
391            Dcfr::accumulate_regret(&s, 2.0, 5.0, 1.0),
392            2.0 * pos + (5.0 - 1.0)
393        );
394        assert_eq!(
395            Dcfr::accumulate_regret(&s, -2.0, 5.0, 1.0),
396            -2.0 * neg + (5.0 - 1.0)
397        );
398        assert_eq!(Dcfr::strategy_accumulation(&s), (strat, 1.0));
399        assert_eq!(Dcfr::regret_weight_step(&s, 4.0), 4.0 * pos + 1.0);
400        assert_eq!(Dcfr::regret_weight_total(&p, 7, 9.5), 9.5);
401        assert_eq!(Dcfr::pre_discount(&s), 0.0);
402        assert_eq!(Dcfr::LANES, 2);
403    }
404
405    // ── DCFR+ (floored, non-predictive) ─────────────────────────────────────
406
407    #[test]
408    fn dcfr_plus_mirrors_scalar() {
409        let p = DcfrPlus::RECOMMENDED;
410        let s = DcfrPlus::step(&p, 3);
411        let prev = DiscountParams::discount_factor(2, p.alpha);
412        let strat = (2.0f32 / 3.0).powf(p.gamma);
413
414        // Left-associative `old*prev + rw - exp`, floored — exactly dcfr_plus.rs.
415        assert_eq!(
416            DcfrPlus::accumulate_regret(&s, 2.0, 5.0, 1.0),
417            (2.0 * prev + 5.0 - 1.0).max(0.0)
418        );
419        assert_eq!(DcfrPlus::accumulate_regret(&s, -10.0, 0.0, 1.0), 0.0); // floored
420        assert_eq!(DcfrPlus::strategy_accumulation(&s), (strat, 1.0));
421        assert_eq!(DcfrPlus::regret_weight_step(&s, 4.0), 4.0 * prev + 1.0);
422        assert_eq!(DcfrPlus::pre_discount(&s), 0.0); // non-predictive
423        assert_eq!(DcfrPlus::LANES, 2);
424    }
425
426    #[test]
427    fn dcfr_plus_first_iteration_has_no_history() {
428        let s = DcfrPlus::step(&DcfrPlus::RECOMMENDED, 1);
429        // t == 1: nothing to discount yet, so old regret is dropped entirely.
430        assert_eq!(
431            DcfrPlus::accumulate_regret(&s, 9.0, 2.0, 0.5),
432            (2.0f32 - 0.5).max(0.0)
433        );
434        assert_eq!(DcfrPlus::strategy_accumulation(&s).0, 0.0);
435    }
436
437    // ── Linear CFR (weighted, non-predictive) ───────────────────────────────
438
439    #[test]
440    fn linear_cfr_mirrors_scalar() {
441        let s = LinearCfr::step(&(), 4);
442        // R += t * (rw - exp); X += t * x; weight total = t(t+1)/2.
443        assert_eq!(
444            LinearCfr::accumulate_regret(&s, 3.0, 5.0, 1.0),
445            3.0 + 4.0 * (5.0 - 1.0)
446        );
447        assert_eq!(LinearCfr::strategy_accumulation(&s), (1.0, 4.0));
448        assert_eq!(LinearCfr::regret_weight_total(&(), 4, 0.0), 4.0 * 5.0 / 2.0);
449        assert_eq!(LinearCfr::pre_discount(&s), 0.0);
450        assert_eq!(LinearCfr::LANES, 2);
451    }
452
453    // ── PCFR+ (predictive, no discount) ─────────────────────────────────────
454
455    #[test]
456    fn pcfr_plus_mirrors_scalar() {
457        let s = PcfrPlus::step(&(), 3);
458        // Floored CFR+ regret; quadratic averaging weight t².
459        assert_eq!(
460            PcfrPlus::accumulate_regret(&s, 1.0, 4.0, 1.0),
461            (1.0f32 + (4.0 - 1.0)).max(0.0)
462        );
463        assert_eq!(PcfrPlus::accumulate_regret(&s, 0.0, 0.0, 5.0), 0.0); // floored
464        assert_eq!(PcfrPlus::strategy_accumulation(&s), (1.0, 9.0)); // t² = 9
465        assert_eq!(PcfrPlus::regret_weight_total(&(), 3, 0.0), 3.0); // T
466        // Predictive but undiscounted: regret enters the strategy at weight 1.
467        assert_eq!(PcfrPlus::pre_discount(&s), 1.0);
468        assert_eq!(PcfrPlus::post_discount(&s), 1.0);
469        assert_eq!(PcfrPlus::LANES, 3);
470
471        // strategy = normalize([regret + last_inst]^+)
472        let mut out = [0.0f32; 2];
473        PcfrPlus::strategy_from_lanes(&(), &[1.0, 0.0], &[0.0, 1.0], 1.0, &mut out);
474        assert!((out[0] - 0.5).abs() < 1e-6 && (out[1] - 0.5).abs() < 1e-6);
475    }
476
477    // ── PDCFR+ (predictive, discounted; 3 lanes) ────────────────────────────
478
479    #[test]
480    fn pdcfr_plus_mirrors_scalar() {
481        let p = PdcfrPlus::RECOMMENDED; // alpha 2.3, gamma 5
482        let s = PdcfrPlus::step(&p, 3);
483        let prev = DiscountParams::discount_factor(2, p.alpha);
484        let curr = DiscountParams::discount_factor(3, p.alpha);
485        let strat = (2.0f32 / 3.0).powf(p.gamma);
486
487        // Stored regret: DCFR+-style with parenthesized inst, floored.
488        assert_eq!(
489            PdcfrPlus::accumulate_regret(&s, 2.0, 5.0, 1.0),
490            (2.0 * prev + (5.0 - 1.0)).max(0.0)
491        );
492        assert_eq!(PdcfrPlus::strategy_accumulation(&s), (strat, 1.0));
493        assert_eq!(PdcfrPlus::regret_weight_step(&s, 4.0), 4.0 * prev + 1.0);
494        // Pre-update strategy uses last iteration's discount d(t-1); post uses d(t).
495        assert_eq!(PdcfrPlus::pre_discount(&s), prev);
496        assert_eq!(PdcfrPlus::post_discount(&s), curr);
497        assert_eq!(PdcfrPlus::LANES, 3);
498
499        // strategy = normalize([regret*disc + last_inst]^+)
500        let mut out = [0.0f32; 2];
501        PdcfrPlus::strategy_from_lanes(&p, &[1.0, 1.0], &[0.0, 0.0], curr, &mut out);
502        assert!((out[0] - 0.5).abs() < 1e-6 && (out[1] - 0.5).abs() < 1e-6);
503    }
504}