Skip to main content

skippy_package_format/
lib.rs

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    /// Validate manifest structure and self-consistency, not source completeness.
123    /// Creation/certification must separately compare with independent source
124    /// directories and verify the artifact bytes; a manifest cannot certify itself.
125    pub fn validate(&self) -> Result<(), ValidationErrors> {
126        self.validate_with_inventory(true)
127    }
128
129    /// Validate the small on-disk root before its metadata carrier is opened.
130    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    /// Attach the inventory derived from the verified metadata carrier.
182    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    /// Digest of the primary file in the immutable source distribution.
197    pub sha256: String,
198    /// Generated package artifact that carries the model metadata needed to
199    /// construct the metadata-only graph.
200    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/// Immutable publisher files used to derive typed model defaults.
310///
311/// These files are package-level metadata. They are not loader sidecars and
312/// therefore never participate in stage selection.
313#[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/// Generation capability declarations carried as package data.
364///
365/// Describes speculative-decoding capabilities of the source model only.
366/// It is never stage-selection or loader policy: which strategy actually runs
367/// is resolved from runtime configuration, and layer indices here must never
368/// be consulted by a stage selector.
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct Generation {
372    /// Publisher-reviewed request defaults for the exact source revision.
373    /// These values are advisory package data and are resolved below explicit
374    /// request and deployment/operator settings by the serving runtime.
375    #[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    /// Validate profile references, sampler values, and immutable provenance.
390    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/// Selects a package profile after the request's reasoning mode has been
402/// resolved. `default` is used when the mode remains automatic or unknown.
403#[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/// One publisher-recommended request profile. Unknown values stay absent.
414#[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/// Strategy wrapper: `#[serde(flatten)]` of the tagged enum forbids
561/// `deny_unknown_fields` here, so unknown-field rejection happens inside the
562/// tagged enum variants.
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
564pub struct ProposerSpec {
565    #[serde(flatten)]
566    pub kind: ProposerKind,
567}
568
569/// See [`ProposerSpec`] for the flatten/deny_unknown_fields tradeoff.
570#[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    /// Native-MTP strategies carry either a proposer reference or the inline
613    /// `prediction_depth`/`layer_indices` form — never both, never neither.
614    #[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
1394/// Schema-level (graph-checkable) generation validation only.
1395///
1396/// Deliberately excludes runtime-capability bounds such as the native-MTP
1397/// prediction-depth value, n-gram window limits, or history-scope vocabulary:
1398/// those are enforced by the runtime/preflight, not by the package format.
1399fn 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;