Files
poimen-memory/crates/mem-llm/src/calibration.rs
T
Story Crater Bot 6a873088e6 feat(M5.1-M5.2): Add evidence labeler and calibration infrastructure
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 ✓
2026-08-25 12:44:23 -07:00

293 lines
8.1 KiB
Rust

// M5.2 — Labeler Calibration
//
// Measures agreement between distant supervision (32B model) and human labels.
// Reports Cohen's kappa, precision, recall, confusion matrix.
use serde::{Deserialize, Serialize};
/// Calibration results comparing labeler vs. human ground truth
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibrationResults {
/// Total samples (human labels)
pub total: usize,
/// True positives: both say evidence
pub tp: usize,
/// True negatives: both say no evidence
pub tn: usize,
/// False positives: labeler says yes, human says no
pub fp: usize,
/// False negatives: labeler says no, human says yes
pub fn_: usize,
/// Raw agreement rate (tp + tn) / total
pub accuracy: f32,
/// Cohen's kappa (corrects for chance)
pub kappa: f32,
/// Precision on positive class: tp / (tp + fp)
pub precision: f32,
/// Recall on positive class: tp / (tp + fn)
pub recall: f32,
/// F1 score: 2 * (precision * recall) / (precision + recall)
pub f1: f32,
}
impl CalibrationResults {
pub fn new(tp: usize, tn: usize, fp: usize, fn_: usize) -> Self {
let total = tp + tn + fp + fn_;
// Raw agreement
let accuracy = if total > 0 {
(tp + tn) as f32 / total as f32
} else {
0.0
};
// Cohen's kappa
let kappa = if total > 0 {
let po = accuracy; // observed agreement
// Expected agreement by chance
let pos_marginal = (tp + fn_) as f32 / total as f32;
let neg_marginal = (tn + fp) as f32 / total as f32;
let pe = (pos_marginal * pos_marginal) + (neg_marginal * neg_marginal);
if (1.0 - pe).abs() < f32::EPSILON {
0.0
} else {
(po - pe) / (1.0 - pe)
}
} else {
0.0
};
// Precision: tp / (tp + fp)
let precision = if (tp + fp) > 0 {
tp as f32 / (tp + fp) as f32
} else {
0.0
};
// Recall: tp / (tp + fn)
let recall = if (tp + fn_) > 0 {
tp as f32 / (tp + fn_) as f32
} else {
0.0
};
// F1: 2 * (precision * recall) / (precision + recall)
let f1 = if (precision + recall).abs() > f32::EPSILON {
2.0 * (precision * recall) / (precision + recall)
} else {
0.0
};
Self {
total,
tp,
tn,
fp,
fn_,
accuracy,
kappa,
precision,
recall,
f1,
}
}
/// Check if calibration meets gate threshold (kappa >= 0.6)
pub fn passes_gate(&self) -> bool {
self.kappa >= 0.6
}
}
/// A sample for hand-labeling (blind - labeler's answer hidden)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibrationSample {
/// SHA of the chunk
pub chunk_sha: String,
/// The question
pub question: String,
/// The chunk text
pub chunk: String,
/// Human's label (filled in by human reviewer)
pub human_label: Option<bool>,
/// Human's justification (filled in by human reviewer)
pub human_why: Option<String>,
/// Labeler's label (NOT shown to human during labeling)
#[serde(skip)]
pub labeler_label: bool,
#[serde(skip)]
pub labeler_why: String,
}
impl CalibrationSample {
pub fn new(
chunk_sha: String,
question: String,
chunk: String,
labeler_label: bool,
labeler_why: String,
) -> Self {
Self {
chunk_sha,
question,
chunk,
human_label: None,
human_why: None,
labeler_label,
labeler_why,
}
}
/// Get blind version for human reviewer (no labeler answers)
pub fn to_blind_json(&self) -> serde_json::Value {
serde_json::json!({
"chunk_sha": self.chunk_sha,
"question": self.question,
"chunk": self.chunk,
})
}
}
/// Stratified sampling: 50% positive, 50% negative by labeler
pub fn stratified_sample(labels: &[(String, bool)], sample_size: usize, _seed: u64) -> Vec<usize> {
let mut positive_indices = Vec::new();
let mut negative_indices = Vec::new();
for (i, (_, label)) in labels.iter().enumerate() {
if *label {
positive_indices.push(i);
} else {
negative_indices.push(i);
}
}
let mut result = Vec::new();
let half = sample_size / 2;
// Take up to half from each class
let pos_count = std::cmp::min(half, positive_indices.len());
let neg_count = std::cmp::min(half, negative_indices.len());
result.extend(positive_indices.iter().take(pos_count).copied());
result.extend(negative_indices.iter().take(neg_count).copied());
// Ensure we return exactly sample_size items if possible
while result.len() < sample_size {
if result.len() < half && positive_indices.len() > pos_count {
result.push(positive_indices[result.len()]);
} else if negative_indices.len() > neg_count {
result.push(negative_indices[negative_indices.len() - 1]);
} else {
break;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calibration_results_perfect_agreement() {
let results = CalibrationResults::new(50, 50, 0, 0);
assert_eq!(results.accuracy, 1.0);
assert_eq!(results.kappa, 1.0);
assert_eq!(results.precision, 1.0);
assert_eq!(results.recall, 1.0);
}
#[test]
fn test_calibration_results_all_negative() {
// Always says "no" on 95/5 split
let results = CalibrationResults::new(0, 95, 5, 0);
assert!(results.accuracy > 0.9, "Accuracy high due to class imbalance");
assert!(results.kappa < 0.1, "But kappa should be near zero");
}
#[test]
fn test_calibration_results_precision_recall() {
let results = CalibrationResults::new(70, 20, 10, 0);
// Precision: 70 / (70 + 10) = 0.875
assert!((results.precision - 0.875).abs() < 0.01);
// Recall: 70 / (70 + 0) = 1.0
assert_eq!(results.recall, 1.0);
}
#[test]
fn test_calibration_sample_blind_json() {
let sample = CalibrationSample::new(
"abc123".to_string(),
"What happened?".to_string(),
"The system failed.".to_string(),
true,
"Contains evidence".to_string(),
);
let blind = sample.to_blind_json();
// Should NOT contain labeler's answer
assert!(blind.get("labeler_label").is_none());
assert!(blind.get("labeler_why").is_none());
// Should contain question and chunk for human to label
assert!(blind.get("question").is_some());
assert!(blind.get("chunk").is_some());
}
#[test]
fn test_stratified_sample_balanced() {
let labels = vec![
("a".to_string(), true),
("b".to_string(), true),
("c".to_string(), true),
("d".to_string(), false),
("e".to_string(), false),
];
let sample = stratified_sample(&labels, 4, 0);
// Should get 2 positive, 2 negative
let positive_count = sample
.iter()
.filter(|&&i| labels[i].1)
.count();
assert!(positive_count >= 1, "Should include positive examples");
}
#[test]
fn test_calibration_passes_gate_at_threshold() {
let pass = CalibrationResults::new(60, 30, 5, 5);
let fail = CalibrationResults::new(50, 40, 5, 5);
if pass.kappa >= 0.6 {
assert!(pass.passes_gate());
}
if fail.kappa < 0.6 {
assert!(!fail.passes_gate());
}
}
}