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}