Skip to main content

wm_memory/
recall_conformal.rs

1//! Conformal prediction sets for retrieval (V8 S8).
2//!
3//! Wraps `RecallEngine` result truncation with split-conformal sets so
4//! recall results can carry calibrated coverage — "these N results cover
5//! at 90%" — instead of an uncalibrated ranked list. The math reuses
6//! `wm_conformal::SplitConformalClassifier` with a binary relevance
7//! encoding: a candidate's conformity for "relevant" is its fused score
8//! `s`, for "not-relevant" it is `1 − s`, so set membership is
9//! `s ≥ 1 − q̂` with `q̂` the calibrated quantile.
10//!
11//! Evidence-gated like every retrieval knob: `WM_RECALL_CONFORMAL_ALPHA`
12//! unset (the default) leaves the engine byte-identical. When set but not
13//! yet fitted, the disclosure says `uncalibrated` — a silent zero-coverage
14//! claim would violate the substrate-honesty principle.
15//!
16//! Calibration labels come from explicit relevance feedback
17//! (`record_feedback`); the engine persists the fitted classifier to
18//! `<store_root>/recall_conformal.json` write-through so daemon restarts
19//! keep their calibration.
20
21use crate::store::MemoryStore;
22use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24use std::sync::Arc;
25use wm_conformal::SplitConformalClassifier;
26
27/// Where the calibrated state lives, relative to the store root.
28const STATE_FILE: &str = "recall_conformal.json";
29
30/// Minimum calibration samples before `fit` produces a threshold.
31pub const MIN_SAMPLES: usize = 10;
32
33/// Conformal state for one recall engine.
34pub struct RecallConformal {
35    alpha: f32,
36    classifier: SplitConformalClassifier,
37    store: Arc<MemoryStore>,
38}
39
40impl RecallConformal {
41    /// Construct with a validated miscoverage level (0 < alpha < 1).
42    pub fn new(alpha: f32, store: Arc<MemoryStore>) -> Option<Self> {
43        if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
44            return None;
45        }
46        // Load persisted state when present so a restarted daemon keeps
47        // its calibration; a fresh store starts unfitted.
48        let path = state_path(&store);
49        let classifier = std::fs::read_to_string(&path)
50            .ok()
51            .and_then(|body| SplitConformalClassifier::from_json(&body).ok())
52            .filter(|c| (c.alpha() - f64::from(alpha)).abs() < 1e-9)
53            .unwrap_or_else(|| {
54                SplitConformalClassifier::new(f64::from(alpha)).expect("alpha validated above")
55            });
56        Some(Self {
57            alpha,
58            classifier,
59            store,
60        })
61    }
62
63    /// The miscoverage level.
64    #[must_use]
65    pub const fn alpha(&self) -> f32 {
66        self.alpha
67    }
68
69    /// Number of calibration samples recorded (across restarts).
70    #[must_use]
71    pub fn sample_count(&self) -> usize {
72        self.classifier.sample_count()
73    }
74
75    /// Whether a calibrated threshold is in effect.
76    #[must_use]
77    pub const fn is_fitted(&self) -> bool {
78        self.classifier.threshold().is_some()
79    }
80
81    /// Record one relevance-feedback sample and refit write-through.
82    /// Returns the sample count after the update.
83    ///
84    /// The two-class encoding: conformity for "relevant" is the score,
85    /// for "not-relevant" its complement — so a high fused score is strong
86    /// evidence for membership in the relevant set.
87    pub fn record_feedback(&mut self, score: f32, relevant: bool) -> usize {
88        let s = f64::from(score.clamp(0.0, 1.0));
89        let scores = [1.0 - s, s];
90        let label = usize::from(relevant);
91        // Duplicate near-identical scores are information-free — skip them
92        // so the calibration set does not fill with one query's shape.
93        if let Err(e) = self.classifier.add_sample(&scores, label) {
94            tracing::debug!(error = %e, "recall conformal: sample rejected");
95            return self.classifier.sample_count();
96        }
97        if self.classifier.sample_count() >= MIN_SAMPLES {
98            if let Err(e) = self.classifier.fit() {
99                tracing::debug!(error = %e, "recall conformal: fit failed");
100            } else {
101                self.persist();
102            }
103        }
104        self.classifier.sample_count()
105    }
106
107    /// Is this fused score inside the calibrated prediction set?
108    /// `None` when not yet fitted (callers must disclose that, not guess).
109    #[must_use]
110    pub fn membership(&self, score: f32) -> Option<bool> {
111        let q = self.classifier.threshold()?;
112        let s = f64::from(score.clamp(0.0, 1.0));
113        // predict_set semantics: class i is in the set iff 1 − score_i ≤ q.
114        // Class 1 is "relevant": membership iff s ≥ 1 − q.
115        Some(1.0 - s <= q)
116    }
117
118    /// The calibrated threshold (fused-score floor for set membership).
119    #[must_use]
120    pub const fn threshold(&self) -> Option<f64> {
121        self.classifier.threshold()
122    }
123
124    fn persist(&self) {
125        let path = state_path(&self.store);
126        match self.classifier.to_json() {
127            Ok(body) => {
128                if let Some(parent) = path.parent() {
129                    let _ = std::fs::create_dir_all(parent);
130                }
131                if let Err(e) = std::fs::write(&path, body) {
132                    tracing::warn!(error = %e, path = %path.display(), "recall conformal: persist failed");
133                }
134            }
135            Err(e) => {
136                tracing::warn!(error = %e, "recall conformal: serialize failed");
137            }
138        }
139    }
140}
141
142fn state_path(store: &MemoryStore) -> PathBuf {
143    store
144        .path()
145        .parent()
146        .map_or_else(|| PathBuf::from(STATE_FILE), |root| root.join(STATE_FILE))
147}
148
149/// Set-level disclosure attached to a hybrid search when conformal mode
150/// is configured.
151///
152/// `status` is honest about what the coverage claim is backed by:
153/// `active` (fitted), `uncalibrated` (alpha set, too few samples), or
154/// `off` (knob unset — no claim is made at all).
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct ConformalSetInfo {
157    pub status: String,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub alpha: Option<f64>,
160    /// `1 − alpha` — the coverage the guarantee targets when active.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub coverage_target: Option<f64>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub calibration_samples: Option<usize>,
165    /// Fused-score floor for set membership when active.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub threshold: Option<f64>,
168    /// Results inside the set when active.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub set_size: Option<usize>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub hint: Option<String>,
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn test_store() -> Arc<MemoryStore> {
180        let dir = tempfile::tempdir().unwrap();
181        let path = dir.path().join("lmdb");
182        std::fs::create_dir_all(&path).unwrap();
183        // Leak-free temp store: the state file writes land in the tempdir.
184        Arc::new(MemoryStore::open(path, 1024 * 1024).unwrap())
185    }
186
187    #[test]
188    fn invalid_alpha_is_refused_not_silently_clamped() {
189        let store = test_store();
190        assert!(RecallConformal::new(0.0, store.clone()).is_none());
191        assert!(RecallConformal::new(1.0, store.clone()).is_none());
192        assert!(RecallConformal::new(f32::NAN, store).is_none());
193    }
194
195    #[test]
196    fn unfitted_membership_is_none_not_a_guess() {
197        let store = test_store();
198        let rc = RecallConformal::new(0.1, store).unwrap();
199        assert!(!rc.is_fitted());
200        assert!(rc.membership(0.9).is_none());
201    }
202
203    #[test]
204    fn feedback_below_min_samples_stays_uncalibrated() {
205        let store = test_store();
206        let mut rc = RecallConformal::new(0.1, store).unwrap();
207        for i in 0..MIN_SAMPLES - 1 {
208            let n = rc.record_feedback(0.5 + i as f32 / 100.0, true);
209            assert_eq!(n, i + 1);
210        }
211        assert!(!rc.is_fitted(), "MIN_SAMPLES-1 must not fit");
212    }
213
214    #[test]
215    fn calibration_reaches_fit_and_membership_follows_threshold() {
216        let store = test_store();
217        let mut rc = RecallConformal::new(0.1, store).unwrap();
218        // Relevant samples score high; irrelevant low — the quantile of
219        // the relevant nonconformities lands somewhere in (0, 0.25).
220        for i in 0..MIN_SAMPLES {
221            rc.record_feedback(0.90 - i as f32 / 100.0, true);
222            rc.record_feedback(0.10 + i as f32 / 100.0, false);
223        }
224        assert!(rc.is_fitted());
225        let q = rc.threshold().expect("fitted");
226        assert!(
227            q > 0.0 && q < 0.25,
228            "threshold {q} should track relevant scores"
229        );
230        assert_eq!(rc.membership(0.95), Some(true), "high score in set");
231        assert_eq!(rc.membership(0.05), Some(false), "low score out of set");
232        // Status at the fence: 1 − q with q ∈ (0, 0.25) → fence in (0.75, 1.0).
233        let fence = (1.0 - q) as f32;
234        assert_eq!(rc.membership(fence), Some(true));
235    }
236
237    #[test]
238    fn state_persists_across_reconstruction() {
239        let store = test_store();
240        {
241            let mut rc = RecallConformal::new(0.2, store.clone()).unwrap();
242            for i in 0..MIN_SAMPLES + 5 {
243                rc.record_feedback(0.80 + (i % 7) as f32 / 100.0, i % 3 != 0);
244            }
245            assert!(rc.is_fitted());
246        }
247        // Reconstruct from the same store: calibration survives.
248        let rc2 = RecallConformal::new(0.2, store).unwrap();
249        assert!(rc2.is_fitted(), "persisted classifier reloads fitted");
250        assert!(rc2.sample_count() >= MIN_SAMPLES);
251    }
252
253    #[test]
254    fn disclosure_serializes_honest_statuses() {
255        let off = ConformalSetInfo {
256            status: "off".into(),
257            alpha: None,
258            coverage_target: None,
259            calibration_samples: None,
260            threshold: None,
261            set_size: None,
262            hint: None,
263        };
264        let json = serde_json::to_value(&off).unwrap();
265        assert_eq!(json["status"], "off");
266        assert!(json.get("alpha").is_none(), "off discloses no numbers");
267
268        let uncal = ConformalSetInfo {
269            status: "uncalibrated".into(),
270            alpha: Some(0.1),
271            coverage_target: Some(0.9),
272            calibration_samples: Some(3),
273            threshold: None,
274            set_size: None,
275            hint: Some("record ≥ 10 feedback samples to calibrate".into()),
276        };
277        let json = serde_json::to_value(&uncal).unwrap();
278        assert_eq!(json["status"], "uncalibrated");
279        assert_eq!(json["calibration_samples"], 3);
280        assert!(json.get("threshold").is_none());
281    }
282}