Skip to main content

contextgraph_types/
query.rs

1//! `context/query` request/response shapes
2//! (`SPEC.md` §5). Budget-aware
3//! by contract: every query carries `max_tokens`; a conforming provider
4//! never returns more than the budget and never lies about cost.
5
6use serde::{Deserialize, Serialize};
7
8use crate::frame::{ContextFrame, FrameKind, Representation};
9
10/// A request to a CGP provider for context frames relevant to a goal.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ContextQuery {
13    /// The task/turn goal driving retrieval.
14    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    /// Anchor URIs (open files, mentioned symbols) used for graph-proximity
22    /// scoring.
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub anchors: Vec<String>,
25    pub max_frames: u32,
26    pub max_tokens: u32,
27    /// Pin retrieval to a point in time for bi-temporal facts.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub as_of: Option<String>,
30    /// Ordered [frame representation](Representation) preference. The provider
31    /// returns the first supported representation it can satisfy. Empty on the
32    /// wire ⇒ the default `[full]`, so pre-representation hosts are unchanged.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub representation_preferences: Vec<Representation>,
35}
36
37impl ContextQuery {
38    /// The effective ordered representation preference, defaulting to `[full]`
39    /// when the host stated none (the legacy behavior).
40    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    /// The representation a provider should return: the first
49    /// [preferred](Self::preferred_representations) one it supports. `None` ⇒
50    /// none of the requested representations is supported and the provider must
51    /// answer `unsupported_representation`.
52    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/// The response to a `context/query` call.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ContextQueryResult {
62    pub frames: Vec<ContextFrame>,
63    /// True if the provider had more candidates than fit the budget.
64    pub truncated: bool,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub dropped_estimate: Option<u32>,
67}
68
69impl ContextQueryResult {
70    /// Sum of `token_cost` across returned frames — must never exceed the
71    /// query's `max_tokens` for a conforming provider (checked in
72    /// `contextgraph-conformance`, phase 3; this is the cheap client-side sanity
73    /// check any host can run today).
74    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    /// Whether the provider honored the query's `max_frames` cap
83    /// (`SPEC.md` §B4).
84    ///
85    /// `max_frames` was part of the query contract from the beginning and was
86    /// audited by nothing: a provider returning ten thousand one-token frames
87    /// against `max_frames: 8` passed every check. Frame count is a real cost
88    /// — each frame carries a title, a citation label, and rendering chrome the
89    /// token budget does not capture.
90    pub fn respects_frame_limit(&self, max_frames: u32) -> bool {
91        self.frames.len() as u64 <= max_frames as u64
92    }
93
94    /// Frames whose declared `token_cost` does not match the canonical count
95    /// for their content (`SPEC.md` §B3).
96    ///
97    /// Returns ids so a host's audit report can name the offending frames
98    /// rather than only the provider.
99    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    /// The sum of the *canonical* costs of the returned frames — what the
108    /// provider's frames actually cost, as opposed to what it claimed.
109    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        // A host that states nothing gets the legacy `[full]` behavior, and the
147        // field is omitted from the wire.
148        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        // With an explicit preference, the provider returns the first it can
174        // satisfy; if none is supported, it must answer unsupported.
175        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    /// A frame whose declared cost is the canonical cost of its content.
213    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        // The exact hole from issue #10: ten thousand one-token frames against
223        // `max_frames: 8` used to pass everything, because only the token
224        // budget was audited.
225        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; // claims 1, actually costs 1_000
252        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        // The declared sum sails under a budget the real content blows past.
260        assert_eq!(result.total_token_cost(), 2);
261        assert_eq!(result.canonical_token_cost(), 1_001);
262        assert!(result.respects_budget(100));
263    }
264}