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