use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::draw::seeded_shuffle;
use super::merges::{resolve_merges, scale_out_merges};
use super::records::{SampleRecord, StrataSummary, Stratum, SubsampleOrigin};
use super::sample::{write_json, write_jsonl, write_labels};
use super::score::{read_sample, read_strata};
use super::{io_err, EvalError, Result};
#[derive(Debug, Clone)]
pub struct SubsampleParams {
pub from: PathBuf,
pub strata: Option<PathBuf>,
pub size: usize,
pub seed: u64,
pub db: Option<PathBuf>,
pub out: PathBuf,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct SubsampleSummary {
pub strata: StrataSummary,
pub files: Vec<PathBuf>,
}
pub fn largest_remainder(size: usize, counts: &[usize]) -> Vec<usize> {
let total: u128 = counts.iter().map(|&c| c as u128).sum();
if total == 0 {
return vec![0; counts.len()];
}
let size = size.min(total as usize);
let quota = |c: usize| {
(
size as u128 * c as u128 / total,
size as u128 * c as u128 % total,
)
};
let mut alloc: Vec<usize> = counts.iter().map(|&c| quota(c).0 as usize).collect();
let mut order: Vec<usize> = (0..counts.len()).collect();
order.sort_by(|&a, &b| quota(counts[b]).1.cmp("a(counts[a]).1).then(a.cmp(&b)));
let left = size - alloc.iter().sum::<usize>();
for &i in order.iter().take(left) {
alloc[i] += 1;
}
debug_assert_eq!(alloc.iter().sum::<usize>(), size);
debug_assert!(alloc.iter().zip(counts).all(|(a, c)| a <= c));
alloc
}
fn subsample_seed(seed: u64, stratum: Stratum) -> u64 {
let mut h = blake3::Hasher::new();
h.update(b"tga-eval-subsample\0");
h.update(&seed.to_le_bytes());
h.update(stratum.as_str().as_bytes());
let mut first = [0u8; 8];
first.copy_from_slice(&h.finalize().as_bytes()[..8]);
u64::from_le_bytes(first)
}
pub fn run_subsample(params: &SubsampleParams) -> Result<SubsampleSummary> {
let all = read_sample(¶ms.from)?;
let merge_flags = resolve_merges(&all, params.db.as_deref())?;
let strata_path = params.strata.clone().unwrap_or_else(|| {
params
.from
.parent()
.unwrap_or(Path::new("."))
.join("strata.json")
});
let mut source_strata = read_strata(&strata_path)?;
let _estimated = scale_out_merges(&mut source_strata, &all, &merge_flags);
let source: Vec<SampleRecord> = all
.into_iter()
.zip(merge_flags)
.filter(|&(_, m)| !m)
.map(|(mut r, _)| {
r.is_merge = Some(false);
r
})
.collect();
if params.size == 0 || params.size > source.len() {
return Err(EvalError::Invalid(format!(
"--size must be between 1 and the {} non-merge rows of {}",
source.len(),
params.from.display()
)));
}
let mut seen = HashSet::new();
if let Some(dup) = source.iter().find(|r| !seen.insert(r.sha.as_str())) {
return Err(EvalError::Invalid(format!(
"{} lists {} twice",
params.from.display(),
dup.sha
)));
}
let mut by_stratum: BTreeMap<Stratum, Vec<usize>> = BTreeMap::new();
for (i, r) in source.iter().enumerate() {
by_stratum.entry(r.stratum).or_default().push(i);
}
for stratum in by_stratum.keys() {
if source_strata.population_of(*stratum) == 0 {
return Err(EvalError::Invalid(format!(
"{} has no population for stratum {}; pass the strata.json written with this sample",
strata_path.display(),
stratum.as_str()
)));
}
}
let counts: Vec<usize> = Stratum::ALL
.iter()
.map(|s| by_stratum.get(s).map_or(0, Vec::len))
.collect();
let alloc = largest_remainder(params.size, &counts);
let mut keep = vec![false; source.len()];
let mut taken: BTreeMap<Stratum, usize> = BTreeMap::new();
for (stratum, k) in Stratum::ALL.into_iter().zip(alloc) {
let Some(rows) = by_stratum.get_mut(&stratum) else {
continue;
};
rows.sort_by(|a, b| source[*a].sha.cmp(&source[*b].sha));
seeded_shuffle(rows, subsample_seed(params.seed, stratum));
for &i in rows.iter().take(k) {
keep[i] = true;
}
taken.insert(stratum, k);
}
let records: Vec<_> = source
.iter()
.zip(&keep)
.filter(|(_, k)| **k)
.map(|(r, _)| {
let mut r = r.clone();
let n = taken.get(&r.stratum).copied().unwrap_or(1).max(1) as f64;
r.weight = source_strata.population_of(r.stratum) as f64 / n;
r
})
.collect();
let mut strata = source_strata;
for (key, counts) in strata.strata.iter_mut() {
counts.sampled = Stratum::ALL
.iter()
.find(|s| s.as_str() == key)
.and_then(|s| taken.get(s))
.copied()
.unwrap_or(0) as u64;
}
strata.subsample = Some(SubsampleOrigin {
seed: params.seed,
size: records.len() as u64,
source_size: source.len() as u64,
});
let files = ["sample.jsonl", "labels.csv", "strata.json"].map(|f| params.out.join(f));
if let Some(existing) = files.iter().find(|f| f.exists()) {
return Err(already_exists(existing));
}
create_private_dir(¶ms.out)?;
for f in &files {
create_private_file(f)?;
}
let salt = format!("tga-eval-subsample:{}", params.seed);
write_jsonl(&files[0], &records)?;
write_labels(&files[1], &records, &salt)?;
write_json(&files[2], &strata)?;
Ok(SubsampleSummary {
strata,
files: files.to_vec(),
})
}
pub(crate) fn create_private_dir(dir: &Path) -> Result<()> {
if dir.exists() {
return Ok(());
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir).map_err(io_err(dir))
}
pub(crate) fn create_private_file(path: &Path) -> Result<()> {
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
match opts.open(path) {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(already_exists(path)),
Err(e) => Err(io_err(path)(e)),
}
}
fn already_exists(path: &Path) -> EvalError {
EvalError::Invalid(format!(
"{} already exists; pick an empty --out",
path.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn largest_remainder_hits_the_exact_total() {
assert_eq!(
largest_remainder(100, &[212, 85, 37, 66]),
vec![53, 21, 9, 17]
);
assert_eq!(largest_remainder(2, &[1, 1, 1]), vec![1, 1, 0]);
assert_eq!(largest_remainder(10, &[5, 5, 5, 5]), vec![3, 3, 2, 2]);
assert_eq!(largest_remainder(7, &[0, 10, 0, 4]), vec![0, 5, 0, 2]);
assert_eq!(largest_remainder(14, &[0, 10, 0, 4]), vec![0, 10, 0, 4]);
}
}