Skip to main content

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(&current.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}