rapid-structure-rs 1.0.0

Rust document-structure engine for PaddleOCR-family ONNX models: text detection/recognition (PP-OCRv6), table structure (SLANet-plus) and formula recognition (PP-FormulaNet) on Windows x64 via ONNX Runtime.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
//! 评测抽样与 manifest 稳定性。
//!
//! 公式主评测必须“测试顺序稳定、可重复生成相同 manifest/hash”,并且不能只报告
//! 对模型有利的样本。因此:
//!
//! - 抽样使用**数据集相对路径 + 真值的稳定哈希排序**,而不是“取前 N 张”、
//!   时间戳随机或绝对路径:同一数据集在任何机器、任何绝对路径下都选出同一子集;
//! - 图像**内容**摘要单独记录在 `content_sha256` / `ManifestEntry::image_sha256`,
//!   用于检测“路径与标签不变但文件被替换”。内容不参与样本选择,
//!   否则数据一改动就无法再用既有 manifest 判断“换的是内容还是选的样本”。
//! - manifest 记录每个样本的相对路径与真值 SHA-256,并给出整体哈希,
//!   重跑必须得到同一个哈希。

use serde::{Deserialize, Serialize};

use crate::evaluation::digest::digest_hex;
use crate::evaluation::formula::fixture::FormulaSample;

/// 抽样策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SampleStrategy {
    /// 按 `相对路径 + 真值` 的稳定哈希排序后取前 N 条:与文件系统顺序、绝对路径无关。
    Hash,
    /// 文件/标签的原始顺序取前 N 条(仅用于复现历史 smoke 结果)。
    First,
}

impl SampleStrategy {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Hash => "hash",
            Self::First => "first",
        }
    }
}

/// 抽样键:数据集相对路径 + 真值。
///
/// **不含绝对路径**,因此数据集被复制到别处不会改变样本选择。
/// 也不含图像内容,理由见模块文档。
fn sample_key(sample: &FormulaSample) -> String {
    format!("{}\u{1f}{}", sample.relative_path(), sample.ground_truth)
}

/// 选择评测样本;`limit == 0` 表示全量。
///
/// 返回值保持“评测执行顺序”,`Hash` 策略下即按哈希升序,因此顺序本身也是稳定的。
pub fn select_samples(
    samples: &[FormulaSample],
    strategy: SampleStrategy,
    limit: usize,
) -> Vec<&FormulaSample> {
    match strategy {
        SampleStrategy::First => samples
            .iter()
            .take(if limit == 0 { usize::MAX } else { limit })
            .collect(),
        SampleStrategy::Hash => {
            let mut keyed: Vec<(String, &FormulaSample)> = samples
                .iter()
                .map(|s| (digest_hex(sample_key(s).as_bytes()), s))
                .collect();
            keyed.sort_by(|a, b| a.0.cmp(&b.0));
            let take = if limit == 0 { usize::MAX } else { limit };
            keyed.into_iter().take(take).map(|(_, s)| s).collect()
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
    /// 相对数据集根目录的路径,使用 `/` 分隔,保证跨平台哈希一致。
    pub relative_path: String,
    pub ground_truth_sha256: String,
    pub has_ground_truth: bool,
    /// 图像文件内容的 SHA-256;文件不可读时为 `None`(不阻塞 manifest 生成)。
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub image_sha256: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
    pub dataset: String,
    pub split: String,
    pub subset: Option<String>,
    pub strategy: SampleStrategy,
    pub limit: usize,
    pub entry_count: usize,
    pub scored_count: usize,
    /// 样本选择摘要:数据集 / 切分 / 子集 / 抽样策略 / 数量 + 每个样本的
    /// `relative_path` 与真值摘要**(按评测顺序)**。
    ///
    /// 它只取决于“选了哪些样本、以什么顺序评测”,与图像文件内容无关。
    /// 注意它**包含顺序**:改变抽样键(例如从绝对路径改为相对路径)会改变该值,
    /// 即使选出的样本集合完全相同。判断“是不是同一批样本”应使用
    /// [`Manifest::sample_set_sha256`]。
    pub manifest_sha256: String,
    /// **顺序无关**的样本集合摘要:把 `relative_path` + 真值摘要排序后再哈希。
    ///
    /// 抽样键或执行顺序变化时该值不变,因此历史报告可以用它对齐到重新生成的
    /// manifest,从而区分“换了一批样本”和“只是换了顺序”。
    ///
    /// `default`:该字段是后来加入的,旧 manifest 里没有;读取旧报告时留空,
    /// 由合并逻辑给出「需要重新生成」的可定位错误,而不是反序列化失败。
    #[serde(default)]
    pub sample_set_sha256: String,
    /// 图像内容摘要:在样本集合摘要之上再纳入每个样本的图像文件 SHA-256
    /// (同样按 `relative_path` 排序,因此与评测顺序无关)。
    ///
    /// 路径与标签不变、但图像文件被替换时,`sample_set_sha256` 不变而本字段变化,
    /// 因此 `--expect-manifest` 能发现数据被改动。旧 manifest(本字段为 `None`)
    /// 仍可用来固定样本集合,只是不校验内容。
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_sha256: Option<String>,
    pub entries: Vec<ManifestEntry>,
}

/// 构建 manifest 并计算稳定哈希。
///
/// `relative_path` 直接取样本在加载时校验过的数据集相对路径(与抽样键同源):
/// manifest 与实际选择的样本不会分叉,也不需要再次剥离根目录。
///
/// 会为每个样本计算图像文件 SHA-256(`content_sha256` 用),文件不可读时该条目
/// 的图像摘要为 `None`,并且不写入 `content_sha256`(避免给出“已校验”的假象)。
pub fn build_manifest(
    dataset: &str,
    split: &str,
    subset: Option<&str>,
    strategy: SampleStrategy,
    limit: usize,
    samples: &[&FormulaSample],
) -> Manifest {
    let entries: Vec<ManifestEntry> = samples
        .iter()
        .map(|sample| ManifestEntry {
            relative_path: sample.relative_path().to_string(),
            ground_truth_sha256: digest_hex(sample.ground_truth.as_bytes()),
            has_ground_truth: sample.has_ground_truth(),
            image_sha256: crate::model_store::sha256_file(&sample.image_path).ok(),
        })
        .collect();

    let mut canonical = String::new();
    canonical.push_str(dataset);
    canonical.push('\u{1f}');
    canonical.push_str(split);
    canonical.push('\u{1f}');
    canonical.push_str(subset.unwrap_or(""));
    canonical.push('\u{1f}');
    canonical.push_str(strategy.as_str());
    canonical.push('\u{1f}');
    canonical.push_str(&limit.to_string());
    for entry in &entries {
        canonical.push('\u{1e}');
        canonical.push_str(&entry.relative_path);
        canonical.push('\u{1f}');
        canonical.push_str(&entry.ground_truth_sha256);
    }

    // 顺序无关的样本集合摘要:先按 `relative_path` 排序,再哈希。
    let mut sorted_paths: Vec<(&str, &str)> = entries
        .iter()
        .map(|entry| {
            (
                entry.relative_path.as_str(),
                entry.ground_truth_sha256.as_str(),
            )
        })
        .collect();
    sorted_paths.sort_unstable();
    let mut set_canonical = String::new();
    set_canonical.push_str(dataset);
    set_canonical.push('\u{1f}');
    set_canonical.push_str(split);
    set_canonical.push('\u{1f}');
    set_canonical.push_str(subset.unwrap_or(""));
    set_canonical.push('\u{1f}');
    set_canonical.push_str(strategy.as_str());
    set_canonical.push('\u{1f}');
    set_canonical.push_str(&limit.to_string());
    for (path, truth) in &sorted_paths {
        set_canonical.push('\u{1e}');
        set_canonical.push_str(path);
        set_canonical.push('\u{1f}');
        set_canonical.push_str(truth);
    }
    let sample_set_sha256 = digest_hex(set_canonical.as_bytes());

    // 内容摘要复用集合摘要,再按同一排序追加图像摘要,便于审查“只差图像内容”。
    let content_sha256 = entries
        .iter()
        .all(|entry| entry.image_sha256.is_some())
        .then(|| {
            let mut sorted_images: Vec<(&str, &str)> = entries
                .iter()
                .map(|entry| {
                    (
                        entry.relative_path.as_str(),
                        entry.image_sha256.as_deref().unwrap_or(""),
                    )
                })
                .collect();
            sorted_images.sort_unstable();
            let mut content = set_canonical.clone();
            for (path, image) in &sorted_images {
                content.push('\u{1e}');
                content.push_str(path);
                content.push('\u{1f}');
                content.push_str(image);
            }
            digest_hex(content.as_bytes())
        });

    Manifest {
        dataset: dataset.to_string(),
        split: split.to_string(),
        subset: subset.map(str::to_string),
        strategy,
        limit,
        entry_count: entries.len(),
        scored_count: entries
            .iter()
            .filter(|entry| entry.has_ground_truth)
            .count(),
        manifest_sha256: digest_hex(canonical.as_bytes()),
        sample_set_sha256,
        content_sha256,
        entries,
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use super::*;
    use crate::evaluation::formula::fixture::{
        FormulaDataset, FormulaSample, FormulaSplit, dataset_relative_path,
    };

    fn samples(count: usize) -> Vec<FormulaSample> {
        (0..count)
            .map(|index| {
                FormulaSample::new(
                    format!("image_{index}.png"),
                    PathBuf::from(format!("root/image_{index}.png")),
                    format!("x_{index}"),
                    FormulaDataset::Im2Latex,
                    FormulaSplit::Test,
                    Some(index),
                )
                .expect("sample")
            })
            .collect()
    }

    /// 数据集必须自包含:清单里指向根目录之外的图像必须**报错**,
    /// 而不是把绝对路径当成“相对路径”泄漏进抽样键与 manifest。
    #[test]
    fn dataset_relative_path_rejects_images_outside_the_root() {
        let root = std::env::temp_dir().join(format!(
            "rapid-structure-rs-rootcheck-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        let inner = root.join("images");
        std::fs::create_dir_all(&inner).expect("root");
        let inside = inner.join("a.png");
        std::fs::write(&inside, b"x").expect("write");

        // 根目录之内:得到 `/` 分隔的相对路径,且与根的写法无关。
        assert_eq!(
            dataset_relative_path(&root, &inside).expect("inside root"),
            "images/a.png"
        );
        assert_eq!(
            dataset_relative_path(&root.join("."), &inside).expect("normalized root"),
            "images/a.png"
        );

        // 根目录之外:必须失败,并且错误信息能定位到图像与数据集根。
        let outside = std::env::temp_dir().join("rapid-structure-rs-outside.png");
        std::fs::write(&outside, b"x").expect("write outside");
        let error = dataset_relative_path(&root, &outside)
            .expect_err("images outside the dataset root must be rejected");
        let message = error.to_string();
        assert!(
            message.contains("outside the dataset root"),
            "error: {message}"
        );

        // 调用方必须传入已 join 到根目录的绝对路径;裸相对路径在这里没有明确含义,
        // 因此同样被拒绝(而不是被猜测成“已经是相对路径”)。
        assert!(
            dataset_relative_path(&root, Path::new("images/a.png")).is_err(),
            "a bare relative path has no defined meaning and must be rejected"
        );

        // `FormulaSample::new` 拒绝绝对路径与空路径(双保险)。
        assert!(
            FormulaSample::new(
                "D:/elsewhere/a.png".to_string(),
                inside.clone(),
                "x".to_string(),
                FormulaDataset::Im2Latex,
                FormulaSplit::Test,
                None,
            )
            .is_err(),
            "absolute relative_path must be rejected"
        );
        assert!(
            FormulaSample::new(
                String::new(),
                inside,
                "x".to_string(),
                FormulaDataset::Im2Latex,
                FormulaSplit::Test,
                None,
            )
            .is_err(),
            "empty relative_path must be rejected"
        );

        let _ = std::fs::remove_dir_all(&root);
        let _ = std::fs::remove_file(&outside);
    }

    /// 词法前缀匹配会放过 `<root>/../outside/image.png`:前缀是根目录,剩下的
    /// `../outside/image.png` 既不是绝对路径也不为空,于是“相对路径”里带上了
    /// 越界成分。这条测试固定该回归必须被拒绝。
    #[test]
    fn dataset_relative_path_rejects_parent_dir_escapes() {
        let base =
            std::env::temp_dir().join(format!("rapid-structure-rs-escape-{}", std::process::id()));
        let root = base.join("dataset");
        let outside_dir = base.join("outside");
        std::fs::create_dir_all(root.join("images")).expect("root");
        std::fs::create_dir_all(&outside_dir).expect("outside");
        let inside = root.join("images").join("a.png");
        std::fs::write(&inside, b"x").expect("write inside");
        let outside = outside_dir.join("b.png");
        std::fs::write(&outside, b"y").expect("write outside");

        // 路径自身带 `.` 成分时也要规范化成同一个键。
        assert_eq!(
            dataset_relative_path(&root, &root.join("images").join(".").join("a.png"))
                .expect("normalized path"),
            "images/a.png"
        );

        // 词法上以 `<root>/` 开头,实际指向根目录之外。
        let escape = root.join("..").join("outside").join("b.png");
        // 先固定“旧实现为什么会放过它”:纯词法前缀比较是成功的。
        assert_eq!(
            escape.strip_prefix(&root).expect("lexical prefix matches"),
            Path::new("..").join("outside").join("b.png"),
            "the lexical prefix check succeeds, which is exactly why it is not enough"
        );
        let error =
            dataset_relative_path(&root, &escape).expect_err("`..` escape must be rejected");
        assert!(
            error.to_string().contains("outside the dataset root"),
            "error: {error}"
        );

        // 混合写法(先进入子目录再 `..` 出去)同样必须被拒绝。
        let mixed = root
            .join("images")
            .join("..")
            .join("..")
            .join("outside")
            .join("b.png");
        assert!(
            dataset_relative_path(&root, &mixed).is_err(),
            "mixed `..` escape must be rejected"
        );

        // `FormulaSample::new` 对 `..` / `.` 成分本身也要拒绝(词法双保险)。
        assert!(
            FormulaSample::new(
                "../outside/b.png".to_string(),
                escape,
                "x".to_string(),
                FormulaDataset::Im2Latex,
                FormulaSplit::Test,
                None,
            )
            .is_err(),
            "relative paths containing `..` must be rejected"
        );
        assert!(
            FormulaSample::new(
                "./images/a.png".to_string(),
                inside,
                "x".to_string(),
                FormulaDataset::Im2Latex,
                FormulaSplit::Test,
                None,
            )
            .is_err(),
            "relative paths containing `.` must be rejected"
        );

        let _ = std::fs::remove_dir_all(&base);
    }

    /// 指向数据集根目录之外的符号链接必须被拒绝(规范化会解析链接目标)。
    ///
    /// **环境要求**:Windows 上创建符号链接需要开启开发者模式或管理员权限。
    /// 未开启时本用例会打印一条环境说明并退化,**不**把缺少权限当成通过:
    /// 同一条不变式由 [`dataset_relative_path_rejects_parent_dir_escapes`] 无条件覆盖,
    /// 那条用例不依赖任何权限,因此始终执行。
    #[test]
    fn dataset_relative_path_rejects_symlinks_leading_outside() {
        let base =
            std::env::temp_dir().join(format!("rapid-structure-rs-link-{}", std::process::id()));
        let root = base.join("dataset");
        let outside_dir = base.join("outside");
        std::fs::create_dir_all(&root).expect("root");
        std::fs::create_dir_all(&outside_dir).expect("outside");
        let outside = outside_dir.join("b.png");
        std::fs::write(&outside, b"y").expect("write outside");

        let link = root.join("linked");
        if let Err(error) = std::os::windows::fs::symlink_dir(&outside_dir, &link) {
            eprintln!(
                "ENVIRONMENT: this Windows session cannot create directory symlinks ({error}); \
                 enable Developer Mode or run elevated to exercise this case. The `..` escape \
                 test covers the same invariant unconditionally."
            );
            let _ = std::fs::remove_dir_all(&base);
            return;
        }

        let escaped = link.join("b.png");
        let error = dataset_relative_path(&root, &escaped)
            .expect_err("a symlink escaping the dataset root must be rejected");
        assert!(
            error.to_string().contains("outside the dataset root"),
            "error: {error}"
        );

        // 链接位于根内且目标也在根内时,规范化后的相对路径使用**真实**位置,
        // 因此仍然是根内的稳定键。
        let inner_target = root.join("real");
        std::fs::create_dir_all(&inner_target).expect("real");
        let inner_file = inner_target.join("ok.png");
        std::fs::write(&inner_file, b"z").expect("write inner");
        let inner_link = root.join("inner-link");
        if std::os::windows::fs::symlink_dir(&inner_target, &inner_link).is_ok() {
            assert_eq!(
                dataset_relative_path(&root, &inner_link.join("ok.png")).expect("inside link"),
                "real/ok.png"
            );
        }

        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn hash_sampling_is_deterministic_and_independent_of_input_order() {
        let original = samples(50);
        let mut shuffled = original.clone();
        shuffled.reverse();

        let first = select_samples(&original, SampleStrategy::Hash, 10);
        let second = select_samples(&shuffled, SampleStrategy::Hash, 10);
        let first_paths: Vec<_> = first.iter().map(|s| s.image_path.clone()).collect();
        let second_paths: Vec<_> = second.iter().map(|s| s.image_path.clone()).collect();
        assert_eq!(
            first_paths, second_paths,
            "hash sampling must be order independent"
        );
        assert_eq!(first_paths.len(), 10);
    }

    /// 抽样键必须只依赖数据集相对路径与真值:把数据集搬到别的绝对路径下,
    /// 选出的样本与顺序都不能变(跨机器复现的前提)。
    #[test]
    fn hash_sampling_is_independent_of_the_absolute_root() {
        let relocate = |prefix: &str| -> Vec<FormulaSample> {
            (0..40)
                .map(|index| {
                    FormulaSample::new(
                        format!("images/image_{index}.png"),
                        PathBuf::from(format!("{prefix}/images/image_{index}.png")),
                        format!("x_{index}"),
                        FormulaDataset::Im2Latex,
                        FormulaSplit::Test,
                        Some(index),
                    )
                    .expect("sample")
                })
                .collect()
        };
        let original = relocate("D:/datasets/Formula-TestSet");
        let moved = relocate("C:/other/place/Formula-TestSet");

        let first = select_samples(&original, SampleStrategy::Hash, 12);
        let second = select_samples(&moved, SampleStrategy::Hash, 12);
        let first_keys: Vec<&str> = first.iter().map(|s| s.relative_path()).collect();
        let second_keys: Vec<&str> = second.iter().map(|s| s.relative_path()).collect();
        assert_eq!(
            first_keys, second_keys,
            "sample selection must not depend on the absolute dataset root"
        );

        // 全量抽样(limit = 0)下集合相同、顺序也必须相同。
        let all_first: Vec<&str> = select_samples(&original, SampleStrategy::Hash, 0)
            .iter()
            .map(|s| s.relative_path())
            .collect();
        let all_second: Vec<&str> = select_samples(&moved, SampleStrategy::Hash, 0)
            .iter()
            .map(|s| s.relative_path())
            .collect();
        assert_eq!(all_first, all_second);

        // manifest 也必须一致(相对路径同源,内容相同)。
        let selected_a: Vec<&FormulaSample> = select_samples(&original, SampleStrategy::Hash, 0);
        let selected_b: Vec<&FormulaSample> = select_samples(&moved, SampleStrategy::Hash, 0);
        let manifest_a = build_manifest(
            "im2latex",
            "test",
            None,
            SampleStrategy::Hash,
            0,
            &selected_a,
        );
        let manifest_b = build_manifest(
            "im2latex",
            "test",
            None,
            SampleStrategy::Hash,
            0,
            &selected_b,
        );
        assert_eq!(
            manifest_a.manifest_sha256, manifest_b.manifest_sha256,
            "manifest hash must be root independent"
        );
    }

    #[test]
    fn hash_sampling_is_not_first_n() {
        let all = samples(50);
        let hashed = select_samples(&all, SampleStrategy::Hash, 10);
        let first = select_samples(&all, SampleStrategy::First, 10);
        let hashed_paths: Vec<_> = hashed.iter().map(|s| s.image_path.clone()).collect();
        let first_paths: Vec<_> = first.iter().map(|s| s.image_path.clone()).collect();
        assert_ne!(
            hashed_paths, first_paths,
            "hash sampling must not degenerate into first-N selection"
        );
    }

    #[test]
    fn zero_limit_selects_everything() {
        let all = samples(20);
        assert_eq!(select_samples(&all, SampleStrategy::Hash, 0).len(), 20);
        assert_eq!(select_samples(&all, SampleStrategy::First, 0).len(), 20);
    }

    /// 图像内容变化必须改变 `content_sha256`,但**不**改变样本选择摘要。
    #[test]
    fn content_hash_detects_replaced_image_files() {
        let workspace = std::env::temp_dir().join(format!(
            "rapid-structure-rs-manifest-content-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&workspace);
        std::fs::create_dir_all(&workspace).expect("temp dir");
        let image = workspace.join("image.png");
        std::fs::write(&image, b"first-bytes").expect("write image");

        let sample = FormulaSample::new(
            "image.png".to_string(),
            image.clone(),
            "x".to_string(),
            FormulaDataset::Im2Latex,
            FormulaSplit::Test,
            Some(0),
        )
        .expect("sample");
        let selected = [&sample];
        let before = build_manifest("im2latex", "test", None, SampleStrategy::Hash, 0, &selected);
        assert!(
            before.content_sha256.is_some(),
            "readable images must produce a content digest"
        );
        assert_eq!(
            before.entries[0].image_sha256.as_deref(),
            before.content_sha256.as_deref().map(|_| before.entries[0]

                .image_sha256
                .as_deref()
                .expect("image hash"))
        );

        // 路径与标签不变,只替换文件内容。
        std::fs::write(&image, b"second-bytes").expect("rewrite image");
        let after = build_manifest("im2latex", "test", None, SampleStrategy::Hash, 0, &selected);

        assert_eq!(
            before.manifest_sha256, after.manifest_sha256,
            "replacing image bytes must not change the sample-selection digest"
        );
        assert_ne!(
            before.entries[0].image_sha256, after.entries[0].image_sha256,
            "the per-entry image digest must change"
        );
        assert_ne!(
            before.content_sha256, after.content_sha256,
            "the content digest must change so --expect-manifest can detect it"
        );

        let _ = std::fs::remove_dir_all(&workspace);
    }

    /// 图像不可读时不得伪造内容摘要。
    #[test]
    fn missing_images_do_not_produce_a_content_digest() {
        let sample = FormulaSample::new(
            "does-not-exist.png".to_string(),
            PathBuf::from("root/does-not-exist.png"),
            "x".to_string(),
            FormulaDataset::Im2Latex,
            FormulaSplit::Test,
            None,
        )
        .expect("sample");
        let manifest = build_manifest(
            "im2latex",
            "test",
            None,
            SampleStrategy::First,
            0,
            &[&sample],
        );
        assert!(manifest.entries[0].image_sha256.is_none());
        assert!(
            manifest.content_sha256.is_none(),
            "a manifest with unreadable images must not claim content verification"
        );
    }

    #[test]
    fn manifest_hash_is_stable_and_sensitive_to_content() {
        let all = samples(8);
        let selected = select_samples(&all, SampleStrategy::Hash, 0);
        let first = build_manifest("im2latex", "test", None, SampleStrategy::Hash, 0, &selected);
        let second = build_manifest("im2latex", "test", None, SampleStrategy::Hash, 0, &selected);
        assert_eq!(first.manifest_sha256, second.manifest_sha256);
        assert_eq!(first.entry_count, 8);
        let paths: std::collections::BTreeSet<&str> = first
            .entries
            .iter()
            .map(|entry| entry.relative_path.as_str())
            .collect();
        assert_eq!(
            paths.len(),
            8,
            "manifest must list every selected sample once"
        );
        assert!(
            paths.contains("image_0.png") && paths.contains("image_7.png"),
            "manifest must keep every selected sample: {paths:?}"
        );

        let mut mutated: Vec<FormulaSample> = selected.iter().map(|s| (*s).clone()).collect();
        mutated[0].ground_truth = "different".to_string();
        let mutated_refs: Vec<&FormulaSample> = mutated.iter().collect();
        let third = build_manifest(
            "im2latex",
            "test",
            None,
            SampleStrategy::Hash,
            0,
            &mutated_refs,
        );
        assert_ne!(
            first.manifest_sha256, third.manifest_sha256,
            "manifest hash must change when ground truth changes"
        );
    }

    #[test]
    fn manifest_uses_forward_slashes_on_windows_paths() {
        let sample = FormulaSample::new(
            "sub/image.png".to_string(),
            PathBuf::from(r"root\sub\image.png"),
            "x".to_string(),
            FormulaDataset::Im2Latex,
            FormulaSplit::Test,
            None,
        )
        .expect("sample");
        let manifest = build_manifest(
            "im2latex",
            "test",
            None,
            SampleStrategy::First,
            0,
            &[&sample],
        );
        assert_eq!(manifest.entries[0].relative_path, "sub/image.png");
    }
}