dwara_core/ai/routing.rs
1//! AI routing decisions (DW-076): which provider/model serves one
2//! request, and in what order to fail over.
3//!
4//! Pure functions over the compiled [`AiRuntime`](crate::ai::AiRuntime)
5//! structures — no I/O, no clocks, no shared state. The dataplane's AI
6//! action calls [`route`](crate::ai::AiRuntime::route) once per request
7//! and walks the returned candidates; this module owns the two
8//! decision kinds:
9//!
10//! - **Failover chain** (an alias with `failover:`): candidates are
11//! `[primary, alternates...]` in config order. The action tries each
12//! in turn on 429/5xx/transport errors and STOPS at the first
13//! non-retryable outcome. Same-provider retries are not the chain's
14//! job — the provider's upstream breaker owns those.
15//! - **Canary split** (an alias with `canary:`): ONE candidate is
16//! picked deterministically by a weighted hash of the pick key (the
17//! request id), using the same slot semantics as the dataplane's
18//! traffic splitting: cumulative bounds over weights, `hash % total`
19//! selects the slot, and the entry whose bound first exceeds the
20//! slot wins. Deterministic means re-sends with the same request id
21//! land on the same version, and ratios hold per request (converging
22//! statistically over distinct ids).
23//!
24//! The hash is an explicit 64-bit FNV-1a rather than
25//! `std::collections::hash_map::DefaultHasher`: the pick must be stable
26//! across toolchain versions (it is arithmetic on the bytes, not a
27//! seeded SipHash) because canary attribution compares series across
28//! process restarts. This mirrors why `dataplane::balance::key_hash`
29//! exists — but duplicated here in four lines rather than importing
30//! upward from `dataplane`, which the dependency direction forbids.
31
32/// The FNV-1a 64-bit offset basis.
33const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
34/// The FNV-1a 64-bit prime.
35const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
36
37/// Toolchain-stable 64-bit FNV-1a over UTF-8 bytes. Deterministic for
38/// a given input forever; good enough distribution for slot picks.
39pub fn pick_hash(key: &str) -> u64 {
40 let mut h = FNV_OFFSET;
41 for b in key.as_bytes() {
42 h ^= u64::from(*b);
43 h = h.wrapping_mul(FNV_PRIME);
44 }
45 h
46}
47
48/// The deterministic weighted pick over `(weight, T)` entries: the
49/// entry whose cumulative weight bound first exceeds
50/// `pick_hash(key) % total`. Mirrors `dataplane::split`'s slot walk
51/// over at most a handful of entries (validation bounds canary lists),
52/// so a linear scan is cheaper than any index. `total` must be > 0
53/// (validation guarantees it: weights are >= 1 and the list
54/// non-empty).
55pub fn weighted_pick<'a, T>(entries: &'a [(u32, T)], key: &str) -> &'a T {
56 let total: u64 = entries.iter().map(|(w, _)| u64::from(*w)).sum();
57 debug_assert!(total > 0, "validation guarantees positive total weight");
58 let slot = pick_hash(key) % total;
59 let mut bound = 0u64;
60 for (w, item) in entries {
61 bound += u64::from(*w);
62 if slot < bound {
63 return item;
64 }
65 }
66 // Unreachable for total > 0 (the last bound IS the total); the
67 // fallthrough keeps a hostile zero-weight list from panicking.
68 &entries[0].1
69}
70
71/// DW-091: like [`weighted_pick`] but also returns the picked entry's
72/// INDEX (0 = baseline, 1 = canary for a 2-version canary split).
73/// The auto-canary controller needs the index to record the outcome
74/// on the correct side. Same hash and slot arithmetic.
75pub fn weighted_pick_with_index<'a, T>(entries: &'a [(u32, T)], key: &str) -> (&'a T, usize) {
76 let total: u64 = entries.iter().map(|(w, _)| u64::from(*w)).sum();
77 debug_assert!(total > 0, "validation guarantees positive total weight");
78 let slot = pick_hash(key) % total;
79 let mut bound = 0u64;
80 for (i, (w, item)) in entries.iter().enumerate() {
81 bound += u64::from(*w);
82 if slot < bound {
83 return (item, i);
84 }
85 }
86 (&entries[0].1, 0)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 // White-box: the slot arithmetic is private behavior; the
94 // end-to-end convergence is covered by tests/ai_routing.rs against
95 // the real gateway. These stay here with that justification.
96
97 #[test]
98 fn hash_is_stable_for_known_inputs() {
99 // FNV-1a 64 of "" and "a" — fixed vectors pin the arithmetic.
100 assert_eq!(pick_hash(""), 0xcbf2_9ce4_8422_2325);
101 assert_eq!(pick_hash("a"), 0xaf63_dc4c_8601_ec8c);
102 }
103
104 #[test]
105 fn pick_is_deterministic_and_respects_zero_slots() {
106 let entries = vec![(10u32, "wide"), (0u32, "parked")];
107 // The zero-weight entry can never win when the other has all
108 // the slots... with total 10, slot < 10 always lands on "wide".
109 for key in ["a", "b", "c", "d", "e", "f", "g"] {
110 assert_eq!(weighted_pick(&entries, key), &"wide");
111 }
112 // Same key, same pick, always.
113 assert_eq!(
114 weighted_pick(&entries, "same"),
115 weighted_pick(&entries, "same")
116 );
117 }
118
119 #[test]
120 fn single_entry_always_wins() {
121 let entries = vec![(1u32, "only")];
122 for key in ["", "x", "long-request-id-123"] {
123 assert_eq!(weighted_pick(&entries, key), &"only");
124 }
125 }
126}