Skip to main content

keyhog_scanner/
scanner_config.rs

1//! Scanner configuration and tuning types.
2
3use std::ops::{Deref, DerefMut};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use keyhog_core::{Calibration, ScanConfig};
8
9/// Explicit per-scanner performance-route tuning.
10///
11/// Each field is optional: `None` means the compiled shipped default, while
12/// `Some(value)` is an explicit config override. These knobs choose
13/// recall-equivalent routes inside the scanner (prefilter engine, anchor
14/// localization, no-candidate gates, decode focus), so they must be part of the
15/// resolved scan config and autoroute cache identity instead of ambient process
16/// environment.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
18pub struct ScannerTuningConfig {
19    pub phase2_hs: Option<bool>,
20    pub hs_prefilter_max_len: Option<usize>,
21    pub hs_shard_target: Option<usize>,
22    pub phase2_anchor: Option<bool>,
23    pub homoglyph_gate: Option<bool>,
24    pub homoglyph_ascii_skip: Option<bool>,
25    pub fallback_reverse: Option<bool>,
26    pub prefilter_truncate: Option<bool>,
27    pub fallback_prefix_gate: Option<bool>,
28    pub decode_focus: Option<bool>,
29    pub confirmed_suffix_gate: Option<bool>,
30    pub no_candidate_gate: Option<bool>,
31    pub fallback_localizer: Option<bool>,
32    pub gpu_recall_floor: Option<bool>,
33    pub gpu_moe_timeout_ms: Option<u64>,
34}
35
36impl ScannerTuningConfig {
37    pub(crate) const FALLBACK_HS_DEFAULT: bool = true;
38    /// Chunk-size ceiling above which the always-active prefilter falls back from
39    /// the Hyperscan engine to the portable RegexSet batches. Held at 4096: HS is
40    /// findings-identical to the RegexSet AND ~2× faster on large ASCII chunks (the
41    /// `|| is_ascii` clause in `hs_prefilter_engages` runs HS there), but on
42    /// large NON-ASCII chunks HS is NOT a win. Forcing HS on non-ASCII was tried
43    /// (route the unicode-vs-byte divergent `.`/`\w`/`\s` patterns to a supplemental
44    /// unicode host RegexSet so byte-mode HS stays recall-exact), recall held, but
45    /// it cost **2.75× more CPU** than the RegexSet (51.9s vs 18.8s on a 2 MiB
46    /// non-ASCII corpus): HS byte-mode ≈ the RegexSet's cost there, and the
47    /// supplemental divergent set is dot-heavy and CANNOT be prefix-AC-gated the
48    /// way the portable batches are, so it is pure overhead. DEAD END (Law 7), the
49    /// recall-required work dominates non-ASCII either way, so the RegexSet (with
50    /// its prefix gating) is strictly faster. Gate stays a Tier-A knob for opt-in.
51    pub(crate) const HS_PREFILTER_MAX_LEN_DEFAULT: usize = 4096;
52    pub(crate) const HS_SHARD_TARGET_DEFAULT: usize = 320;
53    pub(crate) const FALLBACK_ANCHOR_DEFAULT: bool = true;
54    pub(crate) const HOMOGLYPH_GATE_DEFAULT: bool = true;
55    pub(crate) const HOMOGLYPH_ASCII_SKIP_DEFAULT: bool = true;
56    pub(crate) const FALLBACK_REVERSE_DEFAULT: bool = false;
57    pub(crate) const PREFILTER_TRUNCATE_DEFAULT: bool = true;
58    pub(crate) const FALLBACK_PREFIX_GATE_DEFAULT: bool = false;
59    pub(crate) const DECODE_FOCUS_DEFAULT: bool = true;
60    pub(crate) const CONFIRMED_SUFFIX_GATE_DEFAULT: bool = true;
61    pub(crate) const NO_CANDIDATE_GATE_DEFAULT: bool = true;
62    pub(crate) const FALLBACK_LOCALIZER_DEFAULT: bool = true;
63    pub(crate) const GPU_RECALL_FLOOR_DEFAULT: bool = false;
64    pub(crate) const GPU_MOE_TIMEOUT_MS_DEFAULT: u64 = 30_000;
65
66    pub fn effective(&self) -> ResolvedScannerTuningConfig {
67        ResolvedScannerTuningConfig {
68            fallback_hs: self.fallback_hs_effective(),
69            hs_prefilter_max_len: self.hs_prefilter_max_len_effective(),
70            hs_shard_target: self.hs_shard_target_effective(),
71            fallback_anchor: self.fallback_anchor_effective(),
72            homoglyph_gate: self.homoglyph_gate_effective(),
73            homoglyph_ascii_skip: self.homoglyph_ascii_skip_effective(),
74            fallback_reverse: self.fallback_reverse_effective(),
75            prefilter_truncate: self.prefilter_truncate_effective(),
76            fallback_prefix_gate: self.fallback_prefix_gate_effective(),
77            decode_focus: self.decode_focus_effective(),
78            confirmed_suffix_gate: self.confirmed_suffix_gate_effective(),
79            no_candidate_gate: self.no_candidate_gate_effective(),
80            fallback_localizer: self.fallback_localizer_effective(),
81            gpu_recall_floor: self.gpu_recall_floor_effective(),
82            gpu_moe_timeout_ms: self.gpu_moe_timeout_ms_effective(),
83        }
84    }
85
86    pub(crate) fn fallback_hs_effective(&self) -> bool {
87        self.phase2_hs.unwrap_or(Self::FALLBACK_HS_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
88    }
89
90    pub(crate) fn hs_prefilter_max_len_effective(&self) -> usize {
91        self.hs_prefilter_max_len
92            .unwrap_or(Self::HS_PREFILTER_MAX_LEN_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
93    }
94
95    pub(crate) fn hs_shard_target_effective(&self) -> usize {
96        self.hs_shard_target
97            .unwrap_or(Self::HS_SHARD_TARGET_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner compile tuning, recall-safe.
98    }
99
100    pub(crate) fn fallback_anchor_effective(&self) -> bool {
101        self.phase2_anchor.unwrap_or(Self::FALLBACK_ANCHOR_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
102    }
103
104    pub(crate) fn homoglyph_gate_effective(&self) -> bool {
105        self.homoglyph_gate.unwrap_or(Self::HOMOGLYPH_GATE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
106    }
107
108    pub(crate) fn homoglyph_ascii_skip_effective(&self) -> bool {
109        self.homoglyph_ascii_skip
110            .unwrap_or(Self::HOMOGLYPH_ASCII_SKIP_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
111    }
112
113    pub(crate) fn fallback_reverse_effective(&self) -> bool {
114        self.fallback_reverse
115            .unwrap_or(Self::FALLBACK_REVERSE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
116    }
117
118    pub(crate) fn prefilter_truncate_effective(&self) -> bool {
119        self.prefilter_truncate
120            .unwrap_or(Self::PREFILTER_TRUNCATE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
121    }
122
123    pub(crate) fn fallback_prefix_gate_effective(&self) -> bool {
124        self.fallback_prefix_gate
125            .unwrap_or(Self::FALLBACK_PREFIX_GATE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
126    }
127
128    pub(crate) fn decode_focus_effective(&self) -> bool {
129        self.decode_focus.unwrap_or(Self::DECODE_FOCUS_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
130    }
131
132    pub(crate) fn confirmed_suffix_gate_effective(&self) -> bool {
133        self.confirmed_suffix_gate
134            .unwrap_or(Self::CONFIRMED_SUFFIX_GATE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
135    }
136
137    pub(crate) fn no_candidate_gate_effective(&self) -> bool {
138        self.no_candidate_gate
139            .unwrap_or(Self::NO_CANDIDATE_GATE_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
140    }
141
142    pub(crate) fn fallback_localizer_effective(&self) -> bool {
143        self.fallback_localizer
144            .unwrap_or(Self::FALLBACK_LOCALIZER_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
145    }
146
147    pub(crate) fn gpu_recall_floor_effective(&self) -> bool {
148        self.gpu_recall_floor
149            .unwrap_or(Self::GPU_RECALL_FLOOR_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
150    }
151
152    pub(crate) fn gpu_moe_timeout_ms_effective(&self) -> u64 {
153        self.gpu_moe_timeout_ms
154            .unwrap_or(Self::GPU_MOE_TIMEOUT_MS_DEFAULT) // LAW10: documented default; unset/absent config means shipped scanner tuning, recall-safe.
155    }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
159pub struct ResolvedScannerTuningConfig {
160    pub fallback_hs: bool,
161    pub hs_prefilter_max_len: usize,
162    pub hs_shard_target: usize,
163    pub fallback_anchor: bool,
164    pub homoglyph_gate: bool,
165    pub homoglyph_ascii_skip: bool,
166    pub fallback_reverse: bool,
167    pub prefilter_truncate: bool,
168    pub fallback_prefix_gate: bool,
169    pub decode_focus: bool,
170    pub confirmed_suffix_gate: bool,
171    pub no_candidate_gate: bool,
172    pub fallback_localizer: bool,
173    pub gpu_recall_floor: bool,
174    pub gpu_moe_timeout_ms: u64,
175}
176
177/// Recall-equivalent execution choices resolved for one scan request.
178///
179/// This is separate from [`ScannerTuningConfig`]: tuning supplies the default,
180/// while autoroute can select a measured route for an exact workload without
181/// mutating scanner-global state or racing concurrent requests.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
183pub struct ScanExecutionRoute {
184    /// Backend for residual host work, including decoded derived buffers and
185    /// phase-two acceleration. GPU routes deliberately use `CpuFallback` here:
186    /// GPU owns phase-one evidence while residual extraction stays portable.
187    /// Keeping that choice in the measured route prevents scalar and GPU
188    /// candidates from silently borrowing unattributed Hyperscan work.
189    pub decode_backend: crate::hw_probe::ScanBackend,
190    /// Localize eligible folded plain patterns before residual extraction.
191    pub phase2_plain_localizer: bool,
192    /// Localize eligible keyword-anchored patterns before residual extraction.
193    pub phase2_keyword_localizer: bool,
194}
195
196impl ScanExecutionRoute {
197    /// Only a route whose residual host work is SIMD-owned may execute the
198    /// Hyperscan phase-two engine.
199    #[must_use]
200    pub const fn owns_hyperscan_phase2(self) -> bool {
201        matches!(self.decode_backend, crate::hw_probe::ScanBackend::SimdCpu)
202    }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206pub(crate) struct ResolvedRuntimeTuningConfig {
207    pub fallback_hs: bool,
208    pub hs_prefilter_max_len: usize,
209    pub fallback_anchor: bool,
210    pub homoglyph_gate: bool,
211    pub homoglyph_ascii_skip: bool,
212    pub fallback_reverse: bool,
213    pub prefilter_truncate: bool,
214    pub fallback_prefix_gate: bool,
215    pub decode_focus: bool,
216    pub confirmed_suffix_gate: bool,
217    pub no_candidate_gate: bool,
218    pub fallback_localizer: bool,
219    pub gpu_recall_floor: bool,
220    pub gpu_moe_timeout_ms: u64,
221}
222
223impl ResolvedRuntimeTuningConfig {
224    #[cfg(feature = "ml")]
225    pub(crate) fn gpu_moe_timeout(&self) -> Duration {
226        Duration::from_millis(self.gpu_moe_timeout_ms)
227    }
228}
229
230/// Scanner-side configuration: the canonical [`ScanConfig`], the single owned
231/// source of truth for every shared detection knob (decode depth, entropy, ML,
232/// confidence floor, keyword lists, …). PLUS the two knobs that are
233/// scanner-crate-local and have no place on `keyhog-core`'s `ScanConfig`:
234///
235/// - `multiline`: its type ([`crate::multiline::MultilineConfig`]) is defined
236///   in THIS crate, and `keyhog-core` cannot depend on `keyhog-scanner` without
237///   a dependency cycle, so the field cannot live on `ScanConfig`.
238/// - `penalize_test_paths`: a scanner-internal suppression toggle the CLI flips
239///   for `--no-suppress-test-fixtures`; it never appears on the on-disk config.
240///
241/// This is the "thin newtype over `ScanConfig`" MC-01 calls for. It deliberately
242/// does **not** restate any of `ScanConfig`'s fields: every shared knob is read
243/// and written straight through [`Deref`]/[`DerefMut`] (`config.min_confidence`,
244/// `config.entropy_enabled`, `config.known_prefixes`, …), so there is exactly
245/// ONE definition of each, no parallel field list that can drift, and the
246/// `From<ScanConfig>` impl below is a structural wrap, never a hand-maintained
247/// field-by-field copy.
248///
249/// `ScanConfig`'s `max_file_size` / `dedup` fields are reachable through the
250/// deref but are NOT consumed by the scan engine, they are enforced elsewhere
251/// (the source walker and the verifier) and carry that caveat in their own doc
252/// comments on `ScanConfig`. Their presence here is the wrapped truth, not a
253/// second silent copy. `min_secret_len` is consumed by the entropy fallback.
254#[derive(Debug, Clone)]
255pub struct ScannerConfig {
256    /// The canonical shared detection config, single source of truth for every
257    /// knob the engine and CLI agree on. Reached transparently via `Deref`, so
258    /// callers write `config.min_confidence`, not `config.scan.min_confidence`.
259    pub scan: ScanConfig,
260    /// Explicit Tier-A scan override for detector-local BPE policy. `None`
261    /// means each detector TOML owns its ceiling and the wrapped ScanConfig
262    /// value is only the compatibility fallback. `Some` means TOML/CLI scan
263    /// configuration explicitly requested one ceiling for every eligible
264    /// detector; CLI/config presence is preserved so precedence remains
265    /// compiled default -> detector TOML -> scan TOML -> CLI.
266    pub entropy_bpe_max_bytes_per_token_override: Option<f64>,
267    /// Explicit Tier-A override for detector-local ML scoring weights. `None`
268    /// keeps each detector TOML authoritative; presence applies one diagnostic
269    /// or benchmarking override across eligible detector paths.
270    pub ml_weight_override: Option<f64>,
271    /// Configuration for multiline concatenation (scanner-local type).
272    pub multiline: crate::multiline::MultilineConfig,
273    /// Apply test/example path confidence and hard-suppression heuristics.
274    /// The CLI disables this for `--no-suppress-test-fixtures`.
275    pub penalize_test_paths: bool,
276    /// Optional caller-resolved per-chunk scan deadline in milliseconds.
277    pub per_chunk_timeout_ms: Option<u64>,
278    /// Emit the scanner-owned hierarchical profile report to stderr.
279    pub profile: bool,
280    /// Emit low-level phase timing traces for GPU/perf investigation.
281    pub perf_trace: bool,
282    /// Explicit per-detector Bayesian calibration store. Absent means the scan
283    /// is hermetic and score-stable; the scanner never reads a default disk
284    /// cache on its own because that would make findings depend on stray host
285    /// state.
286    pub calibration: Option<Arc<Calibration>>,
287}
288
289impl Deref for ScannerConfig {
290    type Target = ScanConfig;
291    fn deref(&self) -> &ScanConfig {
292        &self.scan
293    }
294}
295
296impl DerefMut for ScannerConfig {
297    fn deref_mut(&mut self) -> &mut ScanConfig {
298        &mut self.scan
299    }
300}
301
302impl Default for ScannerConfig {
303    fn default() -> Self {
304        ScanConfig::default().into()
305    }
306}
307
308impl ScannerConfig {
309    /// Confidence floor for [`ScannerConfig::high_precision`]. Distinct from the
310    /// canonical `ScanConfig::default()` floor (0.40) on purpose: precision mode
311    /// trades recall for a near-zero false-positive rate at mass-scan scale.
312    pub const HIGH_PRECISION_MIN_CONFIDENCE: f64 = 0.85;
313
314    /// Deep mode admits one complete production scan chunk into decode-through.
315    /// The default stays at 512 KiB to bound routine work; deep intentionally
316    /// spends more memory and CPU to recover encoded values anywhere in a
317    /// filesystem window.
318    pub const DEEP_MAX_DECODE_BYTES: usize = crate::types::MAX_SCAN_CHUNK_BYTES;
319
320    pub fn fast() -> Self {
321        let mut config = Self::default();
322        config.max_decode_depth = 0;
323        config.ml_enabled = false;
324        config.entropy_enabled = false;
325        config
326    }
327
328    pub fn thorough() -> Self {
329        // `min_confidence` intentionally omitted: it inherits the canonical
330        // `ScanConfig::default()` floor (single source of truth) instead of
331        // forking a second literal. Deep scanning widens decode/entropy, not
332        // the confidence bar.
333        let mut config = Self::default();
334        config.max_decode_depth = 10;
335        config.max_decode_bytes = Self::DEEP_MAX_DECODE_BYTES;
336        config.ml_enabled = true;
337        config.entropy_enabled = true;
338        config.entropy_in_source_files = true;
339        config.entropy_ml_authoritative = false;
340        config.scan_comments = true;
341        config
342    }
343
344    /// High-precision mass-scan preset: minimise false positives at the cost of
345    /// some recall, for scanning huge corpora where every FP is expensive to
346    /// triage. Fully offline, with ML confidence scoring, no entropy sweep, and
347    /// shallow decode.
348    ///
349    /// - `entropy_enabled = false`: generic high-entropy matching is the single
350    ///   largest FP source; precision mode drops it entirely.
351    /// - `ml_enabled = true` (inherited): ML is the confidence discriminator that
352    ///   lifts genuine secrets over the high floor while leaving FP-shaped tokens
353    ///   below it. Disabling it would crater the scores the 0.85 bar relies on,
354    ///   so precision KEEPS ML (this mode trades recall for precision, not for
355    ///   speed (use `--fast` when speed is the goal)).
356    /// - `min_confidence = HIGH_PRECISION_MIN_CONFIDENCE` (0.85): combined with
357    ///   the engine's checksum policy (valid token → floored 0.9, invalid →
358    ///   capped 0.1) and clamped over every detector's self-declared floor, this
359    ///   bar admits checksum-validated tokens and strong ML-scored findings while
360    ///   dropping checksum-failures and weak-signal matches.
361    /// - `max_decode_depth = 1`: deep-decoded payloads are a FP source at scale.
362    ///
363    /// `penalize_test_paths` stays on (the default) to suppress fixture-shaped
364    /// hits. A `--min-confidence` override still layers on top of this preset.
365    pub fn high_precision() -> Self {
366        let mut config = Self::default();
367        config.max_decode_depth = 1;
368        config.entropy_enabled = false;
369        // High-precision mode does not admit low-entropy keyword-anchored
370        // values: that surface trades precision for real-world recall, the
371        // opposite of this preset's contract. Restores the high
372        // `generic-secret` floor.
373        config.generic_keyword_low_entropy = false;
374        config.min_confidence = Self::HIGH_PRECISION_MIN_CONFIDENCE;
375        config
376    }
377
378    pub fn min_confidence(mut self, min_confidence: f64) -> Self {
379        self.min_confidence = min_confidence;
380        self
381    }
382
383    pub(crate) fn per_chunk_deadline(&self) -> Option<Instant> {
384        self.per_chunk_timeout_ms
385            .map(|ms| Instant::now() + Duration::from_millis(ms))
386    }
387
388    /// Clamp every float field into its valid range and replace any
389    /// NaN with a safe default. A user-supplied
390    /// `--min-confidence=-5.0` or a corrupt config TOML feeding
391    /// `min_confidence = nan` would otherwise NaN-infect the
392    /// confidence-comparison path and silently drop every finding
393    /// (NaN comparisons are always false, so `conf < min_confidence`
394    /// is `false`, but `conf >= min_confidence` is also `false`,
395    /// behaviour-dependent on the call site).
396    ///
397    /// Idempotent - sanitising an already-sane config is a no-op.
398    /// Called inside `From<ScanConfig>` so any path that constructs
399    /// a ScannerConfig from a user-influenced source pays this
400    /// once at config-build time.
401    pub fn sanitise(&mut self) {
402        // Probabilities: clamp to [0.0, 1.0], NaN → canonical default. The
403        // NaN fallbacks READ FROM `ScanConfig::default()` rather than repeating
404        // a literal, so a corrupt-config scrub can never fork from the shipped
405        // floor (currently ml_weight 0.5, min_confidence 0.40) - one source.
406        let canon = keyhog_core::ScanConfig::default();
407        if !self.ml_weight.is_finite() {
408            self.ml_weight = canon.ml_weight;
409        } else {
410            self.ml_weight = self.ml_weight.clamp(0.0, 1.0);
411        }
412        if self
413            .ml_weight_override
414            .is_some_and(|weight| !weight.is_finite() || !(0.0..=1.0).contains(&weight))
415        {
416            self.ml_weight_override = None;
417        }
418        if !self.min_confidence.is_finite() {
419            self.min_confidence = canon.min_confidence;
420        } else {
421            self.min_confidence = self.min_confidence.clamp(0.0, 1.0);
422        }
423        // Shannon entropy: 8.0 is the mathematical upper bound for byte-level
424        // entropy (a genuine constant, not a config default). NaN / negative →
425        // the CANONICAL `ScanConfig::default()` floor, read from `canon` like the
426        // `ml_weight` / `min_confidence` scrubs above. NOT a forked literal, so a
427        // future change to the shipped entropy default can never silently diverge
428        // on the corrupt-config path (single source of truth).
429        if !self.entropy_threshold.is_finite() || self.entropy_threshold < 0.0 {
430            self.entropy_threshold = canon.entropy_threshold;
431        } else if self.entropy_threshold > 8.0 {
432            self.entropy_threshold = 8.0;
433        }
434        // BPE bytes-per-token suppression bound. NaN would silently break the
435        // `cpt > bound` gate (NaN comparisons are always false → nothing ever
436        // suppressed), and a negative bound would suppress EVERY candidate
437        // (cpt is always ≥ ~0.5 > any negative). Both scrub to the CANONICAL
438        // shipped bound, read from `canon` like the scrubs above, never a
439        // forked literal. No upper clamp: a deliberately large bound is the
440        // documented way to disable the gate (trade precision for recall).
441        if !self.entropy_bpe_max_bytes_per_token.is_finite()
442            || self.entropy_bpe_max_bytes_per_token <= 0.0
443        {
444            self.entropy_bpe_max_bytes_per_token = canon.entropy_bpe_max_bytes_per_token;
445        }
446        if self
447            .entropy_bpe_max_bytes_per_token_override
448            .is_some_and(|bound| !bound.is_finite() || bound <= 0.0)
449        {
450            // Invalid presence must not manufacture a scan-wide override. Drop
451            // it so detector-local TOML policy remains authoritative; the
452            // operator-facing CLI/TOML boundaries reject these values before
453            // construction, while this defensive library scrub stays safe for
454            // programmatic callers.
455            self.entropy_bpe_max_bytes_per_token_override = None;
456        }
457        // Recursion-depth + chunk-size caps. The decode-depth ceiling is the
458        // same contract used by CLI parsing and TOML validation.
459        let max_decode_depth = keyhog_core::max_decode_depth_limit();
460        if self.max_decode_depth > max_decode_depth {
461            self.max_decode_depth = max_decode_depth;
462        }
463        if self.max_matches_per_chunk > 1_000_000 {
464            self.max_matches_per_chunk = 1_000_000;
465        }
466        if self.max_matches_per_chunk == 0 {
467            self.max_matches_per_chunk = 1000;
468        }
469        if self.per_chunk_timeout_ms == Some(0) {
470            self.per_chunk_timeout_ms = None;
471        }
472    }
473
474    pub fn with_calibration(mut self, calibration: Arc<Calibration>) -> Self {
475        self.calibration = Some(calibration);
476        self
477    }
478
479    /// Set an explicit scan-wide BPE ceiling while preserving presence even
480    /// when `bound` equals the compiled fallback. Library callers should use
481    /// this instead of relying on [`From<ScanConfig>`] when they intend a value
482    /// of `2.2` to override detector-local TOML policy: `ScanConfig` stores only
483    /// the number and cannot distinguish “omitted default” from “explicitly set
484    /// to the default.” The complete shared scan config is validated before the
485    /// override is accepted, so invalid programmatic policy fails closed.
486    pub fn with_entropy_bpe_max_bytes_per_token_override(
487        mut self,
488        bound: f64,
489    ) -> Result<Self, keyhog_core::ConfigError> {
490        self.scan.entropy_bpe_max_bytes_per_token = bound;
491        self.scan.validate()?;
492        self.entropy_bpe_max_bytes_per_token_override = Some(bound);
493        Ok(self)
494    }
495
496    /// Set an explicit scan-wide model-weight override. Ordinary scans should
497    /// leave this absent so detector TOMLs retain their calibrated weights.
498    pub fn with_ml_weight_override(
499        mut self,
500        weight: f64,
501    ) -> Result<Self, keyhog_core::ConfigError> {
502        self.scan.ml_weight = weight;
503        self.scan.validate()?;
504        self.ml_weight_override = Some(weight);
505        Ok(self)
506    }
507}
508
509impl From<ScanConfig> for ScannerConfig {
510    fn from(scan: ScanConfig) -> Self {
511        // Structural wrap, NOT a field-by-field copy: the canonical `ScanConfig`
512        // is moved in whole into `self.scan`, so there is no parallel field list
513        // that can silently drift from the owned truth (the original lossy
514        // `From`: which renamed/invented/dropped fields, was MC-01's core
515        // complaint; a wrap makes that class of bug structurally impossible).
516        //
517        // The only additions are the two scanner-crate-local knobs:
518        //   - `multiline`: its type lives in this crate; `keyhog-core` cannot
519        //     depend on `keyhog-scanner` (cycle), so it cannot sit on
520        //     `ScanConfig`. Takes the scanner default here.
521        //   - `penalize_test_paths`: defaults on; the CLI flips it off for
522        //     `--no-suppress-test-fixtures`.
523        let canonical_bpe_bound = ScanConfig::default().entropy_bpe_max_bytes_per_token;
524        let entropy_bpe_max_bytes_per_token_override =
525            (scan.entropy_bpe_max_bytes_per_token.to_bits() != canonical_bpe_bound.to_bits())
526                .then_some(scan.entropy_bpe_max_bytes_per_token);
527        let canonical_ml_weight = ScanConfig::default().ml_weight;
528        let ml_weight_override =
529            (scan.ml_weight.to_bits() != canonical_ml_weight.to_bits()).then_some(scan.ml_weight);
530        let mut out = Self {
531            scan,
532            entropy_bpe_max_bytes_per_token_override,
533            ml_weight_override,
534            multiline: crate::multiline::MultilineConfig::default(),
535            penalize_test_paths: true,
536            per_chunk_timeout_ms: None,
537            profile: false,
538            perf_trace: false,
539            calibration: None,
540        };
541        // Defensive clamp + NaN scrub on every user-influenced numeric field
542        // (applied to the wrapped `ScanConfig` via `DerefMut`). Idempotent.
543        // See `ScannerConfig::sanitise` for rationale.
544        out.sanitise();
545        out
546    }
547}
548
549#[cfg(test)]
550mod config_tests {
551    use super::*;
552
553    #[test]
554    fn tuning_effective_resolves_compiled_defaults_when_unset() {
555        let cfg = ScannerTuningConfig::default();
556        assert!(cfg.fallback_hs_effective());
557        assert_eq!(
558            cfg.hs_prefilter_max_len_effective(),
559            ScannerTuningConfig::HS_PREFILTER_MAX_LEN_DEFAULT
560        );
561        assert_eq!(
562            cfg.hs_shard_target_effective(),
563            ScannerTuningConfig::HS_SHARD_TARGET_DEFAULT
564        );
565        assert!(cfg.fallback_anchor_effective());
566        assert!(!cfg.fallback_reverse_effective()); // FALLBACK_REVERSE_DEFAULT = false
567        assert_eq!(
568            cfg.gpu_moe_timeout_ms_effective(),
569            ScannerTuningConfig::GPU_MOE_TIMEOUT_MS_DEFAULT
570        );
571    }
572
573    #[test]
574    fn tuning_effective_honors_explicit_overrides() {
575        let cfg = ScannerTuningConfig {
576            phase2_hs: Some(false),
577            hs_shard_target: Some(999),
578            fallback_reverse: Some(true),
579            gpu_moe_timeout_ms: Some(1_500),
580            ..ScannerTuningConfig::default()
581        };
582        assert!(!cfg.fallback_hs_effective());
583        assert_eq!(cfg.hs_shard_target_effective(), 999);
584        assert!(cfg.fallback_reverse_effective());
585        assert_eq!(cfg.gpu_moe_timeout_ms_effective(), 1_500);
586    }
587
588    #[test]
589    fn sanitise_scrubs_nan_probabilities_to_canonical_defaults() {
590        let canon = keyhog_core::ScanConfig::default();
591        let mut cfg = ScannerConfig::default();
592        cfg.ml_weight = f64::NAN;
593        cfg.min_confidence = f64::NAN;
594        cfg.sanitise();
595        assert_eq!(cfg.ml_weight, canon.ml_weight);
596        assert_eq!(cfg.min_confidence, canon.min_confidence);
597    }
598
599    #[test]
600    fn sanitise_clamps_out_of_range_probabilities() {
601        let mut cfg = ScannerConfig::default();
602        cfg.ml_weight = 5.0;
603        cfg.min_confidence = -2.0;
604        cfg.sanitise();
605        assert_eq!(cfg.ml_weight, 1.0);
606        assert_eq!(cfg.min_confidence, 0.0);
607    }
608
609    #[test]
610    fn sanitise_bounds_entropy_threshold() {
611        let canon = keyhog_core::ScanConfig::default();
612        // NaN and negative both scrub to the canonical shipped floor.
613        let mut nanned = ScannerConfig::default();
614        nanned.entropy_threshold = f64::NAN;
615        nanned.sanitise();
616        assert_eq!(nanned.entropy_threshold, canon.entropy_threshold);
617        let mut negative = ScannerConfig::default();
618        negative.entropy_threshold = -1.0;
619        negative.sanitise();
620        assert_eq!(negative.entropy_threshold, canon.entropy_threshold);
621        // Above the 8-bit byte-entropy ceiling clamps to exactly 8.0.
622        let mut high = ScannerConfig::default();
623        high.entropy_threshold = 99.0;
624        high.sanitise();
625        assert_eq!(high.entropy_threshold, 8.0);
626    }
627
628    #[test]
629    fn sanitise_scrubs_bpe_bound_nan_and_nonpositive_but_keeps_high() {
630        let canon = keyhog_core::ScanConfig::default();
631        // NaN would silently break the `cpt > bound` gate (all comparisons false
632        // → nothing ever suppressed); it must scrub to the canonical 2.2.
633        let mut nanned = ScannerConfig::default();
634        nanned.entropy_bpe_max_bytes_per_token = f64::NAN;
635        nanned.sanitise();
636        assert_eq!(
637            nanned.entropy_bpe_max_bytes_per_token,
638            canon.entropy_bpe_max_bytes_per_token
639        );
640        // A negative bound would suppress EVERY candidate (cpt is always ≥ ~0.5 >
641        // any negative); it scrubs to the canonical default, not left as a footgun.
642        let mut negative = ScannerConfig::default();
643        negative.entropy_bpe_max_bytes_per_token = -1.0;
644        negative.sanitise();
645        assert_eq!(
646            negative.entropy_bpe_max_bytes_per_token,
647            canon.entropy_bpe_max_bytes_per_token
648        );
649        let mut zero = ScannerConfig::default();
650        zero.entropy_bpe_max_bytes_per_token = 0.0;
651        zero.sanitise();
652        assert_eq!(
653            zero.entropy_bpe_max_bytes_per_token,
654            canon.entropy_bpe_max_bytes_per_token
655        );
656        // A deliberately HIGH bound is the documented way to disable the gate
657        // (trade precision for recall) and must be preserved, NOT clamped.
658        let mut high = ScannerConfig::default();
659        high.entropy_bpe_max_bytes_per_token = 99.0;
660        high.sanitise();
661        assert_eq!(high.entropy_bpe_max_bytes_per_token, 99.0);
662    }
663
664    #[test]
665    fn scan_config_conversion_preserves_explicit_bpe_precedence() {
666        let default = ScannerConfig::default();
667        assert_eq!(default.entropy_bpe_max_bytes_per_token_override, None);
668
669        let mut scan = ScanConfig::default();
670        scan.entropy_bpe_max_bytes_per_token = 3.4;
671        let explicit = ScannerConfig::from(scan);
672        assert_eq!(explicit.entropy_bpe_max_bytes_per_token_override, Some(3.4));
673
674        let explicit_default = ScannerConfig::default()
675            .with_entropy_bpe_max_bytes_per_token_override(2.2)
676            .expect("the compiled default is a valid explicit override");
677        assert_eq!(
678            explicit_default.entropy_bpe_max_bytes_per_token_override,
679            Some(2.2),
680            "library callers must be able to preserve an explicit default-valued override"
681        );
682
683        let rejected = ScannerConfig::default()
684            .with_entropy_bpe_max_bytes_per_token_override(0.0)
685            .expect_err("a zero BPE ceiling must fail closed");
686        assert!(matches!(
687            rejected,
688            keyhog_core::ConfigError::InvalidBpeBound(bound) if bound == 0.0
689        ));
690
691        let mut invalid = ScannerConfig::default();
692        invalid.entropy_bpe_max_bytes_per_token_override = Some(f64::NAN);
693        invalid.sanitise();
694        assert_eq!(
695            invalid.entropy_bpe_max_bytes_per_token_override, None,
696            "an invalid programmatic override must restore detector-local policy"
697        );
698    }
699
700    #[test]
701    fn detector_ml_weight_remains_authoritative_until_override_is_explicit() {
702        let default = ScannerConfig::default();
703        assert_eq!(default.ml_weight_override, None);
704
705        let explicit = ScannerConfig::default()
706            .with_ml_weight_override(0.75)
707            .expect("a unit-interval model weight is valid");
708        assert_eq!(explicit.ml_weight_override, Some(0.75));
709
710        let invalid = ScannerConfig::default().with_ml_weight_override(1.5);
711        assert!(invalid.is_err());
712    }
713}