Skip to main content

lean_ctx/core/
free_energy_budget.rs

1//! Free-Energy Budget — optimal context allocation under token constraints.
2//!
3//! The LLM context window is a finite resource. This module allocates budget
4//! across context columns (filesystem, providers, knowledge) to minimize
5//! "free energy" — the gap between what the LLM knows and what it needs.
6//!
7//! Scientific basis: Free Energy Principle (Friston 2010). The system minimizes
8//! surprise (unexpected tokens) by allocating budget to the most informative sources.
9//!
10//! Algorithm:
11//!   1. Each column reports its saliency score and estimated token cost.
12//!   2. Budget is allocated proportionally to saliency / cost (efficiency ratio).
13//!   3. A minimum floor ensures every active column gets at least some budget.
14
15/// A context column's budget request.
16#[derive(Debug, Clone)]
17pub struct ColumnBudgetRequest {
18    pub column_id: String,
19    pub saliency_score: f64,
20    pub estimated_tokens: usize,
21    pub minimum_tokens: usize,
22}
23
24/// The allocated budget for each column.
25#[derive(Debug, Clone)]
26pub struct ColumnBudgetAllocation {
27    pub column_id: String,
28    pub allocated_tokens: usize,
29    pub fraction: f64,
30}
31
32/// Allocate a total token budget across multiple context columns.
33///
34/// Uses efficiency-weighted allocation: columns with high saliency per token
35/// get more budget. Every active column gets at least `floor_fraction` of the
36/// total budget (default 5%).
37pub fn allocate_budget(
38    total_budget: usize,
39    requests: &[ColumnBudgetRequest],
40    floor_fraction: f64,
41) -> Vec<ColumnBudgetAllocation> {
42    if requests.is_empty() || total_budget == 0 {
43        return Vec::new();
44    }
45
46    let floor = (total_budget as f64 * floor_fraction.clamp(0.0, 0.5)) as usize;
47    let total_floor = floor * requests.len();
48    let distributable = total_budget.saturating_sub(total_floor);
49
50    let efficiencies: Vec<f64> = requests
51        .iter()
52        .map(|r| {
53            let cost = r.estimated_tokens.max(1) as f64;
54            r.saliency_score / cost
55        })
56        .collect();
57
58    let total_efficiency: f64 = efficiencies.iter().sum();
59
60    requests
61        .iter()
62        .enumerate()
63        .map(|(i, req)| {
64            let proportional = if total_efficiency > 0.0 {
65                (efficiencies[i] / total_efficiency * distributable as f64) as usize
66            } else {
67                distributable / requests.len()
68            };
69
70            let allocated = (floor + proportional)
71                .max(req.minimum_tokens)
72                .min(total_budget);
73            let fraction = allocated as f64 / total_budget as f64;
74
75            ColumnBudgetAllocation {
76                column_id: req.column_id.clone(),
77                allocated_tokens: allocated,
78                fraction,
79            }
80        })
81        .collect()
82}
83
84/// Compute the "free energy" — how much information gap remains after allocation.
85/// Lower is better. 0.0 means all requested tokens were fully satisfied.
86pub fn free_energy(
87    requests: &[ColumnBudgetRequest],
88    allocations: &[ColumnBudgetAllocation],
89) -> f64 {
90    if requests.is_empty() {
91        return 0.0;
92    }
93
94    let total_requested: f64 = requests.iter().map(|r| r.estimated_tokens as f64).sum();
95    let total_allocated: f64 = allocations.iter().map(|a| a.allocated_tokens as f64).sum();
96
97    if total_requested == 0.0 {
98        return 0.0;
99    }
100
101    ((total_requested - total_allocated) / total_requested).max(0.0)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn request(id: &str, saliency: f64, tokens: usize) -> ColumnBudgetRequest {
109        ColumnBudgetRequest {
110            column_id: id.into(),
111            saliency_score: saliency,
112            estimated_tokens: tokens,
113            minimum_tokens: 0,
114        }
115    }
116
117    #[test]
118    fn allocate_empty_returns_empty() {
119        assert!(allocate_budget(1000, &[], 0.05).is_empty());
120    }
121
122    #[test]
123    fn allocate_single_column_gets_all() {
124        let reqs = vec![request("fs", 1.0, 500)];
125        let allocs = allocate_budget(1000, &reqs, 0.05);
126
127        assert_eq!(allocs.len(), 1);
128        assert!(allocs[0].allocated_tokens >= 950);
129    }
130
131    #[test]
132    fn high_saliency_gets_more_budget() {
133        let reqs = vec![
134            request("important", 0.9, 500),
135            request("unimportant", 0.1, 500),
136        ];
137        let allocs = allocate_budget(1000, &reqs, 0.05);
138
139        assert!(allocs[0].allocated_tokens > allocs[1].allocated_tokens);
140    }
141
142    #[test]
143    fn efficient_column_gets_more_budget() {
144        let reqs = vec![
145            request("efficient", 0.5, 100),   // 0.005 per token
146            request("expensive", 0.5, 10000), // 0.00005 per token
147        ];
148        let allocs = allocate_budget(2000, &reqs, 0.05);
149
150        assert!(allocs[0].allocated_tokens > allocs[1].allocated_tokens);
151    }
152
153    #[test]
154    fn floor_ensures_minimum_allocation() {
155        let reqs = vec![request("dominant", 0.99, 100), request("tiny", 0.01, 100)];
156        let allocs = allocate_budget(1000, &reqs, 0.1);
157
158        assert!(allocs[1].allocated_tokens >= 100);
159    }
160
161    #[test]
162    fn free_energy_zero_when_fully_satisfied() {
163        let reqs = vec![request("a", 1.0, 500)];
164        let allocs = vec![ColumnBudgetAllocation {
165            column_id: "a".into(),
166            allocated_tokens: 500,
167            fraction: 1.0,
168        }];
169        assert!((free_energy(&reqs, &allocs)).abs() < f64::EPSILON);
170    }
171
172    #[test]
173    fn free_energy_positive_when_under_budget() {
174        let reqs = vec![request("a", 1.0, 1000)];
175        let allocs = vec![ColumnBudgetAllocation {
176            column_id: "a".into(),
177            allocated_tokens: 500,
178            fraction: 0.5,
179        }];
180        let fe = free_energy(&reqs, &allocs);
181        assert!(fe > 0.0);
182        assert!((fe - 0.5).abs() < f64::EPSILON);
183    }
184}