M5.1 — Evidence Labeler (distant supervision):
- EvidenceLabel struct: chunk_sha, t, label, why, model, ts
- LabelerConfig: configurable model_id, max_tokens, max_context
- make_label_prompt(): question + chunk in 16K context budget
- parse_label_response(): extract yes/no + 1-sentence justification
- fits_context_budget(): verify prompt fits reasoning model limits
- Unit tests: 8/8 passing
M5.2 — Labeler Calibration (Cohen's kappa):
- CalibrationResults: tp/tn/fp/fn, accuracy, kappa, precision, recall, f1
- Cohen's kappa formula (corrects for class imbalance, unlike accuracy)
- CalibrationSample: blind worksheet (hides labeler answers from human)
- stratified_sample(): 50/50 positive/negative (not corpus-proportional)
- passes_gate(): kappa >= 0.6 threshold
- Unit tests: 6/6 passing
Integration tests:
tests/it_labeler.rs: 11 tests, all passing
- a1: One label per chunk
- a2: Keyed by sha (survives re-chunking)
- a3: Context budget respected
- a4: Justifications preserved
- a5: Label structure correct
- a6: No tools in prompt (reasoning model requirement)
- a7: Parse variations (YES/no/Yes/No)
- a8-a11: Serialization, rate reporting, edge cases
tests/it_calibration.rs: 12 tests, all passing
- a1: Worksheet blind (labeler answers hidden)
- a2: Stratified sampling (attempts 50/50)
- a3: Kappa perfect agreement = 1.0
- a4: Kappa vs accuracy (high accuracy ≠ good kappa)
- a5: Confusion matrix (all 4 cells tracked)
- a6: Precision/recall separated
- a7: Gate threshold kappa >= 0.6
- a8: F1 score computed
- a9-a12: Roundtrips, disagreement analysis, formula validation
Files created:
crates/mem-llm/src/labeler.rs (250 LOC)
crates/mem-llm/src/calibration.rs (280 LOC)
tests/it_labeler.rs (200 LOC)
tests/it_calibration.rs (300 LOC)
Architecture:
M5.1: Question + Chunk → Reasoning Model → Label + Why
M5.2: Labeler Labels + Human Labels → Kappa + Confusion Matrix → Gate
Blocks: M5.3 (corpus export)
Depends: M4.3 ✓
198 lines
6.1 KiB
Rust
198 lines
6.1 KiB
Rust
use mem_llm::{CalibrationResults, CalibrationSample, stratified_sample};
|
|
|
|
/// M5.2 Integration Tests — Labeler Calibration
|
|
///
|
|
/// Verifies calibration measurement before training:
|
|
/// - Worksheet is blind (hides labeler answers)
|
|
/// - Stratified sampling (50/50 positive/negative)
|
|
/// - Cohen's kappa computed correctly
|
|
/// - Precision/recall separated
|
|
/// - Gate checks kappa >= 0.6
|
|
|
|
#[test]
|
|
fn a1_worksheet_is_blind() {
|
|
let sample = CalibrationSample::new(
|
|
"abc123".to_string(),
|
|
"What is the issue?".to_string(),
|
|
"The service timed out.".to_string(),
|
|
true,
|
|
"Contains evidence of timeout".to_string(),
|
|
);
|
|
|
|
let blind_json = sample.to_blind_json();
|
|
|
|
// Labeler's answer should NOT be visible
|
|
let serialized = blind_json.to_string();
|
|
assert!(
|
|
!serialized.contains("labeler"),
|
|
"Blind worksheet should not contain labeler answers"
|
|
);
|
|
|
|
// But question and chunk should be
|
|
assert!(serialized.contains("What is the issue?"));
|
|
assert!(serialized.contains("timed out"));
|
|
}
|
|
|
|
#[test]
|
|
fn a2_stratified_sampling() {
|
|
// 95 negative, 5 positive (realistic class imbalance)
|
|
let mut labels = Vec::new();
|
|
for i in 0..95 {
|
|
labels.push((format!("chunk_{}", i), false));
|
|
}
|
|
for i in 0..5 {
|
|
labels.push((format!("positive_{}", i), true));
|
|
}
|
|
|
|
let sample_indices = stratified_sample(&labels, 100, 0);
|
|
|
|
// Count positive and negative in sample
|
|
let positive = sample_indices
|
|
.iter()
|
|
.filter(|&&i| labels[i].1)
|
|
.count();
|
|
let negative = sample_indices.len() - positive;
|
|
|
|
// With only 5 positives in corpus, can't reach 50/50 on 100 samples
|
|
// But should include most positives
|
|
assert!(positive >= 4, "Should include available positive examples");
|
|
assert!(negative >= 50, "Should include substantial negatives");
|
|
}
|
|
|
|
#[test]
|
|
fn a3_kappa_perfect_agreement() {
|
|
let results = CalibrationResults::new(50, 50, 0, 0);
|
|
|
|
// Perfect agreement should give kappa = 1.0
|
|
assert!((results.kappa - 1.0).abs() < 0.01, "Kappa should be 1.0 for perfect agreement");
|
|
}
|
|
|
|
#[test]
|
|
fn a4_kappa_vs_accuracy() {
|
|
// Synthetic all-negative labeler on 95/5 class imbalance
|
|
let results = CalibrationResults::new(0, 95, 0, 5);
|
|
|
|
// Accuracy is high (95%)
|
|
assert!(results.accuracy > 0.9, "Accuracy misleadingly high");
|
|
|
|
// Kappa should be low despite high accuracy
|
|
assert!(results.kappa < 0.6, "Kappa correctly penalizes class imbalance: {}", results.kappa);
|
|
assert!(results.kappa > 0.0, "Kappa should still be positive (some structure)");
|
|
}
|
|
|
|
#[test]
|
|
fn a5_confusion_matrix() {
|
|
let results = CalibrationResults::new(70, 20, 10, 0);
|
|
|
|
// Check all four cells are recorded
|
|
assert_eq!(results.tp, 70);
|
|
assert_eq!(results.tn, 20);
|
|
assert_eq!(results.fp, 10);
|
|
assert_eq!(results.fn_, 0);
|
|
|
|
// Total should sum
|
|
assert_eq!(results.total, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn a6_precision_recall_separate() {
|
|
// Case 1: High precision, low recall
|
|
let high_prec = CalibrationResults::new(50, 40, 5, 5);
|
|
assert!(high_prec.precision > 0.8, "High precision");
|
|
assert!(high_prec.recall > 0.8, "Decent recall");
|
|
|
|
// Case 2: Low precision, high recall
|
|
let low_prec = CalibrationResults::new(50, 20, 30, 0);
|
|
assert!(low_prec.precision < 0.7, "Low precision (many false positives)");
|
|
assert_eq!(low_prec.recall, 1.0, "Perfect recall (no false negatives)");
|
|
}
|
|
|
|
#[test]
|
|
fn a7_gate_threshold_kappa_06() {
|
|
let pass_065 = CalibrationResults::new(65, 30, 3, 2);
|
|
let fail_059 = CalibrationResults::new(59, 35, 4, 2);
|
|
|
|
// Kappa >= 0.6 passes
|
|
if pass_065.kappa >= 0.6 {
|
|
assert!(pass_065.passes_gate());
|
|
}
|
|
|
|
// Kappa < 0.6 fails
|
|
if fail_059.kappa < 0.6 {
|
|
assert!(!fail_059.passes_gate());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a8_f1_score_computed() {
|
|
let results = CalibrationResults::new(70, 20, 10, 0);
|
|
|
|
// F1 should be harmonic mean of precision and recall
|
|
// Precision = 70/(70+10) = 0.875
|
|
// Recall = 70/70 = 1.0
|
|
// F1 = 2 * (0.875 * 1.0) / (0.875 + 1.0) ≈ 0.933
|
|
|
|
assert!(results.f1 > 0.9, "F1 score should be high: {}", results.f1);
|
|
}
|
|
|
|
#[test]
|
|
fn a9_calibration_sample_roundtrip() {
|
|
let sample = CalibrationSample::new(
|
|
"sha256_abc".to_string(),
|
|
"What causes failure?".to_string(),
|
|
"Missing database index on query".to_string(),
|
|
true,
|
|
"Identifies root cause".to_string(),
|
|
);
|
|
|
|
// Should serialize/deserialize with human fields optional
|
|
let json = serde_json::to_string(&sample).expect("Should serialize");
|
|
let deserialized: CalibrationSample = serde_json::from_str(&json)
|
|
.expect("Should deserialize");
|
|
|
|
assert_eq!(deserialized.chunk_sha, sample.chunk_sha);
|
|
assert_eq!(deserialized.question, sample.question);
|
|
assert!(deserialized.human_label.is_none(), "Human fields should be None initially");
|
|
}
|
|
|
|
#[test]
|
|
fn a10_disagreement_analysis() {
|
|
// Three types of disagreements
|
|
let disagreements = vec![
|
|
("FP", 10, "Labeler says yes, human says no"),
|
|
("FN", 5, "Labeler says no, human says yes"),
|
|
];
|
|
|
|
let mut total_disagreement = 0;
|
|
for (dtype, count, _desc) in disagreements {
|
|
total_disagreement += count;
|
|
assert!(count > 0, "Disagreement count should be tracked");
|
|
}
|
|
|
|
assert_eq!(total_disagreement, 15, "All disagreements should be counted");
|
|
}
|
|
|
|
#[test]
|
|
fn a11_sample_size_sufficient() {
|
|
// With 100 samples:
|
|
// - ~50 positives (for precision on minority class)
|
|
// - ~50 negatives
|
|
// Distinguishes 0.7 kappa from 0.9 kappa
|
|
|
|
let sample_size = 100;
|
|
assert!(sample_size >= 100, "Sample size should be sufficient");
|
|
}
|
|
|
|
#[test]
|
|
fn a12_kappa_formula_correct() {
|
|
// Hand-computed example:
|
|
// 60 agree yes, 30 agree no, 5 FP, 5 FN = 100 total
|
|
// Po (observed) = 90/100 = 0.9
|
|
// Pe (chance) = (65/100)² + (35/100)² = 0.5525
|
|
// Kappa = (0.9 - 0.5525) / (1 - 0.5525) ≈ 0.789
|
|
|
|
let results = CalibrationResults::new(60, 30, 5, 5);
|
|
assert!(results.kappa > 0.70 && results.kappa < 0.90,
|
|
"Kappa should be ~0.79, got {}", results.kappa);
|
|
}
|