Skip to main content

steeldb/
registry.rs

1//! **Step 4: the registry** — version the spec, its metrics, and the growth log so an ontology is
2//! reproducible and auditable (`design.py`: "version the model + facet spec + metrics in a filesystem
3//! registry (`models/<id>/`, `registry.json`)").
4//!
5//! Layout:
6//! ```text
7//!   <root>/registry.json          index: every version, newest first
8//!   <root>/<id>/spec.json         the VocabularySpace at that version
9//!   <root>/<id>/metrics.json      MECE report + growth decisions
10//! ```
11//!
12//! Ids are `v<N>-<corpus-slug>` so they sort readably and say what they describe. The index is rewritten
13//! atomically (temp + rename) because it is the one file two runs could race on.
14
15use 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    /// seconds since the Unix epoch — wall-clock provenance without pulling a date library
25    pub created: u64,
26    pub corpus: String,
27    pub entity_facets: usize,
28    pub relation_facets: usize,
29    /// facets carrying a `parent` — a spec with none is flat and cannot serve subtree wildcards
30    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    /// Write the index atomically so a crash mid-write can't truncate it.
56    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    /// Newest version, if any.
64    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    /// Next sequence number — one past the highest `v<N>` seen, so ids never collide even if an older
73    /// version was deleted from disk.
74    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
84/// Register a spec version: writes `<root>/<id>/{spec.json,metrics.json}` and prepends the index entry.
85/// `metrics` is free-form (MECE report, growth log, training numbers) and stored verbatim.
86pub 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
117/// Load a registered spec by id (or the newest when `id` is `None`).
118pub 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        // specs round-trip, and the grown one keeps its hierarchy
168        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        // metrics are stored verbatim
175        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        // delete v2's directory but leave the index: the next id must still advance past it
188        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        // relation pointing at an undeclared facet — must fail closed
200        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}