use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const SCHEMA: u32 = 2;
pub const MANIFEST: &str = "manifest.json";
pub const VOCABULARY: &str = "vocabulary.json";
pub const GAZETTEER: &str = "gazetteer.json";
pub const RELATIONS: &str = "relations.json";
pub const TRAINING_DIR: &str = "training";
pub const TRAINING_FILE: &str = "spans.jsonl";
pub const IGNORE_FILE: &str = ".gitignore";
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Registration {
pub surface: String,
pub token: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CategoryRecord {
pub name: String,
pub words: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Artifacts {
pub schema: u32,
pub producer: String,
pub created: String,
pub categories: Vec<CategoryRecord>,
pub gazetteer: Vec<Registration>,
pub relations: Vec<String>,
pub digests: BTreeMap<String, usize>,
#[serde(default)]
pub training_examples: usize,
#[serde(default)]
pub contains_document_text: Vec<String>,
#[serde(skip)]
training: Option<String>,
}
#[derive(Debug)]
pub enum ArtifactError {
Io(std::io::Error),
NotAnArtifactDir(PathBuf),
SchemaMismatch { found: u32, expected: u32 },
Corrupt(String),
Parse(String),
}
impl std::fmt::Display for ArtifactError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ArtifactError::Io(e) => write!(f, "{e}"),
ArtifactError::NotAnArtifactDir(p) => write!(
f,
"{} is not an artefact directory (no {MANIFEST}); run save() first",
p.display()
),
ArtifactError::SchemaMismatch { found, expected } => write!(
f,
"artefact schema {found} cannot be read by this build (expects {expected}); re-run learn"
),
ArtifactError::Corrupt(what) => write!(f, "artefact set is incomplete: {what}"),
ArtifactError::Parse(e) => write!(f, "could not parse artefact: {e}"),
}
}
}
impl std::error::Error for ArtifactError {}
impl From<std::io::Error> for ArtifactError {
fn from(e: std::io::Error) -> Self {
ArtifactError::Io(e)
}
}
impl Artifacts {
pub fn new(
producer: impl Into<String>,
categories: Vec<CategoryRecord>,
gazetteer: Vec<Registration>,
relations: Vec<String>,
) -> Artifacts {
Artifacts {
schema: SCHEMA,
producer: producer.into(),
created: now_rfc3339(),
categories,
gazetteer,
relations,
digests: BTreeMap::new(),
training_examples: 0,
contains_document_text: Vec::new(),
training: None,
}
}
pub fn with_training(mut self, jsonl: impl Into<String>) -> Artifacts {
let jsonl = jsonl.into();
self.training_examples = jsonl.lines().filter(|l| !l.trim().is_empty()).count();
self.contains_document_text = vec![format!("{TRAINING_DIR}/")];
self.training = Some(jsonl);
self
}
pub fn training(&self) -> Option<&str> {
self.training.as_deref()
}
pub fn save(&self, dir: impl AsRef<Path>) -> Result<(), ArtifactError> {
let dir = dir.as_ref();
std::fs::create_dir_all(dir)?;
let vocab = serde_json::to_vec_pretty(&self.categories).map_err(|e| ArtifactError::Parse(e.to_string()))?;
let gaz = serde_json::to_vec_pretty(&self.gazetteer).map_err(|e| ArtifactError::Parse(e.to_string()))?;
let rel = serde_json::to_vec_pretty(&self.relations).map_err(|e| ArtifactError::Parse(e.to_string()))?;
let mut manifest = self.clone();
manifest.digests.clear();
manifest.digests.insert(VOCABULARY.into(), vocab.len());
manifest.digests.insert(GAZETTEER.into(), gaz.len());
manifest.digests.insert(RELATIONS.into(), rel.len());
let mut slim = manifest.clone();
slim.training = None;
slim.categories = Vec::new();
slim.gazetteer = Vec::new();
slim.relations = Vec::new();
std::fs::write(
dir.join(IGNORE_FILE),
format!(
"# Written by hypersteeldb. The finetuning set under {TRAINING_DIR}/ contains verbatim document\n\
# text, because a span label is meaningless without the words it points at. Everything else in\n\
# this directory is derived vocabulary and is safe to commit.\n\
{TRAINING_DIR}/\n"
),
)?;
if let Some(jsonl) = &self.training {
let tdir = dir.join(TRAINING_DIR);
std::fs::create_dir_all(&tdir)?;
std::fs::write(tdir.join(TRAINING_FILE), jsonl.as_bytes())?;
}
std::fs::write(dir.join(VOCABULARY), &vocab)?;
std::fs::write(dir.join(GAZETTEER), &gaz)?;
std::fs::write(dir.join(RELATIONS), &rel)?;
std::fs::write(
dir.join(MANIFEST),
serde_json::to_vec_pretty(&slim).map_err(|e| ArtifactError::Parse(e.to_string()))?,
)?;
Ok(())
}
pub fn load(dir: impl AsRef<Path>) -> Result<Artifacts, ArtifactError> {
let dir = dir.as_ref();
let mpath = dir.join(MANIFEST);
if !mpath.exists() {
return Err(ArtifactError::NotAnArtifactDir(dir.to_path_buf()));
}
let mut set: Artifacts = serde_json::from_slice(&std::fs::read(&mpath)?)
.map_err(|e| ArtifactError::Parse(e.to_string()))?;
if set.schema != SCHEMA {
return Err(ArtifactError::SchemaMismatch { found: set.schema, expected: SCHEMA });
}
let vocab = std::fs::read(dir.join(VOCABULARY))?;
let gaz = std::fs::read(dir.join(GAZETTEER))?;
let rel = std::fs::read(dir.join(RELATIONS))?;
for (name, actual) in [(VOCABULARY, vocab.len()), (GAZETTEER, gaz.len()), (RELATIONS, rel.len())] {
if let Some(expected) = set.digests.get(name) {
if *expected != actual {
return Err(ArtifactError::Corrupt(format!(
"{name} is {actual} bytes, manifest says {expected}"
)));
}
}
}
set.categories = serde_json::from_slice(&vocab).map_err(|e| ArtifactError::Parse(e.to_string()))?;
set.gazetteer = serde_json::from_slice(&gaz).map_err(|e| ArtifactError::Parse(e.to_string()))?;
set.relations = serde_json::from_slice(&rel).map_err(|e| ArtifactError::Parse(e.to_string()))?;
let tpath = dir.join(TRAINING_DIR).join(TRAINING_FILE);
set.training = std::fs::read_to_string(&tpath).ok();
Ok(set)
}
pub fn exists(dir: impl AsRef<Path>) -> bool {
dir.as_ref().join(MANIFEST).exists()
}
}
fn now_rfc3339() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) else { return String::new() };
let secs = d.as_secs() as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d_ = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d_:02}T{:02}:{:02}:{:02}Z",
tod / 3600,
(tod % 3600) / 60,
tod % 60
)
}
#[cfg(target_arch = "wasm32")]
{
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("hsdb_artifact_{}_{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
p
}
fn sample() -> Artifacts {
Artifacts::new(
"discovery",
vec![
CategoryRecord { name: "battle".into(), words: vec!["defeated".into(), "faced".into()] },
CategoryRecord { name: "survey".into(), words: vec!["elevation".into()] },
],
vec![
Registration { surface: "Sootopolis City".into(), token: "entity/sootopolis-city".into() },
Registration { surface: "Indigo Invitational".into(), token: "entity/indigo-invitational".into() },
],
vec!["defeated".into(), "documented".into()],
)
}
#[test]
fn a_set_round_trips_exactly() {
let dir = tmp("roundtrip");
let a = sample();
a.save(&dir).unwrap();
let b = Artifacts::load(&dir).unwrap();
assert_eq!(b.schema, SCHEMA);
assert_eq!(b.producer, "discovery");
assert_eq!(b.categories.len(), 2);
assert_eq!(b.categories[0].name, "battle");
assert_eq!(b.gazetteer, a.gazetteer);
assert_eq!(b.relations, a.relations);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_files_contain_no_document_text() {
let dir = tmp("leak");
let sentence = "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational.";
sample().save(&dir).unwrap();
for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS] {
let text = std::fs::read_to_string(dir.join(f)).unwrap();
assert!(!text.contains(sentence), "{f} contains a corpus sentence");
assert!(!text.contains("Morty Shade defeated"), "{f} contains a document fragment");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_set_says_what_to_do() {
let dir = tmp("missing");
std::fs::create_dir_all(&dir).unwrap();
let err = Artifacts::load(&dir).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not an artefact directory"), "{msg}");
assert!(msg.contains("save()"), "must say how to create one: {msg}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_truncated_part_is_refused_not_silently_loaded() {
let dir = tmp("truncated");
sample().save(&dir).unwrap();
std::fs::write(dir.join(VOCABULARY), b"[]").unwrap();
let err = Artifacts::load(&dir).unwrap_err();
assert!(matches!(err, ArtifactError::Corrupt(_)), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_future_schema_is_refused() {
let dir = tmp("schema");
let mut a = sample();
a.save(&dir).unwrap();
a.schema = SCHEMA + 1;
let mut slim = a.clone();
slim.categories = Vec::new();
slim.gazetteer = Vec::new();
slim.relations = Vec::new();
std::fs::write(dir.join(MANIFEST), serde_json::to_vec_pretty(&slim).unwrap()).unwrap();
assert!(matches!(
Artifacts::load(&dir).unwrap_err(),
ArtifactError::SchemaMismatch { .. }
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_manifest_records_provenance() {
let dir = tmp("provenance");
Artifacts::new("learn:local:qwen2.5-0.5b", vec![], vec![], vec![]).save(&dir).unwrap();
let loaded = Artifacts::load(&dir).unwrap();
assert_eq!(loaded.producer, "learn:local:qwen2.5-0.5b");
assert!(loaded.created.contains('T') || loaded.created.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_finetuning_set_is_separated_and_gitignored() {
let dir = tmp("training");
let jsonl = "{\"text\":\"Morty Shade defeated Wallace Gale.\",\"spans\":[]}\n\
{\"text\":\"A survey recorded Aggron at 1082 m.\",\"spans\":[]}\n";
sample().with_training(jsonl).save(&dir).unwrap();
let ignore = std::fs::read_to_string(dir.join(IGNORE_FILE)).unwrap();
assert!(ignore.contains(&format!("{TRAINING_DIR}/")), "{ignore}");
let train = std::fs::read_to_string(dir.join(TRAINING_DIR).join(TRAINING_FILE)).unwrap();
assert!(train.contains("Morty Shade defeated"));
for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS] {
let text = std::fs::read_to_string(dir.join(f)).unwrap();
assert!(!text.contains("Morty Shade defeated"), "{f} leaked document text");
}
let loaded = Artifacts::load(&dir).unwrap();
assert_eq!(loaded.training_examples, 2);
assert_eq!(loaded.contains_document_text, vec![format!("{TRAINING_DIR}/")]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_vocabulary_only_set_declares_no_text_and_still_loads() {
let dir = tmp("novocab");
sample().save(&dir).unwrap();
let loaded = Artifacts::load(&dir).unwrap();
assert!(loaded.contains_document_text.is_empty(), "nothing here carries document text");
assert_eq!(loaded.training_examples, 0);
assert!(loaded.training().is_none());
assert_eq!(loaded.categories.len(), 2, "the vocabulary is complete without the training set");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_ignore_rule_is_written_even_without_a_training_set() {
let dir = tmp("ignorefirst");
sample().save(&dir).unwrap();
assert!(dir.join(IGNORE_FILE).exists(), "the rule must exist before the data can");
let _ = std::fs::remove_dir_all(&dir);
}
}