hypersteeldb 0.3.1

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
//! Units + numeric parsing — turns raw cell values and tagged quantity spans into comparable numbers
//! so IKL's `(num <field> <op> <value>)` range predicate can filter on magnitude (e.g. "braking
//! distance ≤ 70 m", "range ≥ 800", "price < 30000"). Two entry points:
//!   • `parse_number` — a bare numeric (CSV cell): strips thousands separators, currency, `%`.
//!   • `parse_quantity` — a value+unit span (from the QTY tagger): canonicalises to an SI dimension so
//!     "100 km/h" and "27.8 m/s" compare, tagged under a dimensional field (`qty-length`, `qty-mass`…).

/// Parse a bare number from a cell, tolerating thousands separators, a leading currency symbol, and a
/// trailing percent sign. `None` if it isn't numeric.
pub fn parse_number(s: &str) -> Option<f64> {
    let t: String = s.trim().chars().filter(|c| !matches!(c, ',' | '$' | '£' | '€' | '%' | ' ' | '\u{00A0}')).collect();
    if t.is_empty() {
        return None;
    }
    t.parse::<f64>().ok()
}

/// Parse a `<number><unit>` quantity → (dimensional field, SI value). Recognises the common
/// engineering dimensions; returns `None` for anything it can't canonicalise.
pub fn parse_quantity(s: &str) -> Option<(String, f64)> {
    let s = s.trim();
    // split leading numeric (with sign/decimal/commas) from the trailing unit
    let mut split = 0;
    for (i, ch) in s.char_indices() {
        if ch.is_ascii_digit() || ch == '.' || ch == ',' || ch == '-' || ch == '+' {
            split = i + ch.len_utf8();
        } else if i == 0 || split == 0 {
            return None; // must start with a number
        } else {
            break;
        }
    }
    let num: f64 = s[..split].chars().filter(|c| *c != ',').collect::<String>().parse().ok()?;
    let unit = s[split..].trim().to_lowercase();
    let unit = unit.trim_end_matches(|c: char| c == '.' || c == ')' || c == ']');

    // (field, factor-to-SI); temperature handled specially below
    let (field, si) = match unit {
        // length → metres
        "m" | "metre" | "metres" | "meter" | "meters" => ("qty-length", num),
        "km" | "kilometre" | "kilometres" => ("qty-length", num * 1000.0),
        "cm" => ("qty-length", num * 0.01),
        "mm" => ("qty-length", num * 0.001),
        "mi" | "mile" | "miles" => ("qty-length", num * 1609.344),
        // mass → kilograms
        "kg" | "kilogram" | "kilograms" => ("qty-mass", num),
        "g" | "gram" | "grams" => ("qty-mass", num * 0.001),
        "mg" => ("qty-mass", num * 1e-6),
        "t" | "tonne" | "tonnes" | "ton" => ("qty-mass", num * 1000.0),
        "lb" | "lbs" | "pound" | "pounds" => ("qty-mass", num * 0.453592),
        // speed → m/s
        "m/s" | "mps" => ("qty-speed", num),
        "km/h" | "kmh" | "kph" => ("qty-speed", num / 3.6),
        "mph" => ("qty-speed", num * 0.44704),
        // pressure → pascals
        "pa" => ("qty-pressure", num),
        "kpa" => ("qty-pressure", num * 1000.0),
        "mpa" => ("qty-pressure", num * 1e6),
        "bar" => ("qty-pressure", num * 1e5),
        // time → seconds
        "s" | "sec" | "secs" | "second" | "seconds" => ("qty-time", num),
        "ms" => ("qty-time", num * 0.001),
        "min" | "mins" | "minute" | "minutes" => ("qty-time", num * 60.0),
        "h" | "hr" | "hrs" | "hour" | "hours" => ("qty-time", num * 3600.0),
        // energy/power (light touch)
        "w" | "watt" | "watts" => ("qty-power", num),
        "kw" => ("qty-power", num * 1000.0),
        "wh" => ("qty-energy", num),
        "kwh" => ("qty-energy", num * 1000.0),
        // temperature → Celsius (normalise F/K)
        "°c" | "℃" | "c" => ("qty-temp", num),
        "°f" | "℉" | "f" => ("qty-temp", (num - 32.0) * 5.0 / 9.0),
        "k" | "kelvin" => ("qty-temp", num - 273.15),
        _ => return None,
    };
    Some((field.to_string(), si))
}

/// Evaluate a numeric comparison for a stored value: `value op target`.
pub fn cmp_op(value: f64, op: &str, target: f64) -> bool {
    match op {
        "ge" | ">=" => value >= target,
        "gt" | ">" => value > target,
        "le" | "<=" => value <= target,
        "lt" | "<" => value < target,
        "eq" | "==" | "=" => (value - target).abs() < 1e-9,
        "ne" | "!=" => (value - target).abs() >= 1e-9,
        _ => false,
    }
}

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

    #[test]
    fn numbers() {
        assert_eq!(parse_number("3,500"), Some(3500.0));
        assert_eq!(parse_number("$28000"), Some(28000.0));
        assert_eq!(parse_number("12.5%"), Some(12.5));
        assert_eq!(parse_number("hello"), None);
    }

    #[test]
    fn quantities() {
        assert_eq!(parse_quantity("70 m"), Some(("qty-length".into(), 70.0)));
        let (f, v) = parse_quantity("100 km/h").unwrap();
        assert_eq!(f, "qty-speed");
        assert!((v - 27.777).abs() < 0.01);
        assert_eq!(parse_quantity("12 MPa"), Some(("qty-pressure".into(), 12_000_000.0)));
        let (_, c) = parse_quantity("205 ℃").unwrap();
        assert!((c - 205.0).abs() < 1e-6);
        assert_eq!(parse_quantity("not a qty"), None);
    }

    #[test]
    fn ops() {
        assert!(cmp_op(70.0, "le", 70.0));
        assert!(cmp_op(800.0, "ge", 500.0));
        assert!(!cmp_op(38.0, "gt", 70.0));
    }
}