#![allow(dead_code)]
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VlmSample {
pub id: String,
pub kind: String,
pub image_path: String,
pub prompt: String,
pub answer: String,
pub is_negative: bool,
}
pub struct VlmDataset {
root: PathBuf,
}
impl VlmDataset {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let root = path.as_ref().to_path_buf();
std::fs::create_dir_all(root.join("images"))
.with_context(|| format!("creating dataset images dir under {}", root.display()))?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn add_sample(
&self,
kind: impl Into<String>,
prompt: impl Into<String>,
answer: impl Into<String>,
image_png: &[u8],
is_negative: bool,
) -> Result<VlmSample> {
let id = sha256_hex(image_png);
let image_filename = format!("{id}.png");
let image_path = self.root.join("images").join(&image_filename);
if !image_path.exists() {
std::fs::write(&image_path, image_png)
.with_context(|| format!("writing image {}", image_path.display()))?;
}
let sample = VlmSample {
id: id.clone(),
kind: kind.into(),
image_path: format!("images/{image_filename}"),
prompt: prompt.into(),
answer: answer.into(),
is_negative,
};
self.append_to_manifest(&sample)?;
Ok(sample)
}
pub fn load_all(&self) -> Result<Vec<VlmSample>> {
let path = self.manifest_path();
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let mut out = Vec::new();
for (i, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let sample: VlmSample = serde_json::from_str(line).with_context(|| {
format!("parsing manifest line {} of {}", i + 1, path.display())
})?;
out.push(sample);
}
Ok(out)
}
pub fn kind_counts(&self) -> Result<Vec<(String, usize)>> {
let samples = self.load_all()?;
let mut counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for s in samples {
*counts.entry(s.kind).or_insert(0) += 1;
}
let mut out: Vec<(String, usize)> = counts.into_iter().collect();
out.sort();
Ok(out)
}
pub fn manifest_path(&self) -> PathBuf {
self.root.join("manifest.jsonl")
}
fn append_to_manifest(&self, sample: &VlmSample) -> Result<()> {
use std::io::Write;
let mut line = serde_json::to_string(sample).context("serialising VLM sample")?;
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.manifest_path())
.with_context(|| {
format!("opening manifest {}", self.manifest_path().display())
})?;
file.write_all(line.as_bytes())
.context("writing manifest line")?;
Ok(())
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
digest.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn open_creates_images_dir() {
let tmp = tempdir().unwrap();
let _ds = VlmDataset::open(tmp.path()).unwrap();
assert!(tmp.path().join("images").exists());
}
#[test]
fn add_sample_writes_image_and_manifest_line() {
let tmp = tempdir().unwrap();
let ds = VlmDataset::open(tmp.path()).unwrap();
let sample = ds
.add_sample(
"recaptcha-v2-image-grid",
"select all squares with traffic lights",
r#"{"cells":[0,4,8]}"#,
b"PNGFAKEBYTES",
false,
)
.unwrap();
assert!(tmp.path().join(&sample.image_path).exists());
assert!(ds.manifest_path().exists());
let loaded = ds.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], sample);
}
#[test]
fn add_sample_dedupes_identical_image_to_single_file() {
let tmp = tempdir().unwrap();
let ds = VlmDataset::open(tmp.path()).unwrap();
let png = b"DUPLICATE_PNG_BYTES";
let s1 = ds
.add_sample("kind1", "prompt1", "answer1", png, false)
.unwrap();
let s2 = ds
.add_sample("kind2", "prompt2", "answer2", png, false)
.unwrap();
assert_eq!(s1.image_path, s2.image_path);
let loaded = ds.load_all().unwrap();
assert_eq!(loaded.len(), 2);
let images: Vec<_> = std::fs::read_dir(tmp.path().join("images"))
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(images.len(), 1);
}
#[test]
fn kind_counts_aggregates_correctly() {
let tmp = tempdir().unwrap();
let ds = VlmDataset::open(tmp.path()).unwrap();
for i in 0..5 {
ds.add_sample(
"image-grid",
format!("prompt {i}"),
"answer",
format!("PNG_GRID_{i}").as_bytes(),
false,
)
.unwrap();
}
for i in 0..3 {
ds.add_sample(
"icon-pick",
format!("prompt {i}"),
"answer",
format!("PNG_ICON_{i}").as_bytes(),
false,
)
.unwrap();
}
let counts = ds.kind_counts().unwrap();
assert_eq!(counts.len(), 2);
let map: std::collections::HashMap<_, _> = counts.into_iter().collect();
assert_eq!(map.get("image-grid"), Some(&5));
assert_eq!(map.get("icon-pick"), Some(&3));
}
#[test]
fn negative_samples_round_trip_with_marker() {
let tmp = tempdir().unwrap();
let ds = VlmDataset::open(tmp.path()).unwrap();
ds.add_sample("k", "p", "wrong-answer", b"X", true).unwrap();
let loaded = ds.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded[0].is_negative);
}
#[test]
fn load_all_returns_empty_for_fresh_dataset() {
let tmp = tempdir().unwrap();
let ds = VlmDataset::open(tmp.path()).unwrap();
assert!(ds.load_all().unwrap().is_empty());
}
#[test]
fn sha256_hex_matches_known_oracle() {
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}