hypersteeldb 0.4.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
//! **Vocabulary Space `V`** — the canonical URI taxonomy (paper §1, §2). Every producer's output is
//! normalised into one of six hierarchical, slash-delimited dimensions. The hierarchy is load-bearing,
//! not cosmetic: **subtree wildcards only work if the URIs have depth.** `glob_match` already globs the
//! whole token, so `qty/temp/*`, `time/2026/*`, `geo/apac/*`, `rel/supplies/+` all resolve the moment
//! emission is hierarchical — whereas a flat `qty/27c` or `time/q3-2026` is unreachable by prefix.
//!
//! The six dimensions:
//!   1. entities & artifacts   `org/toyota`, `artifact/battery_cell`      (type from the tagger)
//!   2. relational roles       `rel/supplies/+`, `rel/supplies/-`         (polarity = argument side)
//!   3. spatial/temporal loci  `time/2026/q3`, `geo/apac/brisbane`        (deterministic, here)
//!   4. quantities/tolerances  `qty/temp/celsius/under_30`                (deterministic, here)
//!   5. epistemic modifiers    `state/negated`, `trend/cost/decrease`     (from the tagger)
//!   6. latent motifs          `motif/hazard/thermal`                     (SPLADE / Sinkhorn-OT)

use crate::projector::slug;

/// Which side of a relation an argument occupies — the grammatical polarity of dimension 2.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    /// the acting participant → `rel/<pred>/+`
    Actor,
    /// the affected participant → `rel/<pred>/-`
    Target,
}

impl Role {
    pub fn mark(self) -> &'static str {
        match self {
            Role::Actor => "+",
            Role::Target => "-",
        }
    }
}

/// Dimension 2: `rel/<predicate>/<+|->`. Polarity belongs to the *argument side*, so a bound pair emits
/// two tokens (actor `+`, target `-`) for one predicate.
pub fn rel_uri(predicate: &str, role: Role) -> String {
    format!("rel/{}/{}", slug(predicate), role.mark())
}

/// Dimension 5: epistemic state of an assertion. `confidence` and `negated` come from the tagger's
/// epistemic head; both map onto the 4-level Dempster-Shafer polarity in `InfonIndex::add_infon_polar`.
pub fn state_uri(negated: bool, hedged: bool) -> &'static str {
    match (negated, hedged) {
        (true, false) => "state/negated",
        (true, true) => "state/negated/hedged",
        (false, true) => "state/hedged",
        (false, false) => "state/asserted",
    }
}

/// The DS belief level (±1 / ±0.5) implied by an epistemic reading — the bridge from dimension 5 to the
/// signed-infon layer.
pub fn belief_level(negated: bool, hedged: bool) -> f32 {
    match (negated, hedged) {
        (false, false) => 1.0,
        (false, true) => 0.5,
        (true, true) => -0.5,
        (true, false) => -1.0,
    }
}

/// Dimension 6: `motif/<facet>/<term>` — a SPLADE head's learned facet plus its active term.
pub fn motif_uri(facet: &str, term: &str) -> String {
    format!("motif/{}/{}", slug(facet), slug(term))
}

// ── dimension 4: quantities → `qty/<dimension>/<unit>/<bucket>` ────────────────────────────────

/// Canonical SI unit name per dimensional field, plus the bucket ladder (ascending edges) used to
/// quantize a magnitude into a boolean-matchable range token.
fn qty_scheme(field: &str) -> Option<(&'static str, &'static str, &'static [f64])> {
    // (dimension, canonical unit, bucket edges in SI)
    Some(match field {
        "qty-length" => ("length", "metre", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0, 100_000.0]),
        "qty-mass" => ("mass", "kilogram", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0]),
        "qty-speed" => ("speed", "mps", &[1.0, 10.0, 30.0, 100.0, 300.0]),
        "qty-pressure" => ("pressure", "pascal", &[1e3, 1e5, 1e6, 1e7]),
        "qty-time" => ("time", "second", &[1.0, 60.0, 3_600.0, 86_400.0, 604_800.0]),
        "qty-power" => ("power", "watt", &[1.0, 1e3, 1e5, 1e6]),
        "qty-energy" => ("energy", "watthour", &[1.0, 1e3, 1e5, 1e6]),
        "qty-temp" => ("temp", "celsius", &[0.0, 30.0, 60.0, 100.0, 300.0]),
        _ => return None,
    })
}

/// Format a number for a URI label: integers bare, else trimmed decimal, `-` → `neg`.
fn num_label(v: f64) -> String {
    let s = if (v.fract()).abs() < 1e-9 { format!("{}", v as i64) } else { format!("{v}") };
    s.replace('-', "neg").replace('.', "_")
}

/// Dimension 4: canonicalise a parsed quantity into a hierarchical URI with a range bucket, e.g.
/// `("qty-temp", 27.0)` → `qty/temp/celsius/under_30`. Returns `None` for unknown dimensions.
pub fn qty_uri(field: &str, si_value: f64) -> Option<String> {
    let (dim, unit, edges) = qty_scheme(field)?;
    let bucket = match edges.iter().position(|e| si_value < *e) {
        Some(0) => format!("under_{}", num_label(edges[0])),
        Some(i) => format!("{}_to_{}", num_label(edges[i - 1]), num_label(edges[i])),
        None => format!("over_{}", num_label(*edges.last().unwrap())),
    };
    Some(format!("qty/{dim}/{unit}/{bucket}"))
}

// ── dimension 3: temporal loci → `time/<year>[/<q|month>]` ─────────────────────────────────────

/// Dimension 3 (temporal): normalise a TIME span into a bucketed hierarchy — `Q3 2026` → `time/2026/q3`,
/// `March 2026` → `time/2026/03`, `2026` → `time/2026`. Returns `None` if no year is present (callers
/// fall back to a flat slug).
pub fn time_uri(text: &str) -> Option<String> {
    let low = text.to_lowercase();
    // year: first standalone 4-digit 19xx/20xx
    let year = low
        .split(|c: char| !c.is_ascii_digit())
        .find(|t| t.len() == 4 && (t.starts_with("19") || t.starts_with("20")))
        .and_then(|t| t.parse::<u32>().ok())?;
    // quarter
    for q in 1..=4u32 {
        if low.contains(&format!("q{q}")) || low.contains(&format!("quarter {q}")) {
            return Some(format!("time/{year}/q{q}"));
        }
    }
    const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
    for (i, m) in MONTHS.iter().enumerate() {
        if low.contains(m) {
            return Some(format!("time/{year}/{:02}", i + 1));
        }
    }
    Some(format!("time/{year}"))
}

// ── dimension 3: spatial loci → `geo/<region>/<place>` ─────────────────────────────────────────

/// Seed region map so geo URIs have a queryable parent (`geo/apac/*`). Deliberately small — the growing
/// gazetteer is the mechanism for extending coverage per corpus; unknown places stay one level deep.
fn region_of(place: &str) -> Option<&'static str> {
    Some(match place {
        "japan" | "tokyo" | "osaka" | "korea" | "seoul" | "china" | "beijing" | "shanghai" | "india" | "mumbai"
        | "australia" | "sydney" | "brisbane" | "melbourne" | "singapore" | "thailand" | "bangkok" => "apac",
        "usa" | "us" | "united-states" | "california" | "texas" | "seattle" | "austin" | "denver" | "miami"
        | "boston" | "canada" | "toronto" | "mexico" => "amer",
        "germany" | "berlin" | "munich" | "france" | "paris" | "uk" | "london" | "spain" | "madrid" | "italy"
        | "rome" | "sweden" | "netherlands" | "poland" => "emea",
        "brazil" | "sao-paulo" | "argentina" | "chile" | "colombia" => "latam",
        _ => return None,
    })
}

/// Dimension 3 (spatial): `Brisbane` → `geo/apac/brisbane`; unknown → `geo/<slug>`.
pub fn geo_uri(text: &str) -> String {
    let s = slug(text);
    match region_of(&s) {
        Some(r) => format!("geo/{r}/{s}"),
        None => format!("geo/{s}"),
    }
}

/// Dimension 1: `<type>/<entity>` — the type comes from the tagger's span kind (`ORG` → `org`). Falls
/// back to the generic `ent` bucket when the tagger is untyped.
pub fn entity_uri(kind: &str, text: &str) -> String {
    let t = slug(kind);
    let t = if t.is_empty() || t == "ent" { "ent".to_string() } else { t };
    format!("{t}/{}", slug(text))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn qty_buckets_are_hierarchical_and_globbable() {
        assert_eq!(qty_uri("qty-temp", 27.0).unwrap(), "qty/temp/celsius/0_to_30");
        assert_eq!(qty_uri("qty-temp", 45.0).unwrap(), "qty/temp/celsius/30_to_60");
        assert_eq!(qty_uri("qty-temp", 500.0).unwrap(), "qty/temp/celsius/over_300");
        assert_eq!(qty_uri("qty-temp", -5.0).unwrap(), "qty/temp/celsius/under_0");
        assert_eq!(qty_uri("qty-length", 38.0).unwrap(), "qty/length/metre/10_to_100");
        assert!(qty_uri("qty-unknown", 1.0).is_none());
        // every level is a valid wildcard prefix
        let u = qty_uri("qty-temp", 27.0).unwrap();
        for p in ["qty/", "qty/temp/", "qty/temp/celsius/"] {
            assert!(u.starts_with(p), "{u} must be reachable by {p}*");
        }
    }

    #[test]
    fn time_hierarchy() {
        assert_eq!(time_uri("Q3 2026").unwrap(), "time/2026/q3");
        assert_eq!(time_uri("March 2026").unwrap(), "time/2026/03");
        assert_eq!(time_uri("in 2026").unwrap(), "time/2026");
        assert!(time_uri("last quarter").is_none());
    }

    #[test]
    fn geo_and_rel_and_state() {
        assert_eq!(geo_uri("Brisbane"), "geo/apac/brisbane");
        assert_eq!(geo_uri("Atlantis"), "geo/atlantis");
        assert_eq!(rel_uri("supplies", Role::Actor), "rel/supplies/+");
        assert_eq!(rel_uri("supplies", Role::Target), "rel/supplies/-");
        assert_eq!(state_uri(true, false), "state/negated");
        assert_eq!(belief_level(true, false), -1.0);
        assert_eq!(belief_level(false, true), 0.5);
        assert_eq!(entity_uri("ORG", "Toyota"), "org/toyota");
        assert_eq!(entity_uri("ENT", "battery cell"), "ent/battery-cell");
    }
}