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}