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}