pub(crate) mod ml_weights;
pub(crate) mod model_arch;
use std::cell::RefCell;
pub(crate) mod ml_features;
pub(crate) mod service_vocab;
#[cfg(all(test, feature = "ml"))]
pub(crate) use ml_features::compute_features_public;
pub use ml_features::compute_features_with_config;
pub(crate) use ml_features::NUM_FEATURES;
pub use ml_features::{compute_features_for_detector_with_config, MlCandidateChannel};
#[cfg(feature = "ml")]
pub(crate) trait MlScoreInput: Sync {
fn ml_text(&self) -> &str;
fn ml_features(&self, config: &crate::types::ScannerConfig) -> [f32; NUM_FEATURES];
}
#[cfg(feature = "ml")]
impl MlScoreInput for (&str, &str) {
#[inline]
fn ml_text(&self) -> &str {
self.0
}
#[inline]
fn ml_features(&self, config: &crate::types::ScannerConfig) -> [f32; NUM_FEATURES] {
if self.0.is_empty() {
return [0.0; NUM_FEATURES];
}
compute_features_with_config(
self.0,
self.1,
&config.known_prefixes,
&config.secret_keywords,
&config.test_keywords,
&config.placeholder_keywords,
)
}
}
#[cfg(feature = "ml")]
impl MlScoreInput for crate::types::MlPendingMatch {
#[inline]
fn ml_text(&self) -> &str {
self.pending_raw_match.credential.as_ref()
}
#[inline]
fn ml_features(&self, _config: &crate::types::ScannerConfig) -> [f32; NUM_FEATURES] {
self.ml_features
}
}
pub(crate) use model_arch::SIGMOID_SATURATION;
use model_arch::{EXPERT_COUNT, EXPERT_FC1_OUT, EXPERT_FC2_OUT};
#[cfg(any(feature = "gpu", feature = "ml"))]
pub(crate) const GPU_BATCH_THRESHOLD: usize = 64;
pub(crate) const SCORE_CACHE_CAPACITY: usize = 256;
pub(crate) fn score(text: &str, context: &str) -> f64 {
score_with_config(text, context, &[], &[], &[], &[])
}
pub(crate) fn score_with_config(
text: &str,
context: &str,
known_prefixes: &[String],
secret_keywords: &[String],
test_keywords: &[String],
placeholder_keywords: &[String],
) -> f64 {
if text.is_empty() {
return 0.0;
}
thread_local! {
static SCORE_CACHE: RefCell<std::collections::HashMap<u64, f64>> =
RefCell::new(std::collections::HashMap::with_capacity(64));
}
let cache_key = score_cache_key(
text,
context,
known_prefixes,
secret_keywords,
test_keywords,
placeholder_keywords,
);
crate::util_hash::memoize_by_hash(&SCORE_CACHE, cache_key, SCORE_CACHE_CAPACITY, || {
let features = compute_features_with_config(
text,
context,
known_prefixes,
secret_keywords,
test_keywords,
placeholder_keywords,
);
forward_pass(&features) as f64
})
}
pub(crate) fn score_cache_key(
text: &str,
context: &str,
known_prefixes: &[String],
secret_keywords: &[String],
test_keywords: &[String],
placeholder_keywords: &[String],
) -> u64 {
fn write_field(hasher: &mut crate::util_hash::FnvHasher, value: &[u8]) {
hasher.write(&(value.len() as u64).to_le_bytes());
hasher.write(value);
}
let mut hasher = crate::util_hash::FnvHasher::new();
hasher.write(b"keyhog-ml-score-v2");
write_field(&mut hasher, text.as_bytes());
write_field(&mut hasher, context.as_bytes());
for values in [
known_prefixes,
secret_keywords,
test_keywords,
placeholder_keywords,
] {
hasher.write(&(values.len() as u64).to_le_bytes());
for value in values {
write_field(&mut hasher, value.as_bytes());
}
}
hasher.finish()
}
#[cfg(feature = "ml")]
pub(crate) fn complete_batch_scores_with_config<T: MlScoreInput>(
scores: Vec<f64>,
inputs: &[T],
_config: &crate::types::ScannerConfig,
) -> crate::Result<Vec<f64>> {
if scores.len() != inputs.len() {
return Err(crate::ScanError::Gpu(format!(
"ML backend score count mismatch: expected {}, received {}",
inputs.len(),
scores.len()
)));
}
Ok(scores)
}
#[cfg(feature = "ml")]
pub(crate) fn score_features(features: &[f32; NUM_FEATURES]) -> f64 {
forward_pass(features) as f64
}
#[cfg(feature = "ml")]
pub(crate) fn score_precomputed_batch_on_cpu<T: MlScoreInput>(
inputs: &[T],
features: &[[f32; NUM_FEATURES]],
) -> Vec<f64> {
use rayon::prelude::*;
assert_eq!(
inputs.len(),
features.len(),
"internal invariant violation: ML input and feature batch lengths differ"
);
let model = ml_weights::model();
inputs
.par_iter()
.zip(features.par_iter())
.map(|(input, features)| {
crate::confidence::policy::ml_score_for_candidate_text(input.ml_text(), || {
forward_pass_impl(model, features) as f64
})
})
.collect()
}
#[cfg(feature = "ml")]
pub(crate) fn score_input_batch_serial<T: MlScoreInput>(
inputs: &[T],
config: &crate::types::ScannerConfig,
) -> Vec<f64> {
let model = ml_weights::model();
inputs
.iter()
.map(|input| {
crate::confidence::policy::ml_score_for_candidate_text(input.ml_text(), || {
forward_pass_impl(model, &input.ml_features(config)) as f64
})
})
.collect()
}
pub fn model_version() -> &'static str {
ml_weights::MODEL_VERSION
}
pub fn model_card_summary() -> &'static str {
ml_weights::MODEL_CARD_SUMMARY
}
pub fn model_card_json() -> &'static str {
ml_weights::MODEL_CARD_JSON
}
fn forward_pass(input: &[f32; NUM_FEATURES]) -> f32 {
let model = ml_weights::model();
forward_pass_impl(model, input)
}
fn forward_pass_impl(model: &ml_weights::MoeModel, input: &[f32; NUM_FEATURES]) -> f32 {
let gate_probs = softmax(&compute_gate_logits(model, input));
let mut score_logit = 0.0f32;
for (expert_idx, gate_prob) in gate_probs.iter().enumerate() {
score_logit += *gate_prob * expert_logit(&model.experts[expert_idx], input);
}
sigmoid(score_logit)
}
fn compute_gate_logits(
model: &ml_weights::MoeModel,
input: &[f32; NUM_FEATURES],
) -> [f32; EXPERT_COUNT] {
debug_assert_eq!(model.gate_weight.len(), NUM_FEATURES * EXPERT_COUNT);
debug_assert_eq!(model.gate_bias.len(), EXPERT_COUNT);
let mut gate_logits = [0.0f32; EXPERT_COUNT];
for (expert_idx, logit) in gate_logits.iter_mut().enumerate() {
let row = &model.gate_weight[expert_idx * NUM_FEATURES..];
*logit = dense_row(row, input, model.gate_bias[expert_idx]);
}
gate_logits
}
fn expert_logit(expert: &ml_weights::ExpertWeights, input: &[f32; NUM_FEATURES]) -> f32 {
let h1 = dense_relu_layer_t::<NUM_FEATURES, EXPERT_FC1_OUT>(
expert.fc1_weight_t,
expert.fc1_bias,
input,
);
let h2 = dense_relu_layer_t::<EXPERT_FC1_OUT, EXPERT_FC2_OUT>(
expert.fc2_weight_t,
expert.fc2_bias,
&h1,
);
dense_row(expert.fc3_weight, &h2, expert.fc3_bias)
}
#[inline]
fn dense_relu_layer_t<const INPUT: usize, const OUTPUT: usize>(
weights_t: &[f32],
bias: &[f32],
input: &[f32; INPUT],
) -> [f32; OUTPUT] {
let mut acc = [0.0f32; OUTPUT];
for (o, slot) in acc.iter_mut().enumerate() {
*slot = bias[o];
}
for k in 0..INPUT {
let x = input[k];
let row = &weights_t[k * OUTPUT..k * OUTPUT + OUTPUT];
for (slot, &w) in acc.iter_mut().zip(row.iter()) {
*slot += x * w;
}
}
for slot in acc.iter_mut() {
*slot = slot.max(0.0);
}
acc
}
#[inline(always)]
fn dense_row<const INPUT: usize>(weights: &[f32], input: &[f32; INPUT], bias: f32) -> f32 {
let mut sum = bias;
for (&x, &w) in input.iter().zip(weights.iter()) {
sum += x * w;
}
sum
}
pub(crate) fn sigmoid(value: f32) -> f32 {
let x = value;
if x <= -SIGMOID_SATURATION {
0.0
} else if x >= SIGMOID_SATURATION {
1.0
} else {
0.5 + 0.5 * x / (1.0 + x.abs())
}
}
fn softmax(logits: &[f32; EXPERT_COUNT]) -> [f32; EXPERT_COUNT] {
let max_logit = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mut exps = [0.0f32; EXPERT_COUNT];
let mut sum = 0.0f32;
for (idx, logit) in logits.iter().enumerate() {
let value = (*logit - max_logit).exp();
exps[idx] = value;
sum += value;
}
for value in &mut exps {
*value /= sum;
}
exps
}