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,
28 pub tick_budget_ratio: f32,
30 pub vector_dimensions: usize,
32}
33
34impl Default for EccCalibrationConfig {
35 fn default() -> Self {
36 Self {
37 calibration_ticks: 30,
38 tick_interval_ms: 50,
39 tick_budget_ratio: 0.3,
40 vector_dimensions: 384,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct EccCalibration {
56 pub compute_p50_us: u64,
58 pub compute_p95_us: u64,
60 pub tick_interval_ms: u32,
62 pub headroom_ratio: f32,
64 pub hnsw_vector_count: u32,
66 pub causal_edge_count: u32,
68 pub spectral_capable: bool,
70 pub calibrated_at: u64,
72}
73
74fn pseudo_random_vector(seed: u64, dims: usize) -> Vec<f32> {
81 let mut state = seed
82 .wrapping_mul(6364136223846793005)
83 .wrapping_add(1442695040888963407);
84 (0..dims)
85 .map(|_| {
86 state = state
87 .wrapping_mul(6364136223846793005)
88 .wrapping_add(1442695040888963407);
89 ((state >> 33) as f32) / (u32::MAX as f32 / 2.0) - 1.0
91 })
92 .collect()
93}
94
95pub fn run_calibration(
109 hnsw: &HnswService,
110 causal: &CausalGraph,
111 config: &EccCalibrationConfig,
112) -> EccCalibration {
113 let n = config.calibration_ticks as usize;
114 assert!(n > 0, "calibration_ticks must be > 0");
115
116 let vectors: Vec<Vec<f32>> = (0..n)
118 .map(|i| pseudo_random_vector(i as u64, config.vector_dimensions))
119 .collect();
120
121 let node_ids: Vec<u64> = (0..n)
123 .map(|i| causal.add_node(format!("cal_{i}"), serde_json::json!({})))
124 .collect();
125
126 let mut timings_us: Vec<u64> = Vec::with_capacity(n);
128
129 for i in 0..n {
130 let start = Instant::now();
131
132 let id = format!("cal_{i}");
134 hnsw.insert(id, vectors[i].clone(), serde_json::json!({}));
135
136 let _results = hnsw.search(&vectors[i], 10);
138
139 if i > 0 {
141 causal.link(
142 node_ids[i - 1],
143 node_ids[i],
144 CausalEdgeType::Follows,
145 1.0,
146 0,
147 0,
148 );
149 }
150
151 let vec_bytes: Vec<u8> = vectors[i]
153 .iter()
154 .flat_map(|f| f.to_le_bytes())
155 .collect();
156 let _hash = blake3::hash(&vec_bytes);
157
158 let elapsed = start.elapsed().as_micros() as u64;
159 timings_us.push(elapsed);
160 }
161
162 timings_us.sort_unstable();
164 let p50 = timings_us[n / 2];
165 let p95 = timings_us[n * 95 / 100];
166
167 let hnsw_vector_count = n as u32;
169 let causal_edge_count = if n > 1 { (n - 1) as u32 } else { 0 };
170
171 hnsw.clear();
173 causal.clear();
174
175 let p95_ms = p95 as f32 / 1000.0;
178 let required_ms = (p95_ms / config.tick_budget_ratio) as u32;
179 let tick_interval_ms = config.tick_interval_ms.max(required_ms);
180
181 let headroom_ratio = if tick_interval_ms > 0 {
183 p95_ms / (tick_interval_ms as f32)
184 } else {
185 1.0
186 };
187
188 let spectral_capable = p95 < 10_000;
191
192 let calibrated_at = std::time::SystemTime::now()
194 .duration_since(std::time::UNIX_EPOCH)
195 .map(|d| d.as_secs())
196 .unwrap_or(0);
197
198 EccCalibration {
199 compute_p50_us: p50,
200 compute_p95_us: p95,
201 tick_interval_ms,
202 headroom_ratio,
203 hnsw_vector_count,
204 causal_edge_count,
205 spectral_capable,
206 calibrated_at,
207 }
208}
209
210#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::hnsw_service::{HnswService, HnswServiceConfig};
218
219 fn make_hnsw() -> HnswService {
220 HnswService::new(HnswServiceConfig::default())
221 }
222
223 fn make_causal() -> CausalGraph {
224 CausalGraph::new()
225 }
226
227 fn small_config() -> EccCalibrationConfig {
228 EccCalibrationConfig {
229 calibration_ticks: 10,
230 tick_interval_ms: 50,
231 tick_budget_ratio: 0.3,
232 vector_dimensions: 16,
233 }
234 }
235
236 #[test]
238 fn default_config() {
239 let cfg = EccCalibrationConfig::default();
240 assert_eq!(cfg.calibration_ticks, 30);
241 assert_eq!(cfg.tick_interval_ms, 50);
242 assert!((cfg.tick_budget_ratio - 0.3).abs() < f32::EPSILON);
243 assert_eq!(cfg.vector_dimensions, 384);
244 }
245
246 #[test]
248 fn pseudo_random_vector_deterministic() {
249 let a = pseudo_random_vector(42, 8);
250 let b = pseudo_random_vector(42, 8);
251 assert_eq!(a, b, "same seed must produce identical vectors");
252
253 let c = pseudo_random_vector(99, 8);
254 assert_ne!(a, c, "different seeds must produce different vectors");
255 }
256
257 #[test]
259 fn pseudo_random_vector_correct_dimensions() {
260 for dims in [1, 16, 128, 384] {
261 let v = pseudo_random_vector(0, dims);
262 assert_eq!(v.len(), dims);
263 }
264 }
265
266 #[test]
268 fn calibration_basic() {
269 let hnsw = make_hnsw();
270 let causal = make_causal();
271 let cfg = small_config();
272
273 let cal = run_calibration(&hnsw, &causal, &cfg);
274
275 assert!(cal.compute_p50_us > 0, "p50 must be positive");
276 assert!(cal.compute_p95_us > 0, "p95 must be positive");
277 assert!(cal.tick_interval_ms > 0, "tick interval must be positive");
278 assert!(cal.headroom_ratio > 0.0, "headroom must be positive");
279 assert_eq!(cal.hnsw_vector_count, 10);
280 assert_eq!(cal.causal_edge_count, 9);
281 assert!(cal.calibrated_at > 0, "timestamp must be set");
282 }
283
284 #[test]
286 fn calibration_cleans_up() {
287 let hnsw = make_hnsw();
288 let causal = make_causal();
289 let cfg = small_config();
290
291 let _cal = run_calibration(&hnsw, &causal, &cfg);
292
293 assert!(
294 hnsw.is_empty(),
295 "HNSW store must be empty after calibration cleanup"
296 );
297 assert_eq!(
298 causal.node_count(),
299 0,
300 "causal graph must be empty after calibration cleanup"
301 );
302 assert_eq!(
303 causal.edge_count(),
304 0,
305 "causal graph edges must be zero after calibration cleanup"
306 );
307 }
308
309 #[test]
311 fn calibration_p50_less_than_p95() {
312 let hnsw = make_hnsw();
313 let causal = make_causal();
314 let cfg = small_config();
315
316 let cal = run_calibration(&hnsw, &causal, &cfg);
317
318 assert!(
319 cal.compute_p50_us <= cal.compute_p95_us,
320 "p50 ({}) must be <= p95 ({})",
321 cal.compute_p50_us,
322 cal.compute_p95_us,
323 );
324 }
325
326 #[test]
328 fn calibration_spectral_capable() {
329 let hnsw = make_hnsw();
330 let causal = make_causal();
331 let cfg = EccCalibrationConfig {
333 calibration_ticks: 5,
334 tick_interval_ms: 50,
335 tick_budget_ratio: 0.3,
336 vector_dimensions: 4,
337 };
338
339 let cal = run_calibration(&hnsw, &causal, &cfg);
340
341 assert!(
342 cal.spectral_capable,
343 "a trivial calibration run should report spectral capable (p95={}us)",
344 cal.compute_p95_us,
345 );
346 }
347
348 #[test]
350 fn calibration_tick_interval_auto_adjusted() {
351 let hnsw = make_hnsw();
352 let causal = make_causal();
353 let cfg = EccCalibrationConfig {
356 calibration_ticks: 10,
357 tick_interval_ms: 1, tick_budget_ratio: 0.01, vector_dimensions: 64,
360 };
361
362 let cal = run_calibration(&hnsw, &causal, &cfg);
363
364 assert!(
365 cal.tick_interval_ms >= cfg.tick_interval_ms,
366 "tick_interval_ms ({}) must be >= configured value ({})",
367 cal.tick_interval_ms,
368 cfg.tick_interval_ms,
369 );
370 }
371
372 #[test]
374 fn calibration_single_tick() {
375 let hnsw = make_hnsw();
376 let causal = make_causal();
377 let cfg = EccCalibrationConfig {
378 calibration_ticks: 1,
379 tick_interval_ms: 50,
380 tick_budget_ratio: 0.3,
381 vector_dimensions: 8,
382 };
383
384 let cal = run_calibration(&hnsw, &causal, &cfg);
385
386 assert_eq!(cal.hnsw_vector_count, 1);
387 assert_eq!(cal.causal_edge_count, 0, "no edges with a single tick");
388 assert!(hnsw.is_empty());
389 assert_eq!(causal.node_count(), 0);
390 }
391
392 #[test]
394 fn calibration_headroom_sane() {
395 let hnsw = make_hnsw();
396 let causal = make_causal();
397 let cfg = small_config();
398
399 let cal = run_calibration(&hnsw, &causal, &cfg);
400
401 assert!(
402 cal.headroom_ratio >= 0.0 && cal.headroom_ratio <= 1.0,
403 "headroom_ratio ({}) should be in [0, 1] for a well-budgeted run",
404 cal.headroom_ratio,
405 );
406 }
407}