1use std::collections::HashSet;
4
5use candle_core::quantized::GgmlDType;
6use serde::{Deserialize, Serialize};
7
8use crate::CandleError;
9
10pub const BEGIN_OF_TEXT: &str = "<|begin_of_text|>";
11pub const START_HEADER: &str = "<|start_header_id|>";
12pub const END_HEADER: &str = "<|end_header_id|>";
13pub const END_OF_TURN: &str = "<|eot_id|>";
14pub const IM_START: &str = "<|im_start|>";
15pub const IM_END: &str = "<|im_end|>";
16pub(crate) const END_OF_TEXT: &str = "<|endoftext|>";
17pub const SMOLLM2_DEFAULT_SYSTEM_PROMPT: &str =
18 "You are a helpful AI assistant named SmolLM, trained by Hugging Face";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ConversationProtocol {
24 Llama3,
26 SmolLm2,
28 Qwen3,
30}
31
32pub type ModelFamily = ConversationProtocol;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ModelArchitecture {
39 Llama,
41 Qwen3,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Quantization {
49 Q4K,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(crate) enum ArtifactFormat {
55 Safetensors,
56 Gguf,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(crate) enum LoaderBackend {
61 LlamaSafetensors,
62 LlamaGguf,
63 Qwen3Gguf,
64}
65
66#[derive(Debug)]
67pub(crate) struct ConfigIdentity {
68 pub(crate) model_type: &'static str,
69 pub(crate) architecture: &'static str,
70 pub(crate) required: bool,
71}
72
73#[derive(Debug, Clone, Copy)]
74pub(crate) struct DimensionRequirement {
75 pub(crate) field: &'static str,
76 pub(crate) value: usize,
77}
78
79#[derive(Debug)]
80pub(crate) struct ConfigRequirements {
81 pub(crate) hidden_act: Option<&'static str>,
82 pub(crate) attention_bias: Option<bool>,
83 pub(crate) mlp_bias: Option<bool>,
84 pub(crate) rope_interleaved: Option<bool>,
85 pub(crate) tie_word_embeddings: Option<bool>,
86 pub(crate) rms_norm_eps: Option<f64>,
87 pub(crate) rope_theta: Option<f64>,
88 pub(crate) bos_token_id: Option<u32>,
89 pub(crate) eos_token_id: Option<u32>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub(crate) enum TokenizerVocabulary {
94 ModelCapacity,
95 Exact(usize),
96}
97
98#[derive(Debug)]
99pub(crate) struct MetadataStringRequirement {
100 pub(crate) key: &'static str,
101 pub(crate) value: &'static str,
102}
103
104#[derive(Debug)]
105pub(crate) struct GgufRequirements {
106 pub(crate) file_type: u32,
107 pub(crate) quantization_version: usize,
108 pub(crate) metadata_strings: &'static [MetadataStringRequirement],
109 pub(crate) chat_template_markers: &'static [&'static str],
110 pub(crate) allowed_tensor_dtypes: &'static [GgmlDType],
111 pub(crate) token_embedding_dtypes: &'static [GgmlDType],
112 pub(crate) norm_dtypes: &'static [GgmlDType],
113 pub(crate) matrix_dtypes: &'static [GgmlDType],
114 pub(crate) mixed_matrix_dtypes: &'static [GgmlDType],
115 pub(crate) tensors_per_layer: Option<usize>,
116}
117
118#[derive(Debug)]
119pub(crate) struct ProfileDefinition {
120 pub(crate) name: &'static str,
121 pub(crate) architecture: ModelArchitecture,
122 pub(crate) protocol: ConversationProtocol,
123 pub(crate) artifact_format: ArtifactFormat,
124 pub(crate) loader: LoaderBackend,
125 pub(crate) quantization: Option<Quantization>,
126 pub(crate) config_identity: ConfigIdentity,
127 pub(crate) config_dimensions: &'static [DimensionRequirement],
128 pub(crate) config_requirements: ConfigRequirements,
129 pub(crate) tokenizer_tokens: &'static [&'static str],
130 pub(crate) tokenizer_vocabulary: TokenizerVocabulary,
131 pub(crate) start_token: &'static str,
132 pub(crate) end_token: &'static str,
133 pub(crate) context_limit_cap: Option<usize>,
134 pub(crate) gguf: Option<GgufRequirements>,
135}
136
137const LLAMA3_PROFILE: ProfileDefinition = ProfileDefinition {
138 name: "Llama 3 safetensors",
139 architecture: ModelArchitecture::Llama,
140 protocol: ConversationProtocol::Llama3,
141 artifact_format: ArtifactFormat::Safetensors,
142 loader: LoaderBackend::LlamaSafetensors,
143 quantization: None,
144 config_identity: ConfigIdentity {
145 model_type: "llama",
146 architecture: "LlamaForCausalLM",
147 required: false,
148 },
149 config_dimensions: &[],
150 config_requirements: ConfigRequirements {
151 hidden_act: None,
152 attention_bias: None,
153 mlp_bias: None,
154 rope_interleaved: None,
155 tie_word_embeddings: None,
156 rms_norm_eps: None,
157 rope_theta: None,
158 bos_token_id: None,
159 eos_token_id: None,
160 },
161 tokenizer_tokens: &[
162 "<|begin_of_text|>",
163 "<|start_header_id|>",
164 "<|end_header_id|>",
165 "<|eot_id|>",
166 ],
167 tokenizer_vocabulary: TokenizerVocabulary::ModelCapacity,
168 start_token: BEGIN_OF_TEXT,
169 end_token: END_OF_TURN,
170 context_limit_cap: None,
171 gguf: None,
172};
173
174const SMOLLM2_PROFILE: ProfileDefinition = ProfileDefinition {
175 name: "SmolLM2-360M-Instruct Q4_K_M GGUF",
176 architecture: ModelArchitecture::Llama,
177 protocol: ConversationProtocol::SmolLm2,
178 artifact_format: ArtifactFormat::Gguf,
179 loader: LoaderBackend::LlamaGguf,
180 quantization: Some(Quantization::Q4K),
181 config_identity: ConfigIdentity {
182 model_type: "llama",
183 architecture: "LlamaForCausalLM",
184 required: true,
185 },
186 config_dimensions: &[
187 DimensionRequirement {
188 field: "hidden_size",
189 value: 960,
190 },
191 DimensionRequirement {
192 field: "intermediate_size",
193 value: 2560,
194 },
195 DimensionRequirement {
196 field: "vocab_size",
197 value: 49_152,
198 },
199 DimensionRequirement {
200 field: "num_hidden_layers",
201 value: 32,
202 },
203 DimensionRequirement {
204 field: "num_attention_heads",
205 value: 15,
206 },
207 DimensionRequirement {
208 field: "num_key_value_heads",
209 value: 5,
210 },
211 DimensionRequirement {
212 field: "max_position_embeddings",
213 value: 8192,
214 },
215 ],
216 config_requirements: ConfigRequirements {
217 hidden_act: Some("silu"),
218 attention_bias: Some(false),
219 mlp_bias: Some(false),
220 rope_interleaved: Some(false),
221 tie_word_embeddings: Some(true),
222 rms_norm_eps: Some(1e-5),
223 rope_theta: Some(100_000.0),
224 bos_token_id: None,
225 eos_token_id: None,
226 },
227 tokenizer_tokens: &["<|im_start|>", "<|im_end|>"],
228 tokenizer_vocabulary: TokenizerVocabulary::ModelCapacity,
229 start_token: IM_START,
230 end_token: IM_END,
231 context_limit_cap: Some(4096),
232 gguf: Some(GgufRequirements {
233 file_type: 15,
234 quantization_version: 2,
235 metadata_strings: &[
236 MetadataStringRequirement {
237 key: "general.basename",
238 value: "smollm2",
239 },
240 MetadataStringRequirement {
241 key: "tokenizer.ggml.model",
242 value: "gpt2",
243 },
244 MetadataStringRequirement {
245 key: "tokenizer.ggml.pre",
246 value: "smollm",
247 },
248 ],
249 chat_template_markers: &[],
250 allowed_tensor_dtypes: &[
251 GgmlDType::F32,
252 GgmlDType::Q4K,
253 GgmlDType::Q5_0,
254 GgmlDType::Q6K,
255 GgmlDType::Q8_0,
256 ],
257 token_embedding_dtypes: &[],
258 norm_dtypes: &[],
259 matrix_dtypes: &[],
260 mixed_matrix_dtypes: &[],
261 tensors_per_layer: None,
262 }),
263};
264
265const QWEN3_PROFILE: ProfileDefinition = ProfileDefinition {
266 name: "Qwen3-4B Q4_K_M GGUF",
267 architecture: ModelArchitecture::Qwen3,
268 protocol: ConversationProtocol::Qwen3,
269 artifact_format: ArtifactFormat::Gguf,
270 loader: LoaderBackend::Qwen3Gguf,
271 quantization: Some(Quantization::Q4K),
272 config_identity: ConfigIdentity {
273 model_type: "qwen3",
274 architecture: "Qwen3ForCausalLM",
275 required: true,
276 },
277 config_dimensions: &[
278 DimensionRequirement {
279 field: "hidden_size",
280 value: 2560,
281 },
282 DimensionRequirement {
283 field: "intermediate_size",
284 value: 9728,
285 },
286 DimensionRequirement {
287 field: "num_hidden_layers",
288 value: 36,
289 },
290 DimensionRequirement {
291 field: "num_attention_heads",
292 value: 32,
293 },
294 DimensionRequirement {
295 field: "num_key_value_heads",
296 value: 8,
297 },
298 DimensionRequirement {
299 field: "head_dim",
300 value: 128,
301 },
302 DimensionRequirement {
303 field: "max_position_embeddings",
304 value: 40_960,
305 },
306 DimensionRequirement {
307 field: "vocab_size",
308 value: 151_936,
309 },
310 ],
311 config_requirements: ConfigRequirements {
312 hidden_act: Some("silu"),
313 attention_bias: Some(false),
314 mlp_bias: None,
315 rope_interleaved: None,
316 tie_word_embeddings: Some(true),
317 rms_norm_eps: Some(1e-6),
318 rope_theta: Some(1_000_000.0),
319 bos_token_id: Some(151_643),
320 eos_token_id: Some(151_645),
321 },
322 tokenizer_tokens: &["<|endoftext|>", "<|im_start|>", "<|im_end|>"],
323 tokenizer_vocabulary: TokenizerVocabulary::Exact(151_669),
324 start_token: END_OF_TEXT,
325 end_token: IM_END,
326 context_limit_cap: Some(4096),
327 gguf: Some(GgufRequirements {
328 file_type: 15,
329 quantization_version: 2,
330 metadata_strings: &[
331 MetadataStringRequirement {
332 key: "general.basename",
333 value: "qwen3",
334 },
335 MetadataStringRequirement {
336 key: "general.size_label",
337 value: "4b",
338 },
339 MetadataStringRequirement {
340 key: "general.finetune",
341 value: "instruct-awq",
342 },
343 MetadataStringRequirement {
344 key: "tokenizer.ggml.model",
345 value: "gpt2",
346 },
347 MetadataStringRequirement {
348 key: "tokenizer.ggml.pre",
349 value: "qwen2",
350 },
351 ],
352 chat_template_markers: &[
353 "# Tools",
354 "<tools></tools>",
355 "<tool_call>",
356 "<tool_response>",
357 "enable_thinking",
358 ],
359 allowed_tensor_dtypes: &[GgmlDType::F32, GgmlDType::Q4K, GgmlDType::Q6K],
360 token_embedding_dtypes: &[GgmlDType::Q6K],
361 norm_dtypes: &[GgmlDType::F32],
362 matrix_dtypes: &[GgmlDType::Q4K],
363 mixed_matrix_dtypes: &[GgmlDType::Q4K, GgmlDType::Q6K],
364 tensors_per_layer: Some(11),
365 }),
366};
367
368#[derive(Debug, Clone)]
369pub(crate) struct ValidatedProfile {
370 pub(crate) definition: &'static ProfileDefinition,
371 pub(crate) vocab_size: usize,
372 pub(crate) context_limit: usize,
373 pub(crate) stop_tokens: HashSet<u32>,
374}
375
376impl ValidatedProfile {
377 pub(crate) fn new(
378 definition: &'static ProfileDefinition,
379 vocab_size: usize,
380 configured_context_limit: usize,
381 stop_tokens: HashSet<u32>,
382 ) -> Result<Self, CandleError> {
383 if stop_tokens.is_empty() {
384 return Err(CandleError::MissingStopToken);
385 }
386 let context_limit = definition
387 .context_limit_cap
388 .map_or(configured_context_limit, |cap| {
389 configured_context_limit.min(cap)
390 });
391 Ok(Self {
392 definition,
393 vocab_size,
394 context_limit,
395 stop_tokens,
396 })
397 }
398}
399
400pub(crate) fn definition_for(
401 protocol: ConversationProtocol,
402 artifact_format: ArtifactFormat,
403) -> Result<&'static ProfileDefinition, CandleError> {
404 let definition = match protocol {
405 ConversationProtocol::Llama3 => &LLAMA3_PROFILE,
406 ConversationProtocol::SmolLm2 => &SMOLLM2_PROFILE,
407 ConversationProtocol::Qwen3 => &QWEN3_PROFILE,
408 };
409 if definition.artifact_format != artifact_format {
410 return Err(CandleError::UnsupportedModelFamily(format!(
411 "{} requires {:?} artifacts",
412 definition.name, definition.artifact_format
413 )));
414 }
415 Ok(definition)
416}
417
418pub(crate) fn validate_identity(
419 definition: &ProfileDefinition,
420 model_type: Option<&str>,
421 architectures: &[String],
422) -> Result<(), CandleError> {
423 let expected = &definition.config_identity;
424 let model_type_mismatch = model_type
425 .is_some_and(|model_type| model_type != expected.model_type)
426 || (expected.required && model_type.is_none());
427 let architecture_mismatch = (!architectures.is_empty()
428 && !architectures
429 .iter()
430 .any(|architecture| architecture == expected.architecture))
431 || (expected.required && architectures.is_empty());
432 if model_type_mismatch || architecture_mismatch {
433 return Err(CandleError::UnsupportedModelFamily(format!(
434 "{} requires model_type `{}` and architecture `{}`",
435 definition.name, expected.model_type, expected.architecture
436 )));
437 }
438 Ok(())
439}
440
441pub(crate) fn validate_dimensions(
442 definition: &ProfileDefinition,
443 actual: &[(&'static str, usize)],
444) -> Result<(), CandleError> {
445 for requirement in definition.config_dimensions {
446 let actual = actual
447 .iter()
448 .find_map(|(field, value)| (*field == requirement.field).then_some(*value))
449 .ok_or_else(|| {
450 CandleError::Configuration(format!(
451 "internal profile validation omitted `{}`",
452 requirement.field
453 ))
454 })?;
455 if actual != requirement.value {
456 return Err(CandleError::ArtifactMismatch {
457 artifact: "config.json",
458 reason: format!(
459 "{} requires {}={}, found {actual}",
460 definition.name, requirement.field, requirement.value
461 ),
462 });
463 }
464 }
465 Ok(())
466}
467
468pub(crate) struct ConfigValues<'a> {
469 pub(crate) hidden_act: Option<&'a str>,
470 pub(crate) attention_bias: Option<bool>,
471 pub(crate) mlp_bias: Option<bool>,
472 pub(crate) rope_interleaved: Option<bool>,
473 pub(crate) tie_word_embeddings: bool,
474 pub(crate) rms_norm_eps: f64,
475 pub(crate) rope_theta: f64,
476 pub(crate) bos_token_id: Option<u32>,
477 pub(crate) eos_token_id: Option<u32>,
478}
479
480pub(crate) fn validate_config_requirements(
481 definition: &ProfileDefinition,
482 actual: &ConfigValues<'_>,
483) -> Result<(), CandleError> {
484 let expected = &definition.config_requirements;
485 let mismatch = expected
486 .hidden_act
487 .is_some_and(|value| actual.hidden_act != Some(value))
488 || expected
489 .attention_bias
490 .is_some_and(|value| actual.attention_bias != Some(value))
491 || expected
492 .mlp_bias
493 .is_some_and(|value| actual.mlp_bias != Some(value))
494 || expected
495 .rope_interleaved
496 .is_some_and(|value| actual.rope_interleaved != Some(value))
497 || expected
498 .tie_word_embeddings
499 .is_some_and(|value| actual.tie_word_embeddings != value)
500 || expected
501 .rms_norm_eps
502 .is_some_and(|value| (actual.rms_norm_eps - value).abs() > f64::EPSILON)
503 || expected
504 .rope_theta
505 .is_some_and(|value| (actual.rope_theta - value).abs() > f64::EPSILON)
506 || expected
507 .bos_token_id
508 .is_some_and(|value| actual.bos_token_id != Some(value))
509 || expected
510 .eos_token_id
511 .is_some_and(|value| actual.eos_token_id != Some(value));
512 if mismatch {
513 return Err(CandleError::ArtifactMismatch {
514 artifact: "config.json",
515 reason: format!(
516 "{} configuration invariants do not match its validated profile",
517 definition.name
518 ),
519 });
520 }
521 Ok(())
522}
523
524pub(crate) fn validate_tokenizer_requirements(
525 definition: &ProfileDefinition,
526 tokenizer: &tokenizers::Tokenizer,
527 vocab_size: usize,
528 configured_bos: Option<u32>,
529 configured_eos: &[u32],
530) -> Result<(), CandleError> {
531 let actual_vocabulary = tokenizer.get_vocab_size(true);
532 match definition.tokenizer_vocabulary {
533 TokenizerVocabulary::ModelCapacity if actual_vocabulary != vocab_size => {
534 return Err(CandleError::TokenizerVocabularyMismatch {
535 expected: vocab_size,
536 actual: actual_vocabulary,
537 });
538 }
539 TokenizerVocabulary::Exact(expected) if actual_vocabulary != expected => {
540 return Err(CandleError::ArtifactMismatch {
541 artifact: "tokenizer.json",
542 reason: format!(
543 "{} requires {expected} defined tokenizer IDs within model capacity {vocab_size}, found {actual_vocabulary}",
544 definition.name
545 ),
546 });
547 }
548 _ if actual_vocabulary > vocab_size => {
549 return Err(CandleError::TokenizerVocabularyMismatch {
550 expected: vocab_size,
551 actual: actual_vocabulary,
552 });
553 }
554 _ => {}
555 }
556 for &token in definition.tokenizer_tokens {
557 let id = tokenizer
558 .token_to_id(token)
559 .ok_or(CandleError::MissingSpecialToken { token })?;
560 if id as usize >= vocab_size {
561 return Err(CandleError::TokenIdOutOfRange {
562 token: token.to_string(),
563 id,
564 vocab_size,
565 });
566 }
567 if !tokenizer.get_added_vocabulary().is_special_token(token) {
568 return Err(CandleError::SpecialTokenNotMarked { token });
569 }
570 }
571 let start_id =
572 tokenizer
573 .token_to_id(definition.start_token)
574 .ok_or(CandleError::MissingSpecialToken {
575 token: definition.start_token,
576 })?;
577 if configured_bos.is_some_and(|configured| configured != start_id) {
578 return Err(CandleError::ArtifactMismatch {
579 artifact: "bos_token_id",
580 reason: format!(
581 "configured BOS ID does not match '{}' ID {start_id}",
582 definition.start_token
583 ),
584 });
585 }
586 let end_id =
587 tokenizer
588 .token_to_id(definition.end_token)
589 .ok_or(CandleError::MissingSpecialToken {
590 token: definition.end_token,
591 })?;
592 if !configured_eos.is_empty() && !configured_eos.contains(&end_id) {
593 return Err(CandleError::ArtifactMismatch {
594 artifact: "eos_token_id",
595 reason: format!(
596 "configured EOS IDs do not contain '{}' ID {end_id}",
597 definition.end_token
598 ),
599 });
600 }
601 Ok(())
602}
603
604#[cfg(test)]
605#[allow(clippy::panic_in_result_fn)]
606mod tests {
607 use std::collections::HashSet;
608
609 use super::*;
610
611 #[test]
612 fn supported_profiles_centralize_backend_format_and_context() -> Result<(), CandleError> {
613 let llama = definition_for(ConversationProtocol::Llama3, ArtifactFormat::Safetensors)?;
614 assert_eq!(llama.loader, LoaderBackend::LlamaSafetensors);
615 assert_eq!(llama.architecture, ModelArchitecture::Llama);
616 assert_eq!(llama.quantization, None);
617 assert_eq!(llama.context_limit_cap, None);
618 assert_eq!(
619 llama.tokenizer_vocabulary,
620 TokenizerVocabulary::ModelCapacity
621 );
622 assert_eq!(llama.start_token, BEGIN_OF_TEXT);
623 assert_eq!(llama.end_token, END_OF_TURN);
624 assert!(llama.gguf.is_none());
625
626 let smol = definition_for(ConversationProtocol::SmolLm2, ArtifactFormat::Gguf)?;
627 assert_eq!(smol.loader, LoaderBackend::LlamaGguf);
628 assert_eq!(smol.quantization, Some(Quantization::Q4K));
629 let smol = ValidatedProfile::new(smol, 49_152, 8192, HashSet::from([1]))?;
630 assert_eq!(smol.context_limit, 4096);
631 assert_eq!(
632 smol.definition.config_requirements.rope_theta,
633 Some(100_000.0)
634 );
635 assert_eq!(smol.definition.end_token, IM_END);
636 assert!(smol.definition.gguf.as_ref().is_some_and(|requirements| {
637 requirements
638 .allowed_tensor_dtypes
639 .contains(&GgmlDType::Q5_0)
640 }));
641
642 let qwen = definition_for(ConversationProtocol::Qwen3, ArtifactFormat::Gguf)?;
643 assert_eq!(qwen.loader, LoaderBackend::Qwen3Gguf);
644 assert_eq!(qwen.architecture, ModelArchitecture::Qwen3);
645 assert_eq!(qwen.quantization, Some(Quantization::Q4K));
646 let qwen = ValidatedProfile::new(qwen, 151_936, 40_960, HashSet::from([151_645]))?;
647 assert_eq!(qwen.context_limit, 4096);
648 assert_eq!(
649 qwen.definition.tokenizer_vocabulary,
650 TokenizerVocabulary::Exact(151_669)
651 );
652 assert_eq!(
653 qwen.definition.config_requirements.eos_token_id,
654 Some(151_645)
655 );
656 assert!(
657 qwen.definition
658 .gguf
659 .as_ref()
660 .is_some_and(|requirements| requirements.tensors_per_layer == Some(11)
661 && requirements.chat_template_markers.contains(&"<tool_call>"))
662 );
663 Ok(())
664 }
665
666 #[test]
667 fn profiles_reject_unsupported_artifact_combinations_and_empty_stops() {
668 assert!(matches!(
669 definition_for(ConversationProtocol::SmolLm2, ArtifactFormat::Safetensors),
670 Err(CandleError::UnsupportedModelFamily(_))
671 ));
672 assert!(matches!(
673 definition_for(ConversationProtocol::Qwen3, ArtifactFormat::Safetensors),
674 Err(CandleError::UnsupportedModelFamily(_))
675 ));
676 assert!(matches!(
677 definition_for(ConversationProtocol::Llama3, ArtifactFormat::Gguf),
678 Err(CandleError::UnsupportedModelFamily(_))
679 ));
680 assert!(matches!(
681 ValidatedProfile::new(&LLAMA3_PROFILE, 8, 16, HashSet::new()),
682 Err(CandleError::MissingStopToken)
683 ));
684 }
685}