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