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}