lean_ctx/core/context_kernel/
mcp_schema_opt.rs1use super::coverage_class::CoverageClass;
4
5#[derive(Debug, Clone)]
7pub struct SchemaEntry {
8 pub name: String,
10 pub description: String,
12 pub param_count: usize,
14 pub estimated_tokens: usize,
16 pub essential: bool,
18}
19
20#[derive(Debug, Clone)]
22pub struct SchemaBudget {
23 pub max_total_tokens: usize,
25 pub max_per_tool_tokens: usize,
27}
28
29impl Default for SchemaBudget {
30 fn default() -> Self {
31 Self {
32 max_total_tokens: 8_000,
33 max_per_tool_tokens: 500,
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
40pub struct OptimizedSchemas {
41 pub entries: Vec<SchemaEntry>,
43 pub tokens_before: usize,
45 pub tokens_after: usize,
47 pub compressed_count: usize,
49 pub dropped_count: usize,
51}
52
53impl OptimizedSchemas {
54 pub fn savings_pct(&self) -> f64 {
56 if self.tokens_before == 0 {
57 return 0.0;
58 }
59
60 self.tokens_before.saturating_sub(self.tokens_after) as f64 / self.tokens_before as f64
61 * 100.0
62 }
63}
64
65pub fn estimate_tokens(text: &str) -> usize {
67 text.chars().count().div_ceil(4)
68}
69
70pub fn compress_description(desc: &str, max_tokens: usize) -> String {
72 if estimate_tokens(desc) <= max_tokens {
73 return desc.to_owned();
74 }
75
76 let normalized = desc.split_whitespace().collect::<Vec<_>>().join(" ");
77 if estimate_tokens(&normalized) <= max_tokens {
78 return normalized;
79 }
80
81 let max_chars = max_tokens.saturating_mul(4);
82 if max_chars == 0 {
83 return String::new();
84 }
85
86 let mut compressed = normalized
87 .chars()
88 .take(max_chars.saturating_sub(1))
89 .collect::<String>();
90 compressed.push('…');
91 compressed
92}
93
94pub fn optimize_schemas(entries: &[SchemaEntry], budget: &SchemaBudget) -> OptimizedSchemas {
96 let tokens_before = entries.iter().fold(0usize, |total, entry| {
97 total.saturating_add(entry.estimated_tokens)
98 });
99 let mut optimized = entries.to_vec();
100 optimized.sort_unstable_by_key(|entry| (!entry.essential, entry.estimated_tokens));
101
102 let mut compressed_count = 0;
103 for entry in &mut optimized {
104 let description_tokens = estimate_tokens(&entry.description);
105 if description_tokens > budget.max_per_tool_tokens {
106 let compressed = compress_description(&entry.description, budget.max_per_tool_tokens);
107 let schema_overhead = entry.estimated_tokens.saturating_sub(description_tokens);
108 entry.estimated_tokens = schema_overhead.saturating_add(estimate_tokens(&compressed));
109 entry.description = compressed;
110 compressed_count += 1;
111 }
112 }
113
114 let mut tokens_after = optimized.iter().fold(0usize, |total, entry| {
115 total.saturating_add(entry.estimated_tokens)
116 });
117 let mut dropped_count = 0;
118 while tokens_after > budget.max_total_tokens {
119 let Some(index) = optimized.iter().rposition(|entry| !entry.essential) else {
120 break;
121 };
122 let dropped = optimized.remove(index);
123 tokens_after = tokens_after.saturating_sub(dropped.estimated_tokens);
124 dropped_count += 1;
125 }
126
127 OptimizedSchemas {
128 entries: optimized,
129 tokens_before,
130 tokens_after,
131 compressed_count,
132 dropped_count,
133 }
134}
135
136pub fn budget_for_coverage(coverage: CoverageClass) -> SchemaBudget {
138 match coverage {
139 CoverageClass::FullInline => SchemaBudget {
140 max_total_tokens: 12_000,
141 max_per_tool_tokens: 800,
142 },
143 CoverageClass::ContextControlled => SchemaBudget::default(),
144 CoverageClass::ObserveOnly => SchemaBudget {
145 max_total_tokens: 4_000,
146 max_per_tool_tokens: 300,
147 },
148 CoverageClass::Unmanaged => SchemaBudget {
149 max_total_tokens: usize::MAX,
150 max_per_tool_tokens: usize::MAX,
151 },
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::{
158 CoverageClass, OptimizedSchemas, SchemaBudget, SchemaEntry, budget_for_coverage,
159 compress_description, optimize_schemas,
160 };
161
162 fn entry(name: &str, tokens: usize, essential: bool) -> SchemaEntry {
163 SchemaEntry {
164 name: name.to_owned(),
165 description: "short description".to_owned(),
166 param_count: 1,
167 estimated_tokens: tokens,
168 essential,
169 }
170 }
171
172 #[test]
173 fn compress_under_budget_unchanged() {
174 let description = "short\n description";
175 assert_eq!(compress_description(description, 20), description);
176 }
177
178 #[test]
179 fn compress_over_budget_truncates() {
180 let compressed = compress_description(&"a".repeat(100), 10);
181 assert!(compressed.ends_with('…'));
182 assert!(compressed.chars().count() <= 40);
183 }
184
185 #[test]
186 fn optimize_drops_non_essential() {
187 let entries = vec![entry("small", 20, false), entry("large", 80, false)];
188 let result = optimize_schemas(
189 &entries,
190 &SchemaBudget {
191 max_total_tokens: 30,
192 max_per_tool_tokens: 500,
193 },
194 );
195 assert_eq!(result.entries.len(), 1);
196 assert_eq!(result.entries[0].name, "small");
197 assert_eq!(result.dropped_count, 1);
198 }
199
200 #[test]
201 fn optimize_keeps_essential() {
202 let entries = vec![entry("optional", 20, false), entry("essential", 80, true)];
203 let result = optimize_schemas(
204 &entries,
205 &SchemaBudget {
206 max_total_tokens: 10,
207 max_per_tool_tokens: 500,
208 },
209 );
210 assert_eq!(result.entries.len(), 1);
211 assert_eq!(result.entries[0].name, "essential");
212 }
213
214 #[test]
215 fn savings_pct_correct() {
216 let result = OptimizedSchemas {
217 entries: Vec::new(),
218 tokens_before: 1_000,
219 tokens_after: 600,
220 compressed_count: 0,
221 dropped_count: 0,
222 };
223 assert!((result.savings_pct() - 40.0).abs() < f64::EPSILON);
224 }
225
226 #[test]
227 fn budget_for_coverage_varies() {
228 let full = budget_for_coverage(CoverageClass::FullInline);
229 let controlled = budget_for_coverage(CoverageClass::ContextControlled);
230 let observed = budget_for_coverage(CoverageClass::ObserveOnly);
231 assert!(full.max_total_tokens > controlled.max_total_tokens);
232 assert!(controlled.max_total_tokens > observed.max_total_tokens);
233 assert!(full.max_per_tool_tokens > controlled.max_per_tool_tokens);
234 assert!(controlled.max_per_tool_tokens > observed.max_per_tool_tokens);
235 }
236}