use crate::model_handle::ModelHandle;
use latexsnipper_foundation::{Result, SnipperError};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct ModelId {
pub category: String,
pub variant: String,
}
impl ModelId {
pub fn new(category: impl Into<String>, variant: impl Into<String>) -> Self {
Self {
category: category.into(),
variant: variant.into(),
}
}
pub fn from_composite_key(composite_key: &str) -> Self {
let parts: Vec<&str> = composite_key.splitn(2, '/').collect();
Self {
category: parts[0].to_string(),
variant: parts.get(1).unwrap_or(&"").to_string(),
}
}
pub fn composite_key(&self) -> String {
if self.variant.is_empty() {
self.category.clone()
} else {
format!("{}/{}", self.category, self.variant)
}
}
}
pub trait ModelResolver: Send + Sync {
fn resolve(&self, id: &ModelId) -> Result<ModelHandle>;
fn is_available(&self, id: &ModelId) -> bool;
fn resolve_artifact(&self, id: &ModelId, artifact: &str) -> Result<ModelHandle> {
let key = format!("{}/{}", id.composite_key(), artifact);
self.resolve(&ModelId::from_composite_key(&key))
}
fn read_text_artifact(&self, id: &ModelId, artifact: &str) -> Result<String> {
let handle = self.resolve_artifact(id, artifact)?;
let bytes = handle.model_bytes().ok_or_else(|| {
SnipperError::Model(format!(
"Text artifact is not available as bytes: {}/{}",
id.composite_key(),
artifact
))
})?;
String::from_utf8(bytes.to_vec()).map_err(|error| {
SnipperError::Model(format!(
"Text artifact is not valid UTF-8 ({}/{}): {}",
id.composite_key(),
artifact,
error
))
})
}
fn list_artifacts(&self, _id: &ModelId) -> Vec<String> {
Vec::new()
}
}
pub fn normalize_key(key: &str) -> String {
key.replace('\\', "/").trim_start_matches('/').to_string()
}
pub struct FsModelResolver {
models_dir: PathBuf,
}
impl FsModelResolver {
pub fn new(models_dir: PathBuf) -> Self {
Self { models_dir }
}
}
impl ModelResolver for FsModelResolver {
fn resolve(&self, id: &ModelId) -> Result<ModelHandle> {
let cat_dir = self.models_dir.join(&id.category);
let variant_dir = cat_dir.join(&id.variant);
let candidates = [
variant_dir.join("model.onnx"),
variant_dir.join("model_int8.onnx"),
variant_dir.join("inference.onnx"),
variant_dir.join(format!("{}.onnx", id.category)),
];
for path in &candidates {
if path.exists() {
return Ok(ModelHandle::with_path(id.composite_key(), path.clone()));
}
}
if let Ok(entries) = std::fs::read_dir(&variant_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("onnx") {
return Ok(ModelHandle::with_path(id.composite_key(), path));
}
}
}
Err(SnipperError::Model(format!(
"Model not found: {}",
id.composite_key()
)))
}
fn is_available(&self, id: &ModelId) -> bool {
self.resolve(id).is_ok()
}
fn resolve_artifact(&self, id: &ModelId, artifact: &str) -> Result<ModelHandle> {
let path = self
.models_dir
.join(&id.category)
.join(&id.variant)
.join(artifact);
if path.exists() {
Ok(ModelHandle::with_path(
format!("{}/{}", id.composite_key(), artifact),
path,
))
} else {
Err(SnipperError::Model(format!(
"Artifact not found: {}/{}",
id.composite_key(),
artifact
)))
}
}
fn read_text_artifact(&self, id: &ModelId, artifact: &str) -> Result<String> {
let path = self
.models_dir
.join(&id.category)
.join(&id.variant)
.join(artifact);
std::fs::read_to_string(&path).map_err(|error| {
SnipperError::Model(format!(
"Failed to read {}/{}: {}",
id.composite_key(),
artifact,
error
))
})
}
fn list_artifacts(&self, id: &ModelId) -> Vec<String> {
let variant_dir = self.models_dir.join(&id.category).join(&id.variant);
std::fs::read_dir(variant_dir)
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.filter_map(|entry| entry.file_name().into_string().ok())
.collect()
}
}
pub struct MemoryModelResolver {
store: std::sync::Mutex<HashMap<String, Vec<u8>>>,
}
impl MemoryModelResolver {
pub fn new() -> Self {
Self {
store: std::sync::Mutex::new(HashMap::new()),
}
}
pub fn store(&self, key: impl Into<String>, bytes: Vec<u8>) {
if let Ok(mut store) = self.store.lock() {
store.insert(normalize_key(&key.into()), bytes);
}
}
pub fn has(&self, key: &str) -> bool {
self.store
.lock()
.map(|s| s.contains_key(&normalize_key(key)))
.unwrap_or(false)
}
pub fn get(&self, key: &str) -> Option<Vec<u8>> {
self.store.lock().ok()?.get(&normalize_key(key)).cloned()
}
pub fn list(&self) -> Vec<String> {
self.store
.lock()
.map(|s| s.keys().cloned().collect())
.unwrap_or_default()
}
pub fn clear(&self) {
if let Ok(mut store) = self.store.lock() {
store.clear();
}
}
pub fn remove(&self, key: &str) -> bool {
self.store
.lock()
.map(|mut store| store.remove(&normalize_key(key)).is_some())
.unwrap_or(false)
}
pub fn len(&self) -> usize {
self.store.lock().map(|store| store.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn attach_package_input_shape(&self, id: &ModelId, handle: ModelHandle) -> ModelHandle {
let config_key = format!("{}/config.json", id.composite_key());
let Some(config_bytes) = self.get(&config_key) else {
return handle;
};
let Ok(config) = serde_json::from_slice::<serde_json::Value>(&config_bytes) else {
return handle;
};
let Some(dimensions) = config
.pointer("/input/shape")
.and_then(serde_json::Value::as_array)
else {
return handle;
};
let shape: Option<Vec<usize>> = dimensions
.iter()
.map(|dimension| {
dimension
.as_i64()
.filter(|value| *value > 0)
.and_then(|value| usize::try_from(value).ok())
})
.collect();
if let Some(shape) = shape {
handle.with_input_shape(shape)
} else {
handle
}
}
}
impl Default for MemoryModelResolver {
fn default() -> Self {
Self::new()
}
}
impl ModelResolver for MemoryModelResolver {
fn resolve(&self, id: &ModelId) -> Result<ModelHandle> {
let key = id.composite_key();
if let Some(bytes) = self.get(&key) {
return Ok(self.attach_package_input_shape(id, ModelHandle::with_bytes(key, bytes)));
}
if let Some(bytes) = self.get(&id.category) {
return Ok(self.attach_package_input_shape(id, ModelHandle::with_bytes(key, bytes)));
}
for suffix in &["model.onnx", "model_int8.onnx", "inference.onnx"] {
let full_key = format!("{}/{}", key, suffix);
if let Some(bytes) = self.get(&full_key) {
return Ok(self.attach_package_input_shape(id, ModelHandle::with_bytes(key, bytes)));
}
}
Err(SnipperError::Model(format!(
"Model not found in memory store: {}",
key
)))
}
fn is_available(&self, id: &ModelId) -> bool {
self.has(&id.composite_key()) || self.has(&id.category)
}
fn resolve_artifact(&self, id: &ModelId, artifact: &str) -> Result<ModelHandle> {
let key = format!("{}/{}", id.composite_key(), artifact);
if let Some(bytes) = self.get(&key) {
return Ok(self.attach_package_input_shape(id, ModelHandle::with_bytes(key, bytes)));
}
Err(SnipperError::Model(format!(
"Artifact not found in memory store: {}",
key
)))
}
fn list_artifacts(&self, id: &ModelId) -> Vec<String> {
let prefix = format!("{}/", id.composite_key());
self.list()
.into_iter()
.filter(|key| key.starts_with(&prefix))
.map(|key| key[prefix.len()..].to_string())
.collect()
}
}
pub type SharedModelResolver = Arc<dyn ModelResolver>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_resolver_attaches_static_input_shape_from_package_config() {
let resolver = MemoryModelResolver::new();
let id = ModelId::new("third-party-det", "custom-640");
resolver.store(
"third-party-det/custom-640/config.json",
br#"{"input":{"shape":[1,3,640,960]}}"#.to_vec(),
);
resolver.store("third-party-det/custom-640/model.onnx", vec![1, 2, 3]);
let handle = resolver.resolve_artifact(&id, "model.onnx").unwrap();
assert_eq!(handle.input_shape(), Some([1, 3, 640, 960].as_slice()));
}
#[test]
fn memory_resolver_preserves_dynamic_package_shapes() {
let resolver = MemoryModelResolver::new();
let id = ModelId::new("third-party-det", "dynamic");
resolver.store(
"third-party-det/dynamic/config.json",
br#"{"input":{"shape":[1,3,-1,-1]}}"#.to_vec(),
);
resolver.store("third-party-det/dynamic/model.onnx", vec![1, 2, 3]);
let handle = resolver.resolve_artifact(&id, "model.onnx").unwrap();
assert_eq!(handle.input_shape(), None);
}
}