relay-knowledge 1.1.6

Graph-database-based knowledge graph project.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! GraphRAG evaluation harness for retrieval and code-impact observations.
//!
//! The harness is intentionally pure: callers run CLI/API/storage workflows,
//! then submit compact observations here so exact fact, multi-hop, temporal,
//! negative, stale-index, ambiguous-entity, and code-impact checks share one
//! scoring contract.

use std::{collections::BTreeSet, error::Error, fmt};

use serde::{Deserialize, Serialize};

use crate::{
    api::HybridRetrievalResponse,
    domain::{CodeRetrievalHit, RetrieverSource},
};

/// Phase 4 GraphRAG evaluation scenario family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvaluationCaseKind {
    ExactFact,
    MultiHop,
    Temporal,
    NegativeRejection,
    StaleIndex,
    AmbiguousEntity,
    CodeImpact,
}

/// Expected behavior for one evaluation query or workflow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationCase {
    pub id: String,
    pub kind: EvaluationCaseKind,
    pub query: String,
    pub expected_result_ids: Vec<String>,
    pub forbidden_result_ids: Vec<String>,
    pub required_sources: Vec<RetrieverSource>,
    pub expected_stale: Option<bool>,
}

impl EvaluationCase {
    /// Validates a case and keeps expected result IDs deterministic.
    pub fn new(
        id: impl Into<String>,
        kind: EvaluationCaseKind,
        query: impl Into<String>,
    ) -> Result<Self, EvaluationError> {
        let id = required_text("case id", id.into())?;
        let query = required_text("query", query.into())?;

        Ok(Self {
            id,
            kind,
            query,
            expected_result_ids: Vec::new(),
            forbidden_result_ids: Vec::new(),
            required_sources: Vec::new(),
            expected_stale: None,
        })
    }

    /// Adds result IDs that must be present in the observation.
    pub fn requiring_results(mut self, ids: &[&str]) -> Result<Self, EvaluationError> {
        self.expected_result_ids = normalize_ids(ids)?;
        Ok(self)
    }

    /// Adds result IDs that must not appear in the observation.
    pub fn forbidding_results(mut self, ids: &[&str]) -> Result<Self, EvaluationError> {
        self.forbidden_result_ids = normalize_ids(ids)?;
        Ok(self)
    }

    /// Requires at least one observed retrieval hit from each listed source.
    pub fn requiring_sources(mut self, sources: &[RetrieverSource]) -> Self {
        self.required_sources = sources.to_vec();
        self
    }

    /// Requires the response stale flag to match the expected value.
    pub const fn expecting_stale(mut self, stale: bool) -> Self {
        self.expected_stale = Some(stale);
        self
    }
}

/// Small deterministic Phase 4 fixture suite used by integration and CI gates.
pub fn phase4_fixture_cases() -> Result<Vec<EvaluationCase>, EvaluationError> {
    Ok(vec![
        EvaluationCase::new(
            "phase4_exact_fact",
            EvaluationCaseKind::ExactFact,
            "exact fact async sqlite",
        )?
        .requiring_results(&["ev-exact"])?
        .requiring_sources(&[RetrieverSource::Bm25]),
        EvaluationCase::new(
            "phase4_multi_hop",
            EvaluationCaseKind::MultiHop,
            "GraphRAG uses vector path",
        )?
        .requiring_results(&["ev-path"])?
        .requiring_sources(&[RetrieverSource::GraphPath]),
        EvaluationCase::new(
            "phase4_temporal",
            EvaluationCaseKind::Temporal,
            "timeline 2026 relay release",
        )?
        .requiring_results(&["ev-temporal"])?
        .requiring_sources(&[RetrieverSource::Temporal]),
        EvaluationCase::new(
            "phase4_negative_rejection",
            EvaluationCaseKind::NegativeRejection,
            "rejected only context",
        )?
        .forbidding_results(&["ev-rejected"])?,
        EvaluationCase::new(
            "phase4_stale_index",
            EvaluationCaseKind::StaleIndex,
            "stale index refresh",
        )?
        .requiring_results(&["ev-stale"])?
        .expecting_stale(true),
        EvaluationCase::new(
            "phase4_ambiguous_entity",
            EvaluationCaseKind::AmbiguousEntity,
            "rust",
        )?
        .requiring_results(&["ev-rust-language", "ev-rust-material"])?,
        EvaluationCase::new(
            "phase4_code_impact",
            EvaluationCaseKind::CodeImpact,
            "retry policy changed",
        )?
        .requiring_results(&["symbol:retry_policy"])?
        .requiring_sources(&[RetrieverSource::CodeGraph]),
    ])
}

/// Compact observation submitted by integration tests or diagnostics commands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationObservation {
    pub result_ids: Vec<String>,
    pub retriever_sources: Vec<RetrieverSource>,
    pub stale: bool,
}

impl EvaluationObservation {
    /// Captures the stable retrieval fields needed for evaluation.
    pub fn from_retrieval(response: &HybridRetrievalResponse) -> Self {
        let result_ids = response
            .results
            .iter()
            .map(|hit| hit.evidence_id.clone())
            .collect::<Vec<_>>();
        let retriever_sources = response
            .results
            .iter()
            .flat_map(|hit| hit.retriever_sources.iter().copied())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();

        Self {
            result_ids,
            retriever_sources,
            stale: response.metadata.stale,
        }
    }

    /// Captures code-impact hits as artifact IDs while preserving source kind.
    pub fn from_code_impact(hits: &[CodeRetrievalHit]) -> Self {
        let retriever_sources = (!hits.is_empty())
            .then_some(RetrieverSource::CodeGraph)
            .into_iter()
            .collect::<Vec<_>>();
        Self {
            result_ids: hits
                .iter()
                .map(|hit| {
                    hit.symbol_snapshot_id
                        .clone()
                        .or_else(|| hit.file_id.clone())
                        .unwrap_or_else(|| hit.path.clone())
                })
                .collect(),
            retriever_sources,
            stale: hits.iter().any(|hit| hit.stale),
        }
    }
}

/// Per-case evaluation result with concrete failure reasons.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationResult {
    pub case_id: String,
    pub kind: EvaluationCaseKind,
    pub passed: bool,
    pub missing_result_ids: Vec<String>,
    pub forbidden_result_ids: Vec<String>,
    pub missing_sources: Vec<RetrieverSource>,
    pub stale_mismatch: Option<bool>,
}

/// Aggregated report for a Phase 4 evaluation run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationReport {
    pub passed: bool,
    pub total: usize,
    pub failed: usize,
    pub results: Vec<EvaluationResult>,
}

/// Scores one case against one observation.
pub fn evaluate_case(
    case: &EvaluationCase,
    observation: &EvaluationObservation,
) -> EvaluationResult {
    let observed_ids = observation
        .result_ids
        .iter()
        .cloned()
        .collect::<BTreeSet<_>>();
    let observed_sources = observation
        .retriever_sources
        .iter()
        .copied()
        .collect::<BTreeSet<_>>();
    let missing_result_ids = case
        .expected_result_ids
        .iter()
        .filter(|id| !observed_ids.contains(*id))
        .cloned()
        .collect::<Vec<_>>();
    let forbidden_result_ids = case
        .forbidden_result_ids
        .iter()
        .filter(|id| observed_ids.contains(*id))
        .cloned()
        .collect::<Vec<_>>();
    let missing_sources = case
        .required_sources
        .iter()
        .filter(|source| !observed_sources.contains(*source))
        .copied()
        .collect::<Vec<_>>();
    let stale_mismatch = case
        .expected_stale
        .filter(|expected| *expected != observation.stale);
    let passed = missing_result_ids.is_empty()
        && forbidden_result_ids.is_empty()
        && missing_sources.is_empty()
        && stale_mismatch.is_none();

    EvaluationResult {
        case_id: case.id.clone(),
        kind: case.kind,
        passed,
        missing_result_ids,
        forbidden_result_ids,
        missing_sources,
        stale_mismatch,
    }
}

/// Scores a suite where observations are supplied in case order.
pub fn evaluate_suite(
    cases: &[EvaluationCase],
    observations: &[EvaluationObservation],
) -> Result<EvaluationReport, EvaluationError> {
    if cases.len() != observations.len() {
        return Err(EvaluationError::MismatchedObservationCount);
    }
    let results = cases
        .iter()
        .zip(observations)
        .map(|(case, observation)| evaluate_case(case, observation))
        .collect::<Vec<_>>();
    let failed = results.iter().filter(|result| !result.passed).count();

    Ok(EvaluationReport {
        passed: failed == 0,
        total: results.len(),
        failed,
        results,
    })
}

/// Evaluation input validation error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvaluationError {
    EmptyField(&'static str),
    MismatchedObservationCount,
}

impl fmt::Display for EvaluationError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyField(field) => write!(formatter, "{field} must not be empty"),
            Self::MismatchedObservationCount => {
                write!(
                    formatter,
                    "evaluation case and observation counts must match"
                )
            }
        }
    }
}

impl Error for EvaluationError {}

fn required_text(field: &'static str, value: String) -> Result<String, EvaluationError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(EvaluationError::EmptyField(field));
    }

    Ok(trimmed.to_owned())
}

fn normalize_ids(ids: &[&str]) -> Result<Vec<String>, EvaluationError> {
    ids.iter()
        .map(|id| required_text("result id", (*id).to_owned()))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{CodeRetrievalLayer, RepositoryCodeRange};

    #[test]
    fn evaluates_phase4_case_families() {
        let cases = phase4_fixture_cases().expect("fixture cases should validate");
        let observations = vec![
            observation(&["ev-exact"], &[RetrieverSource::Bm25], false),
            observation(&["ev-path"], &[RetrieverSource::GraphPath], false),
            observation(&["ev-temporal"], &[RetrieverSource::Temporal], false),
            observation(&[], &[], false),
            observation(&["ev-stale"], &[], true),
            observation(&["ev-rust-language", "ev-rust-material"], &[], false),
            observation(
                &["symbol:retry_policy"],
                &[RetrieverSource::CodeGraph],
                false,
            ),
        ];

        let report = evaluate_suite(&cases, &observations).expect("suite should score");

        assert!(report.passed);
        assert_eq!(report.total, 7);
    }

    #[test]
    fn reports_missing_forbidden_and_stale_failures() {
        let case = EvaluationCase::new("case", EvaluationCaseKind::ExactFact, "query")
            .unwrap()
            .requiring_results(&["wanted"])
            .unwrap()
            .forbidding_results(&["forbidden"])
            .unwrap()
            .requiring_sources(&[RetrieverSource::Vector])
            .expecting_stale(false);
        let result = evaluate_case(
            &case,
            &observation(&["forbidden"], &[RetrieverSource::Bm25], true),
        );

        assert!(!result.passed);
        assert_eq!(result.missing_result_ids, ["wanted"]);
        assert_eq!(result.forbidden_result_ids, ["forbidden"]);
        assert_eq!(result.missing_sources, [RetrieverSource::Vector]);
        assert_eq!(result.stale_mismatch, Some(false));
    }

    #[test]
    fn code_impact_observation_preserves_sources_and_stale_state() {
        let observation = EvaluationObservation::from_code_impact(&[code_hit(true)]);

        assert_eq!(observation.result_ids, ["symbol:retry_policy"]);
        assert_eq!(observation.retriever_sources, [RetrieverSource::CodeGraph]);
        assert!(observation.stale);
    }

    fn observation(
        ids: &[&str],
        sources: &[RetrieverSource],
        stale: bool,
    ) -> EvaluationObservation {
        EvaluationObservation {
            result_ids: ids.iter().map(|id| (*id).to_owned()).collect(),
            retriever_sources: sources.to_vec(),
            stale,
        }
    }

    fn code_hit(stale: bool) -> CodeRetrievalHit {
        CodeRetrievalHit {
            repository_id: "repo".to_owned(),
            scope_id: "main".to_owned(),
            resolved_commit_sha: "abc".to_owned(),
            tree_hash: "tree".to_owned(),
            path: "src/lib.rs".to_owned(),
            language_id: "rust".to_owned(),
            byte_range: RepositoryCodeRange { start: 0, end: 10 },
            line_range: RepositoryCodeRange { start: 1, end: 1 },
            symbol_snapshot_id: Some("symbol:retry_policy".to_owned()),
            canonical_symbol_id: Some("repo://repo/src::lib::retry_policy".to_owned()),
            file_id: Some("file:src/lib.rs".to_owned()),
            retrieval_layers: vec![CodeRetrievalLayer::Impact],
            index_versions: vec!["code_graph:1".to_owned()],
            stale,
            degraded_reason: None,
            edge_kind: None,
            edge_resolution_state: None,
            edge_target_hint: None,
            edge_confidence_basis_points: None,
            edge_confidence_tier: None,
            score: 1.0,
            excerpt: "fn retry_policy() {}".to_owned(),
        }
    }
}