1use std::{
2 fs,
3 path::{Path, PathBuf},
4};
5
6use clap::Args;
7use ferrum_types::{
8 FerrumError, Result, TokenId, VNextCheckpointCaptureConfig, VNextTeacherForcingConfig,
9};
10use serde::Deserialize;
11
12const MAX_TEACHER_TOKEN_FILE_BYTES: usize = 64 * 1024;
13
14#[derive(Args, Clone, Debug, Default)]
15pub struct VNextCheckpointArgs {
16 #[arg(long = "vnext-checkpoint-dir", value_name = "DIR")]
18 pub output_dir: Option<PathBuf>,
19
20 #[arg(
23 long = "vnext-checkpoint-value",
24 value_name = "VALUE_ID",
25 action = clap::ArgAction::Append
26 )]
27 pub value_ids: Vec<String>,
28
29 #[arg(long = "vnext-checkpoint-prefill-waves", value_name = "N")]
32 pub maximum_prefill_waves: Option<usize>,
33
34 #[arg(long = "vnext-checkpoint-decode-waves", value_name = "N")]
37 pub maximum_decode_waves: Option<usize>,
38
39 #[arg(long = "vnext-checkpoint-product-output")]
42 pub capture_product_output: bool,
43
44 #[arg(long = "vnext-checkpoint-teacher-token-file", value_name = "JSON")]
47 pub teacher_token_file: Option<PathBuf>,
48}
49
50impl VNextCheckpointArgs {
51 pub fn to_config(&self) -> Result<Option<VNextCheckpointCaptureConfig>> {
52 let configured = self.output_dir.is_some()
53 || !self.value_ids.is_empty()
54 || self.maximum_prefill_waves.is_some()
55 || self.maximum_decode_waves.is_some()
56 || self.capture_product_output
57 || self.teacher_token_file.is_some();
58 if !configured {
59 return Ok(None);
60 }
61 let teacher_forcing = self
62 .teacher_token_file
63 .as_deref()
64 .map(load_teacher_forcing)
65 .transpose()?;
66 let output_dir = self.output_dir.clone().ok_or_else(|| {
67 FerrumError::config(
68 "--vnext-checkpoint-dir is required when checkpoint capture is configured",
69 )
70 })?;
71 if self.value_ids.is_empty() && !self.capture_product_output {
72 return Err(FerrumError::config(
73 "at least one --vnext-checkpoint-value or --vnext-checkpoint-product-output is required",
74 ));
75 }
76 if let Some(teacher_forcing) = &teacher_forcing {
77 if !self.capture_product_output {
78 return Err(FerrumError::config(
79 "--vnext-checkpoint-teacher-token-file requires --vnext-checkpoint-product-output",
80 ));
81 }
82 if self.maximum_prefill_waves.is_some_and(|waves| waves != 1) {
83 return Err(FerrumError::config(
84 "teacher-forced checkpoint capture requires exactly one final prefill wave",
85 ));
86 }
87 let expected_decode_waves = teacher_forcing.token_count().saturating_sub(1);
88 if self
89 .maximum_decode_waves
90 .is_some_and(|waves| waves != expected_decode_waves)
91 {
92 return Err(FerrumError::config(format!(
93 "teacher-forced checkpoint capture requires {expected_decode_waves} decode waves for {} tokens",
94 teacher_forcing.token_count()
95 )));
96 }
97 }
98 Ok(Some(VNextCheckpointCaptureConfig {
99 output_dir,
100 value_ids: self.value_ids.clone(),
101 maximum_prefill_waves: self.maximum_prefill_waves.unwrap_or(1),
102 maximum_decode_waves: self.maximum_decode_waves.unwrap_or_else(|| {
103 teacher_forcing
104 .as_ref()
105 .map_or(0, |teacher| teacher.token_count().saturating_sub(1))
106 }),
107 capture_product_output: self.capture_product_output,
108 teacher_forcing,
109 }))
110 }
111}
112
113#[derive(Deserialize)]
114#[serde(deny_unknown_fields)]
115struct TeacherTokenFile {
116 schema_version: u32,
117 encoding: String,
118 token_ids: Vec<u32>,
119}
120
121fn load_teacher_forcing(path: &Path) -> Result<VNextTeacherForcingConfig> {
122 let bytes = fs::read(path).map_err(|error| {
123 FerrumError::config(format!(
124 "cannot read vNext teacher-token file {}: {error}",
125 path.display()
126 ))
127 })?;
128 if bytes.len() > MAX_TEACHER_TOKEN_FILE_BYTES {
129 return Err(FerrumError::config(format!(
130 "vNext teacher-token file exceeds {MAX_TEACHER_TOKEN_FILE_BYTES} bytes"
131 )));
132 }
133 parse_teacher_forcing(&bytes)
134}
135
136fn parse_teacher_forcing(bytes: &[u8]) -> Result<VNextTeacherForcingConfig> {
137 let parsed: TeacherTokenFile = serde_json::from_slice(bytes).map_err(|error| {
138 FerrumError::config(format!("invalid vNext teacher-token JSON: {error}"))
139 })?;
140 if parsed.schema_version != 1 {
141 return Err(FerrumError::config(
142 "vNext teacher-token schema_version must be 1",
143 ));
144 }
145 if parsed.encoding != "u32-le" {
146 return Err(FerrumError::config(
147 "vNext teacher-token encoding must be u32-le",
148 ));
149 }
150 VNextTeacherForcingConfig::new(parsed.token_ids.into_iter().map(TokenId::new).collect())
151 .map_err(FerrumError::config)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn absent_flags_preserve_the_product_default() {
160 assert!(VNextCheckpointArgs::default()
161 .to_config()
162 .unwrap()
163 .is_none());
164 }
165
166 #[test]
167 fn capture_requires_a_directory_and_semantic_value() {
168 let missing_directory = VNextCheckpointArgs {
169 value_ids: vec!["value.output.logits".to_owned()],
170 ..VNextCheckpointArgs::default()
171 };
172 assert!(missing_directory.to_config().is_err());
173
174 let missing_value = VNextCheckpointArgs {
175 output_dir: Some(PathBuf::from("capture")),
176 ..VNextCheckpointArgs::default()
177 };
178 assert!(missing_value.to_config().is_err());
179 }
180
181 #[test]
182 fn capture_defaults_to_one_real_prefill_wave() {
183 let config = VNextCheckpointArgs {
184 output_dir: Some(PathBuf::from("capture")),
185 value_ids: vec!["value.output.logits".to_owned()],
186 maximum_prefill_waves: None,
187 maximum_decode_waves: None,
188 capture_product_output: false,
189 teacher_token_file: None,
190 }
191 .to_config()
192 .unwrap()
193 .unwrap();
194 assert_eq!(config.maximum_prefill_waves, 1);
195 assert_eq!(config.maximum_decode_waves, 0);
196 }
197
198 #[test]
199 fn decode_capture_is_an_explicit_shared_product_option() {
200 let config = VNextCheckpointArgs {
201 output_dir: Some(PathBuf::from("capture")),
202 value_ids: vec!["value.output.greedy_token".to_owned()],
203 maximum_prefill_waves: Some(1),
204 maximum_decode_waves: Some(64),
205 capture_product_output: false,
206 teacher_token_file: None,
207 }
208 .to_config()
209 .unwrap()
210 .unwrap();
211
212 assert_eq!(config.maximum_prefill_waves, 1);
213 assert_eq!(config.maximum_decode_waves, 64);
214 }
215
216 #[test]
217 fn product_output_capture_does_not_require_retained_values() {
218 let config = VNextCheckpointArgs {
219 output_dir: Some(PathBuf::from("capture")),
220 maximum_prefill_waves: Some(1),
221 maximum_decode_waves: Some(64),
222 capture_product_output: true,
223 ..VNextCheckpointArgs::default()
224 }
225 .to_config()
226 .unwrap()
227 .unwrap();
228
229 assert!(config.value_ids.is_empty());
230 assert!(config.capture_product_output);
231 }
232
233 #[test]
234 fn teacher_token_json_is_typed_and_bounded() {
235 let parsed = parse_teacher_forcing(
236 br#"{"schema_version":1,"encoding":"u32-le","token_ids":[11690,369]}"#,
237 )
238 .unwrap();
239 assert_eq!(
240 parsed
241 .token_ids()
242 .iter()
243 .map(|token| token.get())
244 .collect::<Vec<_>>(),
245 [11690, 369]
246 );
247
248 assert!(parse_teacher_forcing(
249 br#"{"schema_version":2,"encoding":"u32-le","token_ids":[1]}"#
250 )
251 .is_err());
252 assert!(parse_teacher_forcing(
253 br#"{"schema_version":1,"encoding":"json","token_ids":[1]}"#
254 )
255 .is_err());
256 assert!(parse_teacher_forcing(
257 br#"{"schema_version":1,"encoding":"u32-le","token_ids":[],"extra":true}"#
258 )
259 .is_err());
260
261 let excessive = serde_json::to_vec(&serde_json::json!({
262 "schema_version": 1,
263 "encoding": "u32-le",
264 "token_ids": vec![0_u32; ferrum_types::MAX_VNEXT_TEACHER_FORCED_TOKENS + 1],
265 }))
266 .unwrap();
267 assert!(parse_teacher_forcing(&excessive).is_err());
268 }
269
270 #[test]
271 fn teacher_token_file_derives_exact_wave_contract() {
272 let root = std::env::temp_dir().join(format!(
273 "ferrum-vnext-teacher-token-test-{}",
274 uuid::Uuid::new_v4()
275 ));
276 std::fs::create_dir_all(&root).unwrap();
277 let token_file = root.join("tokens.json");
278 std::fs::write(
279 &token_file,
280 br#"{"schema_version":1,"encoding":"u32-le","token_ids":[7,11,13]}"#,
281 )
282 .unwrap();
283 let args = VNextCheckpointArgs {
284 output_dir: Some(root.join("capture")),
285 capture_product_output: true,
286 teacher_token_file: Some(token_file.clone()),
287 ..VNextCheckpointArgs::default()
288 };
289 let config = args.to_config().unwrap().unwrap();
290 assert_eq!(config.maximum_prefill_waves, 1);
291 assert_eq!(config.maximum_decode_waves, 2);
292 assert_eq!(config.teacher_forcing.unwrap().token_count(), 3);
293
294 let wrong_decode_count = VNextCheckpointArgs {
295 maximum_decode_waves: Some(3),
296 ..args.clone()
297 };
298 assert!(wrong_decode_count.to_config().is_err());
299 let missing_product_output = VNextCheckpointArgs {
300 capture_product_output: false,
301 value_ids: vec!["value.output.logits".to_owned()],
302 ..args
303 };
304 assert!(missing_product_output.to_config().is_err());
305 std::fs::remove_dir_all(root).unwrap();
306 }
307}