Skip to main content

keyhog_scanner/engine/
phase1_admission.rs

1//! Scanner-owned direct-literal admission classification.
2
3use super::{CompiledScanner, BIGRAM_BLOOM_MIN_CHUNK_BYTES};
4use keyhog_core::Chunk;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub(crate) enum Phase1Admission {
8    AlphabetRejected,
9    BigramRejected,
10    Admitted,
11}
12
13/// Exact direct-literal admission totals for one routed scan batch.
14///
15/// Autoroute persists these totals after logarithmic bucketing. The summary is
16/// scanner-owned so routing uses the same compiled alphabet and bigram filters
17/// as production dispatch instead of reimplementing detector admission in the
18/// CLI.
19#[non_exhaustive]
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct Phase1AdmissionSummary {
22    pub alphabet_rejected_chunks: u64,
23    pub alphabet_rejected_bytes: u64,
24    pub bigram_rejected_chunks: u64,
25    pub bigram_rejected_bytes: u64,
26    pub admitted_chunks: u64,
27    pub admitted_bytes: u64,
28}
29
30/// Exact phase-2 keyword-trigger density for one routed scan batch.
31///
32/// Keyword localization changes the amount of phase-2 work only when the
33/// compiled keyword automaton fires. Autoroute buckets this scanner-owned
34/// summary so sparse and trigger-dense payloads cannot reuse timing evidence.
35#[non_exhaustive]
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
37pub struct Phase2KeywordTriggerSummary {
38    pub keyword_trigger_chunks: u64,
39    pub keyword_trigger_bytes: u64,
40    pub keyword_trigger_count: u64,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub(crate) enum Phase1AdmissionPlanIdentityError {
45    Malformed,
46    Mismatch,
47}
48
49/// Exact per-chunk phase-1 admissions computed while an autoroute key is
50/// built. The plan is intentionally opaque: callers can only reuse it through
51/// the scanner boundary that verifies its internal totals and exact live chunk
52/// identity. GPU region presence does not consume this plan because VYRE owns
53/// that path's trigger admission.
54#[derive(Debug)]
55pub struct Phase1AdmissionPlan {
56    admissions: Vec<Phase1Admission>,
57    chunk_shapes: Vec<(usize, usize)>,
58    summary: Phase1AdmissionSummary,
59    phase2_keyword_triggers: Phase2KeywordTriggerSummary,
60}
61
62impl Phase1AdmissionPlan {
63    #[must_use]
64    pub fn summary(&self) -> Phase1AdmissionSummary {
65        self.summary
66    }
67
68    #[must_use]
69    pub fn phase2_keyword_triggers(&self) -> Phase2KeywordTriggerSummary {
70        self.phase2_keyword_triggers
71    }
72
73    #[inline]
74    pub(crate) fn admission_for(&self, index: usize) -> Option<Phase1Admission> {
75        self.admissions.get(index).copied()
76    }
77
78    #[inline]
79    pub(crate) fn validate_chunks(
80        &self,
81        chunks: &[Chunk],
82    ) -> Result<(), Phase1AdmissionPlanIdentityError> {
83        let Some(summary_chunks) = self
84            .summary
85            .alphabet_rejected_chunks
86            .checked_add(self.summary.bigram_rejected_chunks)
87            .and_then(|count| count.checked_add(self.summary.admitted_chunks))
88        else {
89            return Err(Phase1AdmissionPlanIdentityError::Malformed);
90        };
91        let Some(summary_bytes) = self
92            .summary
93            .alphabet_rejected_bytes
94            .checked_add(self.summary.bigram_rejected_bytes)
95            .and_then(|count| count.checked_add(self.summary.admitted_bytes))
96        else {
97            return Err(Phase1AdmissionPlanIdentityError::Malformed);
98        };
99        let Ok(shape_count) = u64::try_from(self.chunk_shapes.len()) else {
100            return Err(Phase1AdmissionPlanIdentityError::Malformed);
101        };
102        let mut shape_bytes = 0u64;
103        for &(_, len) in &self.chunk_shapes {
104            let Ok(len) = u64::try_from(len) else {
105                return Err(Phase1AdmissionPlanIdentityError::Malformed);
106            };
107            let Some(total) = shape_bytes.checked_add(len) else {
108                return Err(Phase1AdmissionPlanIdentityError::Malformed);
109            };
110            shape_bytes = total;
111        }
112        let keyword_summary_valid = self.phase2_keyword_triggers.keyword_trigger_chunks
113            <= shape_count
114            && self.phase2_keyword_triggers.keyword_trigger_bytes <= shape_bytes
115            && self.phase2_keyword_triggers.keyword_trigger_count
116                >= self.phase2_keyword_triggers.keyword_trigger_chunks
117            && (self.phase2_keyword_triggers.keyword_trigger_chunks == 0)
118                == (self.phase2_keyword_triggers.keyword_trigger_count == 0);
119        if self.admissions.len() != self.chunk_shapes.len()
120            || summary_chunks != shape_count
121            || summary_bytes != shape_bytes
122            || !keyword_summary_valid
123            || self
124                .chunk_shapes
125                .iter()
126                .any(|&(ptr, len)| len != 0 && ptr == 0)
127        {
128            return Err(Phase1AdmissionPlanIdentityError::Malformed);
129        }
130        if chunks.len() != self.chunk_shapes.len() {
131            return Err(Phase1AdmissionPlanIdentityError::Malformed);
132        }
133        if !chunks
134            .iter()
135            .zip(&self.chunk_shapes)
136            .all(|(chunk, &(ptr, len))| {
137                let bytes = chunk.data.as_bytes();
138                bytes.as_ptr() as usize == ptr && bytes.len() == len
139            })
140        {
141            return Err(Phase1AdmissionPlanIdentityError::Mismatch);
142        }
143        Ok(())
144    }
145}
146
147impl Phase1AdmissionSummary {
148    /// Construct a summary for a caller that has independently proved every
149    /// chunk advances past direct-literal admission.
150    pub fn all_admitted(chunks: u64, bytes: u64) -> Self {
151        Self {
152            admitted_chunks: chunks,
153            admitted_bytes: bytes,
154            ..Self::default()
155        }
156    }
157
158    #[inline]
159    fn record(&mut self, admission: Phase1Admission, bytes: u64) {
160        match admission {
161            Phase1Admission::AlphabetRejected => {
162                self.alphabet_rejected_chunks += 1;
163                self.alphabet_rejected_bytes += bytes;
164            }
165            Phase1Admission::BigramRejected => {
166                self.bigram_rejected_chunks += 1;
167                self.bigram_rejected_bytes += bytes;
168            }
169            Phase1Admission::Admitted => {
170                self.admitted_chunks += 1;
171                self.admitted_bytes += bytes;
172            }
173        }
174    }
175
176    #[inline]
177    fn merge(self, other: Self) -> Self {
178        Self {
179            alphabet_rejected_chunks: self
180                .alphabet_rejected_chunks
181                .saturating_add(other.alphabet_rejected_chunks),
182            alphabet_rejected_bytes: self
183                .alphabet_rejected_bytes
184                .saturating_add(other.alphabet_rejected_bytes),
185            bigram_rejected_chunks: self
186                .bigram_rejected_chunks
187                .saturating_add(other.bigram_rejected_chunks),
188            bigram_rejected_bytes: self
189                .bigram_rejected_bytes
190                .saturating_add(other.bigram_rejected_bytes),
191            admitted_chunks: self.admitted_chunks.saturating_add(other.admitted_chunks),
192            admitted_bytes: self.admitted_bytes.saturating_add(other.admitted_bytes),
193        }
194    }
195}
196
197impl CompiledScanner {
198    #[inline]
199    pub(crate) fn phase1_admission(&self, data: &[u8]) -> Phase1Admission {
200        if self
201            .alphabet_screen
202            .as_ref()
203            .is_some_and(|screen| !screen.screen(data))
204        {
205            return Phase1Admission::AlphabetRejected;
206        }
207        if data.len() >= BIGRAM_BLOOM_MIN_CHUNK_BYTES && !self.bigram_bloom.maybe_overlaps(data) {
208            return Phase1Admission::BigramRejected;
209        }
210        Phase1Admission::Admitted
211    }
212
213    #[inline]
214    fn phase1_admission_bypassing_bigram(&self, data: &[u8]) -> Phase1Admission {
215        if self
216            .alphabet_screen
217            .as_ref()
218            .is_some_and(|screen| !screen.screen(data))
219        {
220            return Phase1Admission::AlphabetRejected;
221        }
222        Phase1Admission::Admitted
223    }
224
225    #[inline]
226    fn phase2_keyword_trigger_count(&self, data: &str) -> u64 {
227        self.phase2_keyword_ac.as_ref().map_or(0, |keyword_ac| {
228            keyword_ac
229                .find_iter(data)
230                .fold(0u64, |count, _| count.saturating_add(1))
231        })
232    }
233
234    /// Classify direct-literal phase-1 work with the exact compiled prefilters
235    /// production scanning uses. Decode work is intentionally separate and is
236    /// represented by the scanner's decode workload plan.
237    pub fn phase1_admission_summary(&self, chunks: &[Chunk]) -> Phase1AdmissionSummary {
238        // Fused batches otherwise serialize the exact admission probes on one
239        // thread immediately before the production Rayon scan. Keep tiny
240        // batches allocation-free, but fold larger batches in parallel so
241        // route selection does not become a serial pre-scan bottleneck.
242        if chunks.len() >= 4
243            && chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>() >= 64 * 1024
244        {
245            use rayon::prelude::*;
246
247            return chunks
248                .par_iter()
249                .map(|chunk| {
250                    let mut summary = Phase1AdmissionSummary::default();
251                    summary.record(
252                        self.phase1_admission(chunk.data.as_bytes()),
253                        chunk.data.len() as u64,
254                    );
255                    summary
256                })
257                .reduce(
258                    Phase1AdmissionSummary::default,
259                    Phase1AdmissionSummary::merge,
260                );
261        }
262
263        let mut summary = Phase1AdmissionSummary::default();
264        for chunk in chunks {
265            summary.record(
266                self.phase1_admission(chunk.data.as_bytes()),
267                chunk.data.len() as u64,
268            );
269        }
270        summary
271    }
272
273    /// Build exact per-chunk evidence for autoroute and the next production scan.
274    /// Reuse avoids duplicate gates; malformed or mismatched identity is recomputed
275    /// with an exact recovery receipt.
276    pub fn phase1_admission_plan(&self, chunks: &[Chunk]) -> Phase1AdmissionPlan {
277        self.phase1_admission_plan_with_bigram_mode(chunks, false)
278    }
279
280    /// Build admission evidence with only the bigram gate bypassed.
281    ///
282    /// This is a diagnostic oracle for corpus differential benchmarks. The
283    /// alphabet screen and every downstream matcher remain unchanged, so an
284    /// enabled-versus-bypassed comparison isolates whether the bigram gate
285    /// dropped a finding. Production scans must use [`Self::phase1_admission_plan`].
286    pub fn phase1_admission_plan_bypassing_bigram_for_diagnostics(
287        &self,
288        chunks: &[Chunk],
289    ) -> Phase1AdmissionPlan {
290        self.phase1_admission_plan_with_bigram_mode(chunks, true)
291    }
292
293    fn phase1_admission_plan_with_bigram_mode(
294        &self,
295        chunks: &[Chunk],
296        bypass_bigram: bool,
297    ) -> Phase1AdmissionPlan {
298        let classify = |chunk: &Chunk| {
299            let admission = if bypass_bigram {
300                self.phase1_admission_bypassing_bigram(chunk.data.as_bytes())
301            } else {
302                self.phase1_admission(chunk.data.as_bytes())
303            };
304            (
305                admission,
306                self.phase2_keyword_trigger_count(&chunk.data),
307                chunk.data.as_bytes().as_ptr() as usize,
308                chunk.data.len(),
309            )
310        };
311        let classified = if chunks.len() >= 4
312            && chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>() >= 64 * 1024
313        {
314            use rayon::prelude::*;
315
316            chunks.par_iter().map(classify).collect::<Vec<_>>()
317        } else {
318            chunks.iter().map(classify).collect::<Vec<_>>()
319        };
320        let mut summary = Phase1AdmissionSummary::default();
321        let mut phase2_keyword_triggers = Phase2KeywordTriggerSummary::default();
322        let mut admissions = Vec::with_capacity(classified.len());
323        let mut chunk_shapes = Vec::with_capacity(classified.len());
324        for (admission, keyword_trigger_count, ptr, len) in classified {
325            summary.record(admission, len as u64);
326            if keyword_trigger_count != 0 {
327                phase2_keyword_triggers.keyword_trigger_chunks += 1;
328                phase2_keyword_triggers.keyword_trigger_bytes += len as u64;
329                phase2_keyword_triggers.keyword_trigger_count = phase2_keyword_triggers
330                    .keyword_trigger_count
331                    .saturating_add(keyword_trigger_count);
332            }
333            admissions.push(admission);
334            chunk_shapes.push((ptr, len));
335        }
336        Phase1AdmissionPlan {
337            admissions,
338            chunk_shapes,
339            summary,
340            phase2_keyword_triggers,
341        }
342    }
343}