hypersteeldb 0.1.0

A database that compiles questions instead of guessing answers: typed vocabulary discovered from your documents, queries type-checked before they run, roaring-bitmap set algebra over reified hyperedges, and Dempster-Shafer evidence with an explicit conflict guard.
Documentation
//! **Step 4: the registry** — version the spec, its metrics, and the growth log so an ontology is
//! reproducible and auditable (`design.py`: "version the model + facet spec + metrics in a filesystem
//! registry (`models/<id>/`, `registry.json`)").
//!
//! Layout:
//! ```text
//!   <root>/registry.json          index: every version, newest first
//!   <root>/<id>/spec.json         the VocabularySpace at that version
//!   <root>/<id>/metrics.json      MECE report + growth decisions
//! ```
//!
//! Ids are `v<N>-<corpus-slug>` so they sort readably and say what they describe. The index is rewritten
//! atomically (temp + rename) because it is the one file two runs could race on.

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,
    /// seconds since the Unix epoch — wall-clock provenance without pulling a date library
    pub created: u64,
    pub corpus: String,
    pub entity_facets: usize,
    pub relation_facets: usize,
    /// facets carrying a `parent` — a spec with none is flat and cannot serve subtree wildcards
    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()
    }

    /// Write the index atomically so a crash mid-write can't truncate it.
    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))
    }

    /// Newest version, if any.
    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)
    }

    /// Next sequence number — one past the highest `v<N>` seen, so ids never collide even if an older
    /// version was deleted from disk.
    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)
    }
}

/// Register a spec version: writes `<root>/<id>/{spec.json,metrics.json}` and prepends the index entry.
/// `metrics` is free-form (MECE report, growth log, training numbers) and stored verbatim.
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)
}

/// Load a registered spec by id (or the newest when `id` is `None`).
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));

        // specs round-trip, and the grown one keeps its hierarchy
        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());

        // metrics are stored verbatim
        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();
        // delete v2's directory but leave the index: the next id must still advance past it
        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);
        // relation pointing at an undeclared facet — must fail closed
        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);
    }
}