1use std::{
4 fs,
5 path::{Path, PathBuf},
6};
7
8use eredu_core::{
9 ObservationSelector, ObservationSet, ObservationValue, TensorObservation, TensorObservationData,
10};
11use safetensors::{tensor::SafeTensors, Dtype};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 compare_observations, LogitTolerance, ParityComparison, ParityPolicy, ParityReport, ParityRule,
16};
17
18const LOGIT_TENSORS: [&str; 2] = ["prefill.logits", "decode.logits"];
19
20#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
22pub struct CheckpointParityOptions {
23 pub logits: LogitTolerance,
25}
26
27impl Default for CheckpointParityOptions {
28 fn default() -> Self {
29 Self {
30 logits: LogitTolerance {
31 relative_l2_max: 0.02,
32 cosine_similarity_min: 0.999,
33 top_k: 5,
34 top_k_overlap_min: 4,
35 require_unambiguous_argmax_match: true,
36 argmax_margin_min: 0.0,
37 },
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct CheckpointParityReport {
45 pub format_version: u32,
47 pub kind: String,
49 pub passed: bool,
51 pub actual: PathBuf,
53 pub reference: PathBuf,
55 pub parity: ParityReport,
57}
58
59pub fn compare_checkpoint_artifacts(
61 actual: impl AsRef<Path>,
62 reference: impl AsRef<Path>,
63 options: CheckpointParityOptions,
64) -> Result<CheckpointParityReport, CheckpointParityError> {
65 let actual = actual.as_ref();
66 let reference = reference.as_ref();
67 let actual_observations = read_checkpoint_observations(actual)?;
68 let reference_observations = read_checkpoint_observations(reference)?;
69 let policy = ParityPolicy {
70 default: ParityComparison::Exact,
71 rules: vec![ParityRule {
72 selector: ObservationSelector::Prefix("logits".into()),
73 comparison: ParityComparison::Logits {
74 tolerance: options.logits,
75 },
76 }],
77 require_same_paths: true,
78 };
79 let parity = compare_observations(&actual_observations, &reference_observations, &policy)?;
80 Ok(CheckpointParityReport {
81 format_version: 1,
82 kind: "checkpoint_parity".into(),
83 passed: parity.passed,
84 actual: actual.into(),
85 reference: reference.into(),
86 parity,
87 })
88}
89
90fn read_checkpoint_observations(path: &Path) -> Result<ObservationSet, CheckpointParityError> {
91 let report: serde_json::Value = serde_json::from_slice(&fs::read(path)?)?;
92 let input = json_integer_array(&report, &["input", "token_ids"], path)?;
93 let fed = json_integer_array(&report, &["output", "fed_token_ids"], path)?;
94 let tensor_path = report
95 .pointer("/output/tensor_file")
96 .and_then(serde_json::Value::as_str)
97 .ok_or_else(|| CheckpointParityError::InvalidArtifact {
98 path: path.into(),
99 message: "missing string output.tensor_file".into(),
100 })?;
101 let tensor_path = resolve_tensor_path(path, Path::new(tensor_path));
102 let bytes = fs::read(&tensor_path)?;
103 let tensors = SafeTensors::deserialize(&bytes)?;
104
105 let mut observations = ObservationSet::new();
106 observations.insert(
107 "input.token_ids",
108 ObservationValue::Tensor(TensorObservation::new(
109 vec![input.len()],
110 TensorObservationData::I64(input),
111 )?),
112 )?;
113 observations.insert(
114 "output.fed_token_ids",
115 ObservationValue::Tensor(TensorObservation::new(
116 vec![fed.len()],
117 TensorObservationData::I64(fed),
118 )?),
119 )?;
120 for name in LOGIT_TENSORS {
121 let tensor = tensors.tensor(name)?;
122 if tensor.dtype() != Dtype::F32 {
123 return Err(CheckpointParityError::InvalidArtifact {
124 path: tensor_path.clone(),
125 message: format!("tensor {name:?} must be F32, got {:?}", tensor.dtype()),
126 });
127 }
128 let (chunks, remainder) = tensor.data().as_chunks::<4>();
129 if !remainder.is_empty() {
130 return Err(CheckpointParityError::InvalidArtifact {
131 path: tensor_path.clone(),
132 message: format!("tensor {name:?} has a partial F32 value"),
133 });
134 }
135 let values = chunks
136 .iter()
137 .map(|bytes| f32::from_le_bytes(*bytes))
138 .collect();
139 observations.insert(
140 format!("logits.{name}"),
141 ObservationValue::Tensor(TensorObservation::new(
142 tensor.shape().to_vec(),
143 TensorObservationData::F32(values),
144 )?),
145 )?;
146 }
147 Ok(observations)
148}
149
150fn json_integer_array(
151 report: &serde_json::Value,
152 path: &[&str],
153 source: &Path,
154) -> Result<Vec<i64>, CheckpointParityError> {
155 let pointer = format!("/{}", path.join("/"));
156 report
157 .pointer(&pointer)
158 .and_then(serde_json::Value::as_array)
159 .ok_or_else(|| CheckpointParityError::InvalidArtifact {
160 path: source.into(),
161 message: format!("missing integer array {}", path.join(".")),
162 })?
163 .iter()
164 .map(|value| {
165 value
166 .as_i64()
167 .ok_or_else(|| CheckpointParityError::InvalidArtifact {
168 path: source.into(),
169 message: format!("{} contains a non-integer value", path.join(".")),
170 })
171 })
172 .collect()
173}
174
175fn resolve_tensor_path(report: &Path, tensor: &Path) -> PathBuf {
176 if tensor.is_absolute() || tensor.exists() {
177 return tensor.into();
178 }
179 let relative = report
180 .parent()
181 .unwrap_or_else(|| Path::new("."))
182 .join(tensor);
183 if relative.exists() {
184 return relative;
185 }
186 report
187 .parent()
188 .unwrap_or_else(|| Path::new("."))
189 .join(tensor.file_name().unwrap_or_default())
190}
191
192#[derive(Debug, thiserror::Error)]
194pub enum CheckpointParityError {
195 #[error(transparent)]
197 Io(#[from] std::io::Error),
198 #[error(transparent)]
200 Json(#[from] serde_json::Error),
201 #[error(transparent)]
203 SafeTensors(#[from] safetensors::SafeTensorError),
204 #[error(transparent)]
206 Observation(#[from] eredu_core::ObservationError),
207 #[error(transparent)]
209 Parity(#[from] crate::ParityError),
210 #[error("invalid checkpoint artifact {}: {message}", path.display())]
212 InvalidArtifact {
213 path: PathBuf,
215 message: String,
217 },
218}
219
220#[cfg(test)]
221mod tests {
222 use std::collections::BTreeMap;
223
224 use safetensors::tensor::{serialize_to_file, TensorView};
225 use tempfile::tempdir;
226
227 use super::*;
228
229 fn artifact(
230 root: &Path,
231 name: &str,
232 input: &[i64],
233 fed: &[i64],
234 prefill: &[f32],
235 decode: &[f32],
236 ) -> PathBuf {
237 let tensors = root.join(format!("{name}.safetensors"));
238 let prefill_bytes = prefill
239 .iter()
240 .flat_map(|value| value.to_le_bytes())
241 .collect::<Vec<_>>();
242 let decode_bytes = decode
243 .iter()
244 .flat_map(|value| value.to_le_bytes())
245 .collect::<Vec<_>>();
246 let mut views = BTreeMap::new();
247 views.insert(
248 "prefill.logits",
249 TensorView::new(Dtype::F32, vec![1, prefill.len()], &prefill_bytes).unwrap(),
250 );
251 views.insert(
252 "decode.logits",
253 TensorView::new(Dtype::F32, vec![1, decode.len()], &decode_bytes).unwrap(),
254 );
255 serialize_to_file(views, None, &tensors).unwrap();
256 let report = root.join(format!("{name}.json"));
257 fs::write(
258 &report,
259 serde_json::to_vec(&serde_json::json!({
260 "input": {"token_ids": input},
261 "output": {"fed_token_ids": fed, "tensor_file": tensors},
262 }))
263 .unwrap(),
264 )
265 .unwrap();
266 report
267 }
268
269 #[test]
270 fn checkpoint_adapter_uses_general_identity_and_logit_parity() {
271 let root = tempdir().unwrap();
272 let actual = artifact(
273 root.path(),
274 "actual",
275 &[1, 2],
276 &[3],
277 &[0.1, 0.8, 0.2],
278 &[0.7, 0.2, 0.1],
279 );
280 let reference = artifact(
281 root.path(),
282 "reference",
283 &[1, 2],
284 &[3],
285 &[0.1, 0.8, 0.2],
286 &[0.7, 0.2, 0.1],
287 );
288 assert!(
289 compare_checkpoint_artifacts(actual, reference, CheckpointParityOptions::default())
290 .unwrap()
291 .passed
292 );
293 }
294
295 #[test]
296 fn checkpoint_adapter_reports_token_and_numeric_failures_together() {
297 let root = tempdir().unwrap();
298 let actual = artifact(
299 root.path(),
300 "actual",
301 &[1, 9],
302 &[3],
303 &[0.9, 0.1, 0.0],
304 &[0.7, 0.2, 0.1],
305 );
306 let reference = artifact(
307 root.path(),
308 "reference",
309 &[1, 2],
310 &[3],
311 &[0.1, 0.8, 0.2],
312 &[0.7, 0.2, 0.1],
313 );
314 let report =
315 compare_checkpoint_artifacts(actual, reference, CheckpointParityOptions::default())
316 .unwrap();
317 assert!(!report.passed);
318 assert!(report
319 .parity
320 .failures
321 .iter()
322 .any(|failure| failure.contains("input.token_ids")));
323 assert!(report
324 .parity
325 .failures
326 .iter()
327 .any(|failure| failure.contains("prefill.logits")));
328 }
329}