use mem_llm::{EvidenceLabel, make_label_prompt, parse_label_response, fits_context_budget}; /// M5.1 Integration Tests — Evidence Labeler /// /// Verifies the evidence labeling pipeline: /// - Labels keyed by chunk_sha (survives re-chunking) /// - Context budget checked (16384 token limit) /// - Justifications preserved for calibration /// - Prompts have no tools (reasoning model requirement) #[test] fn a1_one_label_per_chunk() { let chunks = vec![ "chunk1", "chunk2", "chunk3", "chunk4", "chunk5" ]; let labels: Vec = chunks .iter() .enumerate() .map(|(i, sha)| { EvidenceLabel::new( sha.to_string(), i, i % 2 == 0, // Alternate yes/no "Test justification".to_string(), ) }) .collect(); assert_eq!(labels.len(), 5, "One label per chunk"); assert!(labels.iter().all(|l| !l.chunk_sha.is_empty()), "All have sha"); // No duplicates let mut shas = labels.iter().map(|l| &l.chunk_sha).collect::>(); let original_len = shas.len(); shas.sort(); shas.dedup(); assert_eq!(shas.len(), original_len, "No duplicate labels"); } #[test] fn a2_keyed_by_sha() { // Labels are keyed on chunk_sha, not turn number let label1 = EvidenceLabel::new( "abc123".to_string(), 7, true, "Contains evidence".to_string(), ); let label2 = EvidenceLabel::new( "abc123".to_string(), 5, // Different turn number true, "Contains evidence".to_string(), ); // Same chunk_sha = same label, regardless of turn order assert_eq!(label1.chunk_sha, label2.chunk_sha); // (In practice, we'd deduplicate by sha) } #[test] fn a3_context_budget_respected() { let question = "What causes the timeout?"; let chunk = "The database query lacks an index, causing sequential scans that take 30 seconds."; let prompt = make_label_prompt(question, chunk); // Should fit in reasoning model's 16384 limit assert!(fits_context_budget(&prompt, 64, 16384), "Reasonable chunk should fit"); } #[test] fn a4_justification_kept() { let responses = vec![ "yes\nThis chunk directly answers the question about timeouts.", "no\nThis chunk discusses unrelated infrastructure.", ]; for response in responses { let (label, why) = parse_label_response(response) .expect("Should parse label response"); assert!(!why.is_empty(), "Justification should be preserved"); assert!(why.len() > 10, "Justification should be a full sentence"); } } #[test] fn a5_label_structure() { let label = EvidenceLabel::new( "sha256abc".to_string(), 12, true, "This chunk contains the evidence".to_string(), ); assert_eq!(label.chunk_sha, "sha256abc"); assert_eq!(label.t, 12); assert!(label.label); assert_eq!(label.why, "This chunk contains the evidence"); assert_eq!(label.model, "reasoning"); assert!(!label.ts.is_empty()); } #[test] fn a6_prompt_no_tools_field() { let prompt = make_label_prompt( "What is the issue?", "The service is down.", ); // Reasoning model rejects tools - prompt should never contain them assert!( !prompt.contains("tools"), "Labeling prompt must not include tools field" ); assert!( !prompt.contains("function_calls"), "Labeling prompt must not include function calls" ); } #[test] fn a7_parsing_handles_variations() { let variations = vec![ ("YES\nThis is evidence", true), ("no\nThis is not evidence", false), ("Yes, definitely\nEvidence present", true), ("No, unrelated", false), ]; for (response, expected_label) in variations { let (label, why) = parse_label_response(response) .expect("Should parse"); assert_eq!(label, expected_label); assert!(!why.is_empty()); } } #[test] fn a8_empty_prompt_safe() { let prompt = make_label_prompt("", ""); // Should still be valid (just asking labeler to work with nothing) assert!(!prompt.is_empty()); } #[test] fn a9_large_chunk_exceeds_budget() { let question = "What happened?"; let huge_chunk = "x".repeat(100000); let prompt = make_label_prompt(question, &huge_chunk); // Should NOT fit in context assert!(!fits_context_budget(&prompt, 64, 16384)); } #[test] fn a10_label_rate_summary() { let labels = vec![ EvidenceLabel::new("a".to_string(), 1, true, "yes".to_string()), EvidenceLabel::new("b".to_string(), 2, false, "no".to_string()), EvidenceLabel::new("c".to_string(), 3, true, "yes".to_string()), EvidenceLabel::new("d".to_string(), 4, false, "no".to_string()), EvidenceLabel::new("e".to_string(), 5, true, "yes".to_string()), ]; let positive = labels.iter().filter(|l| l.label).count(); let rate = positive as f32 / labels.len() as f32; assert_eq!(positive, 3, "3 out of 5 labeled as evidence"); assert!((rate - 0.6).abs() < 0.01, "Label rate should be 60%"); } #[test] fn a11_evidence_label_serde() { let label = EvidenceLabel::new( "abc123def456".to_string(), 7, true, "Contains direct evidence of the bug".to_string(), ); // Should be serializable to JSON (for JSONL output) let json = serde_json::to_string(&label) .expect("Should serialize"); assert!(json.contains("abc123def456")); assert!(json.contains("true")); assert!(json.contains("evidence")); // Should deserialize back let deserialized: EvidenceLabel = serde_json::from_str(&json) .expect("Should deserialize"); assert_eq!(deserialized.chunk_sha, label.chunk_sha); assert_eq!(deserialized.label, label.label); }