1use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use std::collections::BTreeMap;
11
12pub const EXPLANATION_CARD_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ExplanationSurface {
17 SearchRobot,
18 HealthRobot,
19 StatusRobot,
20 SourceSync,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ExplanationDecision {
26 SearchFallback,
27 CacheAdmission,
28 RebuildThrottle,
29 SemanticUnavailable,
30 SourceSyncDeferred,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ExplanationFallbackContract {
35 pub fail_open: bool,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub realized_tier: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub operator_action: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub rollback_trigger: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ExplanationCard {
46 pub schema_version: u32,
47 pub card_id: String,
48 pub surface: ExplanationSurface,
49 pub decision: ExplanationDecision,
50 pub level: u8,
51 pub summary: String,
52 #[serde(default)]
53 pub inputs: BTreeMap<String, Value>,
54 #[serde(default)]
55 pub evidence: BTreeMap<String, Value>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub fallback_contract: Option<ExplanationFallbackContract>,
58}
59
60impl ExplanationCard {
61 fn new(
62 card_id: impl Into<String>,
63 surface: ExplanationSurface,
64 decision: ExplanationDecision,
65 level: u8,
66 summary: impl Into<String>,
67 ) -> Self {
68 Self {
69 schema_version: EXPLANATION_CARD_SCHEMA_VERSION,
70 card_id: card_id.into(),
71 surface,
72 decision,
73 level,
74 summary: summary.into(),
75 inputs: BTreeMap::new(),
76 evidence: BTreeMap::new(),
77 fallback_contract: None,
78 }
79 }
80
81 fn input(mut self, key: impl Into<String>, value: Value) -> Self {
82 self.inputs.insert(key.into(), value);
83 self
84 }
85
86 fn evidence(mut self, key: impl Into<String>, value: Value) -> Self {
87 self.evidence.insert(key.into(), value);
88 self
89 }
90
91 fn fallback_contract(mut self, contract: ExplanationFallbackContract) -> Self {
92 self.fallback_contract = Some(contract);
93 self
94 }
95}
96
97#[derive(Debug, Clone)]
98pub struct SearchRobotExplanationInput {
99 pub requested_mode: String,
100 pub realized_mode: String,
101 pub fallback_tier: Option<String>,
102 pub fallback_reason: Option<String>,
103 pub semantic_refinement: bool,
104 pub wildcard_fallback: bool,
105 pub cache_policy: String,
106 pub cache_hits: u64,
107 pub cache_misses: u64,
108 pub cache_shortfall: u64,
109 pub cache_evictions: u64,
110 pub cache_admission_rejects: u64,
111 pub cache_ghost_entries: usize,
112 pub index_rebuilding: bool,
113 pub pending_sessions: Option<u64>,
114}
115
116pub fn search_robot_explanation_cards(input: SearchRobotExplanationInput) -> Vec<ExplanationCard> {
117 let mut cards = Vec::new();
118 if let Some(reason) = input.fallback_reason.as_deref() {
119 cards.push(search_fallback_card(
120 &input.requested_mode,
121 &input.realized_mode,
122 input.fallback_tier.as_deref(),
123 reason,
124 input.semantic_refinement,
125 ));
126 if reason.to_ascii_lowercase().contains("semantic") {
127 cards.push(semantic_unavailable_card(
128 None,
129 input.fallback_tier.as_deref().unwrap_or("lexical"),
130 reason,
131 "build semantic assets or rerun with --mode lexical",
132 ));
133 }
134 }
135 if input.cache_shortfall > 0 || input.cache_evictions > 0 || input.cache_admission_rejects > 0 {
136 cards.push(cache_admission_card(
137 &input.cache_policy,
138 input.cache_hits,
139 input.cache_misses,
140 input.cache_shortfall,
141 input.cache_evictions,
142 input.cache_admission_rejects,
143 input.cache_ghost_entries,
144 ));
145 }
146 if input.index_rebuilding {
147 cards.push(rebuild_throttle_card(
148 input.pending_sessions,
149 "index generation is rebuilding; cursor and cache decisions stay conservative",
150 ));
151 }
152 if input.wildcard_fallback {
153 cards.push(
154 ExplanationCard::new(
155 "search.wildcard_fallback",
156 ExplanationSurface::SearchRobot,
157 ExplanationDecision::SearchFallback,
158 0,
159 "query broadened automatically after sparse exact matches",
160 )
161 .input("wildcard_fallback", json!(true))
162 .fallback_contract(ExplanationFallbackContract {
163 fail_open: true,
164 realized_tier: Some("lexical".to_string()),
165 operator_action: Some(
166 "quote terms or use explicit wildcards to control breadth".to_string(),
167 ),
168 rollback_trigger: Some(
169 "unexpected broad matches in the first result page".to_string(),
170 ),
171 }),
172 );
173 }
174 cards
175}
176
177pub fn search_fallback_card(
178 requested_mode: &str,
179 realized_mode: &str,
180 fallback_tier: Option<&str>,
181 reason: &str,
182 semantic_refinement: bool,
183) -> ExplanationCard {
184 ExplanationCard::new(
185 "search.semantic_fallback",
186 ExplanationSurface::SearchRobot,
187 ExplanationDecision::SearchFallback,
188 1,
189 "search mode degraded but results remain available",
190 )
191 .input("requested_mode", json!(requested_mode))
192 .input("realized_mode", json!(realized_mode))
193 .input("fallback_tier", json!(fallback_tier))
194 .evidence("reason", json!(reason))
195 .evidence("semantic_refinement", json!(semantic_refinement))
196 .fallback_contract(ExplanationFallbackContract {
197 fail_open: true,
198 realized_tier: fallback_tier.map(str::to_string),
199 operator_action: Some("inspect semantic readiness or run with --mode lexical".to_string()),
200 rollback_trigger: Some("strict semantic mode was requested".to_string()),
201 })
202}
203
204pub fn cache_admission_card(
205 policy: &str,
206 hits: u64,
207 misses: u64,
208 shortfall: u64,
209 evictions: u64,
210 admission_rejects: u64,
211 ghost_entries: usize,
212) -> ExplanationCard {
213 ExplanationCard::new(
214 "search.cache_admission",
215 ExplanationSurface::SearchRobot,
216 ExplanationDecision::CacheAdmission,
217 1,
218 "cache policy constrained search-result reuse",
219 )
220 .input("policy", json!(policy))
221 .evidence("hits", json!(hits))
222 .evidence("misses", json!(misses))
223 .evidence("shortfall", json!(shortfall))
224 .evidence("evictions", json!(evictions))
225 .evidence("admission_rejects", json!(admission_rejects))
226 .evidence("ghost_entries", json!(ghost_entries))
227 .fallback_contract(ExplanationFallbackContract {
228 fail_open: true,
229 realized_tier: Some("uncached_search".to_string()),
230 operator_action: Some(
231 "raise cache byte caps only if repeated-query p95 regresses".to_string(),
232 ),
233 rollback_trigger: Some(
234 "cache pressure increases cold-query latency or RSS beyond budget".to_string(),
235 ),
236 })
237}
238
239pub fn rebuild_throttle_card(pending_sessions: Option<u64>, reason: &str) -> ExplanationCard {
240 ExplanationCard::new(
241 "index.rebuild_throttle",
242 ExplanationSurface::StatusRobot,
243 ExplanationDecision::RebuildThrottle,
244 1,
245 "index rebuild state makes continuation and cache decisions conservative",
246 )
247 .input("pending_sessions", json!(pending_sessions))
248 .evidence("reason", json!(reason))
249 .fallback_contract(ExplanationFallbackContract {
250 fail_open: true,
251 realized_tier: Some("existing_generation".to_string()),
252 operator_action: Some(
253 "wait for index rebuild to finish before treating cursors as stable".to_string(),
254 ),
255 rollback_trigger: Some(
256 "rebuild remains active beyond the operator's freshness budget".to_string(),
257 ),
258 })
259}
260
261pub fn semantic_unavailable_card(
262 requested_model: Option<&str>,
263 fallback_mode: &str,
264 reason: &str,
265 recommended_action: &str,
266) -> ExplanationCard {
267 ExplanationCard::new(
268 "semantic.unavailable",
269 ExplanationSurface::HealthRobot,
270 ExplanationDecision::SemanticUnavailable,
271 1,
272 "semantic refinement is unavailable; lexical behavior remains valid",
273 )
274 .input("requested_model", json!(requested_model))
275 .input("fallback_mode", json!(fallback_mode))
276 .evidence("reason", json!(reason))
277 .fallback_contract(ExplanationFallbackContract {
278 fail_open: true,
279 realized_tier: Some(fallback_mode.to_string()),
280 operator_action: Some(recommended_action.to_string()),
281 rollback_trigger: Some("operator requires semantic-only results".to_string()),
282 })
283}
284
285pub fn source_sync_deferral_card(
286 source_id: &str,
287 retryable: bool,
288 deferred_until_ms: Option<i64>,
289 reason: &str,
290) -> ExplanationCard {
291 ExplanationCard::new(
292 "source.sync_deferred",
293 ExplanationSurface::SourceSync,
294 ExplanationDecision::SourceSyncDeferred,
295 1,
296 "remote source sync was deferred without blocking local search",
297 )
298 .input("source_id", json!(source_id))
299 .input("retryable", json!(retryable))
300 .input("deferred_until_ms", json!(deferred_until_ms))
301 .evidence("reason", json!(reason))
302 .fallback_contract(ExplanationFallbackContract {
303 fail_open: true,
304 realized_tier: Some("local_sources".to_string()),
305 operator_action: Some("inspect source health and retry the deferred source".to_string()),
306 rollback_trigger: Some("remote source is required for the requested audit".to_string()),
307 })
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn pins_search_fallback_card_shape() {
316 let card = search_fallback_card(
317 "hybrid",
318 "lexical",
319 Some("lexical"),
320 "semantic context unavailable: model missing",
321 false,
322 );
323 let value = serde_json::to_value(card).unwrap();
324 assert_eq!(value["schema_version"], EXPLANATION_CARD_SCHEMA_VERSION);
325 assert_eq!(value["card_id"], "search.semantic_fallback");
326 assert_eq!(value["decision"], "search_fallback");
327 assert_eq!(value["inputs"]["requested_mode"], "hybrid");
328 assert_eq!(value["fallback_contract"]["fail_open"], true);
329 }
330
331 #[test]
332 fn pins_cache_admission_card_shape() {
333 let card = cache_admission_card("s3-fifo", 4, 9, 2, 3, 1, 7);
334 let value = serde_json::to_value(card).unwrap();
335 assert_eq!(value["card_id"], "search.cache_admission");
336 assert_eq!(value["inputs"]["policy"], "s3-fifo");
337 assert_eq!(value["evidence"]["evictions"], 3);
338 assert_eq!(value["evidence"]["admission_rejects"], 1);
339 }
340
341 #[test]
342 fn pins_rebuild_throttle_card_shape() {
343 let card = rebuild_throttle_card(Some(42), "rebuild active");
344 let value = serde_json::to_value(card).unwrap();
345 assert_eq!(value["surface"], "status_robot");
346 assert_eq!(value["decision"], "rebuild_throttle");
347 assert_eq!(value["inputs"]["pending_sessions"], 42);
348 }
349
350 #[test]
351 fn pins_semantic_unavailable_card_shape() {
352 let card = semantic_unavailable_card(
353 Some("minilm"),
354 "lexical",
355 "model files are absent",
356 "run cass models install",
357 );
358 let value = serde_json::to_value(card).unwrap();
359 assert_eq!(value["surface"], "health_robot");
360 assert_eq!(value["decision"], "semantic_unavailable");
361 assert_eq!(value["inputs"]["requested_model"], "minilm");
362 assert_eq!(value["fallback_contract"]["realized_tier"], "lexical");
363 }
364
365 #[test]
366 fn pins_source_sync_deferral_card_shape() {
367 let card = source_sync_deferral_card("workstation", true, Some(1234), "ssh busy");
368 let value = serde_json::to_value(card).unwrap();
369 assert_eq!(value["surface"], "source_sync");
370 assert_eq!(value["decision"], "source_sync_deferred");
371 assert_eq!(value["inputs"]["source_id"], "workstation");
372 assert_eq!(value["inputs"]["retryable"], true);
373 }
374
375 #[test]
376 fn search_robot_cards_stay_concise_when_no_decision_needs_explaining() {
377 let cards = search_robot_explanation_cards(SearchRobotExplanationInput {
378 requested_mode: "hybrid".to_string(),
379 realized_mode: "hybrid".to_string(),
380 fallback_tier: None,
381 fallback_reason: None,
382 semantic_refinement: true,
383 wildcard_fallback: false,
384 cache_policy: "lru".to_string(),
385 cache_hits: 0,
386 cache_misses: 1,
387 cache_shortfall: 0,
388 cache_evictions: 0,
389 cache_admission_rejects: 0,
390 cache_ghost_entries: 0,
391 index_rebuilding: false,
392 pending_sessions: None,
393 });
394 assert!(cards.is_empty());
395 }
396
397 #[test]
398 fn semantic_fallback_detection_is_case_insensitive() {
399 let cards = search_robot_explanation_cards(SearchRobotExplanationInput {
400 requested_mode: "hybrid".to_string(),
401 realized_mode: "lexical".to_string(),
402 fallback_tier: Some("lexical".to_string()),
403 fallback_reason: Some("Semantic context unavailable: model missing".to_string()),
404 semantic_refinement: false,
405 wildcard_fallback: false,
406 cache_policy: "lru".to_string(),
407 cache_hits: 0,
408 cache_misses: 0,
409 cache_shortfall: 0,
410 cache_evictions: 0,
411 cache_admission_rejects: 0,
412 cache_ghost_entries: 0,
413 index_rebuilding: false,
414 pending_sessions: None,
415 });
416
417 assert!(
418 cards
419 .iter()
420 .any(|card| card.decision == ExplanationDecision::SemanticUnavailable),
421 "semantic-unavailable card missing for capitalized reason"
422 );
423 }
424}