use anyhow::{Context, Result, bail};
use fastembed::{
EmbeddingModel, InitOptionsUserDefined, Pooling, TextEmbedding, TextInitOptions,
TokenizerFiles, UserDefinedEmbeddingModel,
};
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub const GRANITE_REPO: &str = "onnx-community/granite-embedding-small-english-r2-ONNX";
const GRANITE_ONNX: &str = "onnx/model_quantized.onnx";
const GRANITE_WEIGHTS: &str = "onnx/model_quantized.onnx_data";
pub const GRANITE_NAME: &str = "granite-embedding-small-english-r2-int8";
const GRANITE_DIM: usize = 384;
pub const THREADS_ENV: &str = "SEMLITH_EMBED_THREADS";
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Model {
Builtin(EmbeddingModel),
#[default]
Granite,
}
impl std::fmt::Display for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Model::Granite => f.write_str(GRANITE_NAME),
Model::Builtin(m) => write!(f, "{m}"),
}
}
}
impl FromStr for Model {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == GRANITE_NAME {
return Ok(Model::Granite);
}
EmbeddingModel::from_str(s).map(Model::Builtin)
}
}
impl Model {
pub fn dim(&self) -> Result<usize> {
match self {
Model::Granite => Ok(GRANITE_DIM),
Model::Builtin(m) => Ok(TextEmbedding::get_model_info(m)
.map_err(|e| anyhow::anyhow!("{e}"))?
.dim),
}
}
pub fn query_text(&self, query: &str) -> String {
match self {
Model::Builtin(m) => {
let name = m.to_string();
if name.starts_with("BGE") && name.contains("EN") {
format!("Represent this sentence for searching relevant passages: {query}")
} else {
query.to_string()
}
}
Model::Granite => query.to_string(),
}
}
pub fn tokenizer(&self, cache_dir: &Path) -> Option<tokenizers::Tokenizer> {
let name = match self {
Model::Granite => GRANITE_REPO,
Model::Builtin(m) => return builtin_tokenizer(cache_dir, m),
};
let dir = snapshot_dir(cache_dir, name, GRANITE_REVISION)?;
tokenizers::Tokenizer::from_file(dir.join("tokenizer.json")).ok()
}
pub fn load(
&self,
cache_dir: PathBuf,
max_length: usize,
quiet: bool,
) -> Result<TextEmbedding> {
self.load_with_threads(cache_dir, max_length, quiet, embed_threads())
}
pub fn load_with_threads(
&self,
cache_dir: PathBuf,
max_length: usize,
quiet: bool,
threads: usize,
) -> Result<TextEmbedding> {
self.load_variant(cache_dir, max_length, quiet, threads, Variant::Int8)
}
pub fn load_variant(
&self,
cache_dir: PathBuf,
max_length: usize,
quiet: bool,
threads: usize,
variant: Variant,
) -> Result<TextEmbedding> {
let threads = threads.max(1);
if airgap() && !is_cached(&cache_dir) {
bail!(
"{AIRGAP_ENV} is set and no model is cached at {} — \
pre-seed it with SEMLITH_MODEL_CACHE on a connected machine, \
or drop --airgap to let this run download it",
cache_dir.display()
);
}
match self {
Model::Builtin(m) => {
link_runtime()?;
let opts = TextInitOptions::new(m.clone())
.with_show_download_progress(!quiet)
.with_max_length(max_length)
.with_intra_threads(threads)
.with_cache_dir(cache_dir);
TextEmbedding::try_new(opts).map_err(|e| anyhow::anyhow!("{e}"))
}
Model::Granite => load_granite(cache_dir, max_length, quiet, threads, variant),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Int8,
Fp16,
Fp32,
}
impl Variant {
fn files(self) -> [(&'static str, &'static str); 2] {
match self {
Variant::Int8 => [GRANITE_FILES[4], GRANITE_FILES[5]],
Variant::Fp16 => [
(
"onnx/model_fp16.onnx",
"ee200de55cb2f94e858aabca54be7697a9c0805a14c858ee26ad0922b05f57d7",
),
(
"onnx/model_fp16.onnx_data",
"28d16e29cd623f25cc6fa0968700c5bc31036466091a5fa06d1353c1777f050e",
),
],
Variant::Fp32 => [
(
"onnx/model.onnx",
"cddb145cd1147ec24a3908b2ca2602b98b20a3d198365cff270b7cb26c98179e",
),
(
"onnx/model.onnx_data",
"86a3a705d4598615894d89540ea71a3d9bbdb17a315e79edcd5dfc737222834b",
),
],
}
}
pub fn name(self) -> &'static str {
match self {
Variant::Int8 => "int8-cpu",
Variant::Fp16 => "fp16-cpu",
Variant::Fp32 => "fp32-cpu",
}
}
fn parse(text: &str) -> Option<Self> {
match text {
"int8" => Some(Variant::Int8),
"fp16" => Some(Variant::Fp16),
"fp32" => Some(Variant::Fp32),
_ => None,
}
}
}
pub const VARIANT_ENV: &str = "SEMLITH_EMBED_VARIANT";
pub const QUERY_VARIANT_ENV: &str = "SEMLITH_QUERY_VARIANT";
pub fn index_variant() -> (Variant, Option<Variant>) {
match std::env::var(VARIANT_ENV).ok().as_deref() {
Some("mix") => (Variant::Int8, Some(Variant::Fp16)),
Some(other) => (Variant::parse(other).unwrap_or(Variant::Int8), None),
None => (Variant::Int8, None),
}
}
pub fn query_variant() -> Variant {
std::env::var(QUERY_VARIANT_ENV)
.ok()
.and_then(|v| Variant::parse(&v))
.unwrap_or(Variant::Int8)
}
pub fn granite_tokenizer(cache_dir: &Path, max_length: usize) -> Result<tokenizers::Tokenizer> {
use tokenizers::{AddedToken, PaddingParams, PaddingStrategy, TruncationParams};
let dir = snapshot_dir(cache_dir, GRANITE_REPO, GRANITE_REVISION)
.context("granite is not cached yet; the CPU lane fetches it first")?;
let read = |name: &str| -> Result<Vec<u8>> {
let bytes = std::fs::read(dir.join(name)).with_context(|| format!("reading {name}"))?;
let expected = GRANITE_FILES
.iter()
.find(|(file, _)| *file == name)
.map(|(_, digest)| *digest)
.with_context(|| format!("{name} is not a file semlith pins"))?;
verify(name, &bytes, expected)?;
Ok(bytes)
};
let config: serde_json::Value = serde_json::from_slice(&read("config.json")?)?;
let special: serde_json::Value = serde_json::from_slice(&read("special_tokens_map.json")?)?;
let tokenizer_config: serde_json::Value =
serde_json::from_slice(&read("tokenizer_config.json")?)?;
let mut tokenizer = tokenizers::Tokenizer::from_bytes(read("tokenizer.json")?)
.map_err(|e| anyhow::anyhow!("reading the tokenizer: {e}"))?;
let model_max = tokenizer_config["model_max_length"]
.as_f64()
.unwrap_or(max_length as f64) as usize;
tokenizer
.with_padding(Some(PaddingParams {
strategy: PaddingStrategy::BatchLongest,
pad_token: tokenizer_config["pad_token"]
.as_str()
.unwrap_or("[PAD]")
.to_string(),
pad_id: config["pad_token_id"].as_u64().unwrap_or(0) as u32,
..Default::default()
}))
.with_truncation(Some(TruncationParams {
max_length: max_length.min(model_max),
..Default::default()
}))
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let serde_json::Value::Object(map) = special {
for value in map.values() {
let token = match value {
serde_json::Value::String(content) => AddedToken {
content: content.clone(),
special: true,
..Default::default()
},
serde_json::Value::Object(_) => match (
value["content"].as_str(),
value["single_word"].as_bool(),
value["lstrip"].as_bool(),
value["rstrip"].as_bool(),
value["normalized"].as_bool(),
) {
(
Some(content),
Some(single_word),
Some(lstrip),
Some(rstrip),
Some(normalized),
) => AddedToken {
content: content.into(),
special: true,
single_word,
lstrip,
rstrip,
normalized,
},
_ => continue,
},
_ => continue,
};
let _ = tokenizer.add_special_tokens([token]);
}
}
Ok(tokenizer)
}
pub fn link_runtime() -> Result<()> {
#[cfg(feature = "dynamic-ort")]
let linked = {
use std::sync::OnceLock;
static ONCE: OnceLock<Result<(), String>> = OnceLock::new();
ONCE.get_or_init(|| {
let Ok(exe) = std::env::current_exe() else {
return Err("the running binary could not be located".to_string());
};
let beside = exe
.parent()
.map(|dir| dir.join(RUNTIME_FILE))
.unwrap_or_else(|| PathBuf::from(RUNTIME_FILE));
if !beside.exists() {
return Err(format!(
"{} is not beside the semlith binary. The Linux release \
archive carries it next to `semlith`, and `install.sh` \
puts both in the same directory — a binary copied out of \
the archive on its own cannot embed anything. Unpack the \
archive again, or install with the one-liner in the \
README.",
beside.display()
));
}
ort::init_from(beside.to_string_lossy().as_ref())
.map_err(|e| format!("loading {}: {e}", beside.display()))?
.commit();
Ok(())
})
.clone()
.map_err(anyhow::Error::msg)
};
#[cfg(not(feature = "dynamic-ort"))]
let linked = Ok(());
linked
}
#[cfg(feature = "dynamic-ort")]
const RUNTIME_FILE: &str = if cfg!(target_os = "macos") {
"libonnxruntime.dylib"
} else if cfg!(windows) {
"onnxruntime.dll"
} else {
"libonnxruntime.so"
};
const DOWNLOAD_STALL: std::time::Duration = std::time::Duration::from_secs(60);
fn download_stall() -> std::time::Duration {
std::env::var("SEMLITH_DOWNLOAD_STALL")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|n| *n > 0)
.map(std::time::Duration::from_secs)
.unwrap_or(DOWNLOAD_STALL)
}
pub(crate) struct Pinned {
api: hf_hub::api::sync::Api,
cache: hf_hub::Cache,
repo: hf_hub::Repo,
quiet: bool,
stall: std::time::Duration,
}
impl Pinned {
pub(crate) fn new(cache_dir: &Path, repo: &str, revision: &str, quiet: bool) -> Result<Self> {
Self::at("https://huggingface.co", cache_dir, repo, revision, quiet)
}
fn at(
endpoint: &str,
cache_dir: &Path,
repo: &str,
revision: &str,
quiet: bool,
) -> Result<Self> {
let cache = hf_hub::Cache::new(cache_dir.to_path_buf());
let api = hf_hub::api::sync::ApiBuilder::from_cache(cache.clone())
.with_endpoint(endpoint.to_string())
.with_progress(false)
.build()
.context("building the Hugging Face client")?;
Ok(Self {
api,
cache,
repo: hf_hub::Repo::with_revision(
repo.to_string(),
hf_hub::RepoType::Model,
revision.to_string(),
),
quiet,
stall: download_stall(),
})
}
pub(crate) fn get(&self, file: &str) -> Result<PathBuf> {
if let Some(path) = self.cache.repo(self.repo.clone()).get(file) {
return Ok(path);
}
let url = self.api.repo(self.repo.clone()).url(file);
crate::add::note_outbound("model", &url);
let watch = Watch::default();
let (done, answer) = std::sync::mpsc::channel();
let (api, repo, name, seen) = (
self.api.clone(),
self.repo.clone(),
file.to_string(),
watch.clone(),
);
std::thread::spawn(move || {
let _ = done.send(api.repo(repo).download_with_progress(&name, seen));
});
let mut said = std::time::Instant::now();
loop {
match answer.recv_timeout(std::time::Duration::from_millis(250)) {
Ok(fetched) => return fetched.with_context(|| format!("fetching {url}")),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
bail!("fetching {url}: the download ended without an answer")
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
let (received, total, quiet_for) = watch.read();
if quiet_for >= self.stall {
bail!(
"the model download stalled: no bytes for {} s while fetching {url} \
({} of {} received). Check this machine can reach huggingface.co, then \
run `semlith setup` again; it resumes into the same cache ({})",
self.stall.as_secs(),
crate::human_bytes(received as i64),
if total > 0 {
crate::human_bytes(total as i64)
} else {
"an unknown size".to_string()
},
self.cache.path().display(),
);
}
if !self.quiet && total > 0 && said.elapsed() >= std::time::Duration::from_secs(2) {
said = std::time::Instant::now();
eprintln!(
" {file}: {} of {}",
crate::human_bytes(received as i64),
crate::human_bytes(total as i64)
);
}
}
}
}
#[derive(Clone)]
struct Watch(std::sync::Arc<std::sync::Mutex<(u64, u64, std::time::Instant)>>);
impl Default for Watch {
fn default() -> Self {
Self(std::sync::Arc::new(std::sync::Mutex::new((
0,
0,
std::time::Instant::now(),
))))
}
}
impl Watch {
fn read(&self) -> (u64, u64, std::time::Duration) {
let g = self.0.lock().unwrap_or_else(|e| e.into_inner());
(g.0, g.1, g.2.elapsed())
}
}
impl hf_hub::api::Progress for Watch {
fn init(&mut self, size: usize, _filename: &str) {
let mut g = self.0.lock().unwrap_or_else(|e| e.into_inner());
g.1 = size as u64;
g.2 = std::time::Instant::now();
}
fn update(&mut self, size: usize) {
let mut g = self.0.lock().unwrap_or_else(|e| e.into_inner());
g.0 += size as u64;
g.2 = std::time::Instant::now();
}
fn finish(&mut self) {}
}
fn load_granite(
cache_dir: PathBuf,
max_length: usize,
quiet: bool,
threads: usize,
variant: Variant,
) -> Result<TextEmbedding> {
link_runtime()?;
let paths = granite_paths(&cache_dir, quiet, variant)?;
let read = |name: &str| -> Result<Vec<u8>> {
let path = paths
.iter()
.find(|(file, _)| *file == name)
.map(|(_, path)| path)
.with_context(|| format!("{name} is not a file semlith pins a digest for"))?;
std::fs::read(path).with_context(|| format!("reading {}", path.display()))
};
let [graph, weights] = variant.files();
let tokenizer_files = TokenizerFiles {
tokenizer_file: read("tokenizer.json")?,
config_file: read("config.json")?,
special_tokens_map_file: read("special_tokens_map.json")?,
tokenizer_config_file: read("tokenizer_config.json")?,
};
let model = UserDefinedEmbeddingModel::new(read(graph.0)?, tokenizer_files)
.with_pooling(Pooling::Cls)
.with_external_initializer(
Path::new(weights.0)
.file_name()
.expect("weights constant has a file name")
.to_string_lossy()
.into_owned(),
read(weights.0)?,
);
let opts = InitOptionsUserDefined::new()
.with_max_length(max_length)
.with_intra_threads(threads);
TextEmbedding::try_new_from_user_defined(model, opts)
.map_err(|e| anyhow::anyhow!("loading {GRANITE_NAME}: {e}"))
}
pub fn granite_graph(cache_dir: &Path, quiet: bool, variant: Variant) -> Result<PathBuf> {
let [graph, _] = variant.files();
granite_paths(cache_dir, quiet, variant)?
.into_iter()
.find(|(file, _)| *file == graph.0)
.map(|(_, path)| path)
.context("the graph is missing from its own pinned file list")
}
fn granite_paths(
cache_dir: &Path,
quiet: bool,
variant: Variant,
) -> Result<Vec<(&'static str, PathBuf)>> {
if airgap() && !is_cached(cache_dir) {
bail!(
"{AIRGAP_ENV} is set and no model is cached at {} — \
pre-seed it with SEMLITH_MODEL_CACHE on a connected machine, \
or drop --airgap to let this run download it",
cache_dir.display()
);
}
check_cache_dir(cache_dir)?;
let cache = cache_dir.to_path_buf();
let [graph, weights] = variant.files();
let files: Vec<(&str, &str)> = GRANITE_FILES[..4]
.iter()
.copied()
.chain([graph, weights])
.collect();
let stamp = match variant {
Variant::Int8 => STAMP.to_string(),
other => format!("{STAMP}-{}", other.name()),
};
let checked = snapshot_dir(&cache, GRANITE_REPO, GRANITE_REVISION)
.is_some_and(|dir| already_verified_as(&dir, &stamp, GRANITE_REVISION, &files));
let repo = Pinned::new(&cache, GRANITE_REPO, GRANITE_REVISION, quiet)?;
let mut paths = Vec::with_capacity(files.len());
for (name, expected) in &files {
let path = repo
.get(name)
.with_context(|| format!("fetching {name} from {GRANITE_REPO}"))?;
if !checked {
let bytes =
std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
verify(name, &bytes, expected)?;
}
paths.push((*name, path));
}
if !checked && let Some(dir) = snapshot_dir(&cache, GRANITE_REPO, GRANITE_REVISION) {
record_verified_as(&dir, &stamp, GRANITE_REVISION, &files);
}
Ok(paths)
}
pub const AIRGAP_ENV: &str = "SEMLITH_AIRGAP";
fn builtin_tokenizer(cache_dir: &Path, model: &EmbeddingModel) -> Option<tokenizers::Tokenizer> {
let wanted = format!("{model}").to_lowercase().replace('/', "--");
let entries = std::fs::read_dir(cache_dir).ok()?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_lowercase();
if !name.contains(&wanted) {
continue;
}
for candidate in walk_for(&entry.path(), "tokenizer.json") {
if let Ok(tokenizer) = tokenizers::Tokenizer::from_file(&candidate) {
return Some(tokenizer);
}
}
}
None
}
fn walk_for(root: &Path, name: &str) -> Vec<PathBuf> {
let mut found = Vec::new();
let direct = root.join(name);
if direct.is_file() {
found.push(direct);
}
let Ok(entries) = std::fs::read_dir(root) else {
return found;
};
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let nested = entry.path().join(name);
if nested.is_file() {
found.push(nested);
}
}
found
}
pub fn airgap() -> bool {
matches!(
std::env::var(AIRGAP_ENV).ok().as_deref(),
Some("1" | "true" | "yes")
) || crate::add::runtime_airgap()
}
pub fn refuse_if_airgapped(model: &str) -> Result<()> {
let cache = crate::model_cache_dir()?;
if airgap() && !model_is_cached(&cache, model) {
bail!(
"{AIRGAP_ENV} is set and {model} is not cached at {} — \
pre-seed it with SEMLITH_MODEL_CACHE on a connected machine, \
or drop --airgap to let this run download it",
cache.display()
);
}
Ok(())
}
pub fn model_is_cached(cache_dir: &Path, model: &str) -> bool {
let want = model.replace('/', "--").to_ascii_lowercase();
std::fs::read_dir(cache_dir)
.map(|entries| {
entries.flatten().any(|entry| {
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
name.ends_with(&want) && entry.path().join("snapshots").exists()
})
})
.unwrap_or(false)
}
pub fn is_cached(cache_dir: &Path) -> bool {
let snapshot = match snapshot_dir(cache_dir, GRANITE_REPO, GRANITE_REVISION) {
Some(dir) => dir,
None => return false,
};
GRANITE_FILES
.iter()
.all(|(name, _)| snapshot.join(name).exists())
}
fn snapshot_dir(cache_dir: &Path, repo: &str, revision: &str) -> Option<PathBuf> {
let dir = cache_dir
.join(format!("models--{}", repo.replace('/', "--")))
.join("snapshots")
.join(revision);
dir.is_dir().then_some(dir)
}
pub fn verify_cached(
cache_dir: &Path,
repo: &str,
revision: &str,
files: &[(&str, &str)],
) -> Result<()> {
check_cache_dir(cache_dir)?;
let _ = revision;
let snapshots = cache_dir
.join(format!("models--{}", repo.replace('/', "--")))
.join("snapshots");
let Ok(entries) = std::fs::read_dir(&snapshots) else {
return Ok(());
};
for entry in entries.flatten() {
let dir = entry.path();
if already_verified(&dir, revision, files) {
continue;
}
let mut read_all = true;
for (name, expected) in files {
let path = dir.join(name);
let Ok(bytes) = std::fs::read(&path) else {
read_all = false;
continue;
};
verify(&format!("{repo}/{name}"), &bytes, expected)?;
}
if read_all {
record_verified(&dir, revision, files);
}
}
Ok(())
}
static THREADS_IN_FORCE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub fn set_threads_in_force(threads: usize) {
THREADS_IN_FORCE.store(threads, std::sync::atomic::Ordering::Relaxed);
}
static WRITERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
pub struct Writer(());
impl Drop for Writer {
fn drop(&mut self) {
WRITERS.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
pub fn writer() -> Writer {
WRITERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Writer(())
}
pub fn threads_in_force() -> usize {
match THREADS_IN_FORCE.load(std::sync::atomic::Ordering::Relaxed) {
0 => split_threads(WRITERS.load(std::sync::atomic::Ordering::Relaxed)),
n => n,
}
}
pub fn split_threads(writers: usize) -> usize {
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
split(embed_threads(), writers, cores)
}
fn split(all: usize, writers: usize, cores: usize) -> usize {
if writers <= 1 {
return all;
}
let budget = cores.saturating_sub(1).max(1);
(all / writers).clamp(1, (budget / writers).max(1))
}
pub fn embed_threads() -> usize {
if let Ok(raw) = std::env::var(THREADS_ENV)
&& let Ok(n) = raw.parse::<usize>()
&& n > 0
{
return n;
}
performance_cores().unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
})
}
#[cfg(target_os = "macos")]
fn performance_cores() -> Option<usize> {
let mut out: i32 = 0;
let mut len = std::mem::size_of::<i32>();
let name = c"hw.perflevel0.logicalcpu";
let rc = unsafe {
libc::sysctlbyname(
name.as_ptr(),
&mut out as *mut i32 as *mut libc::c_void,
&mut len,
std::ptr::null_mut(),
0,
)
};
(rc == 0 && out > 0).then_some(out as usize)
}
#[cfg(target_os = "linux")]
fn performance_cores() -> Option<usize> {
cpulist_len(&std::fs::read_to_string("/sys/devices/cpu_core/cpus").ok()?)
}
#[cfg(any(target_os = "linux", test))]
fn cpulist_len(list: &str) -> Option<usize> {
let mut count = 0;
for part in list.trim().split(',') {
let (first, last) = part.split_once('-').unwrap_or((part, part));
let (first, last) = (first.parse::<usize>().ok()?, last.parse::<usize>().ok()?);
count += last.checked_sub(first)? + 1;
}
(count > 0).then_some(count)
}
#[cfg(windows)]
fn performance_cores() -> Option<usize> {
use windows_sys::Win32::System::SystemInformation::{
CpuSetInformation, GetSystemCpuSetInformation, SYSTEM_CPU_SET_INFORMATION,
};
let mut len = 0u32;
unsafe {
GetSystemCpuSetInformation(std::ptr::null_mut(), 0, &mut len, std::ptr::null_mut(), 0)
};
let mut buf = vec![0u8; len as usize];
let ok = unsafe {
GetSystemCpuSetInformation(
buf.as_mut_ptr().cast(),
len,
&mut len,
std::ptr::null_mut(),
0,
)
} != 0;
if !ok || len as usize > buf.len() {
return None;
}
let mut classes = Vec::new();
let mut at = 0;
let whole = std::mem::size_of::<SYSTEM_CPU_SET_INFORMATION>();
while at + whole <= len as usize {
let entry: SYSTEM_CPU_SET_INFORMATION =
unsafe { std::ptr::read_unaligned(buf.as_ptr().add(at).cast()) };
if entry.Size == 0 {
return None;
}
if entry.Type == CpuSetInformation {
classes.push(unsafe { entry.Anonymous.CpuSet.EfficiencyClass });
}
at += entry.Size as usize;
}
fastest_class_len(&classes)
}
#[cfg(any(windows, test))]
fn fastest_class_len(classes: &[u8]) -> Option<usize> {
let fastest = *classes.iter().max()?;
let count = classes.iter().filter(|c| **c == fastest).count();
(count < classes.len()).then_some(count)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn performance_cores() -> Option<usize> {
None
}
#[cfg(test)]
mod performance_cores_tests {
use super::*;
#[test]
fn a_hybrid_intel_cpulist_counts_its_performance_threads() {
assert_eq!(cpulist_len("0-15\n"), Some(16));
assert_eq!(cpulist_len("16-23\n"), Some(8));
assert_eq!(cpulist_len("0-7,16-19"), Some(12));
assert_eq!(cpulist_len("3"), Some(1));
}
#[test]
fn a_cpulist_this_does_not_know_is_none() {
for list in ["", "\n", "0-15:2/4", "15-0", "0-", "a-b", "0,,2"] {
assert_eq!(cpulist_len(list), None, "{list:?}");
}
}
#[test]
fn two_efficiency_classes_count_the_higher() {
let classes = [[1u8; 8], [0u8; 8]].concat();
assert_eq!(fastest_class_len(&classes), Some(8));
}
#[test]
fn one_efficiency_class_is_a_symmetric_cpu() {
assert_eq!(fastest_class_len(&[0; 16]), None);
assert_eq!(fastest_class_len(&[]), None);
}
}
pub const GRANITE_REVISION: &str = "1dc7835ba0cb9c76a3618d0bf0c427c97671b3c8";
pub const GRANITE_FILES: &[(&str, &str)] = &[
(
"tokenizer.json",
"feeb83348dcb033bc6b9d2e1f7906ca9eb2d122845000c9416d894d7c2927149",
),
(
"config.json",
"1a1710c20911da8c96179716bf44058e54cca6fa7952cce77be83ef05edae3ee",
),
(
"special_tokens_map.json",
"ea97ecdbcc73713039d8d64dbb05e3689495c96657fbd9a18f5bed381be81049",
),
(
"tokenizer_config.json",
"ce06781b38bb393db68c9e0709bddd31ef5d88f2c6fbb3fd9f369778fb85e451",
),
(
GRANITE_ONNX,
"a3fad524afc3f060216a8ddbb1ac89c9b6498fba8995b5718bde879076a2e9ba",
),
(
GRANITE_WEIGHTS,
"1f4cf47e4adec7f7ae09db03d071ba8667e07f9a4203142c7efa8d37fe453597",
),
];
#[derive(serde::Serialize, serde::Deserialize)]
struct Verified {
revision: String,
files: std::collections::BTreeMap<String, (u64, u128, String)>,
}
const STAMP: &str = ".semlith-verified";
fn fingerprint(path: &Path) -> Option<(u64, u128)> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
Some((meta.len(), modified))
}
fn already_verified(dir: &Path, revision: &str, files: &[(&str, &str)]) -> bool {
already_verified_as(dir, STAMP, revision, files)
}
fn already_verified_as(dir: &Path, stamp: &str, revision: &str, files: &[(&str, &str)]) -> bool {
let Ok(text) = std::fs::read_to_string(dir.join(stamp)) else {
return false;
};
let Ok(stamp) = serde_json::from_str::<Verified>(&text) else {
return false;
};
if stamp.revision != revision {
return false;
}
files.iter().all(|(name, expected)| {
stamp
.files
.get(*name)
.zip(fingerprint(&dir.join(name)))
.is_some_and(|((size, mtime, digest), (now_size, now_mtime))| {
digest == expected && *size == now_size && *mtime == now_mtime
})
})
}
fn record_verified(dir: &Path, revision: &str, files: &[(&str, &str)]) {
record_verified_as(dir, STAMP, revision, files)
}
fn record_verified_as(dir: &Path, stamp_name: &str, revision: &str, files: &[(&str, &str)]) {
let mut stamp = Verified {
revision: revision.to_string(),
files: std::collections::BTreeMap::new(),
};
for (name, expected) in files {
let Some((size, mtime)) = fingerprint(&dir.join(name)) else {
return;
};
stamp
.files
.insert((*name).to_string(), (size, mtime, (*expected).to_string()));
}
let Ok(body) = serde_json::to_vec(&stamp) else {
return;
};
let _ = crate::home::write_private(&dir.join(stamp_name), &body);
}
pub fn digest(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn verify(name: &str, bytes: &[u8], expected: &str) -> Result<()> {
let actual = digest(bytes);
if actual != expected {
bail!(
"{name} does not match the digest semlith pins for it.\n \
expected {expected}\n got {actual}\n\
The model cache holds a file that is not the one this release was \
built against. Delete the cache and let semlith fetch it again; if \
it happens twice, the file upstream has changed and semlith needs a \
release rather than a retry."
);
}
Ok(())
}
pub fn check_cache_dir(cache: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(cache) else {
return Ok(());
};
let me = unsafe { libc::getuid() };
if meta.uid() != me {
bail!(
"{} is owned by uid {} rather than by you. semlith will not load \
model weights from a directory somebody else owns — run `chown -R \
{me} {}`, or point {} somewhere you own.",
cache.display(),
meta.uid(),
cache.display(),
crate::MODEL_CACHE_ENV,
);
}
let mode = meta.permissions().mode() & 0o777;
if mode & 0o022 != 0 {
bail!(
"{} is mode {mode:o}, so other users on this machine can write to \
it. semlith will not load model weights from it — run `chmod 700 \
{}`.",
cache.display(),
cache.display(),
);
}
}
#[cfg(not(unix))]
let _ = cache;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn granite_round_trips_through_its_stored_name() {
let parsed: Model = GRANITE_NAME.parse().unwrap();
assert_eq!(parsed, Model::Granite);
assert_eq!(parsed.to_string(), GRANITE_NAME);
assert_eq!(parsed.dim().unwrap(), 384);
}
#[test]
fn stores_written_by_0_1_0_still_parse() {
let parsed: Model = "BGESmallENV15".parse().unwrap();
assert_eq!(parsed, Model::Builtin(EmbeddingModel::BGESmallENV15));
assert_eq!(parsed.to_string(), "BGESmallENV15");
assert_eq!(parsed.dim().unwrap(), 384);
}
#[test]
fn nonsense_model_names_are_rejected() {
assert!("not-a-model".parse::<Model>().is_err());
}
#[test]
fn only_bge_english_gets_the_instruction_prefix() {
let bge = Model::Builtin(EmbeddingModel::BGESmallENV15);
assert!(bge.query_text("hi").starts_with("Represent this sentence"));
assert!(bge.query_text("hi").ends_with("hi"));
assert_eq!(Model::Granite.query_text("hi"), "hi");
}
#[test]
fn thread_count_honours_the_override() {
unsafe { std::env::set_var(THREADS_ENV, "3") };
assert_eq!(embed_threads(), 3);
unsafe { std::env::remove_var(THREADS_ENV) };
assert!(embed_threads() >= 1);
}
#[test]
fn a_lone_writer_gets_every_derived_thread() {
assert_eq!(split(4, 0, 4), 4);
assert_eq!(split(4, 1, 4), 4, "a four-core runner, one run");
assert_eq!(split(4, 3, 8), 1, "the M1, three runs");
assert_eq!(split(4, 2, 8), 2);
for writers in 2..=8 {
let each = split(4, writers, 2);
assert!((1..=4).contains(&each), "{writers} writers got {each}");
}
}
#[test]
fn the_digest_is_lowercase_hex_of_the_published_vectors() {
assert_eq!(
digest(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
digest(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
digest(b"semlith"),
digest("semlith".as_bytes()),
"the same bytes hash the same way whatever produced them"
);
assert_eq!(
digest(b"").len(),
64,
"sixty-four hex characters, never a shorter form"
);
}
#[test]
fn a_stalled_download_fails_with_a_reason_instead_of_waiting() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let mut held = Vec::new();
for stream in listener.incoming().flatten() {
held.push(stream);
}
});
let cache = std::env::temp_dir().join(format!("semlith-stall-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
let mut pinned = Pinned::at(
&format!("http://127.0.0.1:{port}"),
&cache,
"someone/some-model",
"main",
true,
)
.unwrap();
pinned.stall = std::time::Duration::from_secs(1);
let started = std::time::Instant::now();
let error = format!("{:#}", pinned.get("model.onnx").unwrap_err());
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"{error}"
);
assert!(error.contains("stalled"), "{error}");
assert!(error.contains("someone/some-model"), "{error}");
assert!(error.contains("0 B of an unknown size received"), "{error}");
assert!(error.contains("semlith setup"), "{error}");
let _ = std::fs::remove_dir_all(&cache);
}
}