Skip to main content

roder_api/
retrieval.rs

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}