1use serde::{Deserialize, Serialize};
7
8use crate::frame::{ContextFrame, FrameKind, Representation};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ContextQuery {
13 pub goal: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub query_text: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub embedding: Option<Vec<f32>>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub kinds: Vec<FrameKind>,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub anchors: Vec<String>,
25 pub max_frames: u32,
26 pub max_tokens: u32,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub as_of: Option<String>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub representation_preferences: Vec<Representation>,
35}
36
37impl ContextQuery {
38 pub fn preferred_representations(&self) -> Vec<Representation> {
41 if self.representation_preferences.is_empty() {
42 vec![Representation::Full]
43 } else {
44 self.representation_preferences.clone()
45 }
46 }
47
48 pub fn select_representation(&self, supported: &[Representation]) -> Option<Representation> {
53 self.preferred_representations()
54 .into_iter()
55 .find(|wanted| supported.contains(wanted))
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ContextQueryResult {
62 pub frames: Vec<ContextFrame>,
63 pub truncated: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub dropped_estimate: Option<u32>,
67}
68
69impl ContextQueryResult {
70 pub fn total_token_cost(&self) -> u64 {
75 self.frames.iter().map(|f| f.token_cost as u64).sum()
76 }
77
78 pub fn respects_budget(&self, max_tokens: u32) -> bool {
79 self.total_token_cost() <= max_tokens as u64
80 }
81
82 pub fn respects_frame_limit(&self, max_frames: u32) -> bool {
91 self.frames.len() as u64 <= max_frames as u64
92 }
93
94 pub fn frames_with_dishonest_cost(&self) -> Vec<&str> {
100 self.frames
101 .iter()
102 .filter(|f| !f.declares_honest_token_cost())
103 .map(|f| f.id.as_str())
104 .collect()
105 }
106
107 pub fn canonical_token_cost(&self) -> u64 {
110 self.frames
111 .iter()
112 .map(|f| f.expected_inline_token_cost() as u64)
113 .sum()
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::frame::ContextFrame;
121
122 fn frame_with_cost(id: &str, cost: u32) -> ContextFrame {
123 ContextFrame::full(id, FrameKind::Snippet, id, String::new(), 0.5, cost)
124 }
125
126 #[test]
127 fn context_query_roundtrips() {
128 let query = ContextQuery {
129 goal: "fix the failing test".into(),
130 query_text: Some("failing test".into()),
131 embedding: None,
132 kinds: vec![FrameKind::Symbol, FrameKind::Doc],
133 anchors: vec!["file:///repo/src/lib.rs".into()],
134 max_frames: 20,
135 max_tokens: 4000,
136 as_of: None,
137 representation_preferences: vec![],
138 };
139 let json = serde_json::to_string(&query).unwrap();
140 let back: ContextQuery = serde_json::from_str(&json).unwrap();
141 assert_eq!(back, query);
142 }
143
144 #[test]
145 fn representation_preferences_default_to_full_and_select_first_supported() {
146 let mut query = ContextQuery {
149 goal: "g".into(),
150 query_text: None,
151 embedding: None,
152 kinds: vec![],
153 anchors: vec![],
154 max_frames: 1,
155 max_tokens: 10,
156 as_of: None,
157 representation_preferences: vec![],
158 };
159 assert_eq!(
160 query.preferred_representations(),
161 vec![Representation::Full]
162 );
163 assert!(
164 !serde_json::to_string(&query)
165 .unwrap()
166 .contains("representation_preferences")
167 );
168 assert_eq!(
169 query.select_representation(&[Representation::Full]),
170 Some(Representation::Full)
171 );
172
173 query.representation_preferences = vec![Representation::Reference, Representation::Full];
176 assert_eq!(
177 query.select_representation(&[Representation::Full]),
178 Some(Representation::Full),
179 );
180 assert_eq!(
181 query.select_representation(&[Representation::Reference, Representation::Full]),
182 Some(Representation::Reference),
183 );
184 assert_eq!(
185 query.select_representation(&[Representation::Compact]),
186 None
187 );
188 }
189
190 #[test]
191 fn respects_budget_true_when_under_or_at_limit() {
192 let result = ContextQueryResult {
193 frames: vec![frame_with_cost("a", 100), frame_with_cost("b", 200)],
194 truncated: false,
195 dropped_estimate: None,
196 };
197 assert_eq!(result.total_token_cost(), 300);
198 assert!(result.respects_budget(300));
199 assert!(result.respects_budget(500));
200 }
201
202 #[test]
203 fn respects_budget_false_when_provider_lies_about_cost() {
204 let result = ContextQueryResult {
205 frames: vec![frame_with_cost("a", 400)],
206 truncated: false,
207 dropped_estimate: None,
208 };
209 assert!(!result.respects_budget(300));
210 }
211
212 fn honest_frame(id: &str, content: &str) -> ContextFrame {
214 let mut frame = frame_with_cost(id, 0);
215 frame.content = Some(content.to_string());
216 frame.token_cost = frame.expected_inline_token_cost();
217 frame
218 }
219
220 #[test]
221 fn frame_limit_catches_the_provider_that_floods_with_cheap_frames() {
222 let flood = ContextQueryResult {
226 frames: (0..50)
227 .map(|i| honest_frame(&format!("f{i}"), "x"))
228 .collect(),
229 truncated: false,
230 dropped_estimate: None,
231 };
232 assert!(flood.respects_budget(10_000), "the token budget is fine");
233 assert!(!flood.respects_frame_limit(8), "but the frame cap is not");
234 assert!(flood.respects_frame_limit(50), "boundary is inclusive");
235 }
236
237 #[test]
238 fn an_honest_result_reports_no_dishonest_frames() {
239 let result = ContextQueryResult {
240 frames: vec![honest_frame("a", "abcd"), honest_frame("b", "abcdefgh")],
241 truncated: false,
242 dropped_estimate: None,
243 };
244 assert!(result.frames_with_dishonest_cost().is_empty());
245 assert_eq!(result.total_token_cost(), result.canonical_token_cost());
246 }
247
248 #[test]
249 fn dishonest_frames_are_named_and_the_true_cost_is_recoverable() {
250 let mut liar = honest_frame("liar", &"x".repeat(4_000));
251 liar.token_cost = 1; let result = ContextQueryResult {
253 frames: vec![honest_frame("honest", "abcd"), liar],
254 truncated: false,
255 dropped_estimate: None,
256 };
257
258 assert_eq!(result.frames_with_dishonest_cost(), vec!["liar"]);
259 assert_eq!(result.total_token_cost(), 2);
261 assert_eq!(result.canonical_token_cost(), 1_001);
262 assert!(result.respects_budget(100));
263 }
264}