1use std::collections::BTreeMap;
38use std::path::{Path, PathBuf};
39
40pub const SCHEMA: u32 = 3;
52
53pub const MANIFEST: &str = "manifest.json";
55pub const VOCABULARY: &str = "vocabulary.json";
56pub const GAZETTEER: &str = "gazetteer.json";
57pub const RELATIONS: &str = "relations.json";
58pub const MOTIFS: &str = "motifs.json";
60pub const TRAINING_DIR: &str = "training";
63pub const TRAINING_FILE: &str = "spans.jsonl";
64pub const IGNORE_FILE: &str = ".gitignore";
65
66#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68pub struct Registration {
69 pub surface: String,
71 pub token: String,
73}
74
75#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct CategoryRecord {
78 pub name: String,
79 pub words: Vec<String>,
80}
81
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct Artifacts {
86 pub schema: u32,
87 pub producer: String,
89 pub created: String,
91 pub categories: Vec<CategoryRecord>,
92 pub gazetteer: Vec<Registration>,
98 pub relations: Vec<String>,
100 #[serde(default)]
102 pub motifs: Vec<CategoryRecord>,
103 pub digests: BTreeMap<String, usize>,
105 #[serde(default)]
110 pub training_examples: usize,
111 #[serde(default)]
117 pub contains_document_text: Vec<String>,
118 #[serde(skip)]
120 training: Option<String>,
121}
122
123#[derive(Debug)]
125pub enum ArtifactError {
126 Io(std::io::Error),
127 NotAnArtifactDir(PathBuf),
129 SchemaMismatch { found: u32, expected: u32 },
131 Corrupt(String),
133 Parse(String),
134}
135
136impl std::fmt::Display for ArtifactError {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 ArtifactError::Io(e) => write!(f, "{e}"),
140 ArtifactError::NotAnArtifactDir(p) => write!(
141 f,
142 "{} is not an artefact directory (no {MANIFEST}); run save() first",
143 p.display()
144 ),
145 ArtifactError::SchemaMismatch { found, expected } => write!(
146 f,
147 "artefact schema {found} cannot be read by this build (expects {expected}); re-run learn"
148 ),
149 ArtifactError::Corrupt(what) => write!(f, "artefact set is incomplete: {what}"),
150 ArtifactError::Parse(e) => write!(f, "could not parse artefact: {e}"),
151 }
152 }
153}
154
155impl std::error::Error for ArtifactError {}
156
157impl From<std::io::Error> for ArtifactError {
158 fn from(e: std::io::Error) -> Self {
159 ArtifactError::Io(e)
160 }
161}
162
163impl Artifacts {
164 pub fn new(
166 producer: impl Into<String>,
167 categories: Vec<CategoryRecord>,
168 gazetteer: Vec<Registration>,
169 relations: Vec<String>,
170 ) -> Artifacts {
171 Artifacts {
172 schema: SCHEMA,
173 producer: producer.into(),
174 created: now_rfc3339(),
175 categories,
176 gazetteer,
177 relations,
178 motifs: Vec::new(),
179 digests: BTreeMap::new(),
180 training_examples: 0,
181 contains_document_text: Vec::new(),
182 training: None,
183 }
184 }
185
186 pub fn with_training(mut self, jsonl: impl Into<String>) -> Artifacts {
193 let jsonl = jsonl.into();
194 self.training_examples = jsonl.lines().filter(|l| !l.trim().is_empty()).count();
195 self.contains_document_text = vec![format!("{TRAINING_DIR}/")];
196 self.training = Some(jsonl);
197 self
198 }
199
200 pub fn training(&self) -> Option<&str> {
202 self.training.as_deref()
203 }
204
205 pub fn with_motifs(mut self, motifs: Vec<CategoryRecord>) -> Artifacts {
210 self.motifs = motifs;
211 self
212 }
213
214 pub fn save(&self, dir: impl AsRef<Path>) -> Result<(), ArtifactError> {
215 let dir = dir.as_ref();
216 std::fs::create_dir_all(dir)?;
217
218 let vocab = serde_json::to_vec_pretty(&self.categories).map_err(|e| ArtifactError::Parse(e.to_string()))?;
219 let gaz = serde_json::to_vec_pretty(&self.gazetteer).map_err(|e| ArtifactError::Parse(e.to_string()))?;
220 let rel = serde_json::to_vec_pretty(&self.relations).map_err(|e| ArtifactError::Parse(e.to_string()))?;
221 let mot = serde_json::to_vec_pretty(&self.motifs).map_err(|e| ArtifactError::Parse(e.to_string()))?;
222
223 let mut manifest = self.clone();
224 manifest.digests.clear();
225 manifest.digests.insert(VOCABULARY.into(), vocab.len());
226 manifest.digests.insert(GAZETTEER.into(), gaz.len());
227 manifest.digests.insert(RELATIONS.into(), rel.len());
228 manifest.digests.insert(MOTIFS.into(), mot.len());
229 let mut slim = manifest.clone();
231 slim.training = None;
232 slim.categories = Vec::new();
233 slim.gazetteer = Vec::new();
234 slim.relations = Vec::new();
235 slim.motifs = Vec::new();
236
237 std::fs::write(
240 dir.join(IGNORE_FILE),
241 format!(
242 "# Written by hypersteeldb. The finetuning set under {TRAINING_DIR}/ contains verbatim document\n\
243 # text, because a span label is meaningless without the words it points at. Everything else in\n\
244 # this directory is derived vocabulary and is safe to commit.\n\
245 {TRAINING_DIR}/\n"
246 ),
247 )?;
248 if let Some(jsonl) = &self.training {
249 let tdir = dir.join(TRAINING_DIR);
250 std::fs::create_dir_all(&tdir)?;
251 std::fs::write(tdir.join(TRAINING_FILE), jsonl.as_bytes())?;
252 }
253
254 std::fs::write(dir.join(VOCABULARY), &vocab)?;
255 std::fs::write(dir.join(GAZETTEER), &gaz)?;
256 std::fs::write(dir.join(RELATIONS), &rel)?;
257 std::fs::write(dir.join(MOTIFS), &mot)?;
258 std::fs::write(
259 dir.join(MANIFEST),
260 serde_json::to_vec_pretty(&slim).map_err(|e| ArtifactError::Parse(e.to_string()))?,
261 )?;
262 Ok(())
263 }
264
265 pub fn load(dir: impl AsRef<Path>) -> Result<Artifacts, ArtifactError> {
267 let dir = dir.as_ref();
268 let mpath = dir.join(MANIFEST);
269 if !mpath.exists() {
270 return Err(ArtifactError::NotAnArtifactDir(dir.to_path_buf()));
271 }
272 let mut set: Artifacts = serde_json::from_slice(&std::fs::read(&mpath)?)
273 .map_err(|e| ArtifactError::Parse(e.to_string()))?;
274 if set.schema != SCHEMA {
275 return Err(ArtifactError::SchemaMismatch { found: set.schema, expected: SCHEMA });
276 }
277
278 let vocab = std::fs::read(dir.join(VOCABULARY))?;
279 let gaz = std::fs::read(dir.join(GAZETTEER))?;
280 let rel = std::fs::read(dir.join(RELATIONS))?;
281 let mot = std::fs::read(dir.join(MOTIFS))?;
282
283 for (name, actual) in
286 [(VOCABULARY, vocab.len()), (GAZETTEER, gaz.len()), (RELATIONS, rel.len()), (MOTIFS, mot.len())]
287 {
288 if let Some(expected) = set.digests.get(name) {
289 if *expected != actual {
290 return Err(ArtifactError::Corrupt(format!(
291 "{name} is {actual} bytes, manifest says {expected}"
292 )));
293 }
294 }
295 }
296
297 set.categories = serde_json::from_slice(&vocab).map_err(|e| ArtifactError::Parse(e.to_string()))?;
298 set.gazetteer = serde_json::from_slice(&gaz).map_err(|e| ArtifactError::Parse(e.to_string()))?;
299 set.relations = serde_json::from_slice(&rel).map_err(|e| ArtifactError::Parse(e.to_string()))?;
300 set.motifs = serde_json::from_slice(&mot).map_err(|e| ArtifactError::Parse(e.to_string()))?;
301 let tpath = dir.join(TRAINING_DIR).join(TRAINING_FILE);
304 set.training = std::fs::read_to_string(&tpath).ok();
305 Ok(set)
306 }
307
308 pub fn exists(dir: impl AsRef<Path>) -> bool {
310 dir.as_ref().join(MANIFEST).exists()
311 }
312}
313
314fn now_rfc3339() -> String {
316 #[cfg(not(target_arch = "wasm32"))]
317 {
318 use std::time::{SystemTime, UNIX_EPOCH};
319 let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) else { return String::new() };
320 let secs = d.as_secs() as i64;
321 let days = secs.div_euclid(86_400);
323 let tod = secs.rem_euclid(86_400);
324 let z = days + 719_468;
325 let era = z.div_euclid(146_097);
326 let doe = z.rem_euclid(146_097);
327 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
328 let y = yoe + era * 400;
329 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
330 let mp = (5 * doy + 2) / 153;
331 let d_ = doy - (153 * mp + 2) / 5 + 1;
332 let m = if mp < 10 { mp + 3 } else { mp - 9 };
333 let y = if m <= 2 { y + 1 } else { y };
334 format!(
335 "{y:04}-{m:02}-{d_:02}T{:02}:{:02}:{:02}Z",
336 tod / 3600,
337 (tod % 3600) / 60,
338 tod % 60
339 )
340 }
341 #[cfg(target_arch = "wasm32")]
342 {
343 String::new()
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn tmp(name: &str) -> PathBuf {
352 let p = std::env::temp_dir().join(format!("hsdb_artifact_{}_{name}", std::process::id()));
353 let _ = std::fs::remove_dir_all(&p);
354 p
355 }
356
357 fn sample() -> Artifacts {
358 Artifacts::new(
359 "discovery",
360 vec![
361 CategoryRecord { name: "battle".into(), words: vec!["defeated".into(), "faced".into()] },
362 CategoryRecord { name: "survey".into(), words: vec!["elevation".into()] },
363 ],
364 vec![
365 Registration { surface: "Sootopolis City".into(), token: "entity/sootopolis-city".into() },
366 Registration { surface: "Indigo Invitational".into(), token: "entity/indigo-invitational".into() },
367 ],
368 vec!["defeated".into(), "documented".into()],
369 )
370 .with_motifs(vec![CategoryRecord {
371 name: "series".into(),
372 words: vec!["series".into(), "season".into()],
373 }])
374 }
375
376 #[test]
377 fn a_set_round_trips_exactly() {
378 let dir = tmp("roundtrip");
379 let a = sample();
380 a.save(&dir).unwrap();
381 let b = Artifacts::load(&dir).unwrap();
382 assert_eq!(b.schema, SCHEMA);
383 assert_eq!(b.producer, "discovery");
384 assert_eq!(b.categories.len(), 2);
385 assert_eq!(b.categories[0].name, "battle");
386 assert_eq!(b.gazetteer, a.gazetteer);
387 assert_eq!(b.relations, a.relations);
388 assert_eq!(b.motifs.len(), 1);
390 assert_eq!(b.motifs[0].name, "series");
391 assert_eq!(b.motifs[0].words, a.motifs[0].words);
392 let _ = std::fs::remove_dir_all(&dir);
393 }
394
395 #[test]
396 fn the_files_contain_no_document_text() {
397 let dir = tmp("leak");
400 let sentence = "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational.";
401 sample().save(&dir).unwrap();
402 for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS, MOTIFS] {
403 let text = std::fs::read_to_string(dir.join(f)).unwrap();
404 assert!(!text.contains(sentence), "{f} contains a corpus sentence");
405 assert!(!text.contains("Morty Shade defeated"), "{f} contains a document fragment");
406 }
407 let _ = std::fs::remove_dir_all(&dir);
408 }
409
410 #[test]
411 fn a_missing_set_says_what_to_do() {
412 let dir = tmp("missing");
413 std::fs::create_dir_all(&dir).unwrap();
414 let err = Artifacts::load(&dir).unwrap_err();
415 let msg = err.to_string();
416 assert!(msg.contains("not an artefact directory"), "{msg}");
417 assert!(msg.contains("save()"), "must say how to create one: {msg}");
418 let _ = std::fs::remove_dir_all(&dir);
419 }
420
421 #[test]
422 fn a_truncated_part_is_refused_not_silently_loaded() {
423 let dir = tmp("truncated");
426 sample().save(&dir).unwrap();
427 std::fs::write(dir.join(VOCABULARY), b"[]").unwrap();
428 let err = Artifacts::load(&dir).unwrap_err();
429 assert!(matches!(err, ArtifactError::Corrupt(_)), "{err}");
430 let _ = std::fs::remove_dir_all(&dir);
431 }
432
433 #[test]
434 fn a_future_schema_is_refused() {
435 let dir = tmp("schema");
436 let mut a = sample();
437 a.save(&dir).unwrap();
438 a.schema = SCHEMA + 1;
439 let mut slim = a.clone();
440 slim.categories = Vec::new();
441 slim.gazetteer = Vec::new();
442 slim.relations = Vec::new();
443 std::fs::write(dir.join(MANIFEST), serde_json::to_vec_pretty(&slim).unwrap()).unwrap();
444 assert!(matches!(
445 Artifacts::load(&dir).unwrap_err(),
446 ArtifactError::SchemaMismatch { .. }
447 ));
448 let _ = std::fs::remove_dir_all(&dir);
449 }
450
451 #[test]
452 fn the_manifest_records_provenance() {
453 let dir = tmp("provenance");
454 Artifacts::new("learn:local:qwen2.5-0.5b", vec![], vec![], vec![]).save(&dir).unwrap();
455 let loaded = Artifacts::load(&dir).unwrap();
456 assert_eq!(loaded.producer, "learn:local:qwen2.5-0.5b");
457 assert!(loaded.created.contains('T') || loaded.created.is_empty());
458 let _ = std::fs::remove_dir_all(&dir);
459 }
460
461 #[test]
462 fn the_finetuning_set_is_separated_and_gitignored() {
463 let dir = tmp("training");
466 let jsonl = "{\"text\":\"Morty Shade defeated Wallace Gale.\",\"spans\":[]}\n\
467 {\"text\":\"A survey recorded Aggron at 1082 m.\",\"spans\":[]}\n";
468 sample().with_training(jsonl).save(&dir).unwrap();
469
470 let ignore = std::fs::read_to_string(dir.join(IGNORE_FILE)).unwrap();
472 assert!(ignore.contains(&format!("{TRAINING_DIR}/")), "{ignore}");
473
474 let train = std::fs::read_to_string(dir.join(TRAINING_DIR).join(TRAINING_FILE)).unwrap();
476 assert!(train.contains("Morty Shade defeated"));
477
478 for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS, MOTIFS] {
480 let text = std::fs::read_to_string(dir.join(f)).unwrap();
481 assert!(!text.contains("Morty Shade defeated"), "{f} leaked document text");
482 }
483
484 let loaded = Artifacts::load(&dir).unwrap();
486 assert_eq!(loaded.training_examples, 2);
487 assert_eq!(loaded.contains_document_text, vec![format!("{TRAINING_DIR}/")]);
488 let _ = std::fs::remove_dir_all(&dir);
489 }
490
491 #[test]
492 fn a_vocabulary_only_set_declares_no_text_and_still_loads() {
493 let dir = tmp("novocab");
496 sample().save(&dir).unwrap();
497 let loaded = Artifacts::load(&dir).unwrap();
498 assert!(loaded.contains_document_text.is_empty(), "nothing here carries document text");
499 assert_eq!(loaded.training_examples, 0);
500 assert!(loaded.training().is_none());
501 assert_eq!(loaded.categories.len(), 2, "the vocabulary is complete without the training set");
502 let _ = std::fs::remove_dir_all(&dir);
503 }
504
505 #[test]
506 fn the_ignore_rule_is_written_even_without_a_training_set() {
507 let dir = tmp("ignorefirst");
509 sample().save(&dir).unwrap();
510 assert!(dir.join(IGNORE_FILE).exists(), "the rule must exist before the data can");
511 let _ = std::fs::remove_dir_all(&dir);
512 }
513}