Skip to main content

reddb_server/runtime/ai/
ask_response_envelope.rs

1//! `AskResponseEnvelope` — pure serializer for the canonical
2//! non-streaming ASK JSON response (issue #406, PRD #391).
3//!
4//! Deep module: no I/O, no transport, no clock. Owns the on-wire JSON
5//! shape that the embedded stdio JSON-RPC `query` method returns, and
6//! that gRPC (#407), Postgres-wire (#408), and MCP non-stream (#409)
7//! all embed verbatim. Pinning the shape here means a future transport
8//! slice cannot accidentally drop `citations`, rename `cache_hit`, or
9//! re-shape `validation` without the tests in this file failing first.
10//!
11//! ## Why a separate module
12//!
13//! ASK has six new fields the legacy bucketed response did not carry
14//! (`answer`, `sources_flat`, `citations`, `validation`, `cache_hit`,
15//! `cost_usd`). The acceptance criteria for #406 require every one
16//! present in the JSON-RPC response. That's a "field-presence" bug
17//! surface — easy to forget one when the wiring slice lands and hard
18//! to notice in review. Building the JSON in a tested deep module
19//! keeps the wiring slice focused on "where do I write these bytes"
20//! and the contract here on "are the bytes right".
21//!
22//! ## Shape pinned by tests
23//!
24//! Top-level keys (alphabetised by the `BTreeMap`-backed encoder):
25//!
26//! - `answer` — full answer text with inline `[^N]` markers.
27//! - `cache_hit` — bool.
28//! - `citations` — `[{marker, urn}]`, sorted by marker ascending.
29//! - `completion_tokens` — number.
30//! - `cost_usd` — number.
31//! - `mode` — `"strict"` or `"lenient"`, the *effective* mode after
32//!   provider-capability fallback (#396) — mirrors the audit row #402.
33//! - `model` — string.
34//! - `prompt_tokens` — number.
35//! - `provider` — string.
36//! - `retry_count` — number (0 or 1 per #395's one-retry budget).
37//! - `sources_flat` — `[{payload, urn}]`, post-RRF (#398) order
38//!   preserved verbatim so the client can map `[^N]` → `sources_flat[N-1]`.
39//! - `validation` — `{errors, ok, warnings}`. Errors and warnings are
40//!   `[{detail, kind}]` to match the shapes audit_record_builder (#402)
41//!   and sse_frame_encoder (#405) already pin.
42//!
43//! Determinism = seed (#400) is *not* in the response. It's recorded
44//! in the audit row, not surfaced to the caller — leaking the seed
45//! would let a hostile caller replay deterministic answers.
46
47use crate::serde_json::{Map, Value};
48
49/// One row from `sources_flat`. `urn` is the engine entity URN,
50/// `payload` is the column-policy-redacted JSON serialised as a
51/// string so the envelope JSON stays flat (the client re-parses if
52/// it wants structure — matches the SSE `sources` frame shape #405).
53#[derive(Debug, Clone, PartialEq)]
54pub struct SourceRow {
55    pub urn: String,
56    pub payload: String,
57}
58
59/// One citation: `[^N]` in the answer ↔ `sources_flat[N-1]`.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Citation {
62    pub marker: u32,
63    pub urn: String,
64}
65
66/// One validation warning. Same shape as the SSE terminal frame so
67/// HTTP clients can share parsing code across streaming and non-
68/// streaming paths.
69#[derive(Debug, Clone, PartialEq)]
70pub struct ValidationWarning {
71    pub kind: String,
72    pub detail: String,
73}
74
75/// One validation error. Same shape as warnings; `kind` is one of
76/// `"malformed"` / `"out_of_range"` per #395's `ValidationErrorKind`.
77#[derive(Debug, Clone, PartialEq)]
78pub struct ValidationError {
79    pub kind: String,
80    pub detail: String,
81}
82
83/// Validation block. `ok = false` with non-empty `errors` corresponds
84/// to the HTTP 422 path on retry exhaustion (#395).
85#[derive(Debug, Clone, PartialEq)]
86pub struct Validation {
87    pub ok: bool,
88    pub warnings: Vec<ValidationWarning>,
89    pub errors: Vec<ValidationError>,
90}
91
92/// Effective mode actually applied — *after* provider-capability
93/// fallback (#396). The originally-requested mode is recorded in the
94/// audit row, not here.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Mode {
97    Strict,
98    Lenient,
99}
100
101impl Mode {
102    fn as_str(self) -> &'static str {
103        match self {
104            Mode::Strict => "strict",
105            Mode::Lenient => "lenient",
106        }
107    }
108}
109
110/// Internal result a non-streaming ASK call produces — input to
111/// [`build`]. The wiring slice (deferred) constructs this from
112/// `execute_ask`'s outputs.
113#[derive(Debug, Clone)]
114pub struct AskResult {
115    pub answer: String,
116    pub sources_flat: Vec<SourceRow>,
117    pub citations: Vec<Citation>,
118    pub validation: Validation,
119    pub cache_hit: bool,
120    pub provider: String,
121    pub model: String,
122    pub prompt_tokens: u32,
123    pub completion_tokens: u32,
124    pub cost_usd: f64,
125    pub effective_mode: Mode,
126    pub retry_count: u32,
127}
128
129impl Default for AskResult {
130    fn default() -> Self {
131        Self {
132            answer: String::new(),
133            sources_flat: Vec::new(),
134            citations: Vec::new(),
135            validation: Validation {
136                ok: true,
137                warnings: Vec::new(),
138                errors: Vec::new(),
139            },
140            cache_hit: false,
141            provider: String::new(),
142            model: String::new(),
143            prompt_tokens: 0,
144            completion_tokens: 0,
145            cost_usd: 0.0,
146            effective_mode: Mode::Strict,
147            retry_count: 0,
148        }
149    }
150}
151
152/// Serialise an [`AskResult`] to its canonical JSON envelope.
153///
154/// Output is a `Value::Object` ready to drop into a JSON-RPC `result`
155/// field, a gRPC message, or a Postgres-wire single-row result set.
156/// Re-running on byte-equal input is byte-equal output (pinned by
157/// `build_is_deterministic_across_calls`) — required by the ASK
158/// determinism contract (#400).
159pub fn build(result: &AskResult) -> Value {
160    let mut m = Map::new();
161    m.insert("answer".into(), Value::String(result.answer.clone()));
162    m.insert("cache_hit".into(), Value::Bool(result.cache_hit));
163    m.insert("citations".into(), citations_value(&result.citations));
164    m.insert(
165        "completion_tokens".into(),
166        Value::Number(result.completion_tokens as f64),
167    );
168    m.insert("cost_usd".into(), Value::Number(result.cost_usd));
169    m.insert(
170        "mode".into(),
171        Value::String(result.effective_mode.as_str().into()),
172    );
173    m.insert("model".into(), Value::String(result.model.clone()));
174    m.insert(
175        "prompt_tokens".into(),
176        Value::Number(result.prompt_tokens as f64),
177    );
178    m.insert("provider".into(), Value::String(result.provider.clone()));
179    m.insert(
180        "retry_count".into(),
181        Value::Number(result.retry_count as f64),
182    );
183    m.insert("sources_flat".into(), sources_value(&result.sources_flat));
184    m.insert("validation".into(), validation_value(&result.validation));
185    Value::Object(m)
186}
187
188fn citations_value(cites: &[Citation]) -> Value {
189    // Marker order is the contract — `[^1]` must come before `[^2]`
190    // in the array so the index aligns with the marker. Pinned by
191    // `citations_are_sorted_by_marker_ascending`.
192    let mut sorted: Vec<Citation> = cites.to_vec();
193    sorted.sort_by_key(|c| c.marker);
194    Value::Array(
195        sorted
196            .iter()
197            .map(|c| {
198                let mut o = Map::new();
199                o.insert("marker".into(), Value::Number(c.marker as f64));
200                o.insert("urn".into(), Value::String(c.urn.clone()));
201                Value::Object(o)
202            })
203            .collect(),
204    )
205}
206
207fn sources_value(rows: &[SourceRow]) -> Value {
208    Value::Array(
209        rows.iter()
210            .map(|r| {
211                let mut o = Map::new();
212                o.insert("payload".into(), Value::String(r.payload.clone()));
213                o.insert("urn".into(), Value::String(r.urn.clone()));
214                Value::Object(o)
215            })
216            .collect(),
217    )
218}
219
220fn warning_value(w: &ValidationWarning) -> Value {
221    let mut o = Map::new();
222    o.insert("detail".into(), Value::String(w.detail.clone()));
223    o.insert("kind".into(), Value::String(w.kind.clone()));
224    Value::Object(o)
225}
226
227fn error_value(e: &ValidationError) -> Value {
228    let mut o = Map::new();
229    o.insert("detail".into(), Value::String(e.detail.clone()));
230    o.insert("kind".into(), Value::String(e.kind.clone()));
231    Value::Object(o)
232}
233
234fn validation_value(v: &Validation) -> Value {
235    let mut o = Map::new();
236    o.insert(
237        "errors".into(),
238        Value::Array(v.errors.iter().map(error_value).collect()),
239    );
240    o.insert("ok".into(), Value::Bool(v.ok));
241    o.insert(
242        "warnings".into(),
243        Value::Array(v.warnings.iter().map(warning_value).collect()),
244    );
245    Value::Object(o)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn fixture() -> AskResult {
253        AskResult {
254            answer: "X is 42 [^1].".into(),
255            sources_flat: vec![SourceRow {
256                urn: "urn:reddb:row:1".into(),
257                payload: "{\"k\":\"v\"}".into(),
258            }],
259            citations: vec![Citation {
260                marker: 1,
261                urn: "urn:reddb:row:1".into(),
262            }],
263            validation: Validation {
264                ok: true,
265                warnings: vec![],
266                errors: vec![],
267            },
268            cache_hit: false,
269            provider: "openai".into(),
270            model: "gpt-4o-mini".into(),
271            prompt_tokens: 123,
272            completion_tokens: 45,
273            cost_usd: 0.000_321,
274            effective_mode: Mode::Strict,
275            retry_count: 0,
276        }
277    }
278
279    #[test]
280    fn build_emits_every_required_key() {
281        let v = build(&fixture());
282        let obj = v.as_object().unwrap();
283        let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
284        keys.sort();
285        assert_eq!(
286            keys,
287            vec![
288                "answer",
289                "cache_hit",
290                "citations",
291                "completion_tokens",
292                "cost_usd",
293                "mode",
294                "model",
295                "prompt_tokens",
296                "provider",
297                "retry_count",
298                "sources_flat",
299                "validation",
300            ]
301        );
302    }
303
304    #[test]
305    fn answer_text_preserved_with_inline_markers() {
306        let v = build(&fixture());
307        assert_eq!(
308            v.get("answer").and_then(|x| x.as_str()),
309            Some("X is 42 [^1].")
310        );
311    }
312
313    #[test]
314    fn cache_hit_serializes_as_bool() {
315        let mut r = fixture();
316        r.cache_hit = true;
317        let v = build(&r);
318        assert_eq!(v.get("cache_hit").and_then(|x| x.as_bool()), Some(true));
319    }
320
321    #[test]
322    fn citations_are_sorted_by_marker_ascending() {
323        let mut r = fixture();
324        r.citations = vec![
325            Citation {
326                marker: 3,
327                urn: "urn:c".into(),
328            },
329            Citation {
330                marker: 1,
331                urn: "urn:a".into(),
332            },
333            Citation {
334                marker: 2,
335                urn: "urn:b".into(),
336            },
337        ];
338        let v = build(&r);
339        let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
340        let markers: Vec<u64> = arr
341            .iter()
342            .map(|c| c.get("marker").and_then(|m| m.as_u64()).unwrap())
343            .collect();
344        assert_eq!(markers, vec![1, 2, 3]);
345    }
346
347    #[test]
348    fn sources_flat_preserves_input_order() {
349        // Post-RRF order is the contract — `[^N]` indexes into this
350        // array, so reordering would silently break grounding.
351        let mut r = fixture();
352        r.sources_flat = vec![
353            SourceRow {
354                urn: "urn:z".into(),
355                payload: "{}".into(),
356            },
357            SourceRow {
358                urn: "urn:a".into(),
359                payload: "{}".into(),
360            },
361        ];
362        let v = build(&r);
363        let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
364        assert_eq!(arr[0].get("urn").and_then(|x| x.as_str()), Some("urn:z"));
365        assert_eq!(arr[1].get("urn").and_then(|x| x.as_str()), Some("urn:a"));
366    }
367
368    #[test]
369    fn sources_row_carries_payload_as_string() {
370        let v = build(&fixture());
371        let arr = v.get("sources_flat").and_then(|x| x.as_array()).unwrap();
372        assert_eq!(
373            arr[0].get("payload").and_then(|x| x.as_str()),
374            Some("{\"k\":\"v\"}")
375        );
376    }
377
378    #[test]
379    fn validation_ok_carries_empty_arrays() {
380        let v = build(&fixture());
381        let val = v.get("validation").unwrap();
382        assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(true));
383        assert_eq!(
384            val.get("warnings")
385                .and_then(|x| x.as_array())
386                .unwrap()
387                .len(),
388            0
389        );
390        assert_eq!(
391            val.get("errors").and_then(|x| x.as_array()).unwrap().len(),
392            0
393        );
394    }
395
396    #[test]
397    fn validation_carries_warnings_and_errors_with_kind_detail() {
398        let mut r = fixture();
399        r.validation = Validation {
400            ok: false,
401            warnings: vec![ValidationWarning {
402                kind: "mode_fallback".into(),
403                detail: "ollama".into(),
404            }],
405            errors: vec![ValidationError {
406                kind: "out_of_range".into(),
407                detail: "marker 7 > 3 sources".into(),
408            }],
409        };
410        let v = build(&r);
411        let val = v.get("validation").unwrap();
412        assert_eq!(val.get("ok").and_then(|x| x.as_bool()), Some(false));
413        let warns = val.get("warnings").and_then(|x| x.as_array()).unwrap();
414        assert_eq!(
415            warns[0].get("kind").and_then(|x| x.as_str()),
416            Some("mode_fallback")
417        );
418        assert_eq!(
419            warns[0].get("detail").and_then(|x| x.as_str()),
420            Some("ollama")
421        );
422        let errs = val.get("errors").and_then(|x| x.as_array()).unwrap();
423        assert_eq!(
424            errs[0].get("kind").and_then(|x| x.as_str()),
425            Some("out_of_range")
426        );
427    }
428
429    #[test]
430    fn mode_serializes_as_strict_or_lenient() {
431        let mut r = fixture();
432        r.effective_mode = Mode::Strict;
433        assert_eq!(
434            build(&r).get("mode").and_then(|x| x.as_str()),
435            Some("strict")
436        );
437        r.effective_mode = Mode::Lenient;
438        assert_eq!(
439            build(&r).get("mode").and_then(|x| x.as_str()),
440            Some("lenient")
441        );
442    }
443
444    #[test]
445    fn usage_fields_flat_at_top_level() {
446        // Matches the audit row shape (#402) and SSE audit frame
447        // (#405). Nested `usage: {...}` would force every transport
448        // and SDK to re-shape.
449        let v = build(&fixture());
450        assert_eq!(v.get("prompt_tokens").and_then(|x| x.as_u64()), Some(123));
451        assert_eq!(
452            v.get("completion_tokens").and_then(|x| x.as_u64()),
453            Some(45)
454        );
455        assert!(v.get("cost_usd").is_some());
456    }
457
458    #[test]
459    fn cost_usd_keeps_fractional_precision() {
460        let mut r = fixture();
461        r.cost_usd = 0.000_321;
462        let v = build(&r);
463        assert_eq!(v.get("cost_usd").and_then(|x| x.as_f64()), Some(0.000_321));
464    }
465
466    #[test]
467    fn retry_count_zero_and_one_both_round_trip() {
468        // #395 caps retries at one — pinning both endpoints guards
469        // against an off-by-one if the budget ever changes.
470        let mut r = fixture();
471        r.retry_count = 0;
472        assert_eq!(
473            build(&r).get("retry_count").and_then(|x| x.as_u64()),
474            Some(0)
475        );
476        r.retry_count = 1;
477        assert_eq!(
478            build(&r).get("retry_count").and_then(|x| x.as_u64()),
479            Some(1)
480        );
481    }
482
483    #[test]
484    fn does_not_expose_seed_or_temperature() {
485        // Determinism inputs (#400) are recorded in the audit row,
486        // not surfaced to the caller. Leaking the seed would let a
487        // hostile caller replay deterministic answers.
488        let v = build(&fixture());
489        let obj = v.as_object().unwrap();
490        assert!(!obj.contains_key("seed"));
491        assert!(!obj.contains_key("temperature"));
492    }
493
494    #[test]
495    fn empty_sources_and_citations_are_arrays_not_null() {
496        // Empty arrays are well-formed (`STRICT OFF` on a refusal can
497        // produce no citations). Missing keys would break a downstream
498        // `.length` access.
499        let mut r = fixture();
500        r.sources_flat = vec![];
501        r.citations = vec![];
502        let v = build(&r);
503        assert!(v
504            .get("sources_flat")
505            .and_then(|x| x.as_array())
506            .unwrap()
507            .is_empty());
508        assert!(v
509            .get("citations")
510            .and_then(|x| x.as_array())
511            .unwrap()
512            .is_empty());
513    }
514
515    #[test]
516    fn answer_escaping_handled_by_compact_encoder() {
517        let mut r = fixture();
518        r.answer = "she said \"hi\"\nnewline".into();
519        let bytes = build(&r).to_string_compact();
520        assert!(bytes.contains(r#"\"hi\""#));
521        assert!(bytes.contains(r#"\n"#));
522    }
523
524    #[test]
525    fn build_is_deterministic_across_calls() {
526        let r = fixture();
527        let a = build(&r).to_string_compact();
528        let b = build(&r).to_string_compact();
529        assert_eq!(a, b);
530    }
531
532    #[test]
533    fn build_is_deterministic_across_clone_inputs() {
534        let r1 = fixture();
535        let r2 = r1.clone();
536        assert_eq!(
537            build(&r1).to_string_compact(),
538            build(&r2).to_string_compact()
539        );
540    }
541
542    #[test]
543    fn top_level_key_order_is_alphabetical() {
544        // Pinned because clients on weak parsers (e.g. some PG-wire
545        // bindings doing string slicing) have been known to depend on
546        // it. BTreeMap-backed encoder gives it for free.
547        let bytes = build(&fixture()).to_string_compact();
548        let answer_pos = bytes.find("\"answer\"").unwrap();
549        let cache_pos = bytes.find("\"cache_hit\"").unwrap();
550        let citations_pos = bytes.find("\"citations\"").unwrap();
551        let validation_pos = bytes.find("\"validation\"").unwrap();
552        assert!(answer_pos < cache_pos);
553        assert!(cache_pos < citations_pos);
554        assert!(citations_pos < validation_pos);
555    }
556
557    #[test]
558    fn citation_with_same_marker_is_stable_under_sort() {
559        // Defensive: if two citations share a marker (malformed input
560        // from the validator path), the sort must be stable so the
561        // input order is preserved. Pinned because a different sort
562        // strategy (unstable + tie on marker) would non-determinise
563        // the response and break #400.
564        let mut r = fixture();
565        r.citations = vec![
566            Citation {
567                marker: 1,
568                urn: "urn:first".into(),
569            },
570            Citation {
571                marker: 1,
572                urn: "urn:second".into(),
573            },
574        ];
575        let v = build(&r);
576        let arr = v.get("citations").and_then(|x| x.as_array()).unwrap();
577        assert_eq!(
578            arr[0].get("urn").and_then(|x| x.as_str()),
579            Some("urn:first")
580        );
581        assert_eq!(
582            arr[1].get("urn").and_then(|x| x.as_str()),
583            Some("urn:second")
584        );
585    }
586}