Skip to main content

clawft_kernel/
calibration.rs

1//! Boot-time ECC benchmarking and capability advertisement (Phase K3c).
2//!
3//! This module is compiled only when the `ecc` feature is enabled.
4//! It provides [`run_calibration`], which exercises the HNSW index and
5//! causal graph with synthetic data, measures per-tick latency, and
6//! returns an [`EccCalibration`] that other modules (CognitiveTick,
7//! cluster advertisement) use to auto-tune cadence and decide which
8//! subsystems are feasible on this hardware.
9
10use std::time::Instant;
11
12use serde::{Deserialize, Serialize};
13
14use crate::causal::{CausalEdgeType, CausalGraph};
15use crate::hnsw_service::HnswService;
16
17// ---------------------------------------------------------------------------
18// EccCalibrationConfig
19// ---------------------------------------------------------------------------
20
21/// Tuning knobs for the calibration run.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EccCalibrationConfig {
24    /// Number of synthetic ticks to execute during calibration.
25    pub calibration_ticks: u32,
26    /// Minimum tick interval in milliseconds. The auto-computed band
27    /// will never go below this floor. Set to 0 for fully auto mode.
28    pub tick_interval_ms: u32,
29    /// Target compute-time / tick-interval ratio (e.g. 0.3 = 30%).
30    /// Lower = more headroom but slower ticks. Higher = tighter but faster.
31    pub tick_budget_ratio: f32,
32    /// Dimensionality of synthetic test vectors.
33    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, // 0 = fully auto-computed from calibration
41            tick_budget_ratio: 0.3,
42            vector_dimensions: 384,
43        }
44    }
45}
46
47// ---------------------------------------------------------------------------
48// EccCalibration
49// ---------------------------------------------------------------------------
50
51/// Results of a boot-time calibration run.
52///
53/// Consumed by `CognitiveTick` (for cadence), by `cluster.rs` (for
54/// capability advertisement), and optionally by ExoChain (logged as an
55/// `ecc.boot.calibration` chain event).
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct EccCalibration {
58    /// Median per-tick latency in microseconds.
59    pub compute_p50_us: u64,
60    /// 95th-percentile per-tick latency in microseconds.
61    pub compute_p95_us: u64,
62    /// Effective tick interval after auto-adjustment (ms).
63    pub tick_interval_ms: u32,
64    /// Ratio of p95 compute time to tick interval.
65    pub headroom_ratio: f32,
66    /// Number of HNSW vectors inserted during calibration.
67    pub hnsw_vector_count: u32,
68    /// Number of causal edges created during calibration.
69    pub causal_edge_count: u32,
70    /// Whether spectral analysis is feasible on this hardware.
71    pub spectral_capable: bool,
72    /// Unix timestamp (seconds) at which calibration completed.
73    pub calibrated_at: u64,
74}
75
76// ---------------------------------------------------------------------------
77// Deterministic pseudo-random vector generation
78// ---------------------------------------------------------------------------
79
80/// Generate a deterministic f32 vector using a simple LCG, avoiding the
81/// `rand` crate dependency.
82fn 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            // Map to [-1.0, 1.0]
92            ((state >> 33) as f32) / (u32::MAX as f32 / 2.0) - 1.0
93        })
94        .collect()
95}
96
97// ---------------------------------------------------------------------------
98// run_calibration
99// ---------------------------------------------------------------------------
100
101/// Execute a calibration run against the provided HNSW index and causal
102/// graph.
103///
104/// This inserts `config.calibration_ticks` synthetic vectors into HNSW,
105/// searches each one, creates causal edges linking consecutive ticks,
106/// and hashes each vector with BLAKE3 (simulating a Merkle commit).
107/// After collecting per-tick timings it computes p50/p95 percentiles,
108/// decides the effective tick interval, checks spectral feasibility,
109/// and cleans up all synthetic data.
110pub 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    // Pre-generate all test vectors.
119    let vectors: Vec<Vec<f32>> = (0..n)
120        .map(|i| pseudo_random_vector(i as u64, config.vector_dimensions))
121        .collect();
122
123    // Pre-create causal graph nodes so that link() can find them.
124    let node_ids: Vec<u64> = (0..n)
125        .map(|i| causal.add_node(format!("cal_{i}"), serde_json::json!({})))
126        .collect();
127
128    // Run synthetic ticks and collect per-tick timings.
129    let mut timings_us: Vec<u64> = Vec::with_capacity(n);
130
131    for i in 0..n {
132        let start = Instant::now();
133
134        // 1. HNSW insert
135        let id = format!("cal_{i}");
136        hnsw.insert(id, vectors[i].clone(), serde_json::json!({}));
137
138        // 2. HNSW search (k=10)
139        let _results = hnsw.search(&vectors[i], 10);
140
141        // 3. Causal edge: link tick i-1 -> tick i
142        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        // 4. BLAKE3 hash (Merkle commit simulation)
154        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    // Compute percentiles.
162    timings_us.sort_unstable();
163    let p50 = timings_us[n / 2];
164    let p95 = timings_us[n * 95 / 100];
165
166    // Record counts before cleanup.
167    let hnsw_vector_count = n as u32;
168    let causal_edge_count = if n > 1 { (n - 1) as u32 } else { 0 };
169
170    // Clean up synthetic data.
171    let _ = hnsw.clear();
172    let _ = causal.clear();
173
174    // Auto-compute tick interval from calibration results.
175    //
176    // Select the tightest band from the standard set that gives the
177    // ECC at least `tick_budget_ratio` headroom:
178    //
179    //   required = p95 / budget_ratio
180    //   tick     = smallest band >= required
181    //
182    // Bands (ms): 0.01, 0.05, 0.1, 0.25, 0.5, 1, 10, 25, 50, 100, 500, 1000
183    //
184    // Example: p95=24μs, budget=30% → required=0.08ms → band=0.1ms (10,000 Hz)
185    const TICK_BANDS_US: &[u64] = &[
186        10,        // 0.01ms — 100,000 Hz (extreme real-time)
187        50,        // 0.05ms —  20,000 Hz
188        100,       // 0.1ms  —  10,000 Hz
189        250,       // 0.25ms —   4,000 Hz (hard real-time)
190        500,       // 0.5ms  —   2,000 Hz
191        1_000,     // 1ms    —   1,000 Hz (servo control)
192        10_000,    // 10ms   —     100 Hz (fast planning)
193        25_000,    // 25ms   —      40 Hz
194        50_000,    // 50ms   —      20 Hz (default planning)
195        100_000,   // 100ms  —      10 Hz
196        500_000,   // 500ms  —       2 Hz (slow/constrained)
197        1_000_000, // 1000ms —     1 Hz (minimal)
198    ];
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    // Find the smallest band that fits the required interval.
204    let tick_us = TICK_BANDS_US
205        .iter()
206        .copied()
207        .find(|&band| band >= required_us)
208        .unwrap_or(1_000_000); // fallback: 1s
209
210    // Apply configured floor if set (non-zero).
211    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    // Convert to ms for the result (floor at 1ms for the u32 field).
218    let tick_interval_ms = (tick_us / 1000).max(1) as u32;
219
220    // Log the auto-computed band for diagnostics.
221    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    // Headroom ratio: what fraction of the tick interval does p95 consume?
235    let headroom_ratio = if tick_us > 0 {
236        p95 as f32 / tick_us as f32
237    } else {
238        1.0
239    };
240
241    // Spectral feasibility: p95 under 10ms means we can afford spectral
242    // analysis within the tick budget.
243    let spectral_capable = p95 < 10_000;
244
245    // Unix timestamp (seconds).
246    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// ---------------------------------------------------------------------------
264// Tests
265// ---------------------------------------------------------------------------
266
267#[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    // 1. default_config
290    #[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); // 0 = auto-computed from calibration
295        assert!((cfg.tick_budget_ratio - 0.3).abs() < f32::EPSILON);
296        assert_eq!(cfg.vector_dimensions, 384);
297    }
298
299    // 2. pseudo_random_vector_deterministic
300    #[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    // 3. pseudo_random_vector_correct_dimensions
311    #[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    // 4. calibration_basic
320    #[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    // 5. calibration_cleans_up
338    #[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    // 6. calibration_p50_less_than_p95
363    #[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    // 7. calibration_spectral_capable
380    #[test]
381    fn calibration_spectral_capable() {
382        let hnsw = make_hnsw();
383        let causal = make_causal();
384        // Very small workload should be fast enough for spectral.
385        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    // 8. calibration_tick_interval_auto_adjusted
402    #[test]
403    fn calibration_tick_interval_auto_adjusted() {
404        let hnsw = make_hnsw();
405        let causal = make_causal();
406        // Set an impossibly low tick interval and tight budget ratio
407        // so that p95 forces an upward adjustment.
408        let cfg = EccCalibrationConfig {
409            calibration_ticks: 10,
410            tick_interval_ms: 1,     // very low
411            tick_budget_ratio: 0.01, // very tight budget
412            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    // Bonus: single-tick calibration edge case (no causal edges)
426    #[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    // Bonus: headroom_ratio is within sane bounds
446    #[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}