1use std::collections::HashMap;
2use std::sync::LazyLock;
3
4pub use apif_parser::validator::{
6 BENCH_ASSERT_MODE_VALUES, BENCH_CACHE_VALUES, BENCH_DURATION_KEYS, BENCH_DURATION_STOP_VALUES,
7 BENCH_LOAD_SCHEDULE_VALUES, BENCH_MODE_VALUES, BENCH_NUMERIC_KEYS, allowed_values_message,
8 canonical_bench_key, is_allowed_value,
9};
10
11pub const BENCH_BOOLEAN_KEYS: &[&str] = &["no_assert", "count_errors_in_latency"];
12
13pub const BENCH_DIRECT_KEYS: &[&str] = &[
14 "mode",
15 "profile",
16 "load_schedule",
17 "name",
18 "assert_mode",
19 "duration_stop",
20 "sample_rate",
21 "cache",
22 "latency_percentiles",
23 "warmup_mode",
24 "load_profile",
25];
26
27pub const BENCH_COMPOUND_KEYS: &[&str] = &["sources"];
28
29pub fn supported_bench_keys() -> Vec<&'static str> {
30 let mut keys: Vec<&'static str> = Vec::new();
31 keys.extend_from_slice(BENCH_DIRECT_KEYS);
32 keys.extend_from_slice(BENCH_NUMERIC_KEYS);
33 keys.extend_from_slice(BENCH_DURATION_KEYS);
34 keys.extend_from_slice(BENCH_BOOLEAN_KEYS);
35 keys.extend_from_slice(BENCH_COMPOUND_KEYS);
36 keys.push("thresholds.*");
37 keys.sort_unstable();
38 keys.dedup();
39 keys
40}
41
42pub fn bench_keys_canonical_order() -> Vec<&'static str> {
43 let mut keys = supported_bench_keys();
44 keys.sort_by(|a, b| {
45 bench_key_rank(a)
46 .cmp(&bench_key_rank(b))
47 .then_with(|| a.cmp(b))
48 });
49 keys
50}
51
52pub fn bench_key_detail(key: &str) -> String {
53 match key {
54 "mode" => format!(
55 "Runtime mode ({})",
56 allowed_values_message(BENCH_MODE_VALUES)
57 ),
58 "profile" => "Bench profile label (e.g. smoke, stress, soak)".to_string(),
59 "concurrency" => "Worker concurrency".to_string(),
60 "requests" => "Total requests stop condition".to_string(),
61 "duration" => "Duration stop condition (e.g. 30s)".to_string(),
62 "max_duration" => "Hard duration cap in requests mode".to_string(),
63 "ramp_up" => "Ramp-up duration".to_string(),
64 "warmup" => "Warmup duration".to_string(),
65 "max_rps" => "Max requests per second".to_string(),
66 "load_schedule" => format!(
67 "Load schedule ({})",
68 allowed_values_message(BENCH_LOAD_SCHEDULE_VALUES)
69 ),
70 "load_start" => "Schedule start RPS".to_string(),
71 "load_step" => "Schedule step delta RPS".to_string(),
72 "load_end" => "Schedule end RPS".to_string(),
73 "load_step_duration" => "Duration per schedule step".to_string(),
74 "load_max_duration" => "Maximum schedule duration".to_string(),
75 "progress_interval" => "Progress heartbeat interval".to_string(),
76 "connections" => "Number of transport connections".to_string(),
77 "connect_timeout" => "gRPC dial timeout".to_string(),
78 "keepalive" => "Transport keepalive interval".to_string(),
79 "cpus" => "CPU pinning hint".to_string(),
80 "name" => "Run name metadata".to_string(),
81 "assert_mode" => format!(
82 "Assertion mode ({})",
83 allowed_values_message(BENCH_ASSERT_MODE_VALUES)
84 ),
85 "no_assert" => "Disable assertions for transport-only benchmark".to_string(),
86 "duration_stop" => format!(
87 "In-flight behavior at duration deadline ({})",
88 allowed_values_message(BENCH_DURATION_STOP_VALUES)
89 ),
90 "sample_rate" => "Sampling rate for sampled assert/details".to_string(),
91 "cache" => format!(
92 "Cache mode ({})",
93 allowed_values_message(BENCH_CACHE_VALUES)
94 ),
95 "skip_first" => "Skip first N samples from stats".to_string(),
96 "count_errors_in_latency" => "Include failed calls in latency stats".to_string(),
97 "latency_percentiles" => "Comma-separated percentile list".to_string(),
98 "sources" => "Data source definitions for bench (file, format, index)".to_string(),
99 "cache_ttl" => "Cache TTL duration".to_string(),
100 "thresholds.*" => "Threshold expressions map".to_string(),
101 "load_midpoint" => "Midpoint RPS for sine schedule".to_string(),
102 "load_amplitude" => "Amplitude for sine schedule".to_string(),
103 "load_frequency" => "Frequency (rad/s) for sine schedule".to_string(),
104 "load_spike_target" => "Target RPS during spike".to_string(),
105 "load_spike_after" => "Seconds before spike starts".to_string(),
106 "load_spike_duration" => "Spike duration in seconds".to_string(),
107 "cool_down" => "Cool-down duration after main test".to_string(),
108 "warmup_mode" => "Warmup mode: warmup (default) or dry_run".to_string(),
109 "load_profile" => "Custom RPS profile (time:rps,time:rps)".to_string(),
110 _ => "BENCH option".to_string(),
111 }
112}
113
114pub fn bench_aliases(_key: &str) -> &'static [&'static str] {
115 &[]
116}
117
118pub fn is_known_bench_key(key: &str) -> bool {
119 if key == "thresholds" || key.starts_with("thresholds.") {
120 return true;
121 }
122 canonical_bench_key(key).is_some()
123}
124
125pub fn suggest_bench_key(raw_key: &str) -> Option<&'static str> {
126 let needle = raw_key.trim().to_ascii_lowercase().replace('-', "_");
127 if needle.is_empty() || needle == "thresholds" || needle.starts_with("thresholds.") {
128 return None;
129 }
130
131 let candidates = bench_keys_canonical_order();
132 let mut best: Option<(&'static str, usize)> = None;
133
134 for key in candidates {
135 if key == "thresholds.*" {
136 continue;
137 }
138 let key_norm = key.to_ascii_lowercase();
139 let Some(score) = bounded_edit_distance(&needle, &key_norm, 3) else {
140 continue;
141 };
142 match best {
143 Some((_, best_score)) if score >= best_score => {}
144 _ => best = Some((key, score)),
145 }
146 }
147
148 best.map(|(k, _)| k)
149}
150
151fn bounded_edit_distance(a: &str, b: &str, max: usize) -> Option<usize> {
152 let a_bytes = a.as_bytes();
153 let b_bytes = b.as_bytes();
154 if a_bytes == b_bytes {
155 return Some(0);
156 }
157 if a_bytes.len().abs_diff(b_bytes.len()) > max {
158 return None;
159 }
160
161 let mut prev: Vec<usize> = (0..=b_bytes.len()).collect();
162 let mut curr = vec![0; b_bytes.len() + 1];
163
164 for (i, &ac) in a_bytes.iter().enumerate() {
165 curr[0] = i + 1;
166 let mut row_min = curr[0];
167 for (j, &bc) in b_bytes.iter().enumerate() {
168 let cost = if ac == bc { 0 } else { 1 };
169 let del = prev[j + 1] + 1;
170 let ins = curr[j] + 1;
171 let sub = prev[j] + cost;
172 let v = del.min(ins).min(sub);
173 curr[j + 1] = v;
174 row_min = row_min.min(v);
175 }
176 if row_min > max {
177 return None;
178 }
179 std::mem::swap(&mut prev, &mut curr);
180 }
181
182 let dist = prev[b_bytes.len()];
183 if dist <= max { Some(dist) } else { None }
184}
185
186pub fn bench_value<'a>(bench: &'a HashMap<String, String>, key: &str) -> Option<&'a String> {
187 if let Some(v) = bench.get(key) {
188 return Some(v);
189 }
190 for alias in bench_aliases(key) {
191 if let Some(v) = bench.get(*alias) {
192 return Some(v);
193 }
194 }
195 None
196}
197
198pub fn bench_key_rank(key: &str) -> usize {
199 let canonical_order = [
200 "mode",
201 "profile",
202 "name",
203 "concurrency",
204 "requests",
205 "duration",
206 "max_duration",
207 "ramp_up",
208 "warmup",
209 "warmup_mode",
210 "cool_down",
211 "max_rps",
212 "load_schedule",
213 "load_start",
214 "load_step",
215 "load_end",
216 "load_step_duration",
217 "load_max_duration",
218 "load_midpoint",
219 "load_amplitude",
220 "load_frequency",
221 "load_spike_target",
222 "load_spike_after",
223 "load_spike_duration",
224 "load_profile",
225 "progress_interval",
226 "connections",
227 "connect_timeout",
228 "keepalive",
229 "cpus",
230 "assert_mode",
231 "no_assert",
232 "sample_rate",
233 "duration_stop",
234 "cache",
235 "cache_ttl",
236 "skip_first",
237 "count_errors_in_latency",
238 "latency_percentiles",
239 "sources",
240 ];
241
242 if let Some((idx, _)) = canonical_order.iter().enumerate().find(|(_, k)| **k == key) {
243 return idx;
244 }
245 if key.starts_with("thresholds.") || key == "thresholds" {
246 return canonical_order.len();
247 }
248 canonical_order.len() + 1
249}
250
251pub static BUILTIN_PROFILES: LazyLock<HashMap<&'static str, HashMap<&'static str, &'static str>>> =
254 LazyLock::new(|| {
255 let mut m: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
256
257 let mut functional = HashMap::new();
258 functional.insert("description", "Quick functional check");
259 functional.insert("mode", "fixed");
260 functional.insert("concurrency", "1");
261 functional.insert("requests", "100");
262 functional.insert("duration", "30s");
263 m.insert("functional", functional);
264
265 let mut load = HashMap::new();
266 load.insert("description", "Stepped load test 50→200 RPS");
267 load.insert("mode", "stepping");
268 load.insert("concurrency", "10");
269 load.insert("duration", "60s");
270 load.insert("load_schedule", "step");
271 load.insert("load_start", "50");
272 load.insert("load_step", "10");
273 load.insert("load_end", "200");
274 load.insert("load_step_duration", "10s");
275 m.insert("load", load);
276
277 let mut stress = HashMap::new();
278 stress.insert("description", "Linear stress test 10→500 RPS");
279 stress.insert("mode", "stepping");
280 stress.insert("concurrency", "50");
281 stress.insert("duration", "120s");
282 stress.insert("load_schedule", "line");
283 stress.insert("load_start", "10");
284 stress.insert("load_step", "5");
285 stress.insert("load_end", "500");
286 m.insert("stress", stress);
287
288 let mut spike = HashMap::new();
289 spike.insert("description", "Spike test: 10→500→10 RPS");
290 spike.insert("mode", "fixed");
291 spike.insert("concurrency", "100");
292 spike.insert("duration", "60s");
293 spike.insert("load_schedule", "spike");
294 spike.insert("load_start", "10");
295 spike.insert("load_spike_target", "500");
296 spike.insert("load_spike_after", "30");
297 spike.insert("load_spike_duration", "10");
298 m.insert("spike", spike);
299
300 let mut soak = HashMap::new();
301 soak.insert("description", "Long-duration soak at 50 RPS");
302 soak.insert("mode", "fixed");
303 soak.insert("concurrency", "5");
304 soak.insert("duration", "3600s");
305 soak.insert("load_schedule", "const");
306 soak.insert("load_start", "50");
307 m.insert("soak", soak);
308
309 m
310 });
311
312pub fn apply_profile(name: &str) -> Vec<(&'static str, &'static str)> {
315 if let Some(profile) = BUILTIN_PROFILES.get(name) {
316 return profile.iter().map(|(k, v)| (*k, *v)).collect();
317 }
318 Vec::new()
319}
320
321static CUSTOM_PROFILES: std::sync::LazyLock<
323 std::sync::RwLock<HashMap<String, HashMap<String, String>>>,
324> = std::sync::LazyLock::new(|| std::sync::RwLock::new(HashMap::new()));
325
326pub fn register_custom_profile(name: &str, keys: HashMap<String, String>) {
328 CUSTOM_PROFILES
329 .write()
330 .unwrap()
331 .insert(name.to_string(), keys);
332}
333
334pub fn apply_profile_dynamic(name: &str) -> Vec<(String, String)> {
337 let builtin = apply_profile(name);
339 if !builtin.is_empty() {
340 return builtin
341 .into_iter()
342 .map(|(k, v)| (k.to_string(), v.to_string()))
343 .collect();
344 }
345 if let Some(keys) = CUSTOM_PROFILES.read().unwrap().get(name) {
347 return keys.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
348 }
349 Vec::new()
350}
351
352pub fn list_profiles() -> Vec<(String, HashMap<String, String>)> {
354 let mut result: Vec<(String, HashMap<String, String>)> = BUILTIN_PROFILES
355 .iter()
356 .map(|(name, keys)| {
357 (
358 name.to_string(),
359 keys.iter()
360 .map(|(k, v)| (k.to_string(), v.to_string()))
361 .collect(),
362 )
363 })
364 .collect();
365 for (name, keys) in CUSTOM_PROFILES.read().unwrap().iter() {
366 result.push((name.clone(), keys.clone()));
367 }
368 result.sort_by(|a, b| a.0.cmp(&b.0));
369 result
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn supported_bench_keys_contains_scheduler_and_thresholds() {
378 let keys = supported_bench_keys();
379 assert!(keys.contains(&"load_schedule"));
380 assert!(keys.contains(&"progress_interval"));
381 assert!(keys.contains(&"thresholds.*"));
382 }
383
384 #[test]
385 fn bench_key_rank_orders_core_fields_before_thresholds() {
386 assert!(bench_key_rank("mode") < bench_key_rank("profile"));
387 assert!(bench_key_rank("profile") < bench_key_rank("concurrency"));
388 assert!(bench_key_rank("concurrency") < bench_key_rank("load_schedule"));
389 assert!(bench_key_rank("load_schedule") < bench_key_rank("thresholds.p(95)"));
390 assert!(bench_key_rank("thresholds.p(95)") < bench_key_rank("unknown_key"));
391 }
392
393 #[test]
394 fn bench_value_uses_canonical_keys_only() {
395 let mut bench = HashMap::new();
396 bench.insert("load_schedule".to_string(), "step".to_string());
397 bench.insert("progress_interval".to_string(), "2s".to_string());
398
399 assert_eq!(
400 bench_value(&bench, "load_schedule"),
401 Some(&"step".to_string())
402 );
403 assert_eq!(
404 bench_value(&bench, "progress_interval"),
405 Some(&"2s".to_string())
406 );
407 }
408
409 #[test]
410 fn canonical_bench_key_resolves_known_keys() {
411 assert_eq!(canonical_bench_key("load_schedule"), Some("load_schedule"));
412 assert_eq!(
413 canonical_bench_key("progress_interval"),
414 Some("progress_interval")
415 );
416 assert_eq!(canonical_bench_key("mode"), Some("mode"));
417 assert_eq!(canonical_bench_key("unknown_key"), None);
418 }
419
420 #[test]
421 fn bench_keys_canonical_order_starts_with_mode() {
422 let keys = bench_keys_canonical_order();
423 assert_eq!(keys.first().copied(), Some("mode"));
424 assert!(keys.iter().position(|k| *k == "thresholds.*").is_some());
425 }
426
427 #[test]
428 fn suggest_bench_key_for_typo() {
429 assert_eq!(suggest_bench_key("load_shedule"), Some("load_schedule"));
430 assert_eq!(suggest_bench_key("duraton_stop"), Some("duration_stop"));
431 assert_eq!(suggest_bench_key("thresholds"), None);
432 }
433}