mesh: the real engines address their weights, so a grant's ModelIn can admit them and a receipt binds what answered #311
7 changed files with 229 additions and 16 deletions
2
joulehook/joulehook-rs/Cargo.lock
generated
2
joulehook/joulehook-rs/Cargo.lock
generated
|
|
@ -553,7 +553,9 @@ dependencies = [
|
|||
name = "mesh-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"mesh-schema",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
13
mesh/SPEC.md
13
mesh/SPEC.md
|
|
@ -338,6 +338,19 @@ decision from a warm one.
|
|||
> fields is how a verifier can tell it apart from the stronger one, rather than
|
||||
> having to assume.
|
||||
|
||||
A backend that loads its weights from files addresses them as BLAKE3 over the
|
||||
canonical encoding of the list of files its forward pass reads, in path order,
|
||||
each as its path relative to the model directory and the BLAKE3 of its bytes.
|
||||
Every byte is hashed, once, when the backend loads: a digest over a sample, a
|
||||
header or a size would look like a binding while leaving the weights free to
|
||||
change beneath it, and is worse than none. The file names are part of the
|
||||
address because they are part of what loads, and files that do not reach the
|
||||
forward pass, such as a tokenizer, are not. The reference engines address
|
||||
`config.json` with `model.safetensors`, or with `model.safetensors.index.json`
|
||||
and every shard it names; Gemma reports `fp32`, the arithmetic its loader
|
||||
widens to, and Falcon-H1 `bf16`. A backend that names its weights before
|
||||
serving MUST report the same digest after (§2).
|
||||
|
||||
---
|
||||
|
||||
## §4 Routing — cheapest-sufficient
|
||||
|
|
|
|||
2
mesh/mesh-rs/Cargo.lock
generated
2
mesh/mesh-rs/Cargo.lock
generated
|
|
@ -586,7 +586,9 @@ dependencies = [
|
|||
name = "mesh-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"mesh-schema",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
blake3 = "1"
|
||||
hex = "0.4"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@ mod engine {
|
|||
use jouleclaw_loader_gguf::gemma4::Gemma4;
|
||||
use jouleclaw_loader_gguf::ParseError;
|
||||
use mesh_engine::{Served, ServingEngine};
|
||||
use mesh_schema::{BackendId, Fidelity, ServeOutcome, ServeRequest};
|
||||
use mesh_schema::{BackendId, ContentId, Fidelity, ServeOutcome, ServeRequest};
|
||||
|
||||
/// The arithmetic this engine's forward pass runs at: the loader widens
|
||||
/// every tensor to `f32` and decodes in `f32`.
|
||||
const PRECISION: &str = "fp32";
|
||||
|
||||
/// Errors loading a GGUF-backed engine.
|
||||
///
|
||||
|
|
@ -46,6 +50,9 @@ mod engine {
|
|||
/// The model directory could not be parsed/loaded.
|
||||
#[error("load model: {0:?}")]
|
||||
Load(ParseError),
|
||||
/// The weights could not be read to address them.
|
||||
#[error("address the weights: {0}")]
|
||||
Digest(String),
|
||||
}
|
||||
|
||||
/// A Mesh backend backed by a GGUF model decoded with the family's pure-Rust
|
||||
|
|
@ -53,6 +60,9 @@ mod engine {
|
|||
/// feature).
|
||||
pub struct GgufEngine {
|
||||
model: Gemma4,
|
||||
/// The content address of the weights loaded (SPEC §3.6), taken once,
|
||||
/// at load, over every file the forward pass reads.
|
||||
digest: ContentId,
|
||||
id: BackendId,
|
||||
recipe: String,
|
||||
fidelity: Fidelity,
|
||||
|
|
@ -75,9 +85,12 @@ mod engine {
|
|||
fidelity: Fidelity,
|
||||
uj_per_token: u64,
|
||||
) -> Result<Self, GgufError> {
|
||||
let dir = dir.as_ref();
|
||||
let model = Gemma4::load(dir).map_err(GgufError::Load)?;
|
||||
let digest = gemma_weights_digest(dir).map_err(|e| GgufError::Digest(e.to_string()))?;
|
||||
Ok(Self {
|
||||
model,
|
||||
digest,
|
||||
id: BackendId::new(id),
|
||||
recipe: recipe.into(),
|
||||
fidelity,
|
||||
|
|
@ -86,11 +99,35 @@ mod engine {
|
|||
}
|
||||
}
|
||||
|
||||
/// The files the Gemma loader reads: `config.json`, and either one
|
||||
/// `model.safetensors` or an index together with every shard it names.
|
||||
pub(crate) fn gemma_weights_digest(dir: &Path) -> std::io::Result<ContentId> {
|
||||
let index = "model.safetensors.index.json";
|
||||
if !dir.join(index).exists() {
|
||||
return mesh_engine::weights_digest(dir, &["config.json", "model.safetensors"]);
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join(index))?)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
let shards: Vec<String> = parsed["weight_map"]
|
||||
.as_object()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "index has no weight_map"))?
|
||||
.values()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
let mut files = vec!["config.json", index];
|
||||
files.extend(shards.iter().map(String::as_str));
|
||||
mesh_engine::weights_digest(dir, &files)
|
||||
}
|
||||
|
||||
impl ServingEngine for GgufEngine {
|
||||
fn backend_id(&self) -> BackendId {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn weights_digest(&self) -> Option<ContentId> {
|
||||
Some(self.digest)
|
||||
}
|
||||
|
||||
fn recipe_id(&self) -> String {
|
||||
self.recipe.clone()
|
||||
}
|
||||
|
|
@ -123,13 +160,10 @@ mod engine {
|
|||
// makes no claim: the gate falls back to the advertised
|
||||
// rating, which is where these engines already stood.
|
||||
delivered_fidelity: None,
|
||||
// The weights are a real file, so a digest is obtainable —
|
||||
// but once, at load, never per request. Until this engine does
|
||||
// that it reports absent rather than something cheaper that
|
||||
// would look like a binding: the field's presence is a claim to
|
||||
// re-executability, and a partial digest is worse than none.
|
||||
weights_digest: None,
|
||||
precision: None,
|
||||
// Addressed once, at load, over every byte the forward pass
|
||||
// reads: the field's presence is a claim to re-executability.
|
||||
weights_digest: Some(self.digest),
|
||||
precision: Some(PRECISION.into()),
|
||||
};
|
||||
Served { tokens, outcome }
|
||||
}
|
||||
|
|
@ -146,7 +180,11 @@ mod falcon {
|
|||
use jouleclaw_loader_gguf::falcon_h1::{FalconH1Config, FalconH1Model};
|
||||
use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file;
|
||||
use mesh_engine::{Served, ServingEngine};
|
||||
use mesh_schema::{BackendId, Fidelity, ServeOutcome, ServeRequest};
|
||||
use mesh_schema::{BackendId, ContentId, Fidelity, ServeOutcome, ServeRequest};
|
||||
|
||||
/// The arithmetic this engine's forward pass runs at: the model binds only
|
||||
/// `bf16` weights and multiplies them as `bf16`.
|
||||
const PRECISION: &str = "bf16";
|
||||
|
||||
/// Errors loading a Falcon-H1 backend.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -159,6 +197,8 @@ mod falcon {
|
|||
Bind(String),
|
||||
#[error("load tokenizer: {0}")]
|
||||
Tokenizer(String),
|
||||
#[error("address the weights: {0}")]
|
||||
Digest(String),
|
||||
}
|
||||
|
||||
/// A Mesh backend backed by a real Falcon-H1 checkpoint plus its tokenizer.
|
||||
|
|
@ -166,6 +206,10 @@ mod falcon {
|
|||
pub struct FalconEngine {
|
||||
model: FalconH1Model,
|
||||
tokenizer: BpeTokenizer,
|
||||
/// The content address of the weights loaded (SPEC §3.6): `config.json`
|
||||
/// and `model.safetensors`, the files the forward pass reads. The
|
||||
/// tokenizer maps text to the ids a caller sends and is not among them.
|
||||
digest: ContentId,
|
||||
id: BackendId,
|
||||
recipe: String,
|
||||
fidelity: Fidelity,
|
||||
|
|
@ -193,9 +237,12 @@ mod falcon {
|
|||
.map_err(|e| FalconError::Bind(format!("{e:?}")))?;
|
||||
let tokenizer = BpeTokenizer::from_file(dir.join("tokenizer.json"))
|
||||
.map_err(|e| FalconError::Tokenizer(format!("{e:?}")))?;
|
||||
let digest = mesh_engine::weights_digest(dir, &["config.json", "model.safetensors"])
|
||||
.map_err(|e| FalconError::Digest(e.to_string()))?;
|
||||
Ok(Self {
|
||||
model,
|
||||
tokenizer,
|
||||
digest,
|
||||
id: BackendId::new(id),
|
||||
recipe: recipe.into(),
|
||||
fidelity,
|
||||
|
|
@ -232,6 +279,9 @@ mod falcon {
|
|||
fn backend_id(&self) -> BackendId {
|
||||
self.id.clone()
|
||||
}
|
||||
fn weights_digest(&self) -> Option<ContentId> {
|
||||
Some(self.digest)
|
||||
}
|
||||
fn recipe_id(&self) -> String {
|
||||
self.recipe.clone()
|
||||
}
|
||||
|
|
@ -268,13 +318,10 @@ mod falcon {
|
|||
// makes no claim: the gate falls back to the advertised
|
||||
// rating, which is where these engines already stood.
|
||||
delivered_fidelity: None,
|
||||
// The weights are a real file, so a digest is obtainable —
|
||||
// but once, at load, never per request. Until this engine does
|
||||
// that it reports absent rather than something cheaper that
|
||||
// would look like a binding: the field's presence is a claim to
|
||||
// re-executability, and a partial digest is worse than none.
|
||||
weights_digest: None,
|
||||
precision: None,
|
||||
// Addressed once, at load, over every byte the forward pass
|
||||
// reads: the field's presence is a claim to re-executability.
|
||||
weights_digest: Some(self.digest),
|
||||
precision: Some(PRECISION.into()),
|
||||
};
|
||||
Served { tokens, outcome }
|
||||
}
|
||||
|
|
@ -314,5 +361,71 @@ mod tests {
|
|||
let served = e.serve(&req, &[2u32, 3u32]);
|
||||
assert!(served.outcome.tokens_out > 0);
|
||||
assert_eq!(served.outcome.tokens_accepted, served.outcome.tokens_out);
|
||||
// The weights are addressed before serving and reported after, the same.
|
||||
assert!(e.weights_digest().is_some());
|
||||
assert_eq!(served.outcome.weights_digest, e.weights_digest());
|
||||
assert_eq!(served.outcome.precision.as_deref(), Some("fp32"));
|
||||
}
|
||||
|
||||
/// A scratch model directory, removed when dropped.
|
||||
struct ModelDir(std::path::PathBuf);
|
||||
impl ModelDir {
|
||||
fn with(tag: &str, files: &[(&str, &[u8])]) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("mesh-gguf-digest-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, bytes) in files {
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
}
|
||||
ModelDir(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for ModelDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Gemma engine addresses the files its loader reads: one
|
||||
/// `model.safetensors`, or an index and every shard the index names, and
|
||||
/// a shard the index names but the directory lacks is an error, not a
|
||||
/// smaller address.
|
||||
#[test]
|
||||
fn the_gemma_engine_addresses_what_its_loader_reads() {
|
||||
let single = ModelDir::with("single", &[("config.json", b"{}"), ("model.safetensors", b"w")]);
|
||||
assert_eq!(
|
||||
engine::gemma_weights_digest(&single.0).unwrap(),
|
||||
mesh_engine::weights_digest(&single.0, &["config.json", "model.safetensors"]).unwrap()
|
||||
);
|
||||
|
||||
let index = br#"{"weight_map":{"a.weight":"m-1.safetensors","b.weight":"m-2.safetensors","c.weight":"m-1.safetensors"}}"#;
|
||||
let sharded = ModelDir::with(
|
||||
"sharded",
|
||||
&[("config.json", b"{}"), ("model.safetensors.index.json", index), ("m-1.safetensors", b"one"), ("m-2.safetensors", b"two")],
|
||||
);
|
||||
let all = ["config.json", "model.safetensors.index.json", "m-1.safetensors", "m-2.safetensors"];
|
||||
assert_eq!(engine::gemma_weights_digest(&sharded.0).unwrap(), mesh_engine::weights_digest(&sharded.0, &all).unwrap());
|
||||
|
||||
std::fs::remove_file(sharded.0.join("m-2.safetensors")).unwrap();
|
||||
assert!(engine::gemma_weights_digest(&sharded.0).is_err(), "a named shard is missing");
|
||||
}
|
||||
|
||||
/// On real weights: the address covers every shard actually on disk. Runs
|
||||
/// only when `MESH_WEIGHTS_DIR` names a model directory; it hashes the
|
||||
/// weights without loading them.
|
||||
#[test]
|
||||
#[ignore = "requires a local model dir via MESH_WEIGHTS_DIR"]
|
||||
fn addresses_every_shard_of_real_weights() {
|
||||
let dir = std::path::PathBuf::from(std::env::var("MESH_WEIGHTS_DIR").expect("set MESH_WEIGHTS_DIR"));
|
||||
let mut files: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.filter(|n| n.ends_with(".safetensors") || n == "config.json" || n == "model.safetensors.index.json")
|
||||
.collect();
|
||||
files.sort();
|
||||
let names: Vec<&str> = files.iter().map(String::as_str).collect();
|
||||
let d = engine::gemma_weights_digest(&dir).unwrap();
|
||||
assert_eq!(d, mesh_engine::weights_digest(&dir, &names).unwrap(), "every shard on disk, and nothing else");
|
||||
println!("{} files, weights digest {}", names.len(), d.to_hex());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ description = "Mesh — the serving-engine interface a backend implements, plus
|
|||
|
||||
[dependencies]
|
||||
mesh-schema = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -15,6 +15,37 @@
|
|||
|
||||
use mesh_schema::{BackendId, ContentId, Fidelity, ServeOutcome, ServeRequest};
|
||||
|
||||
/// The content address of a model's weights (SPEC §3.6), for a backend that
|
||||
/// loads them from files: BLAKE3 over the canonical list of the files its
|
||||
/// forward pass reads, each named by its path relative to `dir` and addressed
|
||||
/// by the BLAKE3 of its bytes, in path order.
|
||||
///
|
||||
/// Naming each file keeps the address honest about structure: the same bytes
|
||||
/// split across differently named shards address differently, as they load
|
||||
/// differently. Hashing every byte is the point. A digest over a sample, a
|
||||
/// header or a file size is cheaper and would look like a binding while
|
||||
/// leaving the weights free to change underneath it, so this reads them all,
|
||||
/// once, when the backend loads them.
|
||||
///
|
||||
/// `files` are the paths the forward pass reads, relative to `dir`; the order
|
||||
/// they are listed in does not matter.
|
||||
pub fn weights_digest(dir: &std::path::Path, files: &[&str]) -> std::io::Result<ContentId> {
|
||||
#[derive(serde::Serialize)]
|
||||
struct WeightsManifest<'a> {
|
||||
files: Vec<(&'a str, ContentId)>,
|
||||
}
|
||||
let mut named: Vec<&str> = files.to_vec();
|
||||
named.sort_unstable();
|
||||
named.dedup();
|
||||
let mut manifest = WeightsManifest { files: Vec::with_capacity(named.len()) };
|
||||
for name in named {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_reader(std::fs::File::open(dir.join(name))?)?;
|
||||
manifest.files.push((name, ContentId(*hasher.finalize().as_bytes())));
|
||||
}
|
||||
ContentId::of(&manifest).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
|
||||
}
|
||||
|
||||
/// What an engine returns from one `serve`: the generated token ids and the
|
||||
/// serving counters.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -257,6 +288,55 @@ impl ServingEngine for ReferenceEngine {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch directory holding `files`, removed when dropped.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
impl Scratch {
|
||||
fn with(tag: &str, files: &[(&str, &[u8])]) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("mesh-weights-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, bytes) in files {
|
||||
std::fs::write(dir.join(name), bytes).unwrap();
|
||||
}
|
||||
Scratch(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The address is a function of every named file's bytes and name, and of
|
||||
/// nothing else: not the order they are listed in.
|
||||
#[test]
|
||||
fn a_weights_digest_addresses_every_byte_and_every_name() {
|
||||
let base = Scratch::with("base", &[("config.json", b"{\"d\":4}"), ("model.safetensors", b"weights")]);
|
||||
let files = ["config.json", "model.safetensors"];
|
||||
let d = weights_digest(&base.0, &files).unwrap();
|
||||
assert_eq!(d, weights_digest(&base.0, &["model.safetensors", "config.json"]).unwrap(), "order-free");
|
||||
assert_eq!(d, weights_digest(&base.0, &files).unwrap(), "deterministic");
|
||||
|
||||
let one_byte = Scratch::with("byte", &[("config.json", b"{\"d\":4}"), ("model.safetensors", b"weightz")]);
|
||||
assert_ne!(d, weights_digest(&one_byte.0, &files).unwrap(), "one changed byte");
|
||||
let renamed = Scratch::with("name", &[("config.json", b"{\"d\":4}"), ("shard.safetensors", b"weights")]);
|
||||
assert_ne!(d, weights_digest(&renamed.0, &["config.json", "shard.safetensors"]).unwrap(), "a renamed file");
|
||||
assert_ne!(d, weights_digest(&base.0, &["model.safetensors"]).unwrap(), "a file left out");
|
||||
assert!(weights_digest(&base.0, &["config.json", "absent.safetensors"]).is_err(), "a missing file is an error");
|
||||
}
|
||||
|
||||
/// Each file is addressed exactly as `ContentId::of_bytes` would address
|
||||
/// it, though it is streamed rather than read into memory.
|
||||
#[test]
|
||||
fn a_streamed_file_hash_is_its_content_id() {
|
||||
let big: Vec<u8> = (0..300_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let dir = Scratch::with("stream", &[("w.bin", &big)]);
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_reader(std::fs::File::open(dir.0.join("w.bin")).unwrap()).unwrap();
|
||||
assert_eq!(ContentId(*hasher.finalize().as_bytes()), ContentId::of_bytes(&big));
|
||||
}
|
||||
|
||||
use mesh_schema::ContentId;
|
||||
|
||||
fn req(tag: u8, max_tokens: u32) -> ServeRequest {
|
||||
|
|
|
|||
Loading…
Reference in a new issue