Skip to main content

eidos_kernel/eval/
combo.rs

1use crate::retrieval::Confidence;
2
3use super::GoldenCase;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum ComboKind {
7    GroundGate,
8    VerifyReceipt,
9    VocabLoop,
10    GapPropose,
11    AunRepair,
12    RynReconcile,
13    VyreQueue,
14    TrustReview,
15    AetherStale,
16    NavigationPacket,
17    GlyphNavigation,
18}
19
20impl ComboKind {
21    pub fn parse(value: &str) -> Option<Self> {
22        match value {
23            "ground_gate" => Some(Self::GroundGate),
24            "verify_receipt" => Some(Self::VerifyReceipt),
25            "vocab_loop" => Some(Self::VocabLoop),
26            "gap_propose" => Some(Self::GapPropose),
27            "aun_repair" => Some(Self::AunRepair),
28            "ryn_reconcile" => Some(Self::RynReconcile),
29            "vyre_queue" => Some(Self::VyreQueue),
30            "trust_review" => Some(Self::TrustReview),
31            "aether_stale" => Some(Self::AetherStale),
32            "navigation_packet" => Some(Self::NavigationPacket),
33            "glyph_navigation" => Some(Self::GlyphNavigation),
34            _ => None,
35        }
36    }
37
38    pub fn label(self) -> &'static str {
39        match self {
40            Self::GroundGate => "ground_gate",
41            Self::VerifyReceipt => "verify_receipt",
42            Self::VocabLoop => "vocab_loop",
43            Self::GapPropose => "gap_propose",
44            Self::AunRepair => "aun_repair",
45            Self::RynReconcile => "ryn_reconcile",
46            Self::VyreQueue => "vyre_queue",
47            Self::TrustReview => "trust_review",
48            Self::AetherStale => "aether_stale",
49            Self::NavigationPacket => "navigation_packet",
50            Self::GlyphNavigation => "glyph_navigation",
51        }
52    }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum ComboSpec {
57    GroundGate {
58        query: String,
59        min_band_text: String,
60        min_band: Confidence,
61        expected_pass: bool,
62    },
63    VerifyReceipt {
64        file: String,
65        line_start: usize,
66        line_end: Option<usize>,
67        quote: Option<String>,
68        expected_holds: bool,
69    },
70    VocabLoop {
71        query: String,
72        expected_top: String,
73        target_node: Option<String>,
74        min_band_text: String,
75        min_band: Confidence,
76        expected_pass: bool,
77    },
78    GapPropose {
79        query: String,
80        expected_pass: bool,
81    },
82    AunRepair {
83        query: String,
84        expected_pass: bool,
85    },
86    RynReconcile {
87        query: String,
88        expected_pass: bool,
89    },
90    VyreQueue {
91        query: String,
92        expected_pass: bool,
93    },
94    TrustReview {
95        query: String,
96        expected_pass: bool,
97    },
98    AetherStale {
99        query: String,
100        min_band_text: String,
101        min_band: Confidence,
102        expected_pass: bool,
103    },
104    NavigationPacket {
105        tool: String,
106        query: String,
107        expected_route: Option<String>,
108        expected_nodes: Vec<String>,
109        expected_partition: Option<String>,
110        expected_pass: bool,
111    },
112    GlyphNavigation {
113        query: String,
114        expected_pass: bool,
115    },
116}
117
118impl ComboSpec {
119    pub fn kind(&self) -> ComboKind {
120        match self {
121            Self::GroundGate { .. } => ComboKind::GroundGate,
122            Self::VerifyReceipt { .. } => ComboKind::VerifyReceipt,
123            Self::VocabLoop { .. } => ComboKind::VocabLoop,
124            Self::GapPropose { .. } => ComboKind::GapPropose,
125            Self::AunRepair { .. } => ComboKind::AunRepair,
126            Self::RynReconcile { .. } => ComboKind::RynReconcile,
127            Self::VyreQueue { .. } => ComboKind::VyreQueue,
128            Self::TrustReview { .. } => ComboKind::TrustReview,
129            Self::AetherStale { .. } => ComboKind::AetherStale,
130            Self::NavigationPacket { .. } => ComboKind::NavigationPacket,
131            Self::GlyphNavigation { .. } => ComboKind::GlyphNavigation,
132        }
133    }
134
135    pub fn from_golden(raw: &GoldenCase) -> Result<Self, ComboSpecError> {
136        let combo = raw.combo.as_deref().unwrap_or("route");
137        let Some(kind) = ComboKind::parse(combo) else {
138            return Err(ComboSpecError::Unsupported(combo.to_string()));
139        };
140        match kind {
141            ComboKind::GroundGate => {
142                let query = require_query(raw, kind)?;
143                let (min_band_text, min_band) = optional_confidence(raw, kind, "weak")?;
144                Ok(Self::GroundGate {
145                    query,
146                    min_band_text,
147                    min_band,
148                    expected_pass: raw.expected_pass.unwrap_or(true),
149                })
150            }
151            ComboKind::VerifyReceipt => {
152                let file = require_text(
153                    raw.file.as_deref(),
154                    kind,
155                    "file",
156                    "set file to the receipt path, relative to the package root",
157                )?;
158                let line_start = raw.line_start.filter(|line| *line > 0).ok_or_else(|| {
159                    validation_error(
160                        kind,
161                        "line_start",
162                        "missing positive line_start",
163                        "set line_start to a 1-indexed receipt line",
164                    )
165                })?;
166                Ok(Self::VerifyReceipt {
167                    file,
168                    line_start,
169                    line_end: raw.line_end,
170                    quote: raw.quote.clone(),
171                    expected_holds: raw.expected_holds.unwrap_or(true),
172                })
173            }
174            ComboKind::VocabLoop => {
175                let query = require_query(raw, kind)?;
176                let expected_top = raw
177                    .expect
178                    .first()
179                    .map(String::as_str)
180                    .map(str::trim)
181                    .filter(|expect| !expect.is_empty())
182                    .map(ToOwned::to_owned)
183                    .ok_or_else(|| {
184                        validation_error(
185                            kind,
186                            "expect",
187                            "missing first expect node id",
188                            "set expect to the node id that should receive the query example",
189                        )
190                    })?;
191                let (min_band_text, min_band) = optional_confidence(raw, kind, "strong")?;
192                Ok(Self::VocabLoop {
193                    query,
194                    expected_top,
195                    target_node: raw.target_node.clone(),
196                    min_band_text,
197                    min_band,
198                    expected_pass: raw.expected_pass.unwrap_or(true),
199                })
200            }
201            ComboKind::GapPropose => Ok(Self::GapPropose {
202                query: require_query(raw, kind)?,
203                expected_pass: raw.expected_pass.unwrap_or(true),
204            }),
205            ComboKind::AunRepair => Ok(Self::AunRepair {
206                query: require_query(raw, kind)?,
207                expected_pass: raw.expected_pass.unwrap_or(true),
208            }),
209            ComboKind::RynReconcile => Ok(Self::RynReconcile {
210                query: require_query(raw, kind)?,
211                expected_pass: raw.expected_pass.unwrap_or(true),
212            }),
213            ComboKind::VyreQueue => Ok(Self::VyreQueue {
214                query: require_query(raw, kind)?,
215                expected_pass: raw.expected_pass.unwrap_or(true),
216            }),
217            ComboKind::TrustReview => Ok(Self::TrustReview {
218                query: require_query(raw, kind)?,
219                expected_pass: raw.expected_pass.unwrap_or(true),
220            }),
221            ComboKind::AetherStale => {
222                let query = require_query(raw, kind)?;
223                let (min_band_text, min_band) = optional_confidence(raw, kind, "ambiguous")?;
224                Ok(Self::AetherStale {
225                    query,
226                    min_band_text,
227                    min_band,
228                    expected_pass: raw.expected_pass.unwrap_or(true),
229                })
230            }
231            ComboKind::NavigationPacket => {
232                let tool = require_text(
233                    raw.tool.as_deref(),
234                    kind,
235                    "tool",
236                    "set tool to callers, impact, explain, neighbors, or paths",
237                )?;
238                Ok(Self::NavigationPacket {
239                    tool,
240                    query: require_query(raw, kind)?,
241                    expected_route: raw.expected_focus_route.clone(),
242                    expected_nodes: raw.expect.clone(),
243                    expected_partition: raw.expected_partition.clone(),
244                    expected_pass: raw.expected_pass.unwrap_or(true),
245                })
246            }
247            ComboKind::GlyphNavigation => Ok(Self::GlyphNavigation {
248                query: require_query(raw, kind)?,
249                expected_pass: raw.expected_pass.unwrap_or(true),
250            }),
251        }
252    }
253}
254
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub enum ComboSpecError {
257    Invalid(ComboValidationError),
258    Unsupported(String),
259}
260
261#[derive(Clone, Debug, PartialEq, Eq)]
262pub struct ComboValidationError {
263    pub combo: String,
264    pub field: String,
265    pub reason: String,
266    pub suggested_fix: String,
267}
268
269pub fn classify_combo_case(raw: &GoldenCase) -> Result<ComboSpec, ComboSpecError> {
270    ComboSpec::from_golden(raw)
271}
272
273fn require_query(raw: &GoldenCase, kind: ComboKind) -> Result<String, ComboSpecError> {
274    require_text(
275        Some(raw.query.as_str()),
276        kind,
277        "query",
278        "set query to the agent request this combo should exercise",
279    )
280}
281
282fn require_text(
283    value: Option<&str>,
284    kind: ComboKind,
285    field: &'static str,
286    suggested_fix: &'static str,
287) -> Result<String, ComboSpecError> {
288    value
289        .map(str::trim)
290        .filter(|value| !value.is_empty())
291        .map(ToOwned::to_owned)
292        .ok_or_else(|| validation_error(kind, field, format!("missing {field}"), suggested_fix))
293}
294
295fn optional_confidence(
296    raw: &GoldenCase,
297    kind: ComboKind,
298    default: &'static str,
299) -> Result<(String, Confidence), ComboSpecError> {
300    let text = raw
301        .min_confidence
302        .as_deref()
303        .map(str::trim)
304        .filter(|text| !text.is_empty())
305        .unwrap_or(default);
306    let confidence = parse_confidence(text).ok_or_else(|| {
307        validation_error(
308            kind,
309            "min_confidence",
310            format!("unknown min_confidence '{text}'"),
311            "set min_confidence to one of exact, strong, ambiguous, weak, fallback",
312        )
313    })?;
314    Ok((text.to_string(), confidence))
315}
316
317fn parse_confidence(value: &str) -> Option<Confidence> {
318    match value.trim().to_ascii_lowercase().as_str() {
319        "exact" => Some(Confidence::Exact),
320        "strong" => Some(Confidence::Strong),
321        "ambiguous" => Some(Confidence::Ambiguous),
322        "weak" => Some(Confidence::Weak),
323        "fallback" => Some(Confidence::Fallback),
324        _ => None,
325    }
326}
327
328fn validation_error(
329    kind: ComboKind,
330    field: impl Into<String>,
331    reason: impl Into<String>,
332    suggested_fix: impl Into<String>,
333) -> ComboSpecError {
334    ComboSpecError::Invalid(ComboValidationError {
335        combo: kind.label().to_string(),
336        field: field.into(),
337        reason: reason.into(),
338        suggested_fix: suggested_fix.into(),
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn combo_spec_requires_vocab_loop_expected_target() {
348        let err = ComboSpec::from_golden(&GoldenCase {
349            combo: Some("vocab_loop".to_string()),
350            query: "what happens to failed messages".to_string(),
351            ..Default::default()
352        })
353        .unwrap_err();
354
355        let ComboSpecError::Invalid(err) = err else {
356            panic!("expected invalid combo spec");
357        };
358        assert_eq!(err.combo, "vocab_loop");
359        assert_eq!(err.field, "expect");
360        assert!(err.suggested_fix.contains("node id"));
361    }
362
363    #[test]
364    fn combo_spec_parses_vocab_loop_target_node() {
365        let spec = ComboSpec::from_golden(&GoldenCase {
366            combo: Some("vocab_loop".to_string()),
367            query: "what happens to failed messages".to_string(),
368            expect: vec!["doc.dlq".to_string()],
369            target_node: Some("doc.dlq".to_string()),
370            ..Default::default()
371        })
372        .unwrap();
373
374        assert_eq!(
375            spec,
376            ComboSpec::VocabLoop {
377                query: "what happens to failed messages".to_string(),
378                expected_top: "doc.dlq".to_string(),
379                target_node: Some("doc.dlq".to_string()),
380                min_band_text: "strong".to_string(),
381                min_band: Confidence::Strong,
382                expected_pass: true,
383            }
384        );
385    }
386
387    #[test]
388    fn combo_spec_parses_ground_gate_defaults() {
389        let spec = ComboSpec::from_golden(&GoldenCase {
390            combo: Some("ground_gate".to_string()),
391            query: "ground me".to_string(),
392            ..Default::default()
393        })
394        .unwrap();
395
396        assert_eq!(
397            spec,
398            ComboSpec::GroundGate {
399                query: "ground me".to_string(),
400                min_band_text: "weak".to_string(),
401                min_band: Confidence::Weak,
402                expected_pass: true,
403            }
404        );
405    }
406
407    #[test]
408    fn combo_spec_parses_vyre_queue() {
409        let spec = ComboSpec::from_golden(&GoldenCase {
410            combo: Some("vyre_queue".to_string()),
411            query: "quartz semaphore scheduling covenant".to_string(),
412            ..Default::default()
413        })
414        .unwrap();
415
416        assert_eq!(
417            spec,
418            ComboSpec::VyreQueue {
419                query: "quartz semaphore scheduling covenant".to_string(),
420                expected_pass: true,
421            }
422        );
423    }
424
425    #[test]
426    fn combo_spec_parses_ryn_reconcile() {
427        let spec = ComboSpec::from_golden(&GoldenCase {
428            combo: Some("ryn_reconcile".to_string()),
429            query: "legacy retrieval".to_string(),
430            ..Default::default()
431        })
432        .unwrap();
433
434        assert_eq!(
435            spec,
436            ComboSpec::RynReconcile {
437                query: "legacy retrieval".to_string(),
438                expected_pass: true,
439            }
440        );
441    }
442
443    #[test]
444    fn combo_spec_parses_aun_repair() {
445        let spec = ComboSpec::from_golden(&GoldenCase {
446            combo: Some("aun_repair".to_string()),
447            query: "duplicate query example repair".to_string(),
448            ..Default::default()
449        })
450        .unwrap();
451
452        assert_eq!(
453            spec,
454            ComboSpec::AunRepair {
455                query: "duplicate query example repair".to_string(),
456                expected_pass: true,
457            }
458        );
459    }
460
461    #[test]
462    fn combo_spec_parses_glyph_navigation() {
463        let spec = ComboSpec::from_golden(&GoldenCase {
464            combo: Some("glyph_navigation".to_string()),
465            query: "retry payment callers precision".to_string(),
466            expected_pass: Some(false),
467            ..Default::default()
468        })
469        .unwrap();
470
471        assert_eq!(
472            spec,
473            ComboSpec::GlyphNavigation {
474                query: "retry payment callers precision".to_string(),
475                expected_pass: false,
476            }
477        );
478    }
479}