1use crate::projector::slug;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Role {
20 Actor,
22 Target,
24}
25
26impl Role {
27 pub fn mark(self) -> &'static str {
28 match self {
29 Role::Actor => "+",
30 Role::Target => "-",
31 }
32 }
33}
34
35pub fn rel_uri(predicate: &str, role: Role) -> String {
38 format!("rel/{}/{}", slug(predicate), role.mark())
39}
40
41pub fn state_uri(negated: bool, hedged: bool) -> &'static str {
44 match (negated, hedged) {
45 (true, false) => "state/negated",
46 (true, true) => "state/negated/hedged",
47 (false, true) => "state/hedged",
48 (false, false) => "state/asserted",
49 }
50}
51
52pub fn belief_level(negated: bool, hedged: bool) -> f32 {
55 match (negated, hedged) {
56 (false, false) => 1.0,
57 (false, true) => 0.5,
58 (true, true) => -0.5,
59 (true, false) => -1.0,
60 }
61}
62
63pub fn motif_uri(facet: &str, term: &str) -> String {
65 format!("motif/{}/{}", slug(facet), slug(term))
66}
67
68fn qty_scheme(field: &str) -> Option<(&'static str, &'static str, &'static [f64])> {
73 Some(match field {
75 "qty-length" => ("length", "metre", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0, 100_000.0]),
76 "qty-mass" => ("mass", "kilogram", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0]),
77 "qty-speed" => ("speed", "mps", &[1.0, 10.0, 30.0, 100.0, 300.0]),
78 "qty-pressure" => ("pressure", "pascal", &[1e3, 1e5, 1e6, 1e7]),
79 "qty-time" => ("time", "second", &[1.0, 60.0, 3_600.0, 86_400.0, 604_800.0]),
80 "qty-power" => ("power", "watt", &[1.0, 1e3, 1e5, 1e6]),
81 "qty-energy" => ("energy", "watthour", &[1.0, 1e3, 1e5, 1e6]),
82 "qty-temp" => ("temp", "celsius", &[0.0, 30.0, 60.0, 100.0, 300.0]),
83 _ => return None,
84 })
85}
86
87fn num_label(v: f64) -> String {
89 let s = if (v.fract()).abs() < 1e-9 { format!("{}", v as i64) } else { format!("{v}") };
90 s.replace('-', "neg").replace('.', "_")
91}
92
93pub fn qty_uri(field: &str, si_value: f64) -> Option<String> {
96 let (dim, unit, edges) = qty_scheme(field)?;
97 let bucket = match edges.iter().position(|e| si_value < *e) {
98 Some(0) => format!("under_{}", num_label(edges[0])),
99 Some(i) => format!("{}_to_{}", num_label(edges[i - 1]), num_label(edges[i])),
100 None => format!("over_{}", num_label(*edges.last().unwrap())),
101 };
102 Some(format!("qty/{dim}/{unit}/{bucket}"))
103}
104
105pub fn time_uri(text: &str) -> Option<String> {
111 let low = text.to_lowercase();
112 let year = low
114 .split(|c: char| !c.is_ascii_digit())
115 .find(|t| t.len() == 4 && (t.starts_with("19") || t.starts_with("20")))
116 .and_then(|t| t.parse::<u32>().ok())?;
117 for q in 1..=4u32 {
119 if low.contains(&format!("q{q}")) || low.contains(&format!("quarter {q}")) {
120 return Some(format!("time/{year}/q{q}"));
121 }
122 }
123 const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
124 for (i, m) in MONTHS.iter().enumerate() {
125 if low.contains(m) {
126 return Some(format!("time/{year}/{:02}", i + 1));
127 }
128 }
129 Some(format!("time/{year}"))
130}
131
132fn region_of(place: &str) -> Option<&'static str> {
137 Some(match place {
138 "japan" | "tokyo" | "osaka" | "korea" | "seoul" | "china" | "beijing" | "shanghai" | "india" | "mumbai"
139 | "australia" | "sydney" | "brisbane" | "melbourne" | "singapore" | "thailand" | "bangkok" => "apac",
140 "usa" | "us" | "united-states" | "california" | "texas" | "seattle" | "austin" | "denver" | "miami"
141 | "boston" | "canada" | "toronto" | "mexico" => "amer",
142 "germany" | "berlin" | "munich" | "france" | "paris" | "uk" | "london" | "spain" | "madrid" | "italy"
143 | "rome" | "sweden" | "netherlands" | "poland" => "emea",
144 "brazil" | "sao-paulo" | "argentina" | "chile" | "colombia" => "latam",
145 _ => return None,
146 })
147}
148
149pub fn geo_uri(text: &str) -> String {
151 let s = slug(text);
152 match region_of(&s) {
153 Some(r) => format!("geo/{r}/{s}"),
154 None => format!("geo/{s}"),
155 }
156}
157
158pub fn entity_uri(kind: &str, text: &str) -> String {
161 let t = slug(kind);
162 let t = if t.is_empty() || t == "ent" { "ent".to_string() } else { t };
163 format!("{t}/{}", slug(text))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn qty_buckets_are_hierarchical_and_globbable() {
172 assert_eq!(qty_uri("qty-temp", 27.0).unwrap(), "qty/temp/celsius/0_to_30");
173 assert_eq!(qty_uri("qty-temp", 45.0).unwrap(), "qty/temp/celsius/30_to_60");
174 assert_eq!(qty_uri("qty-temp", 500.0).unwrap(), "qty/temp/celsius/over_300");
175 assert_eq!(qty_uri("qty-temp", -5.0).unwrap(), "qty/temp/celsius/under_0");
176 assert_eq!(qty_uri("qty-length", 38.0).unwrap(), "qty/length/metre/10_to_100");
177 assert!(qty_uri("qty-unknown", 1.0).is_none());
178 let u = qty_uri("qty-temp", 27.0).unwrap();
180 for p in ["qty/", "qty/temp/", "qty/temp/celsius/"] {
181 assert!(u.starts_with(p), "{u} must be reachable by {p}*");
182 }
183 }
184
185 #[test]
186 fn time_hierarchy() {
187 assert_eq!(time_uri("Q3 2026").unwrap(), "time/2026/q3");
188 assert_eq!(time_uri("March 2026").unwrap(), "time/2026/03");
189 assert_eq!(time_uri("in 2026").unwrap(), "time/2026");
190 assert!(time_uri("last quarter").is_none());
191 }
192
193 #[test]
194 fn geo_and_rel_and_state() {
195 assert_eq!(geo_uri("Brisbane"), "geo/apac/brisbane");
196 assert_eq!(geo_uri("Atlantis"), "geo/atlantis");
197 assert_eq!(rel_uri("supplies", Role::Actor), "rel/supplies/+");
198 assert_eq!(rel_uri("supplies", Role::Target), "rel/supplies/-");
199 assert_eq!(state_uri(true, false), "state/negated");
200 assert_eq!(belief_level(true, false), -1.0);
201 assert_eq!(belief_level(false, true), 0.5);
202 assert_eq!(entity_uri("ORG", "Toyota"), "org/toyota");
203 assert_eq!(entity_uri("ENT", "battery cell"), "ent/battery-cell");
204 }
205}