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}