1use std::path::Path;
9
10use anyhow::{bail, Context, Result};
11use burn::tensor::{activation, Device, Tensor, TensorData};
12use zsfm_burn::linear::burn_linear_nd;
13use zsfm_burn::norm::burn_rms_norm_nd;
14use zsfm_burn::rope::{apply_llama_rope, rope_tables};
15use zsfm_burn::{open_gguf_weights, GgufWeights};
16
17use crate::config::Chronos2Config;
18
19#[derive(Clone, Debug)]
20pub struct InferConfig {
21 d_model: usize,
22 d_kv: usize,
23 d_ff: usize,
24 num_layers: usize,
25 num_heads: usize,
26 layer_norm_eps: f64,
27 rope_theta: f64,
28 patch_size: usize,
29 patch_stride: usize,
30 context_length: usize,
31 quantiles: Vec<f32>,
32 use_reg_token: bool,
33 use_arcsinh: bool,
34 time_encoding_scale: usize,
35 dense_act_fn: String,
36}
37
38impl From<&Chronos2Config> for InferConfig {
42 fn from(c2: &Chronos2Config) -> Self {
43 let cc = &c2.chronos_config;
44 InferConfig {
45 d_model: c2.d_model as usize,
46 d_kv: c2.d_kv as usize,
47 d_ff: c2.d_ff as usize,
48 num_layers: c2.num_layers as usize,
49 num_heads: c2.num_heads as usize,
50 layer_norm_eps: c2.layer_norm_epsilon,
51 rope_theta: c2.rope_theta,
52 patch_size: cc.input_patch_size as usize,
53 patch_stride: cc.input_patch_stride as usize,
54 context_length: cc.context_length as usize,
55 quantiles: cc.quantiles.clone(),
56 use_reg_token: cc.use_reg_token,
57 use_arcsinh: cc.use_arcsinh,
58 time_encoding_scale: c2.time_encoding_scale() as usize,
59 dense_act_fn: c2.dense_act_fn().to_string(),
60 }
61 }
62}
63
64impl InferConfig {
65 fn inner_dim(&self) -> usize {
66 self.num_heads * self.d_kv
67 }
68
69 pub fn patch_size(&self) -> usize {
70 self.patch_size
71 }
72 pub fn patch_stride(&self) -> usize {
73 self.patch_stride
74 }
75 pub fn context_length(&self) -> usize {
76 self.context_length
77 }
78 pub fn quantiles(&self) -> &[f32] {
79 &self.quantiles
80 }
81}
82
83fn device() -> Device {
84 Device::flex()
85}
86
87fn load_t(weights: &GgufWeights, name: &str) -> Result<Tensor<2>> {
88 weights.load_2d(name)
89}
90
91fn load_v(weights: &GgufWeights, name: &str) -> Result<Tensor<1>> {
92 weights.load_1d(name)
93}
94
95fn load_w(weights: &GgufWeights, name: &str, expected_d_out: usize) -> Result<Tensor<2>> {
96 weights.load_weight_2d(name, expected_d_out)
97}
98
99fn linear<const D: usize>(x: Tensor<D>, w: Tensor<2>, b: Option<Tensor<1>>) -> Tensor<D> {
100 burn_linear_nd(x, w, b)
101}
102
103fn rms<const D: usize>(x: Tensor<D>, w: Tensor<1>, eps: f64) -> Tensor<D> {
104 burn_rms_norm_nd(x, w, eps as f32)
105}
106
107fn softmax_last<const D: usize>(x: Tensor<D>) -> Tensor<D> {
108 activation::softmax(x, D - 1)
109}
110
111fn apply_act<const D: usize>(x: Tensor<D>, name: &str) -> Result<Tensor<D>> {
112 Ok(match name {
113 "relu" => activation::relu(x),
114 "gelu" => activation::gelu(x),
115 "gelu_new" | "gelu_pytorch_tanh" => activation::gelu_approximate(x),
116 "silu" | "swish" => activation::silu(x),
117 other => bail!("unsupported activation function: {other}"),
118 })
119}
120
121struct BurnResidualBlockW {
122 hidden_w: Tensor<2>,
123 hidden_b: Tensor<1>,
124 output_w: Tensor<2>,
125 output_b: Tensor<1>,
126 skip_w: Tensor<2>,
127 skip_b: Tensor<1>,
128}
129
130struct BurnAttnW {
131 qkv_w: Tensor<2>,
132 o_w: Tensor<2>,
133 norm_w: Tensor<1>,
134}
135
136struct BurnFfnW {
137 wi_w: Tensor<2>,
138 wo_w: Tensor<2>,
139 norm_w: Tensor<1>,
140}
141
142struct BurnBlockW {
143 time_attn: BurnAttnW,
144 group_attn: BurnAttnW,
145 ffn: BurnFfnW,
146}
147
148pub struct BurnChronosModel {
149 config: InferConfig,
150 rope_cos: Tensor<2>,
151 rope_sin: Tensor<2>,
152 token_embd: Tensor<2>,
153 input_patch: BurnResidualBlockW,
154 blocks: Vec<BurnBlockW>,
155 enc_norm: Tensor<1>,
156 output_patch: BurnResidualBlockW,
157}
158
159fn load_residual_block(
160 weights: &GgufWeights,
161 prefix: &str,
162 d_out_h: usize,
163 d_out: usize,
164) -> Result<BurnResidualBlockW> {
165 Ok(BurnResidualBlockW {
166 hidden_w: load_w(weights, &format!("{prefix}.hidden.weight"), d_out_h)?,
167 hidden_b: load_v(weights, &format!("{prefix}.hidden.bias"))?,
168 output_w: load_w(weights, &format!("{prefix}.output.weight"), d_out)?,
169 output_b: load_v(weights, &format!("{prefix}.output.bias"))?,
170 skip_w: load_w(weights, &format!("{prefix}.skip.weight"), d_out)?,
171 skip_b: load_v(weights, &format!("{prefix}.skip.bias"))?,
172 })
173}
174
175fn load_attn(
176 weights: &GgufWeights,
177 blk: usize,
178 kind: &str,
179 d_model: usize,
180 inner_dim: usize,
181) -> Result<BurnAttnW> {
182 let qw = load_w(weights, &format!("blk.{blk}.{kind}.q.weight"), inner_dim)?;
183 let kw = load_w(weights, &format!("blk.{blk}.{kind}.k.weight"), inner_dim)?;
184 let vw = load_w(weights, &format!("blk.{blk}.{kind}.v.weight"), inner_dim)?;
185 let qkv_w = Tensor::cat(vec![qw, kw, vw], 0);
186 Ok(BurnAttnW {
187 qkv_w,
188 o_w: load_w(weights, &format!("blk.{blk}.{kind}.o.weight"), d_model)?,
189 norm_w: load_v(weights, &format!("blk.{blk}.{kind}_norm.weight"))?,
190 })
191}
192
193impl BurnChronosModel {
194 pub fn patch_size(&self) -> usize {
195 self.config.patch_size()
196 }
197 pub fn patch_stride(&self) -> usize {
198 self.config.patch_stride()
199 }
200 pub fn context_length(&self) -> usize {
201 self.config.context_length()
202 }
203 pub fn quantiles(&self) -> &[f32] {
204 self.config.quantiles()
205 }
206
207 pub fn load(gguf_path: &Path, config: InferConfig) -> Result<Self> {
208 let weights = open_gguf_weights(gguf_path)
209 .with_context(|| format!("open {}", gguf_path.display()))?;
210
211 let d = config.d_model;
212 let ff = config.d_ff;
213 let id = config.num_heads * config.d_kv;
214 let ps = config.patch_size;
215 let nq = config.quantiles.len();
216
217 let token_embd = load_t(&weights, "token_embd.weight")?;
218 let input_patch = load_residual_block(&weights, "input_patch", ff, d)?;
219
220 let mut blocks = Vec::with_capacity(config.num_layers);
221 for n in 0..config.num_layers {
222 let time_attn = load_attn(&weights, n, "time_attn", d, id)?;
223 let group_attn = load_attn(&weights, n, "group_attn", d, id)?;
224 blocks.push(BurnBlockW {
225 time_attn,
226 group_attn,
227 ffn: BurnFfnW {
228 wi_w: load_w(&weights, &format!("blk.{n}.ffn.wi.weight"), ff)?,
229 wo_w: load_w(&weights, &format!("blk.{n}.ffn.wo.weight"), d)?,
230 norm_w: load_v(&weights, &format!("blk.{n}.ffn_norm.weight"))?,
231 },
232 });
233 }
234
235 let enc_norm = load_v(&weights, "enc_norm.weight")?;
236 let out_d = nq * ps;
237 let output_patch = load_residual_block(&weights, "output_patch", ff, out_d)?;
238
239 let (rope_cos, rope_sin) = rope_tables(config.d_kv, 8192, config.rope_theta, &device())?;
240 Ok(Self {
241 config,
242 rope_cos,
243 rope_sin,
244 token_embd,
245 input_patch,
246 blocks,
247 enc_norm,
248 output_patch,
249 })
250 }
251
252 pub fn forecast(&self, context: &[f32], prediction_length: usize) -> Result<Vec<Vec<f32>>> {
253 let cfg = &self.config;
254 let ps = cfg.patch_size;
255 let stride = cfg.patch_stride;
256 let n_quantiles = cfg.quantiles.len();
257 let n_out_patches = prediction_length.div_ceil(ps);
258
259 let (normalized, loc, scale) = instance_norm(context, cfg.use_arcsinh);
260 let padded = pad_for_patching(&normalized, ps);
261 let n_ctx_patches = (padded.len() - ps) / stride + 1;
262
263 let ctx_features = build_patch_features(
264 &padded,
265 n_ctx_patches,
266 ps,
267 stride,
268 -((n_ctx_patches * ps) as f32),
269 cfg.time_encoding_scale as f32,
270 true,
271 );
272 let fut_features =
273 build_patch_features_future(n_out_patches, ps, 0.0, cfg.time_encoding_scale as f32);
274
275 let dev = device();
276 let ctx_tensor =
277 Tensor::<2>::from_data(TensorData::new(ctx_features, [n_ctx_patches, 3 * ps]), &dev);
278 let fut_tensor =
279 Tensor::<2>::from_data(TensorData::new(fut_features, [n_out_patches, 3 * ps]), &dev);
280 let ctx_embeds = self.forward_residual_block(ctx_tensor, &self.input_patch)?;
281 let fut_embeds = self.forward_residual_block(fut_tensor, &self.input_patch)?;
282
283 let seq = if cfg.use_reg_token {
284 let reg: Tensor<2> = self.token_embd.clone().narrow(0, 1, 1);
285 Tensor::cat(vec![ctx_embeds, reg, fut_embeds], 0)
286 } else {
287 Tensor::cat(vec![ctx_embeds, fut_embeds], 0)
288 };
289 let total_seq = seq.dims()[0];
290 let hidden = seq.unsqueeze_dim::<3>(0);
291 let hidden = self.forward_encoder(hidden, total_seq)?;
292 let forecast_embeds: Tensor<3> = hidden.narrow(1, total_seq - n_out_patches, n_out_patches);
293 let forecast_embeds: Tensor<2> = forecast_embeds.squeeze_dim(0);
294 let quantile_raw = self.forward_residual_block(forecast_embeds, &self.output_patch)?;
295
296 let total_out = n_out_patches * ps;
297 let qmat: Tensor<3> = quantile_raw.reshape([n_out_patches, n_quantiles, ps]);
298 let qmat: Tensor<3> = qmat.permute([1, 0, 2]);
299 let qmat: Tensor<2> = qmat.reshape([n_quantiles, total_out]);
300 let qmat: Tensor<2> = if total_out > prediction_length {
301 qmat.narrow(1, 0, prediction_length)
302 } else {
303 qmat
304 };
305 let data: Vec<f32> = qmat
306 .to_data()
307 .try_to_vec()
308 .map_err(|e| anyhow::anyhow!("{e:?}"))?;
309 let mut result = vec![vec![0.0f32; prediction_length]; n_quantiles];
310 for q in 0..n_quantiles {
311 for t in 0..prediction_length {
312 let v = data[q * prediction_length + t] as f64;
313 let v = if cfg.use_arcsinh { v.sinh() } else { v };
314 result[q][t] = (v as f32) * scale + loc;
315 }
316 }
317 Ok(result)
318 }
319
320 fn forward_encoder(&self, mut x: Tensor<3>, seq_len: usize) -> Result<Tensor<3>> {
321 for blk in &self.blocks {
322 x = self.forward_block(x, blk, seq_len)?;
323 }
324 Ok(rms(x, self.enc_norm.clone(), self.config.layer_norm_eps))
325 }
326
327 fn forward_block(&self, x: Tensor<3>, blk: &BurnBlockW, seq_len: usize) -> Result<Tensor<3>> {
328 let normed = rms(
329 x.clone(),
330 blk.time_attn.norm_w.clone(),
331 self.config.layer_norm_eps,
332 );
333 let attn_out = self.forward_time_attn(normed, &blk.time_attn, seq_len);
334 let x = x + attn_out;
335 let normed = rms(
336 x.clone(),
337 blk.group_attn.norm_w.clone(),
338 self.config.layer_norm_eps,
339 );
340 let grp_out = self.forward_group_attn_univariate(normed, &blk.group_attn);
341 let x = x + grp_out;
342 let normed = rms(
343 x.clone(),
344 blk.ffn.norm_w.clone(),
345 self.config.layer_norm_eps,
346 );
347 let ffn_out = self.forward_ffn(normed, &blk.ffn)?;
348 Ok(x + ffn_out)
349 }
350
351 fn forward_time_attn(&self, x: Tensor<3>, w: &BurnAttnW, seq_len: usize) -> Tensor<3> {
352 let id = self.config.num_heads * self.config.d_kv;
353 let nh = self.config.num_heads;
354 let dkv = self.config.d_kv;
355 let qkv = linear(x, w.qkv_w.clone(), None);
356 let q: Tensor<3> = qkv.clone().narrow(2, 0, id);
357 let k: Tensor<3> = qkv.clone().narrow(2, id, id);
358 let v: Tensor<3> = qkv.narrow(2, 2 * id, id);
359 let pack = |t: Tensor<3>| t.reshape([1, seq_len, nh, dkv]).permute([0, 2, 1, 3]);
360 let (q, k, v) = (pack(q), pack(k), pack(v));
361 let q = apply_llama_rope(q, self.rope_cos.clone(), self.rope_sin.clone(), 0);
362 let k = apply_llama_rope(k, self.rope_cos.clone(), self.rope_sin.clone(), 0);
363 let scores = q.matmul(k.transpose());
364 let attn = softmax_last(scores);
365 let out = attn.matmul(v);
366 let out: Tensor<3> = out.permute([0, 2, 1, 3]).reshape([1, seq_len, id]);
367 linear(out, w.o_w.clone(), None)
368 }
369
370 fn forward_group_attn_univariate(&self, x: Tensor<3>, w: &BurnAttnW) -> Tensor<3> {
371 let id = self.config.num_heads * self.config.d_kv;
372 let qkv = linear(x, w.qkv_w.clone(), None);
373 let v: Tensor<3> = qkv.narrow(2, 2 * id, id);
374 linear(v, w.o_w.clone(), None)
375 }
376
377 fn forward_ffn(&self, x: Tensor<3>, w: &BurnFfnW) -> Result<Tensor<3>> {
378 let h = linear(x, w.wi_w.clone(), None);
379 let h = apply_act(h, &self.config.dense_act_fn)?;
380 Ok(linear(h, w.wo_w.clone(), None))
381 }
382
383 fn forward_residual_block(&self, x: Tensor<2>, w: &BurnResidualBlockW) -> Result<Tensor<2>> {
384 let h = linear(x.clone(), w.hidden_w.clone(), Some(w.hidden_b.clone()));
385 let h = apply_act(h, &self.config.dense_act_fn)?;
386 let out = linear(h, w.output_w.clone(), Some(w.output_b.clone()));
387 let skip = linear(x, w.skip_w.clone(), Some(w.skip_b.clone()));
388 Ok(out + skip)
389 }
390}
391
392fn instance_norm(x: &[f32], use_arcsinh: bool) -> (Vec<f32>, f32, f32) {
393 let n = x.len() as f64;
394 let loc = x.iter().map(|&v| v as f64).sum::<f64>() / n;
395 let var = x.iter().map(|&v| (v as f64 - loc).powi(2)).sum::<f64>() / n;
396 let scale = (var.sqrt() as f32).max(1e-5);
397 let mut out: Vec<f32> = x.iter().map(|&v| (v as f64 - loc) as f32 / scale).collect();
398 if use_arcsinh {
399 for v in &mut out {
400 *v = (*v as f64).asinh() as f32;
401 }
402 }
403 (out, loc as f32, scale)
404}
405
406fn pad_for_patching(x: &[f32], patch_size: usize) -> Vec<f32> {
407 let rem = x.len() % patch_size;
408 if rem == 0 {
409 x.to_vec()
410 } else {
411 let pad_len = patch_size - rem;
412 let mut out = vec![f32::NAN; pad_len];
413 out.extend_from_slice(x);
414 out
415 }
416}
417
418fn build_patch_features(
419 padded: &[f32],
420 n_patches: usize,
421 patch_size: usize,
422 stride: usize,
423 time_start: f32,
424 time_scale: f32,
425 observed: bool,
426) -> Vec<f32> {
427 let feature_dim = 3 * patch_size;
428 let mut out = vec![0.0f32; n_patches * feature_dim];
429 for p in 0..n_patches {
430 let offset = p * stride;
431 let base = p * feature_dim;
432 for i in 0..patch_size {
433 let t = offset + i;
434 let global_t = time_start + t as f32;
435 out[base + i] = global_t / time_scale;
436 let v = padded.get(t).copied().unwrap_or(0.0);
437 let is_obs = observed && v.is_finite();
438 out[base + patch_size + i] = if is_obs { v } else { 0.0 };
439 out[base + 2 * patch_size + i] = if is_obs { 1.0 } else { 0.0 };
440 }
441 }
442 out
443}
444
445fn build_patch_features_future(
446 n_patches: usize,
447 patch_size: usize,
448 time_start: f32,
449 time_scale: f32,
450) -> Vec<f32> {
451 let feature_dim = 3 * patch_size;
452 let mut out = vec![0.0f32; n_patches * feature_dim];
453 for p in 0..n_patches {
454 let offset = p * patch_size;
455 let base = p * feature_dim;
456 for i in 0..patch_size {
457 let global_t = time_start + (offset + i) as f32;
458 out[base + i] = global_t / time_scale;
459 }
460 }
461 out
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::config::{Chronos2Config, ChronosInnerConfig};
468 use zsfm_gguf::{GGMLType, GGUFMetaValue, GGUFWriter};
469
470 fn pseudo(n: usize, seed: u64) -> Vec<f32> {
471 let mut x = seed;
472 (0..n)
473 .map(|_| {
474 x = x
475 .wrapping_mul(6364136223846793005)
476 .wrapping_add(1442695040888963407);
477 ((x >> 33) as f32 / u32::MAX as f32) * 2.0 - 1.0
478 })
479 .collect()
480 }
481
482 fn tiny_config() -> InferConfig {
483 let c2 = Chronos2Config {
484 d_model: 16,
485 d_kv: 8,
486 d_ff: 32,
487 num_layers: 1,
488 num_heads: 2,
489 layer_norm_epsilon: 1e-6,
490 rope_theta: 10000.0,
491 feed_forward_proj: "relu".into(),
492 chronos_config: ChronosInnerConfig {
493 context_length: 64,
494 input_patch_size: 4,
495 output_patch_size: 4,
496 input_patch_stride: 2,
497 quantiles: vec![0.1, 0.5, 0.9],
498 use_reg_token: false,
499 use_arcsinh: false,
500 max_output_patches: 1,
501 time_encoding_scale: None,
502 },
503 };
504 InferConfig::from(&c2)
505 }
506
507 fn put2(w: &mut GGUFWriter, name: &str, rows: usize, cols: usize, seed: &mut u64, scale: f32) {
508 *seed += 1;
509 let data: Vec<f32> = pseudo(rows * cols, *seed)
510 .iter()
511 .map(|v| v * scale)
512 .collect();
513 let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
514 w.add_tensor(name, vec![cols as u64, rows as u64], GGMLType::F32, bytes);
515 }
516
517 fn put1(w: &mut GGUFWriter, name: &str, n: usize, seed: &mut u64) {
518 *seed += 1;
519 let bytes: Vec<u8> = pseudo(n, *seed)
520 .iter()
521 .flat_map(|v| v.to_le_bytes())
522 .collect();
523 w.add_tensor(name, vec![n as u64], GGMLType::F32, bytes);
524 }
525
526 fn put_res(
527 w: &mut GGUFWriter,
528 prefix: &str,
529 h: usize,
530 d_in: usize,
531 d_out: usize,
532 seed: &mut u64,
533 s: f32,
534 ) {
535 put2(w, &format!("{prefix}.hidden.weight"), h, d_in, seed, s);
536 put1(w, &format!("{prefix}.hidden.bias"), h, seed);
537 put2(w, &format!("{prefix}.output.weight"), d_out, h, seed, s);
538 put1(w, &format!("{prefix}.output.bias"), d_out, seed);
539 put2(w, &format!("{prefix}.skip.weight"), d_out, d_in, seed, s);
540 put1(w, &format!("{prefix}.skip.bias"), d_out, seed);
541 }
542
543 fn put_attn(
544 w: &mut GGUFWriter,
545 blk: usize,
546 kind: &str,
547 d: usize,
548 id: usize,
549 seed: &mut u64,
550 s: f32,
551 ) {
552 for t in ["q", "k", "v"] {
553 put2(w, &format!("blk.{blk}.{kind}.{t}.weight"), id, d, seed, s);
554 }
555 put2(w, &format!("blk.{blk}.{kind}.o.weight"), d, id, seed, s);
556 put1(w, &format!("blk.{blk}.{kind}_norm.weight"), d, seed);
557 }
558
559 #[test]
560 fn burn_forecast_golden() {
561 let dir = std::env::temp_dir().join(format!("chronos-burn-{}", std::process::id()));
563 let _ = std::fs::remove_dir_all(&dir);
564 std::fs::create_dir_all(&dir).unwrap();
565 let path = dir.join("model.gguf");
566 let mut w = GGUFWriter::new();
567 w.add_metadata(
568 "general.architecture",
569 GGUFMetaValue::String("chronos".into()),
570 );
571 let mut seed = 900u64;
572 let s = 0.2;
573 put2(&mut w, "token_embd.weight", 4, 16, &mut seed, s);
574 put_res(&mut w, "input_patch", 32, 12, 16, &mut seed, s);
575 put_attn(&mut w, 0, "time_attn", 16, 16, &mut seed, s);
576 put_attn(&mut w, 0, "group_attn", 16, 16, &mut seed, s);
577 put2(&mut w, "blk.0.ffn.wi.weight", 32, 16, &mut seed, s);
578 put2(&mut w, "blk.0.ffn.wo.weight", 16, 32, &mut seed, s);
579 put1(&mut w, "blk.0.ffn_norm.weight", 16, &mut seed);
580 put1(&mut w, "enc_norm.weight", 16, &mut seed);
581 put_res(&mut w, "output_patch", 32, 16, 12, &mut seed, s);
582 let f = std::fs::File::create(&path).unwrap();
583 w.write_to(&mut std::io::BufWriter::new(f)).unwrap();
584
585 let cfg = tiny_config();
586 let burn = BurnChronosModel::load(&path, cfg).unwrap();
587 let ctx: Vec<f32> = (0..8).map(|i| 10.0 + 0.5 * i as f32).collect();
588 let t0 = std::time::Instant::now();
589 let a = burn.forecast(&ctx, 4).unwrap();
590 let ms_a = t0.elapsed().as_secs_f64() * 1000.0;
591 let b = burn.forecast(&ctx, 4).unwrap();
592 assert_eq!(a.len(), 3);
593 for row in &a {
594 assert_eq!(row.len(), 4);
595 }
596 assert!(a.iter().flatten().all(|v| v.is_finite()));
597 assert!(a.iter().flatten().any(|&v| v != 0.0));
598 assert_eq!(a, b, "forecast must be deterministic");
599 println!("chronos synthetic: burn {ms_a:.3}ms out={a:?}");
600 let golden: [[f32; 4]; 3] = [
602 [9.894369, 8.8220005, 9.853922, 9.345992],
603 [9.070834, 9.884812, 9.241523, 9.49992],
604 [9.611428, 10.309262, 10.598283, 9.180033],
605 ];
606 assert_eq!(a.len(), golden.len());
607 for (row, grow) in a.iter().zip(golden.iter()) {
608 assert_eq!(row.len(), grow.len());
609 for (x, y) in row.iter().zip(grow.iter()) {
610 assert!((x - y).abs() < 1e-3, "golden drift: {x} vs {y}");
611 }
612 }
613 let _ = std::fs::remove_dir_all(&dir);
614 }
615}