use std::path::Path;
use ferrox_gguf::{GgufFile, GgufValue};
use sha2::{Digest, Sha256};
pub(crate) const SAMPLE_BYTES: usize = 4096;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SlotIdentity {
pub(crate) model_name: String,
pub(crate) n_layers: usize,
pub(crate) n_kv_heads: usize,
pub(crate) head_dim: usize,
pub(crate) dtype: ferrox_core::kv_signature::KvDtype,
pub(crate) fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct IdentityMismatch {
pub(crate) field: &'static str,
pub(crate) saved: String,
pub(crate) serving: String,
}
impl std::fmt::Display for IdentityMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: the slot was saved under {} and this server is serving {}",
self.field, self.saved, self.serving
)
}
}
impl SlotIdentity {
pub(crate) fn compare(&self, serving: &SlotIdentity) -> Result<(), IdentityMismatch> {
let SlotIdentity {
model_name,
n_layers,
n_kv_heads,
head_dim,
dtype,
fingerprint,
} = self;
let pairs: [(&'static str, String, String); 6] = [
("model", model_name.clone(), serving.model_name.clone()),
(
"n_layers",
n_layers.to_string(),
serving.n_layers.to_string(),
),
(
"n_kv_heads",
n_kv_heads.to_string(),
serving.n_kv_heads.to_string(),
),
(
"head_dim",
head_dim.to_string(),
serving.head_dim.to_string(),
),
(
"kv_dtype",
dtype.as_str().to_string(),
serving.dtype.as_str().to_string(),
),
(
"checkpoint",
fingerprint.clone(),
serving.fingerprint.clone(),
),
];
for (field, saved, serving) in pairs {
if saved != serving {
return Err(IdentityMismatch {
field,
saved,
serving,
});
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum FingerprintError {
#[error(
"this server is not serving a GGUF checkpoint on disk, so a slot cannot be identified \
with one. Slots need -m/--model pointing at a .gguf file"
)]
NoCheckpoint,
#[error("reading the checkpoint {path} to identify it: {source}")]
Unreadable {
path: String,
#[source]
source: ferrox_gguf::GgufError,
},
}
pub(crate) fn fingerprint_gguf(path: &Path) -> Result<String, FingerprintError> {
let file = GgufFile::open(path).map_err(|source| FingerprintError::Unreadable {
path: path.display().to_string(),
source,
})?;
let mut hasher = Sha256::new();
hasher.update(b"ferrox.slot.checkpoint.v1");
hasher.update(file.version.to_le_bytes());
let mut keys: Vec<&String> = file.metadata.keys().collect();
keys.sort();
hasher.update((keys.len() as u64).to_le_bytes());
for key in keys {
hash_len_prefixed(&mut hasher, key.as_bytes());
hash_value(&mut hasher, &file.metadata[key]);
}
hasher.update((file.tensors.len() as u64).to_le_bytes());
for tensor in &file.tensors {
hash_len_prefixed(&mut hasher, tensor.name.as_bytes());
hasher.update((tensor.shape.len() as u64).to_le_bytes());
for dim in &tensor.shape {
hasher.update(dim.to_le_bytes());
}
hasher.update(tensor.dtype.to_tag().to_le_bytes());
hasher.update(tensor.offset.to_le_bytes());
match file.tensor_bytes(&tensor.name) {
Ok(bytes) => {
let head = &bytes[..bytes.len().min(SAMPLE_BYTES)];
let tail = &bytes[bytes.len().saturating_sub(SAMPLE_BYTES)..];
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(head);
hasher.update(tail);
}
Err(_) => hasher.update(b"\xffunsampled"),
}
}
Ok(hex(&hasher.finalize()))
}
fn hash_len_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
fn hash_value(hasher: &mut Sha256, value: &GgufValue) {
match value {
GgufValue::U8(v) => {
hasher.update([0u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::I8(v) => {
hasher.update([1u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::U16(v) => {
hasher.update([2u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::I16(v) => {
hasher.update([3u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::U32(v) => {
hasher.update([4u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::I32(v) => {
hasher.update([5u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::F32(v) => {
hasher.update([6u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::Bool(v) => {
hasher.update([7u8]);
hasher.update([*v as u8]);
}
GgufValue::String(v) => {
hasher.update([8u8]);
hash_len_prefixed(hasher, v.as_bytes());
}
GgufValue::U64(v) => {
hasher.update([9u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::I64(v) => {
hasher.update([10u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::F64(v) => {
hasher.update([11u8]);
hasher.update(v.to_le_bytes());
}
GgufValue::Array(items) => {
hasher.update([12u8]);
hasher.update((items.len() as u64).to_le_bytes());
for item in items {
hash_value(hasher, item);
}
}
}
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ferrox_core::kv_signature::KvDtype;
fn identity(name: &str, layers: usize, fingerprint: &str) -> SlotIdentity {
SlotIdentity {
model_name: name.to_string(),
n_layers: layers,
n_kv_heads: 4,
head_dim: 8,
dtype: KvDtype::F32,
fingerprint: fingerprint.to_string(),
}
}
#[test]
fn an_identity_matches_itself() {
let a = identity("llama", 16, "abc");
assert_eq!(a.compare(&a), Ok(()));
}
#[test]
fn two_checkpoints_of_identical_shape_are_told_apart_by_their_weights() {
let saved = identity("llama", 16, "aaaa");
let serving = identity("llama", 16, "bbbb");
let err = saved.compare(&serving).unwrap_err();
assert_eq!(err.field, "checkpoint");
assert_eq!(err.saved, "aaaa");
assert_eq!(err.serving, "bbbb");
}
#[test]
fn a_shape_difference_is_named_before_the_digest() {
let saved = identity("llama", 16, "aaaa");
let serving = identity("llama", 32, "bbbb");
let err = saved.compare(&serving).unwrap_err();
assert_eq!(err.field, "n_layers", "the actionable field wins");
assert!(err.to_string().contains("16"), "{err}");
assert!(err.to_string().contains("32"), "{err}");
}
#[test]
fn a_different_model_name_is_named_first_of_all() {
let saved = identity("llama", 16, "aaaa");
let serving = identity("qwen2", 16, "aaaa");
assert_eq!(saved.compare(&serving).unwrap_err().field, "model");
}
#[test]
fn metadata_values_of_different_types_do_not_hash_alike() {
let mut a = Sha256::new();
hash_value(&mut a, &GgufValue::U32(4));
let mut b = Sha256::new();
hash_value(&mut b, &GgufValue::String("4".to_string()));
assert_ne!(hex(&a.finalize()), hex(&b.finalize()));
}
#[test]
fn adjacent_strings_cannot_be_reassociated_across_their_boundary() {
let mut a = Sha256::new();
hash_len_prefixed(&mut a, b"ab");
hash_len_prefixed(&mut a, b"c");
let mut b = Sha256::new();
hash_len_prefixed(&mut b, b"a");
hash_len_prefixed(&mut b, b"bc");
assert_ne!(hex(&a.finalize()), hex(&b.finalize()));
}
}