Skip to main content

agentic_evolve_core/query/
intent.rs

1//! Extraction intent — controls how much data a query returns to conserve tokens.
2
3use serde::{Deserialize, Serialize};
4
5/// The level of detail a query should return.
6///
7/// Defaults to `IdsOnly` to be token-conservative.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
9pub enum ExtractionIntent {
10    /// Just check existence — returns a boolean.
11    Exists,
12    /// Return only identifiers.
13    #[default]
14    IdsOnly,
15    /// Return a compact summary (name, id, key metadata).
16    Summary,
17    /// Return specific fields.
18    Fields,
19    /// Return the full object.
20    Full,
21}
22
23impl ExtractionIntent {
24    /// Estimated token cost multiplier relative to `Full`.
25    ///
26    /// These are approximate multipliers:
27    /// - `Exists`: ~1 token
28    /// - `IdsOnly`: ~5 tokens per item
29    /// - `Summary`: ~20 tokens per item
30    /// - `Fields`: ~50 tokens per item
31    /// - `Full`: ~100 tokens per item
32    pub fn estimated_tokens(&self) -> u64 {
33        match self {
34            Self::Exists => 1,
35            Self::IdsOnly => 5,
36            Self::Summary => 20,
37            Self::Fields => 50,
38            Self::Full => 100,
39        }
40    }
41
42    /// Whether this intent requests the full object.
43    pub fn is_full(&self) -> bool {
44        matches!(self, Self::Full)
45    }
46
47    /// Whether this is the minimal intent.
48    pub fn is_minimal(&self) -> bool {
49        matches!(self, Self::Exists | Self::IdsOnly)
50    }
51}
52
53/// Trait for types that can be scoped to different extraction intents.
54pub trait Scopeable {
55    /// The identifier type for this object.
56    type Id: Clone + Serialize + for<'de> Deserialize<'de>;
57
58    /// Return just the identifier.
59    fn id(&self) -> Self::Id;
60
61    /// Return a summary representation (as JSON-friendly string).
62    fn summary(&self) -> String;
63
64    /// Estimated token count for the full representation.
65    fn full_token_estimate(&self) -> u64;
66}
67
68/// Apply an extraction intent to a collection of scopeable items.
69pub fn apply_intent<T: Scopeable + Clone + Serialize>(
70    items: &[T],
71    intent: ExtractionIntent,
72) -> ScopedResult<T> {
73    match intent {
74        ExtractionIntent::Exists => ScopedResult::Exists(!items.is_empty()),
75        ExtractionIntent::IdsOnly => ScopedResult::Ids(items.iter().map(|i| i.id()).collect()),
76        ExtractionIntent::Summary => {
77            ScopedResult::Summaries(items.iter().map(|i| i.summary()).collect())
78        }
79        ExtractionIntent::Fields | ExtractionIntent::Full => ScopedResult::Full(items.to_vec()),
80    }
81}
82
83/// The result of a scoped query, carrying only the requested level of detail.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(tag = "type", content = "data")]
86pub enum ScopedResult<T: Scopeable> {
87    /// Just an existence check.
88    Exists(bool),
89    /// Only identifiers.
90    Ids(Vec<T::Id>),
91    /// Compact summaries.
92    Summaries(Vec<String>),
93    /// Full objects.
94    Full(Vec<T>),
95}
96
97impl<T: Scopeable> ScopedResult<T> {
98    /// Estimated token cost of this result.
99    pub fn estimated_tokens(&self) -> u64 {
100        match self {
101            Self::Exists(_) => 1,
102            Self::Ids(ids) => ids.len() as u64 * 5,
103            Self::Summaries(s) => s.len() as u64 * 20,
104            Self::Full(items) => items.iter().map(|i| i.full_token_estimate()).sum(),
105        }
106    }
107
108    /// Number of items in the result.
109    pub fn count(&self) -> usize {
110        match self {
111            Self::Exists(_) => 1,
112            Self::Ids(ids) => ids.len(),
113            Self::Summaries(s) => s.len(),
114            Self::Full(items) => items.len(),
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[derive(Debug, Clone, Serialize, Deserialize)]
124    struct TestItem {
125        id: String,
126        name: String,
127        data: String,
128    }
129
130    impl Scopeable for TestItem {
131        type Id = String;
132
133        fn id(&self) -> String {
134            self.id.clone()
135        }
136
137        fn summary(&self) -> String {
138            format!("{}: {}", self.id, self.name)
139        }
140
141        fn full_token_estimate(&self) -> u64 {
142            100
143        }
144    }
145
146    fn sample_items() -> Vec<TestItem> {
147        vec![
148            TestItem {
149                id: "1".into(),
150                name: "alpha".into(),
151                data: "x".repeat(100),
152            },
153            TestItem {
154                id: "2".into(),
155                name: "beta".into(),
156                data: "y".repeat(100),
157            },
158        ]
159    }
160
161    #[test]
162    fn default_intent_is_ids_only() {
163        assert_eq!(ExtractionIntent::default(), ExtractionIntent::IdsOnly);
164    }
165
166    #[test]
167    fn estimated_tokens_ordering() {
168        assert!(
169            ExtractionIntent::Exists.estimated_tokens()
170                < ExtractionIntent::IdsOnly.estimated_tokens()
171        );
172        assert!(
173            ExtractionIntent::IdsOnly.estimated_tokens()
174                < ExtractionIntent::Summary.estimated_tokens()
175        );
176        assert!(
177            ExtractionIntent::Summary.estimated_tokens()
178                < ExtractionIntent::Fields.estimated_tokens()
179        );
180        assert!(
181            ExtractionIntent::Fields.estimated_tokens() < ExtractionIntent::Full.estimated_tokens()
182        );
183    }
184
185    #[test]
186    fn is_full_only_for_full() {
187        assert!(!ExtractionIntent::IdsOnly.is_full());
188        assert!(ExtractionIntent::Full.is_full());
189    }
190
191    #[test]
192    fn is_minimal_for_exists_and_ids() {
193        assert!(ExtractionIntent::Exists.is_minimal());
194        assert!(ExtractionIntent::IdsOnly.is_minimal());
195        assert!(!ExtractionIntent::Summary.is_minimal());
196        assert!(!ExtractionIntent::Full.is_minimal());
197    }
198
199    #[test]
200    fn apply_intent_exists() {
201        let items = sample_items();
202        let result = apply_intent(&items, ExtractionIntent::Exists);
203        match result {
204            ScopedResult::Exists(b) => assert!(b),
205            _ => panic!("Expected Exists variant"),
206        }
207    }
208
209    #[test]
210    fn apply_intent_ids_only() {
211        let items = sample_items();
212        let result = apply_intent(&items, ExtractionIntent::IdsOnly);
213        match result {
214            ScopedResult::Ids(ids) => {
215                assert_eq!(ids.len(), 2);
216                assert_eq!(ids[0], "1");
217            }
218            _ => panic!("Expected Ids variant"),
219        }
220    }
221
222    #[test]
223    fn apply_intent_full() {
224        let items = sample_items();
225        let result = apply_intent(&items, ExtractionIntent::Full);
226        assert_eq!(result.estimated_tokens(), 200);
227    }
228
229    #[test]
230    fn scoped_result_count() {
231        let items = sample_items();
232        let result = apply_intent(&items, ExtractionIntent::IdsOnly);
233        assert_eq!(result.count(), 2);
234    }
235
236    #[test]
237    fn ids_cheaper_than_full() {
238        let items = sample_items();
239        let ids_result = apply_intent(&items, ExtractionIntent::IdsOnly);
240        let full_result = apply_intent(&items, ExtractionIntent::Full);
241        assert!(ids_result.estimated_tokens() < full_result.estimated_tokens());
242    }
243}