Skip to main content

keyhog_scanner/compiled_scanner/
compile.rs

1#[cfg(feature = "simdsieve")]
2use super::compile_helpers::build_hot_pattern_slots;
3#[cfg(all(target_os = "linux", feature = "gpu"))]
4use super::compile_helpers::surface_cuda_acquisition_failure;
5use super::*;
6
7impl CompiledScanner {
8    /// Compile detector specs into a [`CompiledScanner`] using the process-wide
9    /// runtime GPU policy and default tuning. The common entry point.
10    pub fn compile(detectors: Vec<DetectorSpec>) -> Result<Self> {
11        Self::compile_with_gpu_policy(detectors, GpuInitPolicy::FromRuntimePolicy)
12    }
13
14    /// Compile with an explicit [`GpuInitPolicy`] (overriding the runtime
15    /// policy) and default scanner tuning.
16    pub fn compile_with_gpu_policy(
17        detectors: Vec<DetectorSpec>,
18        gpu_policy: GpuInitPolicy,
19    ) -> Result<Self> {
20        Self::compile_with_gpu_policy_and_tuning(
21            detectors,
22            gpu_policy,
23            &ScannerTuningConfig::default(),
24        )
25    }
26
27    /// Full-control compile entry point: explicit [`GpuInitPolicy`] and scanner
28    /// [`ScannerTuningConfig`]. The other `compile*` methods delegate here.
29    pub fn compile_with_gpu_policy_and_tuning(
30        detectors: Vec<DetectorSpec>,
31        gpu_policy: GpuInitPolicy,
32        tuning_config: &ScannerTuningConfig,
33    ) -> Result<Self> {
34        super::validation::validate_detector_corpus(&detectors)
35            .map_err(crate::error::ScanError::Config)?;
36        crate::entropy::policy::validate_feature_compatibility(&detectors)
37            .map_err(crate::error::ScanError::Config)?;
38        let decoder_plan = Arc::new(crate::decode::CompiledDecoderPlan::snapshot().map_err(
39            |error| crate::error::ScanError::Config(format!("invalid decoder registry: {error}")),
40        )?);
41        // LAW10: cfg-only Hyperscan tuning marker; no runtime effect.
42        #[cfg(not(feature = "simd"))]
43        let _tuning_config = tuning_config;
44        let state = build_compile_state(&detectors)?;
45        // Build the canonical detector execution plan before any backend
46        // projection. Backends consume only derived matcher inputs from this
47        // owner and never reinterpret detector TOML independently.
48        let static_intern_strings: Vec<&str> = detectors
49            .iter()
50            .flat_map(|detector| {
51                [
52                    detector.id.as_str(),
53                    detector.name.as_str(),
54                    detector.service.as_str(),
55                ]
56                .into_iter()
57                .chain(
58                    detector
59                        .entropy_fallback
60                        .as_ref()
61                        .into_iter()
62                        .flat_map(|metadata| {
63                            [
64                                metadata.id.as_str(),
65                                metadata.name.as_str(),
66                                metadata.service.as_str(),
67                            ]
68                        }),
69                )
70            })
71            .collect();
72        let static_intern = Arc::new(crate::static_intern::StaticInterner::from_detector_strings(
73            static_intern_strings,
74        ));
75        let detector_digest = super::detector_digest::from_execution_plan(
76            keyhog_core::compute_spec_hash(&detectors),
77            decoder_plan.identity(),
78        );
79        let detector_plans =
80            crate::detector_plan::CompiledDetectorPlans::compile_with_decoder_plan(
81                &detectors,
82                static_intern.as_ref(),
83                state.companions,
84                decoder_plan,
85            )
86            .map_err(crate::error::ScanError::Config)?;
87        validate_compiled_pattern_detector_indices(
88            &state.ac_map,
89            &state.phase2_patterns,
90            detectors.len(),
91        )?;
92        let ac = build_ac_pattern_set(&state.ac_literals)?;
93        // GPU is unconditional in the build; runtime probe decides whether to
94        // actually use it. `gpu_available` is set by hw_probe based on adapter
95        // detection (excluding software renderers like llvmpipe/lavapipe).
96        // Census every compiled GPU driver independently without retaining an
97        // execution device. Persisted autoroute evidence chooses the exact
98        // peer for each workload; the selected peer is materialized lazily at
99        // the dispatch boundary, while calibration materializes every peer it
100        // measures.
101        // `crate::gpu::gpu_disabled_by_policy()` is the single source of truth
102        // for "skip every GPU init path". The value comes from the resolved
103        // scanner runtime policy set by the CLI/TOML layer, not ambient process
104        // environment.
105        let gpu_disabled = match gpu_policy {
106            GpuInitPolicy::FromRuntimePolicy => crate::gpu::gpu_disabled_by_policy(),
107            GpuInitPolicy::ForceEnabled => false,
108            GpuInitPolicy::ForceDisabled => true,
109        };
110        if gpu_disabled {
111            let disabled_by_policy = matches!(gpu_policy, GpuInitPolicy::ForceDisabled);
112            if disabled_by_policy {
113                tracing::info!(
114                    target: "keyhog::routing",
115                    "GPU init bypassed by caller policy; scanner will use CPU/SIMD paths"
116                );
117            } else {
118                tracing::info!(
119                    target: "keyhog::routing",
120                    "GPU init bypassed by resolved scanner policy; routing every chunk through the CPU/SIMD path"
121                );
122            }
123        }
124        #[cfg(feature = "gpu")]
125        let (gpu_backends, gpu_acquisition_failures) = if !gpu_disabled {
126            let mut peers = GpuBackendPeers::default();
127            let mut failures = Vec::new();
128            {
129                #[cfg(target_os = "linux")]
130                {
131                    match super::types::probe_cuda_peer() {
132                        Ok(caps) => {
133                            peers.cuda_available = true;
134                            peers.cuda_device_identity = Some(format!(
135                                "{}:ordinal={}:cc={}.{}:vram={}",
136                                caps.name,
137                                caps.ordinal,
138                                caps.compute_capability.0,
139                                caps.compute_capability.1,
140                                caps.total_memory
141                            ));
142                            match linux_cuda_runtime_identity() {
143                                Ok(identity) => peers.cuda_runtime_identity = Some(identity),
144                                Err(diagnostic) => {
145                                    tracing::warn!(
146                                        target: "keyhog::routing",
147                                        %diagnostic,
148                                        "CUDA peer acquired without reproducible runtime identity"
149                                    );
150                                }
151                            }
152                            tracing::debug!(target: "keyhog::routing", "CUDA peer identity probed");
153                        }
154                        Err(error) => {
155                            surface_cuda_acquisition_failure(&error);
156                            failures.push(GpuBackendAcquisitionFailure {
157                                backend: "cuda",
158                                diagnostic: error.to_string(),
159                            });
160                        }
161                    }
162                }
163            }
164            if let Some(probe) = crate::gpu::gpu_adapter_probe() {
165                peers.wgpu_available = true;
166                peers.wgpu_device_identity = Some(probe.device_identity.clone());
167                peers.wgpu_runtime_identity = Some(probe.runtime_identity.clone());
168                peers.wgpu_is_software = probe.is_software;
169                tracing::debug!(target: "keyhog::routing", "WGPU peer identity probed");
170            } else {
171                failures.push(GpuBackendAcquisitionFailure {
172                    backend: "wgpu",
173                    diagnostic: "WGPU adapter census found no adapters".to_string(),
174                });
175            }
176            (peers, failures)
177        } else {
178            (GpuBackendPeers::default(), Vec::new())
179        };
180
181        // Lean (no-`gpu`) build: never link the wgpu / CUDA drivers, never
182        // probe Vulkan at startup. The hw_probe still reports its findings so
183        // downstream routing surfaces resolved GPU-policy semantics, but no
184        // backend is acquired. `gpu_disabled` stays read so the cfg-aware
185        // dead-code warning is suppressed without an `_ =` decoration.
186        #[cfg(not(feature = "gpu"))]
187        let (gpu_backends, gpu_acquisition_failures) = {
188            let _ = gpu_disabled; // LAW10: unused-binding marker (signature/borrowck/cfg/compile-time assert); no runtime effect, not a fallback
189            (GpuBackendPeers::default(), Vec::new())
190        };
191        let prefix_propagation = CsrU32::from(build_prefix_propagation(&state.ac_literals));
192        let same_prefix_patterns = CsrU32::from(build_same_prefix_patterns(&state.ac_literals));
193
194        // Build only the backend-neutral Hyperscan plan. A selected SIMD route
195        // materializes its database lazily; rejected rows then retain their
196        // exact detector-owned literals in the recovery prefilter.
197        #[cfg(feature = "simd")]
198        let simd_compile_plan =
199            build_simd_compile_plan(&state.ac_map, &state.ac_literals, tuning_config);
200        #[cfg(feature = "simd")]
201        let simd_candidate_available = simd_compile_plan.is_some();
202
203        let (phase2_keyword_ac, phase2_keyword_to_patterns, phase2_keywords) =
204            build_phase2_keyword_ac(&state.phase2_patterns);
205        let phase2_keyword_count = phase2_keywords.len();
206        let phase2_keyword_to_patterns = CsrU32::from(phase2_keyword_to_patterns);
207        // Precompute always-active phase-2 indices so the per-chunk hot path
208        // seeds the sparse active set without scanning the full phase-2 table.
209        let phase2_always_active_indices = phase2_always_active_indices(&state.phase2_patterns);
210
211        // Three independent Aho-Corasick indices over the canonical compile
212        // state. They share no mutable state and each is a pure function
213        // of `state`, so they build concurrently on the rayon pool instead of
214        // back-to-back (~82ms -> ~46ms serial->parallel on the full corpus):
215        //   - phase2_anchor_index: shared-anchor localization over every phase-2
216        //     pattern's regex-REQUIRED prefix literals, so one chunk pass yields
217        //     candidate positions for all eligible patterns. Built BEFORE the
218        //     prefilter so eligible always-active patterns can be removed from it
219        //     (the prefilter, not extraction, is ~90% of phase-2 cost). `None`
220        //     when no pattern is anchor-eligible. Recall-identical.
221        //   - suffix gate: one AC over required suffix literals so a triggered
222        //     detector whose rare trailing literal (`.*<sitename>`) is absent
223        //     skips its O(chunk) whole-chunk regex run.
224        //   - confirmed_anchor_index: AC over the confirmed ac_map anchors.
225        let (phase2_anchor_index, ((suffix_gate_ac, ac_suffix_gate), confirmed_anchor_index)) =
226            rayon::join(
227                || Phase2AnchorIndex::build(&state.phase2_patterns, &phase2_always_active_indices),
228                || {
229                    rayon::join(
230                        || build_confirmed_suffix_gate(&state.ac_map),
231                        || ConfirmedAnchorIndex::build(&state.ac_map),
232                    )
233                },
234            );
235        let phase2_always_anchor_literal_count = phase2_anchor_index
236            .as_ref()
237            .map_or(0, |index| index.always_anchor_literals().len());
238        #[cfg(feature = "gpu")]
239        let confirmed_anchor_literals = confirmed_anchor_index
240            .as_ref()
241            .map_or(&[] as &[String], |index| index.anchor_literals());
242        #[cfg(feature = "gpu")]
243        let confirmed_anchor_literal_count = confirmed_anchor_literals.len();
244        #[cfg(feature = "gpu")]
245        let generic_keyword_literals = detector_plans
246            .generic_assignment()
247            .map(|plan| plan.stem_literals().map(str::to_owned).collect::<Vec<_>>())
248            // LAW10: absence means the validated corpus has no generic assignment owner, so there is no generic matcher or recall surface to populate.
249            .unwrap_or_default();
250        #[cfg(feature = "gpu")]
251        let generic_keyword_literal_count = generic_keyword_literals.len();
252        let gated = ac_suffix_gate.iter().filter(|g| !g.is_empty()).count();
253        #[cfg(feature = "gpu")]
254        let gpu_literals = if gpu_backends.availability().any() {
255            let phase2_always_anchor_literals = phase2_anchor_index
256                .as_ref()
257                .map_or(&[] as &[String], |index| index.always_anchor_literals());
258            build_gpu_literals(
259                &state.ac_literals,
260                &phase2_keywords,
261                phase2_always_anchor_literals,
262                confirmed_anchor_literals,
263                &generic_keyword_literals,
264            )
265        } else {
266            None
267        };
268        #[cfg(not(feature = "gpu"))]
269        let gpu_literals: Option<Arc<Vec<Vec<u8>>>> = None;
270        #[cfg(feature = "gpu")]
271        let gpu_max_literal_len = gpu_literals.as_ref().map_or(0, |literals| {
272            literals
273                .iter()
274                .fold(0, |longest, literal| longest.max(literal.len()))
275        });
276
277        // Compile one ownership plan for the always-active phase-2 set. The full
278        // scope serves legacy extraction and admission; anchored extraction uses
279        // residual scopes that omit patterns already owned by its localizers.
280        // Hyperscan and portable RegexSet engines consume the same scopes lazily.
281        let phase2_always_active_prefilter = phase2::Phase2AlwaysActivePrefilter::build(
282            &state.phase2_patterns,
283            &phase2_always_active_indices,
284            phase2_anchor_index.as_ref(),
285        );
286        tracing::debug!(
287            eligible = phase2_anchor_index
288                .as_ref()
289                .map_or(0, |i| i.eligible_count()),
290            total = state.phase2_patterns.len(),
291            always_active = phase2_always_active_indices.len(),
292            "phase-2 prefilter built with homoglyph ASCII-folded fast path"
293        );
294
295        tracing::debug!(
296            gated,
297            anchored = confirmed_anchor_index
298                .as_ref()
299                .map_or(0, |index| index.eligible_count()),
300            total = state.ac_map.len(),
301            "confirmed suffix/anchor gates built"
302        );
303
304        log_quality_warnings(&state.quality_warnings);
305
306        let mut alphabet_targets = state.ac_literals.clone();
307        // Reserve the exact keyword total up front and clone each keyword
308        // straight in (`iter().cloned()`), instead of materializing a throwaway
309        // `Vec<String>` per phase-2 pattern via `keywords.clone()` and growing
310        // `alphabet_targets` by repeated reallocation (Law 7). Byte-identical:
311        // the same keyword strings land in the same order.
312        let extra_keyword_count: usize = state
313            .phase2_patterns
314            .iter()
315            .map(|(_, keywords)| keywords.len())
316            .sum();
317        alphabet_targets.reserve(extra_keyword_count);
318        for (_, keywords) in &state.phase2_patterns {
319            alphabet_targets.extend(keywords.iter().cloned());
320        }
321        let alphabet_screen = if alphabet_targets.is_empty() {
322            None
323        } else {
324            Some(crate::alphabet_filter::AlphabetScreen::new(
325                &alphabet_targets,
326            ))
327        };
328
329        let bigram_bloom =
330            crate::bigram_bloom::BigramBloom::from_literal_prefixes(&alphabet_targets);
331        tracing::debug!(
332            popcount = bigram_bloom.popcount(),
333            "bigram bloom built (65536 slots / 8 KB direct table, lower popcount = stronger filter)"
334        );
335
336        // Pre-resolve the detector-wide weak-anchor base once. The per-pattern
337        // bit is compiled beside its regex, so mixed detectors protect only the
338        // patterns that declare the policy. Built before `detectors` is moved.
339        let missing_weak_anchor_floors = detectors
340            .iter()
341            .enumerate()
342            .filter_map(|(index, detector)| {
343                let has_weak_pattern = match detector_plans.get(index).weak_anchor_base {
344                    crate::suppression::WeakAnchorBase::Always => true,
345                    crate::suppression::WeakAnchorBase::PerPattern => {
346                        detector.patterns.iter().any(|pattern| pattern.weak_anchor)
347                    }
348                    crate::suppression::WeakAnchorBase::Never => false,
349                };
350                (has_weak_pattern && detector_plans.get(index).entropy_floor.is_none())
351                    .then_some(detector.id.as_str())
352            })
353            .collect::<Vec<_>>();
354        if !missing_weak_anchor_floors.is_empty() {
355            return Err(crate::error::ScanError::Config(format!(
356                "weak-anchor detectors omit detector-local entropy_high/entropy_floor policy: {}",
357                missing_weak_anchor_floors.join(", ")
358            )));
359        }
360        // Resolve the detector-owned hot-prefix table once, then mark its exact
361        // confirmed delegates. Limiting suppression to the delegate is
362        // recall-safe when one detector has overlapping regexes at one offset.
363        #[cfg(feature = "simdsieve")]
364        let hot_pattern_slots = build_hot_pattern_slots(&detectors, &state.ac_map)?;
365        #[cfg(feature = "simdsieve")]
366        let hot_confirmed_by_pattern = {
367            let mut hot = vec![false; state.ac_map.len()];
368            for slot in &hot_pattern_slots {
369                hot[slot.ac_map_index] = true;
370            }
371            hot
372        };
373        #[cfg(not(feature = "simdsieve"))]
374        let hot_confirmed_by_pattern = vec![false; state.ac_map.len()];
375
376        let pattern_boundary_context = derive_pattern_boundary_context(
377            state
378                .ac_map
379                .iter()
380                .chain(state.phase2_patterns.iter().map(|(pattern, _)| pattern)),
381        );
382        #[cfg(feature = "gpu")]
383        let ac_match_upper_bounds: Vec<Option<usize>> = state
384            .ac_map
385            .iter()
386            .map(|pattern| regex_match_byte_upper_bound(pattern.regex.as_str()))
387            .collect();
388
389        let mut structural_confirmed_patterns = vec![Vec::new(); detectors.len()];
390        for (pattern_index, pattern) in state.ac_map.iter().enumerate() {
391            if pattern.structural_password_slot {
392                structural_confirmed_patterns[pattern.detector_index].push(pattern_index);
393            }
394        }
395        let mut structural_phase2_patterns = vec![Vec::new(); detectors.len()];
396        for (pattern_index, (pattern, _)) in state.phase2_patterns.iter().enumerate() {
397            if pattern.structural_password_slot {
398                structural_phase2_patterns[pattern.detector_index].push(pattern_index);
399            }
400        }
401        let scanner = Self {
402            detector_digest,
403            ac,
404            gpu_backends,
405            gpu_acquisition_failures,
406            gpu_literals,
407            #[cfg(feature = "gpu")]
408            gpu_max_literal_len,
409            gpu_matcher: OnceLock::new(),
410            #[cfg(feature = "gpu")]
411            gpu_resident_literal_cuda: std::sync::Mutex::new(GpuResidentLiteralSlot::Empty),
412            #[cfg(feature = "gpu")]
413            gpu_resident_literal_wgpu: std::sync::Mutex::new(GpuResidentLiteralSlot::Empty),
414            gpu_last_degrade_reason: std::sync::Mutex::new(None),
415            gpu_degrade_count: std::sync::atomic::AtomicU64::new(0),
416            autoroute_gpu_shared_cold_ns: std::sync::atomic::AtomicU64::new(0),
417            static_intern,
418            detector_plans,
419            assignment_keyword_matcher: std::sync::Mutex::new(
420                crate::assignment_keyword_matcher::AssignmentKeywordMatcherCache::default(),
421            ),
422            #[cfg(feature = "gpu")]
423            ac_match_upper_bounds,
424            suffix_gate_ac,
425            ac_suffix_gate,
426            hot_confirmed_by_pattern,
427            confirmed_anchor_index,
428            ac_map: state.ac_map,
429            pattern_boundary_context,
430            prefix_propagation,
431            phase2_patterns: state.phase2_patterns,
432            structural_confirmed_patterns,
433            structural_phase2_patterns,
434            same_prefix_patterns,
435            phase2_keyword_ac,
436            phase2_keyword_to_patterns,
437            phase2_keyword_count,
438            phase2_always_anchor_literal_count,
439            #[cfg(feature = "gpu")]
440            confirmed_anchor_literal_count,
441            #[cfg(feature = "gpu")]
442            generic_keyword_literal_count,
443            phase2_always_active_indices,
444            phase2_always_active_prefilter,
445            phase2_anchor_index,
446            #[cfg(feature = "gpu")]
447            phase2_gpu_dfa: Phase2GpuDfaCatalogCache::default(),
448            tuning: phase2::ScannerTuning::from_defaults(),
449            #[cfg(feature = "simd")]
450            simd_candidate_available,
451            #[cfg(feature = "simd")]
452            simd_compile_plan: std::sync::Mutex::new(simd_compile_plan),
453            #[cfg(feature = "simd")]
454            simd_prefilter: std::sync::OnceLock::new(),
455            #[cfg(feature = "simd")]
456            simd_initialization_ns: std::sync::atomic::AtomicU64::new(0),
457            #[cfg(feature = "simdsieve")]
458            hot_pattern_slots,
459            config: ScannerConfig::default(),
460            alphabet_screen,
461            bigram_bloom,
462            fragment_cache: crate::fragment_cache::FragmentCache::new(1000),
463        };
464
465        Ok(scanner)
466    }
467
468    /// Apply a custom configuration to the compiled scanner.
469    pub fn with_config(mut self, config: ScannerConfig) -> Self {
470        profile::set_profile_enabled(config.profile);
471        profile::set_perf_trace_enabled(config.perf_trace);
472        self.config = config;
473        self
474    }
475
476    /// Apply explicit performance-route tuning to this compiled scanner.
477    pub fn with_tuning_config(self, config: ScannerTuningConfig) -> Self {
478        self.tuning.apply_config(&config);
479        self
480    }
481}
482
483#[cfg(all(target_os = "linux", feature = "gpu"))]
484fn linux_cuda_runtime_identity() -> std::result::Result<String, String> {
485    let version = std::fs::read_to_string("/proc/driver/nvidia/version")
486        .map_err(|error| format!("cannot read /proc/driver/nvidia/version: {error}"))?;
487    let version = version.split_whitespace().collect::<Vec<_>>().join(" ");
488    if version.is_empty() {
489        Err("/proc/driver/nvidia/version contains no runtime identity".to_owned())
490    } else {
491        Ok(format!("nvidia-kernel:{version}"))
492    }
493}