Skip to main content

remem/retrieval/search/memory/
explain.rs

1use serde::{ser::SerializeStruct, Serialize, Serializer};
2
3#[derive(Debug, Clone, Serialize)]
4pub struct SearchExplain {
5    pub query: String,
6    pub project: Option<String>,
7    pub memory_type: Option<String>,
8    pub branch: Option<String>,
9    pub include_stale: bool,
10    pub limit: i64,
11    pub offset: i64,
12    pub fetch_limit: i64,
13    pub expanded_terms: Vec<String>,
14    pub core_terms: Vec<String>,
15    pub claim_terms: Vec<String>,
16    pub fts_query: Option<String>,
17    pub temporal_range: Option<(i64, i64)>,
18    pub temporal_field: Option<String>,
19    pub rrf_k: f64,
20    pub min_evidence_confidence: f64,
21    pub filtered_result_count: usize,
22    pub timings: Vec<crate::perf::PhaseTiming>,
23    /// Rerank is a dedicated post-fusion stage, not a recall channel.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub rerank: Option<crate::retrieval::rerank::RerankExplain>,
26    pub channels: Vec<SearchExplainChannel>,
27    pub results: Vec<SearchExplainResult>,
28    pub has_more: bool,
29    pub raw_fallback_count: usize,
30}
31
32impl SearchExplain {
33    pub fn retain_result_ids(&mut self, result_ids: &[i64], has_more: bool, visible_limit: i64) {
34        self.has_more = has_more;
35        self.limit = visible_limit;
36        self.results
37            .retain(|result| result_ids.contains(&result.memory_id));
38        for (index, result) in self.results.iter_mut().enumerate() {
39            result.final_rank = index + 1;
40        }
41    }
42
43    pub fn set_raw_fallback_count(&mut self, count: usize) {
44        self.raw_fallback_count = count;
45    }
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct SearchExplainDetails {
50    #[serde(flatten)]
51    pub explain: SearchExplain,
52    pub contribution_breakdowns: Vec<SearchExplainResultBreakdown>,
53}
54
55impl SearchExplainDetails {
56    pub fn retain_result_ids(&mut self, result_ids: &[i64], has_more: bool, visible_limit: i64) {
57        self.explain
58            .retain_result_ids(result_ids, has_more, visible_limit);
59        self.contribution_breakdowns
60            .retain(|result| result_ids.contains(&result.memory_id));
61    }
62
63    pub fn set_raw_fallback_count(&mut self, count: usize) {
64        self.explain.set_raw_fallback_count(count);
65    }
66}
67
68#[derive(Debug, Clone, Serialize)]
69pub struct SearchExplainChannel {
70    pub name: String,
71    pub enabled: bool,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub disabled_reason: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub candidates_scanned: Option<usize>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub embedding: Option<crate::retrieval::embedding::EmbeddingExecutionMetadata>,
78    pub hits: Vec<ChannelHit>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct ChannelHit {
83    pub memory_id: i64,
84    pub rank: usize,
85}
86
87#[derive(Debug, Clone)]
88pub struct SearchExplainResult {
89    pub memory_id: i64,
90    pub final_rank: usize,
91    pub final_score: f64,
92    pub evidence_confidence: f64,
93    pub project: String,
94    pub scope: String,
95    pub visibility: String,
96    pub staleness: crate::memory::MemoryStalenessLabel,
97    pub contributions: Vec<ChannelContribution>,
98}
99
100impl SearchExplainResult {
101    /// Sum of the per-channel RRF contributions before post-fusion policies.
102    pub fn fusion_score(&self) -> f64 {
103        self.contributions
104            .iter()
105            .map(|contribution| contribution.score)
106            .sum()
107    }
108
109    /// Multiplier applied after fusion, such as source-anchor demotion.
110    pub fn post_fusion_score_factor(&self) -> Option<f64> {
111        let fusion_score = self.fusion_score();
112        (fusion_score.is_finite() && fusion_score > 0.0)
113            .then_some(self.final_score / fusion_score)
114            .filter(|factor| factor.is_finite() && *factor >= 0.0)
115    }
116}
117
118impl Serialize for SearchExplainResult {
119    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120    where
121        S: Serializer,
122    {
123        let mut state = serializer.serialize_struct("SearchExplainResult", 11)?;
124        state.serialize_field("memory_id", &self.memory_id)?;
125        state.serialize_field("final_rank", &self.final_rank)?;
126        state.serialize_field("final_score", &self.final_score)?;
127        state.serialize_field("evidence_confidence", &self.evidence_confidence)?;
128        state.serialize_field("project", &self.project)?;
129        state.serialize_field("scope", &self.scope)?;
130        state.serialize_field("visibility", &self.visibility)?;
131        state.serialize_field("staleness", &self.staleness)?;
132        state.serialize_field("contributions", &self.contributions)?;
133        state.serialize_field("fusion_score", &self.fusion_score())?;
134        state.serialize_field("post_fusion_score_factor", &self.post_fusion_score_factor())?;
135        state.end()
136    }
137}
138
139#[derive(Debug, Clone, Serialize)]
140pub struct ChannelContribution {
141    pub channel: String,
142    pub rank: usize,
143    pub score: f64,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct SearchExplainResultBreakdown {
148    pub memory_id: i64,
149    pub contributions: Vec<ChannelContributionBreakdown>,
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub struct ChannelContributionBreakdown {
154    pub channel: String,
155    pub rank: usize,
156    pub weight: f64,
157    pub reciprocal_rank: f64,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub normalized_signal: Option<f64>,
160    pub total_score: f64,
161}