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 per-chunk phase-1 admissions computed while an autoroute key is
31/// built. The plan is intentionally opaque: callers can only reuse it through
32/// the scanner method that verifies the same chunk slice shape. GPU region
33/// presence does not consume this plan because VYRE owns that path's trigger
34/// admission.
35#[derive(Debug)]
36pub struct Phase1AdmissionPlan {
37    admissions: Vec<Phase1Admission>,
38    chunk_shapes: Vec<(usize, usize)>,
39    summary: Phase1AdmissionSummary,
40}
41
42impl Phase1AdmissionPlan {
43    #[must_use]
44    pub fn summary(&self) -> Phase1AdmissionSummary {
45        self.summary
46    }
47
48    #[inline]
49    pub(crate) fn admission_for(&self, index: usize) -> Option<Phase1Admission> {
50        self.admissions.get(index).copied()
51    }
52
53    #[inline]
54    pub(crate) fn matches_chunks(&self, chunks: &[Chunk]) -> bool {
55        chunks.len() == self.chunk_shapes.len()
56            && chunks
57                .iter()
58                .zip(&self.chunk_shapes)
59                .all(|(chunk, &(ptr, len))| {
60                    let bytes = chunk.data.as_bytes();
61                    bytes.as_ptr() as usize == ptr && bytes.len() == len
62                })
63    }
64}
65
66impl Phase1AdmissionSummary {
67    /// Construct a summary for a caller that has independently proved every
68    /// chunk advances past direct-literal admission.
69    pub fn all_admitted(chunks: u64, bytes: u64) -> Self {
70        Self {
71            admitted_chunks: chunks,
72            admitted_bytes: bytes,
73            ..Self::default()
74        }
75    }
76
77    #[inline]
78    fn record(&mut self, admission: Phase1Admission, bytes: u64) {
79        match admission {
80            Phase1Admission::AlphabetRejected => {
81                self.alphabet_rejected_chunks += 1;
82                self.alphabet_rejected_bytes += bytes;
83            }
84            Phase1Admission::BigramRejected => {
85                self.bigram_rejected_chunks += 1;
86                self.bigram_rejected_bytes += bytes;
87            }
88            Phase1Admission::Admitted => {
89                self.admitted_chunks += 1;
90                self.admitted_bytes += bytes;
91            }
92        }
93    }
94
95    #[inline]
96    fn merge(self, other: Self) -> Self {
97        Self {
98            alphabet_rejected_chunks: self
99                .alphabet_rejected_chunks
100                .saturating_add(other.alphabet_rejected_chunks),
101            alphabet_rejected_bytes: self
102                .alphabet_rejected_bytes
103                .saturating_add(other.alphabet_rejected_bytes),
104            bigram_rejected_chunks: self
105                .bigram_rejected_chunks
106                .saturating_add(other.bigram_rejected_chunks),
107            bigram_rejected_bytes: self
108                .bigram_rejected_bytes
109                .saturating_add(other.bigram_rejected_bytes),
110            admitted_chunks: self.admitted_chunks.saturating_add(other.admitted_chunks),
111            admitted_bytes: self.admitted_bytes.saturating_add(other.admitted_bytes),
112        }
113    }
114}
115
116impl CompiledScanner {
117    #[inline]
118    pub(crate) fn phase1_admission(&self, data: &[u8]) -> Phase1Admission {
119        if self
120            .alphabet_screen
121            .as_ref()
122            .is_some_and(|screen| !screen.screen(data))
123        {
124            return Phase1Admission::AlphabetRejected;
125        }
126        if data.len() >= BIGRAM_BLOOM_MIN_CHUNK_BYTES && !self.bigram_bloom.maybe_overlaps(data) {
127            return Phase1Admission::BigramRejected;
128        }
129        Phase1Admission::Admitted
130    }
131
132    /// Classify direct-literal phase-1 work with the exact compiled prefilters
133    /// production scanning uses. Decode work is intentionally separate and is
134    /// represented by the scanner's decode workload plan.
135    pub fn phase1_admission_summary(&self, chunks: &[Chunk]) -> Phase1AdmissionSummary {
136        // Fused batches otherwise serialize the exact admission probes on one
137        // thread immediately before the production Rayon scan. Keep tiny
138        // batches allocation-free, but fold larger batches in parallel so
139        // route selection does not become a serial pre-scan bottleneck.
140        if chunks.len() >= 4
141            && chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>() >= 64 * 1024
142        {
143            use rayon::prelude::*;
144
145            return chunks
146                .par_iter()
147                .map(|chunk| {
148                    let mut summary = Phase1AdmissionSummary::default();
149                    summary.record(
150                        self.phase1_admission(chunk.data.as_bytes()),
151                        chunk.data.len() as u64,
152                    );
153                    summary
154                })
155                .reduce(
156                    Phase1AdmissionSummary::default,
157                    Phase1AdmissionSummary::merge,
158                );
159        }
160
161        let mut summary = Phase1AdmissionSummary::default();
162        for chunk in chunks {
163            summary.record(
164                self.phase1_admission(chunk.data.as_bytes()),
165                chunk.data.len() as u64,
166            );
167        }
168        summary
169    }
170
171    /// Build the exact per-chunk admission evidence used by autoroute and
172    /// retain it for the immediately following production scan. Reusing this
173    /// plan removes a duplicate alphabet/bigram pass on SIMD and CPU routes;
174    /// the scan boundary rejects a plan for a different chunk slice and
175    /// recomputes admissions instead of trusting stale evidence.
176    pub fn phase1_admission_plan(&self, chunks: &[Chunk]) -> Phase1AdmissionPlan {
177        let classified = if chunks.len() >= 4
178            && chunks.iter().map(|chunk| chunk.data.len()).sum::<usize>() >= 64 * 1024
179        {
180            use rayon::prelude::*;
181
182            chunks
183                .par_iter()
184                .map(|chunk| {
185                    (
186                        self.phase1_admission(chunk.data.as_bytes()),
187                        chunk.data.as_bytes().as_ptr() as usize,
188                        chunk.data.len(),
189                    )
190                })
191                .collect::<Vec<_>>()
192        } else {
193            chunks
194                .iter()
195                .map(|chunk| {
196                    (
197                        self.phase1_admission(chunk.data.as_bytes()),
198                        chunk.data.as_bytes().as_ptr() as usize,
199                        chunk.data.len(),
200                    )
201                })
202                .collect::<Vec<_>>()
203        };
204        let mut summary = Phase1AdmissionSummary::default();
205        let mut admissions = Vec::with_capacity(classified.len());
206        let mut chunk_shapes = Vec::with_capacity(classified.len());
207        for (admission, ptr, len) in classified {
208            summary.record(admission, len as u64);
209            admissions.push(admission);
210            chunk_shapes.push((ptr, len));
211        }
212        Phase1AdmissionPlan {
213            admissions,
214            chunk_shapes,
215            summary,
216        }
217    }
218}