wm_memory/
recall_conformal.rs1use crate::store::MemoryStore;
22use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24use std::sync::Arc;
25use wm_conformal::SplitConformalClassifier;
26
27const STATE_FILE: &str = "recall_conformal.json";
29
30pub const MIN_SAMPLES: usize = 10;
32
33pub struct RecallConformal {
35 alpha: f32,
36 classifier: SplitConformalClassifier,
37 store: Arc<MemoryStore>,
38}
39
40impl RecallConformal {
41 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 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 #[must_use]
65 pub const fn alpha(&self) -> f32 {
66 self.alpha
67 }
68
69 #[must_use]
71 pub fn sample_count(&self) -> usize {
72 self.classifier.sample_count()
73 }
74
75 #[must_use]
77 pub const fn is_fitted(&self) -> bool {
78 self.classifier.threshold().is_some()
79 }
80
81 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 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 #[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 Some(1.0 - s <= q)
116 }
117
118 #[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#[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
167 pub threshold: Option<f64>,
168 #[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 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 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 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 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}