use crate::vocabulary::VocabularySpace;
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const INDEX_FILE: &str = "registry.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Version {
pub id: String,
pub created: u64,
pub corpus: String,
pub entity_facets: usize,
pub relation_facets: usize,
pub hierarchical_facets: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mean_gain: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub versions: Vec<Version>,
}
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl Registry {
pub fn load(root: &Path) -> Registry {
std::fs::read(root.join(INDEX_FILE))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(root)?;
let tmp = root.join(format!("{INDEX_FILE}.tmp"));
std::fs::write(&tmp, serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)?;
std::fs::rename(tmp, root.join(INDEX_FILE))
}
pub fn latest(&self) -> Option<&Version> {
self.versions.first()
}
pub fn get(&self, id: &str) -> Option<&Version> {
self.versions.iter().find(|v| v.id == id)
}
fn next_seq(&self) -> usize {
self.versions
.iter()
.filter_map(|v| v.id.strip_prefix('v').and_then(|s| s.split('-').next()).and_then(|n| n.parse::<usize>().ok()))
.max()
.map(|m| m + 1)
.unwrap_or(1)
}
}
pub fn register(
root: &Path,
spec: &VocabularySpace,
metrics: &serde_json::Value,
note: Option<&str>,
) -> std::io::Result<Version> {
spec.validate().map_err(std::io::Error::other)?;
let mut reg = Registry::load(root);
let slug = crate::projector::slug(&spec.corpus);
let slug = if slug.is_empty() { "corpus".to_string() } else { slug.chars().take(40).collect() };
let id = format!("v{}-{}", reg.next_seq(), slug);
let dir = root.join(&id);
std::fs::create_dir_all(&dir)?;
spec.save(&dir.join("spec.json")).map_err(std::io::Error::other)?;
std::fs::write(dir.join("metrics.json"), serde_json::to_vec_pretty(metrics).map_err(std::io::Error::other)?)?;
let version = Version {
id: id.clone(),
created: now_secs(),
corpus: spec.corpus.clone(),
entity_facets: spec.entity_facets.len(),
relation_facets: spec.relation_facets.len(),
hierarchical_facets: spec.entity_facets.iter().filter(|f| f.parent.is_some()).count(),
mean_gain: metrics.get("mean_gain").and_then(|v| v.as_f64()),
note: note.map(String::from),
};
reg.versions.insert(0, version.clone());
reg.save(root)?;
Ok(version)
}
pub fn load_spec(root: &Path, id: Option<&str>) -> Option<VocabularySpace> {
let reg = Registry::load(root);
let v = match id {
Some(i) => reg.get(i)?,
None => reg.latest()?,
};
VocabularySpace::load(&root.join(&v.id).join("spec.json")).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vocabulary::{EntityFacet, RelationFacet};
fn spec(corpus: &str, extra: bool) -> VocabularySpace {
let mut facets = vec![
EntityFacet { name: "org".into(), parent: None, description: "companies".into(), examples: vec![], structural: false },
EntityFacet { name: "system".into(), parent: None, description: "platforms".into(), examples: vec![], structural: false },
];
if extra {
facets.push(EntityFacet { name: "battery".into(), parent: Some("system".into()), description: "cells".into(), examples: vec![], structural: false });
}
VocabularySpace {
version: 1,
corpus: corpus.into(),
entity_facets: facets,
relation_facets: vec![RelationFacet { name: "develops".into(), head: "org".into(), tail: "system".into() }],
gazetteer: vec![],
metrics: None,
}
}
#[test]
fn versions_accumulate_newest_first_and_round_trip() {
let root = std::env::temp_dir().join(format!("steeldb-reg-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let v1 = register(&root, &spec("defence corpus", false), &serde_json::json!({"mean_gain": 0.21}), Some("seed")).unwrap();
let v2 = register(&root, &spec("defence corpus", true), &serde_json::json!({"mean_gain": 0.34}), Some("grown")).unwrap();
assert_eq!(v1.id, "v1-defence-corpus");
assert_eq!(v2.id, "v2-defence-corpus");
assert_eq!(v2.hierarchical_facets, 1, "the grown facet carries a parent");
assert_eq!(v1.hierarchical_facets, 0);
let reg = Registry::load(&root);
assert_eq!(reg.versions.len(), 2);
assert_eq!(reg.latest().unwrap().id, v2.id, "newest first");
assert_eq!(reg.latest().unwrap().mean_gain, Some(0.34));
let latest = load_spec(&root, None).unwrap();
assert_eq!(latest.facet_path("battery"), "system/battery");
let first = load_spec(&root, Some(&v1.id)).unwrap();
assert!(!first.has_entity_facet("battery"));
assert!(load_spec(&root, Some("v99-nope")).is_none());
let m: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join(&v2.id).join("metrics.json")).unwrap()).unwrap();
assert_eq!(m["mean_gain"], 0.34);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn ids_do_not_collide_after_deletion() {
let root = std::env::temp_dir().join(format!("steeldb-reg2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
let v2 = register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
std::fs::remove_dir_all(root.join(&v2.id)).unwrap();
let v3 = register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
assert_eq!(v3.id, "v3-c");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn invalid_specs_are_not_registered() {
let root = std::env::temp_dir().join(format!("steeldb-reg3-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut bad = spec("c", false);
bad.relation_facets.push(RelationFacet { name: "ships".into(), head: "org".into(), tail: "ghost".into() });
assert!(register(&root, &bad, &serde_json::json!({}), None).is_err());
assert!(Registry::load(&root).versions.is_empty(), "nothing registered");
let _ = std::fs::remove_dir_all(&root);
}
}