1use super::phase2::Phase2AlwaysActiveGpuEvidence;
6use super::scan_filters::*;
7use super::*;
8
9#[cfg(feature = "simd")]
10use std::cell::RefCell;
11
12#[cfg(feature = "simd")]
16thread_local! {
17 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 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 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 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 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 (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 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 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 #[allow(clippy::needless_return)] 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 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 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 #[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 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 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 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 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 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}