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 {
Hash,
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)
}
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,
#[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,
pub manifest_sha256: String,
#[serde(default)]
pub sample_set_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_sha256: Option<String>,
pub entries: Vec<ManifestEntry>,
}
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);
}
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()
}
#[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}"
);
assert!(
dataset_relative_path(&root, Path::new("images/a.png")).is_err(),
"a bare relative path has no defined meaning and must be rejected"
);
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);
}
#[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"
);
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"
);
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);
}
#[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"
);
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);
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);
}
#[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");
}
}