Skip to main content

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}