Skip to main content

keyhog_scanner/compiled_scanner/
runtime.rs

1use super::*;
2use crate::hw_probe::ScanBackend;
3
4#[inline]
5fn scan_deadline_expired(deadline: Option<std::time::Instant>) -> bool {
6    let expired = crate::deadline::expired(deadline);
7    if expired {
8        crate::telemetry::record_chunk_deadline_abort();
9    }
10    expired
11}
12
13fn backend_driver_name(backend: ScanBackend) -> &'static str {
14    match backend {
15        ScanBackend::GpuCuda => "cuda",
16        ScanBackend::GpuMetal => "metal",
17        ScanBackend::GpuWgpu => "wgpu",
18        _ => "",
19    }
20}
21
22/// Family + homoglyph breakdown of the always-active (`phase2_always_active_indices`)
23/// pool, used to pin the true composition behind the F3 perf floor.
24///
25/// The distinction that matters: `*_homoglyph` patterns are ASCII-fold-skippable
26/// on a pure-ASCII chunk (the CredData common case) they are SKIPPED by
27/// `homoglyph_ascii_skip` and contribute NOTHING to the ASCII prefilter cost. So
28/// the pool that actually runs the 84.3%-of-scan HS pass on ASCII source is the
29/// `*_real` (non-homoglyph) subset. Splitting these apart is what tells whether the
30/// ASCII prefilter cost is generic/entropy-bound or vendor-bound.
31#[cfg(test)]
32#[derive(Default)]
33pub(crate) struct Phase2PoolBreakdown {
34    pub(crate) generic_entropy_real: usize,
35    pub(crate) generic_entropy_homoglyph: usize,
36    pub(crate) vendor_real: usize,
37    pub(crate) vendor_homoglyph: usize,
38    pub(crate) vendor_real_ids: Vec<String>,
39}
40
41impl CompiledScanner {
42    /// Configured recall-equivalent route used when a caller does not provide
43    /// workload-specific autoroute evidence.
44    #[must_use]
45    pub fn default_execution_route(&self) -> crate::ScanExecutionRoute {
46        self.execution_route_for_backend(ScanBackend::CpuFallback)
47    }
48
49    #[must_use]
50    pub fn execution_route_for_backend(&self, backend: ScanBackend) -> crate::ScanExecutionRoute {
51        crate::ScanExecutionRoute {
52            decode_backend: if backend.is_gpu() {
53                ScanBackend::CpuFallback
54            } else {
55                backend
56            },
57            phase2_plain_localizer: self.tuning.phase2_plain_localizer_enabled(),
58            phase2_keyword_localizer: true,
59        }
60    }
61
62    /// Compile the immutable GPU literal and phase-2 programs once for an
63    /// autoroute sweep and remember their measured one-time costs. Per-workload
64    /// calibration retains those programs while composing their costs into
65    /// every matching GPU one-shot observation.
66    pub fn prepare_autoroute_calibration_gpu_artifact(&self) -> std::result::Result<(), String> {
67        let eligible_gpu = self
68            .gpu_backend_candidates()
69            .into_iter()
70            .filter(|candidate| candidate.is_eligible())
71            .collect::<Vec<_>>();
72        if eligible_gpu.is_empty() {
73            self.autoroute_gpu_shared_cold_ns
74                .store(0, std::sync::atomic::Ordering::Relaxed);
75            return Ok(());
76        }
77        if self.gpu_matcher().is_none() {
78            return Err(
79                "eligible GPU peers exist but the shared literal program could not be prepared"
80                    .to_string(),
81            );
82        }
83        if self
84            .autoroute_gpu_shared_cold_ns
85            .load(std::sync::atomic::Ordering::Acquire)
86            == 0
87        {
88            return Err(
89                "the shared GPU literal program initialized without recording its preparation duration"
90                    .to_string(),
91            );
92        }
93        #[cfg(feature = "gpu")]
94        for candidate in eligible_gpu {
95            let backend_id = candidate.driver_id.ok_or_else(|| {
96                "eligible GPU peer has no driver identity during phase-2 preparation".to_string()
97            })?;
98            let _catalog = self.phase2_gpu_dfa_catalog(Some(backend_id));
99            if self.phase2_gpu_dfa.preparation_ns(Some(backend_id)) == 0 {
100                return Err(format!(
101                    "the {backend_id} phase-2 GPU program initialized without recording its preparation duration"
102                ));
103            }
104        }
105        Ok(())
106    }
107
108    /// Materialize the SIMD peer and preserve its exact initialization error.
109    pub fn initialize_simd_backend(&self) -> std::result::Result<(), String> {
110        self.try_initialize_simd_backend().map_err(str::to_owned)
111    }
112
113    /// One-time Hyperscan materialization cost recorded by this scanner.
114    #[must_use]
115    pub fn simd_initialization_ns(&self) -> Option<u128> {
116        #[cfg(feature = "simd")]
117        {
118            let ns = self
119                .simd_initialization_ns
120                .load(std::sync::atomic::Ordering::Acquire);
121            return (self.simd_backend_initialized() && ns > 0).then_some(ns as u128);
122        }
123        #[cfg(not(feature = "simd"))]
124        {
125            None
126        }
127    }
128
129    /// Reset workload-shaped GPU state while retaining immutable literal and
130    /// phase-2 programs whose measured preparation costs are composed into cold
131    /// evidence.
132    pub fn reset_autoroute_calibration_gpu_workload(&self) -> std::result::Result<(), String> {
133        #[cfg(feature = "gpu")]
134        {
135            self.reset_gpu_resident_literal_for_calibration()?;
136        }
137        Ok(())
138    }
139
140    #[must_use]
141    pub fn autoroute_calibration_gpu_shared_cold_ns(&self) -> u128 {
142        self.autoroute_gpu_shared_cold_ns
143            .load(std::sync::atomic::Ordering::Acquire) as u128
144    }
145
146    /// Measured one-time phase-2 program preparation cost for an eligible GPU
147    /// backend. `None` means the backend is not eligible or was not prepared.
148    #[must_use]
149    pub fn autoroute_calibration_gpu_backend_cold_ns(&self, backend: ScanBackend) -> Option<u128> {
150        #[cfg(feature = "gpu")]
151        {
152            let candidate = self
153                .gpu_backend_candidates()
154                .into_iter()
155                .find(|candidate| candidate.backend == backend && candidate.is_eligible())?;
156            let preparation_ns = self.phase2_gpu_dfa.preparation_ns(candidate.driver_id);
157            return (preparation_ns > 0).then_some(preparation_ns);
158        }
159        #[cfg(not(feature = "gpu"))]
160        {
161            let _backend = backend;
162            None
163        }
164    }
165
166    /// Materialize and return the exact phase-one Hyperscan backend.
167    #[cfg(feature = "simd")]
168    pub(crate) fn try_simd_prefilter(
169        &self,
170    ) -> std::result::Result<&crate::engine::SimdPhase1Prefilter, &str> {
171        if !self.simd_candidate_available {
172            return Err("the detector corpus produced no Hyperscan phase-one plan");
173        }
174        self.simd_prefilter
175            .get_or_init(|| {
176                let started = std::time::Instant::now();
177                let plan = self
178                    .simd_compile_plan
179                    .lock()
180                    .map_err(|_| "Hyperscan compile-plan lock was poisoned".to_string())?
181                    .take()
182                    .ok_or_else(|| "Hyperscan compile plan was already consumed".to_string())?;
183                let result = plan.materialize();
184                self.simd_initialization_ns.store(
185                    u64::try_from(started.elapsed().as_nanos())
186                        // LAW10: reporting-only telemetry saturation preserves monotonic timing without changing scan execution or findings.
187                        .unwrap_or(u64::MAX)
188                        .max(1),
189                    std::sync::atomic::Ordering::Release,
190                );
191                result
192            })
193            .as_ref()
194            .map_err(String::as_str)
195    }
196
197    pub(crate) fn try_initialize_simd_backend(&self) -> std::result::Result<(), &str> {
198        #[cfg(feature = "simd")]
199        {
200            self.try_simd_prefilter().map(|_| ())
201        }
202        #[cfg(not(feature = "simd"))]
203        {
204            Err("this scanner build has no Hyperscan/SIMD backend")
205        }
206    }
207
208    /// Whether this scanner has a backend-neutral SIMD candidate plan.
209    /// This census does not materialize a Hyperscan database.
210    #[must_use]
211    pub fn simd_backend_available(&self) -> bool {
212        #[cfg(feature = "simd")]
213        {
214            self.simd_candidate_available
215        }
216        #[cfg(not(feature = "simd"))]
217        {
218            false
219        }
220    }
221
222    /// Whether this process has successfully materialized the SIMD candidate.
223    #[must_use]
224    pub fn simd_backend_initialized(&self) -> bool {
225        #[cfg(feature = "simd")]
226        {
227            self.simd_prefilter
228                .get()
229                .is_some_and(std::result::Result::is_ok)
230        }
231        #[cfg(not(feature = "simd"))]
232        {
233            false
234        }
235    }
236
237    /// Number of loaded detectors.
238    pub(crate) fn detector_count(&self) -> usize {
239        self.detector_plans.len()
240    }
241
242    /// Resolve overlapping findings with the exact detector corpus compiled
243    /// into this scanner. Reporting service names never select execution or
244    /// resolution semantics, and an unknown finding identity is an error.
245    pub fn try_resolve_matches(
246        &self,
247        matches: Vec<keyhog_core::RawMatch>,
248    ) -> std::result::Result<Vec<keyhog_core::RawMatch>, String> {
249        crate::resolution::try_resolve_matches_with_compiled_plan(matches, &self.detector_plans)
250    }
251
252    /// Pre-interned `(detector_id, detector_name, service)` triple for the
253    /// detector at `detector_index`. Three `Arc::clone`s, zero hashing, the
254    /// hot-path replacement for three `ScanState::intern_metadata` calls on
255    /// frozen detector metadata (PERF-locality_intern-1). Returns byte-for-byte
256    /// the same `Arc<str>` values `static_intern.lookup(...)` would, because
257    /// they ARE the same arena entries, so emitted findings are unchanged.
258    #[cfg(test)]
259    #[inline]
260    pub(crate) fn interned_detector_metadata(
261        &self,
262        detector_index: usize,
263    ) -> (Arc<str>, Arc<str>, Arc<str>) {
264        self.detector_plans.get(detector_index).cloned_metadata()
265    }
266
267    /// Total number of patterns (AC + phase-2 capture).
268    pub(crate) fn pattern_count(&self) -> usize {
269        self.ac_map.len() + self.phase2_patterns.len()
270    }
271
272    /// This scanner's performance route tuning. Differential parity tests use
273    /// `keyhog_scanner::testing` helpers to flip a route on one scanner and
274    /// drive a single input down both code paths without process-global state.
275    #[cfg(test)]
276    pub(crate) fn tuning(&self) -> &phase2::ScannerTuning {
277        &self.tuning
278    }
279
280    /// Diagnostic: `(phase2_total, always_active, always_active_eligible)`
281    /// how much the shared-anchor index shrinks the RegexSet prefilter. The
282    /// prefilter cost scales with `always_active - always_active_eligible`.
283    #[cfg(test)]
284    pub(crate) fn phase2_anchor_stats(&self) -> (usize, usize, usize) {
285        let total = self.phase2_patterns.len();
286        let always_active = self.phase2_always_active_indices.len();
287        let aae = self.phase2_anchor_index.as_ref().map_or(0, |idx| {
288            self.phase2_always_active_indices
289                .iter()
290                .filter(|&&i| idx.is_always_active_eligible(i))
291                .count()
292        });
293        (total, always_active, aae)
294    }
295
296    /// Benchmark helper: directly time `mark_matches` on a no-candidate text
297    /// without the phase-1 HS scan overhead. Returns the mean nanoseconds per
298    /// `mark_matches` call over `n_calls` iterations on `text`.
299    ///
300    /// Used by `phase2_no_candidate_gate_perf` to assert the isolated gate
301    /// path (bloom → AC early-exit → return) is well below the 30931 ns/call
302    /// pre-fix baseline. The method bypasses the whole scan pipeline
303    /// (`scan_chunks_with_backend`) so only the `mark_matches` body is timed.
304    #[cfg(test)]
305    pub(crate) fn mark_matches_gate_ns_per_call(&self, text: &str, n_calls: u32) -> f64 {
306        let Some(prefilter) = &self.phase2_always_active_prefilter else {
307            return 0.0;
308        };
309        let tuning = self.tuning().resolve();
310        // Warm: one call to initialise any thread-local state before timing.
311        let mut scratch = phase2::ActivePatternsScratch::new();
312        scratch.begin(self.phase2_patterns.len());
313        prefilter.mark_matches(
314            &self.phase2_patterns,
315            text,
316            &mut scratch,
317            false,
318            false,
319            &tuning,
320            true,
321        );
322        // Timed loop.
323        let t0 = std::time::Instant::now();
324        for _ in 0..n_calls {
325            scratch.begin(self.phase2_patterns.len());
326            prefilter.mark_matches(
327                &self.phase2_patterns,
328                text,
329                &mut scratch,
330                false,
331                false,
332                &tuning,
333                true,
334            );
335        }
336        let elapsed_ns = t0.elapsed().as_nanos() as f64;
337        elapsed_ns / n_calls as f64
338    }
339
340    /// F3 perf experiment: time the always-active HS `mark` on `haystack` with the
341    /// FULL always-active DB vs a lean DB that EXCLUDES homoglyph variants.
342    ///
343    /// On a pure-ASCII chunk the homoglyph variants (99.9% of the pool) cannot
344    /// match, their prefixes are unicode look-alikes absent from ASCII bytes, and
345    /// the base ASCII prefix is already covered by the AC/confirmed path (the same
346    /// invariant `homoglyph_ascii_skip` relies on). The RegexSet path already skips
347    /// them on ASCII; the HS path does NOT. This measures whether that missing skip
348    /// costs real time or whether HS's own literal prefilter (Teddy/FDR) already
349    /// gates the unicode-prefixed patterns for free. Returns
350    /// `(full_ns_per_call, lean_ns_per_call, full_pattern_count, lean_pattern_count)`.
351    #[cfg(all(test, feature = "simd"))]
352    pub(crate) fn bench_hs_homoglyph_skip(
353        &self,
354        haystack: &str,
355        n_calls: u32,
356    ) -> (f64, f64, usize, usize) {
357        use super::phase2::ActivePatternsScratch;
358        use super::Phase2HsEngine;
359        let all: Vec<usize> = self.phase2_always_active_indices.clone();
360        let lean_n = all
361            .iter()
362            .filter(|&&i| !self.phase2_patterns[i].0.homoglyph_variant)
363            .count();
364        // ONE engine, the production object, which now holds both the full DB and
365        // the lean ASCII sub-DB. Time the two routes exactly as the hot path selects
366        // them (`skip_homoglyph_ascii` false vs true).
367        let engine = Phase2HsEngine::build(&self.phase2_patterns, &all).expect("HS engine");
368        let mut scratch = ActivePatternsScratch::new();
369        let mut time_one = |skip_homoglyph_ascii: bool| -> f64 {
370            scratch.begin(self.phase2_patterns.len());
371            if let Err(error) = engine.mark(haystack, &mut scratch, skip_homoglyph_ascii) {
372                panic!("HS benchmark warmup failed: {error}");
373            }
374            let t0 = std::time::Instant::now();
375            for _ in 0..n_calls {
376                scratch.begin(self.phase2_patterns.len());
377                if let Err(error) = engine.mark(haystack, &mut scratch, skip_homoglyph_ascii) {
378                    panic!("HS benchmark trial failed: {error}");
379                }
380            }
381            t0.elapsed().as_nanos() as f64 / n_calls as f64
382        };
383        let full_ns = time_one(false);
384        let lean_ns = time_one(true);
385        (full_ns, lean_ns, all.len(), lean_n)
386    }
387
388    /// Recall-neutrality proof for the HS homoglyph-ASCII skip: on `ascii_text`,
389    /// mark once with the full DB and once with the lean ASCII DB, and return
390    /// `(full_marked, lean_marked, non_homoglyph_dropped, lean_extra)`:
391    ///   * `non_homoglyph_dropped`: patterns the full DB marked that the lean DB
392    ///     did NOT, which are NOT homoglyph variants. MUST be empty: the lean DB may
393    ///     only ever drop homoglyph variants (whose ASCII matches the base AC path
394    ///     already covers), never a real pattern.
395    ///   * `lean_extra`: patterns the lean DB marked that the full DB did not. MUST
396    ///     be empty: lean is a strict subset, so it can never over-mark.
397    /// Both empty ⇒ the lean DB differs from the full DB by EXACTLY the homoglyph
398    /// variants, so on ASCII (base covers homoglyph) findings are unchanged.
399    #[cfg(all(test, feature = "simd"))]
400    pub(crate) fn hs_mark_full_vs_lean_diff(
401        &self,
402        ascii_text: &str,
403    ) -> (usize, usize, Vec<usize>, Vec<usize>) {
404        use super::phase2::ActivePatternsScratch;
405        use super::Phase2HsEngine;
406        use std::collections::HashSet;
407        let all: Vec<usize> = self.phase2_always_active_indices.clone();
408        let engine = Phase2HsEngine::build(&self.phase2_patterns, &all).expect("HS engine");
409        let mut scratch = ActivePatternsScratch::new();
410        scratch.begin(self.phase2_patterns.len());
411        engine
412            .mark(ascii_text, &mut scratch, false)
413            .expect("full mark");
414        let full: HashSet<usize> = scratch.active.iter().copied().collect();
415        scratch.begin(self.phase2_patterns.len());
416        engine
417            .mark(ascii_text, &mut scratch, true)
418            .expect("lean mark");
419        let lean: HashSet<usize> = scratch.active.iter().copied().collect();
420        let non_homoglyph_dropped: Vec<usize> = full
421            .iter()
422            .copied()
423            .filter(|i| !lean.contains(i) && !self.phase2_patterns[*i].0.homoglyph_variant)
424            .collect();
425        let lean_extra: Vec<usize> = lean.iter().copied().filter(|i| !full.contains(i)).collect();
426        (full.len(), lean.len(), non_homoglyph_dropped, lean_extra)
427    }
428
429    /// Diagnostic: `(regex_source, keywords)` for every keyword-gated phase-2
430    /// pattern, in phase-2 order. These are the no-literal-prefix detectors
431    /// that `scan_phase2_patterns` runs over the whole chunk once their
432    /// keyword fires. Used by anchor-localization analysis to classify which
433    /// carry a regex-required literal that can drive a windowed (rather than
434    /// whole-chunk) scan. Diagnostic surface only (not part of the scan path).
435    #[cfg(test)]
436    pub(crate) fn phase2_pattern_diagnostics(&self) -> Vec<(String, Vec<String>)> {
437        self.phase2_patterns
438            .iter()
439            .map(|(p, kw)| (p.regex.as_str().to_string(), kw.clone()))
440            .collect()
441    }
442
443    /// Diagnostic: family composition of the always-active (`phase2_n`) pool
444    /// `(generic_entropy_count, other_count, distinct_other_ids)`.
445    ///
446    /// The recall-neutral decode-path perf lever (F3) rests on what `other_count`
447    /// is. On decoded sub-chunks the adjudicator's decode-guard
448    /// The decode guard suppresses entropy-only findings, but detector-owned
449    /// phase-2 generic assignments remain recall-bearing when their keyword
450    /// survives decoding. This diagnostic therefore reports composition only;
451    /// it must never justify skipping the generic pool wholesale.
452    #[cfg(test)]
453    pub(crate) fn phase2_always_active_family_breakdown(&self) -> Phase2PoolBreakdown {
454        let mut b = Phase2PoolBreakdown::default();
455        for &idx in &self.phase2_always_active_indices {
456            let pattern = &self.phase2_patterns[idx].0;
457            let id = self
458                .detector_plans
459                .get(pattern.detector_index)
460                .metadata
461                .0
462                .as_ref();
463            let generic_entropy = matches!(
464                self.detector_plans.resolution_class(id),
465                Some(
466                    crate::detector_plan::DetectorResolutionClass::Generic
467                        | crate::detector_plan::DetectorResolutionClass::Entropy
468                )
469            );
470            let homoglyph = pattern.homoglyph_variant;
471            match (generic_entropy, homoglyph) {
472                (true, false) => b.generic_entropy_real += 1,
473                (true, true) => b.generic_entropy_homoglyph += 1,
474                (false, false) => {
475                    b.vendor_real += 1;
476                    if !b.vendor_real_ids.iter().any(|existing| existing == id) {
477                        b.vendor_real_ids.push(id.to_string());
478                    }
479                }
480                (false, true) => b.vendor_homoglyph += 1,
481            }
482        }
483        b
484    }
485
486    /// Warm regex transition caches in parallel before scanning.
487    ///
488    /// Detector regexes are already builder-validated and seeded during scanner
489    /// construction (see [`crate::types::LazyRegex`]), so this is now mostly
490    /// DFA/transition-cache first-touch work plus generated/plain fallback
491    /// regexes. For a LONG-lived or LARGE scan - the daemon, `watch`,
492    /// `scan-system`, or a big repo where a detector fires across thousands of
493    /// files - paying that warmup once, in parallel, avoids stalling worker
494    /// threads inside the first hot source batch. Callers on those paths should
495    /// `warm()` after building the scanner.
496    ///
497    /// Idempotent and cheap to repeat: an already-compiled pattern is a
498    /// `OnceLock` hit. Also the correct setup for a per-scan perf benchmark,
499    /// which means to measure match throughput, not one-time compilation.
500    pub fn warm(&self) {
501        use rayon::prelude::*;
502        // Warm the lazy regex transition caches in parallel so the first real
503        // source batch does not serialize DFA first-touch under worker load.
504        const WARM_SAMPLE: &str = concat!(
505            "int main(void){ char *buf = malloc(4096); for(size_t i=0;i<len;i++){ ",
506            "config.timeout_ms = 30000; user_id=0x1f3b9c; const KEY = \"abcDEF0123456789\"; ",
507            "https://example.org/api/v2?payload=eyJhbGciOi&id=550e8400-e29b-41d4-a716; ",
508            "base64=QUtJQUlPU0ZPRE5ON0VYQU1QTEU= sha=da39a3ee5e6b4b0d3255bfef95601890; ",
509            "snake_case_name camelCaseName SCREAMING_CASE path/to/file.rs node_modules ",
510            "} /* comment */ // trailing\n\t<xml attr='v'>text</xml> {\"json\":true,\"n\":42}"
511        );
512        self.ac_map.par_iter().for_each(|p| {
513            let _ = p.regex.get().find(WARM_SAMPLE); // LAW10: forces lazy-static/regex eager init (warm-up); not a fallback
514        });
515        self.phase2_patterns.par_iter().for_each(|(p, _)| {
516            let _ = p.regex.get().find(WARM_SAMPLE); // LAW10: forces lazy-static/regex eager init (warm-up); not a fallback
517        });
518        crate::shared_regexes::warm_runtime_regexes();
519        if let Some(generic_assignment) = self.detector_plans.generic_assignment() {
520            let _ = generic_assignment.matcher().find(WARM_SAMPLE); // LAW10: warm-up result is intentionally discarded; this eagerly initializes the exact regex used by later scans
521        }
522        crate::multiline::warm_runtime_regexes();
523    }
524
525    /// Iterator over the FINAL regex source strings (post anchoring /
526    /// group extraction / normalization) the scanner uses.
527    pub(crate) fn pattern_regex_strs(&self) -> Vec<&str> {
528        let mut out = Vec::with_capacity(self.ac_map.len() + self.phase2_patterns.len());
529        out.extend(self.ac_map.iter().map(|p| p.regex.as_str()));
530        out.extend(self.phase2_patterns.iter().map(|(p, _)| p.regex.as_str()));
531        out
532    }
533
534    /// Stable scanner runtime status for CLI reporting and autoroute cache
535    /// invalidation. This is the public diagnostics boundary; raw corpus
536    /// inspection helpers stay crate-private so tests do not grow a second
537    /// production API around internal matcher layout.
538    pub fn runtime_status(&self) -> CompiledScannerRuntime {
539        CompiledScannerRuntime {
540            detector_count: self.detector_count(),
541            pattern_count: self.pattern_count(),
542            detector_digest: self.detector_digest(),
543            preferred_backend: self.preferred_backend_label(),
544            gpu_backends: self.gpu_backends.availability(),
545            gpu_degrade_count: self.gpu_degrade_count(),
546        }
547    }
548    /// Build-time Layer-0.5 bigram-prefilter density and health.
549    ///
550    /// This performs one 1024-word population-count pass on explicit status
551    /// requests. It is never called from the per-chunk scan path.
552    #[must_use]
553    pub fn bigram_prefilter_status(&self) -> crate::bigram_bloom::BigramPrefilterStatus {
554        self.bigram_bloom.status()
555    }
556
557    /// Measure Layer-0.5 rejection over one explicitly named diagnostic corpus.
558    ///
559    /// Inputs are borrowed and walked without collection. Saturated or invalid
560    /// filters are fail-open and therefore report zero rejected inputs.
561    #[must_use]
562    pub fn bigram_prefilter_corpus_status<'a, I>(
563        &self,
564        corpus_name: &'a str,
565        inputs: I,
566    ) -> crate::bigram_bloom::BigramPrefilterCorpusStatus<'a>
567    where
568        I: IntoIterator<Item = &'a [u8]>,
569    {
570        self.bigram_bloom.corpus_status(
571            corpus_name,
572            inputs,
573            crate::engine::BIGRAM_BLOOM_MIN_CHUNK_BYTES,
574        )
575    }
576
577    /// Cumulative count of scanner-local GPU region-dispatch failures.
578    ///
579    /// Per-request GPU MoE recovery is returned on `CoalescedScanOutcome`;
580    /// it is deliberately excluded here so concurrent scanners cannot affect
581    /// another request's correctness decision.
582    pub fn gpu_degrade_count(&self) -> u64 {
583        self.gpu_degrade_count
584            .load(std::sync::atomic::Ordering::Relaxed)
585    }
586
587    /// Dump and reset every scanner-owned profile stream collected under the
588    /// unified explicit profile switch. This is the only public
589    /// boundary the CLI needs; it prevents CLI/orchestrator code from growing
590    /// its own env reads for individual profiler shards.
591    pub fn dump_profile_reports(&self, label: &str) {
592        if !profile::enabled() {
593            return;
594        }
595        profile::dump(label);
596        self.phase2_profile_dump(label);
597        self.confirmed_profile_dump(label);
598    }
599
600    pub fn reset_profile_reports(&self) {
601        profile::reset();
602        self.phase2_profile_reset();
603        self.confirmed_profile_reset();
604    }
605
606    pub(crate) fn detector_digest(&self) -> u64 {
607        self.detector_digest
608    }
609
610    /// Every compiled GPU driver peer and its census and initialization state.
611    #[must_use]
612    pub fn gpu_backend_candidates(&self) -> Vec<GpuBackendCandidateStatus> {
613        use crate::hw_probe::ScanBackend;
614        [
615            ScanBackend::GpuCuda,
616            ScanBackend::GpuMetal,
617            ScanBackend::GpuWgpu,
618        ]
619        .into_iter()
620        .map(|backend| {
621            let acquired = self.gpu_backends.initialized(backend);
622            let available = match backend {
623                ScanBackend::GpuCuda => self.gpu_backends.cuda_available,
624                ScanBackend::GpuMetal => self.gpu_backends.metal_available,
625                ScanBackend::GpuWgpu => self.gpu_backends.wgpu_available,
626                _ => false,
627            };
628            let acquisition_error = self
629                .gpu_backends
630                .initialization_error(backend)
631                .map(str::to_owned)
632                .or_else(|| {
633                    self.gpu_acquisition_failures
634                        .iter()
635                        .find(|failure| failure.backend == backend_driver_name(backend))
636                        .map(|failure| failure.diagnostic.clone())
637                });
638            GpuBackendCandidateStatus {
639                backend,
640                available,
641                acquired: acquired.is_some(),
642                driver_id: available.then(|| backend_driver_name(backend)),
643                driver_version: available.then(|| match backend {
644                    ScanBackend::GpuCuda => env!("KEYHOG_VYRE_CUDA_VERSION"),
645                    ScanBackend::GpuMetal => env!("KEYHOG_VYRE_METAL_VERSION"),
646                    ScanBackend::GpuWgpu => env!("KEYHOG_VYRE_WGPU_VERSION"),
647                    _ => unreachable!("candidate list contains only GPU backends"),
648                }),
649                device_identity: acquired
650                    .and_then(|peer| peer.device_identity.clone())
651                    .or_else(|| match backend {
652                        ScanBackend::GpuCuda => self.gpu_backends.cuda_device_identity.clone(),
653                        ScanBackend::GpuMetal => self.gpu_backends.metal_device_identity.clone(),
654                        ScanBackend::GpuWgpu => self.gpu_backends.wgpu_device_identity.clone(),
655                        _ => None,
656                    }),
657                runtime_identity: match backend {
658                    ScanBackend::GpuCuda => self.gpu_backends.cuda_runtime_identity.clone(),
659                    ScanBackend::GpuMetal => self.gpu_backends.metal_runtime_identity.clone(),
660                    ScanBackend::GpuWgpu => self.gpu_backends.wgpu_runtime_identity.clone(),
661                    _ => None,
662                },
663                is_software: acquired.map_or_else(
664                    || match backend {
665                        ScanBackend::GpuCuda => false,
666                        ScanBackend::GpuMetal => false,
667                        ScanBackend::GpuWgpu => self.gpu_backends.wgpu_is_software,
668                        _ => true,
669                    },
670                    |peer| peer.is_software,
671                ),
672                acquisition_error,
673            }
674        })
675        .collect()
676    }
677
678    /// Materialize one GPU route and return the identity of the exact peer that
679    /// will execute it. Autoroute persists this value with timing evidence.
680    pub fn acquired_gpu_peer_identity(
681        &self,
682        backend: crate::hw_probe::ScanBackend,
683    ) -> std::result::Result<String, String> {
684        if !backend.is_gpu() {
685            return Err(format!("{} is not a GPU backend", backend.label()));
686        }
687        if !self.warm_backend(backend) {
688            return Err(self.gpu_backend_unavailable_reason(backend));
689        }
690        let candidate = self
691            .gpu_backend_candidates()
692            .into_iter()
693            .find(|candidate| candidate.backend == backend)
694            .ok_or_else(|| format!("{} is not a compiled GPU peer", backend.label()))?;
695        if !candidate.acquired || !candidate.available || candidate.is_software {
696            return Err(self.gpu_backend_unavailable_reason(backend));
697        }
698        let (Some(driver_id), Some(driver_version), Some(device_identity), Some(runtime_identity)) = (
699            candidate
700                .driver_id
701                .as_deref()
702                .filter(|value| !value.trim().is_empty()),
703            candidate
704                .driver_version
705                .as_deref()
706                .filter(|value| !value.trim().is_empty()),
707            candidate
708                .device_identity
709                .as_deref()
710                .filter(|value| !value.trim().is_empty()),
711            candidate
712                .runtime_identity
713                .as_deref()
714                .filter(|value| !value.trim().is_empty()),
715        ) else {
716            let missing = [
717                (
718                    "driver_id",
719                    candidate
720                        .driver_id
721                        .as_deref()
722                        .is_none_or(|value| value.trim().is_empty()),
723                ),
724                (
725                    "driver_version",
726                    candidate
727                        .driver_version
728                        .as_deref()
729                        .is_none_or(|value| value.trim().is_empty()),
730                ),
731                (
732                    "device_identity",
733                    candidate
734                        .device_identity
735                        .as_deref()
736                        .is_none_or(|value| value.trim().is_empty()),
737                ),
738                (
739                    "runtime_identity",
740                    candidate
741                        .runtime_identity
742                        .as_deref()
743                        .is_none_or(|value| value.trim().is_empty()),
744                ),
745            ]
746            .into_iter()
747            .filter_map(|(field, absent)| absent.then_some(field))
748            .collect::<Vec<_>>()
749            .join(", ");
750            return Err(format!(
751                "{} reported acquired eligibility with missing identity fields: {missing}; reinitialize the GPU backend and recalibrate autoroute",
752                backend.label()
753            ));
754        };
755        let identity = (
756            candidate.backend.label(),
757            driver_id,
758            driver_version,
759            device_identity,
760            runtime_identity,
761        );
762        serde_json::to_string(&identity)
763            .map_err(|error| format!("GPU peer identity serialization failed: {error}"))
764    }
765
766    pub(crate) fn gpu_backend_unavailable_reason(
767        &self,
768        backend: crate::hw_probe::ScanBackend,
769    ) -> String {
770        let Some(candidate) = self
771            .gpu_backend_candidates()
772            .into_iter()
773            .find(|candidate| candidate.backend == backend)
774        else {
775            return format!("{} is not a compiled GPU peer", backend.label());
776        };
777        if let Some(error) = candidate.acquisition_error {
778            return format!(
779                "{} execution backend initialization failed: {error}",
780                backend.label()
781            );
782        }
783        if !candidate.available {
784            return format!(
785                "{} is absent from the current hardware peer census",
786                backend.label()
787            );
788        }
789        if !candidate.has_complete_identity() {
790            return format!(
791                "{} has incomplete driver, device, or runtime identity",
792                backend.label()
793            );
794        }
795        if candidate.acquired {
796            return format!("{} execution backend initialized", backend.label());
797        }
798        format!(
799            "{} did not publish an initialized execution handle",
800            backend.label()
801        )
802    }
803
804    /// Most recent concrete GPU runtime-degrade reason for this compiled
805    /// scanner, if one has occurred. Used by health probes to emit
806    /// machine-readable failure causes without scraping stderr.
807    #[cfg(feature = "gpu")]
808    pub(crate) fn last_gpu_degrade_reason(&self) -> Option<String> {
809        match self.gpu_last_degrade_reason.lock() {
810            Ok(guard) => guard.clone(),
811            Err(poisoned) => match poisoned.into_inner().clone() {
812                Some(reason) => Some(format!(
813                    "GPU runtime diagnostic lock was poisoned after recording: {reason}"
814                )),
815                None => Some(
816                    "GPU runtime degradation occurred, but its diagnostic lock was poisoned"
817                        .to_owned(),
818                ),
819            },
820        }
821    }
822
823    /// Return the backend used by no-backend library scan APIs.
824    #[must_use]
825    pub(crate) fn preferred_backend_label(&self) -> &'static str {
826        crate::hw_probe::ScanBackend::CpuFallback.label()
827    }
828
829    /// Warm backend resources that are initialized lazily during scanning.
830    pub fn warm_backend(&self, backend: crate::hw_probe::ScanBackend) -> bool {
831        // GPU readiness means the one production on-GPU engine: GpuLiteralSet
832        // region presence. Retired per-rule routes do not keep compatibility
833        // identities here.
834        let ready = match backend {
835            crate::hw_probe::ScanBackend::GpuCuda
836            | crate::hw_probe::ScanBackend::GpuMetal
837            | crate::hw_probe::ScanBackend::GpuWgpu => self.gpu_stack_usable_for(backend),
838            crate::hw_probe::ScanBackend::SimdCpu => {
839                #[cfg(feature = "simd")]
840                {
841                    match self.try_simd_prefilter() {
842                        Ok(prefilter) => prefilter.scanner().warm().is_ok(),
843                        Err(_) => false, // LAW10: this operator-visible bool is the honest resource status; warm_backend never begins a scan.
844                    }
845                }
846                #[cfg(not(feature = "simd"))]
847                {
848                    false
849                }
850            }
851            crate::hw_probe::ScanBackend::CpuFallback => true,
852        };
853        // Warming is a probe with an in-band `bool` channel: `false` honestly
854        // reports unavailable resources. Selected-backend scans use a separate
855        // API and return `ScanError` for initialization or dispatch failures.
856        ready
857    }
858
859    /// Scan a chunk on the deterministic portable backend.
860    ///
861    /// Runtime failures return `ScanError` and never terminate the host.
862    pub fn scan(&self, chunk: &Chunk) -> crate::error::Result<Vec<RawMatch>> {
863        self.scan_with_deadline(chunk, self.config.per_chunk_deadline())
864    }
865
866    /// Scan a chunk using exactly the caller-selected backend.
867    ///
868    /// Backend initialization and runtime dispatch failures return `ScanError`;
869    /// this library boundary never terminates the embedding process or invents
870    /// a clean empty scan for a failed backend.
871    pub fn scan_with_backend(
872        &self,
873        chunk: &Chunk,
874        backend: crate::hw_probe::ScanBackend,
875    ) -> crate::error::Result<Vec<RawMatch>> {
876        let results = self.scan_coalesced_with_backend_and_admission(
877            std::slice::from_ref(chunk),
878            backend,
879            None,
880        )?;
881        results.into_iter().next().ok_or_else(|| {
882            crate::error::ScanError::Config(
883                "single-chunk backend dispatch returned no result row".to_owned(),
884            )
885        })
886    }
887
888    /// Scan one chunk with optional reusable admission evidence.
889    ///
890    /// The outcome retains an exact recovery receipt when mismatched admission
891    /// evidence is discarded and recomputed by the shared coalesced boundary.
892    /// Backend failures return `ScanError` without terminating the host.
893    pub fn scan_with_backend_and_admission_plan(
894        &self,
895        chunk: &Chunk,
896        backend: crate::hw_probe::ScanBackend,
897        plan: Option<&crate::engine::Phase1AdmissionPlan>,
898    ) -> crate::error::Result<crate::engine::CoalescedScanOutcome> {
899        self.scan_coalesced_with_backend_admission_route_and_recovery(
900            std::slice::from_ref(chunk),
901            backend,
902            plan,
903            self.execution_route_for_backend(backend),
904            false,
905        )
906    }
907
908    /// Scan multiple chunks using exactly the caller-selected backend.
909    ///
910    /// Backend initialization and runtime dispatch failures return `ScanError`;
911    /// successful results preserve one output row per input chunk.
912    pub fn scan_chunks_with_backend(
913        &self,
914        chunks: &[Chunk],
915        backend: crate::hw_probe::ScanBackend,
916    ) -> crate::error::Result<Vec<Vec<RawMatch>>> {
917        self.scan_coalesced_with_backend_and_admission(chunks, backend, None)
918    }
919
920    /// Scan multiple chunks with the bigram gate explicitly bypassed.
921    ///
922    /// This diagnostic-only oracle preserves the alphabet screen, selected
923    /// backend, and all downstream matching. Comparing its result with
924    /// [`Self::scan_chunks_with_backend`] proves whether bigram rejection
925    /// changed any finding identity or location.
926    pub fn scan_chunks_with_backend_bypassing_bigram_for_diagnostics(
927        &self,
928        chunks: &[Chunk],
929        backend: crate::hw_probe::ScanBackend,
930    ) -> crate::error::Result<Vec<Vec<RawMatch>>> {
931        let plan = self.phase1_admission_plan_bypassing_bigram_for_diagnostics(chunks);
932        self.scan_coalesced_with_backend_and_admission(chunks, backend, Some(&plan))
933    }
934
935    /// Reset the cross-file fragment-reassembly cache.
936    pub fn clear_fragment_cache(&self) {
937        self.fragment_cache.clear();
938    }
939
940    /// Scan a chunk of text against all compiled detectors.
941    pub(crate) fn scan_with_deadline(
942        &self,
943        chunk: &Chunk,
944        deadline: Option<std::time::Instant>,
945    ) -> crate::error::Result<Vec<RawMatch>> {
946        // The library default is the deterministic portable reference. Hardware
947        // acceleration requires an explicit backend or the CLI's persisted
948        // fastest-correct router; a library call must not invent a heuristic
949        // route from host state and input size.
950        self.scan_with_deadline_and_backend(
951            chunk,
952            deadline,
953            crate::hw_probe::ScanBackend::CpuFallback,
954        )
955    }
956
957    pub(crate) fn scan_with_deadline_and_backend(
958        &self,
959        chunk: &Chunk,
960        deadline: Option<std::time::Instant>,
961        selected_backend: crate::hw_probe::ScanBackend,
962    ) -> crate::error::Result<Vec<RawMatch>> {
963        self.scan_with_deadline_and_backend_and_admission(chunk, deadline, selected_backend, None)
964    }
965    pub(crate) fn scan_with_deadline_and_backend_and_admission(
966        &self,
967        chunk: &Chunk,
968        deadline: Option<std::time::Instant>,
969        selected_backend: crate::hw_probe::ScanBackend,
970        admission: Option<crate::engine::Phase1Admission>,
971    ) -> crate::error::Result<Vec<RawMatch>> {
972        self.scan_with_deadline_and_backend_admission_and_route(
973            chunk,
974            deadline,
975            selected_backend,
976            admission,
977            self.execution_route_for_backend(selected_backend),
978        )
979    }
980
981    pub(crate) fn scan_with_deadline_and_backend_admission_and_route(
982        &self,
983        chunk: &Chunk,
984        deadline: Option<std::time::Instant>,
985        selected_backend: crate::hw_probe::ScanBackend,
986        admission: Option<crate::engine::Phase1Admission>,
987        route: crate::ScanExecutionRoute,
988    ) -> crate::error::Result<Vec<RawMatch>> {
989        if scan_deadline_expired(deadline) {
990            return Ok(Vec::new());
991        }
992        // Direct-match prefilters: skip chunks that carry none of any
993        // detector's literal bytes (`AlphabetScreen`) or bigrams (bloom). A
994        // FULLY-ENCODED secret carries none of those - its plaintext prefix
995        // only appears AFTER decoding - so the prefilters would drop it before
996        // decode-through could recover it, silently defeating the
997        // decode-through feature on encoded-only inputs. When the prefilter
998        // rejects but the chunk carries a decode-shaped payload, fall through
999        // to a DECODE-ONLY pass instead of skipping. Bounded: only
1000        // encoded-looking rejected chunks pay the decode cost, so normal
1001        // traffic keeps the fast skip.
1002        // LAW10: recall-preserving; `None` computes the identical admission predicate once rather than changing routes or findings.
1003        let admission = admission.unwrap_or_else(|| self.phase1_admission(chunk.data.as_bytes()));
1004        if admission != Phase1Admission::Admitted {
1005            if self.should_scan_no_hit_chunk(chunk, route) {
1006                let prepared = self.prepare_chunk(chunk);
1007                let mut matches = self.scan_prepared_with_triggered(
1008                    prepared,
1009                    &[],
1010                    deadline,
1011                    None,
1012                    None,
1013                    None,
1014                    None,
1015                    route,
1016                )?;
1017                if scan_deadline_expired(deadline) {
1018                    return Ok(matches);
1019                }
1020                self.post_process_matches(chunk, &mut matches, deadline, route)?;
1021                if scan_deadline_expired(deadline) {
1022                    return Ok(matches);
1023                }
1024                return Ok(matches);
1025            }
1026
1027            if self.chunk_needs_decode_postprocess(chunk) {
1028                if scan_deadline_expired(deadline) {
1029                    return Ok(Vec::new());
1030                }
1031                let mut matches = Vec::new();
1032                self.post_process_matches(chunk, &mut matches, deadline, route)?;
1033                if scan_deadline_expired(deadline) {
1034                    return Ok(matches);
1035                }
1036                return Ok(matches);
1037            }
1038            crate::telemetry::record_file_skipped();
1039            return Ok(Vec::new());
1040        }
1041
1042        tracing::trace!(
1043            target: "keyhog::routing",
1044            backend = selected_backend.label(),
1045            chunk_bytes = chunk.data.len(),
1046            source_type = chunk.metadata.source_type.as_ref(),
1047            "scan dispatch"
1048        );
1049        let mut matches = if chunk.data.len() > MAX_SCAN_CHUNK_BYTES {
1050            self.scan_windowed(chunk, selected_backend, deadline, route)?
1051        } else {
1052            self.scan_inner(chunk, selected_backend, deadline, route)?
1053        };
1054
1055        if scan_deadline_expired(deadline) {
1056            return Ok(matches);
1057        }
1058        self.post_process_matches(chunk, &mut matches, deadline, route)?;
1059        if scan_deadline_expired(deadline) {
1060            return Ok(matches);
1061        }
1062
1063        Ok(matches)
1064    }
1065}