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