candle_mi/diffusion/config.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Configuration for the `MDLM` masked-diffusion backend.
4//!
5//! [`MdlmConfig`] is parsed from a `kuleshov-group/mdlm-owt`-style
6//! `config.json` (model type `"mdlm"`). Unlike
7//! [`TransformerConfig`](crate::TransformerConfig), `MDLM` is a bidirectional
8//! `DiT` with `adaLN` conditioning, so it carries its own small config rather
9//! than reusing the causal-decoder one.
10
11use serde_json::Value;
12
13use crate::error::{MIError, Result};
14
15/// `MDLM` masked-diffusion model configuration.
16///
17/// Parsed from the upstream `config.json`. The released `mdlm-owt`
18/// checkpoint is time-independent (`time_conditioning = false`), so the
19/// `DiT` conditioning vector is a constant computed once at load time.
20#[derive(Debug, Clone)]
21pub struct MdlmConfig {
22 /// Hidden dimension (`d_model`; upstream key `hidden_dim`).
23 pub hidden_dim: usize,
24 /// Number of `DiT` blocks (upstream key `n_blocks`).
25 pub n_blocks: usize,
26 /// Number of attention heads (upstream key `n_heads`).
27 pub n_heads: usize,
28 /// Per-head dimension (`hidden_dim / n_heads`).
29 pub head_dim: usize,
30 /// Conditioning dimension fed to the `adaLN` modulation (upstream key `cond_dim`).
31 pub cond_dim: usize,
32 /// Vocabulary size, including the `[MASK]` token (upstream key `vocab_size`).
33 pub vocab_size: usize,
34 /// Maximum sequence length (upstream key `model_length`).
35 pub model_length: usize,
36 /// Feed-forward expansion ratio (`MDLM` uses `4`; not stored in `config.json`).
37 pub mlp_ratio: usize,
38 /// Rotary base frequency (`MDLM` hard-codes `10000`).
39 pub rope_theta: f64,
40 /// `LayerNorm` epsilon (`MDLM` uses `1e-5`).
41 pub norm_eps: f64,
42 /// Token id of the absorbing `[MASK]` state (`vocab_size - 1`).
43 pub mask_token_id: u32,
44 /// Whether the network conditions on diffusion time. The released
45 /// checkpoint sets this `false`, so the conditioning vector is constant.
46 pub time_conditioning: bool,
47}
48
49impl MdlmConfig {
50 /// Parse an [`MdlmConfig`] from a `HuggingFace` `config.json` value.
51 ///
52 /// # Errors
53 ///
54 /// Returns [`MIError::Config`] if a required key
55 /// (`hidden_dim`, `n_blocks`, `n_heads`, `cond_dim`, `vocab_size`) is
56 /// missing or not a non-negative integer, or if `hidden_dim` is not
57 /// divisible by `n_heads`.
58 pub fn from_hf_config(config: &Value) -> Result<Self> {
59 let hidden_dim = get_usize(config, "hidden_dim")?;
60 let n_heads = get_usize(config, "n_heads")?;
61 if n_heads == 0 || !hidden_dim.is_multiple_of(n_heads) {
62 return Err(MIError::Config(format!(
63 "hidden_dim {hidden_dim} not divisible by n_heads {n_heads}"
64 )));
65 }
66 let vocab_size = get_usize(config, "vocab_size")?;
67 // The absorbing [MASK] state is the final vocab index (GPT-2's 50257
68 // tokens 0..=50256 plus [MASK] = 50257, for vocab_size 50258).
69 let mask_token_id = u32::try_from(vocab_size.saturating_sub(1)).map_err(|e| {
70 MIError::Config(format!("vocab_size {vocab_size} does not fit in u32: {e}"))
71 })?;
72
73 Ok(Self {
74 hidden_dim,
75 n_blocks: get_usize(config, "n_blocks")?,
76 n_heads,
77 head_dim: hidden_dim / n_heads,
78 cond_dim: get_usize(config, "cond_dim")?,
79 vocab_size,
80 model_length: get_usize_or(config, "model_length", 1024),
81 mlp_ratio: 4,
82 rope_theta: 10_000.0,
83 norm_eps: 1e-5,
84 mask_token_id,
85 time_conditioning: get_bool_or(config, "time_conditioning", false),
86 })
87 }
88}
89
90/// Read a required non-negative integer config field as `usize`.
91///
92/// # Errors
93///
94/// Returns [`MIError::Config`] if the key is absent or
95/// not a `u64`.
96fn get_usize(config: &Value, key: &str) -> Result<usize> {
97 let value = config
98 .get(key)
99 .and_then(Value::as_u64)
100 .ok_or_else(|| MIError::Config(format!("missing or non-integer `{key}` in MDLM config")))?;
101 // CAST: u64 → usize, model dimensions fit in usize on 64-bit targets
102 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
103 Ok(value as usize)
104}
105
106/// Read an optional non-negative integer config field as `usize`, falling
107/// back to `default` when absent.
108fn get_usize_or(config: &Value, key: &str, default: usize) -> usize {
109 config
110 .get(key)
111 .and_then(Value::as_u64)
112 .map_or(default, |v| {
113 // CAST: u64 → usize, model dimensions fit in usize on 64-bit targets
114 #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
115 {
116 v as usize
117 }
118 })
119}
120
121/// Read an optional boolean config field, falling back to `default` when absent.
122fn get_bool_or(config: &Value, key: &str, default: bool) -> bool {
123 config.get(key).and_then(Value::as_bool).unwrap_or(default)
124}
125
126#[cfg(test)]
127#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
128mod tests {
129 use super::*;
130
131 fn owt_config() -> Value {
132 serde_json::json!({
133 "model_type": "mdlm",
134 "hidden_dim": 768,
135 "n_blocks": 12,
136 "n_heads": 12,
137 "cond_dim": 128,
138 "vocab_size": 50258,
139 "model_length": 1024,
140 "time_conditioning": false
141 })
142 }
143
144 #[test]
145 fn parses_mdlm_owt_config() {
146 let cfg = MdlmConfig::from_hf_config(&owt_config()).unwrap();
147 assert_eq!(cfg.hidden_dim, 768);
148 assert_eq!(cfg.n_blocks, 12);
149 assert_eq!(cfg.n_heads, 12);
150 assert_eq!(cfg.head_dim, 64);
151 assert_eq!(cfg.cond_dim, 128);
152 assert_eq!(cfg.vocab_size, 50258);
153 assert_eq!(cfg.model_length, 1024);
154 assert_eq!(cfg.mask_token_id, 50257);
155 assert!(!cfg.time_conditioning);
156 }
157
158 #[test]
159 fn rejects_indivisible_head_count() {
160 let mut cfg = owt_config();
161 cfg["n_heads"] = serde_json::json!(7);
162 assert!(MdlmConfig::from_hf_config(&cfg).is_err());
163 }
164
165 #[test]
166 fn missing_required_key_errors() {
167 let mut cfg = owt_config();
168 // BORROW: remove a required key to exercise the error path.
169 cfg.as_object_mut().unwrap().remove("hidden_dim");
170 assert!(MdlmConfig::from_hf_config(&cfg).is_err());
171 }
172}