sqlite_graphrag/agent_surface/universe.rs
1//! GAP-SG-201: what the QUERY already discarded, declared so the output surface
2//! can stop describing a set it never saw.
3//!
4//! `--filter type=skill list` answers 39 over 1892 memories. The same request
5//! with `--limit 50` answered `0`, with `exit 0`, because the predicate was
6//! handed the fifty rows SQL returned rather than the corpus the caller asked
7//! about. Both numbers are produced by the same code; only one of them is an
8//! answer to the question.
9//!
10//! The surface cannot see this on its own. It receives a serialized envelope,
11//! downstream of `LIMIT`, and an array of fifty is indistinguishable from a
12//! corpus of fifty. So the command that applied the ceiling declares it here,
13//! and the surface reads the declaration.
14//!
15//! # Why a process-wide cell rather than a field on every response
16//!
17//! This binary is one-shot: one process runs one subcommand and emits one
18//! envelope. A cell is therefore not ambient state that could belong to someone
19//! else — it is the single fact about the single query this process ran. The
20//! same reasoning already governs [`super::AgentSurface`]. Threading a new field
21//! through six response structs would also change six published schemas to carry
22//! a fact none of them is about.
23//!
24//! # Pagination is not top-k
25//!
26//! [`CeilingKind`] is the distinction the refusal turns on, and it is not
27//! cosmetic. `list --limit 50` pages a countable universe: 50 of 1892 is a
28//! recorte, and a predicate over it answers the wrong question. `hybrid-search
29//! -k 5` does not page anything — the five best matches ARE the result set the
30//! caller asked for, and filtering them is a legitimate operation on a complete
31//! answer. Refusing there would break every semantic search that carries a
32//! filter while curing nothing.
33
34use serde_json::{json, Map, Value};
35use std::sync::OnceLock;
36
37/// What the caller declares `--filter` may observe.
38///
39/// Absent, the surface refuses a predicate over a truncated page rather than
40/// answering about a set it never saw. Present, the caller has said which
41/// reading it meant, and the surface obeys.
42#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
43pub enum FilterScope {
44 /// Judge only the rows the query returned, and say so in the record.
45 Page,
46 /// Require the predicate to observe the whole universe.
47 ///
48 /// Identical to the default today; declaring it makes the requirement
49 /// explicit in a script that must not silently start filtering a page if a
50 /// limit is added later.
51 Universe,
52}
53
54/// What kind of ceiling the query applied.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum CeilingKind {
57 /// A page of an enumerable universe whose size the command can count.
58 ///
59 /// `list` and `graph entities`: both run a `COUNT` beside the query, so
60 /// "did the ceiling actually cut anything" has a factual answer.
61 Pagination,
62 /// A bound on a ranked or traversed result set, with no universe to compare.
63 ///
64 /// `hybrid-search -k`, `recall -k`, `deep-research --max-results` and
65 /// `related --limit`, which stops a breadth-first walk rather than paging a
66 /// table. The ceiling defines the answer instead of truncating it.
67 TopK,
68}
69
70impl CeilingKind {
71 /// Wire spelling for the `agent_surface` record.
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Self::Pagination => "pagination",
75 Self::TopK => "top-k",
76 }
77 }
78}
79
80/// Where the ceiling's value came from.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum CeilingSource {
83 /// The caller passed it.
84 Flag,
85 /// A named constant supplied it and the caller never asked for a cut.
86 ///
87 /// This is the case that made GAP-SG-201 fire without anyone doing anything
88 /// wrong: `graph entities` caps at 50 by default, so `--filter` judged 50 of
89 /// 15 615 entities on a command line that mentioned no limit at all.
90 Default,
91}
92
93impl CeilingSource {
94 /// Wire spelling for the `agent_surface` record.
95 pub fn as_str(self) -> &'static str {
96 match self {
97 Self::Flag => "flag",
98 Self::Default => "default",
99 }
100 }
101}
102
103/// The ceiling one query applied, as the command that applied it saw it.
104#[derive(Debug, Clone, Copy)]
105pub struct QueryCeiling {
106 /// Rows the query was allowed to return.
107 pub applied: usize,
108 /// Rows the query skipped before returning any.
109 ///
110 /// A non-zero offset means the page is a recorte even when `applied` alone
111 /// would have covered the universe.
112 pub offset: usize,
113 /// Whether the caller chose the value or a constant did.
114 pub source: CeilingSource,
115 /// Whether the ceiling pages a universe or bounds a ranking.
116 pub kind: CeilingKind,
117 /// Size of the universe, when the command can count it.
118 ///
119 /// `None` is not "unbounded": it means the command has no universe to
120 /// compare against, which is exactly why [`CeilingKind::TopK`] is never
121 /// refused.
122 pub universe_total: Option<usize>,
123}
124
125impl QueryCeiling {
126 /// `true` when the ceiling actually kept rows out of the envelope.
127 ///
128 /// A `--limit` wider than the corpus cut nothing, so a predicate over the
129 /// result observed the whole universe and there is nothing to refuse. This
130 /// is what keeps `list --limit 100000 --filter …` working.
131 pub fn truncated_the_universe(&self) -> bool {
132 if self.offset > 0 {
133 return true;
134 }
135 self.universe_total
136 .is_some_and(|total| self.applied < total)
137 }
138}
139
140static CEILING: OnceLock<QueryCeiling> = OnceLock::new();
141
142/// Declares the ceiling this process's query applied. First call wins.
143///
144/// Called by the command at the point it resolves the effective limit, which is
145/// also where it knows the source and, for a paginated command, the total.
146pub fn record(ceiling: QueryCeiling) {
147 let _ = CEILING.set(ceiling);
148}
149
150/// The declared ceiling, or `None` when the command declared none.
151pub fn get() -> Option<&'static QueryCeiling> {
152 CEILING.get()
153}
154
155/// Wire spelling for a count that the OUTPUT ceiling reduced.
156const COUNT_SCOPE_EMITTED: &str = "emitted";
157
158/// Wire spelling for a count of every element that satisfied the predicates.
159const COUNT_SCOPE_MATCHED: &str = "matched";
160
161/// Wire spelling for a count taken over a page the QUERY had already cut.
162const COUNT_SCOPE_PAGE: &str = "page";
163
164/// Names which of three sets `--count-only` actually counted.
165///
166/// GAP-SG-201. The field existed and reported two of the three readings: it
167/// compared the emitted count against the matched one, which detects
168/// `--max-items` and is structurally blind to the SQL `LIMIT` upstream of it.
169/// Both numbers are measured AFTER the query returned its page, so fifty rows
170/// out of 107 111 answered `matched` — the strongest of the three labels — on a
171/// command line that named no limit.
172///
173/// The query ceiling therefore wins the precedence. It is upstream of
174/// `--max-items`, so when it cut rows the count describes a page no matter what
175/// the output ceiling did afterwards; reporting `emitted` there would name the
176/// smaller omission and hide the larger one.
177///
178/// This still matters after [`super::gate`] refuses a count over a page, because
179/// that refusal has an escape: a caller who declares `--filter-scope page` is let
180/// through, and until now was let through to a label that said `matched`. The
181/// refusal governs the default path; this governs the accepted one.
182///
183/// It lives HERE rather than beside the shaping because it is a statement about
184/// the ceiling, not about the reshaping — the same reason [`insert_query_ceiling`]
185/// is its neighbour.
186pub(super) fn count_scope(
187 output_count: usize,
188 matched_count: usize,
189 ceiling: Option<&QueryCeiling>,
190) -> &'static str {
191 if ceiling.is_some_and(|c| c.kind == CeilingKind::Pagination && c.truncated_the_universe()) {
192 return COUNT_SCOPE_PAGE;
193 }
194 if output_count < matched_count {
195 return COUNT_SCOPE_EMITTED;
196 }
197 COUNT_SCOPE_MATCHED
198}
199
200/// The label used when an envelope carries no result array to count.
201///
202/// Such an envelope is one thing, and no ceiling can make it fewer, so the count
203/// always describes what matched.
204pub(super) const COUNT_SCOPE_SCALAR: &str = COUNT_SCOPE_MATCHED;
205
206/// Writes what the QUERY had already removed, when the command declared it.
207///
208/// GAP-SG-201: reported whatever the verdict, because a top-k is never refused
209/// and this is how its narrowness stops being invisible.
210///
211/// Shared by the shaping path and the inert one for the same reason the target
212/// is: both are facts about the PROCESS, not about the reshaping. Until v1.2.7
213/// this lived inside `base_meta` alone, so `deep-research "x"` with no knob
214/// reported its resolved target and stayed silent about having cut the ranking
215/// to five — the exact asymmetry the inert path was created to remove.
216pub(super) fn insert_query_ceiling(meta: &mut Map<String, Value>, ceiling: Option<&QueryCeiling>) {
217 if let Some(ceiling) = ceiling {
218 meta.insert("query_limited".into(), json!(true));
219 meta.insert("query_limit".into(), json!(ceiling.applied));
220 meta.insert("query_limit_source".into(), json!(ceiling.source.as_str()));
221 meta.insert("query_limit_kind".into(), json!(ceiling.kind.as_str()));
222 if let Some(total) = ceiling.universe_total {
223 meta.insert("universe_total".into(), json!(total));
224 }
225 // Three readings, not two. A top-k is neither the universe nor a page
226 // of one: the caller never asked for a corpus, so reporting `universe`
227 // would claim a completeness it never had, and reporting `page` would
228 // imply a larger set the command cannot name.
229 let scope = match ceiling.kind {
230 CeilingKind::TopK => "top-k",
231 CeilingKind::Pagination if ceiling.truncated_the_universe() => "page",
232 CeilingKind::Pagination => "universe",
233 };
234 meta.insert("filter_scope".into(), json!(scope));
235 if scope == "page" {
236 meta.insert("filter_incomplete".into(), Value::Bool(true));
237 }
238 }
239}