Skip to main content

reddb_server/runtime/ai/
strict_validator.rs

1//! `StrictValidator` — pure citation validation policy.
2//!
3//! Issue #395 (PRD #391): after the `CitationParser` (issue #393)
4//! scans the LLM answer, this module decides what to do with the
5//! result given the requested mode and the current retry attempt.
6//!
7//! Deep module: no I/O, no transport, no LLM calls. Just an enum and
8//! one function. The caller is responsible for actually issuing the
9//! retry, mapping `GiveUp` to HTTP 422, etc.
10//!
11//! ## Policy
12//!
13//! Strict mode (the default per ADR 0013):
14//!
15//! - First call → if no malformed and no out-of-range, [`Decision::Ok`].
16//! - First call → otherwise, [`Decision::Retry`] with a corrected
17//!   prompt that tells the LLM the valid index range and asks it to
18//!   reissue the answer with citations in `1..=sources_count`.
19//! - Retry call → if still failing, [`Decision::GiveUp`] carrying the
20//!   structured errors that the HTTP layer should pack into the 422
21//!   response body under `validation.errors`.
22//!
23//! Exactly one retry is permitted. The validator tracks the retry
24//! budget via the [`Attempt`] argument — callers MUST pass
25//! [`Attempt::First`] on the initial call and [`Attempt::Retry`] on
26//! the single follow-up. There is no `Attempt::Retry2`; the type is
27//! the budget.
28//!
29//! Lenient mode ([`Mode::Lenient`], opt-in via `ASK '...' STRICT OFF`):
30//!
31//! - Always returns [`Decision::Ok`]. Warnings remain on the result
32//!   for the caller to surface, but the validator never asks for a
33//!   retry and never produces errors.
34//!
35//! ## Why a retry-prompt builder lives in here
36//!
37//! The retry message is part of the validator's contract — what the
38//! LLM is told on retry affects whether the second call is likely to
39//! succeed. Keeping prompt construction next to the decision logic
40//! lets the unit tests pin the exact phrasing, and keeps the
41//! `execute_ask` glue code tiny.
42
43use crate::runtime::ai::citation_parser::{CitationParseResult, CitationWarning, CitationWarningKind};
44
45/// Whether the caller wants strict validation or lenient warn-only.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Mode {
48    /// Default. Structural failures trigger a retry; retry failure
49    /// becomes a hard 422.
50    Strict,
51    /// `ASK '...' STRICT OFF`. Warnings are surfaced but never block.
52    Lenient,
53}
54
55/// Which call this is — the validator uses this to enforce the
56/// one-retry budget.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Attempt {
59    First,
60    Retry,
61}
62
63/// Structured error returned in `validation.errors` on retry exhaust.
64///
65/// Mirrors the `CitationWarning` shape but reframed as an error
66/// (the warning was advisory on the first call; on retry exhaust it
67/// becomes the reason we couldn't deliver an answer).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ValidationError {
70    pub kind: ValidationErrorKind,
71    pub detail: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ValidationErrorKind {
76    /// `[^N]` body wasn't a positive decimal terminated by `]`.
77    Malformed,
78    /// `N` was outside `1..=sources_count`.
79    OutOfRange,
80}
81
82/// What the validator decided. The caller acts on this.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Decision {
85    /// Citations parsed cleanly — emit the answer to the user.
86    Ok,
87    /// Strict + first attempt + structural failure. Caller should
88    /// issue exactly one follow-up LLM call with this prompt
89    /// prepended to (or substituted for) the synthesis prompt.
90    Retry { prompt: String },
91    /// Strict + retry attempt + still failing. Caller should respond
92    /// HTTP 422 with these errors in `validation.errors`.
93    GiveUp { errors: Vec<ValidationError> },
94}
95
96/// Pure validation step.
97///
98/// `sources_count` is the length of `sources_flat`; we don't re-derive
99/// out-of-range here because [`CitationParser`] already emitted the
100/// warning during parsing. We just decide what to *do* about it.
101pub fn validate(parsed: &CitationParseResult, mode: Mode, attempt: Attempt) -> Decision {
102    if mode == Mode::Lenient {
103        return Decision::Ok;
104    }
105
106    let structural_warnings: Vec<&CitationWarning> = parsed
107        .warnings
108        .iter()
109        .filter(|w| {
110            matches!(
111                w.kind,
112                CitationWarningKind::Malformed | CitationWarningKind::OutOfRange
113            )
114        })
115        .collect();
116
117    if structural_warnings.is_empty() {
118        return Decision::Ok;
119    }
120
121    match attempt {
122        Attempt::First => Decision::Retry {
123            prompt: build_retry_prompt(&structural_warnings),
124        },
125        Attempt::Retry => Decision::GiveUp {
126            errors: structural_warnings
127                .iter()
128                .map(|w| ValidationError {
129                    kind: match w.kind {
130                        CitationWarningKind::Malformed => ValidationErrorKind::Malformed,
131                        CitationWarningKind::OutOfRange => ValidationErrorKind::OutOfRange,
132                    },
133                    detail: w.detail.clone(),
134                })
135                .collect(),
136        },
137    }
138}
139
140/// Construct the prompt the caller should send on the single retry.
141///
142/// The phrasing is pinned by tests; it intentionally:
143///
144/// - states the valid range explicitly,
145/// - quotes the offending markers/details so the LLM sees its own
146///   mistake,
147/// - forbids inventing sources,
148/// - asks for the answer to be re-emitted in full (we don't try to
149///   patch the prior answer in place).
150fn build_retry_prompt(warnings: &[&CitationWarning]) -> String {
151    let mut out = String::from(
152        "Your previous answer contained citation markers that do not match \
153         the available sources. Reissue the answer in full, with every \
154         `[^N]` marker referring to a real source by its 1-indexed position \
155         in the provided context. Do not invent or renumber sources; if a \
156         claim is not supported by a real source, drop the marker rather \
157         than fabricate one. Problems detected:\n",
158    );
159    for w in warnings {
160        let kind = match w.kind {
161            CitationWarningKind::Malformed => "malformed",
162            CitationWarningKind::OutOfRange => "out_of_range",
163        };
164        out.push_str(&format!("- [{kind}] {}\n", w.detail));
165    }
166    out
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::runtime::ai::citation_parser::{Citation, CitationParseResult, CitationWarning};
173
174    fn ok_result() -> CitationParseResult {
175        CitationParseResult {
176            citations: vec![Citation {
177                marker: 1,
178                span: 0..4,
179                source_index: 0,
180            }],
181            warnings: vec![],
182        }
183    }
184
185    fn malformed_result() -> CitationParseResult {
186        CitationParseResult {
187            citations: vec![],
188            warnings: vec![CitationWarning {
189                kind: CitationWarningKind::Malformed,
190                span: 0..4,
191                detail: "empty marker body".to_string(),
192            }],
193        }
194    }
195
196    fn out_of_range_result() -> CitationParseResult {
197        CitationParseResult {
198            citations: vec![Citation {
199                marker: 9,
200                span: 0..4,
201                source_index: 8,
202            }],
203            warnings: vec![CitationWarning {
204                kind: CitationWarningKind::OutOfRange,
205                span: 0..4,
206                detail: "marker [^9] references source #9 but only 2 sources available".to_string(),
207            }],
208        }
209    }
210
211    fn mixed_result() -> CitationParseResult {
212        CitationParseResult {
213            citations: vec![],
214            warnings: vec![
215                CitationWarning {
216                    kind: CitationWarningKind::Malformed,
217                    span: 0..3,
218                    detail: "empty".into(),
219                },
220                CitationWarning {
221                    kind: CitationWarningKind::OutOfRange,
222                    span: 4..8,
223                    detail: "marker [^7] references source #7 but only 1 sources available"
224                        .to_string(),
225                },
226            ],
227        }
228    }
229
230    // ---- Strict mode --------------------------------------------------
231
232    #[test]
233    fn strict_clean_is_ok_on_first() {
234        assert_eq!(
235            validate(&ok_result(), Mode::Strict, Attempt::First),
236            Decision::Ok
237        );
238    }
239
240    #[test]
241    fn strict_clean_is_ok_on_retry_too() {
242        // The retry call also produced clean output — that's the
243        // success path for "first call failed, retry succeeded".
244        assert_eq!(
245            validate(&ok_result(), Mode::Strict, Attempt::Retry),
246            Decision::Ok
247        );
248    }
249
250    #[test]
251    fn strict_malformed_first_attempt_asks_for_retry() {
252        let decision = validate(&malformed_result(), Mode::Strict, Attempt::First);
253        match decision {
254            Decision::Retry { prompt } => {
255                assert!(prompt.contains("Reissue the answer"));
256                assert!(prompt.contains("malformed"));
257                assert!(prompt.contains("empty marker body"));
258            }
259            other => panic!("expected Retry, got {other:?}"),
260        }
261    }
262
263    #[test]
264    fn strict_out_of_range_first_attempt_asks_for_retry() {
265        let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::First);
266        match decision {
267            Decision::Retry { prompt } => {
268                assert!(prompt.contains("out_of_range"));
269                assert!(prompt.contains("source #9"));
270                // No-fabrication clause is part of the contract.
271                assert!(prompt.contains("Do not invent"));
272            }
273            other => panic!("expected Retry, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn strict_malformed_retry_attempt_gives_up() {
279        let decision = validate(&malformed_result(), Mode::Strict, Attempt::Retry);
280        match decision {
281            Decision::GiveUp { errors } => {
282                assert_eq!(errors.len(), 1);
283                assert_eq!(errors[0].kind, ValidationErrorKind::Malformed);
284                assert_eq!(errors[0].detail, "empty marker body");
285            }
286            other => panic!("expected GiveUp, got {other:?}"),
287        }
288    }
289
290    #[test]
291    fn strict_out_of_range_retry_attempt_gives_up() {
292        let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::Retry);
293        match decision {
294            Decision::GiveUp { errors } => {
295                assert_eq!(errors.len(), 1);
296                assert_eq!(errors[0].kind, ValidationErrorKind::OutOfRange);
297                assert!(errors[0].detail.contains("source #9"));
298            }
299            other => panic!("expected GiveUp, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn strict_mixed_warnings_carry_through_to_giveup() {
305        let decision = validate(&mixed_result(), Mode::Strict, Attempt::Retry);
306        match decision {
307            Decision::GiveUp { errors } => {
308                assert_eq!(errors.len(), 2);
309                assert_eq!(errors[0].kind, ValidationErrorKind::Malformed);
310                assert_eq!(errors[1].kind, ValidationErrorKind::OutOfRange);
311            }
312            other => panic!("expected GiveUp, got {other:?}"),
313        }
314    }
315
316    #[test]
317    fn strict_mixed_warnings_first_attempt_still_retries() {
318        let decision = validate(&mixed_result(), Mode::Strict, Attempt::First);
319        assert!(matches!(decision, Decision::Retry { .. }));
320    }
321
322    // ---- Lenient mode -------------------------------------------------
323
324    #[test]
325    fn lenient_passes_clean() {
326        assert_eq!(
327            validate(&ok_result(), Mode::Lenient, Attempt::First),
328            Decision::Ok
329        );
330    }
331
332    #[test]
333    fn lenient_passes_malformed() {
334        // Warnings are still on `parsed.warnings`; the validator just
335        // refuses to act on them in lenient mode.
336        assert_eq!(
337            validate(&malformed_result(), Mode::Lenient, Attempt::First),
338            Decision::Ok
339        );
340    }
341
342    #[test]
343    fn lenient_passes_out_of_range() {
344        assert_eq!(
345            validate(&out_of_range_result(), Mode::Lenient, Attempt::First),
346            Decision::Ok
347        );
348    }
349
350    #[test]
351    fn lenient_ignores_attempt() {
352        // Retry-budget tracking is a strict-mode concern. In lenient
353        // mode the validator behaves identically regardless of attempt.
354        assert_eq!(
355            validate(&malformed_result(), Mode::Lenient, Attempt::Retry),
356            Decision::Ok
357        );
358    }
359
360    // ---- Retry-prompt contract ---------------------------------------
361
362    #[test]
363    fn retry_prompt_includes_every_warning_detail() {
364        let parsed = mixed_result();
365        let decision = validate(&parsed, Mode::Strict, Attempt::First);
366        let Decision::Retry { prompt } = decision else {
367            panic!("expected Retry");
368        };
369        for w in &parsed.warnings {
370            assert!(
371                prompt.contains(&w.detail),
372                "retry prompt missing detail `{}`, got:\n{prompt}",
373                w.detail
374            );
375        }
376    }
377
378    #[test]
379    fn retry_prompt_is_deterministic() {
380        // Two validations of the same input must produce byte-equal
381        // retry prompts — required for the ASK determinism contract
382        // (#400). Strings of side-effects (e.g. timestamps, RNG) must
383        // never leak into the prompt builder.
384        let parsed = mixed_result();
385        let a = validate(&parsed, Mode::Strict, Attempt::First);
386        let b = validate(&parsed, Mode::Strict, Attempt::First);
387        assert_eq!(a, b);
388    }
389
390    #[test]
391    fn retry_prompt_forbids_fabrication() {
392        let decision = validate(&out_of_range_result(), Mode::Strict, Attempt::First);
393        let Decision::Retry { prompt } = decision else {
394            panic!("expected Retry");
395        };
396        // Anti-hallucination guard — the LLM must not "fix" the
397        // citation by inventing a new source.
398        assert!(prompt.contains("Do not invent"));
399    }
400
401    // ---- Boundary cases ----------------------------------------------
402
403    #[test]
404    fn empty_parse_is_ok_in_either_mode() {
405        let empty = CitationParseResult::default();
406        assert_eq!(
407            validate(&empty, Mode::Strict, Attempt::First),
408            Decision::Ok
409        );
410        assert_eq!(
411            validate(&empty, Mode::Strict, Attempt::Retry),
412            Decision::Ok
413        );
414        assert_eq!(
415            validate(&empty, Mode::Lenient, Attempt::First),
416            Decision::Ok
417        );
418    }
419
420    #[test]
421    fn citations_without_warnings_are_ok() {
422        // Many successful citations, no warnings — the success path.
423        let parsed = CitationParseResult {
424            citations: vec![
425                Citation {
426                    marker: 1,
427                    span: 0..4,
428                    source_index: 0,
429                },
430                Citation {
431                    marker: 2,
432                    span: 5..9,
433                    source_index: 1,
434                },
435                Citation {
436                    marker: 3,
437                    span: 10..14,
438                    source_index: 2,
439                },
440            ],
441            warnings: vec![],
442        };
443        assert_eq!(
444            validate(&parsed, Mode::Strict, Attempt::First),
445            Decision::Ok
446        );
447    }
448}