Skip to main content

keyhog_scanner/engine/
scan_coalesced.rs

1// `scan_filters` is consumed by `should_scan_no_hit_chunk` (the no-phase-1-hit
2// admission gate) on the shared phase-2 tail. SIMD and GPU use it after their
3// trigger pass. Portable builds use it before their phase-2 tail so no-hit
4// chunks are not dropped before anchorless detection.
5#[cfg(feature = "simd")]
6use super::phase2::Phase2AlwaysActiveGpuEvidence;
7use super::scan_filters::*;
8use super::*;
9
10#[cfg(feature = "simd")]
11use std::cell::RefCell;
12
13// The trigger-buffer pool is only used in the Hyperscan-prefilter scratch path
14// of `scan_coalesced`. The pool's win is reuse of buffers that stay inside the
15// pool; extending it to per-chunk trigger builders regressed long-lines benches.
16#[cfg(feature = "simd")]
17thread_local! {
18    /// Per-thread pool of trigger-bitmask vectors. Phase-1 of `scan_coalesced`
19    /// allocates one `Vec<u64>` of size `ac_len.div_ceil(64)` per chunk.
20    static TRIGGER_POOL: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
21}
22
23#[cfg(feature = "simd")]
24#[inline]
25fn with_trigger_buffer<R>(words_needed: usize, f: impl FnOnce(&mut [u64]) -> R) -> R {
26    TRIGGER_POOL.with(|cell| {
27        let mut buf = cell.borrow_mut();
28        if buf.len() < words_needed {
29            buf.resize(words_needed, 0);
30        }
31        let slice = &mut buf[..words_needed];
32        slice.fill(0);
33        f(slice)
34    })
35}
36
37#[cfg(feature = "simd")]
38#[inline]
39fn mark_hs_trigger(
40    scratch: &mut [u64],
41    prefilter: &super::SimdPhase1Prefilter,
42    ac_len: usize,
43    hs_id: usize,
44) {
45    if let Some(orig) = prefilter.original_indices(hs_id) {
46        for &idx in orig {
47            let idx = idx as usize;
48            if idx < ac_len {
49                scratch[idx / 64] |= 1u64 << (idx % 64);
50            }
51        }
52    }
53}
54
55impl CompiledScanner {
56    #[cfg(feature = "simd")]
57    #[inline]
58    fn post_process_coalesced_matches(
59        &self,
60        chunk: &keyhog_core::Chunk,
61        matches: &mut Vec<keyhog_core::RawMatch>,
62        route: crate::ScanExecutionRoute,
63    ) {
64        if self.chunk_needs_decode_postprocess(chunk) {
65            self.post_process_matches(chunk, matches, None, route);
66        } else {
67            self.scan_cross_chunk_fragments(chunk, matches, None, route);
68        }
69    }
70
71    #[cfg(feature = "simd")]
72    #[inline]
73    fn decode_only_coalesced_matches(
74        &self,
75        chunk: &keyhog_core::Chunk,
76        route: crate::ScanExecutionRoute,
77    ) -> Option<Vec<keyhog_core::RawMatch>> {
78        if !self.chunk_needs_decode_postprocess(chunk) {
79            return None;
80        }
81        let mut matches = Vec::new();
82        self.post_process_matches(chunk, &mut matches, None, route);
83        Some(matches)
84    }
85
86    /// High-throughput coalesced scan: all files scanned in parallel, zero
87    /// overhead for non-hit files.
88    ///
89    /// Direct library backend selection is a hard process contract. CLI
90    /// orchestrators that own stable input replay use the fallible companion
91    /// method below and record any automatic-route recovery explicitly.
92    pub fn scan_coalesced_with_backend(
93        &self,
94        chunks: &[keyhog_core::Chunk],
95        backend: crate::hw_probe::ScanBackend,
96    ) -> Vec<Vec<keyhog_core::RawMatch>> {
97        self.scan_coalesced_with_backend_and_admission(chunks, backend, None)
98    }
99
100    /// Coalesced scan using admission evidence computed by the autoroute key
101    /// builder. A mismatched plan is ignored and the scanner recomputes its
102    /// own exact admissions, preserving recall over the optimization.
103    pub fn scan_coalesced_with_backend_and_admission(
104        &self,
105        chunks: &[keyhog_core::Chunk],
106        backend: crate::hw_probe::ScanBackend,
107        plan: Option<&super::Phase1AdmissionPlan>,
108    ) -> Vec<Vec<keyhog_core::RawMatch>> {
109        self.scan_coalesced_with_backend_admission_and_route(
110            chunks,
111            backend,
112            plan,
113            self.execution_route_for_backend(backend),
114        )
115    }
116
117    /// Coalesced scan with an explicit recall-equivalent execution route.
118    pub fn scan_coalesced_with_backend_admission_and_route(
119        &self,
120        chunks: &[keyhog_core::Chunk],
121        backend: crate::hw_probe::ScanBackend,
122        plan: Option<&super::Phase1AdmissionPlan>,
123        route: crate::ScanExecutionRoute,
124    ) -> Vec<Vec<keyhog_core::RawMatch>> {
125        match self.try_scan_coalesced_with_backend_admission_and_route(chunks, backend, plan, route)
126        {
127            Ok(matches) => matches,
128            Err(crate::error::ScanError::Gpu(reason)) => {
129                super::gpu_forced::fail_selected_gpu_dispatch(self, &reason)
130            }
131            Err(error) => crate::process_exit::backend_unavailable(format!(
132                "selected scanner backend failed: {error}"
133            )),
134        }
135    }
136
137    /// Fallible production dispatch boundary for callers that require the
138    /// selected backend as a hard contract. Automatic orchestrators use the
139    /// recovery-aware companion below with an explicitly stable snapshot.
140    pub fn try_scan_coalesced_with_backend_and_admission(
141        &self,
142        chunks: &[keyhog_core::Chunk],
143        backend: crate::hw_probe::ScanBackend,
144        plan: Option<&super::Phase1AdmissionPlan>,
145    ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
146        self.try_scan_coalesced_with_backend_admission_and_route(
147            chunks,
148            backend,
149            plan,
150            self.execution_route_for_backend(backend),
151        )
152    }
153
154    /// Fallible production dispatch with an immutable per-request execution route.
155    pub fn try_scan_coalesced_with_backend_admission_and_route(
156        &self,
157        chunks: &[keyhog_core::Chunk],
158        backend: crate::hw_probe::ScanBackend,
159        plan: Option<&super::Phase1AdmissionPlan>,
160        route: crate::ScanExecutionRoute,
161    ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
162        self.try_scan_coalesced_with_backend_admission_route_and_recovery(
163            chunks, backend, plan, route, false,
164        )
165        .map(|outcome| outcome.matches)
166    }
167
168    /// Fallible dispatch that may recover exact failed GPU dispatch ranges when
169    /// the caller owns a stable input snapshot and explicitly permits recovery.
170    pub fn try_scan_coalesced_with_backend_admission_route_and_recovery(
171        &self,
172        chunks: &[keyhog_core::Chunk],
173        backend: crate::hw_probe::ScanBackend,
174        plan: Option<&super::Phase1AdmissionPlan>,
175        route: crate::ScanExecutionRoute,
176        #[cfg_attr(not(feature = "gpu"), allow(unused_variables))]
177        recover_gpu_dispatch_faults: bool,
178    ) -> crate::error::Result<super::CoalescedScanOutcome> {
179        let expected_residual_backend = if backend.is_gpu() {
180            crate::hw_probe::ScanBackend::CpuFallback
181        } else {
182            backend
183        };
184        if route.decode_backend != expected_residual_backend {
185            return Err(crate::error::ScanError::Config(format!(
186                "{} route declares {} residual execution, expected {}. Rebuild the execution route from the selected backend",
187                backend.label(),
188                route.decode_backend.label(),
189                expected_residual_backend.label(),
190            )));
191        }
192        let result = if backend == crate::hw_probe::ScanBackend::SimdCpu {
193            self.try_initialize_simd_backend().map_err(|error| {
194                crate::error::ScanError::Simd(format!(
195                    "selected Hyperscan backend initialization failed: {error}"
196                ))
197            })?;
198            Ok(super::CoalescedScanOutcome {
199                matches: self.scan_coalesced_simd(
200                    chunks,
201                    plan.filter(|plan| plan.matches_chunks(chunks)),
202                    route,
203                )?,
204                recovery: None,
205            })
206        } else if backend.is_gpu() {
207            #[cfg(feature = "gpu")]
208            {
209                self.try_scan_coalesced_gpu_region_presence_recovering(
210                    chunks,
211                    backend,
212                    route,
213                    recover_gpu_dispatch_faults,
214                )
215                .map_err(|error| {
216                    self.record_gpu_runtime_fault(error.reason());
217                    crate::error::ScanError::Gpu(error.to_string())
218                })
219            }
220            #[cfg(not(feature = "gpu"))]
221            {
222                Err(crate::error::ScanError::Gpu(format!(
223                    "{} selected but this scanner build has no GPU support",
224                    backend.label()
225                )))
226            }
227        } else {
228            Ok(super::CoalescedScanOutcome {
229                matches: self.scan_chunks_with_backend_internal_admission_and_route(
230                    chunks,
231                    backend,
232                    plan.filter(|plan| plan.matches_chunks(chunks)),
233                    route,
234                ),
235                recovery: None,
236            })
237        };
238        // Count logical input only after a complete route succeeds. A failed GPU
239        // attempt followed by visible CPU replay therefore records the input
240        // once, while every successful coalesced backend reports the same bytes.
241        if result.is_ok() {
242            profile::add_bytes(chunks.iter().map(|chunk| chunk.data.len() as u64).sum());
243            profile::add_files(chunks.len() as u64);
244        }
245        result
246    }
247
248    /// Deterministic portable reference scan over several chunks.
249    ///
250    /// Accelerated callers use [`Self::scan_coalesced_with_backend`] with an
251    /// explicit measured backend. Keeping the no-backend API on `CpuFallback`
252    /// makes library results independent of host hardware and calibration state.
253    pub fn scan_coalesced(&self, chunks: &[keyhog_core::Chunk]) -> Vec<Vec<keyhog_core::RawMatch>> {
254        self.scan_chunks_with_backend(chunks, crate::hw_probe::ScanBackend::CpuFallback)
255    }
256
257    /// Explicit Hyperscan coalesced path: all files scanned in parallel, zero
258    /// overhead for non-hit files. Only reached for `ScanBackend::SimdCpu`.
259    #[allow(clippy::needless_return)] // return needed under non-simd cfg branch
260    fn scan_coalesced_simd(
261        &self,
262        chunks: &[keyhog_core::Chunk],
263        admission_plan: Option<&super::Phase1AdmissionPlan>,
264        route: crate::ScanExecutionRoute,
265    ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
266        #[cfg(not(feature = "simd"))]
267        {
268            // LAW10: no-runtime-effect; this cfg-only binding precedes a fail-closed unsupported-backend error.
269            let _ = (chunks, admission_plan, route);
270            return Err(crate::error::ScanError::Simd(
271                "selected SimdCpu/Hyperscan backend but this binary was built without the `simd` feature; rebuild with simd or choose --backend cpu".to_string(),
272            ));
273        }
274
275        #[cfg(feature = "simd")]
276        {
277            let prefilter = self.try_simd_prefilter().map_err(|error| {
278                crate::error::ScanError::Simd(format!(
279                    "selected Hyperscan backend was not initialized: {error}"
280                ))
281            })?;
282
283            // Coalesced SIMD bypasses `scan_inner`, so it owns the same scanner
284            // telemetry events. Logical profiler input is recorded once by the
285            // shared successful coalesced-dispatch boundary above.
286            for chunk in chunks {
287                crate::telemetry::record_file_scanned(chunk.data.len());
288            }
289            let triggers = {
290                let _g = profile::span(profile::P::Phase1Triggers);
291                self.compute_coalesced_triggers(chunks, prefilter, admission_plan)
292                    .map_err(crate::error::ScanError::Simd)?
293            };
294            return Ok(self.scan_coalesced_phase2(chunks, triggers, route));
295        }
296    }
297
298    /// Phase 1 of the coalesced scan: Hyperscan-confirmed rows plus exact
299    /// detector-literal recovery over raw chunk bytes, producing one trigger
300    /// bitmap per chunk. GPU region presence is the alternative producer
301    /// feeding the same phase 2.
302    #[cfg(feature = "simd")]
303    pub(crate) fn compute_coalesced_triggers(
304        &self,
305        chunks: &[keyhog_core::Chunk],
306        prefilter: &super::SimdPhase1Prefilter,
307        admission_plan: Option<&super::Phase1AdmissionPlan>,
308    ) -> Result<Vec<Option<Vec<u64>>>, String> {
309        use rayon::prelude::*;
310        let ac_len = self.ac_map.len();
311        let words_needed = super::trigger_bitmap::words_for(ac_len);
312        let triggers: Result<Vec<Option<Vec<u64>>>, String> = chunks
313            .par_iter()
314            .enumerate()
315            .map(|(chunk_index, chunk)| {
316                let data = chunk.data.as_bytes();
317                let admission =
318                    match admission_plan.and_then(|plan| plan.admission_for(chunk_index)) {
319                        Some(admission) => admission,
320                        None => self.phase1_admission(data),
321                    };
322                if admission != super::Phase1Admission::Admitted {
323                    return Ok(None);
324                }
325                with_trigger_buffer(words_needed, |scratch| {
326                    let scanner = prefilter.scanner();
327                    scanner.scan_each_result(data, |hs_id| {
328                        mark_hs_trigger(scratch, prefilter, ac_len, hs_id);
329                    })?;
330                    prefilter.for_each_recovery_match(data, |pattern_index| {
331                        self.mark_triggered_pattern(scratch, pattern_index);
332                    });
333                    if scratch.iter().any(|&w| w != 0) {
334                        Ok(Some(scratch.to_vec()))
335                    } else {
336                        Ok(None)
337                    }
338                })
339            })
340            .collect();
341        let triggers = triggers?;
342
343        if tracing::enabled!(tracing::Level::INFO) {
344            let hit_count = triggers.iter().filter(|t| t.is_some()).count();
345            let total_hs_matches: usize = triggers
346                .iter()
347                .filter_map(|t| t.as_ref())
348                .map(|t| t.iter().map(|w| w.count_ones() as usize).sum::<usize>())
349                .sum();
350            tracing::info!(
351                files = chunks.len(),
352                hits = hit_count,
353                triggered_patterns = total_hs_matches,
354                "coalesced scan phase 1 complete"
355            );
356        }
357        Ok(triggers)
358    }
359
360    /// No-hit chunk admission: should a chunk that produced no phase-1 trigger
361    /// still be driven through the phase-2 / generic / entropy tail?
362    pub(crate) fn should_scan_no_hit_chunk(
363        &self,
364        chunk: &keyhog_core::Chunk,
365        route: crate::ScanExecutionRoute,
366    ) -> bool {
367        self.should_scan_no_hit_chunk_with_phase2_absence_proof(chunk, false, route)
368    }
369
370    fn should_scan_no_hit_chunk_with_phase2_absence_proof(
371        &self,
372        chunk: &keyhog_core::Chunk,
373        raw_phase2_absence_proven: bool,
374        route: crate::ScanExecutionRoute,
375    ) -> bool {
376        let raw_text = chunk.data.as_ref();
377        if self.no_hit_text_admits(chunk, raw_text, raw_phase2_absence_proven, route) {
378            return true;
379        }
380
381        if !self.config.unicode_normalization
382            || !crate::unicode_hardening::contains_evasion(raw_text)
383        {
384            return false;
385        }
386
387        let prepared = self.prepare_chunk(chunk);
388        let normalized = prepared.preprocessed.text.as_ref();
389        if normalized.as_bytes() == raw_text.as_bytes() {
390            return false;
391        }
392        let normalized_triggers =
393            self.collect_triggered_patterns_for_backend(normalized, route.decode_backend);
394        normalized_triggers.iter().any(|&word| word != 0)
395            || self.no_hit_text_admits(chunk, normalized, false, route)
396    }
397
398    fn no_hit_text_admits(
399        &self,
400        _chunk: &keyhog_core::Chunk,
401        text: &str,
402        phase2_absence_proven: bool,
403        route: crate::ScanExecutionRoute,
404    ) -> bool {
405        if !phase2_absence_proven && self.has_active_phase2_patterns_for_chunk(text, route) {
406            return true;
407        }
408        let data = text.as_bytes();
409        let keyword_admits = self
410            .detector_plans
411            .generic_assignment()
412            .is_some_and(|plan| plan.stems().is_match(data))
413            || has_secret_keyword_fast(data);
414        if keyword_admits {
415            return true;
416        }
417        #[cfg(feature = "entropy")]
418        let isolated_bare_owner_index = self
419            .detector_plans
420            .generic_ownership()
421            .isolated_bare_owner_index();
422        #[cfg(feature = "entropy")]
423        let isolated_bare_policy = isolated_bare_owner_index
424            .and_then(|index| self.detector_plans.get(index).entropy.as_ref())
425            .copied();
426        #[cfg(feature = "entropy")]
427        let keyword_free_min_len = self
428            .detector_plans
429            .generic_ownership()
430            .keyword_free_owner_index()
431            .and_then(|index| self.detector_plans.get(index).entropy.as_ref())
432            .and_then(|policy| {
433                let sensitive_path = _chunk
434                    .metadata
435                    .path
436                    .as_deref()
437                    .is_some_and(crate::confidence::is_sensitive_path);
438                policy.keyword_free_admission_run_min_len(
439                    self.config.entropy_threshold,
440                    sensitive_path,
441                )
442            });
443        #[cfg(feature = "multiline")]
444        if crate::multiline::has_concatenation_indicators(text) {
445            #[cfg(feature = "entropy")]
446            if let Some(policy) = isolated_bare_policy.filter(|_| self.config.entropy_enabled) {
447                if crate::entropy::scanner::has_isolated_bare_secret_candidate_with_policy(
448                    text,
449                    self.config.entropy_threshold,
450                    &self.config.placeholder_keywords,
451                    policy.keyword_free_min_len,
452                    &policy,
453                ) {
454                    return true;
455                }
456            }
457        }
458        #[cfg(feature = "entropy")]
459        let entropy_admits = self.config.entropy_enabled
460            && ((keyword_free_min_len
461                .is_some_and(|minimum| has_high_entropy_run_at_least(data, minimum))
462                && crate::entropy::is_entropy_appropriate_with_content(
463                    _chunk.metadata.path.as_deref(),
464                    self.config.entropy_in_source_files,
465                    text,
466                    &self.config.secret_keywords,
467                ))
468                || isolated_bare_policy.is_some_and(|policy| {
469                    crate::entropy::scanner::has_isolated_bare_secret_candidate_with_policy(
470                        text,
471                        self.config.entropy_threshold,
472                        &self.config.placeholder_keywords,
473                        policy.keyword_free_min_len,
474                        &policy,
475                    )
476                }));
477        #[cfg(feature = "entropy")]
478        {
479            entropy_admits
480        }
481        #[cfg(not(feature = "entropy"))]
482        {
483            false
484        }
485    }
486
487    /// Shared phase-2 tail for the SIMD coalesced producer and GPU
488    /// region-presence producer. Both backends feed identical per-chunk trigger
489    /// bitmaps into this owner so findings remain backend-invariant.
490    #[cfg(feature = "simd")]
491    pub(crate) fn scan_coalesced_phase2(
492        &self,
493        chunks: &[keyhog_core::Chunk],
494        triggers: Vec<Option<Vec<u64>>>,
495        route: crate::ScanExecutionRoute,
496    ) -> Vec<Vec<keyhog_core::RawMatch>> {
497        self.scan_coalesced_phase2_with_admission(
498            chunks, triggers, None, None, None, None, None, None, None, route,
499        )
500    }
501
502    #[cfg(feature = "simd")]
503    fn normalize_coalesced_phase2_triggers(
504        &self,
505        chunks: &[keyhog_core::Chunk],
506        triggers: Vec<Option<Vec<u64>>>,
507        route: crate::ScanExecutionRoute,
508    ) -> Vec<Option<Vec<u64>>> {
509        let chunk_count = chunks.len();
510        let trigger_count = triggers.len();
511        if trigger_count == chunk_count {
512            return triggers;
513        }
514
515        // KH-1431: cardinality mismatch used to warn-and-truncate/pad. Truncation
516        // can drop trigger rows (recall loss). Fail closed: recompute every row
517        // from chunk bytes so no trigger is silently discarded, and surface on
518        // stderr so the operator sees the invariant break without RUST_LOG.
519        eprintln!(
520            "keyhog: ERROR coalesced phase-2 trigger row count mismatch \
521             (chunks={chunk_count}, trigger_rows={trigger_count}); recomputing \
522             all trigger rows from chunk bytes (KH-1431)"
523        );
524        tracing::error!(
525            chunks = chunk_count,
526            trigger_rows = trigger_count,
527            "coalesced phase-2 trigger row count mismatch; recomputing all rows (fail closed)"
528        );
529        crate::telemetry::record_boundary_result_cardinality_mismatch();
530        drop(triggers);
531        let mut recomputed = Vec::with_capacity(chunk_count);
532        for chunk in chunks {
533            let triggered =
534                self.collect_triggered_patterns_for_backend(&chunk.data, route.decode_backend);
535            if triggered.iter().any(|&word| word != 0) {
536                recomputed.push(Some(triggered));
537            } else {
538                recomputed.push(None);
539            }
540        }
541        recomputed
542    }
543
544    /// [`scan_coalesced_phase2`](Self::scan_coalesced_phase2) with an optional
545    /// producer-side phase-2 admission bitmap. A complete negative prefixless
546    /// row composed with complete fused-anchor absence skips the redundant CPU
547    /// always-active prefilter and extraction. Keyword-triggered phase two,
548    /// generic, entropy, multiline, decode, normalized text, ML, and recovery
549    /// remain under their canonical owners.
550    #[cfg(feature = "simd")]
551    pub(crate) fn scan_coalesced_phase2_with_admission(
552        &self,
553        chunks: &[keyhog_core::Chunk],
554        triggers: Vec<Option<Vec<u64>>>,
555        phase2_admission: Option<&[bool]>,
556        phase2_admission_complete: Option<&[bool]>,
557        phase2_keyword_hints: Option<&[Vec<u32>]>,
558        phase2_always_anchor_presence: Option<&[bool]>,
559        phase2_always_anchor_literal_matches: Option<&[Vec<(u32, u32)>]>,
560        confirmed_anchor_literal_matches: Option<&[Vec<(u32, u32)>]>,
561        generic_keyword_positions: Option<&[Vec<u32>]>,
562        route: crate::ScanExecutionRoute,
563    ) -> Vec<Vec<keyhog_core::RawMatch>> {
564        use rayon::prelude::*;
565
566        let triggers = self.normalize_coalesced_phase2_triggers(chunks, triggers, route);
567        let perf_trace = super::profile::perf_trace_enabled();
568        let phase2_start = perf_trace.then(std::time::Instant::now);
569        let telemetry = crate::telemetry::capture_scan_telemetry();
570        struct CoalescedChunkOutput {
571            state: Option<crate::types::ScanState>,
572            matches: Vec<keyhog_core::RawMatch>,
573            needs_postprocess: bool,
574        }
575
576        let mut outputs: Vec<CoalescedChunkOutput> = chunks
577            .par_iter()
578            .zip(triggers.into_par_iter())
579            .enumerate()
580            .map(|(chunk_index, (chunk, triggered_opt))| {
581                crate::telemetry::with_captured_scan_telemetry(telemetry.as_ref(), || {
582                    let keyword_hints = phase2_keyword_hints
583                        .and_then(|rows| rows.get(chunk_index))
584                        .map(Vec::as_slice);
585                    let always_anchor_present = phase2_always_anchor_presence
586                        .and_then(|rows| rows.get(chunk_index).copied());
587                    let always_anchor_literal_matches = phase2_always_anchor_literal_matches
588                        .and_then(|rows| rows.get(chunk_index))
589                        .map(Vec::as_slice);
590                    let admitted_by_phase2_gpu = match phase2_admission
591                        .and_then(|admission| admission.get(chunk_index))
592                        .copied()
593                    {
594                        Some(admitted) => admitted,
595                        None => false,
596                    };
597                    let phase2_gpu_complete = match phase2_admission_complete
598                        .and_then(|complete| complete.get(chunk_index))
599                        .copied()
600                    {
601                        Some(complete) => complete,
602                        None => false,
603                    };
604                    let phase2_always_active_gpu_evidence =
605                        always_anchor_present.map(|anchor_present| Phase2AlwaysActiveGpuEvidence {
606                            prefixless_admitted: admitted_by_phase2_gpu,
607                            prefixless_complete: phase2_gpu_complete,
608                            anchor_present,
609                            anchor_literal_matches: always_anchor_literal_matches,
610                        });
611                    let confirmed_anchor_matches = confirmed_anchor_literal_matches
612                        .and_then(|rows| rows.get(chunk_index))
613                        .map(Vec::as_slice);
614                    let generic_keyword_positions = generic_keyword_positions
615                        .and_then(|rows| rows.get(chunk_index))
616                        .map(Vec::as_slice);
617                    if let Some(triggered) = triggered_opt {
618                        if chunk.data.len() > MAX_SCAN_CHUNK_BYTES {
619                            let matches = self.scan_windowed_with_triggered(
620                                chunk,
621                                &triggered,
622                                None,
623                                keyword_hints,
624                                phase2_always_active_gpu_evidence,
625                                confirmed_anchor_matches,
626                                generic_keyword_positions,
627                                route,
628                            );
629                            return CoalescedChunkOutput {
630                                state: None,
631                                matches,
632                                needs_postprocess: true,
633                            };
634                        } else {
635                            let prepared = self.prepare_chunk(chunk);
636                            let state = self.scan_prepared_state_with_triggered(
637                                prepared,
638                                &triggered,
639                                None,
640                                keyword_hints,
641                                phase2_always_active_gpu_evidence,
642                                confirmed_anchor_matches,
643                                generic_keyword_positions,
644                                route,
645                            );
646                            return CoalescedChunkOutput {
647                                state: Some(state),
648                                matches: Vec::new(),
649                                needs_postprocess: true,
650                            };
651                        }
652                    }
653                    let raw_phase2_absence_proven = phase2_always_active_gpu_evidence
654                        .is_some_and(|evidence| evidence.absence_proven())
655                        && phase2_keyword_hints
656                            .and_then(|rows| rows.get(chunk_index))
657                            .is_some();
658                    let admitted_by_phase2_keyword_hint =
659                        keyword_hints.is_some_and(|hints| !hints.is_empty());
660                    let admitted_by_phase2_always_anchor = match always_anchor_present {
661                        Some(present) => present,
662                        None => false,
663                    };
664                    let admitted_by_generic_keyword_hint =
665                        generic_keyword_positions.is_some_and(|positions| !positions.is_empty());
666                    // An absent positioned row is not evidence that the active
667                    // detector corpus has no generic assignment keyword. When
668                    // a producer cannot supply the compiled plan's positioned
669                    // rows, run the shared stem prefilter instead of composing
670                    // that gap with unrelated complete phase-2 absence.
671                    let generic_assignment_absence_proven =
672                        self.detector_plans.generic_assignment().is_none()
673                            || generic_keyword_positions.is_some();
674                    if !admitted_by_phase2_gpu
675                        && !admitted_by_phase2_keyword_hint
676                        && !admitted_by_phase2_always_anchor
677                        && !admitted_by_generic_keyword_hint
678                        && generic_assignment_absence_proven
679                        && !self.should_scan_no_hit_chunk_with_phase2_absence_proof(
680                            chunk,
681                            raw_phase2_absence_proven,
682                            route,
683                        )
684                    {
685                        if let Some(matches) = self.decode_only_coalesced_matches(chunk, route) {
686                            return CoalescedChunkOutput {
687                                state: None,
688                                matches,
689                                needs_postprocess: false,
690                            };
691                        }
692                        return CoalescedChunkOutput {
693                            state: None,
694                            matches: Vec::new(),
695                            needs_postprocess: false,
696                        };
697                    }
698
699                    let prepared = self.prepare_chunk(chunk);
700                    let state = self.scan_prepared_state_with_triggered(
701                        prepared,
702                        &[],
703                        None,
704                        keyword_hints,
705                        phase2_always_active_gpu_evidence,
706                        confirmed_anchor_matches,
707                        generic_keyword_positions,
708                        route,
709                    );
710                    CoalescedChunkOutput {
711                        state: Some(state),
712                        matches: Vec::new(),
713                        needs_postprocess: true,
714                    }
715                })
716            })
717            .collect();
718
719        #[cfg(feature = "ml")]
720        {
721            let mut output_indices = Vec::new();
722            let mut scan_states = Vec::new();
723            for (output_index, output) in outputs.iter_mut().enumerate() {
724                if let Some(state) = output.state.take() {
725                    output_indices.push(output_index);
726                    scan_states.push(state);
727                }
728            }
729            let _g = profile::span(profile::P::Ml);
730            self.apply_ml_batch_scores_across(&mut scan_states);
731            for (output_index, state) in output_indices.into_iter().zip(scan_states) {
732                outputs[output_index].matches = state.into_matches();
733            }
734        }
735        #[cfg(not(feature = "ml"))]
736        for output in &mut outputs {
737            if let Some(state) = output.state.take() {
738                output.matches = state.into_matches();
739            }
740        }
741
742        let mut results: Vec<Vec<keyhog_core::RawMatch>> = outputs
743            .into_par_iter()
744            .zip(chunks.par_iter())
745            .map(|(mut output, chunk)| {
746                crate::telemetry::with_captured_scan_telemetry(telemetry.as_ref(), || {
747                    if output.needs_postprocess {
748                        self.post_process_coalesced_matches(chunk, &mut output.matches, route);
749                    }
750                    output.matches
751                })
752            })
753            .collect();
754
755        let phase2_elapsed = phase2_start.map(|t| t.elapsed());
756        let boundary_start = perf_trace.then(std::time::Instant::now);
757        super::boundary::scan_chunk_boundaries_with_route(self, chunks, &mut results, route);
758        if perf_trace {
759            eprintln!(
760                "perf-trace scan_coalesced_phase2: chunks={} p2={:.3}s boundary={:.3}s",
761                chunks.len(),
762                phase2_elapsed.map_or(0.0, |d| d.as_secs_f64()),
763                boundary_start.map_or(0.0, |t| t.elapsed().as_secs_f64())
764            );
765        }
766        results
767    }
768}