use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, anyhow, bail};
use half::f16;
use hf_hub::HFClientSync;
use safetensors::tensor::{Dtype, SafeTensors, TensorView, View, serialize};
use sha2::{Digest, Sha256};
const REPO_OWNER: &str = "BAAI";
const REPO_NAME: &str = "bge-small-en-v1.5";
const REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
const WEIGHTS_SRC: &str = "model.safetensors";
const TOKENIZER_SRC: &str = "tokenizer.json";
const CONFIG_SRC: &str = "config.json";
const WEIGHTS_SHA256: &str = "3c9f31665447c8911517620762200d2245a2518d6e7208acc78cd9db317e21ad";
const TOKENIZER_SHA256: &str = "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66";
const CONFIG_SHA256: &str = "094f8e891b932f2000c92cfc663bac4c62069f5d8af5b5278c4306aef3084750";
const WEIGHTS_OUT: &str = "model-fp16.safetensors";
const REVISION_OUT: &str = "revision.txt";
const REPO_OUT: &str = "repo.txt";
const STAMP_OUT: &str = "assets.stamp";
const CONVERSION_VERSION: &str = "2";
const OUTPUTS: [&str; 3] = [WEIGHTS_OUT, TOKENIZER_SRC, CONFIG_SRC];
fn main() {
if let Err(cause) = run() {
eprintln!("\npromptforge-tool-picker build script failed.\n\n{cause:?}\n");
std::process::exit(1);
}
}
fn run() -> Result<()> {
println!("cargo::rerun-if-changed=build.rs");
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").context("OUT_DIR is unset")?);
write_atomic(&out_dir, REVISION_OUT, REVISION.as_bytes())?;
write_atomic(
&out_dir,
REPO_OUT,
format!("{REPO_OWNER}/{REPO_NAME}").as_bytes(),
)?;
if is_up_to_date(&out_dir) {
return Ok(());
}
let weights = fetch(WEIGHTS_SRC, WEIGHTS_SHA256)?;
let tokenizer = fetch(TOKENIZER_SRC, TOKENIZER_SHA256)?;
let config = fetch(CONFIG_SRC, CONFIG_SHA256)?;
let weights_fp16 = to_fp16(&weights).context("downcast the weights to fp16")?;
write_atomic(&out_dir, WEIGHTS_OUT, &weights_fp16)?;
write_atomic(&out_dir, TOKENIZER_SRC, &tokenizer)?;
write_atomic(&out_dir, CONFIG_SRC, &config)?;
write_atomic(&out_dir, STAMP_OUT, stamp(&out_dir)?.as_bytes())?;
Ok(())
}
fn stamp(out_dir: &Path) -> Result<String> {
let mut stamp = format!("{REVISION} fp16 v{CONVERSION_VERSION}\n");
for name in OUTPUTS {
let path = out_dir.join(name);
let bytes = std::fs::read(&path)
.with_context(|| format!("read generated output {}", path.display()))?;
let _ = writeln!(stamp, "{name} {}", hex(Sha256::digest(&bytes).as_slice()));
}
Ok(stamp)
}
fn is_up_to_date(out_dir: &Path) -> bool {
let Ok(recorded) = std::fs::read_to_string(out_dir.join(STAMP_OUT)) else {
return false;
};
match stamp(out_dir) {
Ok(current) => current == recorded,
Err(_) => false,
}
}
fn write_atomic(dir: &Path, name: &str, bytes: &[u8]) -> Result<()> {
let final_path = dir.join(name);
let temp_path = dir.join(format!("{name}.tmp"));
std::fs::write(&temp_path, bytes)
.with_context(|| format!("write staged output {}", temp_path.display()))?;
std::fs::rename(&temp_path, &final_path)
.with_context(|| format!("commit output {}", final_path.display()))?;
Ok(())
}
fn fetch(filename: &str, expected: &str) -> Result<Vec<u8>> {
let client = HFClientSync::new().map_err(|e| unreachable_hub(filename, &e))?;
let path = client
.model(REPO_OWNER, REPO_NAME)
.download_file()
.filename(filename)
.revision(REVISION)
.send()
.map_err(|e| unreachable_hub(filename, &e))?;
let bytes =
std::fs::read(&path).with_context(|| format!("read cached download {}", path.display()))?;
let actual = hex(Sha256::digest(&bytes).as_slice());
if actual != expected {
bail!(
"checksum mismatch for {REPO_OWNER}/{REPO_NAME}@{REVISION}/{filename}\n \
expected sha256 {expected}\n \
actual sha256 {actual}\n \
cached at {}\n\
The pinned revision is immutable, so this file is corrupt or tampered with. \
Delete the cached copy and rebuild.",
path.display()
);
}
Ok(bytes)
}
fn unreachable_hub(filename: &str, cause: &dyn std::fmt::Display) -> anyhow::Error {
anyhow!(
"could not obtain {REPO_OWNER}/{REPO_NAME}@{REVISION}/{filename}: {cause}\n\
This crate compiles the embedding model into the library, so the first build \
needs network access to the Hugging Face Hub (about 130MB). Later builds reuse \
the Hugging Face cache; set HF_HUB_CACHE or HF_HOME to point at a warm one, or \
set HF_ENDPOINT to a reachable mirror."
)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
struct OwnedTensor {
dtype: Dtype,
shape: Vec<usize>,
data: Vec<u8>,
}
impl View for &OwnedTensor {
fn dtype(&self) -> Dtype {
self.dtype
}
fn shape(&self) -> &[usize] {
&self.shape
}
fn data(&self) -> std::borrow::Cow<'_, [u8]> {
self.data.as_slice().into()
}
fn data_len(&self) -> usize {
self.data.len()
}
}
fn to_fp16(bytes: &[u8]) -> Result<Vec<u8>> {
let source = SafeTensors::deserialize(bytes).context("parse the upstream safetensors blob")?;
let converted: Vec<(String, OwnedTensor)> = source
.tensors()
.into_iter()
.map(|(name, view)| convert(&name, &view).map(|tensor| (name, tensor)))
.collect::<Result<_, _>>()?;
let metadata = HashMap::from([
("format".to_owned(), "pt".to_owned()),
(
"source".to_owned(),
format!("{REPO_OWNER}/{REPO_NAME}@{REVISION}"),
),
("precision".to_owned(), "fp16 downcast from fp32".to_owned()),
]);
let named = converted
.iter()
.map(|(name, tensor)| (name.as_str(), tensor));
serialize(named, Some(metadata)).context("serialize the converted safetensors blob")
}
fn convert(name: &str, view: &TensorView<'_>) -> Result<OwnedTensor> {
let shape = view.shape().to_vec();
if view.dtype() != Dtype::F32 {
return Ok(OwnedTensor {
dtype: view.dtype(),
shape,
data: view.data().to_vec(),
});
}
let source = view.data();
if source.len() % 4 != 0 {
bail!(
"tensor {name} is F32 but its {} bytes are not a whole number of f32 values",
source.len()
);
}
let mut data = Vec::with_capacity(source.len() / 2);
for chunk in source.chunks_exact(4) {
let bits = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
data.extend_from_slice(&f16::from_f32(f32::from_bits(bits)).to_le_bytes());
}
Ok(OwnedTensor {
dtype: Dtype::F16,
shape,
data,
})
}