1use crate::vocabulary::VocabularySpace;
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19pub const INDEX_FILE: &str = "registry.json";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Version {
23 pub id: String,
24 pub created: u64,
26 pub corpus: String,
27 pub entity_facets: usize,
28 pub relation_facets: usize,
29 pub hierarchical_facets: usize,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub mean_gain: Option<f64>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub note: Option<String>,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct Registry {
39 #[serde(default)]
40 pub versions: Vec<Version>,
41}
42
43fn now_secs() -> u64 {
44 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
45}
46
47impl Registry {
48 pub fn load(root: &Path) -> Registry {
49 std::fs::read(root.join(INDEX_FILE))
50 .ok()
51 .and_then(|b| serde_json::from_slice(&b).ok())
52 .unwrap_or_default()
53 }
54
55 pub fn save(&self, root: &Path) -> std::io::Result<()> {
57 std::fs::create_dir_all(root)?;
58 let tmp = root.join(format!("{INDEX_FILE}.tmp"));
59 std::fs::write(&tmp, serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)?;
60 std::fs::rename(tmp, root.join(INDEX_FILE))
61 }
62
63 pub fn latest(&self) -> Option<&Version> {
65 self.versions.first()
66 }
67
68 pub fn get(&self, id: &str) -> Option<&Version> {
69 self.versions.iter().find(|v| v.id == id)
70 }
71
72 fn next_seq(&self) -> usize {
75 self.versions
76 .iter()
77 .filter_map(|v| v.id.strip_prefix('v').and_then(|s| s.split('-').next()).and_then(|n| n.parse::<usize>().ok()))
78 .max()
79 .map(|m| m + 1)
80 .unwrap_or(1)
81 }
82}
83
84pub fn register(
87 root: &Path,
88 spec: &VocabularySpace,
89 metrics: &serde_json::Value,
90 note: Option<&str>,
91) -> std::io::Result<Version> {
92 spec.validate().map_err(std::io::Error::other)?;
93 let mut reg = Registry::load(root);
94 let slug = crate::projector::slug(&spec.corpus);
95 let slug = if slug.is_empty() { "corpus".to_string() } else { slug.chars().take(40).collect() };
96 let id = format!("v{}-{}", reg.next_seq(), slug);
97 let dir = root.join(&id);
98 std::fs::create_dir_all(&dir)?;
99 spec.save(&dir.join("spec.json")).map_err(std::io::Error::other)?;
100 std::fs::write(dir.join("metrics.json"), serde_json::to_vec_pretty(metrics).map_err(std::io::Error::other)?)?;
101
102 let version = Version {
103 id: id.clone(),
104 created: now_secs(),
105 corpus: spec.corpus.clone(),
106 entity_facets: spec.entity_facets.len(),
107 relation_facets: spec.relation_facets.len(),
108 hierarchical_facets: spec.entity_facets.iter().filter(|f| f.parent.is_some()).count(),
109 mean_gain: metrics.get("mean_gain").and_then(|v| v.as_f64()),
110 note: note.map(String::from),
111 };
112 reg.versions.insert(0, version.clone());
113 reg.save(root)?;
114 Ok(version)
115}
116
117pub fn load_spec(root: &Path, id: Option<&str>) -> Option<VocabularySpace> {
119 let reg = Registry::load(root);
120 let v = match id {
121 Some(i) => reg.get(i)?,
122 None => reg.latest()?,
123 };
124 VocabularySpace::load(&root.join(&v.id).join("spec.json")).ok()
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::vocabulary::{EntityFacet, RelationFacet};
131
132 fn spec(corpus: &str, extra: bool) -> VocabularySpace {
133 let mut facets = vec![
134 EntityFacet { name: "org".into(), parent: None, description: "companies".into(), examples: vec![], structural: false },
135 EntityFacet { name: "system".into(), parent: None, description: "platforms".into(), examples: vec![], structural: false },
136 ];
137 if extra {
138 facets.push(EntityFacet { name: "battery".into(), parent: Some("system".into()), description: "cells".into(), examples: vec![], structural: false });
139 }
140 VocabularySpace {
141 version: 1,
142 corpus: corpus.into(),
143 entity_facets: facets,
144 relation_facets: vec![RelationFacet { name: "develops".into(), head: "org".into(), tail: "system".into() }],
145 gazetteer: vec![],
146 metrics: None,
147 }
148 }
149
150 #[test]
151 fn versions_accumulate_newest_first_and_round_trip() {
152 let root = std::env::temp_dir().join(format!("steeldb-reg-{}", std::process::id()));
153 let _ = std::fs::remove_dir_all(&root);
154
155 let v1 = register(&root, &spec("defence corpus", false), &serde_json::json!({"mean_gain": 0.21}), Some("seed")).unwrap();
156 let v2 = register(&root, &spec("defence corpus", true), &serde_json::json!({"mean_gain": 0.34}), Some("grown")).unwrap();
157 assert_eq!(v1.id, "v1-defence-corpus");
158 assert_eq!(v2.id, "v2-defence-corpus");
159 assert_eq!(v2.hierarchical_facets, 1, "the grown facet carries a parent");
160 assert_eq!(v1.hierarchical_facets, 0);
161
162 let reg = Registry::load(&root);
163 assert_eq!(reg.versions.len(), 2);
164 assert_eq!(reg.latest().unwrap().id, v2.id, "newest first");
165 assert_eq!(reg.latest().unwrap().mean_gain, Some(0.34));
166
167 let latest = load_spec(&root, None).unwrap();
169 assert_eq!(latest.facet_path("battery"), "system/battery");
170 let first = load_spec(&root, Some(&v1.id)).unwrap();
171 assert!(!first.has_entity_facet("battery"));
172 assert!(load_spec(&root, Some("v99-nope")).is_none());
173
174 let m: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join(&v2.id).join("metrics.json")).unwrap()).unwrap();
176 assert_eq!(m["mean_gain"], 0.34);
177
178 let _ = std::fs::remove_dir_all(&root);
179 }
180
181 #[test]
182 fn ids_do_not_collide_after_deletion() {
183 let root = std::env::temp_dir().join(format!("steeldb-reg2-{}", std::process::id()));
184 let _ = std::fs::remove_dir_all(&root);
185 register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
186 let v2 = register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
187 std::fs::remove_dir_all(root.join(&v2.id)).unwrap();
189 let v3 = register(&root, &spec("c", false), &serde_json::json!({}), None).unwrap();
190 assert_eq!(v3.id, "v3-c");
191 let _ = std::fs::remove_dir_all(&root);
192 }
193
194 #[test]
195 fn invalid_specs_are_not_registered() {
196 let root = std::env::temp_dir().join(format!("steeldb-reg3-{}", std::process::id()));
197 let _ = std::fs::remove_dir_all(&root);
198 let mut bad = spec("c", false);
199 bad.relation_facets.push(RelationFacet { name: "ships".into(), head: "org".into(), tail: "ghost".into() });
201 assert!(register(&root, &bad, &serde_json::json!({}), None).is_err());
202 assert!(Registry::load(&root).versions.is_empty(), "nothing registered");
203 let _ = std::fs::remove_dir_all(&root);
204 }
205}