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}