#![cfg(feature = "gliner2-fastino-candle")]
#![allow(dead_code)]
pub mod decoder;
pub mod encoder;
pub mod heads;
pub mod lora;
pub mod pipeline;
pub mod processor;
use std::path::{Path, PathBuf};
use candle_core::Device;
pub struct GLiNER2FastinoCandle {
pub(crate) tokenizer: tokenizers::Tokenizer,
pub(crate) device: Device,
pub(crate) base_model_dir: PathBuf,
pub(crate) encoder: encoder::Encoder,
pub(crate) heads: heads::AllHeads,
pub(crate) active_adapter: Option<String>,
pub(crate) model_id: String,
}
impl std::fmt::Debug for GLiNER2FastinoCandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GLiNER2FastinoCandle")
.field("model_id", &self.model_id)
.field("active_adapter", &self.active_adapter)
.finish()
}
}
impl GLiNER2FastinoCandle {
pub fn active_adapter(&self) -> Option<&str> {
self.active_adapter.as_deref()
}
pub fn load_adapter(&mut self, name: &str, adapter_dir: &Path) -> crate::Result<()> {
let adapter = lora::LoraAdapter::load(adapter_dir, &self.device)?;
if let Some(adapter_base) = adapter.config.base_model_name_or_path.as_deref() {
if !self.model_id.contains(adapter_base) && !adapter_base.contains(&self.model_id) {
return Err(crate::Error::Backend(format!(
"load_adapter: adapter trained on '{adapter_base}', current \
model is '{}'. Refusing to merge — remove \
base_model_name_or_path from adapter_config.json to bypass.",
self.model_id
)));
}
}
let base_safetensors = self.base_model_dir.join("model.safetensors");
let merged = lora::merge_into_base(&base_safetensors, &adapter, &self.device)?;
let vb = candle_nn::VarBuilder::from_tensors(merged, candle_core::DType::F32, &self.device);
let new_encoder =
encoder::Encoder::from_var_builder(vb.pp("encoder"), &self.encoder.config)?;
let new_heads = heads::AllHeads::from_var_builder(vb, &self.device)?;
self.encoder = new_encoder;
self.heads = new_heads;
self.active_adapter = Some(name.to_string());
Ok(())
}
pub fn unload_adapter(&mut self) -> crate::Result<()> {
if self.active_adapter.is_none() {
return Ok(());
}
let weights_path = self.base_model_dir.join("model.safetensors");
let config_path = if self
.base_model_dir
.join("encoder_config/config.json")
.exists()
{
self.base_model_dir.join("encoder_config/config.json")
} else {
self.base_model_dir.join("config.json")
};
self.encoder =
encoder::Encoder::from_safetensors(&weights_path, &config_path, &self.device)?;
self.heads = heads::AllHeads::from_safetensors(&weights_path, &self.device)?;
self.active_adapter = None;
Ok(())
}
pub fn from_local(model_dir: &Path) -> crate::Result<Self> {
Self::from_local_on_device(model_dir, &Device::Cpu)
}
pub fn from_local_with_device(model_dir: &Path, device: &Device) -> crate::Result<Self> {
Self::from_local_on_device(model_dir, device)
}
pub fn from_pretrained(model_id: &str) -> crate::Result<Self> {
Self::from_pretrained_with_device(model_id, &Device::Cpu)
}
pub fn from_pretrained_with_device(model_id: &str, device: &Device) -> crate::Result<Self> {
let api = crate::backends::hf_loader::hf_api()
.map_err(|e| crate::Error::Backend(format!("hf_api: {e}")))?;
let repo = api.model(model_id.to_string());
let _tokenizer =
crate::backends::hf_loader::download_model_file(&repo, &["tokenizer.json"])
.map_err(|e| crate::Error::Backend(format!("download tokenizer: {e}")))?;
let _config = crate::backends::hf_loader::download_model_file(&repo, &["config.json"])
.map_err(|e| crate::Error::Backend(format!("download config: {e}")))?;
let _encoder_config =
crate::backends::hf_loader::download_model_file(&repo, &["encoder_config/config.json"])
.map_err(|e| crate::Error::Backend(format!("download encoder_config: {e}")))?;
let weights_path = crate::backends::hf_loader::download_model_file(
&repo,
&["model.safetensors", "pytorch_model.bin"],
)
.map_err(|e| crate::Error::Backend(format!("download weights: {e}")))?;
let snapshot_dir = weights_path
.parent()
.ok_or_else(|| crate::Error::Backend("snapshot dir resolution".into()))?;
let mut model = Self::from_local_with_device(snapshot_dir, device)?;
model.model_id = model_id.to_string();
Ok(model)
}
pub(crate) fn from_local_on_device(model_dir: &Path, device: &Device) -> crate::Result<Self> {
let tokenizer_path = model_dir.join("tokenizer.json");
let weights_path = model_dir.join("model.safetensors");
let nested_encoder_config = model_dir.join("encoder_config").join("config.json");
let config_path = if nested_encoder_config.exists() {
nested_encoder_config
} else {
model_dir.join("config.json")
};
if !weights_path.exists() {
return Err(crate::Error::Backend(format!(
"gliner2_fastino_candle: model.safetensors not found in {} \
(PyTorch fastino/gliner2-* repo expected; SemplificaAI ONNX \
export is a different artifact)",
model_dir.display()
)));
}
let tokenizer = crate::backends::hf_loader::load_tokenizer(&tokenizer_path)
.map_err(|e| crate::Error::Backend(format!("tokenizer: {e}")))?;
let encoder = encoder::Encoder::from_safetensors(&weights_path, &config_path, device)?;
let heads = heads::AllHeads::from_safetensors(&weights_path, device)?;
let model_id = model_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "gliner2_fastino_candle_local".to_string());
Ok(Self {
tokenizer,
device: device.clone(),
base_model_dir: model_dir.to_path_buf(),
encoder,
heads,
active_adapter: None,
model_id,
})
}
pub(crate) fn extract_ner(
&self,
text: &str,
types: &[&str],
threshold: f32,
) -> crate::Result<Vec<crate::Entity>> {
if types.is_empty() {
return Ok(vec![]);
}
let labels: Vec<String> = types.iter().map(|s| s.to_string()).collect();
let task = processor::SchemaTask::Entities(labels);
let transformer =
processor::SchemaTransformer::new(self.tokenizer.clone()).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transformer: {e}"))
})?;
let record = transformer.transform(text, &[task]).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transform: {e}"))
})?;
let num_words = record.word_to_char_maps.len();
if num_words == 0 {
return Ok(vec![]);
}
let task_map = record.tasks.first().ok_or_else(|| {
crate::Error::Backend(
"gliner2_fastino_candle: transformer produced no task mapping".into(),
)
})?;
let (scorer_out, pred_count) = pipeline::run_pipeline_candle(self, &record, task_map)?;
if pred_count == 0 {
return Ok(vec![]);
}
let entities = decoder::decode_entities(
text,
&record,
task_map,
&scorer_out,
pred_count,
threshold,
false,
);
Ok(entities)
}
pub fn extract_with_label_descriptions(
&self,
text: &str,
labeled: &[(&str, &str)],
threshold: f32,
) -> crate::Result<Vec<crate::Entity>> {
if labeled.is_empty() {
return Ok(vec![]);
}
let owned: Vec<(String, String)> = labeled
.iter()
.map(|(l, d)| (l.to_string(), d.to_string()))
.collect();
let task = processor::SchemaTask::EntitiesDescribed(owned);
let transformer =
processor::SchemaTransformer::new(self.tokenizer.clone()).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transformer: {e}"))
})?;
let record = transformer.transform(text, &[task]).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transform: {e}"))
})?;
let num_words = record.word_to_char_maps.len();
if num_words == 0 {
return Ok(vec![]);
}
let task_map = record.tasks.first().ok_or_else(|| {
crate::Error::Backend(
"gliner2_fastino_candle: transformer produced no task mapping".into(),
)
})?;
let (scorer_out, pred_count) = pipeline::run_pipeline_candle(self, &record, task_map)?;
if pred_count == 0 {
return Ok(vec![]);
}
Ok(decoder::decode_entities(
text,
&record,
task_map,
&scorer_out,
pred_count,
threshold,
false,
))
}
pub fn extract_with_label_thresholds(
&self,
text: &str,
label_thresholds: &[(&str, f32)],
) -> crate::Result<Vec<crate::Entity>> {
if label_thresholds.is_empty() {
return Ok(vec![]);
}
let labels: Vec<String> = label_thresholds
.iter()
.map(|(l, _)| l.to_string())
.collect();
let task = processor::SchemaTask::Entities(labels);
let transformer =
processor::SchemaTransformer::new(self.tokenizer.clone()).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transformer: {e}"))
})?;
let record = transformer.transform(text, &[task]).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transform: {e}"))
})?;
let num_words = record.word_to_char_maps.len();
if num_words == 0 {
return Ok(vec![]);
}
let task_map = record.tasks.first().ok_or_else(|| {
crate::Error::Backend(
"gliner2_fastino_candle: transformer produced no task mapping".into(),
)
})?;
let (scorer_out, pred_count) = pipeline::run_pipeline_candle(self, &record, task_map)?;
if pred_count == 0 {
return Ok(vec![]);
}
Ok(decoder::decode_entities_with_thresholds(
text,
&record,
task_map,
&scorer_out,
pred_count,
label_thresholds,
false,
))
}
pub fn extract_structure(
&self,
text: &str,
schema: &crate::backends::gliner2_fastino::schema::TaskSchema,
threshold: f32,
) -> crate::Result<Vec<crate::backends::gliner2_fastino::schema::ExtractedStructure>> {
if schema.structures.is_empty() {
return Ok(vec![]);
}
let transformer =
processor::SchemaTransformer::new(self.tokenizer.clone()).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transformer: {e}"))
})?;
let mut all_results: Vec<crate::backends::gliner2_fastino::schema::ExtractedStructure> =
Vec::new();
for st in &schema.structures {
if st.fields.is_empty() {
continue;
}
let fields_owned: Vec<(String, crate::backends::gliner2_fastino::schema::FieldType)> =
st.fields
.iter()
.map(|f| (f.name.clone(), f.field_type))
.collect();
let task = processor::SchemaTask::Structures(st.name.clone(), fields_owned.clone());
let record = transformer.transform(text, &[task]).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transform: {e}"))
})?;
let num_words = record.word_to_char_maps.len();
if num_words == 0 {
continue;
}
let task_map = record.tasks.first().ok_or_else(|| {
crate::Error::Backend(
"gliner2_fastino_candle: transformer produced no task mapping".into(),
)
})?;
let (scorer_out, pred_count) = pipeline::run_pipeline_candle(self, &record, task_map)?;
if pred_count == 0 {
continue;
}
let task_results = decoder::decode_structure(
text,
&record,
task_map,
&scorer_out,
pred_count,
threshold,
&fields_owned,
);
all_results.extend(task_results);
}
Ok(all_results)
}
pub fn classify(
&self,
text: &str,
labels: &[&str],
_threshold: f32,
) -> crate::Result<Vec<(String, f32)>> {
if labels.is_empty() {
return Ok(vec![]);
}
let label_strings: Vec<String> = labels.iter().map(|s| s.to_string()).collect();
let task = processor::SchemaTask::Classifications(
"classification".to_string(),
label_strings.clone(),
);
let transformer =
processor::SchemaTransformer::new(self.tokenizer.clone()).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transformer: {e}"))
})?;
let record = transformer.transform(text, &[task]).map_err(|e| {
crate::Error::Backend(format!("gliner2_fastino_candle: transform: {e}"))
})?;
let task_map = record.tasks.first().ok_or_else(|| {
crate::Error::Backend(
"gliner2_fastino_candle: transformer produced no task mapping".into(),
)
})?;
let probs = pipeline::run_classify_pipeline_candle(self, &record, task_map)?;
let mut out: Vec<(String, f32)> =
label_strings.into_iter().zip(probs.into_iter()).collect();
out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(out)
}
}
use crate::backends::inference::ZeroShotNER;
use crate::{EntityCategory, EntityType, Language};
impl crate::Model for GLiNER2FastinoCandle {
fn extract_entities(
&self,
text: &str,
_language: Option<Language>,
) -> crate::Result<Vec<crate::Entity>> {
self.extract_ner(text, &["person", "organization", "location", "date"], 0.5)
}
fn supported_types(&self) -> Vec<EntityType> {
vec![
EntityType::Person,
EntityType::Organization,
EntityType::Location,
EntityType::Date,
EntityType::custom("misc", EntityCategory::Misc),
]
}
fn is_available(&self) -> bool {
true
}
fn name(&self) -> &'static str {
"GLiNER2FastinoCandle"
}
fn description(&self) -> &'static str {
"fastino-ai GLiNER2 (NER + classification, Candle, runtime LoRA)"
}
fn capabilities(&self) -> crate::ModelCapabilities {
crate::ModelCapabilities {
zero_shot: true,
..Default::default()
}
}
fn as_zero_shot(&self) -> Option<&dyn ZeroShotNER> {
Some(self)
}
}
impl ZeroShotNER for GLiNER2FastinoCandle {
fn default_types(&self) -> &[&'static str] {
&["person", "organization", "location", "date", "event"]
}
fn extract_with_types(
&self,
text: &str,
types: &[&str],
threshold: f32,
) -> crate::Result<Vec<crate::Entity>> {
self.extract_ner(text, types, threshold)
}
fn extract_with_descriptions(
&self,
text: &str,
descriptions: &[&str],
threshold: f32,
) -> crate::Result<Vec<crate::Entity>> {
self.extract_ner(text, descriptions, threshold)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_local_with_device_rejects_missing_weights() {
let dir = tempfile::tempdir().expect("tempdir");
let err = GLiNER2FastinoCandle::from_local_with_device(dir.path(), &Device::Cpu)
.expect_err("empty dir must fail");
assert!(err.to_string().contains("model.safetensors"));
}
}