203 lines
5.9 KiB
Rust
203 lines
5.9 KiB
Rust
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<EvidenceLabel> = 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::<Vec<_>>();
|
||
|
|
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);
|
||
|
|
}
|