Skip to main content

sqlite_graphrag/agent_surface/
shape.rs

1//! Stateless reshaping primitives applied to the result array of an envelope.
2//!
3//! Every function here takes and returns plain `serde_json` data so a single
4//! call site in [`crate::output`] can serve every subcommand. None of them
5//! knows what a memory, an entity or a hit is.
6//!
7//! GAP-SG-274: the one thing they do carry is the subcommand slug, threaded
8//! through as `command` and used for nothing but scoping the field-synonym
9//! table. It is not knowledge of the domain — it is the same answer the gate
10//! used when it admitted the key, and passing it here is what keeps a key that
11//! was accepted from silently matching nothing.
12
13use super::filter::{lookup, scalar_text, FilterExpr};
14use serde_json::{Map, Value};
15
16/// Keeps only the elements accepted by every predicate.
17pub fn filter(items: Vec<Value>, filters: &[FilterExpr], command: Option<&str>) -> Vec<Value> {
18    if filters.is_empty() {
19        return items;
20    }
21    items
22        .into_iter()
23        .filter(|item| super::filter::matches_all(filters, item, command))
24        .collect()
25}
26
27/// Sorts elements ascending by the scalar found at the dotted `key`.
28///
29/// Numeric values compare numerically, everything else compares as text.
30/// Elements without the key keep their relative order at the end of the list,
31/// so a partially populated payload never loses rows to sorting.
32pub fn sort(mut items: Vec<Value>, key: &str, command: Option<&str>) -> Vec<Value> {
33    let path: Vec<String> = key.split('.').map(str::to_string).collect();
34    items.sort_by(|a, b| {
35        let left = lookup(a, &path, command);
36        let right = lookup(b, &path, command);
37        match (left, right) {
38            (None, None) => std::cmp::Ordering::Equal,
39            (None, Some(_)) => std::cmp::Ordering::Greater,
40            (Some(_), None) => std::cmp::Ordering::Less,
41            (Some(l), Some(r)) => compare(l, r),
42        }
43    });
44    items
45}
46
47/// Total order over two JSON scalars used by [`sort`].
48fn compare(left: &Value, right: &Value) -> std::cmp::Ordering {
49    if let (Some(l), Some(r)) = (left.as_f64(), right.as_f64()) {
50        return l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal);
51    }
52    match (scalar_text(left), scalar_text(right)) {
53        (Some(l), Some(r)) => l.cmp(&r),
54        (Some(_), None) => std::cmp::Ordering::Less,
55        (None, Some(_)) => std::cmp::Ordering::Greater,
56        (None, None) => std::cmp::Ordering::Equal,
57    }
58}
59
60/// Drops later elements whose scalar at `key` was already seen.
61///
62/// Elements lacking the key are always kept: dropping them would silently
63/// collapse rows that were never proven duplicate.
64pub fn dedupe(items: Vec<Value>, key: &str, command: Option<&str>) -> Vec<Value> {
65    let path: Vec<String> = key.split('.').map(str::to_string).collect();
66    let mut seen = std::collections::HashSet::new();
67    let mut out = Vec::with_capacity(items.len());
68    for item in items {
69        match lookup(&item, &path, command).and_then(scalar_text) {
70            Some(text) => {
71                if seen.insert(text) {
72                    out.push(item);
73                }
74            }
75            None => out.push(item),
76        }
77    }
78    out
79}
80
81/// Truncates the list to at most `max` elements. `0` means "no cap".
82pub fn limit(mut items: Vec<Value>, max: usize) -> Vec<Value> {
83    if max > 0 && items.len() > max {
84        items.truncate(max);
85    }
86    items
87}
88
89/// Rewrites each object element to carry only `keys`, in the requested order.
90///
91/// Keys absent from an element are skipped rather than emitted as `null`, so a
92/// projection never invents fields. Non-object elements pass through unchanged.
93/// The dotted paths are split ONCE here rather than per element.
94///
95/// Splitting inside the per-element loop allocated a `Vec<String>`, plus a
96/// `String` per segment, for every element times every key — and discarded them
97/// immediately. `graph entities --select name` over the measured corpus of
98/// 107 135 entities did that 107 135 times to answer with one field. [`sort`] and
99/// [`dedupe`] in this same file already hoist the split out of their loops, so
100/// this is the file's own established shape rather than a new idea.
101pub fn project(items: Vec<Value>, keys: &[String], command: Option<&str>) -> Vec<Value> {
102    if keys.is_empty() {
103        return items;
104    }
105    let paths = compile_paths(keys);
106    items
107        .into_iter()
108        .map(|item| project_with(item, keys, &paths, command))
109        .collect()
110}
111
112/// Splits every dotted key into its segments.
113///
114/// Visible to the whole surface because [`super::stream`] compiles ONCE when the
115/// stream opens and reuses the result for every line. A stream has no `Vec` to
116/// hoist the work out of, so the hoisting has to live in the caller.
117pub(super) fn compile_paths(keys: &[String]) -> Vec<Vec<String>> {
118    keys.iter()
119        .map(|key| key.split('.').map(str::to_string).collect())
120        .collect()
121}
122
123/// Projects one value against paths that were already compiled.
124///
125/// `keys` and `paths` are parallel by construction: both come from the same
126/// slice, in the same order, and `keys` supplies the OUTPUT name while `paths`
127/// supplies the lookup. They are not interchangeable — the output key keeps its
128/// dotted spelling so `--select a.b` answers under `"a.b"` and not under `"b"`.
129pub(super) fn project_with(
130    item: Value,
131    keys: &[String],
132    paths: &[Vec<String>],
133    command: Option<&str>,
134) -> Value {
135    let Value::Object(_) = &item else {
136        return item;
137    };
138    let mut out = Map::new();
139    for (key, path) in keys.iter().zip(paths) {
140        if let Some(found) = lookup(&item, path, command) {
141            out.insert(key.clone(), found.clone());
142        }
143    }
144    Value::Object(out)
145}
146
147/// Projects a single value; see [`project`].
148///
149/// Compiles the paths for one value, which is the right trade for the scalar
150/// envelope this serves: there is no loop to hoist the work out of.
151pub fn project_one(item: Value, keys: &[String], command: Option<&str>) -> Value {
152    if keys.is_empty() {
153        return item;
154    }
155    project_with(item, keys, &compile_paths(keys), command)
156}
157
158/// Shortens every string longer than `max` characters, recursively.
159///
160/// Returns `true` when at least one string was cut, so the caller can flag the
161/// envelope. Truncation counts characters, not bytes, and therefore never
162/// splits a UTF-8 sequence.
163pub fn truncate_strings(value: &mut Value, max: usize) -> bool {
164    if max == 0 {
165        return false;
166    }
167    match value {
168        Value::String(s) => {
169            if s.chars().count() > max {
170                let cut = s
171                    .char_indices()
172                    .nth(max)
173                    .map_or(s.len(), |(byte_idx, _)| byte_idx);
174                s.truncate(cut);
175                true
176            } else {
177                false
178            }
179        }
180        Value::Array(items) => {
181            let mut hit = false;
182            for item in items {
183                hit |= truncate_strings(item, max);
184            }
185            hit
186        }
187        Value::Object(map) => {
188            let mut hit = false;
189            for (_, item) in map.iter_mut() {
190                hit |= truncate_strings(item, max);
191            }
192            hit
193        }
194        Value::Null | Value::Bool(_) | Value::Number(_) => false,
195    }
196}