Skip to main content

steeldb/
dimensions.rs

1//! **Vocabulary Space `V`** — the canonical URI taxonomy (paper §1, §2). Every producer's output is
2//! normalised into one of six hierarchical, slash-delimited dimensions. The hierarchy is load-bearing,
3//! not cosmetic: **subtree wildcards only work if the URIs have depth.** `glob_match` already globs the
4//! whole token, so `qty/temp/*`, `time/2026/*`, `geo/apac/*`, `rel/supplies/+` all resolve the moment
5//! emission is hierarchical — whereas a flat `qty/27c` or `time/q3-2026` is unreachable by prefix.
6//!
7//! The six dimensions:
8//!   1. entities & artifacts   `org/toyota`, `artifact/battery_cell`      (type from the tagger)
9//!   2. relational roles       `rel/supplies/+`, `rel/supplies/-`         (polarity = argument side)
10//!   3. spatial/temporal loci  `time/2026/q3`, `geo/apac/brisbane`        (deterministic, here)
11//!   4. quantities/tolerances  `qty/temp/celsius/under_30`                (deterministic, here)
12//!   5. epistemic modifiers    `state/negated`, `trend/cost/decrease`     (from the tagger)
13//!   6. latent motifs          `motif/hazard/thermal`                     (SPLADE / Sinkhorn-OT)
14
15use crate::projector::slug;
16
17/// Which side of a relation an argument occupies — the grammatical polarity of dimension 2.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Role {
20    /// the acting participant → `rel/<pred>/+`
21    Actor,
22    /// the affected participant → `rel/<pred>/-`
23    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
35/// Dimension 2: `rel/<predicate>/<+|->`. Polarity belongs to the *argument side*, so a bound pair emits
36/// two tokens (actor `+`, target `-`) for one predicate.
37pub fn rel_uri(predicate: &str, role: Role) -> String {
38    format!("rel/{}/{}", slug(predicate), role.mark())
39}
40
41/// Dimension 5: epistemic state of an assertion. `confidence` and `negated` come from the tagger's
42/// epistemic head; both map onto the 4-level Dempster-Shafer polarity in `InfonIndex::add_infon_polar`.
43pub 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
52/// Emphatic constructions: `not` negating a predicate nominal rather than the claim.
53///
54/// "Acme is **not only** a supplier but also a partner" asserts MORE than the plain sentence, it does not deny
55/// it. Same for "not merely", "not just", "not simply". Reading these as denials flips the polarity of an
56/// observation, and a flipped polarity is worse here than a missing one: it manufactures a contradiction that
57/// was never observed, inflating the conflict metric and pushing `combine-ds` to refuse a merge it should have
58/// made. Three of six hand-checked sentences were wrong this way.
59const EMPHATIC: &[&str] = &["not only", "not merely", "not just", "not simply", "not solely"];
60
61/// Whether lowercased `text` denies its claim, as far as substring cues can tell.
62///
63/// This is a deliberately shallow reading and it is the weakest part of the epistemic dimension. Cues cannot
64/// see scope: "no longer under review, and remains approved" still comes back negated here, because "no longer"
65/// negates the hedge rather than the claim and nothing in a substring match can tell those apart. What this
66/// function does do is refuse the unambiguous false positives — an emphatic `not` is never a denial.
67///
68/// A calibrated per-observation judgement is the real fix; this is the floor, not the ceiling.
69pub 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    // "is not X" denies, unless the X is an emphatic construction
77    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
91/// The DS belief level (±1 / ±0.5) implied by an epistemic reading — the bridge from dimension 5 to the
92/// signed-infon layer.
93pub 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
102/// Dimension 6: `motif/<facet>/<term>` — a SPLADE head's learned facet plus its active term.
103pub fn motif_uri(facet: &str, term: &str) -> String {
104    format!("motif/{}/{}", slug(facet), slug(term))
105}
106
107// ── dimension 4: quantities → `qty/<dimension>/<unit>/<bucket>` ────────────────────────────────
108
109/// Canonical SI unit name per dimensional field, plus the bucket ladder (ascending edges) used to
110/// quantize a magnitude into a boolean-matchable range token.
111fn qty_scheme(field: &str) -> Option<(&'static str, &'static str, &'static [f64])> {
112    // (dimension, canonical unit, bucket edges in SI)
113    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
126/// Format a number for a URI label: integers bare, else trimmed decimal, `-` → `neg`.
127fn 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
132/// Dimension 4: canonicalise a parsed quantity into a hierarchical URI with a range bucket, e.g.
133/// `("qty-temp", 27.0)` → `qty/temp/celsius/under_30`. Returns `None` for unknown dimensions.
134pub 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
144// ── dimension 3: temporal loci → `time/<year>[/<q|month>]` ─────────────────────────────────────
145
146/// Dimension 3 (temporal): normalise a TIME span into a bucketed hierarchy — `Q3 2026` → `time/2026/q3`,
147/// `March 2026` → `time/2026/03`, `2026` → `time/2026`. Returns `None` if no year is present (callers
148/// fall back to a flat slug).
149pub fn time_uri(text: &str) -> Option<String> {
150    let low = text.to_lowercase();
151    // year: first standalone 4-digit 19xx/20xx
152    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    // quarter
157    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
171// ── dimension 3: spatial loci → `geo/<region>/<place>` ─────────────────────────────────────────
172
173/// Seed region map so geo URIs have a queryable parent (`geo/apac/*`). Deliberately small — the growing
174/// gazetteer is the mechanism for extending coverage per corpus; unknown places stay one level deep.
175fn 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
188/// Dimension 3 (spatial): `Brisbane` → `geo/apac/brisbane`; unknown → `geo/<slug>`.
189pub 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
197/// Dimension 1: `<type>/<entity>` — the type comes from the tagger's span kind (`ORG` → `org`). Falls
198/// back to the generic `ent` bucket when the tagger is untyped.
199pub 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        // every level is a valid wildcard prefix
218        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        // These read as denials to a substring match and are actually affirmations — they assert MORE than the
248        // plain sentence. Getting this wrong flips an observation's polarity, which manufactures a contradiction
249        // that was never observed: worse than missing one, because it inflates the conflict metric and can push
250        // `combine-ds` into refusing a merge it should have made.
251        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        // an emphatic clause must not mask a genuine denial elsewhere in the same text
273        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        // Substring cues cannot see scope. Here "no longer" negates the HEDGE ("under review"), not the claim
282        // ("remains approved"), and nothing available to a substring match distinguishes those. This asserts the
283        // CURRENT behaviour so the gap is visible in the suite rather than discovered by a user — a calibrated
284        // per-observation judgement is the fix, not a longer cue list.
285        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        // denied AND hedged is neither a flat denial nor a hedge; collapsing the pair loses a real reading.
295        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}