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(test)]
pub(crate) mod service_vocab_build;
#[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};
pub(crate) const ML_PARALLEL_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")]
struct PreparedQuantizedBatch {
scores: Vec<f64>,
accelerated_candidate_ids: Vec<usize>,
accelerated_rows: Vec<crate::confidence::quantized::QuantizedFeatureRow>,
cpu_rows: Vec<(usize, Option<[f32; NUM_FEATURES]>)>,
}
#[cfg(feature = "ml")]
fn prepare_quantized_batch<T: MlScoreInput>(
inputs: &[T],
config: &crate::types::ScannerConfig,
) -> Result<PreparedQuantizedBatch, crate::confidence::quantized::QuantizedConfidenceError> {
use crate::confidence::quantized::{
candidate_score_ownership, CandidateScoreOwnership, QuantizedConfidenceError,
QuantizedFeatureRow, MAX_CANDIDATES_PER_BATCH,
};
if inputs.len() > MAX_CANDIDATES_PER_BATCH {
return Err(QuantizedConfidenceError::BatchTooLarge {
candidates: inputs.len(),
maximum: MAX_CANDIDATES_PER_BATCH,
});
}
let mut accelerated_candidate_ids = Vec::new();
let mut accelerated_rows = Vec::new();
let mut cpu_rows = Vec::new();
let mut scores = Vec::new();
scores.try_reserve_exact(inputs.len()).map_err(|_| {
QuantizedConfidenceError::BackendFailure(
"quantized confidence score allocation failed".into(),
)
})?;
scores.resize(inputs.len(), 0.0);
accelerated_candidate_ids
.try_reserve_exact(inputs.len())
.map_err(|_| {
QuantizedConfidenceError::BackendFailure(
"quantized confidence candidate allocation failed".into(),
)
})?;
accelerated_rows
.try_reserve_exact(inputs.len())
.map_err(|_| {
QuantizedConfidenceError::BackendFailure(
"quantized confidence feature allocation failed".into(),
)
})?;
cpu_rows.try_reserve_exact(inputs.len()).map_err(|_| {
QuantizedConfidenceError::BackendFailure(
"CPU-owned confidence feature allocation failed".into(),
)
})?;
for (candidate_id, input) in inputs.iter().enumerate() {
let text = input.ml_text();
match candidate_score_ownership(text.as_bytes()) {
CandidateScoreOwnership::Cpu => cpu_rows.push((candidate_id, None)),
CandidateScoreOwnership::Accelerated => {
let features = input.ml_features(config);
match QuantizedFeatureRow::from_float(&features) {
Ok(row) => {
accelerated_candidate_ids.push(candidate_id);
accelerated_rows.push(row);
}
Err(_) => cpu_rows.push((candidate_id, Some(features))),
}
}
}
}
Ok(PreparedQuantizedBatch {
scores,
accelerated_candidate_ids,
accelerated_rows,
cpu_rows,
})
}
#[cfg(feature = "ml")]
fn score_cpu_owned_rows<T: MlScoreInput>(
batch: &mut PreparedQuantizedBatch,
inputs: &[T],
config: &crate::types::ScannerConfig,
) {
for (candidate_id, features) in batch.cpu_rows.drain(..) {
let input = &inputs[candidate_id];
let text = input.ml_text();
let features = features.unwrap_or_else(|| input.ml_features(config));
batch.scores[candidate_id] =
crate::confidence::policy::ml_score_for_candidate_text(text, || {
forward_pass(&features) as f64
});
}
}
#[cfg(feature = "ml")]
pub(crate) fn score_input_batch_quantized_cpu<T: MlScoreInput>(
inputs: &[T],
config: &crate::types::ScannerConfig,
) -> crate::Result<Vec<f64>> {
let mut batch = prepare_quantized_batch(inputs, config)
.map_err(|error| crate::ScanError::Config(error.to_string()))?;
let accelerated_scores = crate::confidence::quantized::score_batch(&batch.accelerated_rows)
.map_err(|error| crate::ScanError::Config(error.to_string()))?;
score_cpu_owned_rows(&mut batch, inputs, config);
for (candidate_id, score) in batch
.accelerated_candidate_ids
.into_iter()
.zip(accelerated_scores)
{
batch.scores[candidate_id] = score.as_f64();
}
Ok(batch.scores)
}
#[cfg(all(feature = "gpu", feature = "ml"))]
pub(crate) fn score_input_batch_quantized_vyre<T: MlScoreInput>(
inputs: &[T],
config: &crate::types::ScannerConfig,
backend: &dyn vyre::VyreBackend,
deadline: Option<std::time::Instant>,
) -> crate::Result<Vec<f64>> {
let mut batch = prepare_quantized_batch(inputs, config)
.map_err(|error| crate::ScanError::Gpu(error.to_string()))?;
let timeout =
deadline.map(|deadline| deadline.saturating_duration_since(std::time::Instant::now()));
let pending_gpu_scores =
crate::confidence::quantized_vyre::submit_rows(backend, &batch.accelerated_rows, timeout)
.map_err(|error| crate::ScanError::Gpu(error.to_string()))?;
score_cpu_owned_rows(&mut batch, inputs, config);
let gpu_scores = crate::confidence::quantized::validate_accelerated_output(
batch.accelerated_rows.len(),
pending_gpu_scores.await_scores(),
)
.map_err(|error| crate::ScanError::Gpu(error.to_string()))?;
for (candidate_id, score) in batch.accelerated_candidate_ids.into_iter().zip(gpu_scores) {
batch.scores[candidate_id] = score.as_f64();
}
Ok(batch.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()
}
#[cfg(feature = "ml")]
pub(crate) fn score_input_batch<T: MlScoreInput>(
inputs: &[T],
config: &crate::types::ScannerConfig,
) -> Vec<f64> {
if inputs.is_empty() {
return Vec::new();
}
let profile = keyhog_profile::enabled();
if inputs.len() < ML_PARALLEL_BATCH_THRESHOLD {
if !profile {
return score_input_batch_serial(inputs, config);
}
let model = ml_weights::model();
let mut feature_ns = 0_u64;
let mut score_ns = 0_u64;
let mut scores = Vec::with_capacity(inputs.len());
for input in inputs {
let feature_started = std::time::Instant::now();
let features = input.ml_features(config);
feature_ns = feature_ns.saturating_add(feature_started.elapsed().as_nanos() as u64);
let score_started = std::time::Instant::now();
scores.push(crate::confidence::policy::ml_score_for_candidate_text(
input.ml_text(),
|| forward_pass_impl(model, &features) as f64,
));
score_ns = score_ns.saturating_add(score_started.elapsed().as_nanos() as u64);
}
keyhog_profile::add_counter(keyhog_profile::CounterId::MlFeatureNs, feature_ns);
keyhog_profile::add_counter(keyhog_profile::CounterId::MlScoreNs, score_ns);
return scores;
}
use rayon::prelude::*;
if !profile {
let model = ml_weights::model();
return inputs
.par_iter()
.map(|input| {
let features = input.ml_features(config);
crate::confidence::policy::ml_score_for_candidate_text(input.ml_text(), || {
forward_pass_impl(model, &features) as f64
})
})
.collect();
}
let feature_started = std::time::Instant::now();
let features: Vec<[f32; NUM_FEATURES]> = inputs
.par_iter()
.map(|input| input.ml_features(config))
.collect();
keyhog_profile::add_counter(
keyhog_profile::CounterId::MlFeatureNs,
feature_started.elapsed().as_nanos() as u64,
);
let score_started = std::time::Instant::now();
let scores = score_precomputed_batch_on_cpu(inputs, &features);
keyhog_profile::add_counter(
keyhog_profile::CounterId::MlScoreNs,
score_started.elapsed().as_nanos() as u64,
);
scores
}
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
}