memscope_rs/analysis/unsafe_inference/policy.rs
1//! Inference acceptance policy.
2//!
3//! A heuristic score is not a probability. Reporting the highest scoring type
4//! unconditionally turns a weak signal ("this block is 8 bytes, so maybe it is a
5//! pointer") into a confident answer, and a near tie between two candidates always
6//! resolves to whichever row happens to come first in the table.
7//!
8//! This module adds the missing exit: [`InferencePolicy`] decides whether the
9//! evidence is strong enough and unambiguous enough to answer at all, and
10//! [`InferenceDecision`] keeps the raw numbers plus the reason for abstaining so
11//! reports can show "not enough evidence" instead of an invented type.
12//!
13//! # Threshold calibration
14//!
15//! The defaults are *preliminary*: they only require more than a single weak
16//! signal and reject near ties. They must be replaced by thresholds selected on a
17//! validation set before any accuracy number is published (development plan
18//! §11.2), so they live in one place and are consciously chosen, not sprinkled
19//! through the scoring code.
20
21use super::engine::{TypeKind, UnsafeInferenceEngine};
22
23/// Acceptance thresholds for heuristic type inference.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct InferencePolicy {
26 /// Minimum raw score the winning candidate must reach.
27 pub min_score: u16,
28 /// Minimum lead the winner must have over the runner-up.
29 pub min_margin: u16,
30}
31
32impl InferencePolicy {
33 /// Preliminary thresholds: reject a lone weak signal and near ties.
34 ///
35 /// `min_score = 30` corresponds to exactly one weak signal (for example the
36 /// 8-byte pointer heuristic), `min_margin = 10` rejects candidates that are
37 /// less than one signal apart.
38 ///
39 /// # Measured behaviour
40 ///
41 /// On the deterministic four-sample fixture in this module's tests
42 /// (`policy::tests::calibration_fixture`) this pair answers 2-3 samples with
43 /// precision 1.00 (recall 0.50-0.75): printable text and a NUL-terminated C
44 /// string are always answered, while a high-entropy buffer and a control-character
45 /// block abstain when their candidates are only a few points apart. The exact
46 /// count is platform-dependent because `is_valid_ptr` consults the live process
47 /// map, so the test locks the quality contract instead of an exact count. A grid
48 /// search over `min_score ∈ [0, 80]` and `min_margin ∈ [0, 30]` with
49 /// `min_precision = 0.85` finds a more permissive pair, but on four samples that
50 /// is over-fitting, so the shipped pair stays deliberately conservative.
51 ///
52 /// This is a smoke-level measurement, **not** a validated calibration: plan §11.2
53 /// requires thresholds selected on a validation set of real programs, including
54 /// layout-based samples built with a controlled allocator, before any accuracy
55 /// percentage is published.
56 pub const PRELIMINARY: Self = Self {
57 min_score: 30,
58 min_margin: 10,
59 };
60
61 /// Build a policy with explicit thresholds.
62 #[must_use]
63 pub const fn new(min_score: u16, min_margin: u16) -> Self {
64 Self {
65 min_score,
66 min_margin,
67 }
68 }
69}
70
71impl Default for InferencePolicy {
72 fn default() -> Self {
73 Self::PRELIMINARY
74 }
75}
76
77/// Outcome of one inference run: what was predicted, on what evidence, or why not.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct InferenceDecision {
80 /// Accepted type, `None` when the evidence was rejected.
81 pub predicted: Option<TypeKind>,
82 /// Highest scoring candidate, accepted or not.
83 pub best_candidate: TypeKind,
84 /// Raw score of the best candidate (heuristic score, not a probability).
85 pub raw_score: u16,
86 /// Raw score of the runner-up candidate.
87 pub runner_up_score: u16,
88 /// Why the decision abstained, when it did.
89 pub abstain_reason: Option<String>,
90}
91
92impl InferenceDecision {
93 /// Whether a type was accepted.
94 #[must_use]
95 pub fn is_accepted(&self) -> bool {
96 self.predicted.is_some()
97 }
98
99 /// Confidence reported for an accepted decision, `0` when abstaining.
100 ///
101 /// The value is a heuristic score, not a percentage.
102 #[must_use]
103 pub fn accepted_score(&self) -> u16 {
104 if self.is_accepted() {
105 self.raw_score
106 } else {
107 0
108 }
109 }
110}
111
112/// One labelled sample used to select acceptance thresholds.
113///
114/// The label is the ground truth of the fixture that produced the bytes, never the
115/// output of the engine under test.
116pub struct CalibrationSample {
117 /// Memory image handed to the engine.
118 pub bytes: Vec<u8>,
119 /// Requested size of the block.
120 pub size: usize,
121 /// Type the fixture actually allocated.
122 pub expected: TypeKind,
123}
124
125/// Measured outcome of one policy over the calibration set.
126#[derive(Debug, Clone, Default, PartialEq)]
127pub struct CalibrationReport {
128 /// Minimum score that was applied.
129 pub min_score: u16,
130 /// Minimum margin that was applied.
131 pub min_margin: u16,
132 /// Predictions that were accepted.
133 pub accepted: usize,
134 /// Accepted predictions that matched the label.
135 pub correct: usize,
136 /// Samples the policy rejected (abstained on).
137 pub abstained: usize,
138 /// `correct / accepted`; `1.0` when nothing was accepted.
139 pub precision: f64,
140 /// `correct / total`; measures how much recall the abstentions cost.
141 pub recall: f64,
142}
143
144impl CalibrationReport {
145 /// F1 of precision and recall, the value the calibration maximises.
146 #[must_use]
147 pub fn f1(&self) -> f64 {
148 if self.precision + self.recall == 0.0 {
149 return 0.0;
150 }
151 2.0 * self.precision * self.recall / (self.precision + self.recall)
152 }
153}
154
155/// Evaluate a policy over a labelled sample set.
156///
157/// Precision is only meaningful together with the abstention count: a policy that
158/// answers almost nothing can look perfect. The report therefore always carries
159/// `accepted`, `abstained` and `recall` next to `precision`.
160#[must_use]
161pub fn evaluate(policy: InferencePolicy, samples: &[CalibrationSample]) -> CalibrationReport {
162 let mut report = CalibrationReport {
163 min_score: policy.min_score,
164 min_margin: policy.min_margin,
165 ..CalibrationReport::default()
166 };
167 if samples.is_empty() {
168 return report;
169 }
170
171 for sample in samples {
172 let decision =
173 UnsafeInferenceEngine::decide_with_policy(&sample.bytes, sample.size, &policy);
174 match decision.predicted {
175 Some(kind) => {
176 report.accepted += 1;
177 if kind == sample.expected {
178 report.correct += 1;
179 }
180 }
181 None => report.abstained += 1,
182 }
183 }
184
185 report.precision = if report.accepted == 0 {
186 1.0
187 } else {
188 report.correct as f64 / report.accepted as f64
189 };
190 report.recall = report.correct as f64 / samples.len() as f64;
191 report
192}
193
194/// Select thresholds from a labelled set.
195///
196/// The grid is searched for the highest F1 among policies whose precision reaches
197/// `min_precision`; ties are broken towards the more conservative (larger) margin.
198/// The result is a *measurement*, not an opinion: the caller decides whether to ship
199/// it, and the shipped pair is recorded in [`InferencePolicy::PRELIMINARY`].
200#[must_use]
201pub fn calibrate(samples: &[CalibrationSample], min_precision: f64) -> CalibrationReport {
202 let mut best: Option<CalibrationReport> = None;
203
204 for min_score in (0..=80u16).step_by(10) {
205 for min_margin in (0..=30u16).step_by(5) {
206 let policy = InferencePolicy::new(min_score, min_margin);
207 let report = evaluate(policy, samples);
208 if report.precision < min_precision || report.accepted == 0 {
209 continue;
210 }
211 let better = match &best {
212 None => true,
213 Some(current) => match report.f1().partial_cmp(¤t.f1()) {
214 Some(std::cmp::Ordering::Greater) => true,
215 Some(std::cmp::Ordering::Equal) => report.min_margin > current.min_margin,
216 _ => false,
217 },
218 };
219 if better {
220 best = Some(report);
221 }
222 }
223 }
224
225 best.unwrap_or_else(|| evaluate(InferencePolicy::PRELIMINARY, samples))
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 /// Objective: Verify the shipped defaults are the documented preliminary pair.
233 /// Invariants: default equals `PRELIMINARY` and both thresholds are positive.
234 #[test]
235 fn default_policy_is_preliminary() {
236 assert_eq!(
237 InferencePolicy::default(),
238 InferencePolicy::PRELIMINARY,
239 "the default policy must be the documented preliminary thresholds"
240 );
241 assert!(
242 InferencePolicy::default().min_score > 0,
243 "a zero minimum score would accept every guess"
244 );
245 assert!(
246 InferencePolicy::default().min_margin > 0,
247 "a zero margin would accept ties"
248 );
249 }
250
251 /// Objective: Verify the decision helpers expose acceptance, not the raw kind.
252 /// Invariants: an abstaining decision reports score 0 and no predicted type.
253 #[test]
254 fn abstained_decision_reports_zero_score() {
255 let decision = InferenceDecision {
256 predicted: None,
257 best_candidate: TypeKind::Vec,
258 raw_score: 25,
259 runner_up_score: 20,
260 abstain_reason: Some("below min_score".to_string()),
261 };
262 assert!(!decision.is_accepted(), "no type was accepted");
263 assert_eq!(
264 decision.accepted_score(),
265 0,
266 "an abstained decision must not report a confidence score"
267 );
268 }
269
270 /// Objective: Verify an accepted decision keeps the raw heuristic score.
271 /// Invariants: accepted_score equals raw_score and a type is present.
272 #[test]
273 fn accepted_decision_keeps_raw_score() {
274 let decision = InferenceDecision {
275 predicted: Some(TypeKind::String),
276 best_candidate: TypeKind::String,
277 raw_score: 90,
278 runner_up_score: 15,
279 abstain_reason: None,
280 };
281 assert!(decision.is_accepted(), "the type must be accepted");
282 assert_eq!(decision.accepted_score(), 90, "the raw score must survive");
283 }
284
285 /// Deterministic labelled fixture used by the calibration test.
286 ///
287 /// Only content-based samples are included: pointer/layout samples depend on real
288 /// heap addresses (`is_valid_ptr` checks the process map), so they cannot be
289 /// reproduced byte-for-byte and would make the measurement non-deterministic. Those
290 /// cases need a controlled allocator (plan §12.1) and are deferred to PR-05/08.
291 fn calibration_fixture() -> Vec<CalibrationSample> {
292 let mut samples = Vec::new();
293
294 // String: printable UTF-8 text.
295 let text = b"the quick brown fox jumps over the lazy dog".to_vec();
296 samples.push(CalibrationSample {
297 size: text.len(),
298 bytes: text,
299 expected: TypeKind::String,
300 });
301
302 // CString: printable UTF-8 with a terminating NUL.
303 let mut cstring = b"hello world".to_vec();
304 cstring.push(0);
305 samples.push(CalibrationSample {
306 size: cstring.len(),
307 bytes: cstring,
308 expected: TypeKind::CString,
309 });
310
311 // Buffer: sizeable, high-entropy, no readable structure.
312 let buffer: Vec<u8> = (0..=255u8).collect();
313 samples.push(CalibrationSample {
314 size: buffer.len(),
315 bytes: buffer,
316 expected: TypeKind::Buffer,
317 });
318
319 // Control-character UTF-8: valid encoding, no printable content. It must not be
320 // reported as a String; abstaining is the honest outcome.
321 let control = vec![0xC2u8, 0x80, 0xC2, 0x81, 0xC2, 0x82];
322 samples.push(CalibrationSample {
323 size: control.len(),
324 bytes: control,
325 expected: TypeKind::Buffer,
326 });
327
328 samples
329 }
330
331 /// Objective: Verify the shipped thresholds are backed by a measurement on a
332 /// labelled fixture, and that the measurement is reported with the abstention
333 /// rate instead of precision alone.
334 /// Invariants: precision on accepted predictions is high, something is accepted
335 /// (no "abstain everything" policy), and the calibrated pair is no worse than the
336 /// shipped one.
337 #[test]
338 fn preliminary_thresholds_are_backed_by_measurement() {
339 let samples = calibration_fixture();
340 let shipped = evaluate(InferencePolicy::PRELIMINARY, &samples);
341 let calibrated = calibrate(&samples, 0.85);
342
343 assert!(
344 shipped.accepted > 0,
345 "the shipped policy must not abstain on the whole fixture"
346 );
347 assert!(
348 shipped.precision >= 0.85,
349 "shipped thresholds must keep precision above 0.85, measured {} (accepted {}, abstained {})",
350 shipped.precision,
351 shipped.accepted,
352 shipped.abstained
353 );
354 assert!(
355 shipped.recall > 0.0,
356 "the shipped policy must recover at least one sample, got recall {}",
357 shipped.recall
358 );
359 assert!(
360 calibrated.precision >= shipped.precision - 0.2,
361 "calibration must not degrade precision below the shipped policy (calibrated {}, shipped {})",
362 calibrated.precision,
363 shipped.precision
364 );
365 assert!(
366 calibrated.f1() >= shipped.f1() - 1e-9,
367 "the calibrated policy must not be worse than the shipped one (calibrated {}, shipped {})",
368 calibrated.f1(),
369 shipped.f1()
370 );
371 // The fixture is content-only, but `is_valid_ptr` still consults the live
372 // process map: a word inside a sample can look like a mapped address on
373 // Linux/Windows and not on macOS. That shifts the accepted count (2 locally,
374 // 3 on CI) while precision stays 1.0. Re-measure the quality contract instead
375 // of pinning a platform-specific count: at least two answers, every answer
376 // correct, and no sample lost from the report.
377 assert!(
378 shipped.accepted >= 2 && shipped.accepted + shipped.abstained == samples.len(),
379 "measured acceptance on the calibration fixture changed: {shipped:?}"
380 );
381 assert_eq!(
382 shipped.correct, shipped.accepted,
383 "every accepted prediction must match its label: {shipped:?}"
384 );
385 assert!(
386 (shipped.precision - 1.0).abs() < 1e-9,
387 "measured precision must be 1.0, got {}",
388 shipped.precision
389 );
390 }
391
392 /// Objective: Verify that an uncalibrated low-score policy is rejected by the
393 /// evaluation, i.e. the report exposes weak precision instead of hiding it.
394 /// Invariants: a policy with zero thresholds accepts everything and reports the
395 /// resulting precision.
396 #[test]
397 fn evaluation_exposes_weak_thresholds() {
398 let samples = calibration_fixture();
399 let permissive = evaluate(InferencePolicy::new(0, 0), &samples);
400
401 assert_eq!(
402 permissive.abstained, 0,
403 "a zero-threshold policy never abstains"
404 );
405 assert_eq!(
406 permissive.accepted,
407 samples.len(),
408 "every sample must be accepted by the permissive policy"
409 );
410 assert!(
411 permissive.precision <= 1.0,
412 "precision must stay a fraction, got {}",
413 permissive.precision
414 );
415 }
416}