Skip to main content

steeldb/
linter.rs

1//! The **linter** — SteelDB's compile-time boundary between a probabilistic agent and the deterministic
2//! engine (paper §2). Before any IKL query executes, every atom is validated against the corpus
3//! Vocabulary Space `V` (the Bitmap Symbol Table). Unknown terms are rejected with a *"did you mean"*
4//! nearest-term correction; wildcards are expanded to their real child terms; and unbalanced
5//! parentheses are repaired. This is what turns a hallucinated URI into a caught compile error instead
6//! of a silently wrong answer.
7//!
8//! Validation is deterministic and model-free: exact membership, prefix-glob coverage, and — for a miss
9//! — an edit-distance suggestion scoped to the atom's own facet dimension.
10
11use crate::tokenql::{parse, Node};
12use std::collections::{HashMap, HashSet};
13
14/// Status of a single atom against the vocabulary.
15#[derive(Debug, Clone, PartialEq)]
16pub enum AtomStatus {
17    /// Exact term in the vocabulary.
18    Known,
19    /// A glob/prefix pattern that matches at least `n` real terms.
20    Wildcard(usize),
21    /// Not in the vocabulary — with nearest-term suggestions ("did you mean …").
22    Unknown(Vec<String>),
23}
24
25#[derive(Debug, Clone)]
26pub struct LintError {
27    pub atom: String,
28    pub message: String,
29    pub suggestions: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct LintReport {
34    pub ok: bool,
35    pub errors: Vec<LintError>,
36    /// Set when the input had unbalanced parentheses that were auto-repaired.
37    pub repaired: Option<String>,
38}
39
40/// The vocabulary index the linter validates against — the Bitmap Symbol Table's key set, organised by
41/// facet dimension so suggestions stay in-dimension (a bad `geo/…` is corrected against real `geo/…`).
42pub struct Linter {
43    tokens: HashSet<String>,
44    facets: HashSet<String>,
45    by_facet: HashMap<String, Vec<String>>, // facet → leaf values
46    /// Numeric fields are a **separate namespace** from facet tokens: in `(num range_km gt 500)` the
47    /// `range_km` is a column in the numeric layer, not a `facet/value` membership. Validating it against
48    /// the token vocabulary rejects every numeric predicate ever written.
49    numeric: HashSet<String>,
50}
51
52/// Every head the linter treats as structure rather than vocabulary.
53///
54/// This list and the evaluator's match arms are the two halves of one contract, and they used to be able to
55/// drift: a head whitelisted here with no arm in the evaluator passed linting and then evaluated to an empty
56/// set, which is indistinguishable from "no rows matched". `s-path`, `source`, `target` and `constraint` all
57/// sat in that state. A test in `tokenql` now asserts every entry is either evaluated or declared a sub-form.
58pub const STRUCTURAL_HEADS: &[&str] = &[
59    // top-level operators — each needs an evaluator arm
60    "and", "or", "not", "num", "evidence", "combine-ds", "s-path",
61    // sub-forms, only meaningful inside a parent head
62    "stream", "mass", "source", "target", "constraint",
63];
64
65/// Heads that are only valid inside a parent form, so they need no evaluator arm of their own.
66pub const SUB_FORMS: &[&str] = &["stream", "mass", "source", "target", "constraint"];
67
68/// IKL keywords / structural atoms that are NOT vocabulary terms (operators, extended-IKL heads, and
69/// `:keyword` argument markers). These are skipped during atom validation.
70fn is_structural(a: &str) -> bool {
71    STRUCTURAL_HEADS.contains(&a)
72        || a.starts_with(':')
73        || a.parse::<f64>().is_ok()
74        || matches!(a, "ge" | "gt" | "le" | "lt" | "eq" | "ne" | "true" | "false")
75}
76
77fn facet_of(token: &str) -> &str {
78    token.split('/').next().unwrap_or(token)
79}
80fn leaf_of(token: &str) -> &str {
81    match token.find('/') {
82        Some(i) => &token[i + 1..],
83        None => token,
84    }
85}
86
87/// Levenshtein edit distance (small strings; iterative two-row DP).
88fn edit_distance(a: &str, b: &str) -> usize {
89    let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
90    let mut prev: Vec<usize> = (0..=b.len()).collect();
91    let mut cur = vec![0usize; b.len() + 1];
92    for i in 1..=a.len() {
93        cur[0] = i;
94        for j in 1..=b.len() {
95            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
96            cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
97        }
98        std::mem::swap(&mut prev, &mut cur);
99    }
100    prev[b.len()]
101}
102
103impl Linter {
104    /// Build from the corpus's vocabulary (all `facet/value` tokens).
105    pub fn from_tokens<I: IntoIterator<Item = String>>(tokens: I) -> Linter {
106        let mut set = HashSet::new();
107        let mut facets = HashSet::new();
108        let mut by_facet: HashMap<String, Vec<String>> = HashMap::new();
109        for t in tokens {
110            facets.insert(facet_of(&t).to_string());
111            by_facet.entry(facet_of(&t).to_string()).or_default().push(leaf_of(&t).to_string());
112            set.insert(t);
113        }
114        Linter { tokens: set, facets, by_facet, numeric: HashSet::new() }
115    }
116
117    /// Declare the corpus's numeric fields so `(num <field> …)` predicates validate against the right
118    /// namespace.
119    pub fn with_numeric_fields<I: IntoIterator<Item = String>>(mut self, fields: I) -> Self {
120        self.numeric = fields.into_iter().collect();
121        self
122    }
123
124    pub fn numeric_fields(&self) -> Vec<String> {
125        let mut v: Vec<String> = self.numeric.iter().cloned().collect();
126        v.sort();
127        v
128    }
129
130    /// Validate one atom: exact, wildcard-coverage, or unknown-with-suggestions.
131    pub fn validate_atom(&self, atom: &str) -> AtomStatus {
132        if atom.contains('*') {
133            let prefix = &atom[..atom.find('*').unwrap()];
134            let n = self.tokens.iter().filter(|t| t.starts_with(prefix)).count();
135            if n > 0 {
136                return AtomStatus::Wildcard(n);
137            }
138            // A wildcard that expands to nothing used to pass. `gene/*` linted clean while `gene/brca1`
139            // refused, so asking for a category the corpus does not have returned an empty set that reads as
140            // "no matches" — the exact confusion this linter exists to prevent. A wildcard is checked like any
141            // other atom.
142            return AtomStatus::Unknown(self.suggest(atom));
143        }
144        if self.tokens.contains(atom) {
145            return AtomStatus::Known;
146        }
147        AtomStatus::Unknown(self.suggest(atom))
148    }
149
150    /// Is `facet` a dimension this corpus actually has?
151    pub fn has_facet(&self, facet: &str) -> bool {
152        self.facets.contains(facet)
153    }
154
155    /// The corpus's real facet names, sorted — what to show when a query names a dimension that does not
156    /// exist here.
157    pub fn facet_names(&self) -> Vec<String> {
158        let mut v: Vec<String> = self.facets.iter().cloned().collect();
159        v.sort();
160        v
161    }
162
163    /// Nearest real terms to a miss, scoped to the atom's own facet. Returns empty when the facet itself
164    /// is unknown — suggesting values from an unrelated dimension (`artifact/power_cube` →
165    /// `state/negated`) is worse than saying the dimension doesn't exist, which `lint` reports instead.
166    pub fn suggest(&self, atom: &str) -> Vec<String> {
167        let facet = facet_of(atom);
168        let leaf = leaf_of(atom);
169        // only correct WITHIN a real facet; a bogus facet is a different error, reported by `lint`
170        let real_facet = match self.facets.get(facet) {
171            Some(f) => f.clone(),
172            None => return Vec::new(),
173        };
174        let mut cands: Vec<(usize, &String)> =
175            self.by_facet.get(&real_facet).map(|ls| ls.iter().map(|l| (edit_distance(l, leaf), l)).collect()).unwrap_or_default();
176        cands.sort_by_key(|(d, _)| *d);
177        cands.dedup_by(|a, b| a.1 == b.1);
178        cands.into_iter().take(3).map(|(_, l)| format!("{real_facet}/{l}")).collect()
179    }
180
181    /// Lint a full IKL expression: repair unbalanced parens, then validate every vocabulary atom.
182    pub fn lint(&self, ikl: &str) -> LintReport {
183        let (expr, repaired) = balance_parens(ikl);
184        let mut errors = Vec::new();
185        self.walk(&parse(&expr), &mut errors);
186        LintReport { ok: errors.is_empty(), errors, repaired }
187    }
188
189    fn walk(&self, node: &Node, errors: &mut Vec<LintError>) {
190        match node {
191            Node::Atom(a) => {
192                if is_structural(a) {
193                    return;
194                }
195                if let AtomStatus::Unknown(sug) = self.validate_atom(a) {
196                    let facet = facet_of(a);
197                    let msg = if !sug.is_empty() {
198                        format!("term '{a}' not found; did you mean {}?", sug.join(", "))
199                    } else if a.contains('/') && !self.has_facet(facet) {
200                        format!("dimension '{facet}' is not in this corpus; facets are: {}", self.facet_names().join(", "))
201                    } else {
202                        format!("term '{a}' is not in the vocabulary")
203                    };
204                    errors.push(LintError { atom: a.clone(), message: msg, suggestions: sug });
205                }
206            }
207            Node::List(items) => {
208                // `(num <field> <op> <value>)`: the field belongs to the numeric layer, so it is checked
209                // against that namespace and the op/value are structural — recursing as tokens would
210                // reject every numeric predicate.
211                if let Some(Node::Atom(head)) = items.first() {
212                    if head == "num" {
213                        if let Some(Node::Atom(field)) = items.get(1) {
214                            if !self.numeric.is_empty() && !self.numeric.contains(field.as_str()) {
215                                let mut sug: Vec<String> = self.numeric.iter().cloned().collect();
216                                sug.sort_by_key(|f| edit_distance(f, field));
217                                sug.truncate(3);
218                                errors.push(LintError {
219                                    atom: field.clone(),
220                                    message: format!(
221                                        "'{field}' is not a numeric field; numeric fields are: {}",
222                                        self.numeric_fields().join(", ")
223                                    ),
224                                    suggestions: sug,
225                                });
226                            }
227                        }
228                        return; // op and value need no vocabulary check
229                    }
230                    // §6.1 evidential forms carry their own structure. Their keyword values — a stream id, a
231                    // conflict threshold, a mass — are not vocabulary terms, and checking them as such
232                    // rejected every well-formed fusion query ("term 'sensor' is not in the vocabulary").
233                    if head == "combine-ds" {
234                        for it in &items[1..] {
235                            match it {
236                                // :max-conflict and its number
237                                Node::Atom(_) => {}
238                                // (stream …) blocks
239                                other => self.walk(other, errors),
240                            }
241                        }
242                        return;
243                    }
244                    if head == "stream" {
245                        // only :mass-assignments carries atoms; :id and its value do not
246                        let mut i = 1;
247                        while i < items.len() {
248                            if matches!(&items[i], Node::Atom(k) if k == ":mass-assignments") {
249                                if let Some(list) = items.get(i + 1) {
250                                    self.walk(list, errors);
251                                }
252                                i += 2;
253                                continue;
254                            }
255                            i += 1;
256                        }
257                        return;
258                    }
259                    if head == "mass" {
260                        // (mass (<atom>+) <float>) — check the focal atoms, skip the number
261                        if let Some(atoms) = items.get(1) {
262                            self.walk(atoms, errors);
263                        }
264                        return;
265                    }
266                }
267                // Skip the leading operator atom; recurse into the rest.
268                let skip_first = matches!(items.first(), Some(Node::Atom(a)) if is_structural(a));
269                for (i, it) in items.iter().enumerate() {
270                    if i == 0 && skip_first {
271                        continue;
272                    }
273                    self.walk(it, errors);
274                }
275            }
276        }
277    }
278}
279
280/// Repair unbalanced parentheses (a common LLM syntax drift): append missing `)` or trim extras.
281/// Returns `(repaired_expr, Some(repaired) if changed)`.
282pub fn balance_parens(expr: &str) -> (String, Option<String>) {
283    let mut depth: i32 = 0;
284    let mut out = String::with_capacity(expr.len());
285    for c in expr.chars() {
286        match c {
287            '(' => {
288                depth += 1;
289                out.push(c);
290            }
291            ')' => {
292                if depth > 0 {
293                    depth -= 1;
294                    out.push(c);
295                } // drop unmatched ')'
296            }
297            _ => out.push(c),
298        }
299    }
300    for _ in 0..depth {
301        out.push(')'); // close unmatched '('
302    }
303    if out == expr {
304        (out, None)
305    } else {
306        let r = out.clone();
307        (out, Some(r))
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn linter() -> Linter {
316        Linter::from_tokens(
317            ["org/toyota", "org/honda", "artifact/battery_cell", "geo/apac", "powertrain/electric", "powertrain/diesel"]
318                .into_iter()
319                .map(String::from),
320        )
321    }
322
323    #[test]
324    fn exact_and_wildcard() {
325        let l = linter();
326        assert_eq!(l.validate_atom("org/toyota"), AtomStatus::Known);
327        assert!(matches!(l.validate_atom("powertrain/*"), AtomStatus::Wildcard(n) if n == 2));
328    }
329
330    #[test]
331    fn did_you_mean_scoped_to_facet() {
332        let l = linter();
333        // a near-miss leaf in a known facet → suggests the real term
334        match l.validate_atom("artifact/battery_cel") {
335            AtomStatus::Unknown(s) => assert_eq!(s, vec!["artifact/battery_cell".to_string()]),
336            other => panic!("expected Unknown, got {other:?}"),
337        }
338        // hallucinated term
339        match l.validate_atom("artifact/power_cube") {
340            AtomStatus::Unknown(s) => assert!(s.iter().all(|x| x.starts_with("artifact/"))),
341            other => panic!("expected Unknown, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn numeric_predicates_validate_against_the_numeric_namespace() {
347        let l = linter().with_numeric_fields(["range_km".to_string(), "year".to_string()]);
348        // a real numeric field must PASS (previously every numeric predicate was rejected)
349        assert!(l.lint("(num range_km gt 500)").ok, "{:?}", l.lint("(num range_km gt 500)").errors);
350        assert!(l.lint("(and powertrain/electric (num year ge 2020))").ok);
351        // a bogus field is caught, and suggestions come from the numeric namespace — not from tokens
352        let bad = l.lint("(num rnge_km gt 500)");
353        assert!(!bad.ok);
354        assert_eq!(bad.errors[0].suggestions[0], "range_km");
355        assert!(bad.errors[0].message.contains("numeric field"), "{}", bad.errors[0].message);
356        // with no declared numeric fields the predicate is accepted rather than falsely rejected
357        assert!(linter().lint("(num anything gt 1)").ok);
358    }
359
360    #[test]
361    fn unknown_dimension_reports_facets_not_bogus_suggestions() {
362        let l = linter();
363        // a value miss inside a REAL facet → in-facet suggestion
364        let r = l.lint("artifact/battery_cel");
365        assert!(!r.ok);
366        assert_eq!(r.errors[0].suggestions[0], "artifact/battery_cell");
367        // an unknown DIMENSION → no cross-facet guessing; list the real facets instead
368        let r2 = l.lint("gene/brca1");
369        assert!(!r2.ok);
370        assert!(r2.errors[0].suggestions.is_empty(), "must not suggest values from another dimension");
371        assert!(r2.errors[0].message.contains("dimension 'gene' is not in this corpus"), "{}", r2.errors[0].message);
372        assert!(r2.errors[0].message.contains("artifact"), "should list real facets: {}", r2.errors[0].message);
373    }
374
375    #[test]
376    fn lint_catches_bad_atom_and_repairs_parens() {
377        let l = linter();
378        let r = l.lint("(and org/toyota (not powertrain/diesel)"); // missing ')'
379        assert!(r.repaired.is_some());
380        assert!(r.ok, "all atoms valid: {:?}", r.errors);
381        let bad = l.lint("(and org/tyota powertrain/electric)");
382        assert!(!bad.ok);
383        assert_eq!(bad.errors[0].suggestions[0], "org/toyota"); // nearest first
384    }
385
386    #[test]
387    fn evidential_fusion_forms_lint_clean() {
388        let l = Linter::from_tokens(
389            ["artifact/battery_cell", "artifact/power_cube"].into_iter().map(String::from));
390
391        // the bug: a stream id was type-checked as a vocabulary term, so every well-formed fusion query
392        // was rejected with "term 'sensor' is not in the vocabulary"
393        let q = "(combine-ds :max-conflict 0.20 \
394                   (stream :id sensor :mass-assignments ((mass (artifact/battery_cell) 0.7) \
395                                                         (mass (artifact/battery_cell artifact/power_cube) 0.3))))";
396        let r = l.lint(q);
397        assert!(r.ok, "should lint clean, got {:?}", r.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>());
398
399        assert!(l.lint("(evidence artifact/battery_cell :min-bel 0.8 :max-pl 0.95)").ok);
400
401        // a real unknown INSIDE a focal set must still be caught — the skip must not become a blind spot
402        let bad = l.lint("(combine-ds :max-conflict 0.2 \
403                            (stream :id s :mass-assignments ((mass (artifact/nonexistent) 1.0))))");
404        assert!(!bad.ok, "an unknown focal atom must still be reported");
405        assert!(bad.errors.iter().any(|e| e.atom.contains("nonexistent")), "{:?}", bad.errors);
406    }
407
408    #[test]
409    fn a_wildcard_on_a_missing_category_is_refused() {
410        // Shipped as 0.1.0 with this wrong: `gene/*` linted clean while `gene/brca1` refused, so asking for a
411        // category the corpus lacks returned an empty set indistinguishable from "no matches".
412        let l = Linter::from_tokens(["battle/defeated", "survey/elevation"].into_iter().map(String::from));
413
414        assert!(l.lint("battle/*").ok, "a real category must still expand");
415        assert!(l.lint("survey/*").ok);
416
417        let bad = l.lint("gene/*");
418        assert!(!bad.ok, "a wildcard cannot bypass the facet check");
419        assert!(
420            bad.errors[0].message.contains("battle") && bad.errors[0].message.contains("survey"),
421            "and must name what does exist: {}",
422            bad.errors[0].message
423        );
424
425        // nested, so the check cannot be skipped by burying it in an expression
426        assert!(!l.lint("(and battle/* gene/*)").ok);
427        assert!(!l.lint("(or (not gene/*) battle/*)").ok);
428    }
429}