1use crate::serde_json::{Map, Value};
67
68#[derive(Debug, Clone)]
72pub struct BucketPlan {
73 pub bucket: String,
76 pub top_k: u32,
77 pub min_score: f32,
78}
79
80#[derive(Debug, Clone)]
84pub struct PlannedSource {
85 pub urn: String,
86 pub rrf_score: f64,
87}
88
89#[derive(Debug, Clone)]
93pub struct ProviderSelection {
94 pub name: String,
95 pub model: String,
96 pub supports_citations: bool,
97 pub supports_seed: bool,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Mode {
105 Strict,
106 Lenient,
107}
108
109impl Mode {
110 fn as_wire(self) -> &'static str {
111 match self {
112 Mode::Strict => "strict",
113 Mode::Lenient => "lenient",
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default)]
122pub struct Determinism {
123 pub temperature: Option<f32>,
124 pub seed: Option<u64>,
125}
126
127#[derive(Debug, Clone, Copy)]
132pub struct EstimatedCost {
133 pub prompt_tokens: u32,
134 pub max_completion_tokens: u32,
135}
136
137#[derive(Debug, Clone)]
141pub struct Inputs<'a> {
142 pub question: &'a str,
143 pub mode: Mode,
144 pub retrieval: &'a [BucketPlan],
145 pub fusion_limit: u32,
146 pub fusion_k_constant: u32,
147 pub depth: u32,
148 pub sources: &'a [PlannedSource],
149 pub provider: &'a ProviderSelection,
150 pub determinism: Determinism,
151 pub estimated_cost: EstimatedCost,
152}
153
154fn obj(entries: Vec<(&str, Value)>) -> Value {
155 let mut map = Map::new();
156 for (k, v) in entries {
157 map.insert(k.to_string(), v);
158 }
159 Value::Object(map)
160}
161
162fn bucket_value(b: &BucketPlan) -> Value {
163 obj(vec![
164 ("bucket", Value::String(b.bucket.clone())),
165 ("min_score", Value::Number(b.min_score as f64)),
166 ("top_k", Value::Number(b.top_k as f64)),
167 ])
168}
169
170fn source_value(rank: usize, s: &PlannedSource) -> Value {
171 obj(vec![
172 ("rank", Value::Number(rank as f64)),
173 ("rrf_score", Value::Number(s.rrf_score)),
174 ("urn", Value::String(s.urn.clone())),
175 ])
176}
177
178fn provider_value(p: &ProviderSelection) -> Value {
179 obj(vec![
180 ("model", Value::String(p.model.clone())),
181 ("name", Value::String(p.name.clone())),
182 ("supports_citations", Value::Bool(p.supports_citations)),
183 ("supports_seed", Value::Bool(p.supports_seed)),
184 ])
185}
186
187fn determinism_value(d: Determinism) -> Value {
188 let mut entries: Vec<(&str, Value)> = Vec::new();
189 if let Some(seed) = d.seed {
190 entries.push(("seed", Value::Number(seed as f64)));
191 }
192 if let Some(t) = d.temperature {
193 entries.push(("temperature", Value::Number(t as f64)));
194 }
195 obj(entries)
196}
197
198fn cost_value(c: EstimatedCost) -> Value {
199 obj(vec![
200 (
201 "max_completion_tokens",
202 Value::Number(c.max_completion_tokens as f64),
203 ),
204 ("prompt_tokens", Value::Number(c.prompt_tokens as f64)),
205 ])
206}
207
208fn fusion_value(limit: u32, k: u32) -> Value {
209 obj(vec![
210 ("algorithm", Value::String("rrf".to_string())),
211 ("k_constant", Value::Number(k as f64)),
212 ("limit", Value::Number(limit as f64)),
213 ])
214}
215
216pub fn build(inputs: &Inputs<'_>) -> Value {
220 obj(vec![
221 ("depth", Value::Number(inputs.depth as f64)),
222 ("determinism", determinism_value(inputs.determinism)),
223 ("estimated_cost", cost_value(inputs.estimated_cost)),
224 (
225 "fusion",
226 fusion_value(inputs.fusion_limit, inputs.fusion_k_constant),
227 ),
228 ("mode", Value::String(inputs.mode.as_wire().to_string())),
229 ("provider", provider_value(inputs.provider)),
230 ("question", Value::String(inputs.question.to_string())),
231 (
232 "retrieval",
233 Value::Array(inputs.retrieval.iter().map(bucket_value).collect()),
234 ),
235 (
236 "sources",
237 Value::Array(
238 inputs
239 .sources
240 .iter()
241 .enumerate()
242 .map(|(i, s)| source_value(i + 1, s))
243 .collect(),
244 ),
245 ),
246 ])
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn provider_openai() -> ProviderSelection {
254 ProviderSelection {
255 name: "openai".to_string(),
256 model: "gpt-4o-mini".to_string(),
257 supports_citations: true,
258 supports_seed: true,
259 }
260 }
261
262 fn provider_anthropic() -> ProviderSelection {
263 ProviderSelection {
264 name: "anthropic".to_string(),
265 model: "claude-opus-4-7".to_string(),
266 supports_citations: true,
267 supports_seed: false,
268 }
269 }
270
271 fn default_buckets() -> Vec<BucketPlan> {
272 vec![
273 BucketPlan {
274 bucket: "bm25".to_string(),
275 top_k: 20,
276 min_score: 0.0,
277 },
278 BucketPlan {
279 bucket: "vector".to_string(),
280 top_k: 20,
281 min_score: 0.7,
282 },
283 BucketPlan {
284 bucket: "graph".to_string(),
285 top_k: 20,
286 min_score: 0.0,
287 },
288 ]
289 }
290
291 fn fixture<'a>(
292 provider: &'a ProviderSelection,
293 retrieval: &'a [BucketPlan],
294 sources: &'a [PlannedSource],
295 determinism: Determinism,
296 ) -> Inputs<'a> {
297 Inputs {
298 question: "what changed last week?",
299 mode: Mode::Strict,
300 retrieval,
301 fusion_limit: 20,
302 fusion_k_constant: 60,
303 depth: 2,
304 sources,
305 provider,
306 determinism,
307 estimated_cost: EstimatedCost {
308 prompt_tokens: 1500,
309 max_completion_tokens: 1024,
310 },
311 }
312 }
313
314 #[test]
315 fn build_emits_pinned_top_level_keys() {
316 let p = provider_openai();
317 let b = default_buckets();
318 let v = build(&fixture(&p, &b, &[], Determinism::default()));
319 let obj = v.as_object().expect("top-level object");
320 let keys: Vec<&str> = obj.keys().map(|k| k.as_str()).collect();
321 assert_eq!(
322 keys,
323 vec![
324 "depth",
325 "determinism",
326 "estimated_cost",
327 "fusion",
328 "mode",
329 "provider",
330 "question",
331 "retrieval",
332 "sources",
333 ]
334 );
335 }
336
337 #[test]
338 fn build_is_deterministic_across_calls() {
339 let p = provider_openai();
340 let b = default_buckets();
341 let s = vec![PlannedSource {
342 urn: "urn:reddb:row:1".to_string(),
343 rrf_score: 0.0327,
344 }];
345 let d = Determinism {
346 temperature: Some(0.0),
347 seed: Some(12345),
348 };
349 let a = build(&fixture(&p, &b, &s, d));
350 let b2 = build(&fixture(&p, &b, &s, d));
351 assert_eq!(a.to_string_compact(), b2.to_string_compact());
352 }
353
354 #[test]
355 fn mode_serializes_as_lowercase_words() {
356 let p = provider_openai();
357 let b = default_buckets();
358 let mut inp = fixture(&p, &b, &[], Determinism::default());
359 inp.mode = Mode::Lenient;
360 let v = build(&inp);
361 assert_eq!(v.get("mode").and_then(|x| x.as_str()), Some("lenient"));
362 let mut inp2 = fixture(&p, &b, &[], Determinism::default());
363 inp2.mode = Mode::Strict;
364 let v2 = build(&inp2);
365 assert_eq!(v2.get("mode").and_then(|x| x.as_str()), Some("strict"));
366 }
367
368 #[test]
369 fn determinism_omits_seed_when_provider_does_not_support_it() {
370 let p = provider_anthropic();
375 let b = default_buckets();
376 let d = Determinism {
377 temperature: Some(0.0),
378 seed: None,
379 };
380 let v = build(&fixture(&p, &b, &[], d));
381 let det = v.get("determinism").and_then(|x| x.as_object()).unwrap();
382 assert!(det.contains_key("temperature"));
383 assert!(!det.contains_key("seed"));
384 }
385
386 #[test]
387 fn determinism_omits_temperature_for_local_class_providers() {
388 let p = ProviderSelection {
392 name: "local".to_string(),
393 model: "ggml".to_string(),
394 supports_citations: false,
395 supports_seed: false,
396 };
397 let b = default_buckets();
398 let d = Determinism {
399 temperature: None,
400 seed: None,
401 };
402 let v = build(&fixture(&p, &b, &[], d));
403 let det = v.get("determinism").and_then(|x| x.as_object()).unwrap();
404 assert!(det.is_empty());
405 }
406
407 #[test]
408 fn seed_zero_is_preserved_distinct_from_none() {
409 let p = provider_openai();
412 let b = default_buckets();
413 let d = Determinism {
414 temperature: Some(0.0),
415 seed: Some(0),
416 };
417 let v = build(&fixture(&p, &b, &[], d));
418 let det = v.get("determinism").and_then(|x| x.as_object()).unwrap();
419 assert!(det.contains_key("seed"));
420 assert_eq!(det.get("seed").and_then(|x| x.as_u64()), Some(0));
421 }
422
423 #[test]
424 fn retrieval_preserves_input_order_per_bucket() {
425 let p = provider_openai();
429 let b = default_buckets();
430 let v = build(&fixture(&p, &b, &[], Determinism::default()));
431 let buckets = v.get("retrieval").and_then(|x| x.as_array()).unwrap();
432 let names: Vec<&str> = buckets
433 .iter()
434 .map(|b| b.get("bucket").and_then(|x| x.as_str()).unwrap())
435 .collect();
436 assert_eq!(names, vec!["bm25", "vector", "graph"]);
437 }
438
439 #[test]
440 fn retrieval_carries_per_bucket_min_score() {
441 let p = provider_openai();
446 let b = default_buckets();
447 let v = build(&fixture(&p, &b, &[], Determinism::default()));
448 let buckets = v.get("retrieval").and_then(|x| x.as_array()).unwrap();
449 let vector = &buckets[1];
450 let v_score = vector.get("min_score").and_then(|x| x.as_f64()).unwrap();
451 assert!((v_score - 0.7).abs() < 1e-6, "got {v_score}");
454 let bm25 = &buckets[0];
455 assert_eq!(bm25.get("min_score").and_then(|x| x.as_f64()), Some(0.0));
456 }
457
458 #[test]
459 fn sources_emit_one_indexed_rank() {
460 let p = provider_openai();
461 let b = default_buckets();
462 let s = vec![
463 PlannedSource {
464 urn: "urn:a".to_string(),
465 rrf_score: 0.05,
466 },
467 PlannedSource {
468 urn: "urn:b".to_string(),
469 rrf_score: 0.04,
470 },
471 PlannedSource {
472 urn: "urn:c".to_string(),
473 rrf_score: 0.03,
474 },
475 ];
476 let v = build(&fixture(&p, &b, &s, Determinism::default()));
477 let arr = v.get("sources").and_then(|x| x.as_array()).unwrap();
478 let ranks: Vec<u64> = arr
479 .iter()
480 .map(|s| s.get("rank").and_then(|x| x.as_u64()).unwrap())
481 .collect();
482 assert_eq!(ranks, vec![1, 2, 3]);
483 }
484
485 #[test]
486 fn sources_preserve_input_order() {
487 let p = provider_openai();
491 let b = default_buckets();
492 let s = vec![
493 PlannedSource {
494 urn: "urn:z".to_string(),
495 rrf_score: 0.05,
496 },
497 PlannedSource {
498 urn: "urn:a".to_string(),
499 rrf_score: 0.04,
500 },
501 ];
502 let v = build(&fixture(&p, &b, &s, Determinism::default()));
503 let arr = v.get("sources").and_then(|x| x.as_array()).unwrap();
504 let urns: Vec<&str> = arr
505 .iter()
506 .map(|s| s.get("urn").and_then(|x| x.as_str()).unwrap())
507 .collect();
508 assert_eq!(urns, vec!["urn:z", "urn:a"]);
509 }
510
511 #[test]
512 fn empty_sources_is_well_formed() {
513 let p = provider_openai();
515 let b = default_buckets();
516 let v = build(&fixture(&p, &b, &[], Determinism::default()));
517 let arr = v.get("sources").and_then(|x| x.as_array()).unwrap();
518 assert!(arr.is_empty());
519 }
520
521 #[test]
522 fn empty_retrieval_is_well_formed() {
523 let p = provider_openai();
526 let v = build(&fixture(&p, &[], &[], Determinism::default()));
527 let arr = v.get("retrieval").and_then(|x| x.as_array()).unwrap();
528 assert!(arr.is_empty());
529 }
530
531 #[test]
532 fn fusion_section_pins_rrf_and_k_constant() {
533 let p = provider_openai();
536 let b = default_buckets();
537 let v = build(&fixture(&p, &b, &[], Determinism::default()));
538 let fusion = v.get("fusion").and_then(|x| x.as_object()).unwrap();
539 assert_eq!(
540 fusion.get("algorithm").and_then(|x| x.as_str()),
541 Some("rrf")
542 );
543 assert_eq!(fusion.get("k_constant").and_then(|x| x.as_u64()), Some(60));
544 assert_eq!(fusion.get("limit").and_then(|x| x.as_u64()), Some(20));
545 }
546
547 #[test]
548 fn provider_section_carries_capability_flags() {
549 let p = provider_anthropic();
550 let b = default_buckets();
551 let v = build(&fixture(&p, &b, &[], Determinism::default()));
552 let prov = v.get("provider").and_then(|x| x.as_object()).unwrap();
553 assert_eq!(prov.get("name").and_then(|x| x.as_str()), Some("anthropic"));
554 assert_eq!(
555 prov.get("supports_citations").and_then(|x| x.as_bool()),
556 Some(true)
557 );
558 assert_eq!(
559 prov.get("supports_seed").and_then(|x| x.as_bool()),
560 Some(false)
561 );
562 }
563
564 #[test]
565 fn estimated_cost_pins_keys_and_values() {
566 let p = provider_openai();
567 let b = default_buckets();
568 let v = build(&fixture(&p, &b, &[], Determinism::default()));
569 let c = v.get("estimated_cost").and_then(|x| x.as_object()).unwrap();
570 let keys: Vec<&str> = c.keys().map(|k| k.as_str()).collect();
571 assert_eq!(keys, vec!["max_completion_tokens", "prompt_tokens"]);
572 assert_eq!(c.get("prompt_tokens").and_then(|x| x.as_u64()), Some(1500));
573 assert_eq!(
574 c.get("max_completion_tokens").and_then(|x| x.as_u64()),
575 Some(1024)
576 );
577 }
578
579 #[test]
580 fn question_is_passed_through_verbatim() {
581 let p = provider_openai();
585 let b = default_buckets();
586 let mut inp = fixture(&p, &b, &[], Determinism::default());
587 let q = "weird \"quotes\" + newlines\nstill ok?";
588 inp.question = q;
589 let v = build(&inp);
590 assert_eq!(v.get("question").and_then(|x| x.as_str()), Some(q));
591 }
592
593 #[test]
594 fn depth_is_pass_through_u32() {
595 let p = provider_openai();
596 let b = default_buckets();
597 let mut inp = fixture(&p, &b, &[], Determinism::default());
598 inp.depth = 5;
599 let v = build(&inp);
600 assert_eq!(v.get("depth").and_then(|x| x.as_u64()), Some(5));
601 }
602}