1use std::collections::BTreeMap;
2
3use crate::memory::Memory;
4
5#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
6pub struct GoldenDataset {
7 pub version: Option<String>,
8 pub description: Option<String>,
9 #[serde(default)]
10 pub corpus: Vec<GoldenMemory>,
11 #[serde(default)]
12 pub queries: Vec<GoldenQuery>,
13}
14
15impl GoldenDataset {
16 pub fn has_fixture_corpus(&self) -> bool {
17 !self.corpus.is_empty()
18 }
19}
20
21#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
22pub struct GoldenMemory {
23 pub project: String,
24 #[serde(default)]
25 pub topic_key: Option<String>,
26 pub title: String,
27 #[serde(alias = "text")]
28 pub content: String,
29 pub memory_type: String,
30 #[serde(default)]
31 pub branch: Option<String>,
32 #[serde(default = "default_scope")]
33 pub scope: String,
34 #[serde(default = "default_status")]
35 pub status: String,
36 #[serde(default)]
37 pub files: Option<String>,
38 #[serde(default)]
39 pub created_at_epoch: Option<i64>,
40 #[serde(default)]
41 pub access_count: Option<i64>,
42 #[serde(default)]
43 pub last_accessed_epoch: Option<i64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub search_context: Option<GoldenSearchContext>,
49}
50
51#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
52pub struct GoldenSearchContext {
53 pub context: String,
54 #[serde(default)]
55 pub keywords: Vec<String>,
56}
57
58fn default_scope() -> String {
59 "project".to_string()
60}
61
62fn default_status() -> String {
63 "active".to_string()
64}
65
66#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
67pub struct GoldenQuery {
68 pub id: String,
69 pub query: String,
70 pub category: String,
71 #[serde(default)]
72 pub slice: Option<String>,
73 #[serde(default)]
74 pub hop_path: Option<GoldenHopPath>,
75 pub project: Option<String>,
76 #[serde(default)]
77 pub branch: Option<String>,
78 #[serde(default)]
79 pub memory_type: Option<String>,
80 #[serde(default)]
81 pub relevant_ids: Vec<i64>,
82 #[serde(default, alias = "expected_refs")]
83 pub evidence_refs: Vec<EvidenceRef>,
84 #[serde(default)]
85 pub expect_abstain: bool,
86 #[serde(default)]
87 pub false_premise: bool,
88 pub notes: Option<String>,
89}
90
91impl GoldenQuery {
92 pub fn expects_abstention(&self) -> bool {
93 self.expect_abstain || self.false_premise
94 }
95
96 pub fn slice_label(&self) -> &str {
97 self.slice
98 .as_deref()
99 .filter(|slice| !slice.trim().is_empty())
100 .unwrap_or(&self.category)
101 }
102
103 pub fn expected_refs(&self) -> Vec<EvidenceRef> {
104 let mut refs = self.evidence_refs.clone();
105 refs.extend(self.relevant_ids.iter().map(|id| EvidenceRef {
106 memory_id: Some(*id),
107 ..EvidenceRef::default()
108 }));
109 refs
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
114pub struct GoldenHopPath {
115 pub source: String,
116 pub entity_type: String,
117 pub entity: String,
118 pub target: String,
119}
120
121#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
122pub struct EvidenceRef {
123 pub memory_id: Option<i64>,
124 pub topic_key: Option<String>,
125 pub project: Option<String>,
126 pub branch: Option<String>,
127 pub memory_type: Option<String>,
128 pub scope: Option<String>,
129 pub title_contains: Option<String>,
130 pub text_contains: Option<String>,
131}
132
133impl EvidenceRef {
134 pub fn has_match_criteria(&self) -> bool {
135 self.memory_id.is_some()
136 || self.topic_key.is_some()
137 || self.project.is_some()
138 || self.branch.is_some()
139 || self.memory_type.is_some()
140 || self.scope.is_some()
141 || self.title_contains.is_some()
142 || self.text_contains.is_some()
143 }
144
145 pub fn matches(&self, memory: &Memory) -> bool {
146 if !self.has_match_criteria() {
147 return false;
148 }
149 if let Some(memory_id) = self.memory_id {
150 if memory.id != memory_id {
151 return false;
152 }
153 }
154 if let Some(project) = self.project.as_deref() {
155 if !crate::project_id::project_matches(Some(&memory.project), project) {
156 return false;
157 }
158 }
159 if let Some(branch) = self.branch.as_deref() {
160 if memory.branch.as_deref() != Some(branch) {
161 return false;
162 }
163 }
164 if let Some(topic_key) = self.topic_key.as_deref() {
165 if memory.topic_key.as_deref() != Some(topic_key) {
166 return false;
167 }
168 }
169 if let Some(memory_type) = self.memory_type.as_deref() {
170 if memory.memory_type != memory_type {
171 return false;
172 }
173 }
174 if let Some(scope) = self.scope.as_deref() {
175 if memory.scope != scope {
176 return false;
177 }
178 }
179 if let Some(needle) = self.title_contains.as_deref() {
180 if !contains_case_insensitive(&memory.title, needle) {
181 return false;
182 }
183 }
184 if let Some(needle) = self.text_contains.as_deref() {
185 if !contains_case_insensitive(&memory.text, needle) {
186 return false;
187 }
188 }
189 true
190 }
191}
192
193pub(super) fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
194 haystack.to_lowercase().contains(&needle.to_lowercase())
195}
196
197#[derive(Debug, Clone, serde::Serialize)]
198pub struct GoldenEvalReport {
199 pub evaluation_layers: EvaluationLayers,
200 pub version: Option<String>,
201 pub description: Option<String>,
202 pub k: usize,
203 pub rank_k: usize,
204 pub total_queries: usize,
205 pub scored_queries: usize,
206 pub skipped_queries: usize,
207 pub abstention_queries: usize,
208 pub abstention_passed: usize,
209 pub overall: Option<MetricAverages>,
210 pub by_slice: BTreeMap<String, CategoryEvaluation>,
211 pub by_category: BTreeMap<String, CategoryEvaluation>,
212 pub queries: Vec<QueryEvaluation>,
213}
214
215#[derive(Debug, Clone, serde::Serialize)]
216pub struct CategoryEvaluation {
217 pub total_queries: usize,
218 pub scored_queries: usize,
219 pub abstention_queries: usize,
220 pub abstention_passed: usize,
221 pub query_tokens_per_query: f64,
222 pub retrieval_latency_p50_ms: f64,
223 pub retrieval_latency_p95_ms: f64,
224 pub metrics: Option<MetricAverages>,
225}
226
227#[derive(Debug, Clone, serde::Serialize)]
228pub struct QueryEvaluation {
229 pub id: String,
230 pub query: String,
231 pub category: String,
232 pub slice: String,
233 pub status: QueryStatus,
234 pub result_count: usize,
235 pub retrieved_ids: Vec<i64>,
236 pub expected_relevant_ids: Vec<i64>,
237 pub missing_relevant_ids: Vec<i64>,
238 pub missing_evidence_refs: Vec<EvidenceRef>,
239 pub matched_refs: usize,
240 pub expected_refs: usize,
241 pub query_tokens: usize,
242 pub retrieval_latency_ms: f64,
243 pub metrics: Option<QueryMetrics>,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
247pub enum QueryStatus {
248 #[serde(rename = "HIT")]
249 Hit,
250 #[serde(rename = "MISS")]
251 Miss,
252 #[serde(rename = "PASS")]
253 Pass,
254 #[serde(rename = "FAIL")]
255 Fail,
256 #[serde(rename = "SKIP")]
257 Skip,
258}
259
260impl QueryStatus {
261 pub fn label(self) -> &'static str {
262 match self {
263 QueryStatus::Hit => "HIT",
264 QueryStatus::Miss => "MISS",
265 QueryStatus::Pass => "PASS",
266 QueryStatus::Fail => "FAIL",
267 QueryStatus::Skip => "SKIP",
268 }
269 }
270}
271
272#[derive(Debug, Clone, serde::Serialize)]
273pub struct EvaluationLayers {
274 pub retrieval: LayerStatus,
275 pub answer_generation: LayerStatus,
276 pub llm_judge: LayerStatus,
277}
278
279impl EvaluationLayers {
280 pub fn deterministic_retrieval_only() -> Self {
281 Self {
282 retrieval: LayerStatus {
283 status: "deterministic",
284 description:
285 "fixed golden retrieval metrics: Hit@K, Recall@K, MRR, nDCG, evidence recall",
286 },
287 answer_generation: LayerStatus {
288 status: "not_run",
289 description:
290 "answer generation is intentionally excluded from golden retrieval eval",
291 },
292 llm_judge: LayerStatus {
293 status: "not_run",
294 description: "LLM judging is intentionally excluded from deterministic golden eval",
295 },
296 }
297 }
298}
299
300#[derive(Debug, Clone, serde::Serialize)]
301pub struct LayerStatus {
302 pub status: &'static str,
303 pub description: &'static str,
304}
305
306#[derive(Debug, Clone, Default, serde::Serialize)]
307pub struct QueryMetrics {
308 pub hit_at_k: f64,
309 pub mrr_at_10: f64,
310 pub precision_at_k: f64,
311 pub recall_at_k: f64,
312 pub ndcg_at_10: f64,
313 pub evidence_recall_at_k: f64,
314}
315
316#[derive(Debug, Clone, Default, serde::Serialize)]
317pub struct MetricAverages {
318 pub count: usize,
319 pub hit_at_k: f64,
320 pub mrr_at_10: f64,
321 pub precision_at_k: f64,
322 pub recall_at_k: f64,
323 pub ndcg_at_10: f64,
324 pub evidence_recall_at_k: f64,
325}
326
327#[derive(Debug, Default)]
328pub(super) struct MetricSums {
329 count: usize,
330 hit_at_k: f64,
331 mrr_at_10: f64,
332 precision_at_k: f64,
333 recall_at_k: f64,
334 ndcg_at_10: f64,
335 evidence_recall_at_k: f64,
336}
337
338impl MetricSums {
339 pub(super) fn add(&mut self, metrics: &QueryMetrics) {
340 self.count += 1;
341 self.hit_at_k += metrics.hit_at_k;
342 self.mrr_at_10 += metrics.mrr_at_10;
343 self.precision_at_k += metrics.precision_at_k;
344 self.recall_at_k += metrics.recall_at_k;
345 self.ndcg_at_10 += metrics.ndcg_at_10;
346 self.evidence_recall_at_k += metrics.evidence_recall_at_k;
347 }
348
349 pub(super) fn averages(&self) -> Option<MetricAverages> {
350 if self.count == 0 {
351 return None;
352 }
353 let n = self.count as f64;
354 Some(MetricAverages {
355 count: self.count,
356 hit_at_k: self.hit_at_k / n,
357 mrr_at_10: self.mrr_at_10 / n,
358 precision_at_k: self.precision_at_k / n,
359 recall_at_k: self.recall_at_k / n,
360 ndcg_at_10: self.ndcg_at_10 / n,
361 evidence_recall_at_k: self.evidence_recall_at_k / n,
362 })
363 }
364}