Skip to main content

reddb_server/runtime/ai/
determinism_decider.rs

1//! `DeterminismDecider` — pure resolution of `temperature` + `seed`.
2//!
3//! Issue #400 (PRD #391): every ASK call must default to
4//! `temperature = 0` and a `seed` derived from
5//! `hash(question + sources_fingerprint)` so that the same question
6//! against the same data produces the same answer (within provider
7//! determinism guarantees). Per-query `ASK '...' TEMPERATURE x SEED n`
8//! overrides win, and providers that don't honor a given knob silently
9//! drop it.
10//!
11//! This is a deep module: no I/O, no transport, no clock. Inputs are
12//! plain data; the output is the pair of parameters the caller should
13//! actually send to the provider, plus the values to record in the
14//! audit row. The caller is responsible for:
15//!
16//! - threading [`Applied::temperature`] / [`Applied::seed`] into the
17//!   provider request,
18//! - writing what was *actually* applied (not what was requested) into
19//!   `red_ask_audit` per AC.
20//!
21//! ## Policy
22//!
23//! Temperature:
24//! - Per-query override always wins, when the provider accepts a
25//!   temperature at all.
26//! - Otherwise the value comes from `Settings::default_temperature`
27//!   (which itself defaults to `0.0` upstream).
28//! - If the provider does NOT accept a temperature (raw inference
29//!   endpoints like the embedded `local` backend; see
30//!   [`Capabilities::supports_temperature_zero`] in #396), the
31//!   temperature is dropped to `None` regardless of override, since
32//!   sending it would break the call.
33//!
34//! Seed:
35//! - Per-query override wins for providers that honor `seed`.
36//! - Otherwise the seed is derived deterministically from
37//!   `sha256(question || 0x1f || sources_fingerprint)`, taking the
38//!   first 8 bytes as a little-endian `u64`. Same question + same
39//!   data ⇒ same seed, every call, every process.
40//! - Providers that don't honor seed (Anthropic, HuggingFace, `local`,
41//!   `custom`) drop the seed to `None`.
42//!
43//! ## Why this lives in its own module
44//!
45//! Spreading temperature/seed defaulting across the request builders
46//! produced subtle bugs the first time we tried it: the audit row
47//! recorded a seed the provider had silently ignored, and a single
48//! `Option::unwrap_or` slipped past the capability check. Centralising
49//! the decision (and pinning every branch under unit test) makes the
50//! audit row trustworthy and lets the determinism contract be reasoned
51//! about in isolation.
52
53use sha2::{Digest, Sha256};
54
55use crate::runtime::ai::provider_capabilities::Capabilities;
56
57/// Per-query overrides parsed from `ASK '...' TEMPERATURE x SEED n`.
58#[derive(Debug, Clone, Copy, Default, PartialEq)]
59pub struct Overrides {
60    pub temperature: Option<f32>,
61    pub seed: Option<u64>,
62}
63
64/// Deployment-level defaults. `default_temperature` comes from the
65/// `ask.default_temperature` setting (default `0.0`).
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct Settings {
68    pub default_temperature: f32,
69}
70
71impl Default for Settings {
72    fn default() -> Self {
73        Self {
74            default_temperature: 0.0,
75        }
76    }
77}
78
79/// Pure inputs to the deciding function.
80#[derive(Debug, Clone, Copy)]
81pub struct Inputs<'a> {
82    pub question: &'a str,
83    /// Stable hash over the URNs and content versions of the retrieved
84    /// sources. The decider does not recompute this — the retrieval
85    /// layer owns the fingerprint format and passes the bytes/string
86    /// down. Treating it as opaque keeps the determinism contract
87    /// independent of the source schema.
88    pub sources_fingerprint: &'a str,
89}
90
91/// What the caller should actually send to the provider.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct Applied {
94    pub temperature: Option<f32>,
95    pub seed: Option<u64>,
96}
97
98/// Resolve effective `temperature` and `seed` for a single ASK call.
99///
100/// `caps` should be the capabilities row for the *target* provider —
101/// usually [`super::provider_capabilities::Registry::capabilities_for`].
102pub fn decide(
103    inputs: Inputs<'_>,
104    caps: Capabilities,
105    overrides: Overrides,
106    settings: Settings,
107) -> Applied {
108    let temperature =
109        resolve_temperature(caps, overrides.temperature, settings.default_temperature);
110    let seed = resolve_seed(caps, overrides.seed, inputs);
111    Applied { temperature, seed }
112}
113
114fn resolve_temperature(caps: Capabilities, override_t: Option<f32>, default_t: f32) -> Option<f32> {
115    if !caps.supports_temperature_zero {
116        // Provider takes no temperature at all (e.g. embedded `local`).
117        // Sending one would be a request error.
118        return None;
119    }
120    Some(override_t.unwrap_or(default_t))
121}
122
123fn resolve_seed(caps: Capabilities, override_s: Option<u64>, inputs: Inputs<'_>) -> Option<u64> {
124    if !caps.supports_seed {
125        return None;
126    }
127    if let Some(s) = override_s {
128        return Some(s);
129    }
130    Some(derive_seed(inputs.question, inputs.sources_fingerprint))
131}
132
133/// `sha256(question || 0x1f || fingerprint) -> u64 little-endian`.
134///
135/// 0x1f (ASCII US, "unit separator") is used as the field delimiter
136/// because it cannot appear in a question (the SQL parser would have
137/// rejected it) or in a hex fingerprint, so the concatenation is
138/// injective without escaping.
139pub fn derive_seed(question: &str, sources_fingerprint: &str) -> u64 {
140    let mut hasher = Sha256::new();
141    hasher.update(question.as_bytes());
142    hasher.update([0x1f]);
143    hasher.update(sources_fingerprint.as_bytes());
144    let digest = hasher.finalize();
145    let mut buf = [0u8; 8];
146    buf.copy_from_slice(&digest[..8]);
147    u64::from_le_bytes(buf)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn full_caps() -> Capabilities {
155        // OpenAI-like: everything on.
156        Capabilities {
157            supports_citations: true,
158            supports_seed: true,
159            supports_temperature_zero: true,
160            supports_streaming: true,
161        }
162    }
163
164    fn no_seed_caps() -> Capabilities {
165        // Anthropic-like: temperature ok, no seed.
166        Capabilities {
167            supports_citations: true,
168            supports_seed: false,
169            supports_temperature_zero: true,
170            supports_streaming: true,
171        }
172    }
173
174    fn no_temp_caps() -> Capabilities {
175        // `local`-like: no temperature, no seed.
176        Capabilities {
177            supports_citations: false,
178            supports_seed: false,
179            supports_temperature_zero: false,
180            supports_streaming: false,
181        }
182    }
183
184    fn inputs() -> Inputs<'static> {
185        Inputs {
186            question: "what is the meaning of life?",
187            sources_fingerprint: "abc123",
188        }
189    }
190
191    // ---- defaults ----------------------------------------------------
192
193    #[test]
194    fn default_temperature_is_zero() {
195        let out = decide(
196            inputs(),
197            full_caps(),
198            Overrides::default(),
199            Settings::default(),
200        );
201        assert_eq!(out.temperature, Some(0.0));
202    }
203
204    #[test]
205    fn default_seed_is_derived_from_question_and_fingerprint() {
206        let out = decide(
207            inputs(),
208            full_caps(),
209            Overrides::default(),
210            Settings::default(),
211        );
212        let expected = derive_seed(inputs().question, inputs().sources_fingerprint);
213        assert_eq!(out.seed, Some(expected));
214    }
215
216    // ---- overrides ---------------------------------------------------
217
218    #[test]
219    fn temperature_override_wins_over_default() {
220        let out = decide(
221            inputs(),
222            full_caps(),
223            Overrides {
224                temperature: Some(0.7),
225                seed: None,
226            },
227            Settings::default(),
228        );
229        assert_eq!(out.temperature, Some(0.7));
230    }
231
232    #[test]
233    fn seed_override_wins_over_derivation() {
234        let out = decide(
235            inputs(),
236            full_caps(),
237            Overrides {
238                temperature: None,
239                seed: Some(42),
240            },
241            Settings::default(),
242        );
243        assert_eq!(out.seed, Some(42));
244    }
245
246    #[test]
247    fn settings_default_temperature_honored_when_no_override() {
248        let out = decide(
249            inputs(),
250            full_caps(),
251            Overrides::default(),
252            Settings {
253                default_temperature: 0.3,
254            },
255        );
256        assert_eq!(out.temperature, Some(0.3));
257    }
258
259    #[test]
260    fn override_temperature_beats_settings_default() {
261        let out = decide(
262            inputs(),
263            full_caps(),
264            Overrides {
265                temperature: Some(0.9),
266                seed: None,
267            },
268            Settings {
269                default_temperature: 0.3,
270            },
271        );
272        assert_eq!(out.temperature, Some(0.9));
273    }
274
275    // ---- capability gating -------------------------------------------
276
277    #[test]
278    fn no_seed_capability_drops_derived_seed() {
279        let out = decide(
280            inputs(),
281            no_seed_caps(),
282            Overrides::default(),
283            Settings::default(),
284        );
285        assert_eq!(out.seed, None);
286        // Temperature still present.
287        assert_eq!(out.temperature, Some(0.0));
288    }
289
290    #[test]
291    fn no_seed_capability_drops_override_seed_too() {
292        // Audit row must reflect what the provider got, not what the
293        // caller asked for — passing `SEED 42` to Anthropic does
294        // nothing, so the decider drops it.
295        let out = decide(
296            inputs(),
297            no_seed_caps(),
298            Overrides {
299                temperature: None,
300                seed: Some(42),
301            },
302            Settings::default(),
303        );
304        assert_eq!(out.seed, None);
305    }
306
307    #[test]
308    fn no_temperature_capability_drops_temperature() {
309        let out = decide(
310            inputs(),
311            no_temp_caps(),
312            Overrides::default(),
313            Settings::default(),
314        );
315        assert_eq!(out.temperature, None);
316        assert_eq!(out.seed, None);
317    }
318
319    #[test]
320    fn no_temperature_capability_drops_override_temperature() {
321        // Even an explicit `TEMPERATURE 0.7` is dropped if the
322        // provider's endpoint takes no temperature parameter.
323        let out = decide(
324            inputs(),
325            no_temp_caps(),
326            Overrides {
327                temperature: Some(0.7),
328                seed: None,
329            },
330            Settings::default(),
331        );
332        assert_eq!(out.temperature, None);
333    }
334
335    #[test]
336    fn conservative_capabilities_drop_seed_keep_temperature() {
337        // Per #396, `Capabilities::conservative()` is: citations off,
338        // seed off, temperature_zero on, streaming off. The decider
339        // should send temperature but not seed.
340        let out = decide(
341            inputs(),
342            Capabilities::conservative(),
343            Overrides::default(),
344            Settings::default(),
345        );
346        assert_eq!(out.temperature, Some(0.0));
347        assert_eq!(out.seed, None);
348    }
349
350    // ---- determinism contract ----------------------------------------
351
352    #[test]
353    fn derive_seed_is_deterministic_across_calls() {
354        let a = derive_seed("question", "fp");
355        let b = derive_seed("question", "fp");
356        assert_eq!(a, b);
357    }
358
359    #[test]
360    fn derive_seed_differs_on_question_change() {
361        let a = derive_seed("question A", "fp");
362        let b = derive_seed("question B", "fp");
363        assert_ne!(a, b);
364    }
365
366    #[test]
367    fn derive_seed_differs_on_fingerprint_change() {
368        // Same question, different data → different seed. This is the
369        // load-bearing guarantee of #400: a row insert changes the
370        // fingerprint and the next ASK call computes a new seed even
371        // without the cache layer.
372        let a = derive_seed("question", "fp1");
373        let b = derive_seed("question", "fp2");
374        assert_ne!(a, b);
375    }
376
377    #[test]
378    fn derive_seed_is_injective_across_field_boundary() {
379        // Without the 0x1f separator, `("ab", "c")` and `("a", "bc")`
380        // would collide. Pin that they don't.
381        let a = derive_seed("ab", "c");
382        let b = derive_seed("a", "bc");
383        assert_ne!(a, b);
384    }
385
386    #[test]
387    fn decide_is_deterministic_across_calls() {
388        let a = decide(
389            inputs(),
390            full_caps(),
391            Overrides::default(),
392            Settings::default(),
393        );
394        let b = decide(
395            inputs(),
396            full_caps(),
397            Overrides::default(),
398            Settings::default(),
399        );
400        assert_eq!(a, b);
401    }
402
403    // ---- audit-shape sanity ------------------------------------------
404
405    #[test]
406    fn applied_carries_both_knobs_when_provider_supports_both() {
407        // Audit row reads from `Applied` directly; both fields must
408        // populate when capabilities permit.
409        let out = decide(
410            inputs(),
411            full_caps(),
412            Overrides::default(),
413            Settings::default(),
414        );
415        assert!(out.temperature.is_some());
416        assert!(out.seed.is_some());
417    }
418
419    #[test]
420    fn override_zero_temperature_is_preserved_not_treated_as_missing() {
421        // f32 0.0 is a valid override — must not be confused with
422        // "no override". Guards against an `unwrap_or(0.0)` regression
423        // where the override and the default would be indistinguishable.
424        let out = decide(
425            inputs(),
426            full_caps(),
427            Overrides {
428                temperature: Some(0.0),
429                seed: None,
430            },
431            Settings {
432                default_temperature: 0.5,
433            },
434        );
435        assert_eq!(out.temperature, Some(0.0));
436    }
437
438    #[test]
439    fn override_zero_seed_is_preserved() {
440        // u64 0 is a legal seed; treating `Some(0)` as "no override"
441        // would be a subtle bug.
442        let out = decide(
443            inputs(),
444            full_caps(),
445            Overrides {
446                temperature: None,
447                seed: Some(0),
448            },
449            Settings::default(),
450        );
451        assert_eq!(out.seed, Some(0));
452    }
453}