use crate::projector::slug;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Actor,
Target,
}
impl Role {
pub fn mark(self) -> &'static str {
match self {
Role::Actor => "+",
Role::Target => "-",
}
}
}
pub fn rel_uri(predicate: &str, role: Role) -> String {
format!("rel/{}/{}", slug(predicate), role.mark())
}
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",
}
}
const EMPHATIC: &[&str] = &["not only", "not merely", "not just", "not simply", "not solely"];
pub fn denies_claim(text: &str) -> bool {
if text.contains("not permitted") {
return true;
}
if text.contains("no longer") {
return true;
}
let mut from = 0usize;
while let Some(rel) = text[from..].find("is not ") {
let at = from + rel + "is ".len();
if !EMPHATIC.iter().any(|e| text[at..].starts_with(e)) {
return true;
}
from = at + "not ".len();
if from >= text.len() {
break;
}
}
false
}
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,
}
}
pub fn motif_uri(facet: &str, term: &str) -> String {
format!("motif/{}/{}", slug(facet), slug(term))
}
fn qty_scheme(field: &str) -> Option<(&'static str, &'static str, &'static [f64])> {
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,
})
}
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('.', "_")
}
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}"))
}
pub fn time_uri(text: &str) -> Option<String> {
let low = text.to_lowercase();
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())?;
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}"))
}
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,
})
}
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}"),
}
}
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());
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");
}
#[test]
fn an_emphatic_not_is_not_a_denial() {
for affirmation in [
"acme corp is not only a supplier but also a partner",
"epsilon corp is not merely a vendor; it is the prime contractor",
"it is not just a contract, it is a partnership",
"beta is not simply compliant, it exceeds the standard",
"gamma is not solely responsible for the programme",
] {
assert!(!denies_claim(affirmation), "read as a denial: {affirmation}");
}
}
#[test]
fn a_real_denial_is_still_detected() {
for denial in [
"beta corp is not permitted to supply the ministry",
"milotic is not permitted in series 1 play",
"the vendor is not compliant with the standard",
"acme is no longer approved",
] {
assert!(denies_claim(denial), "missed a denial: {denial}");
}
assert!(
denies_claim("acme is not only late, and the contract is not permitted to continue"),
"an emphatic clause masked a real denial"
);
}
#[test]
fn the_known_limitation_is_recorded_rather_than_hidden() {
let scope_limited = "gamma corp is no longer under review and remains approved";
assert!(
denies_claim(scope_limited),
"if this now returns false, the scope limitation was fixed — update this test and the doc comment"
);
}
#[test]
fn belief_level_keeps_the_fourth_polarity_state() {
assert_eq!(belief_level(false, false), 1.0);
assert_eq!(belief_level(false, true), 0.5);
assert_eq!(belief_level(true, true), -0.5);
assert_eq!(belief_level(true, false), -1.0);
}
}