1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::path::{Component, Path};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8
9pub mod materialization;
10pub mod stage_admission;
11
12pub const PACKAGE_SCHEMA_VERSION: u32 = 2;
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct PackageManifest {
16 pub schema_version: u32,
17 pub package_id: String,
18 pub model_id: String,
19 pub source_model: SourceModel,
20 pub format: String,
21 pub layer_count: u32,
22 pub model_metadata: BTreeMap<String, Value>,
23 pub artifact_catalog: ArtifactCatalog,
24 pub tensor_catalog: TensorCatalog,
25 pub sidecars: Vec<Sidecar>,
26 pub publisher_metadata: Vec<PublisherMetadata>,
27 pub publisher_defaults: Option<PublisherModelDefaults>,
28 pub generation: Option<Generation>,
29 pub native_abi_version: String,
30 pub generator_version: String,
31 pub created_at_unix_secs: u64,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36struct PackageRoot {
37 schema_version: u32,
38 package_id: String,
39 model_id: String,
40 source_model: SourceModel,
41 format: String,
42 layer_count: u32,
43 artifact_catalog: ArtifactCatalog,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 sidecars: Vec<Sidecar>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 publisher_metadata: Vec<PublisherMetadata>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 publisher_defaults: Option<PublisherModelDefaults>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 generation: Option<Generation>,
52 native_abi_version: String,
53 generator_version: String,
54 created_at_unix_secs: u64,
55}
56
57impl From<&PackageManifest> for PackageRoot {
58 fn from(manifest: &PackageManifest) -> Self {
59 Self {
60 schema_version: manifest.schema_version,
61 package_id: manifest.package_id.clone(),
62 model_id: manifest.model_id.clone(),
63 source_model: manifest.source_model.clone(),
64 format: manifest.format.clone(),
65 layer_count: manifest.layer_count,
66 artifact_catalog: manifest.artifact_catalog.clone(),
67 sidecars: manifest.sidecars.clone(),
68 publisher_metadata: manifest.publisher_metadata.clone(),
69 publisher_defaults: manifest.publisher_defaults.clone(),
70 generation: manifest.generation.clone(),
71 native_abi_version: manifest.native_abi_version.clone(),
72 generator_version: manifest.generator_version.clone(),
73 created_at_unix_secs: manifest.created_at_unix_secs,
74 }
75 }
76}
77
78impl PackageRoot {
79 fn into_unresolved(self) -> PackageManifest {
80 PackageManifest {
81 schema_version: self.schema_version,
82 package_id: self.package_id,
83 model_id: self.model_id,
84 source_model: self.source_model,
85 format: self.format,
86 layer_count: self.layer_count,
87 model_metadata: BTreeMap::new(),
88 artifact_catalog: self.artifact_catalog,
89 tensor_catalog: TensorCatalog {
90 entries: Vec::new(),
91 },
92 sidecars: self.sidecars,
93 publisher_metadata: self.publisher_metadata,
94 publisher_defaults: self.publisher_defaults,
95 generation: self.generation,
96 native_abi_version: self.native_abi_version,
97 generator_version: self.generator_version,
98 created_at_unix_secs: self.created_at_unix_secs,
99 }
100 }
101}
102
103impl Serialize for PackageManifest {
104 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
105 where
106 S: serde::Serializer,
107 {
108 PackageRoot::from(self).serialize(serializer)
109 }
110}
111
112impl<'de> Deserialize<'de> for PackageManifest {
113 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114 where
115 D: serde::Deserializer<'de>,
116 {
117 PackageRoot::deserialize(deserializer).map(PackageRoot::into_unresolved)
118 }
119}
120
121impl PackageManifest {
122 pub fn validate(&self) -> Result<(), ValidationErrors> {
126 self.validate_with_inventory(true)
127 }
128
129 pub fn validate_root(&self) -> Result<(), ValidationErrors> {
131 self.validate_with_inventory(false)
132 }
133
134 fn validate_with_inventory(&self, require_inventory: bool) -> Result<(), ValidationErrors> {
135 let mut issues = Vec::new();
136 validate_manifest_fields(self, require_inventory, &mut issues);
137
138 let artifacts = collect_artifacts(&self.artifact_catalog.entries, &mut issues);
139 validate_metadata_artifact_binding(self, &artifacts, &mut issues);
140 validate_sidecars(&self.sidecars, &artifacts, &mut issues);
141 validate_publisher_metadata(self, &artifacts, &mut issues);
142 validate_publisher_defaults(self, &artifacts, &mut issues);
143 if let Some(generation) = &self.generation {
144 validate_generation(generation, self.layer_count, &mut issues);
145 }
146 if require_inventory {
147 let tensors = collect_tensors(self, &mut issues);
148 validate_owned_tensor_storage(&self.tensor_catalog.entries, &artifacts, &mut issues);
149 validate_aliases(&self.tensor_catalog.entries, &tensors, &mut issues);
150 }
151
152 if issues.is_empty() {
153 Ok(())
154 } else {
155 Err(ValidationErrors { issues })
156 }
157 }
158
159 pub fn computed_package_id(&self) -> Result<String, serde_json::Error> {
160 let mut normalized = PackageRoot::from(self);
161 normalized.package_id.clear();
162 normalized.created_at_unix_secs = 0;
163 normalized
164 .source_model
165 .files
166 .sort_by(|left, right| left.path.cmp(&right.path));
167 normalized
168 .artifact_catalog
169 .entries
170 .sort_by(|left, right| left.id.cmp(&right.id));
171 normalized.sidecars.sort();
172 normalized.publisher_metadata.sort();
173 let digest = Sha256::digest(serde_json::to_vec(&normalized)?);
174 let hex = digest
175 .iter()
176 .map(|byte| format!("{byte:02x}"))
177 .collect::<String>();
178 Ok(format!("sha256:{hex}"))
179 }
180
181 pub fn resolve_inventory(
183 mut self,
184 model_metadata: BTreeMap<String, Value>,
185 tensor_catalog: TensorCatalog,
186 ) -> Self {
187 self.model_metadata = model_metadata;
188 self.tensor_catalog = tensor_catalog;
189 self
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct SourceModel {
196 pub sha256: String,
198 pub metadata_artifact_id: String,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub repo: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub revision: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub primary_file: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub canonical_ref: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub distribution_id: Option<String>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub files: Vec<SourceFile>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct SourceFile {
218 pub path: String,
219 pub byte_size: u64,
220 pub sha256: String,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct ArtifactCatalog {
226 pub entries: Vec<Artifact>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct Artifact {
232 pub id: String,
233 pub path: String,
234 pub byte_size: u64,
235 pub sha256: String,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct TensorCatalog {
241 pub entries: Vec<Tensor>,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct Tensor {
247 pub id: String,
248 pub name: String,
249 pub ggml_type: u32,
250 pub dimensions: Vec<u64>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub layer_ordinal: Option<u32>,
253 pub storage: TensorStorage,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
258pub enum TensorStorage {
259 Owned {
260 artifact_id: String,
261 data_offset: u64,
262 stored_length: u64,
263 alignment: u64,
264 integrity: TensorIntegrity,
265 },
266 Alias {
267 target_tensor_id: String,
268 },
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
273pub enum TensorIntegrity {
274 ArtifactSha256,
275 TensorSha256 { sha256: String },
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct Sidecar {
281 pub kind: SidecarKind,
282 pub artifact_id: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub name: Option<String>,
285}
286
287impl Ord for Sidecar {
288 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
289 (&self.kind, &self.name, &self.artifact_id).cmp(&(
290 &other.kind,
291 &other.name,
292 &other.artifact_id,
293 ))
294 }
295}
296
297impl PartialOrd for Sidecar {
298 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
299 Some(self.cmp(other))
300 }
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
304#[serde(rename_all = "snake_case")]
305pub enum SidecarKind {
306 Mmproj,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct PublisherMetadata {
316 pub role: PublisherMetadataRole,
317 pub artifact_id: String,
318 pub source_repo: String,
319 pub source_revision: String,
320 pub source_path: String,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325pub enum PublisherMetadataRole {
326 ModelConfig,
327 GenerationConfig,
328 TokenizerConfig,
329 ChatTemplate,
330 HfQuantConfig,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct PublisherModelDefaults {
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub compute_dtype: Option<PublisherDtypeDeclaration>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub kv_cache_dtype: Option<PublisherDtypeDeclaration>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct PublisherDtypeDeclaration {
345 pub dtype: PublisherDtype,
346 pub artifact_id: String,
347 pub json_path: String,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352pub enum PublisherDtype {
353 F16,
354 Bf16,
355 F32,
356 Fp8,
357 Fp8E4m3,
358 Fp8E5m2,
359 Q8_0,
360 Q4_0,
361}
362
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct Generation {
372 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub request_defaults: Option<GenerationRequestDefaults>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub speculative_decoding: Option<SpeculativeDecoding>,
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
382#[serde(deny_unknown_fields)]
383pub struct GenerationRequestDefaults {
384 pub selection: GenerationProfileSelection,
385 pub profiles: BTreeMap<String, GenerationProfile>,
386}
387
388impl GenerationRequestDefaults {
389 pub fn validate(&self) -> Result<(), ValidationErrors> {
391 let mut issues = Vec::new();
392 validate_generation_request_defaults(self, &mut issues);
393 if issues.is_empty() {
394 Ok(())
395 } else {
396 Err(ValidationErrors { issues })
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(deny_unknown_fields)]
405pub struct GenerationProfileSelection {
406 pub default: String,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub reasoning_enabled: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub reasoning_disabled: Option<String>,
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct GenerationProfile {
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub max_tokens: Option<u32>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub stop: Option<Vec<String>>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub temperature: Option<f64>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub top_p: Option<f64>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub top_k: Option<i32>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub min_p: Option<f64>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub typical_p: Option<f64>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub top_nsigma: Option<f64>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub presence_penalty: Option<f64>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub frequency_penalty: Option<f64>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub seed: Option<u64>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub logit_bias: Option<BTreeMap<String, f64>>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub repeat_penalty: Option<f64>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub repeat_last_n: Option<i32>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub dynatemp_range: Option<f64>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub dynatemp_exponent: Option<f64>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub dry: Option<GenerationDryDefaults>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub xtc: Option<GenerationXtcDefaults>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub mirostat_mode: Option<i32>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub mirostat_entropy: Option<f64>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub mirostat_learning_rate: Option<f64>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub samplers: Option<Vec<String>>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub sampler_sequence: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub ignore_eos: Option<bool>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub reasoning: Option<GenerationReasoningDefaults>,
467 pub provenance: GenerationDefaultsProvenance,
468}
469
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct GenerationDryDefaults {
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub multiplier: Option<f64>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub base: Option<f64>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub allowed_length: Option<i32>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub penalty_last_n: Option<i32>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub sequence_breakers: Option<Vec<String>>,
483}
484
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486#[serde(deny_unknown_fields)]
487pub struct GenerationXtcDefaults {
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub probability: Option<f64>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub threshold: Option<f64>,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(deny_unknown_fields)]
496pub struct GenerationReasoningDefaults {
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub enabled: Option<GenerationReasoningEnabled>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub format: Option<GenerationReasoningFormat>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub budget: Option<GenerationReasoningBudget>,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507pub enum GenerationReasoningEnabled {
508 Auto,
509 Off,
510 On,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "kebab-case")]
515pub enum GenerationReasoningFormat {
516 Auto,
517 None,
518 Deepseek,
519 DeepseekLegacy,
520 Hidden,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(untagged)]
525pub enum GenerationReasoningBudget {
526 Tokens(u32),
527 Level(GenerationReasoningBudgetLevel),
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum GenerationReasoningBudgetLevel {
533 Auto,
534 Low,
535 Medium,
536 High,
537 Unrestricted,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(deny_unknown_fields)]
542pub struct GenerationDefaultsProvenance {
543 pub source_repo: String,
544 pub revision: String,
545 pub file: String,
546 pub section: String,
547 pub url: String,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct SpeculativeDecoding {
553 pub default: String,
554 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
555 pub proposers: BTreeMap<String, ProposerSpec>,
556 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
557 pub strategies: BTreeMap<String, StrategySpec>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
564pub struct ProposerSpec {
565 #[serde(flatten)]
566 pub kind: ProposerKind,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct StrategySpec {
572 #[serde(flatten)]
573 pub kind: StrategyKind,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
578pub enum ProposerKind {
579 #[serde(rename = "native-mtp")]
580 NativeMtp {
581 prediction_depth: u32,
582 layer_indices: Vec<u32>,
583 },
584 NgramCache {
585 ngram_min: u32,
586 ngram_max: u32,
587 max_proposal_tokens: u32,
588 history_scope: String,
589 },
590 NgramSuffix {
591 ngram_min: u32,
592 ngram_max: u32,
593 max_proposal_tokens: u32,
594 history_scope: String,
595 },
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599#[serde(deny_unknown_fields)]
600pub struct WindowPolicy {
601 pub default: String,
602 pub initial_window: u32,
603 pub min_window: u32,
604 pub max_window: u32,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub pipeline_depth: Option<u32>,
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
611pub enum StrategyKind {
612 #[serde(rename = "native-mtp")]
615 NativeMtp {
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 proposer: Option<String>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 prediction_depth: Option<u32>,
620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
621 layer_indices: Vec<u32>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 window_policy: Option<WindowPolicy>,
624 },
625 NgramCache {
626 proposer: String,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
628 window_policy: Option<WindowPolicy>,
629 },
630 NgramSuffix {
631 proposer: String,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
633 window_policy: Option<WindowPolicy>,
634 },
635 Composite {
636 primary: String,
637 extender: String,
638 extension_policy: ExtensionPolicy,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 window_policy: Option<WindowPolicy>,
641 },
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct ExtensionPolicy {
647 pub max_tokens: u32,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub initial_tokens: Option<u32>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub tail_backoff_proposals: Option<u32>,
652}
653
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
655pub struct ValidationIssue {
656 pub code: ValidationCode,
657 pub path: String,
658 pub message: String,
659}
660
661#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
662#[serde(rename_all = "snake_case")]
663pub enum ValidationCode {
664 UnsupportedSchemaVersion,
665 MissingValue,
666 InvalidDigest,
667 InvalidIdentifier,
668 InvalidPath,
669 UnknownSourceFile,
670 InvalidMetadataArtifact,
671 SourceIdentityMismatch,
672 DuplicateArtifact,
673 DuplicateArtifactPath,
674 DuplicateSidecar,
675 DuplicateTensor,
676 LayerOutOfBounds,
677 InvalidDimension,
678 UnknownArtifact,
679 InvalidAlignment,
680 MisalignedOffset,
681 StorageOutOfBounds,
682 InvalidStoredLength,
683 OverlappingStorage,
684 UnknownAliasTarget,
685 AliasTargetIsAlias,
686 AliasMetadataMismatch,
687 PackageIdentityMismatch,
688 UnknownStrategy,
689 UnknownProposer,
690 InvalidGenerationValue,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub struct ValidationErrors {
695 issues: Vec<ValidationIssue>,
696}
697
698impl ValidationErrors {
699 pub fn issues(&self) -> &[ValidationIssue] {
700 &self.issues
701 }
702}
703
704impl fmt::Display for ValidationErrors {
705 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
706 write!(formatter, "invalid package manifest")?;
707 for issue in &self.issues {
708 write!(formatter, "; {}: {}", issue.path, issue.message)?;
709 }
710 Ok(())
711 }
712}
713
714impl std::error::Error for ValidationErrors {}
715
716fn validate_manifest_fields(
717 manifest: &PackageManifest,
718 require_inventory: bool,
719 issues: &mut Vec<ValidationIssue>,
720) {
721 if manifest.schema_version != PACKAGE_SCHEMA_VERSION {
722 push_issue(
723 issues,
724 ValidationCode::UnsupportedSchemaVersion,
725 "schema_version",
726 format!(
727 "expected schema version {PACKAGE_SCHEMA_VERSION}, got {}",
728 manifest.schema_version
729 ),
730 );
731 }
732 validate_nonempty("model_id", &manifest.model_id, issues);
733 validate_nonempty("format", &manifest.format, issues);
734 validate_nonempty("native_abi_version", &manifest.native_abi_version, issues);
735 validate_nonempty("generator_version", &manifest.generator_version, issues);
736 if manifest.layer_count == 0 {
737 push_issue(
738 issues,
739 ValidationCode::MissingValue,
740 "layer_count",
741 "layer count must be greater than zero",
742 );
743 }
744 validate_prefixed_sha256("package_id", &manifest.package_id, issues);
745 validate_sha256("source_model.sha256", &manifest.source_model.sha256, issues);
746 validate_nonempty(
747 "source_model.metadata_artifact_id",
748 &manifest.source_model.metadata_artifact_id,
749 issues,
750 );
751 if require_inventory && manifest.model_metadata.is_empty() {
752 push_issue(
753 issues,
754 ValidationCode::MissingValue,
755 "model_metadata",
756 "model metadata must not be empty",
757 );
758 }
759 if require_inventory && !manifest.model_metadata.contains_key("general.architecture") {
760 push_issue(
761 issues,
762 ValidationCode::MissingValue,
763 "model_metadata.general.architecture",
764 "model metadata must contain general.architecture",
765 );
766 }
767 if manifest.source_model.files.is_empty() {
768 push_issue(
769 issues,
770 ValidationCode::MissingValue,
771 "source_model.files",
772 "source file inventory must not be empty",
773 );
774 }
775 if manifest.artifact_catalog.entries.is_empty() {
776 push_issue(
777 issues,
778 ValidationCode::MissingValue,
779 "artifact_catalog.entries",
780 "artifact catalog must not be empty",
781 );
782 }
783 if require_inventory && manifest.tensor_catalog.entries.is_empty() {
784 push_issue(
785 issues,
786 ValidationCode::MissingValue,
787 "tensor_catalog.entries",
788 "tensor catalog must not be empty",
789 );
790 }
791 match manifest.computed_package_id() {
792 Ok(expected) if manifest.package_id != expected => push_issue(
793 issues,
794 ValidationCode::PackageIdentityMismatch,
795 "package_id",
796 format!(
797 "package identity {:?} does not match canonical identity {:?}",
798 manifest.package_id, expected
799 ),
800 ),
801 Ok(_) => {}
802 Err(error) => push_issue(
803 issues,
804 ValidationCode::PackageIdentityMismatch,
805 "package_id",
806 format!("cannot compute canonical package identity: {error}"),
807 ),
808 }
809
810 let mut source_paths = BTreeSet::new();
811 for (index, file) in manifest.source_model.files.iter().enumerate() {
812 validate_relative_path(
813 &format!("source_model.files[{index}].path"),
814 &file.path,
815 issues,
816 );
817 validate_sha256(
818 &format!("source_model.files[{index}].sha256"),
819 &file.sha256,
820 issues,
821 );
822 if !source_paths.insert(file.path.as_str()) {
823 push_issue(
824 issues,
825 ValidationCode::InvalidPath,
826 format!("source_model.files[{index}].path"),
827 format!("source file path {:?} appears more than once", file.path),
828 );
829 }
830 }
831 match &manifest.source_model.primary_file {
832 Some(primary_file) => {
833 validate_relative_path("source_model.primary_file", primary_file, issues);
834 if !source_paths.contains(primary_file.as_str()) {
835 push_issue(
836 issues,
837 ValidationCode::UnknownSourceFile,
838 "source_model.primary_file",
839 format!(
840 "primary file {:?} is absent from source files",
841 primary_file
842 ),
843 );
844 }
845 }
846 None => {
847 push_issue(
848 issues,
849 ValidationCode::MissingValue,
850 "source_model.primary_file",
851 "primary source file is required",
852 );
853 }
854 }
855}
856
857fn validate_metadata_artifact_binding(
858 manifest: &PackageManifest,
859 artifacts: &BTreeMap<&str, &Artifact>,
860 issues: &mut Vec<ValidationIssue>,
861) {
862 let artifact_id = manifest.source_model.metadata_artifact_id.as_str();
863 if artifact_id.trim().is_empty() {
864 return;
865 }
866 let Some(artifact) = artifacts.get(artifact_id) else {
867 push_issue(
868 issues,
869 ValidationCode::UnknownArtifact,
870 "source_model.metadata_artifact_id",
871 format!("metadata artifact {artifact_id:?} is absent from artifact catalog"),
872 );
873 return;
874 };
875 if Path::new(&artifact.path)
876 .extension()
877 .and_then(|value| value.to_str())
878 != Some("gguf")
879 {
880 push_issue(
881 issues,
882 ValidationCode::InvalidMetadataArtifact,
883 "source_model.metadata_artifact_id",
884 format!("metadata artifact {:?} is not a GGUF artifact", artifact.id),
885 );
886 }
887 if manifest
888 .sidecars
889 .iter()
890 .any(|sidecar| sidecar.artifact_id == artifact.id)
891 {
892 push_issue(
893 issues,
894 ValidationCode::InvalidMetadataArtifact,
895 "source_model.metadata_artifact_id",
896 format!("metadata artifact {:?} has a sidecar role", artifact.id),
897 );
898 }
899 let Some(primary_path) = manifest.source_model.primary_file.as_deref() else {
900 return;
901 };
902 let Some(primary_file) = manifest
903 .source_model
904 .files
905 .iter()
906 .find(|file| file.path == primary_path)
907 else {
908 return;
909 };
910 if manifest.source_model.sha256 != primary_file.sha256 {
911 push_issue(
912 issues,
913 ValidationCode::SourceIdentityMismatch,
914 "source_model.sha256",
915 "source model digest does not match the primary source file",
916 );
917 }
918}
919
920fn collect_artifacts<'a>(
921 artifacts: &'a [Artifact],
922 issues: &mut Vec<ValidationIssue>,
923) -> BTreeMap<&'a str, &'a Artifact> {
924 let mut by_id = BTreeMap::new();
925 let mut paths = BTreeSet::new();
926 for (index, artifact) in artifacts.iter().enumerate() {
927 let prefix = format!("artifacts[{index}]");
928 validate_identifier(&format!("{prefix}.id"), &artifact.id, issues);
929 validate_relative_path(&format!("{prefix}.path"), &artifact.path, issues);
930 validate_sha256(&format!("{prefix}.sha256"), &artifact.sha256, issues);
931 if by_id.insert(artifact.id.as_str(), artifact).is_some() {
932 push_issue(
933 issues,
934 ValidationCode::DuplicateArtifact,
935 format!("{prefix}.id"),
936 format!("artifact id {:?} appears more than once", artifact.id),
937 );
938 }
939 if !paths.insert(artifact.path.as_str()) {
940 push_issue(
941 issues,
942 ValidationCode::DuplicateArtifactPath,
943 format!("{prefix}.path"),
944 format!("artifact path {:?} appears more than once", artifact.path),
945 );
946 }
947 }
948 by_id
949}
950
951fn collect_tensors<'a>(
952 manifest: &'a PackageManifest,
953 issues: &mut Vec<ValidationIssue>,
954) -> BTreeMap<&'a str, &'a Tensor> {
955 let tensors = &manifest.tensor_catalog.entries;
956 let mut by_id = BTreeMap::new();
957 let mut names = BTreeSet::new();
958 for (index, tensor) in tensors.iter().enumerate() {
959 let prefix = format!("tensors[{index}]");
960 validate_nonempty(&format!("{prefix}.id"), &tensor.id, issues);
961 validate_nonempty(&format!("{prefix}.name"), &tensor.name, issues);
962 if tensor.dimensions.is_empty() || tensor.dimensions.contains(&0) {
963 push_issue(
964 issues,
965 ValidationCode::InvalidDimension,
966 format!("{prefix}.dimensions"),
967 "tensor dimensions must be non-empty and non-zero",
968 );
969 }
970 if let Some(layer_ordinal) = tensor.layer_ordinal
971 && layer_ordinal >= manifest.layer_count
972 {
973 push_issue(
974 issues,
975 ValidationCode::LayerOutOfBounds,
976 format!("{prefix}.layer_ordinal"),
977 format!(
978 "layer ordinal {layer_ordinal} is outside model layer count {}",
979 manifest.layer_count
980 ),
981 );
982 }
983 if by_id.insert(tensor.id.as_str(), tensor).is_some() {
984 push_issue(
985 issues,
986 ValidationCode::DuplicateTensor,
987 format!("{prefix}.id"),
988 format!("tensor id {:?} appears more than once", tensor.id),
989 );
990 }
991 if !names.insert(tensor.name.as_str()) {
992 push_issue(
993 issues,
994 ValidationCode::DuplicateTensor,
995 format!("{prefix}.name"),
996 format!("tensor name {:?} appears more than once", tensor.name),
997 );
998 }
999 }
1000 by_id
1001}
1002
1003fn validate_owned_tensor_storage(
1004 tensors: &[Tensor],
1005 artifacts: &BTreeMap<&str, &Artifact>,
1006 issues: &mut Vec<ValidationIssue>,
1007) {
1008 let mut ranges: BTreeMap<&str, Vec<(u64, u64, usize)>> = BTreeMap::new();
1009 for (index, tensor) in tensors.iter().enumerate() {
1010 let TensorStorage::Owned {
1011 artifact_id,
1012 data_offset,
1013 stored_length,
1014 alignment,
1015 integrity,
1016 } = &tensor.storage
1017 else {
1018 continue;
1019 };
1020 let prefix = format!("tensors[{index}].storage");
1021 let Some(artifact) = artifacts.get(artifact_id.as_str()) else {
1022 push_issue(
1023 issues,
1024 ValidationCode::UnknownArtifact,
1025 format!("{prefix}.artifact_id"),
1026 format!("artifact {:?} does not exist", artifact_id),
1027 );
1028 continue;
1029 };
1030 if !alignment.is_power_of_two() {
1031 push_issue(
1032 issues,
1033 ValidationCode::InvalidAlignment,
1034 format!("{prefix}.alignment"),
1035 format!("alignment {alignment} is not a non-zero power of two"),
1036 );
1037 } else if data_offset % alignment != 0 {
1038 push_issue(
1039 issues,
1040 ValidationCode::MisalignedOffset,
1041 format!("{prefix}.data_offset"),
1042 format!("offset {data_offset} is not aligned to {alignment} bytes"),
1043 );
1044 }
1045 if *stored_length == 0 {
1046 push_issue(
1047 issues,
1048 ValidationCode::InvalidStoredLength,
1049 format!("{prefix}.stored_length"),
1050 "owned tensor storage must not be empty",
1051 );
1052 }
1053 match data_offset.checked_add(*stored_length) {
1054 Some(end) if end <= artifact.byte_size => {
1055 ranges
1056 .entry(artifact_id)
1057 .or_default()
1058 .push((*data_offset, end, index));
1059 }
1060 _ => push_issue(
1061 issues,
1062 ValidationCode::StorageOutOfBounds,
1063 prefix.clone(),
1064 format!(
1065 "range {data_offset}+{stored_length} exceeds artifact size {}",
1066 artifact.byte_size
1067 ),
1068 ),
1069 }
1070 if let TensorIntegrity::TensorSha256 { sha256 } = integrity {
1071 validate_sha256(&format!("{prefix}.integrity.sha256"), sha256, issues);
1072 }
1073 }
1074
1075 for (artifact_id, artifact_ranges) in &mut ranges {
1076 artifact_ranges.sort_unstable_by_key(|range| (range.0, range.1, range.2));
1077 for pair in artifact_ranges.windows(2) {
1078 let previous = pair[0];
1079 let current = pair[1];
1080 if current.0 < previous.1 {
1081 push_issue(
1082 issues,
1083 ValidationCode::OverlappingStorage,
1084 format!("tensors[{}].storage", current.2),
1085 format!(
1086 "storage overlaps tensors[{}] in artifact {:?}",
1087 previous.2, artifact_id
1088 ),
1089 );
1090 }
1091 }
1092 }
1093}
1094
1095fn validate_aliases(
1096 tensors: &[Tensor],
1097 by_id: &BTreeMap<&str, &Tensor>,
1098 issues: &mut Vec<ValidationIssue>,
1099) {
1100 for (index, tensor) in tensors.iter().enumerate() {
1101 let TensorStorage::Alias { target_tensor_id } = &tensor.storage else {
1102 continue;
1103 };
1104 let path = format!("tensors[{index}].storage.target_tensor_id");
1105 let Some(target) = by_id.get(target_tensor_id.as_str()) else {
1106 push_issue(
1107 issues,
1108 ValidationCode::UnknownAliasTarget,
1109 path,
1110 format!("tensor {:?} does not exist", target_tensor_id),
1111 );
1112 continue;
1113 };
1114 if matches!(target.storage, TensorStorage::Alias { .. }) {
1115 push_issue(
1116 issues,
1117 ValidationCode::AliasTargetIsAlias,
1118 path,
1119 format!("alias target {:?} is not owned storage", target_tensor_id),
1120 );
1121 }
1122 if tensor.ggml_type != target.ggml_type || tensor.dimensions != target.dimensions {
1123 push_issue(
1124 issues,
1125 ValidationCode::AliasMetadataMismatch,
1126 format!("tensors[{index}].storage"),
1127 format!(
1128 "alias metadata does not match target tensor {:?}",
1129 target_tensor_id
1130 ),
1131 );
1132 }
1133 }
1134}
1135
1136fn validate_sidecars(
1137 sidecars: &[Sidecar],
1138 artifacts: &BTreeMap<&str, &Artifact>,
1139 issues: &mut Vec<ValidationIssue>,
1140) {
1141 let mut seen = BTreeSet::new();
1142 for (index, sidecar) in sidecars.iter().enumerate() {
1143 let prefix = format!("sidecars[{index}]");
1144 if !seen.insert((sidecar.kind, sidecar.name.as_deref())) {
1145 push_issue(
1146 issues,
1147 ValidationCode::DuplicateSidecar,
1148 prefix.clone(),
1149 format!(
1150 "sidecar semantic identity ({:?}, {:?}) appears more than once",
1151 sidecar.kind, sidecar.name
1152 ),
1153 );
1154 }
1155 if !artifacts.contains_key(sidecar.artifact_id.as_str()) {
1156 push_issue(
1157 issues,
1158 ValidationCode::UnknownArtifact,
1159 format!("{prefix}.artifact_id"),
1160 format!("artifact {:?} does not exist", sidecar.artifact_id),
1161 );
1162 }
1163 if let Some(name) = &sidecar.name {
1164 validate_nonempty(&format!("{prefix}.name"), name, issues);
1165 }
1166 }
1167}
1168
1169fn validate_publisher_metadata(
1170 manifest: &PackageManifest,
1171 artifacts: &BTreeMap<&str, &Artifact>,
1172 issues: &mut Vec<ValidationIssue>,
1173) {
1174 let mut seen = BTreeSet::new();
1175 for (index, metadata) in manifest.publisher_metadata.iter().enumerate() {
1176 let prefix = format!("publisher_metadata[{index}]");
1177 if !seen.insert((metadata.role, metadata.source_path.as_str())) {
1178 push_issue(
1179 issues,
1180 ValidationCode::DuplicateSidecar,
1181 prefix.clone(),
1182 format!(
1183 "publisher metadata semantic identity ({:?}, {:?}) appears more than once",
1184 metadata.role, metadata.source_path
1185 ),
1186 );
1187 }
1188 if !artifacts.contains_key(metadata.artifact_id.as_str()) {
1189 push_issue(
1190 issues,
1191 ValidationCode::UnknownArtifact,
1192 format!("{prefix}.artifact_id"),
1193 format!("artifact {:?} does not exist", metadata.artifact_id),
1194 );
1195 }
1196 validate_nonempty(
1197 &format!("{prefix}.source_repo"),
1198 &metadata.source_repo,
1199 issues,
1200 );
1201 validate_nonempty(
1202 &format!("{prefix}.source_revision"),
1203 &metadata.source_revision,
1204 issues,
1205 );
1206 if metadata.source_revision.len() != 40
1207 || !metadata
1208 .source_revision
1209 .bytes()
1210 .all(|byte| byte.is_ascii_hexdigit())
1211 {
1212 push_issue(
1213 issues,
1214 ValidationCode::SourceIdentityMismatch,
1215 format!("{prefix}.source_revision"),
1216 "publisher metadata revision must be an immutable 40-character commit",
1217 );
1218 }
1219 validate_relative_path(
1220 &format!("{prefix}.source_path"),
1221 &metadata.source_path,
1222 issues,
1223 );
1224 if manifest.source_model.repo.as_deref() != Some(metadata.source_repo.as_str()) {
1225 push_issue(
1226 issues,
1227 ValidationCode::SourceIdentityMismatch,
1228 format!("{prefix}.source_repo"),
1229 "publisher metadata repository differs from source_model.repo",
1230 );
1231 }
1232 if manifest.source_model.revision.as_deref() != Some(metadata.source_revision.as_str()) {
1233 push_issue(
1234 issues,
1235 ValidationCode::SourceIdentityMismatch,
1236 format!("{prefix}.source_revision"),
1237 "publisher metadata revision differs from source_model.revision",
1238 );
1239 }
1240 }
1241}
1242
1243fn validate_publisher_defaults(
1244 manifest: &PackageManifest,
1245 artifacts: &BTreeMap<&str, &Artifact>,
1246 issues: &mut Vec<ValidationIssue>,
1247) {
1248 let Some(defaults) = &manifest.publisher_defaults else {
1249 return;
1250 };
1251 for (name, declaration) in [
1252 ("compute_dtype", defaults.compute_dtype.as_ref()),
1253 ("kv_cache_dtype", defaults.kv_cache_dtype.as_ref()),
1254 ] {
1255 let Some(declaration) = declaration else {
1256 continue;
1257 };
1258 let path = format!("publisher_defaults.{name}");
1259 if !artifacts.contains_key(declaration.artifact_id.as_str()) {
1260 push_issue(
1261 issues,
1262 ValidationCode::UnknownArtifact,
1263 format!("{path}.artifact_id"),
1264 format!("artifact {:?} does not exist", declaration.artifact_id),
1265 );
1266 }
1267 let metadata = manifest
1268 .publisher_metadata
1269 .iter()
1270 .find(|metadata| metadata.artifact_id == declaration.artifact_id);
1271 if metadata.is_none() {
1272 push_issue(
1273 issues,
1274 ValidationCode::SourceIdentityMismatch,
1275 format!("{path}.artifact_id"),
1276 "typed publisher default is not bound to publisher_metadata",
1277 );
1278 }
1279 if let Some(metadata) = metadata {
1280 let role_is_valid = match name {
1281 "compute_dtype" => metadata.role == PublisherMetadataRole::ModelConfig,
1282 "kv_cache_dtype" => matches!(
1283 metadata.role,
1284 PublisherMetadataRole::ModelConfig | PublisherMetadataRole::HfQuantConfig
1285 ),
1286 _ => false,
1287 };
1288 if !role_is_valid {
1289 push_issue(
1290 issues,
1291 ValidationCode::SourceIdentityMismatch,
1292 format!("{path}.artifact_id"),
1293 format!(
1294 "publisher default {name} cannot be sourced from {:?}",
1295 metadata.role
1296 ),
1297 );
1298 }
1299 }
1300 validate_nonempty(&format!("{path}.json_path"), &declaration.json_path, issues);
1301 }
1302}
1303
1304fn validate_nonempty(path: &str, value: &str, issues: &mut Vec<ValidationIssue>) {
1305 if value.trim().is_empty() {
1306 push_issue(
1307 issues,
1308 ValidationCode::MissingValue,
1309 path,
1310 "value must not be empty",
1311 );
1312 }
1313}
1314
1315fn validate_identifier(path: &str, value: &str, issues: &mut Vec<ValidationIssue>) {
1316 if value.is_empty()
1317 || !value
1318 .bytes()
1319 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1320 {
1321 push_issue(
1322 issues,
1323 ValidationCode::InvalidIdentifier,
1324 path,
1325 format!("identifier {:?} contains unsupported characters", value),
1326 );
1327 }
1328}
1329
1330fn validate_relative_path(path: &str, value: &str, issues: &mut Vec<ValidationIssue>) {
1331 let candidate = Path::new(value);
1332 if value.is_empty()
1333 || value.contains('\\')
1334 || value.contains("//")
1335 || value.ends_with('/')
1336 || candidate.is_absolute()
1337 || candidate
1338 .components()
1339 .any(|component| !matches!(component, Component::Normal(_)))
1340 {
1341 push_issue(
1342 issues,
1343 ValidationCode::InvalidPath,
1344 path,
1345 format!(
1346 "path {:?} must be a normalized relative package path",
1347 value
1348 ),
1349 );
1350 }
1351}
1352
1353fn validate_prefixed_sha256(path: &str, value: &str, issues: &mut Vec<ValidationIssue>) {
1354 let Some(digest) = value.strip_prefix("sha256:") else {
1355 push_issue(
1356 issues,
1357 ValidationCode::InvalidDigest,
1358 path,
1359 "digest must use the sha256:<hex> form",
1360 );
1361 return;
1362 };
1363 validate_sha256(path, digest, issues);
1364}
1365
1366fn validate_sha256(path: &str, value: &str, issues: &mut Vec<ValidationIssue>) {
1367 if value.len() != 64
1368 || !value
1369 .bytes()
1370 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1371 {
1372 push_issue(
1373 issues,
1374 ValidationCode::InvalidDigest,
1375 path,
1376 "digest must contain 64 lowercase hexadecimal characters",
1377 );
1378 }
1379}
1380
1381fn push_issue(
1382 issues: &mut Vec<ValidationIssue>,
1383 code: ValidationCode,
1384 path: impl Into<String>,
1385 message: impl Into<String>,
1386) {
1387 issues.push(ValidationIssue {
1388 code,
1389 path: path.into(),
1390 message: message.into(),
1391 });
1392}
1393
1394fn validate_generation(
1400 generation: &Generation,
1401 layer_count: u32,
1402 issues: &mut Vec<ValidationIssue>,
1403) {
1404 if let Some(request_defaults) = &generation.request_defaults {
1405 validate_generation_request_defaults(request_defaults, issues);
1406 }
1407 let Some(speculative) = &generation.speculative_decoding else {
1408 return;
1409 };
1410 if speculative.default.trim().is_empty() {
1411 push_issue(
1412 issues,
1413 ValidationCode::InvalidGenerationValue,
1414 "generation.speculative_decoding.default",
1415 "default must not be empty",
1416 );
1417 }
1418 for name in speculative.proposers.keys() {
1419 if name.trim().is_empty() {
1420 push_issue(
1421 issues,
1422 ValidationCode::InvalidGenerationValue,
1423 "generation.speculative_decoding.proposers",
1424 "proposer names must not be empty",
1425 );
1426 }
1427 validate_proposer(name, &speculative.proposers[name], layer_count, issues);
1428 }
1429 for name in speculative.strategies.keys() {
1430 if name.trim().is_empty() {
1431 push_issue(
1432 issues,
1433 ValidationCode::InvalidGenerationValue,
1434 "generation.speculative_decoding.strategies",
1435 "strategy names must not be empty",
1436 );
1437 }
1438 validate_strategy(
1439 name,
1440 &speculative.strategies[name],
1441 &speculative.proposers,
1442 layer_count,
1443 issues,
1444 );
1445 }
1446 if !speculative.default.trim().is_empty()
1447 && !speculative.strategies.contains_key(&speculative.default)
1448 {
1449 push_issue(
1450 issues,
1451 ValidationCode::UnknownStrategy,
1452 "generation.speculative_decoding.default",
1453 format!(
1454 "default strategy {:?} is not declared under strategies",
1455 speculative.default
1456 ),
1457 );
1458 }
1459}
1460
1461fn validate_generation_request_defaults(
1462 request_defaults: &GenerationRequestDefaults,
1463 issues: &mut Vec<ValidationIssue>,
1464) {
1465 let base = "generation.request_defaults";
1466 if request_defaults.profiles.is_empty() {
1467 push_issue(
1468 issues,
1469 ValidationCode::MissingValue,
1470 format!("{base}.profiles"),
1471 "at least one generation profile is required",
1472 );
1473 }
1474 for (name, profile) in &request_defaults.profiles {
1475 validate_identifier(&format!("{base}.profiles[{name}]"), name, issues);
1476 validate_generation_profile(name, profile, issues);
1477 }
1478 for (field, selected) in [
1479 ("default", Some(&request_defaults.selection.default)),
1480 (
1481 "reasoning_enabled",
1482 request_defaults.selection.reasoning_enabled.as_ref(),
1483 ),
1484 (
1485 "reasoning_disabled",
1486 request_defaults.selection.reasoning_disabled.as_ref(),
1487 ),
1488 ] {
1489 let Some(selected) = selected else { continue };
1490 if selected.trim().is_empty() {
1491 push_issue(
1492 issues,
1493 ValidationCode::MissingValue,
1494 format!("{base}.selection.{field}"),
1495 "selected profile must not be empty",
1496 );
1497 } else if !request_defaults.profiles.contains_key(selected) {
1498 push_issue(
1499 issues,
1500 ValidationCode::InvalidGenerationValue,
1501 format!("{base}.selection.{field}"),
1502 format!("references undeclared generation profile {selected:?}"),
1503 );
1504 }
1505 }
1506}
1507
1508fn validate_generation_profile(
1509 name: &str,
1510 profile: &GenerationProfile,
1511 issues: &mut Vec<ValidationIssue>,
1512) {
1513 let base = format!("generation.request_defaults.profiles[{name}]");
1514 if profile.max_tokens == Some(0) {
1515 push_issue(
1516 issues,
1517 ValidationCode::InvalidGenerationValue,
1518 format!("{base}.max_tokens"),
1519 "max_tokens must be greater than zero",
1520 );
1521 }
1522 if let Some(stop) = &profile.stop {
1523 for (index, value) in stop.iter().enumerate() {
1524 validate_nonempty(&format!("{base}.stop[{index}]"), value, issues);
1525 }
1526 }
1527 for (field, value) in [
1528 ("temperature", profile.temperature),
1529 ("top_p", profile.top_p),
1530 ("min_p", profile.min_p),
1531 ("typical_p", profile.typical_p),
1532 ("top_nsigma", profile.top_nsigma),
1533 ("presence_penalty", profile.presence_penalty),
1534 ("frequency_penalty", profile.frequency_penalty),
1535 ("repeat_penalty", profile.repeat_penalty),
1536 ("dynatemp_range", profile.dynatemp_range),
1537 ("dynatemp_exponent", profile.dynatemp_exponent),
1538 ("mirostat_entropy", profile.mirostat_entropy),
1539 ("mirostat_learning_rate", profile.mirostat_learning_rate),
1540 ] {
1541 if value.is_some_and(|value| !value.is_finite()) {
1542 push_issue(
1543 issues,
1544 ValidationCode::InvalidGenerationValue,
1545 format!("{base}.{field}"),
1546 "value must be finite",
1547 );
1548 }
1549 }
1550 for (field, value, min, max) in [
1551 ("temperature", profile.temperature, 0.0, 100.0),
1552 ("presence_penalty", profile.presence_penalty, -2.0, 2.0),
1553 ("frequency_penalty", profile.frequency_penalty, -2.0, 2.0),
1554 ("top_nsigma", profile.top_nsigma, -1.0, f64::MAX),
1555 ("dynatemp_range", profile.dynatemp_range, 0.0, f64::MAX),
1556 (
1557 "dynatemp_exponent",
1558 profile.dynatemp_exponent,
1559 0.0,
1560 f64::MAX,
1561 ),
1562 ] {
1563 if value.is_some_and(|value| value < min || value > max) {
1564 push_issue(
1565 issues,
1566 ValidationCode::InvalidGenerationValue,
1567 format!("{base}.{field}"),
1568 format!("value must be within {min}..={max}"),
1569 );
1570 }
1571 }
1572 for (field, value) in [
1573 ("top_p", profile.top_p),
1574 ("min_p", profile.min_p),
1575 ("typical_p", profile.typical_p),
1576 ] {
1577 if value.is_some_and(|value| !(0.0..=1.0).contains(&value)) {
1578 push_issue(
1579 issues,
1580 ValidationCode::InvalidGenerationValue,
1581 format!("{base}.{field}"),
1582 "value must be within 0..=1",
1583 );
1584 }
1585 }
1586 if profile.temperature.is_some_and(|value| value < 0.0) {
1587 push_issue(
1588 issues,
1589 ValidationCode::InvalidGenerationValue,
1590 format!("{base}.temperature"),
1591 "temperature must be non-negative",
1592 );
1593 }
1594 if profile.repeat_penalty.is_some_and(|value| value <= 0.0) {
1595 push_issue(
1596 issues,
1597 ValidationCode::InvalidGenerationValue,
1598 format!("{base}.repeat_penalty"),
1599 "repeat_penalty must be greater than zero",
1600 );
1601 }
1602 if profile.top_k.is_some_and(|value| value < 0) {
1603 push_issue(
1604 issues,
1605 ValidationCode::InvalidGenerationValue,
1606 format!("{base}.top_k"),
1607 "top_k must be greater than or equal to zero",
1608 );
1609 }
1610 if profile.repeat_last_n.is_some_and(|value| value < -1) {
1611 push_issue(
1612 issues,
1613 ValidationCode::InvalidGenerationValue,
1614 format!("{base}.repeat_last_n"),
1615 "repeat_last_n must be greater than or equal to -1",
1616 );
1617 }
1618 if profile.seed.is_some_and(|value| value > u32::MAX.into()) {
1619 push_issue(
1620 issues,
1621 ValidationCode::InvalidGenerationValue,
1622 format!("{base}.seed"),
1623 "seed must fit in the native u32 field",
1624 );
1625 }
1626 if let Some(logit_bias) = &profile.logit_bias {
1627 for (token, bias) in logit_bias {
1628 let valid_token = token.parse::<i32>().is_ok_and(|token| token >= 0);
1629 if !valid_token || !bias.is_finite() || !(-100.0..=100.0).contains(bias) {
1630 push_issue(
1631 issues,
1632 ValidationCode::InvalidGenerationValue,
1633 format!("{base}.logit_bias[{token}]"),
1634 "logit bias keys must be signed token ids and values must be within -100..=100",
1635 );
1636 }
1637 }
1638 }
1639 if profile
1640 .mirostat_mode
1641 .is_some_and(|value| !(0..=2).contains(&value))
1642 {
1643 push_issue(
1644 issues,
1645 ValidationCode::InvalidGenerationValue,
1646 format!("{base}.mirostat_mode"),
1647 "mirostat_mode must be 0, 1, or 2",
1648 );
1649 }
1650 for (field, value) in [
1651 ("mirostat_entropy", profile.mirostat_entropy),
1652 ("mirostat_learning_rate", profile.mirostat_learning_rate),
1653 ] {
1654 if value.is_some_and(|value| value <= 0.0) {
1655 push_issue(
1656 issues,
1657 ValidationCode::InvalidGenerationValue,
1658 format!("{base}.{field}"),
1659 "value must be greater than zero",
1660 );
1661 }
1662 }
1663 if let Some(samplers) = &profile.samplers {
1664 for (index, sampler) in samplers.iter().enumerate() {
1665 validate_nonempty(&format!("{base}.samplers[{index}]"), sampler, issues);
1666 if !matches!(
1667 sampler.as_str(),
1668 "penalties"
1669 | "dry"
1670 | "top_n_sigma"
1671 | "top_k"
1672 | "typical_p"
1673 | "typ_p"
1674 | "top_p"
1675 | "min_p"
1676 | "xtc"
1677 | "temperature"
1678 | "temp"
1679 ) {
1680 push_issue(
1681 issues,
1682 ValidationCode::InvalidGenerationValue,
1683 format!("{base}.samplers[{index}]"),
1684 "unsupported sampler name",
1685 );
1686 }
1687 }
1688 }
1689 if let Some(sequence) = &profile.sampler_sequence {
1690 validate_nonempty(&format!("{base}.sampler_sequence"), sequence, issues);
1691 if sequence
1692 .chars()
1693 .filter(|value| !value.is_whitespace())
1694 .any(|value| !matches!(value, 'e' | 'd' | 's' | 'k' | 'y' | 'p' | 'm' | 'x' | 't'))
1695 {
1696 push_issue(
1697 issues,
1698 ValidationCode::InvalidGenerationValue,
1699 format!("{base}.sampler_sequence"),
1700 "sampler sequence contains an unsupported code",
1701 );
1702 }
1703 }
1704 if let Some(dry) = &profile.dry {
1705 if dry.multiplier.is_some_and(|value| value < 0.0)
1706 || dry.base.is_some_and(|value| value <= 0.0)
1707 || dry.allowed_length.is_some_and(|value| value < 0)
1708 || dry.penalty_last_n.is_some_and(|value| value < -1)
1709 {
1710 push_issue(
1711 issues,
1712 ValidationCode::InvalidGenerationValue,
1713 format!("{base}.dry"),
1714 "DRY values are outside native sampler bounds",
1715 );
1716 }
1717 if let Some(breakers) = &dry.sequence_breakers {
1718 for (index, breaker) in breakers.iter().enumerate() {
1719 if breaker.is_empty() || breaker.len() >= 16 {
1720 push_issue(
1721 issues,
1722 ValidationCode::InvalidGenerationValue,
1723 format!("{base}.dry.sequence_breakers[{index}]"),
1724 "DRY sequence breakers must be non-empty and shorter than 16 bytes",
1725 );
1726 }
1727 }
1728 }
1729 }
1730 if let Some(xtc) = &profile.xtc {
1731 for (field, value) in [
1732 ("probability", xtc.probability),
1733 ("threshold", xtc.threshold),
1734 ] {
1735 if value.is_some_and(|value| !(0.0..=1.0).contains(&value)) {
1736 push_issue(
1737 issues,
1738 ValidationCode::InvalidGenerationValue,
1739 format!("{base}.xtc.{field}"),
1740 "value must be within 0..=1",
1741 );
1742 }
1743 }
1744 }
1745 validate_generation_provenance(&base, &profile.provenance, issues);
1746}
1747
1748fn validate_generation_provenance(
1749 profile_path: &str,
1750 provenance: &GenerationDefaultsProvenance,
1751 issues: &mut Vec<ValidationIssue>,
1752) {
1753 let base = format!("{profile_path}.provenance");
1754 for (field, value) in [
1755 ("source_repo", provenance.source_repo.as_str()),
1756 ("revision", provenance.revision.as_str()),
1757 ("file", provenance.file.as_str()),
1758 ("section", provenance.section.as_str()),
1759 ("url", provenance.url.as_str()),
1760 ] {
1761 validate_nonempty(&format!("{base}.{field}"), value, issues);
1762 }
1763 let revision = provenance.revision.trim();
1764 if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1765 push_issue(
1766 issues,
1767 ValidationCode::InvalidGenerationValue,
1768 format!("{base}.revision"),
1769 "provenance revision must be an immutable 40-character Git commit SHA",
1770 );
1771 }
1772 if !provenance.url.starts_with("https://") && !provenance.url.starts_with("http://") {
1773 push_issue(
1774 issues,
1775 ValidationCode::InvalidGenerationValue,
1776 format!("{base}.url"),
1777 "provenance URL must use http or https",
1778 );
1779 } else if !provenance
1780 .url
1781 .split(['/', '?', '#', '&', '='])
1782 .any(|segment| segment == revision)
1783 {
1784 push_issue(
1785 issues,
1786 ValidationCode::InvalidGenerationValue,
1787 format!("{base}.url"),
1788 "provenance URL must contain the exact revision as a distinct path or query segment",
1789 );
1790 }
1791}
1792
1793fn validate_proposer(
1794 name: &str,
1795 proposer: &ProposerSpec,
1796 layer_count: u32,
1797 issues: &mut Vec<ValidationIssue>,
1798) {
1799 match &proposer.kind {
1800 ProposerKind::NativeMtp {
1801 prediction_depth,
1802 layer_indices,
1803 } => {
1804 if *prediction_depth == 0 {
1805 push_issue(
1806 issues,
1807 ValidationCode::InvalidGenerationValue,
1808 format!("generation.speculative_decoding.proposers[{name}].prediction_depth"),
1809 "prediction_depth must be greater than zero",
1810 );
1811 }
1812 if layer_indices.is_empty() {
1813 push_issue(
1814 issues,
1815 ValidationCode::InvalidGenerationValue,
1816 format!("generation.speculative_decoding.proposers[{name}].layer_indices"),
1817 "layer_indices must not be empty",
1818 );
1819 }
1820 for (index, layer_index) in layer_indices.iter().enumerate() {
1821 if *layer_index >= layer_count {
1822 push_issue(
1823 issues,
1824 ValidationCode::LayerOutOfBounds,
1825 format!(
1826 "generation.speculative_decoding.proposers[{name}].layer_indices[{index}]"
1827 ),
1828 format!(
1829 "layer index {layer_index} is out of range for layer_count {layer_count}"
1830 ),
1831 );
1832 }
1833 }
1834 }
1835 ProposerKind::NgramCache {
1836 ngram_min,
1837 ngram_max,
1838 max_proposal_tokens,
1839 history_scope,
1840 }
1841 | ProposerKind::NgramSuffix {
1842 ngram_min,
1843 ngram_max,
1844 max_proposal_tokens,
1845 history_scope,
1846 } => {
1847 if *ngram_min == 0 || *ngram_max < *ngram_min {
1848 push_issue(
1849 issues,
1850 ValidationCode::InvalidGenerationValue,
1851 format!("generation.speculative_decoding.proposers[{name}]"),
1852 "ngram_min and ngram_max must satisfy 1 <= ngram_min <= ngram_max",
1853 );
1854 }
1855 if *max_proposal_tokens == 0 {
1856 push_issue(
1857 issues,
1858 ValidationCode::InvalidGenerationValue,
1859 format!(
1860 "generation.speculative_decoding.proposers[{name}].max_proposal_tokens"
1861 ),
1862 "max_proposal_tokens must be greater than zero",
1863 );
1864 }
1865 if history_scope.trim().is_empty() {
1866 push_issue(
1867 issues,
1868 ValidationCode::InvalidGenerationValue,
1869 format!("generation.speculative_decoding.proposers[{name}].history_scope"),
1870 "history_scope must not be empty",
1871 );
1872 }
1873 }
1874 }
1875}
1876
1877fn validate_strategy(
1878 name: &str,
1879 strategy: &StrategySpec,
1880 proposers: &BTreeMap<String, ProposerSpec>,
1881 layer_count: u32,
1882 issues: &mut Vec<ValidationIssue>,
1883) {
1884 let require_proposer = |field: &str,
1885 proposer_name: &str,
1886 allowed: &[&str],
1887 issues: &mut Vec<ValidationIssue>| {
1888 if proposer_name.trim().is_empty() {
1889 push_issue(
1890 issues,
1891 ValidationCode::InvalidGenerationValue,
1892 format!("generation.speculative_decoding.strategies[{name}].{field}"),
1893 "proposer reference must not be empty",
1894 );
1895 } else if !proposers.contains_key(proposer_name) {
1896 push_issue(
1897 issues,
1898 ValidationCode::UnknownProposer,
1899 format!("generation.speculative_decoding.strategies[{name}].{field}"),
1900 format!("references undeclared proposer {proposer_name:?}"),
1901 );
1902 } else {
1903 let actual = match &proposers[proposer_name].kind {
1904 ProposerKind::NativeMtp { .. } => "native-mtp",
1905 ProposerKind::NgramCache { .. } => "ngram-cache",
1906 ProposerKind::NgramSuffix { .. } => "ngram-suffix",
1907 };
1908 if !allowed.contains(&actual) {
1909 push_issue(
1910 issues,
1911 ValidationCode::InvalidGenerationValue,
1912 format!("generation.speculative_decoding.strategies[{name}].{field}"),
1913 format!(
1914 "proposer {proposer_name:?} has kind {actual:?}, which is not compatible with this strategy (expected one of {allowed:?})"
1915 ),
1916 );
1917 }
1918 }
1919 };
1920 let validate_window_policy =
1921 |window_policy: &Option<WindowPolicy>, issues: &mut Vec<ValidationIssue>| {
1922 if let Some(window) = window_policy {
1923 if window.default.trim().is_empty() {
1924 push_issue(
1925 issues,
1926 ValidationCode::InvalidGenerationValue,
1927 format!(
1928 "generation.speculative_decoding.strategies[{name}].window_policy.default"
1929 ),
1930 "window_policy.default must not be empty",
1931 );
1932 }
1933 if window.initial_window == 0 || window.min_window == 0 || window.max_window == 0 {
1934 push_issue(
1935 issues,
1936 ValidationCode::InvalidGenerationValue,
1937 format!("generation.speculative_decoding.strategies[{name}].window_policy"),
1938 "window_policy windows must be greater than zero",
1939 );
1940 }
1941 if window.pipeline_depth == Some(0) {
1942 push_issue(
1943 issues,
1944 ValidationCode::InvalidGenerationValue,
1945 format!(
1946 "generation.speculative_decoding.strategies[{name}].window_policy.pipeline_depth"
1947 ),
1948 "window_policy.pipeline_depth must be greater than zero",
1949 );
1950 }
1951 if window.min_window > window.max_window {
1952 push_issue(
1953 issues,
1954 ValidationCode::InvalidGenerationValue,
1955 format!("generation.speculative_decoding.strategies[{name}].window_policy"),
1956 "window_policy min_window must not exceed max_window",
1957 );
1958 }
1959 if window.initial_window < window.min_window
1960 || window.initial_window > window.max_window
1961 {
1962 push_issue(
1963 issues,
1964 ValidationCode::InvalidGenerationValue,
1965 format!("generation.speculative_decoding.strategies[{name}].window_policy"),
1966 "window_policy initial_window must lie within min_window..max_window",
1967 );
1968 }
1969 }
1970 };
1971 match &strategy.kind {
1972 StrategyKind::NativeMtp {
1973 proposer,
1974 prediction_depth,
1975 layer_indices,
1976 window_policy,
1977 } => {
1978 validate_window_policy(window_policy, issues);
1979 match (
1980 proposer,
1981 prediction_depth.is_some(),
1982 !layer_indices.is_empty(),
1983 ) {
1984 (Some(proposer_name), false, false) => {
1985 require_proposer("proposer", proposer_name, &["native-mtp"], issues);
1986 }
1987 (None, true, true) => {
1988 if *prediction_depth == Some(0) {
1989 push_issue(
1990 issues,
1991 ValidationCode::InvalidGenerationValue,
1992 format!(
1993 "generation.speculative_decoding.strategies[{name}].prediction_depth"
1994 ),
1995 "prediction_depth must be greater than zero",
1996 );
1997 }
1998 for (index, layer_index) in layer_indices.iter().enumerate() {
1999 if *layer_index >= layer_count {
2000 push_issue(
2001 issues,
2002 ValidationCode::LayerOutOfBounds,
2003 format!(
2004 "generation.speculative_decoding.strategies[{name}].layer_indices[{index}]"
2005 ),
2006 format!(
2007 "layer index {layer_index} is out of range for layer_count {layer_count}"
2008 ),
2009 );
2010 }
2011 }
2012 }
2013 (Some(_), _, _) => push_issue(
2014 issues,
2015 ValidationCode::InvalidGenerationValue,
2016 format!("generation.speculative_decoding.strategies[{name}]"),
2017 "native-mtp strategy must set either proposer or the inline prediction_depth/layer_indices form, not both",
2018 ),
2019 (None, true, false) => push_issue(
2020 issues,
2021 ValidationCode::InvalidGenerationValue,
2022 format!("generation.speculative_decoding.strategies[{name}].layer_indices"),
2023 "inline native-mtp strategy must declare layer_indices alongside prediction_depth",
2024 ),
2025 (None, false, true) => push_issue(
2026 issues,
2027 ValidationCode::InvalidGenerationValue,
2028 format!("generation.speculative_decoding.strategies[{name}].prediction_depth"),
2029 "inline native-mtp strategy must declare prediction_depth alongside layer_indices",
2030 ),
2031 (None, false, false) => push_issue(
2032 issues,
2033 ValidationCode::InvalidGenerationValue,
2034 format!("generation.speculative_decoding.strategies[{name}]"),
2035 "native-mtp strategy must set either proposer or the inline prediction_depth/layer_indices form",
2036 ),
2037 }
2038 }
2039 StrategyKind::NgramCache {
2040 proposer,
2041 window_policy,
2042 } => {
2043 validate_window_policy(window_policy, issues);
2044 require_proposer("proposer", proposer, &["ngram-cache"], issues);
2045 }
2046 StrategyKind::NgramSuffix {
2047 proposer,
2048 window_policy,
2049 } => {
2050 validate_window_policy(window_policy, issues);
2051 require_proposer("proposer", proposer, &["ngram-suffix"], issues);
2052 }
2053 StrategyKind::Composite {
2054 primary,
2055 extender,
2056 extension_policy,
2057 window_policy,
2058 } => {
2059 validate_window_policy(window_policy, issues);
2060 if extension_policy.max_tokens == 0 {
2061 push_issue(
2062 issues,
2063 ValidationCode::InvalidGenerationValue,
2064 format!(
2065 "generation.speculative_decoding.strategies[{name}].extension_policy.max_tokens"
2066 ),
2067 "extension_policy.max_tokens must be greater than zero",
2068 );
2069 }
2070 if extension_policy.initial_tokens == Some(0) {
2071 push_issue(
2072 issues,
2073 ValidationCode::InvalidGenerationValue,
2074 format!(
2075 "generation.speculative_decoding.strategies[{name}].extension_policy.initial_tokens"
2076 ),
2077 "extension_policy.initial_tokens must be greater than zero",
2078 );
2079 }
2080 if let Some(initial_tokens) = extension_policy.initial_tokens
2081 && initial_tokens > extension_policy.max_tokens
2082 {
2083 push_issue(
2084 issues,
2085 ValidationCode::InvalidGenerationValue,
2086 format!(
2087 "generation.speculative_decoding.strategies[{name}].extension_policy.initial_tokens"
2088 ),
2089 "extension_policy.initial_tokens must not exceed max_tokens",
2090 );
2091 }
2092 if extension_policy.tail_backoff_proposals == Some(0) {
2093 push_issue(
2094 issues,
2095 ValidationCode::InvalidGenerationValue,
2096 format!(
2097 "generation.speculative_decoding.strategies[{name}].extension_policy.tail_backoff_proposals"
2098 ),
2099 "extension_policy.tail_backoff_proposals must be greater than zero",
2100 );
2101 }
2102 require_proposer("primary", primary, &["native-mtp"], issues);
2103 require_proposer(
2104 "extender",
2105 extender,
2106 &["ngram-cache", "ngram-suffix"],
2107 issues,
2108 );
2109 }
2110 }
2111}
2112
2113#[cfg(test)]
2114mod tests;