Skip to main content

lean_ctx/core/
aggressiveness.rs

1//! Single 0.0–1.0 compression-intensity knob.
2//!
3//! TTC-style UX parity: callers (the `ctx_read` tool, the proxy, the CLI) can
4//! express "how hard should I compress?" as one number instead of picking among
5//! the ten read modes. The number is *mapped* onto the existing density /
6//! entropy / information-bottleneck stages — it never introduces a model, so the
7//! #498 determinism contract (output = pure function of inputs) is preserved.
8//!
9//! Resolution order (see [`effective`]): explicit per-call arg > the
10//! `LEAN_CTX_AGGRESSIVENESS` env var > `[compression] compression_aggressiveness`
11//! in config > `None`. `None` means "use each mode's current default", i.e. the
12//! behaviour shipped before this knob existed.
13
14/// Concrete tuning derived from one aggressiveness value. Every field is a pure
15/// function of `a`, so the same `a` always yields the same knobs (determinism).
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct AggressivenessProfile {
18    /// Fraction of tokens to keep (density target for `density:` / IB prose).
19    /// `a=0.0 → 1.00` (keep everything), `a=1.0 → 0.15` (keep ~15%).
20    pub density_target: f64,
21    /// BPE-entropy keep threshold for the `entropy` mode. Lines below this are
22    /// dropped. `a=0.0 → 0.6` (keep almost all), `a=1.0 → 2.0` (drop low-info).
23    pub bpe_entropy: f64,
24    /// Information-bottleneck keep ratio for `task`/IB modes.
25    /// `a=0.0 → 0.60`, `a=1.0 → 0.10`.
26    pub ib_budget_ratio: f64,
27}
28
29impl AggressivenessProfile {
30    /// Maps `a ∈ [0,1]` (clamped) onto the three tuning knobs. The constants are
31    /// a deliberate, monotonic starting point; Epic E (accuracy suite) is meant
32    /// to calibrate them empirically.
33    #[must_use]
34    pub fn from_level(a: f64) -> Self {
35        let a = a.clamp(0.0, 1.0);
36        Self {
37            density_target: (1.0 - 0.85 * a).clamp(0.10, 1.0),
38            bpe_entropy: 0.6 + 1.4 * a,
39            ib_budget_ratio: (0.6 - 0.5 * a).clamp(0.10, 0.6),
40        }
41    }
42}
43
44/// Resolves the effective aggressiveness from (in priority order) an explicit
45/// per-call value, the `LEAN_CTX_AGGRESSIVENESS` env var, and the config field.
46/// Returns `None` when nothing is set so callers keep their current defaults.
47#[must_use]
48pub fn effective(explicit: Option<f64>) -> Option<f64> {
49    if let Some(a) = explicit {
50        return Some(a.clamp(0.0, 1.0));
51    }
52    if let Ok(v) = std::env::var("LEAN_CTX_AGGRESSIVENESS")
53        && let Ok(a) = v.trim().parse::<f64>()
54    {
55        return Some(a.clamp(0.0, 1.0));
56    }
57    crate::core::config::Config::load()
58        .compression_aggressiveness
59        .map(|a| a.clamp(0.0, 1.0))
60}
61
62/// Stable cache-key fragment for an aggressiveness setting.
63///
64/// Buckets to 1/20 (0.05 steps) so float jitter does not fragment the cache,
65/// while distinct settings still get distinct keys (#498). `None` → empty
66/// string so today's keys are unchanged when the knob is unset.
67#[must_use]
68pub fn cache_fragment(a: Option<f64>) -> String {
69    match a {
70        Some(a) => format!("a{}", (a.clamp(0.0, 1.0) * 20.0).round() as u32),
71        None => String::new(),
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn profile_is_monotonic_in_aggressiveness() {
81        let lo = AggressivenessProfile::from_level(0.0);
82        let mid = AggressivenessProfile::from_level(0.5);
83        let hi = AggressivenessProfile::from_level(1.0);
84
85        // Higher aggressiveness keeps fewer tokens and drops more low-info lines.
86        assert!(lo.density_target > mid.density_target);
87        assert!(mid.density_target > hi.density_target);
88        assert!(lo.bpe_entropy < mid.bpe_entropy);
89        assert!(mid.bpe_entropy < hi.bpe_entropy);
90        assert!(lo.ib_budget_ratio > mid.ib_budget_ratio);
91        assert!(mid.ib_budget_ratio > hi.ib_budget_ratio);
92    }
93
94    #[test]
95    fn profile_clamps_out_of_range() {
96        assert_eq!(
97            AggressivenessProfile::from_level(-1.0),
98            AggressivenessProfile::from_level(0.0)
99        );
100        assert_eq!(
101            AggressivenessProfile::from_level(2.0),
102            AggressivenessProfile::from_level(1.0)
103        );
104    }
105
106    #[test]
107    fn cache_fragment_is_stable_and_bucketed() {
108        // None → empty (today's keys unchanged).
109        assert_eq!(cache_fragment(None), "");
110        // Same value → same fragment (determinism).
111        assert_eq!(cache_fragment(Some(0.7)), cache_fragment(Some(0.7)));
112        // Jitter within a bucket collapses; distinct buckets differ.
113        assert_eq!(cache_fragment(Some(0.70)), cache_fragment(Some(0.701)));
114        assert_ne!(cache_fragment(Some(0.70)), cache_fragment(Some(0.80)));
115    }
116}