Skip to main content

coding_agent_search/
explainability.rs

1//! Layered explanation cards for robot-visible controller decisions.
2//!
3//! Cards are intentionally compact by default. Each card has a plain summary
4//! plus optional input, evidence, and fallback-contract fields so robot callers
5//! can decide whether a decision was expected, degraded, or needs operator
6//! action without reverse-engineering scattered metadata fields.
7
8use 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}