mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
- The crate ports Apache-2.0-only Hugging Face transformers code: it is now `license = "Apache-2.0"`, and every ported file carries the modification notice Apache-2.0 §4(b) asks for. - Weights::open checks every tensor's byte range (and the header) against the file's length: a truncated or damaged download is an error at load, never a short read later; regression test with hostile offsets. - fetch/: the model download, only when asked for. An ordered mirror list (env LIGHTCRAFT_SAM3_MIRRORS, a mirrors file, DEFAULT_MIRRORS — empty until LightCraft's CDN exists, TODO for the maintainer), fallback to the next mirror, resume via Range, redirects, connect and stall timeouts, cancel, size caps, exact size + SHA-256 (pinned from tools/install-sam3.sh) before an atomic rename; damaged files are deleted, HTTP error bodies never written. - HTTP/1.1 client over std sockets + rustls with the RustCrypto provider (rustls-rustcrypto) and webpki-roots: no ring / aws-lc-rs, nothing compiled from C (no new `cc` users in the app's build graph). - Tests against a local TcpListener server (no internet): fallback, redirects, chunked bodies, resume, 416, hash mismatch, too much data, stalls, cancel. - *.safetensors are gitignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.3 KiB
Rust
178 lines
6.3 KiB
Rust
//! The CLIP byte-pair-encoding tokenizer (lower-cased, `</w>` word ends), padded to the
|
|
//! 32-token context SAM 3's text encoder takes.
|
|
//!
|
|
//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from
|
|
//! the Python/PyTorch SAM 3 code of Hugging Face Transformers (the CLIP tokenizer used by `models/sam3`), Copyright The HuggingFace
|
|
//! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
use crate::{Error, Result};
|
|
|
|
pub const CONTEXT: usize = 32;
|
|
const BOS: u32 = 49406;
|
|
const EOS: u32 = 49407;
|
|
|
|
pub struct Tokenizer {
|
|
vocab: HashMap<String, u32>,
|
|
ranks: HashMap<(String, String), usize>,
|
|
byte_char: [char; 256],
|
|
}
|
|
|
|
/// GPT-2's reversible byte → printable character map.
|
|
fn bytes_to_unicode() -> [char; 256] {
|
|
let mut map = ['\0'; 256];
|
|
let mut n = 0u32;
|
|
for b in 0..=255u8 {
|
|
let printable = (b'!'..=b'~').contains(&b) || (0xA1..=0xAC).contains(&b) || (0xAE..=0xFF).contains(&b);
|
|
let c = if printable {
|
|
u32::from(b)
|
|
} else {
|
|
n += 1;
|
|
255 + n
|
|
};
|
|
if let Some(slot) = map.get_mut(usize::from(b)) {
|
|
*slot = char::from_u32(c).unwrap_or('?');
|
|
}
|
|
}
|
|
map
|
|
}
|
|
|
|
impl Tokenizer {
|
|
/// From `vocab.json` and `merges.txt` in `dir`.
|
|
pub fn load(dir: &Path) -> Result<Self> {
|
|
let read = |name: &str| std::fs::read_to_string(dir.join(name)).map_err(|e| Error::Model(format!("{name}: {e}")));
|
|
let vocab: HashMap<String, u32> = serde_json::from_str(&read("vocab.json")?).map_err(|e| Error::Model(format!("vocab.json: {e}")))?;
|
|
let merges = read("merges.txt")?;
|
|
Ok(Self::new(vocab, &merges))
|
|
}
|
|
|
|
pub fn new(vocab: HashMap<String, u32>, merges: &str) -> Self {
|
|
let ranks = merges
|
|
.lines()
|
|
.filter(|l| !l.starts_with("#version") && !l.trim().is_empty())
|
|
.filter_map(|l| l.split_once(' ').map(|(a, b)| (a.to_string(), b.to_string())))
|
|
.enumerate()
|
|
.map(|(i, p)| (p, i))
|
|
.collect();
|
|
Self { vocab, ranks, byte_char: bytes_to_unicode() }
|
|
}
|
|
|
|
/// Token ids (start, words, end, padding) and which positions are real tokens.
|
|
pub fn encode(&self, text: &str) -> (Vec<u32>, Vec<bool>) {
|
|
let mut ids = vec![BOS];
|
|
for word in pre_tokenize(&text.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()) {
|
|
let mapped: String = word.bytes().map(|b| self.byte_char.get(usize::from(b)).copied().unwrap_or('?')).collect();
|
|
for piece in self.bpe(&mapped) {
|
|
ids.push(self.vocab.get(&piece).copied().unwrap_or(EOS));
|
|
}
|
|
}
|
|
ids.truncate(CONTEXT - 1);
|
|
ids.push(EOS);
|
|
let n = ids.len();
|
|
ids.resize(CONTEXT, EOS);
|
|
let valid = (0..CONTEXT).map(|i| i < n).collect();
|
|
(ids, valid)
|
|
}
|
|
|
|
fn bpe(&self, word: &str) -> Vec<String> {
|
|
let mut syms: Vec<String> = word.chars().map(String::from).collect();
|
|
if let Some(last) = syms.last_mut() {
|
|
last.push_str("</w>");
|
|
}
|
|
loop {
|
|
let best = syms
|
|
.windows(2)
|
|
.enumerate()
|
|
.filter_map(|(i, w)| match w {
|
|
[a, b] => self.ranks.get(&(a.clone(), b.clone())).map(|r| (*r, i)),
|
|
_ => None,
|
|
})
|
|
.min();
|
|
let Some((_, i)) = best else { break };
|
|
let (Some(a), Some(b)) = (syms.get(i).cloned(), syms.get(i + 1).cloned()) else { break };
|
|
let merged = a.clone() + &b;
|
|
// merge every occurrence of the pair, left to right
|
|
let mut out = Vec::with_capacity(syms.len());
|
|
let mut j = 0;
|
|
while j < syms.len() {
|
|
if j + 1 < syms.len() && syms.get(j) == Some(&a) && syms.get(j + 1) == Some(&b) {
|
|
out.push(merged.clone());
|
|
j += 2;
|
|
} else {
|
|
out.extend(syms.get(j).cloned());
|
|
j += 1;
|
|
}
|
|
}
|
|
syms = out;
|
|
if syms.len() == 1 {
|
|
break;
|
|
}
|
|
}
|
|
syms
|
|
}
|
|
}
|
|
|
|
/// CLIP's pre-tokenizer: contractions, letter runs, single digits, and runs of other
|
|
/// non-space characters.
|
|
fn pre_tokenize(text: &str) -> Vec<String> {
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
while let Some(&c) = chars.get(i) {
|
|
if c.is_whitespace() {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if c == '\'' {
|
|
let rest: String = chars.get(i + 1..(i + 3).min(chars.len())).map(|s| s.iter().collect()).unwrap_or_default();
|
|
if let Some(k) = ["re", "ve", "ll"]
|
|
.iter()
|
|
.find(|k| rest.starts_with(**k))
|
|
.map(|k| k.len())
|
|
.or_else(|| rest.chars().next().filter(|c| matches!(c, 's' | 't' | 'm' | 'd')).map(|_| 1))
|
|
{
|
|
out.push(chars.get(i..i + 1 + k).map(|s| s.iter().collect()).unwrap_or_default());
|
|
i += 1 + k;
|
|
continue;
|
|
}
|
|
}
|
|
let start = i;
|
|
if c.is_alphabetic() {
|
|
while chars.get(i).is_some_and(|c| c.is_alphabetic()) {
|
|
i += 1;
|
|
}
|
|
} else if c.is_numeric() {
|
|
i += 1;
|
|
} else {
|
|
while chars.get(i).is_some_and(|c| !c.is_whitespace() && !c.is_alphabetic() && !c.is_numeric()) {
|
|
i += 1;
|
|
}
|
|
}
|
|
out.push(chars.get(start..i).map(|s| s.iter().collect()).unwrap_or_default());
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn splits_like_clip() {
|
|
assert_eq!(pre_tokenize("a dog's toy, 25 cats!"), ["a", "dog", "'s", "toy", ",", "2", "5", "cats", "!"]);
|
|
}
|
|
|
|
#[test]
|
|
fn merges_by_rank() {
|
|
let vocab: HashMap<String, u32> =
|
|
[("c", 1), ("a", 2), ("t</w>", 3), ("ca", 4), ("cat</w>", 5)].into_iter().map(|(k, v)| (k.to_string(), v)).collect();
|
|
let t = Tokenizer::new(vocab, "#version: 0.2\nc a\nca t</w>\n");
|
|
let (ids, valid) = t.encode(" CAT ");
|
|
assert_eq!(&ids[..3], &[BOS, 5, EOS]);
|
|
assert_eq!(ids.len(), CONTEXT);
|
|
assert_eq!(valid.iter().filter(|v| **v).count(), 3);
|
|
}
|
|
}
|