1use std::time::Instant;
11
12use serde::{Deserialize, Serialize};
13
14use crate::causal::{CausalEdgeType, CausalGraph};
15use crate::hnsw_service::HnswService;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EccCalibrationConfig {
24 pub calibration_ticks: u32,
26 pub tick_interval_ms: u32,
29 pub tick_budget_ratio: f32,
32 pub vector_dimensions: usize,
34}
35
36impl Default for EccCalibrationConfig {
37 fn default() -> Self {
38 Self {
39 calibration_ticks: 30,
40 tick_interval_ms: 0, tick_budget_ratio: 0.3,
42 vector_dimensions: 384,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct EccCalibration {
58 pub compute_p50_us: u64,
60 pub compute_p95_us: u64,
62 pub tick_interval_ms: u32,
64 pub headroom_ratio: f32,
66 pub hnsw_vector_count: u32,
68 pub causal_edge_count: u32,
70 pub spectral_capable: bool,
72 pub calibrated_at: u64,
74}
75
76fn pseudo_random_vector(seed: u64, dims: usize) -> Vec<f32> {
83 let mut state = seed
84 .wrapping_mul(6364136223846793005)
85 .wrapping_add(1442695040888963407);
86 (0..dims)
87 .map(|_| {
88 state = state
89 .wrapping_mul(6364136223846793005)
90 .wrapping_add(1442695040888963407);
91 ((state >> 33) as f32) / (u32::MAX as f32 / 2.0) - 1.0
93 })
94 .collect()
95}
96
97pub fn run_calibration(
111 hnsw: &HnswService,
112 causal: &CausalGraph,
113 config: &EccCalibrationConfig,
114) -> EccCalibration {
115 let n = config.calibration_ticks as usize;
116 assert!(n > 0, "calibration_ticks must be > 0");
117
118 let vectors: Vec<Vec<f32>> = (0..n)
120 .map(|i| pseudo_random_vector(i as u64, config.vector_dimensions))
121 .collect();
122
123 let node_ids: Vec<u64> = (0..n)
125 .map(|i| causal.add_node(format!("cal_{i}"), serde_json::json!({})))
126 .collect();
127
128 let mut timings_us: Vec<u64> = Vec::with_capacity(n);
130
131 for i in 0..n {
132 let start = Instant::now();
133
134 let id = format!("cal_{i}");
136 hnsw.insert(id, vectors[i].clone(), serde_json::json!({}));
137
138 let _results = hnsw.search(&vectors[i], 10);
140
141 if i > 0 {
143 causal.link(
144 node_ids[i - 1],
145 node_ids[i],
146 CausalEdgeType::Follows,
147 1.0,
148 0,
149 0,
150 );
151 }
152
153 let vec_bytes: Vec<u8> = vectors[i].iter().flat_map(|f| f.to_le_bytes()).collect();
155 let _hash = blake3::hash(&vec_bytes);
156
157 let elapsed = start.elapsed().as_micros() as u64;
158 timings_us.push(elapsed);
159 }
160
161 timings_us.sort_unstable();
163 let p50 = timings_us[n / 2];
164 let p95 = timings_us[n * 95 / 100];
165
166 let hnsw_vector_count = n as u32;
168 let causal_edge_count = if n > 1 { (n - 1) as u32 } else { 0 };
169
170 let _ = hnsw.clear();
172 let _ = causal.clear();
173
174 const TICK_BANDS_US: &[u64] = &[
186 10, 50, 100, 250, 500, 1_000, 10_000, 25_000, 50_000, 100_000, 500_000, 1_000_000, ];
199
200 let _p95_ms = p95 as f32 / 1000.0;
201 let required_us = (p95 as f64 / config.tick_budget_ratio as f64) as u64;
202
203 let tick_us = TICK_BANDS_US
205 .iter()
206 .copied()
207 .find(|&band| band >= required_us)
208 .unwrap_or(1_000_000); let tick_us = if config.tick_interval_ms > 0 {
212 tick_us.max(config.tick_interval_ms as u64 * 1000)
213 } else {
214 tick_us
215 };
216
217 let tick_interval_ms = (tick_us / 1000).max(1) as u32;
219
220 let tick_hz = 1_000_000u64.checked_div(tick_us).unwrap_or(0);
222 tracing::info!(
223 p95_us = p95,
224 required_us = required_us,
225 tick_us = tick_us,
226 tick_hz = tick_hz,
227 "ECC tick auto-computed: {}μs ({}Hz), p95={}μs, budget={:.0}%",
228 tick_us,
229 tick_hz,
230 p95,
231 config.tick_budget_ratio * 100.0
232 );
233
234 let headroom_ratio = if tick_us > 0 {
236 p95 as f32 / tick_us as f32
237 } else {
238 1.0
239 };
240
241 let spectral_capable = p95 < 10_000;
244
245 let calibrated_at = std::time::SystemTime::now()
247 .duration_since(std::time::UNIX_EPOCH)
248 .map(|d| d.as_secs())
249 .unwrap_or(0);
250
251 EccCalibration {
252 compute_p50_us: p50,
253 compute_p95_us: p95,
254 tick_interval_ms,
255 headroom_ratio,
256 hnsw_vector_count,
257 causal_edge_count,
258 spectral_capable,
259 calibrated_at,
260 }
261}
262
263#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::hnsw_service::{HnswService, HnswServiceConfig};
271
272 fn make_hnsw() -> HnswService {
273 HnswService::new(HnswServiceConfig::default())
274 }
275
276 fn make_causal() -> CausalGraph {
277 CausalGraph::new()
278 }
279
280 fn small_config() -> EccCalibrationConfig {
281 EccCalibrationConfig {
282 calibration_ticks: 10,
283 tick_interval_ms: 50,
284 tick_budget_ratio: 0.3,
285 vector_dimensions: 16,
286 }
287 }
288
289 #[test]
291 fn default_config() {
292 let cfg = EccCalibrationConfig::default();
293 assert_eq!(cfg.calibration_ticks, 30);
294 assert_eq!(cfg.tick_interval_ms, 0); assert!((cfg.tick_budget_ratio - 0.3).abs() < f32::EPSILON);
296 assert_eq!(cfg.vector_dimensions, 384);
297 }
298
299 #[test]
301 fn pseudo_random_vector_deterministic() {
302 let a = pseudo_random_vector(42, 8);
303 let b = pseudo_random_vector(42, 8);
304 assert_eq!(a, b, "same seed must produce identical vectors");
305
306 let c = pseudo_random_vector(99, 8);
307 assert_ne!(a, c, "different seeds must produce different vectors");
308 }
309
310 #[test]
312 fn pseudo_random_vector_correct_dimensions() {
313 for dims in [1, 16, 128, 384] {
314 let v = pseudo_random_vector(0, dims);
315 assert_eq!(v.len(), dims);
316 }
317 }
318
319 #[test]
321 fn calibration_basic() {
322 let hnsw = make_hnsw();
323 let causal = make_causal();
324 let cfg = small_config();
325
326 let cal = run_calibration(&hnsw, &causal, &cfg);
327
328 assert!(cal.compute_p50_us > 0, "p50 must be positive");
329 assert!(cal.compute_p95_us > 0, "p95 must be positive");
330 assert!(cal.tick_interval_ms > 0, "tick interval must be positive");
331 assert!(cal.headroom_ratio > 0.0, "headroom must be positive");
332 assert_eq!(cal.hnsw_vector_count, 10);
333 assert_eq!(cal.causal_edge_count, 9);
334 assert!(cal.calibrated_at > 0, "timestamp must be set");
335 }
336
337 #[test]
339 fn calibration_cleans_up() {
340 let hnsw = make_hnsw();
341 let causal = make_causal();
342 let cfg = small_config();
343
344 let _cal = run_calibration(&hnsw, &causal, &cfg);
345
346 assert!(
347 hnsw.is_empty(),
348 "HNSW store must be empty after calibration cleanup"
349 );
350 assert_eq!(
351 causal.node_count(),
352 0,
353 "causal graph must be empty after calibration cleanup"
354 );
355 assert_eq!(
356 causal.edge_count(),
357 0,
358 "causal graph edges must be zero after calibration cleanup"
359 );
360 }
361
362 #[test]
364 fn calibration_p50_less_than_p95() {
365 let hnsw = make_hnsw();
366 let causal = make_causal();
367 let cfg = small_config();
368
369 let cal = run_calibration(&hnsw, &causal, &cfg);
370
371 assert!(
372 cal.compute_p50_us <= cal.compute_p95_us,
373 "p50 ({}) must be <= p95 ({})",
374 cal.compute_p50_us,
375 cal.compute_p95_us,
376 );
377 }
378
379 #[test]
381 fn calibration_spectral_capable() {
382 let hnsw = make_hnsw();
383 let causal = make_causal();
384 let cfg = EccCalibrationConfig {
386 calibration_ticks: 5,
387 tick_interval_ms: 50,
388 tick_budget_ratio: 0.3,
389 vector_dimensions: 4,
390 };
391
392 let cal = run_calibration(&hnsw, &causal, &cfg);
393
394 assert!(
395 cal.spectral_capable,
396 "a trivial calibration run should report spectral capable (p95={}us)",
397 cal.compute_p95_us,
398 );
399 }
400
401 #[test]
403 fn calibration_tick_interval_auto_adjusted() {
404 let hnsw = make_hnsw();
405 let causal = make_causal();
406 let cfg = EccCalibrationConfig {
409 calibration_ticks: 10,
410 tick_interval_ms: 1, tick_budget_ratio: 0.01, vector_dimensions: 64,
413 };
414
415 let cal = run_calibration(&hnsw, &causal, &cfg);
416
417 assert!(
418 cal.tick_interval_ms >= cfg.tick_interval_ms,
419 "tick_interval_ms ({}) must be >= configured value ({})",
420 cal.tick_interval_ms,
421 cfg.tick_interval_ms,
422 );
423 }
424
425 #[test]
427 fn calibration_single_tick() {
428 let hnsw = make_hnsw();
429 let causal = make_causal();
430 let cfg = EccCalibrationConfig {
431 calibration_ticks: 1,
432 tick_interval_ms: 50,
433 tick_budget_ratio: 0.3,
434 vector_dimensions: 8,
435 };
436
437 let cal = run_calibration(&hnsw, &causal, &cfg);
438
439 assert_eq!(cal.hnsw_vector_count, 1);
440 assert_eq!(cal.causal_edge_count, 0, "no edges with a single tick");
441 assert!(hnsw.is_empty());
442 assert_eq!(causal.node_count(), 0);
443 }
444
445 #[test]
447 fn calibration_headroom_sane() {
448 let hnsw = make_hnsw();
449 let causal = make_causal();
450 let cfg = small_config();
451
452 let cal = run_calibration(&hnsw, &causal, &cfg);
453
454 assert!(
455 cal.headroom_ratio >= 0.0 && cal.headroom_ratio <= 1.0,
456 "headroom_ratio ({}) should be in [0, 1] for a well-budgeted run",
457 cal.headroom_ratio,
458 );
459 }
460}