use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use parking_lot::Mutex;
use crate::runtime::{
factory::RuntimeFactory,
session::RuntimeSession,
tensor::{Shape, Tensor, TensorData},
};
use crate::wordpiece::Tokenizer;
pub const PUNCT_MODEL_FILE: &str = "rupunct_small_int8.onnx";
pub const PUNCT_TOKENIZER_FILE: &str = "tokenizer.json";
pub const PUNCT_CONFIG_FILE: &str = "config.json";
const MAX_WINDOW_SUBTOKENS: usize = 2000;
fn capitalize(token: &str) -> String {
let mut chars = token.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut out: String = first.to_uppercase().collect();
for c in chars {
out.extend(c.to_lowercase());
}
out
}
}
}
pub fn process_token(token: &str, label: &str) -> String {
let (cased, punct_class) = if let Some(rest) = label.strip_prefix("UPPER_TOTAL_") {
(token.to_uppercase(), rest)
} else if let Some(rest) = label.strip_prefix("UPPER_") {
(capitalize(token), rest)
} else if let Some(rest) = label.strip_prefix("LOWER_") {
(token.to_string(), rest)
} else {
return token.to_string();
};
let is_upper = !label.starts_with("LOWER_");
let suffix: &str = match punct_class {
"O" => "",
"PERIOD" => ".",
"COMMA" => ",",
"QUESTION" => "?",
"VOSKL" => "!",
"DVOETOCHIE" => ":",
"PERIODCOMMA" => ";",
"DEFIS" => "-",
"MNOGOTOCHIE" => "...",
"QUESTIONVOSKL" => "?!",
"TIRE" => {
if is_upper {
" —"
} else {
"—"
}
}
_ => "",
};
let mut out = cased;
out.push_str(suffix);
out
}
mod windows;
use windows::{first_subword_labels, plan_windows, splice_window_labels, word_spans};
fn argmax(row: &[f32]) -> usize {
let mut best = 0usize;
let mut best_v = f32::NEG_INFINITY;
for (i, &v) in row.iter().enumerate() {
if v > best_v {
best_v = v;
best = i;
}
}
best
}
pub struct Punctuator {
session: Mutex<Box<dyn RuntimeSession>>,
tokenizer: Tokenizer,
id2label: Vec<String>,
failed_windows: AtomicU64,
}
impl Punctuator {
pub fn load(model_dir: &Path) -> Result<Self> {
let factory = crate::runtime::cpu_factory();
Self::load_with_factory(model_dir, factory.as_ref())
}
pub fn load_with_factory(model_dir: &Path, factory: &dyn RuntimeFactory) -> Result<Self> {
let model_path = model_dir.join(PUNCT_MODEL_FILE);
let tokenizer_path = model_dir.join(PUNCT_TOKENIZER_FILE);
let config_path = model_dir.join(PUNCT_CONFIG_FILE);
let id2label = load_id2label(&config_path)
.with_context(|| format!("Failed to load id2label from {}", config_path.display()))?;
let tokenizer = Tokenizer::from_file(&tokenizer_path)
.with_context(|| format!("Failed to load tokenizer {}", tokenizer_path.display()))?;
tracing::debug!("Loading punctuation model from {}", model_path.display());
let runtime = factory
.cpu_fallback()
.create(1)
.map_err(|e| anyhow::anyhow!(e))
.context("Failed to create runtime for punctuation model")?;
let session = runtime
.load_session(&model_path, false)
.map_err(|e| anyhow::anyhow!(e))
.context("Failed to load punctuation model")?;
tracing::info!(
"Punctuation model loaded ({} labels) from {}",
id2label.len(),
model_dir.display()
);
Ok(Self {
session: Mutex::new(session),
tokenizer,
id2label,
failed_windows: AtomicU64::new(0),
})
}
pub fn failed_windows(&self) -> u64 {
self.failed_windows.load(Ordering::Relaxed)
}
pub fn restore(&self, text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return text.to_string();
}
match self.restore_inner(trimmed) {
Ok(out) => out,
Err(e) => {
tracing::warn!("Punctuation restore failed, returning bare text: {e:#}");
text.to_string()
}
}
}
fn restore_inner(&self, text: &str) -> Result<String> {
let spans = word_spans(text);
if spans.is_empty() {
return Ok(text.to_string());
}
let windows = plan_windows(spans.len());
let mut per_window: Vec<Option<Vec<usize>>> = Vec::with_capacity(windows.len());
let mut first_error: Option<anyhow::Error> = None;
for window in &windows {
match self.label_word_range(text, &spans, window.start, window.end) {
Ok(labels) => per_window.push(Some(labels)),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
per_window.push(None);
}
}
}
let failed = per_window.iter().filter(|labels| labels.is_none()).count();
if failed > 0 {
self.failed_windows
.fetch_add(failed as u64, Ordering::Relaxed);
}
if failed == windows.len() {
return Err(
first_error.unwrap_or_else(|| anyhow::anyhow!("punct model produced no labels"))
);
}
if failed > 0 {
let detail = first_error.map_or_else(String::new, |e| format!("{e:#}"));
tracing::warn!(
"Punctuation restore: {failed} of {} windows failed, their words stay bare: {detail}",
windows.len()
);
}
let label_ids = splice_window_labels(&windows, &per_window, spans.len());
let mut out = String::new();
for (&(from, to), lid) in spans.iter().zip(label_ids.iter()) {
let word = &text[from..to];
let label = lid
.and_then(|lid| self.id2label.get(lid))
.map(String::as_str)
.unwrap_or("LOWER_O");
let processed = process_token(word, label);
if !out.is_empty() {
out.push(' ');
}
out.push_str(&processed);
}
Ok(out.trim().to_string())
}
fn label_word_range(
&self,
text: &str,
spans: &[(usize, usize)],
start: usize,
end: usize,
) -> Result<Vec<usize>> {
if start >= end || end > spans.len() {
anyhow::bail!(
"invalid word window {start}..{end} over {} words",
spans.len()
);
}
let chunk = &text[spans[start].0..spans[end - 1].1];
let encoding = self.tokenizer.encode(chunk, true);
let seq = encoding.get_ids().len();
if seq > MAX_WINDOW_SUBTOKENS {
let num_words = end - start;
if num_words < 2 {
anyhow::bail!(
"a single word encodes to {seq} subtokens (max {MAX_WINDOW_SUBTOKENS})"
);
}
let mid = start + num_words / 2;
let mut labels = self.label_word_range(text, spans, start, mid)?;
labels.extend(self.label_word_range(text, spans, mid, end)?);
return Ok(labels);
}
let ids: Vec<i64> = encoding.get_ids().iter().map(|&i| i as i64).collect();
let mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&m| m as i64)
.collect();
let token_type_ids = vec![0i64; seq];
let input_ids = Tensor::new(Shape::new(vec![1, seq]), TensorData::I64(ids))?;
let attention_mask = Tensor::new(Shape::new(vec![1, seq]), TensorData::I64(mask))?;
let token_type = Tensor::new(Shape::new(vec![1, seq]), TensorData::I64(token_type_ids))?;
let num_labels = self.id2label.len();
let argmax_per_token: Vec<usize> = {
let session = self.session.lock();
let outputs = session
.run(&[input_ids, attention_mask, token_type])
.context("punct model inference failed")?;
let logits_view = outputs[0].view();
let logits = logits_view
.data()
.as_f32()
.context("failed to extract punct logits")?;
let shape = logits_view.shape().dims();
if shape != [1, seq, num_labels] {
anyhow::bail!(
"unexpected punct logits shape {shape:?} (expected [1, {seq}, {num_labels}])"
);
}
(0..seq)
.map(|t| {
let start = t * num_labels;
argmax(&logits[start..start + num_labels])
})
.collect()
};
Ok(first_subword_labels(
encoding.get_word_ids(),
&argmax_per_token,
end - start,
))
}
}
fn load_id2label(config_path: &Path) -> Result<Vec<String>> {
let raw = std::fs::read_to_string(config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let config: serde_json::Value =
serde_json::from_str(&raw).context("config.json is not valid JSON")?;
let map = config
.get("id2label")
.and_then(|v| v.as_object())
.context("config.json missing id2label object")?;
let mut labels = vec![String::new(); map.len()];
for (k, v) in map {
let idx: usize = k
.parse()
.with_context(|| format!("id2label key '{k}' is not an integer"))?;
let label = v
.as_str()
.with_context(|| format!("id2label['{k}'] is not a string"))?;
if idx >= labels.len() {
anyhow::bail!("id2label index {idx} out of range ({} labels)", map.len());
}
labels[idx] = label.to_string();
}
if labels.iter().any(|l| l.is_empty()) {
anyhow::bail!("id2label has a gap (non-contiguous indices)");
}
Ok(labels)
}
#[cfg(test)]
mod tests;