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
52const EMPHATIC: &[&str] = &["not only", "not merely", "not just", "not simply", "not solely"];
60
61pub fn denies_claim(text: &str) -> bool {
70 if text.contains("not permitted") {
71 return true;
72 }
73 if text.contains("no longer") {
74 return true;
75 }
76 let mut from = 0usize;
78 while let Some(rel) = text[from..].find("is not ") {
79 let at = from + rel + "is ".len();
80 if !EMPHATIC.iter().any(|e| text[at..].starts_with(e)) {
81 return true;
82 }
83 from = at + "not ".len();
84 if from >= text.len() {
85 break;
86 }
87 }
88 false
89}
90
91pub fn belief_level(negated: bool, hedged: bool) -> f32 {
94 match (negated, hedged) {
95 (false, false) => 1.0,
96 (false, true) => 0.5,
97 (true, true) => -0.5,
98 (true, false) => -1.0,
99 }
100}
101
102pub fn motif_uri(facet: &str, term: &str) -> String {
104 format!("motif/{}/{}", slug(facet), slug(term))
105}
106
107fn qty_scheme(field: &str) -> Option<(&'static str, &'static str, &'static [f64])> {
112 Some(match field {
114 "qty-length" => ("length", "metre", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0, 100_000.0]),
115 "qty-mass" => ("mass", "kilogram", &[1.0, 10.0, 100.0, 1_000.0, 10_000.0]),
116 "qty-speed" => ("speed", "mps", &[1.0, 10.0, 30.0, 100.0, 300.0]),
117 "qty-pressure" => ("pressure", "pascal", &[1e3, 1e5, 1e6, 1e7]),
118 "qty-time" => ("time", "second", &[1.0, 60.0, 3_600.0, 86_400.0, 604_800.0]),
119 "qty-power" => ("power", "watt", &[1.0, 1e3, 1e5, 1e6]),
120 "qty-energy" => ("energy", "watthour", &[1.0, 1e3, 1e5, 1e6]),
121 "qty-temp" => ("temp", "celsius", &[0.0, 30.0, 60.0, 100.0, 300.0]),
122 _ => return None,
123 })
124}
125
126fn num_label(v: f64) -> String {
128 let s = if (v.fract()).abs() < 1e-9 { format!("{}", v as i64) } else { format!("{v}") };
129 s.replace('-', "neg").replace('.', "_")
130}
131
132pub fn qty_uri(field: &str, si_value: f64) -> Option<String> {
135 let (dim, unit, edges) = qty_scheme(field)?;
136 let bucket = match edges.iter().position(|e| si_value < *e) {
137 Some(0) => format!("under_{}", num_label(edges[0])),
138 Some(i) => format!("{}_to_{}", num_label(edges[i - 1]), num_label(edges[i])),
139 None => format!("over_{}", num_label(*edges.last().unwrap())),
140 };
141 Some(format!("qty/{dim}/{unit}/{bucket}"))
142}
143
144pub fn time_uri(text: &str) -> Option<String> {
150 let low = text.to_lowercase();
151 let year = low
153 .split(|c: char| !c.is_ascii_digit())
154 .find(|t| t.len() == 4 && (t.starts_with("19") || t.starts_with("20")))
155 .and_then(|t| t.parse::<u32>().ok())?;
156 for q in 1..=4u32 {
158 if low.contains(&format!("q{q}")) || low.contains(&format!("quarter {q}")) {
159 return Some(format!("time/{year}/q{q}"));
160 }
161 }
162 const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
163 for (i, m) in MONTHS.iter().enumerate() {
164 if low.contains(m) {
165 return Some(format!("time/{year}/{:02}", i + 1));
166 }
167 }
168 Some(format!("time/{year}"))
169}
170
171fn region_of(place: &str) -> Option<&'static str> {
176 Some(match place {
177 "japan" | "tokyo" | "osaka" | "korea" | "seoul" | "china" | "beijing" | "shanghai" | "india" | "mumbai"
178 | "australia" | "sydney" | "brisbane" | "melbourne" | "singapore" | "thailand" | "bangkok" => "apac",
179 "usa" | "us" | "united-states" | "california" | "texas" | "seattle" | "austin" | "denver" | "miami"
180 | "boston" | "canada" | "toronto" | "mexico" => "amer",
181 "germany" | "berlin" | "munich" | "france" | "paris" | "uk" | "london" | "spain" | "madrid" | "italy"
182 | "rome" | "sweden" | "netherlands" | "poland" => "emea",
183 "brazil" | "sao-paulo" | "argentina" | "chile" | "colombia" => "latam",
184 _ => return None,
185 })
186}
187
188pub fn geo_uri(text: &str) -> String {
190 let s = slug(text);
191 match region_of(&s) {
192 Some(r) => format!("geo/{r}/{s}"),
193 None => format!("geo/{s}"),
194 }
195}
196
197pub fn entity_uri(kind: &str, text: &str) -> String {
200 let t = slug(kind);
201 let t = if t.is_empty() || t == "ent" { "ent".to_string() } else { t };
202 format!("{t}/{}", slug(text))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn qty_buckets_are_hierarchical_and_globbable() {
211 assert_eq!(qty_uri("qty-temp", 27.0).unwrap(), "qty/temp/celsius/0_to_30");
212 assert_eq!(qty_uri("qty-temp", 45.0).unwrap(), "qty/temp/celsius/30_to_60");
213 assert_eq!(qty_uri("qty-temp", 500.0).unwrap(), "qty/temp/celsius/over_300");
214 assert_eq!(qty_uri("qty-temp", -5.0).unwrap(), "qty/temp/celsius/under_0");
215 assert_eq!(qty_uri("qty-length", 38.0).unwrap(), "qty/length/metre/10_to_100");
216 assert!(qty_uri("qty-unknown", 1.0).is_none());
217 let u = qty_uri("qty-temp", 27.0).unwrap();
219 for p in ["qty/", "qty/temp/", "qty/temp/celsius/"] {
220 assert!(u.starts_with(p), "{u} must be reachable by {p}*");
221 }
222 }
223
224 #[test]
225 fn time_hierarchy() {
226 assert_eq!(time_uri("Q3 2026").unwrap(), "time/2026/q3");
227 assert_eq!(time_uri("March 2026").unwrap(), "time/2026/03");
228 assert_eq!(time_uri("in 2026").unwrap(), "time/2026");
229 assert!(time_uri("last quarter").is_none());
230 }
231
232 #[test]
233 fn geo_and_rel_and_state() {
234 assert_eq!(geo_uri("Brisbane"), "geo/apac/brisbane");
235 assert_eq!(geo_uri("Atlantis"), "geo/atlantis");
236 assert_eq!(rel_uri("supplies", Role::Actor), "rel/supplies/+");
237 assert_eq!(rel_uri("supplies", Role::Target), "rel/supplies/-");
238 assert_eq!(state_uri(true, false), "state/negated");
239 assert_eq!(belief_level(true, false), -1.0);
240 assert_eq!(belief_level(false, true), 0.5);
241 assert_eq!(entity_uri("ORG", "Toyota"), "org/toyota");
242 assert_eq!(entity_uri("ENT", "battery cell"), "ent/battery-cell");
243 }
244
245 #[test]
246 fn an_emphatic_not_is_not_a_denial() {
247 for affirmation in [
252 "acme corp is not only a supplier but also a partner",
253 "epsilon corp is not merely a vendor; it is the prime contractor",
254 "it is not just a contract, it is a partnership",
255 "beta is not simply compliant, it exceeds the standard",
256 "gamma is not solely responsible for the programme",
257 ] {
258 assert!(!denies_claim(affirmation), "read as a denial: {affirmation}");
259 }
260 }
261
262 #[test]
263 fn a_real_denial_is_still_detected() {
264 for denial in [
265 "beta corp is not permitted to supply the ministry",
266 "milotic is not permitted in series 1 play",
267 "the vendor is not compliant with the standard",
268 "acme is no longer approved",
269 ] {
270 assert!(denies_claim(denial), "missed a denial: {denial}");
271 }
272 assert!(
274 denies_claim("acme is not only late, and the contract is not permitted to continue"),
275 "an emphatic clause masked a real denial"
276 );
277 }
278
279 #[test]
280 fn the_known_limitation_is_recorded_rather_than_hidden() {
281 let scope_limited = "gamma corp is no longer under review and remains approved";
286 assert!(
287 denies_claim(scope_limited),
288 "if this now returns false, the scope limitation was fixed — update this test and the doc comment"
289 );
290 }
291
292 #[test]
293 fn belief_level_keeps_the_fourth_polarity_state() {
294 assert_eq!(belief_level(false, false), 1.0);
296 assert_eq!(belief_level(false, true), 0.5);
297 assert_eq!(belief_level(true, true), -0.5);
298 assert_eq!(belief_level(true, false), -1.0);
299 }
300}