Skip to main content

sqlite_graphrag/agent_surface/
vocabulary.rs

1//! GAP-SG-202 / GAP-SG-203: resolving the caller's keys against the envelope
2//! BEFORE any predicate runs.
3//!
4//! Until v1.2.6 a key the envelope never carried was indistinguishable from a
5//! key whose value happened to be absent. `--select body_length read` answered
6//! with an envelope missing the field, `--filter chave_errada=x list` answered
7//! `count: 0` over 1892 memories, and both exited `0`. The caller read its own
8//! typo as "the data is not there".
9//!
10//! The cure is to ask, once, where each requested key actually lives:
11//!
12//! * [`KeyOrigin::Element`] — the predicate has something to work on.
13//! * [`KeyOrigin::EnvelopeOnly`] — the key names a member of the envelope, not a
14//!   field of the elements. `--filter integrity_ok=false health` is this case:
15//!   `integrity_ok` is a top-level scalar, the predicate was redirected onto the
16//!   `checks` array, and all eight checks were deleted while `integrity_ok: true`
17//!   survived in the payload contradicting the very predicate.
18//! * [`KeyOrigin::Absent`] — the key exists nowhere the surface can see.
19//!
20//! # Cost
21//!
22//! Resolution scans EVERY element and allocates nothing: [`filter::resolve`] is a
23//! pointer walk over borrowed data. Sampling here would be the wrong economy —
24//! a key present only in an unsampled element would be reported absent, and the
25//! gate would refuse a legitimate request. Sampling belongs to the suggestion
26//! path alone, which runs only after a key has already failed.
27//!
28//! Serial by decision, not by omission: the parallelism rules forbid paying
29//! coordination overhead for work smaller than it, and this is a handful of
30//! pointer walks per requested key.
31
32use super::filter;
33use crate::constants::{
34    agent_surface_field_synonym_groups, K_VOCABULARY_MAX_KEYS, K_VOCABULARY_MAX_SUGGESTIONS,
35    K_VOCABULARY_SAMPLE_ELEMENTS, VOCABULARY_SUGGESTION_MIN_SIMILARITY,
36};
37use serde_json::Value;
38use std::collections::BTreeSet;
39
40/// GAP-SG-230: every spelling that names the same field as `key`, `key` first.
41///
42/// The synonym applies to the LAST segment of a dotted path and the prefix is
43/// carried over unchanged, so `graph_context.entity_type` yields
44/// `graph_context.type` and never a bare `type`. A synonym is a fact about a
45/// FIELD NAME; where that field sits is the caller's statement about the payload
46/// and is not ours to rewrite.
47///
48/// The caller's own spelling is always first, so a payload that carries BOTH
49/// spellings — which nothing emits today, and which a future struct could —
50/// resolves to what was asked for rather than to whichever the table lists first.
51///
52/// Allocates one small `Vec` per REQUESTED key, never per element. That is the
53/// distinction the module docs draw about cost: [`Scope::classify`] still walks
54/// every element with borrowed data, and the vector here is built once before
55/// that walk starts, so a 107 135-element scan pays for it exactly once.
56///
57/// GAP-SG-274: `command` is the subcommand slug and it selects which groups of
58/// the table apply, so `kind` names the entity type under `graph` and stays the
59/// line discriminator under `graph-ndjson`. A spelling listed by two applicable
60/// groups is emitted once.
61fn spellings(key: &str, command: Option<&str>) -> Vec<String> {
62    let (prefix, leaf) = match key.rfind('.') {
63        Some(idx) => (&key[..=idx], &key[idx + 1..]),
64        None => ("", key),
65    };
66    let mut out = vec![key.to_string()];
67    for group in agent_surface_field_synonym_groups(command) {
68        if !group.contains(&leaf) {
69            continue;
70        }
71        for spelling in group {
72            let candidate = format!("{prefix}{spelling}");
73            if *spelling != leaf && !out.contains(&candidate) {
74                out.push(candidate);
75            }
76        }
77    }
78    out
79}
80
81/// Where a requested key was found.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum KeyOrigin {
84    /// Present in at least one result element.
85    Element,
86    /// Present on the envelope but in none of the elements.
87    EnvelopeOnly,
88    /// Present nowhere the surface observed.
89    Absent,
90}
91
92/// The vocabulary one request is resolved against.
93///
94/// Borrows both halves; nothing here outlives the call that builds it.
95pub struct Scope<'a> {
96    /// Result elements, already lifted out of the envelope.
97    elements: &'a [Value],
98    /// What remains of the envelope once the result array was lifted out.
99    envelope: &'a Value,
100    /// GAP-SG-274: subcommand slug scoping the field-synonym table, if known.
101    command: Option<&'a str>,
102}
103
104impl<'a> Scope<'a> {
105    /// Builds a scope over the elements and the envelope that carried them.
106    ///
107    /// The synonym scope starts unset, which admits only the groups that hold
108    /// for every command. That is the fail-safe half of the pair: a caller that
109    /// never states which subcommand it is resolving for gets no mode-specific
110    /// synonym, rather than the synonyms of some arbitrary mode.
111    pub fn new(elements: &'a [Value], envelope: &'a Value) -> Self {
112        Self {
113            elements,
114            envelope,
115            command: None,
116        }
117    }
118
119    /// GAP-SG-274: states which subcommand slug this scope resolves keys for.
120    ///
121    /// Consumed by the synonym table alone. `graph` declares `kind` a spelling
122    /// of the entity type; `graph-ndjson` does not, because there `kind` is the
123    /// line discriminator and answering `node` to `--select type` would be a
124    /// wrong value rather than a missing one.
125    #[must_use]
126    pub fn with_command(mut self, command: Option<&'a str>) -> Self {
127        self.command = command;
128        self
129    }
130
131    /// `true` when there are no elements to resolve a key against.
132    ///
133    /// An empty result array carries no vocabulary, so it cannot tell a key that
134    /// does not exist from a key that simply had no row to appear in.
135    pub fn is_empty(&self) -> bool {
136        self.elements.is_empty()
137    }
138
139    /// Where `key` lives, if anywhere.
140    ///
141    /// An envelope with no elements can still answer [`KeyOrigin::EnvelopeOnly`],
142    /// which is what makes the scalar-envelope refusal precise instead of a
143    /// blanket "no array here".
144    pub fn classify(&self, key: &str) -> KeyOrigin {
145        // GAP-SG-230: every spelling of the field counts, not just the one the
146        // caller typed. This is the single point the four shaping knobs share —
147        // `--select` reaches it through `resolve_projection`, and `--filter`,
148        // `--sort` and `--dedupe-by` reach it directly — so resolving the synonym
149        // here is what stops three of the four from needing their own copy.
150        let candidates = spellings(key, self.command);
151        if self.elements.iter().any(|element| {
152            candidates
153                .iter()
154                .any(|candidate| filter::resolve(element, candidate).is_some())
155        }) {
156            return KeyOrigin::Element;
157        }
158        if candidates
159            .iter()
160            .any(|candidate| filter::resolve(self.envelope, candidate).is_some())
161        {
162            return KeyOrigin::EnvelopeOnly;
163        }
164        KeyOrigin::Absent
165    }
166
167    /// GAP-SG-230: the spelling this payload actually uses for `key`.
168    ///
169    /// Returns `key` itself when the payload carries it, the synonym when the
170    /// payload spells the same field differently, and `None` when no spelling in
171    /// the group is present anywhere in scope.
172    ///
173    /// # Why this is a separate question from [`Self::classify`]
174    ///
175    /// `classify` answers "may this request proceed"; this answers "which name do
176    /// I look the value up under". They only look like one question while both
177    /// spellings are the same string. `graph entities` emits `entity_type` and
178    /// `graph --format json` emits `type` for the very same column, so a caller
179    /// that learned one name asks with it against both — and the four shaping
180    /// knobs walk the path with the name the CALLER wrote, never with the verdict
181    /// this scope reached. Answering only the first question would let a request
182    /// pass the gate and then match nothing, which trades a refusal that names
183    /// the fix for an empty set with `exit 0`.
184    ///
185    /// Elements are consulted before the envelope, mirroring `classify`, so the
186    /// answer describes the place a predicate or a projection will actually look.
187    pub fn effective_key(&self, key: &str) -> Option<String> {
188        let candidates = spellings(key, self.command);
189        for candidate in &candidates {
190            if self
191                .elements
192                .iter()
193                .any(|element| filter::resolve(element, candidate).is_some())
194            {
195                return Some(candidate.clone());
196            }
197        }
198        candidates
199            .into_iter()
200            .find(|candidate| filter::resolve(self.envelope, candidate).is_some())
201    }
202
203    /// Key names close enough to `key` to be worth offering as a correction.
204    ///
205    /// Ordered by descending similarity and capped at
206    /// [`K_VOCABULARY_MAX_SUGGESTIONS`]. An empty vector means nothing in the
207    /// vocabulary resembled the request, which is itself informative: the caller
208    /// is looking at the wrong command, not at a typo.
209    ///
210    /// GAP-SG-230: a DECLARED synonym that the payload really carries is placed
211    /// first and bypasses the similarity floor entirely. Similarity is a proxy
212    /// for "you mistyped this"; a synonym is not a typo, it is the same field
213    /// under the name a sibling surface chose, and the table says so as a fact.
214    /// Leaving it to Jaro-Winkler was measured to lose exactly the case this
215    /// exists for: `entity_type` against `type` shares no prefix, so the metric
216    /// scores it below [`VOCABULARY_SUGGESTION_MIN_SIMILARITY`] and the caller
217    /// who asked with the sibling spelling was told nothing resembled its key —
218    /// while the field sat right there under another name.
219    pub fn suggestions(&self, key: &str) -> Vec<String> {
220        let (vocabulary, _) = self.candidate_keys();
221        if vocabulary.is_empty() {
222            return Vec::new();
223        }
224
225        // Only spellings the payload actually carries are offered, so a synonym
226        // group never advertises a name this envelope has no column for.
227        let declared: Vec<String> = spellings(key, self.command)
228            .into_iter()
229            .skip(1)
230            .filter(|candidate| {
231                let leaf = candidate.rsplit('.').next().unwrap_or(candidate);
232                vocabulary.contains(leaf)
233            })
234            .collect();
235
236        // `BatchComparator` pre-processes the needle once and reuses it across
237        // the whole vocabulary, which is exactly the one-against-many shape here.
238        let comparator = rapidfuzz::distance::jaro_winkler::BatchComparator::new(key.chars());
239        let mut ranked: Vec<(f64, &str)> = vocabulary
240            .iter()
241            .map(|candidate| {
242                (
243                    comparator.normalized_similarity(candidate.chars()),
244                    *candidate,
245                )
246            })
247            .filter(|(score, _)| *score >= VOCABULARY_SUGGESTION_MIN_SIMILARITY)
248            .collect();
249
250        // Descending by score, then by name so two equally close candidates come
251        // out in the same order on every platform.
252        ranked.sort_by(|a, b| {
253            b.0.partial_cmp(&a.0)
254                .unwrap_or(std::cmp::Ordering::Equal)
255                .then_with(|| a.1.cmp(b.1))
256        });
257        // The declared synonyms take their slots first, and the similarity
258        // ranking fills whatever is left of the budget without restating them.
259        let mut out = declared;
260        for (_, name) in ranked {
261            if out.len() >= K_VOCABULARY_MAX_SUGGESTIONS {
262                break;
263            }
264            if !out.iter().any(|already| already.as_str() == name) {
265                out.push(name.to_string());
266            }
267        }
268        out.truncate(K_VOCABULARY_MAX_SUGGESTIONS);
269        out
270    }
271
272    /// Distinct field names a correction could plausibly have meant.
273    ///
274    /// Reads the elements when there are elements and the envelope when there
275    /// are none, because that is the same split [`resolve_projection`] uses to
276    /// decide what a key must address. Without the fallback the most useful
277    /// refusal in the catalogue — `--select body_length read`, the case
278    /// GAP-SG-202 was written from — named no alternative at all, since a `read`
279    /// envelope carries no array to sample.
280    ///
281    /// Borrowed, never cloned: the set holds `&str` into the payload, so a
282    /// vocabulary of five hundred names costs five hundred pointers.
283    ///
284    /// [`resolve_projection`]: super::gate
285    /// Whether the SUGGESTION vocabulary was built from less than everything.
286    ///
287    /// Reported as `vocabulary_partial` so a caller reading an empty or thin
288    /// suggestion list can tell "nothing resembled your key" from "the sampler
289    /// stopped before it got there". Only the suggestion path samples;
290    /// [`Self::classify`] always scans every element, so a PARTIAL vocabulary
291    /// never weakens a verdict — it only shortens the advice that follows one.
292    pub fn vocabulary_is_partial(&self) -> bool {
293        self.elements.len() > K_VOCABULARY_SAMPLE_ELEMENTS || self.candidate_keys().1
294    }
295
296    /// Returns the candidate names and whether a ceiling cut the collection short.
297    fn candidate_keys(&self) -> (BTreeSet<&'a str>, bool) {
298        let mut names = BTreeSet::new();
299        if self.elements.is_empty() {
300            let capped = self
301                .envelope
302                .as_object()
303                .is_some_and(|map| Self::absorb(map.keys(), &mut names));
304            return (names, capped);
305        }
306        for element in self.elements.iter().take(K_VOCABULARY_SAMPLE_ELEMENTS) {
307            let Some(map) = element.as_object() else {
308                continue;
309            };
310            if Self::absorb(map.keys(), &mut names) {
311                return (names, true);
312            }
313        }
314        (names, false)
315    }
316
317    /// Inserts names until the ceiling is reached; `true` means it was reached.
318    ///
319    /// The ceiling exists because the envelope is caller-influenced, and the
320    /// memory rules forbid letting untrusted input size an allocation without a
321    /// bound. Hitting it shortens the suggestion list and nothing else.
322    fn absorb<I>(keys: I, names: &mut BTreeSet<&'a str>) -> bool
323    where
324        I: Iterator<Item = &'a String>,
325    {
326        for name in keys {
327            if names.len() >= K_VOCABULARY_MAX_KEYS {
328                return true;
329            }
330            names.insert(name.as_str());
331        }
332        false
333    }
334}