sqlite_graphrag/agent_surface/stream.rs
1//! GAP-SG-215: the NDJSON stream contract, decided.
2//!
3//! [`super`] is defined over one complete envelope. `export` and `ingest` emit
4//! something else — N self-contained records followed by a summary — and until
5//! v1.2.8 they reached that envelope machinery once per LINE, through
6//! `crate::output::emit_json_compact`. Three defects followed, all measured:
7//!
8//! * `--select name export --limit 3` emitted three correctly projected records
9//! and then `exit 2` on the fourth line. The summary carries `namespace`, not
10//! `name`, so the projection that resolved for every record failed on the one
11//! line that is not a record — after stdout had already been written to.
12//! * `--select namespace export` did the mirror of that in SILENCE, `exit 0`:
13//! the key resolved, so the summary was projected down to `{"namespace":…}`
14//! and lost `summary: true`, the only end-of-stream signal a consumer has. A
15//! truncated export then looks exactly like a complete one.
16//! * With NO knob at all, every line carried a 278-byte `agent_surface` record —
17//! measured over 200 lines — restating one fact about the PROCESS once per
18//! memory, absolute database path included. At the default `--limit 100000`
19//! that is ~27.8 MB, written into the file `docs/AGENTS.md` recommends
20//! creating with `export > backup.ndjson`.
21//!
22//! # The contract
23//!
24//! * **A record line carries the record and nothing else.** No `agent_surface`,
25//! no `truncated`. This is the invariant `super`'s module docs have declared
26//! since GAP-SG-142 — "NDJSON streams bypass the surface" — restored to being
27//! true. The NDJSON specification is explicit that the format carries no
28//! per-line header, metadata or schema; a stream is data, and the frame around
29//! it belongs somewhere else.
30//! * **Only per-record knobs act.** `--select` and `--truncate-content` are
31//! stateless per record and mean the same thing whether a record arrives alone
32//! or in a stream. `crate::output::stream` named exactly that pair as the safe
33//! extension and asked for a contract decision before wiring it; this module is
34//! that decision. Everything else is refused by [`super::gate::evaluate_stream`]
35//! BEFORE the first byte, so a refusal never leaves a half-written stream.
36//! * **The trailer is never shaped and carries the one record.** The summary
37//! line is already about the stream rather than about a memory, so the resolved
38//! target, the query ceiling and the projection findings ride there — once.
39//!
40//! The published schemas allow all three: `docs/schemas/export-memory-line` and
41//! `export-summary` both declare `agent_surface` OPTIONAL, so dropping it from
42//! the record and keeping it on the summary breaks no contract. What the old
43//! behaviour did break was `export-summary`'s `required` list, every time a
44//! projection deleted `summary`, `exported` or `elapsed_ms`.
45//!
46//! # Why the state is a cell and the decisions are not
47//!
48//! One process runs one subcommand and emits one stream, so a process-wide cell
49//! is the single fact about that stream rather than ambient state — the same
50//! reasoning [`super::universe`] documents. But GAP-SG-201 shipped a refusal no
51//! test could reach precisely because the DECISION read the cell from inside
52//! itself. So every function here that decides anything takes its premises as
53//! arguments, and the cell is read at exactly one place: the emitters in
54//! `crate::output::stream`.
55
56use super::gate::{self, Findings};
57use super::vocabulary::Scope;
58use super::{shape, AgentSurface};
59use crate::errors::AppError;
60use serde_json::{json, Map, Value};
61use std::sync::atomic::{AtomicUsize, Ordering};
62use std::sync::OnceLock;
63
64/// Member marking an `agent_surface` record as describing a stream.
65///
66/// A consumer that reads the block off a summary line needs to know the counts
67/// in it are about N lines rather than about the one it is holding.
68const STREAM_KEY: &str = "stream";
69
70/// Member counting the records `--truncate-content` actually shortened.
71const RECORDS_TRUNCATED_KEY: &str = "records_truncated";
72
73/// What one stream resolved before its first line, and what it did after.
74///
75/// Built once by [`open_with`] and read by every emission. The projection paths
76/// are compiled HERE rather than per line for the same reason
77/// [`shape::project`] compiles them once for a `Vec`: splitting a dotted key
78/// inside the emission loop would allocate a `Vec<String>`, plus a `String` per
79/// segment, for every record times every key. A stream has no `Vec` to hoist the
80/// work out of, so the hoisting has to be the stream's own state.
81#[derive(Debug)]
82pub struct StreamState {
83 /// What `--select` resolved against the record vocabulary, decided once.
84 findings: Findings,
85 /// `--select` keys pre-split into lookup paths.
86 select_paths: Vec<Vec<String>>,
87 /// How many records `--truncate-content` shortened.
88 ///
89 /// Atomic rather than a `Cell` because the emitters take `&'static
90 /// StreamState` out of a `OnceLock`, which is `Sync` only if its contents
91 /// are. Uninteresting cost: the counter is touched only on a record that was
92 /// actually cut.
93 shortened: AtomicUsize,
94}
95
96impl StreamState {
97 /// The state of a stream that was never opened.
98 ///
99 /// Emitting through this shapes nothing and refuses nothing, which is the
100 /// right failure mode for a stream whose command forgot to call [`open`]:
101 /// records go out verbatim, which is the contract, and the trailer still
102 /// carries the process record. `tests/stream_contract_gate.rs` is what makes
103 /// forgetting visible rather than merely harmless.
104 fn inert() -> &'static Self {
105 static INERT: OnceLock<StreamState> = OnceLock::new();
106 INERT.get_or_init(|| StreamState {
107 findings: Findings::default(),
108 select_paths: Vec::new(),
109 shortened: AtomicUsize::new(0),
110 })
111 }
112}
113
114/// Resolves a stream's request against its records, before anything is emitted.
115///
116/// `sample` is a bounded prefix of the records the command is about to write,
117/// and `total` is how many there really are. See [`gate::evaluate_stream`] for
118/// why it is a prefix and not the whole set.
119///
120/// `total` exists so the bound gets DECLARED. [`Scope::vocabulary_is_partial`]
121/// compares the elements it was handed against its own sampling constant, and a
122/// prefix of exactly that size compares equal — so a 100 000-record export judged
123/// on 64 records would have reported a complete vocabulary. Passing the real
124/// count is what turns "I judged a prefix" from an implementation detail into a
125/// field on the trailer.
126///
127/// # Errors
128/// Returns [`AppError::Usage`] — exit `2` — when a knob cannot act on a stream,
129/// or when `--select` names nothing any record carries. Both happen with stdout
130/// still untouched, which is the whole point of resolving up front.
131pub fn open_with(
132 surface: &AgentSurface,
133 sample: &[Value],
134 total: usize,
135) -> Result<StreamState, AppError> {
136 // A stream has no envelope for a key to resolve against instead of the
137 // records, and `Scope` wants one, so it gets the empty value. That is not a
138 // placeholder: it states, correctly, that nothing here is envelope-only.
139 let no_envelope = Value::Null;
140 let mut findings = gate::evaluate_stream(
141 surface,
142 &Scope::new(sample, &no_envelope).with_command(surface.command.as_deref()),
143 )?;
144 // Scoped to `--select`, because the field qualifies an ANSWER about field
145 // names and there is no such answer without a projection. Raising it
146 // unconditionally reported `vocabulary_partial: true` on a plain `export`,
147 // where the sample is empty by design and nothing was ever judged — a true
148 // statement about the sample, and a misleading one about the run.
149 if !surface.select.is_empty() {
150 findings.vocabulary_partial |= total > sample.len();
151 }
152 Ok(StreamState {
153 select_paths: shape::compile_paths(&surface.select),
154 findings,
155 shortened: AtomicUsize::new(0),
156 })
157}
158
159/// Applies the per-record knobs to one line. Never annotates it.
160///
161/// The absence of an `agent_surface` insertion here is the contract, not an
162/// omission — see the module docs.
163#[must_use]
164pub fn shape_record_with(state: &StreamState, surface: &AgentSurface, value: Value) -> Value {
165 let mut value = if surface.select.is_empty() {
166 value
167 } else {
168 shape::project_with(
169 value,
170 &surface.select,
171 &state.select_paths,
172 surface.command.as_deref(),
173 )
174 };
175 if shape::truncate_strings(&mut value, surface.truncate_content) {
176 // Release pairs with the Acquire in `trailer_with`: the trailer is the
177 // one reader, and it must see every increment that happened before it.
178 state.shortened.fetch_add(1, Ordering::Release);
179 }
180 value
181}
182
183/// Annotates the trailer with the one record for the whole stream.
184///
185/// Deliberately does NOT project, filter or cap. The summary line is the stream
186/// describing itself; a `--select` aimed at the records has no business either
187/// failing on it or rewriting it, and both of those were measured defects.
188#[must_use]
189pub fn trailer_with(
190 state: &StreamState,
191 surface: &AgentSurface,
192 target: Option<Map<String, Value>>,
193 mut value: Value,
194) -> Value {
195 let mut meta = Map::new();
196 meta.insert(STREAM_KEY.into(), Value::Bool(true));
197 if !surface.select.is_empty() {
198 meta.insert("select".into(), json!(surface.select));
199 }
200 let shortened = state.shortened.load(Ordering::Acquire);
201 if shortened > 0 {
202 meta.insert(RECORDS_TRUNCATED_KEY.into(), json!(shortened));
203 }
204 if state.findings.is_partial() {
205 meta.insert(
206 "unresolved_keys".into(),
207 json!(state.findings.unresolved_keys),
208 );
209 meta.insert("resolved_keys".into(), json!(state.findings.resolved_keys));
210 meta.insert("key_resolution".into(), json!("partial"));
211 if !state.findings.key_suggestions.is_empty() {
212 meta.insert(
213 "key_suggestions".into(),
214 json!(state.findings.key_suggestions),
215 );
216 }
217 }
218 // Reported whatever the verdict, because it qualifies the whole stream and
219 // not just a partial projection: it says the vocabulary came from a prefix
220 // of the records rather than from all of them.
221 if state.findings.vocabulary_partial {
222 meta.insert("vocabulary_partial".into(), Value::Bool(true));
223 }
224 if let Some(record) = target {
225 meta.extend(record);
226 }
227 // `truncated` rides the trailer for the same reason the counts do. The
228 // module has always promised that removing data is never silent, and with
229 // record lines now unannotated the trailer is the only place left to keep
230 // that promise.
231 super::attach_meta(&mut value, &meta, shortened > 0);
232 value
233}
234
235static STREAM: OnceLock<StreamState> = OnceLock::new();
236
237/// Opens the process's stream. First call wins.
238///
239/// # Errors
240/// Propagates the refusal from [`open_with`], so a streaming command can fail
241/// before it writes its first record simply by using `?`.
242pub fn open(surface: &AgentSurface, sample: &[Value], total: usize) -> Result<(), AppError> {
243 let state = open_with(surface, sample, total)?;
244 let _ = STREAM.set(state);
245 Ok(())
246}
247
248/// How many records [`open`] needs to see to resolve a projection.
249///
250/// The surface's own sampling constant, reused rather than restated: judging a
251/// stream's vocabulary and suggesting names for a failed key are the same
252/// question about the same records, and two constants for one question is how
253/// they drift.
254pub const SAMPLE_RECORDS: usize = crate::constants::K_VOCABULARY_SAMPLE_ELEMENTS;
255
256/// The open stream, or an inert one when the command never opened it.
257pub fn get() -> &'static StreamState {
258 STREAM.get().unwrap_or_else(StreamState::inert)
259}