use std::path::Path;
use std::sync::Mutex;
use harn_vm::security::{HeuristicClassifier, InjectionClassifier};
use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::Tensor;
use tokenizers::Tokenizer;
use crate::catalog::ModelFormat;
use crate::error::{GuardError, Result};
use crate::neural_math::{resolve_injection_label, softmax};
const MAX_TOKENS: usize = 512;
const KNOWN_INPUTS: &[&str] = &["input_ids", "attention_mask", "token_type_ids"];
pub struct OnnxInjectionClassifier {
session: Mutex<Session>,
tokenizer: Tokenizer,
model_id: String,
injection_label: usize,
input_names: Vec<String>,
output_name: String,
heuristic: HeuristicClassifier,
}
impl OnnxInjectionClassifier {
pub fn load(dir: &Path, model_id: &str, format: ModelFormat) -> Result<Self> {
if format != ModelFormat::Onnx {
return Err(GuardError::Inference(format!(
"neural backend supports ONNX models only; `{model_id}` is {}",
format.as_str()
)));
}
let tokenizer_path = dir.join("tokenizer.json");
let tokenizer = load_tokenizer(&tokenizer_path)?;
let config_path = dir.join("config.json");
let config_json: serde_json::Value = {
let bytes = std::fs::read(&config_path).map_err(|source| GuardError::Io {
path: config_path.clone(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| GuardError::Manifest {
path: config_path,
source,
})?
};
let num_labels = config_json
.get("id2label")
.and_then(|v| v.as_object())
.map_or(2, serde_json::Map::len)
.max(1);
let injection_label = resolve_injection_label(&config_json, num_labels);
let model_path = dir.join("model.onnx");
let model_bytes = std::fs::read(&model_path).map_err(|source| GuardError::Io {
path: model_path,
source,
})?;
let session = Session::builder()
.map_err(inference_err)?
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(inference_err)?
.commit_from_memory(&model_bytes)
.map_err(inference_err)?;
let input_names: Vec<String> = session
.inputs
.iter()
.map(|input| input.name.clone())
.filter(|name| KNOWN_INPUTS.contains(&name.as_str()))
.collect();
if !input_names.iter().any(|n| n == "input_ids") {
return Err(GuardError::Inference(format!(
"model `{model_id}` does not declare an `input_ids` input"
)));
}
let output_name = session
.outputs
.iter()
.find(|o| o.name == "logits")
.or_else(|| session.outputs.first())
.map(|o| o.name.clone())
.ok_or_else(|| {
GuardError::Inference(format!("model `{model_id}` declares no outputs"))
})?;
Ok(Self {
session: Mutex::new(session),
tokenizer,
model_id: model_id.to_owned(),
injection_label,
input_names,
output_name,
heuristic: HeuristicClassifier,
})
}
fn score_inner(&self, text: &str) -> Result<f64> {
let encoding = self
.tokenizer
.encode(text, true)
.map_err(|error| GuardError::Inference(format!("tokenize: {error}")))?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| i64::from(x)).collect();
let mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&x| i64::from(x))
.collect();
let len = ids.len();
let shape = [1_i64, len as i64];
let mut inputs: Vec<(std::borrow::Cow<'static, str>, ort::value::DynValue)> =
Vec::with_capacity(self.input_names.len());
for name in &self.input_names {
let tensor = match name.as_str() {
"attention_mask" => Tensor::from_array((shape, mask.clone())),
"token_type_ids" => Tensor::from_array((shape, vec![0_i64; len])),
_ => Tensor::from_array((shape, ids.clone())),
}
.map_err(inference_err)?;
inputs.push((std::borrow::Cow::Owned(name.clone()), tensor.into_dyn()));
}
let mut session = self
.session
.lock()
.map_err(|_| GuardError::Inference("inference session lock poisoned".to_owned()))?;
let outputs = session.run(inputs).map_err(inference_err)?;
let (shape, data) = outputs[self.output_name.as_str()]
.try_extract_tensor::<f32>()
.map_err(inference_err)?;
let num_labels = shape.last().map_or(data.len(), |d| *d as usize).max(1);
let logits = &data[..num_labels.min(data.len())];
let probs = softmax(logits);
let index = self.injection_label.min(probs.len().saturating_sub(1));
Ok(f64::from(probs.get(index).copied().unwrap_or(0.0)))
}
}
impl InjectionClassifier for OnnxInjectionClassifier {
fn model_id(&self) -> &str {
&self.model_id
}
fn score(&self, text: &str) -> f64 {
match self.score_inner(text) {
Ok(score) => score,
Err(error) => {
eprintln!(
"[harn-guard] neural inference failed ({error}); using heuristic for this span"
);
self.heuristic.score(text)
}
}
}
}
fn load_tokenizer(path: &Path) -> Result<Tokenizer> {
let mut tokenizer = Tokenizer::from_file(path).map_err(|error| {
GuardError::Inference(format!("load tokenizer {}: {error}", path.display()))
})?;
let truncation = tokenizers::TruncationParams {
max_length: MAX_TOKENS,
..Default::default()
};
tokenizer
.with_truncation(Some(truncation))
.map_err(|error| GuardError::Inference(format!("configure truncation: {error}")))?;
Ok(tokenizer)
}
fn inference_err(error: impl std::fmt::Display) -> GuardError {
GuardError::Inference(error.to_string())
}