1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::discovery::DiscoveryItemId;
5use crate::events::{ThreadId, TurnId};
6
7pub type RetrievalRouteId = String;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
10#[serde(rename_all = "snake_case")]
11pub enum RetrievalMode {
12 ExactText,
13 FileName,
14 SemanticCode,
15 Artifact,
16 History,
17 Discovery,
18 Promotion,
19 Web,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum RetrievalIntent {
25 FindDefinition,
26 TraceUsage,
27 DebugFailure,
28 InspectTool,
29 RecoverHistory,
30 BroadConcept,
31 FileLookup,
32 ArtifactInspection,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum RetrievalOutcomeKind {
38 Useful,
39 Irrelevant,
40 StaleIndex,
41 MissingIndex,
42 AuthRequired,
43 MissingPromotion,
44 WrongToolFamily,
45 WrongMcpServer,
46 UnknownTool,
47 Failed,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52pub enum RetrievalConfidence {
53 Low,
54 Medium,
55 High,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase")]
60pub struct RetrievalRecommendation {
61 pub mode: RetrievalMode,
62 pub tool: String,
63 pub query: String,
64 pub reason: String,
65 pub confidence: RetrievalConfidence,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub item_id: Option<DiscoveryItemId>,
68}
69
70impl RetrievalRecommendation {
71 pub fn exact_text(
72 tool: impl Into<String>,
73 query: impl Into<String>,
74 reason: impl Into<String>,
75 ) -> Self {
76 Self {
77 mode: RetrievalMode::ExactText,
78 tool: tool.into(),
79 query: query.into(),
80 reason: reason.into(),
81 confidence: RetrievalConfidence::High,
82 item_id: None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "camelCase")]
89pub struct RetrievalAvoidance {
90 pub mode: RetrievalMode,
91 pub reason: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct RetrievalRoutePlan {
97 pub route_id: RetrievalRouteId,
98 pub thread_id: ThreadId,
99 pub turn_id: TurnId,
100 pub intent: RetrievalIntent,
101 #[serde(default)]
102 pub recommended: Vec<RetrievalRecommendation>,
103 #[serde(default)]
104 pub avoid: Vec<RetrievalAvoidance>,
105 #[serde(with = "time::serde::rfc3339")]
106 pub timestamp: OffsetDateTime,
107}
108
109impl RetrievalRoutePlan {
110 pub fn recommended_modes(&self) -> Vec<RetrievalMode> {
111 self.recommended
112 .iter()
113 .map(|recommendation| recommendation.mode.clone())
114 .collect()
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "camelCase")]
120pub struct RetrievalMeasuredOutcome {
121 pub route_id: RetrievalRouteId,
122 pub mode: RetrievalMode,
123 pub tool: String,
124 pub outcome: RetrievalOutcomeKind,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub first_useful_path: Option<RetrievalMode>,
127 #[serde(default)]
128 pub discovery_before_tool_use: bool,
129 #[serde(default)]
130 pub promotion_before_tool_use: bool,
131 #[serde(default)]
132 pub wrong_tool_family_attempts: u64,
133 #[serde(default)]
134 pub result_count: u64,
135 #[serde(default)]
136 pub latency_ms: u64,
137 #[serde(default)]
138 pub bytes_returned: u64,
139 #[serde(default)]
140 pub estimated_tokens_returned: u32,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase")]
145pub struct RetrievalRoutePlanned {
146 pub plan: RetrievalRoutePlan,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
150#[serde(rename_all = "camelCase")]
151pub struct RetrievalRouteAccepted {
152 pub route_id: RetrievalRouteId,
153 pub thread_id: ThreadId,
154 pub turn_id: TurnId,
155 pub mode: RetrievalMode,
156 pub tool: String,
157 pub query: String,
158 #[serde(with = "time::serde::rfc3339")]
159 pub timestamp: OffsetDateTime,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase")]
164pub struct RetrievalRouteIgnored {
165 pub route_id: RetrievalRouteId,
166 pub thread_id: ThreadId,
167 pub turn_id: TurnId,
168 pub chosen_tool: String,
169 pub recommended_modes: Vec<RetrievalMode>,
170 pub reason: String,
171 #[serde(with = "time::serde::rfc3339")]
172 pub timestamp: OffsetDateTime,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "camelCase")]
177pub struct RetrievalRouteFailed {
178 pub route_id: RetrievalRouteId,
179 pub thread_id: ThreadId,
180 pub turn_id: TurnId,
181 pub mode: RetrievalMode,
182 pub tool: String,
183 pub reason: String,
184 #[serde(with = "time::serde::rfc3339")]
185 pub timestamp: OffsetDateTime,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "camelCase")]
190pub struct RetrievalResultUsed {
191 pub outcome: RetrievalMeasuredOutcome,
192 pub thread_id: ThreadId,
193 pub turn_id: TurnId,
194 #[serde(with = "time::serde::rfc3339")]
195 pub timestamp: OffsetDateTime,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase")]
200pub struct RetrievalDiscoveryItemPromoted {
201 pub route_id: RetrievalRouteId,
202 pub thread_id: ThreadId,
203 pub turn_id: TurnId,
204 pub item_id: DiscoveryItemId,
205 #[serde(with = "time::serde::rfc3339")]
206 pub timestamp: OffsetDateTime,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(rename_all = "camelCase")]
211pub struct RetrievalPromotionSkipped {
212 pub route_id: RetrievalRouteId,
213 pub thread_id: ThreadId,
214 pub turn_id: TurnId,
215 pub item_id: DiscoveryItemId,
216 pub reason: String,
217 #[serde(with = "time::serde::rfc3339")]
218 pub timestamp: OffsetDateTime,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn timestamp() -> OffsetDateTime {
226 OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap()
227 }
228
229 #[test]
230 fn retrieval_route_plan_serializes_recommendations_and_avoidance() {
231 let plan = RetrievalRoutePlan {
232 route_id: "route-1".to_string(),
233 thread_id: "thread-1".to_string(),
234 turn_id: "turn-1".to_string(),
235 intent: RetrievalIntent::FindDefinition,
236 recommended: vec![
237 RetrievalRecommendation::exact_text("grep", "ToolExecutionContext", "exact symbol"),
238 RetrievalRecommendation {
239 mode: RetrievalMode::SemanticCode,
240 tool: "code_index.search".to_string(),
241 query: "tool execution policy gate".to_string(),
242 reason: "conceptual fallback".to_string(),
243 confidence: RetrievalConfidence::Medium,
244 item_id: None,
245 },
246 ],
247 avoid: vec![RetrievalAvoidance {
248 mode: RetrievalMode::Web,
249 reason: "local codebase question".to_string(),
250 }],
251 timestamp: timestamp(),
252 };
253
254 let value = serde_json::to_value(&plan).unwrap();
255 assert_eq!(value["intent"], "find_definition");
256 assert_eq!(value["recommended"][0]["mode"], "exact_text");
257 assert_eq!(value["recommended"][1]["tool"], "code_index.search");
258 assert_eq!(value["avoid"][0]["mode"], "web");
259
260 let round_trip: RetrievalRoutePlan = serde_json::from_value(value).unwrap();
261 assert_eq!(
262 round_trip.recommended_modes(),
263 vec![RetrievalMode::ExactText, RetrievalMode::SemanticCode]
264 );
265 }
266
267 #[test]
268 fn retrieval_outcome_tracks_discovery_promotion_and_noise() {
269 let outcome = RetrievalMeasuredOutcome {
270 route_id: "route-2".to_string(),
271 mode: RetrievalMode::Discovery,
272 tool: "discovery.search".to_string(),
273 outcome: RetrievalOutcomeKind::Useful,
274 first_useful_path: Some(RetrievalMode::Discovery),
275 discovery_before_tool_use: true,
276 promotion_before_tool_use: true,
277 wrong_tool_family_attempts: 0,
278 result_count: 1,
279 latency_ms: 12,
280 bytes_returned: 512,
281 estimated_tokens_returned: 128,
282 };
283
284 let value = serde_json::to_value(&outcome).unwrap();
285 assert_eq!(value["firstUsefulPath"], "discovery");
286 assert_eq!(value["promotionBeforeToolUse"], true);
287 assert_eq!(value["wrongToolFamilyAttempts"], 0);
288 }
289
290 #[test]
291 fn retrieval_events_use_camel_case_fields() {
292 let event = RetrievalPromotionSkipped {
293 route_id: "route-3".to_string(),
294 thread_id: "thread-1".to_string(),
295 turn_id: "turn-1".to_string(),
296 item_id: "mcp:github/issues.search".to_string(),
297 reason: "auth required".to_string(),
298 timestamp: timestamp(),
299 };
300
301 let value = serde_json::to_value(&event).unwrap();
302 assert_eq!(value["routeId"], "route-3");
303 assert_eq!(value["itemId"], "mcp:github/issues.search");
304 }
305}