Skip to main content

clawft_kernel/
hnsw_eml.rs

1//! EML-based HNSW search optimization.
2//!
3//! Manages four EML models that learn from operational search data to
4//! optimize HNSW performance:
5//!
6//! - **Distance model**: learned dimension selection for fast approximate
7//!   cosine distance (progressive dimensionality).
8//! - **Ef model**: per-query adaptive beam width (ef_search).
9//! - **Path model**: search entry-point prediction.
10//! - **Rebuild model**: predicts when the HNSW graph needs rebuilding
11//!   based on recall degradation.
12//!
13//! # Two-Tier Pattern
14//!
15//! Follows the same two-tier pattern as [`eml_coherence`]:
16//! - **Every search**: fast EML predictions guide beam width and entry
17//!   point selection (~0.1 us overhead).
18//! - **Periodically**: ground-truth recall measurement via brute-force
19//!   comparison feeds the distance and rebuild models.
20//! - **Every N searches**: models are retrained from accumulated data.
21//!
22//! This module is compiled only when the `ecc` feature is enabled.
23
24use serde::{Deserialize, Serialize};
25
26use clawft_core::embeddings::path_predict::{
27    PathPredictConfig, PathPrediction as RegionPathPrediction, RegionEntryTable,
28    DEFAULT_NUM_REGIONS, DEFAULT_REGION_PROBES,
29};
30
31// ---------------------------------------------------------------------------
32// Configuration
33// ---------------------------------------------------------------------------
34
35/// Configuration for the HNSW EML optimization system.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HnswEmlConfig {
38    /// Enable EML-based HNSW optimization.
39    pub enabled: bool,
40    /// Train models every N searches.
41    pub train_every_n: u64,
42    /// Measure recall every N searches (for distance/rebuild models).
43    pub recall_check_every_n: u64,
44    /// Minimum training samples before enabling learned models.
45    pub min_training_samples: usize,
46    /// Number of selected dimensions for cosine decomposition.
47    pub distance_selected_dims: usize,
48    /// Ef selection strategy.
49    pub ef_strategy: EfStrategy,
50    /// Target recall for threshold-based strategy (default 0.95).
51    pub target_recall: f64,
52    /// Max ef boost factor (caps how aggressively the model can raise ef).
53    pub max_ef_boost: f64,
54    /// Latency weight for score-based strategy (0..1, higher = prefer speed).
55    pub latency_weight: f64,
56    /// Enable region → entry-node search-path prediction (HNSW-EML #4 / WEFT-385).
57    pub path_predict_enabled: bool,
58    /// Number of k-means regions for the path table.
59    pub path_num_regions: usize,
60    /// How many nearest regions to probe per query.
61    pub path_region_probes: usize,
62    /// Entry nodes retained per region.
63    pub path_entries_per_region: usize,
64}
65
66/// Strategy for selecting the adaptive ef_search value.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
68pub enum EfStrategy {
69    /// Boost ef until predicted recall >= target_recall, capped by max_ef_boost.
70    Threshold,
71    /// Maximize `recall * (1 - latency_weight * latency/max_latency)`.
72    Score,
73}
74
75impl Default for HnswEmlConfig {
76    fn default() -> Self {
77        Self {
78            enabled: true,
79            train_every_n: 1000,
80            recall_check_every_n: 5000,
81            min_training_samples: 200,
82            distance_selected_dims: 16,
83            ef_strategy: EfStrategy::Score,
84            target_recall: 0.95,
85            max_ef_boost: 2.5,
86            latency_weight: 0.3,
87            path_predict_enabled: true,
88            path_num_regions: DEFAULT_NUM_REGIONS,
89            path_region_probes: DEFAULT_REGION_PROBES,
90            path_entries_per_region: 3,
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Training data points
97// ---------------------------------------------------------------------------
98
99/// Training point for the ef (beam width) model.
100///
101/// Records search characteristics so the model can learn the optimal
102/// ef_search for a given query profile. Carries both the ef that was
103/// used AND the recall observed (when available), so the 2-head model
104/// can learn the ef → recall tradeoff jointly.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct EfTrainingPoint {
107    /// L2 norm of the query vector.
108    pub query_norm: f64,
109    /// Variance across query dimensions.
110    pub query_variance: f64,
111    /// ef_search value used for this query.
112    pub ef_used: usize,
113    /// Number of results returned.
114    pub result_count: usize,
115    /// Wall-clock search time in microseconds.
116    pub search_time_us: u64,
117    /// Observed recall for this query (None if not measured).
118    pub recall: Option<f64>,
119}
120
121/// Training point for the distance model.
122///
123/// Records per-dimension contribution to distance computations so the
124/// model can learn which dimensions are most informative.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct DistanceTrainingPoint {
127    /// Per-dimension absolute differences between query and result vectors.
128    pub dim_contributions: Vec<f64>,
129    /// Full cosine similarity (ground truth).
130    pub exact_similarity: f64,
131    /// Approximate similarity using only selected dimensions.
132    pub approx_similarity: f64,
133}
134
135/// Training point for the search path model.
136///
137/// Records entry-point selection outcomes so the model can predict
138/// the best starting node for a given query vector.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PathTrainingPoint {
141    /// Query vector norm.
142    pub query_norm: f64,
143    /// Query vector variance.
144    pub query_variance: f64,
145    /// Number of hops taken during the search (or candidate-pool size proxy).
146    pub hops: usize,
147    /// Score of the top result.
148    pub top_score: f64,
149    /// Total entries in the store at search time.
150    pub store_size: usize,
151    /// Predicted region id when path table was used (`None` if unguided).
152    pub region_id: Option<usize>,
153    /// Cosine of query to chosen region centroid (0 when unguided).
154    pub region_score: f64,
155    /// Whether guided search was used.
156    pub guided: bool,
157}
158
159/// Training point for the rebuild model.
160///
161/// Records graph health statistics so the model can predict when recall
162/// has degraded enough to justify a full HNSW rebuild.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct RebuildTrainingPoint {
165    /// Number of entries in the store.
166    pub store_size: usize,
167    /// Number of inserts since last rebuild.
168    pub inserts_since_rebuild: usize,
169    /// Number of deletes since last rebuild.
170    pub deletes_since_rebuild: usize,
171    /// Measured recall (0.0..1.0).
172    pub recall: f64,
173    /// Average search time in microseconds.
174    pub avg_search_time_us: f64,
175}
176
177// ---------------------------------------------------------------------------
178// Feature vectors for EML models
179// ---------------------------------------------------------------------------
180
181/// Features extracted from a search query for ef/path prediction.
182#[derive(Debug, Clone)]
183struct SearchFeatures {
184    query_norm: f64,
185    query_variance: f64,
186    store_size: f64,
187    recent_avg_time_us: f64,
188}
189
190impl SearchFeatures {
191    fn normalized(&self) -> [f64; 4] {
192        [
193            self.query_norm / 100.0,
194            self.query_variance.min(1.0),
195            self.store_size / 100_000.0,
196            self.recent_avg_time_us / 10_000.0,
197        ]
198    }
199}
200
201impl eml_core::FeatureVector for SearchFeatures {
202    fn as_features(&self) -> Vec<f64> {
203        self.normalized().to_vec()
204    }
205
206    fn feature_count() -> usize {
207        4
208    }
209}
210
211/// Features for the rebuild prediction model.
212#[derive(Debug, Clone)]
213struct RebuildFeatures {
214    store_size: f64,
215    inserts_since_rebuild: f64,
216    deletes_since_rebuild: f64,
217    avg_search_time_us: f64,
218}
219
220impl RebuildFeatures {
221    fn normalized(&self) -> [f64; 4] {
222        [
223            self.store_size / 100_000.0,
224            self.inserts_since_rebuild / 10_000.0,
225            self.deletes_since_rebuild / 10_000.0,
226            self.avg_search_time_us / 10_000.0,
227        ]
228    }
229}
230
231impl eml_core::FeatureVector for RebuildFeatures {
232    fn as_features(&self) -> Vec<f64> {
233        self.normalized().to_vec()
234    }
235
236    fn feature_count() -> usize {
237        4
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Status snapshot
243// ---------------------------------------------------------------------------
244
245/// Point-in-time status snapshot of the HNSW EML system.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct HnswEmlStatus {
248    /// Whether EML optimization is enabled.
249    pub enabled: bool,
250    /// Whether the distance model has been trained.
251    pub distance_trained: bool,
252    /// Number of distance training samples.
253    pub distance_samples: usize,
254    /// Whether the ef model has been trained.
255    pub ef_trained: bool,
256    /// Number of ef training samples.
257    pub ef_samples: usize,
258    /// Whether the path model has been trained.
259    pub path_trained: bool,
260    /// Number of path training samples.
261    pub path_samples: usize,
262    /// Whether the rebuild model has been trained.
263    pub rebuild_trained: bool,
264    /// Number of rebuild training samples.
265    pub rebuild_samples: usize,
266    /// Total searches since last training cycle.
267    pub searches_since_train: u64,
268    /// Total searches processed since creation.
269    pub total_searches: u64,
270    /// Total training cycles completed.
271    pub train_cycles: u64,
272    /// Last measured recall (if available).
273    pub last_recall: Option<f64>,
274}
275
276// ---------------------------------------------------------------------------
277// Predictions
278// ---------------------------------------------------------------------------
279
280/// Predicted optimal ef_search for a query.
281#[derive(Debug, Clone)]
282pub struct EfPrediction {
283    /// Recommended ef_search value.
284    pub recommended_ef: usize,
285    /// Whether this is a learned prediction (vs. default).
286    pub is_learned: bool,
287}
288
289/// Predicted rebuild urgency.
290#[derive(Debug, Clone)]
291pub struct RebuildPrediction {
292    /// Predicted recall if no rebuild is performed (0.0..1.0).
293    pub predicted_recall: f64,
294    /// Whether a rebuild is recommended.
295    pub should_rebuild: bool,
296    /// Whether this is a learned prediction (vs. heuristic).
297    pub is_learned: bool,
298}
299
300/// Predicted search entry nodes (WEFT-385 / HNSW-EML #4).
301#[derive(Debug, Clone)]
302pub struct PathPrediction {
303    /// Nearest region id when the path table is ready.
304    pub region_id: Option<usize>,
305    /// Predicted entry-node ids (highway on-ramps), best first.
306    pub entry_ids: Vec<String>,
307    /// Cosine of query to chosen region centroid.
308    pub region_score: f32,
309    /// Candidate pool (members of probed regions).
310    pub candidate_ids: Vec<String>,
311    /// Whether a region table is available (built / learned).
312    pub is_learned: bool,
313    /// Whether the EML path model agrees the guided path is high quality.
314    pub eml_path_quality: Option<f64>,
315}
316
317/// One guided (path-predicted) search, as recorded by
318/// [`HnswEmlManager::record_guided_search`].
319#[derive(Debug, Clone, Copy)]
320pub struct GuidedSearch<'a> {
321    /// The query vector.
322    pub query: &'a [f32],
323    /// Number of results returned.
324    pub result_count: usize,
325    /// Score of the best result.
326    pub top_score: f32,
327    /// `ef` the search ran with.
328    pub ef_used: usize,
329    /// Wall-clock search time in microseconds.
330    pub search_time_us: u64,
331    /// Store size at search time.
332    pub store_size: usize,
333    /// The path prediction that guided the search.
334    pub prediction: &'a PathPrediction,
335    /// Candidate pool size (stands in for hop count).
336    pub candidate_pool_size: usize,
337}
338
339impl From<RegionPathPrediction> for PathPrediction {
340    fn from(p: RegionPathPrediction) -> Self {
341        Self {
342            region_id: p.region_id,
343            entry_ids: p.entry_ids,
344            region_score: p.region_score,
345            candidate_ids: p.candidate_ids,
346            is_learned: p.is_learned,
347            eml_path_quality: None,
348        }
349    }
350}
351
352// ---------------------------------------------------------------------------
353// HnswEmlManager
354// ---------------------------------------------------------------------------
355
356/// Manages EML models for HNSW search optimization.
357///
358/// Follows the two-tier pattern: fast EML predictions on every search,
359/// periodic ground-truth measurement for training.
360pub struct HnswEmlManager {
361    /// Configuration.
362    config: HnswEmlConfig,
363    /// Learned dimension selection for fast approximate distance.
364    distance_model: eml_core::EmlModel,
365    /// Per-query adaptive beam width.
366    ef_model: eml_core::EmlModel,
367    /// Search path entry-point predictor.
368    path_model: eml_core::EmlModel,
369    /// Rebuild trigger predictor.
370    rebuild_model: eml_core::EmlModel,
371    /// Region → entry-node lookup table (HNSW-EML #4).
372    path_table: Option<RegionEntryTable>,
373    /// Training data buffers.
374    distance_training: Vec<DistanceTrainingPoint>,
375    ef_training: Vec<EfTrainingPoint>,
376    path_training: Vec<PathTrainingPoint>,
377    rebuild_training: Vec<RebuildTrainingPoint>,
378    /// Searches since last train cycle.
379    searches_since_train: u64,
380    /// Total searches processed.
381    total_searches: u64,
382    /// Total training cycles completed.
383    train_cycles: u64,
384    /// Last measured recall.
385    last_recall: Option<f64>,
386    /// Recent search times for averaging.
387    recent_search_times_us: Vec<u64>,
388}
389
390impl HnswEmlManager {
391    /// Create a new manager with the given configuration.
392    pub fn new(config: HnswEmlConfig) -> Self {
393        Self {
394            config,
395            distance_model: eml_core::EmlModel::new(3, 4, 1),
396            // 2-head: head 0 = normalized ef, head 1 = predicted recall.
397            // Joint training lets the model learn the Pareto frontier.
398            ef_model: eml_core::EmlModel::new(3, 4, 2),
399            path_model: eml_core::EmlModel::new(3, 4, 1),
400            rebuild_model: eml_core::EmlModel::new(3, 4, 1),
401            path_table: None,
402            distance_training: Vec::new(),
403            ef_training: Vec::new(),
404            path_training: Vec::new(),
405            rebuild_training: Vec::new(),
406            searches_since_train: 0,
407            total_searches: 0,
408            train_cycles: 0,
409            last_recall: None,
410            recent_search_times_us: Vec::new(),
411        }
412    }
413
414    /// Create a manager with default configuration.
415    pub fn with_defaults() -> Self {
416        Self::new(HnswEmlConfig::default())
417    }
418
419    /// Whether EML optimization is enabled.
420    pub fn is_enabled(&self) -> bool {
421        self.config.enabled
422    }
423
424    /// Borrow the configuration.
425    pub fn config(&self) -> &HnswEmlConfig {
426        &self.config
427    }
428
429    // -------------------------------------------------------------------
430    // Training data collection
431    // -------------------------------------------------------------------
432
433    /// Record a search for training data collection.
434    ///
435    /// Called after every HNSW search to collect ef and path training
436    /// data. When enough samples accumulate, triggers a training cycle.
437    pub fn record_search(
438        &mut self,
439        query: &[f32],
440        result_count: usize,
441        top_score: f32,
442        ef_used: usize,
443        search_time_us: u64,
444        store_size: usize,
445    ) {
446        if !self.config.enabled {
447            return;
448        }
449
450        let qnorm = vector_norm(query);
451        let qvar = vector_variance(query);
452
453        // Record ef training data (recall filled in later by measure_recall).
454        self.ef_training.push(EfTrainingPoint {
455            query_norm: qnorm,
456            query_variance: qvar,
457            ef_used,
458            result_count,
459            search_time_us,
460            recall: None,
461        });
462
463        // Record path training data (unguided / hops unknown).
464        self.path_training.push(PathTrainingPoint {
465            query_norm: qnorm,
466            query_variance: qvar,
467            hops: 0,
468            top_score: top_score as f64,
469            store_size,
470            region_id: None,
471            region_score: 0.0,
472            guided: false,
473        });
474
475        // Track recent search times.
476        self.recent_search_times_us.push(search_time_us);
477        if self.recent_search_times_us.len() > 1000 {
478            self.recent_search_times_us.drain(..500);
479        }
480
481        self.searches_since_train += 1;
482        self.total_searches += 1;
483
484        // Trigger periodic training.
485        if self.searches_since_train >= self.config.train_every_n {
486            self.train_all();
487        }
488    }
489
490    /// Record a guided (path-predicted) search for path-model training.
491    ///
492    /// `candidate_pool_size` stands in for hop count: smaller pools that still
493    /// hit a high top score mean the entry/region prediction was better.
494    pub fn record_guided_search(&mut self, search: GuidedSearch<'_>) {
495        let GuidedSearch {
496            query,
497            result_count,
498            top_score,
499            ef_used,
500            search_time_us,
501            store_size,
502            prediction,
503            candidate_pool_size,
504        } = search;
505        if !self.config.enabled {
506            return;
507        }
508
509        let qnorm = vector_norm(query);
510        let qvar = vector_variance(query);
511
512        self.ef_training.push(EfTrainingPoint {
513            query_norm: qnorm,
514            query_variance: qvar,
515            ef_used,
516            result_count,
517            search_time_us,
518            recall: None,
519        });
520
521        self.path_training.push(PathTrainingPoint {
522            query_norm: qnorm,
523            query_variance: qvar,
524            // Proxy hops: smaller relative pool ⇒ shorter effective path.
525            hops: candidate_pool_size,
526            top_score: top_score as f64,
527            store_size,
528            region_id: prediction.region_id,
529            region_score: prediction.region_score as f64,
530            guided: prediction.is_learned,
531        });
532
533        self.recent_search_times_us.push(search_time_us);
534        if self.recent_search_times_us.len() > 1000 {
535            self.recent_search_times_us.drain(..500);
536        }
537
538        self.searches_since_train += 1;
539        self.total_searches += 1;
540
541        if self.searches_since_train >= self.config.train_every_n {
542            self.train_all();
543        }
544    }
545
546    /// Build (or rebuild) the region → entry-node path table from store
547    /// embeddings. Call after bulk ingest / HNSW rebuild.
548    pub fn rebuild_path_table(&mut self, entries: &[(String, Vec<f32>)]) {
549        if !self.config.path_predict_enabled || entries.is_empty() {
550            self.path_table = None;
551            return;
552        }
553        let cfg = PathPredictConfig {
554            num_regions: self.config.path_num_regions,
555            region_probes: self.config.path_region_probes,
556            entries_per_region: self.config.path_entries_per_region,
557            kmeans_iters: 12,
558        };
559        let table = RegionEntryTable::build(entries, cfg);
560        if table.is_ready() {
561            self.path_table = Some(table);
562        } else {
563            self.path_table = None;
564        }
565    }
566
567    /// Whether a path table is ready for guided search.
568    pub fn has_path_table(&self) -> bool {
569        self.path_table
570            .as_ref()
571            .map(|t| t.is_ready())
572            .unwrap_or(false)
573    }
574
575    /// Borrow the region entry table when present.
576    pub fn path_table(&self) -> Option<&RegionEntryTable> {
577        self.path_table.as_ref()
578    }
579
580    /// Region probes configured for path prediction.
581    pub fn path_region_probes(&self) -> usize {
582        self.config.path_region_probes
583    }
584
585    /// Whether path prediction is enabled in config.
586    pub fn path_predict_enabled(&self) -> bool {
587        self.config.path_predict_enabled && self.config.enabled
588    }
589
590    /// Measure actual recall by comparing HNSW results against brute-force.
591    ///
592    /// Called periodically (e.g., every `recall_check_every_n` searches or
593    /// from the DEMOCRITUS loop). Feeds the distance and rebuild models.
594    ///
595    /// Returns the average recall across the sample queries.
596    pub fn measure_recall(
597        &mut self,
598        hnsw_results: &[Vec<String>],
599        exact_results: &[Vec<String>],
600        store_size: usize,
601        inserts_since_rebuild: usize,
602        deletes_since_rebuild: usize,
603    ) -> f64 {
604        if hnsw_results.is_empty() || exact_results.is_empty() {
605            return 0.0;
606        }
607
608        let n = hnsw_results.len().min(exact_results.len());
609        let mut total_recall = 0.0;
610
611        for i in 0..n {
612            let recall = compute_recall(&hnsw_results[i], &exact_results[i]);
613            total_recall += recall;
614        }
615
616        let avg_recall = total_recall / n as f64;
617        self.last_recall = Some(avg_recall);
618
619        // Backfill recall into recent ef training points that lack it.
620        // This gives the 2-head ef model a recall signal to train against.
621        for point in self.ef_training.iter_mut().rev() {
622            if point.recall.is_some() {
623                break;
624            }
625            point.recall = Some(avg_recall);
626        }
627
628        // Record rebuild training data.
629        let avg_search_time = if self.recent_search_times_us.is_empty() {
630            0.0
631        } else {
632            self.recent_search_times_us.iter().sum::<u64>() as f64
633                / self.recent_search_times_us.len() as f64
634        };
635
636        self.rebuild_training.push(RebuildTrainingPoint {
637            store_size,
638            inserts_since_rebuild,
639            deletes_since_rebuild,
640            recall: avg_recall,
641            avg_search_time_us: avg_search_time,
642        });
643
644        avg_recall
645    }
646
647    /// Record distance training data from paired HNSW vs. exact results.
648    ///
649    /// `query` is the search query, `hnsw_embedding` and `exact_embedding`
650    /// are the embeddings of the HNSW result and exact result respectively.
651    pub fn record_distance_pair(
652        &mut self,
653        query: &[f32],
654        hnsw_embedding: &[f32],
655        exact_embedding: &[f32],
656    ) {
657        if !self.config.enabled {
658            return;
659        }
660
661        let dim_contributions: Vec<f64> = query
662            .iter()
663            .zip(exact_embedding.iter())
664            .map(|(q, e)| ((*q - *e) as f64).abs())
665            .collect();
666
667        let exact_sim = cosine_similarity_f32(query, exact_embedding) as f64;
668        let approx_sim = cosine_similarity_f32(query, hnsw_embedding) as f64;
669
670        self.distance_training.push(DistanceTrainingPoint {
671            dim_contributions,
672            exact_similarity: exact_sim,
673            approx_similarity: approx_sim,
674        });
675    }
676
677    // -------------------------------------------------------------------
678    // Predictions
679    // -------------------------------------------------------------------
680
681    /// Predict the optimal ef_search for a query.
682    ///
683    /// The 2-head model predicts [ef, recall] jointly. Two strategies
684    /// are available (configured via `ef_strategy`):
685    ///
686    /// **Threshold**: boost ef until predicted recall >= `target_recall`,
687    /// capped by `max_ef_boost`. Simple, predictable.
688    ///
689    /// **Score**: maximize `recall - latency_weight * (ef / 500)`. Finds
690    /// the Pareto-optimal point balancing recall and speed.
691    ///
692    /// Returns a default of 100 if the model is not yet trained.
693    pub fn predict_ef(&self, query: &[f32], store_size: usize) -> EfPrediction {
694        let default_ef = 100;
695
696        if !self.config.enabled || !self.ef_model.is_trained() {
697            return EfPrediction {
698                recommended_ef: default_ef,
699                is_learned: false,
700            };
701        }
702
703        let features = SearchFeatures {
704            query_norm: vector_norm(query),
705            query_variance: vector_variance(query),
706            store_size: store_size as f64,
707            recent_avg_time_us: self.avg_recent_search_time(),
708        };
709
710        let outputs = self.ef_model.predict(&features.normalized());
711        let raw_ef = (outputs[0] * 500.0).clamp(10.0, 500.0);
712        let predicted_recall = outputs[1].clamp(0.0, 1.0);
713
714        let ef = match self.config.ef_strategy {
715            EfStrategy::Threshold => {
716                if predicted_recall < self.config.target_recall {
717                    let gap = (self.config.target_recall - predicted_recall)
718                        / (1.0 - predicted_recall).max(0.01);
719                    let boost = 1.0 + gap * (self.config.max_ef_boost - 1.0);
720                    (raw_ef * boost.min(self.config.max_ef_boost)).clamp(10.0, 500.0)
721                } else {
722                    raw_ef
723                }
724            }
725            EfStrategy::Score => {
726                // Probe a few ef candidates and pick the one with the best
727                // recall-vs-cost score. The model's head 0 learned the
728                // ef→latency mapping and head 1 the ef→recall mapping, so
729                // we evaluate at several ef levels.
730                let candidates: [f64; 7] = [0.04, 0.08, 0.16, 0.3, 0.5, 0.7, 1.0];
731                let mut best_score = f64::NEG_INFINITY;
732                let mut best_ef = raw_ef;
733                for &frac in &candidates {
734                    let candidate_ef = (frac * 500.0_f64).clamp(10.0, 500.0);
735                    // Approximate: recall scales with log(ef), model gives
736                    // a baseline prediction we shift proportionally.
737                    let ef_ratio = (candidate_ef / raw_ef.max(1.0)).ln().max(0.0);
738                    let est_recall = (predicted_recall + ef_ratio * 0.15).clamp(0.0, 1.0);
739                    let cost = frac; // normalized latency proxy
740                    let score = est_recall - self.config.latency_weight * cost;
741                    if score > best_score {
742                        best_score = score;
743                        best_ef = candidate_ef;
744                    }
745                }
746                best_ef
747            }
748        } as usize;
749
750        EfPrediction {
751            recommended_ef: ef.max(10),
752            is_learned: true,
753        }
754    }
755
756    /// Predict search entry nodes for a query (region → entry-node lookup).
757    ///
758    /// Returns an unlearned empty prediction when the path table has not been
759    /// built yet. When the EML path model is trained, attaches a quality score
760    /// (lower is better — normalized hop / pool proxy).
761    pub fn predict_path(&self, query: &[f32], store_size: usize) -> PathPrediction {
762        if !self.path_predict_enabled() {
763            return PathPrediction {
764                region_id: None,
765                entry_ids: Vec::new(),
766                region_score: 0.0,
767                candidate_ids: Vec::new(),
768                is_learned: false,
769                eml_path_quality: None,
770            };
771        }
772
773        let mut pred = match &self.path_table {
774            Some(table) if table.is_ready() => {
775                PathPrediction::from(table.predict(query, self.config.path_region_probes))
776            }
777            _ => PathPrediction {
778                region_id: None,
779                entry_ids: Vec::new(),
780                region_score: 0.0,
781                candidate_ids: Vec::new(),
782                is_learned: false,
783                eml_path_quality: None,
784            },
785        };
786
787        if self.path_model.is_trained() {
788            let features = SearchFeatures {
789                query_norm: vector_norm(query),
790                query_variance: vector_variance(query),
791                store_size: store_size as f64,
792                recent_avg_time_us: self.avg_recent_search_time(),
793            };
794            // Path model target is normalized hop/pool size; lower ⇒ better entry.
795            let quality = self
796                .path_model
797                .predict_primary(&features.normalized())
798                .max(0.0);
799            pred.eml_path_quality = Some(quality);
800        }
801
802        pred
803    }
804
805    /// Predict whether the HNSW index should be rebuilt.
806    pub fn predict_rebuild(
807        &self,
808        store_size: usize,
809        inserts_since_rebuild: usize,
810        deletes_since_rebuild: usize,
811    ) -> RebuildPrediction {
812        if !self.config.enabled || !self.rebuild_model.is_trained() {
813            // Heuristic fallback: rebuild if mutations exceed 10% of store.
814            let mutation_ratio = if store_size > 0 {
815                (inserts_since_rebuild + deletes_since_rebuild) as f64 / store_size as f64
816            } else {
817                0.0
818            };
819            return RebuildPrediction {
820                predicted_recall: 1.0 - mutation_ratio * 0.1,
821                should_rebuild: mutation_ratio > 0.1,
822                is_learned: false,
823            };
824        }
825
826        let features = RebuildFeatures {
827            store_size: store_size as f64,
828            inserts_since_rebuild: inserts_since_rebuild as f64,
829            deletes_since_rebuild: deletes_since_rebuild as f64,
830            avg_search_time_us: self.avg_recent_search_time(),
831        };
832
833        let predicted_recall = self
834            .rebuild_model
835            .predict_primary(&features.normalized())
836            .clamp(0.0, 1.0);
837
838        RebuildPrediction {
839            predicted_recall,
840            should_rebuild: predicted_recall < 0.90,
841            is_learned: true,
842        }
843    }
844
845    // -------------------------------------------------------------------
846    // Training
847    // -------------------------------------------------------------------
848
849    /// Train all models that have sufficient data.
850    ///
851    /// Returns `true` if at least one model was trained.
852    pub fn train_all(&mut self) -> bool {
853        let mut any_trained = false;
854
855        if self.train_ef_model() {
856            any_trained = true;
857        }
858        if self.train_path_model() {
859            any_trained = true;
860        }
861        if self.train_rebuild_model() {
862            any_trained = true;
863        }
864        if self.train_distance_model() {
865            any_trained = true;
866        }
867
868        self.searches_since_train = 0;
869        if any_trained {
870            self.train_cycles += 1;
871        }
872
873        any_trained
874    }
875
876    /// Train the ef model from collected search data.
877    ///
878    /// The ef model is 2-head:
879    ///   head 0 = normalized ef (ef_used / 500)
880    ///   head 1 = recall (0..1)
881    ///
882    /// When a training point has `recall: None`, head 1 is skipped in the
883    /// loss function via `None` — the model still trains on the ef signal
884    /// from that sample. When recall IS available, both heads train jointly,
885    /// teaching the model the ef ↔ recall tradeoff.
886    fn train_ef_model(&mut self) -> bool {
887        if self.ef_training.len() < self.config.min_training_samples {
888            return false;
889        }
890
891        for point in &self.ef_training {
892            let inputs = [
893                point.query_norm / 100.0,
894                point.query_variance.min(1.0),
895                point.result_count as f64 / 100.0,
896                point.search_time_us as f64 / 10_000.0,
897            ];
898            let ef_target = Some(point.ef_used as f64 / 500.0);
899            let recall_target = point.recall;
900            self.ef_model.record(&inputs, &[ef_target, recall_target]);
901        }
902
903        let converged = self.ef_model.train();
904        let keep = self.ef_training.len().saturating_sub(100);
905        self.ef_training.drain(..keep);
906        converged
907    }
908
909    /// Train the path model.
910    ///
911    /// Target is a normalized path cost: for guided searches, candidate-pool
912    /// size relative to store size (smaller = better entry/region). For
913    /// unguided searches, falls back to hop proxy / top-score complement.
914    fn train_path_model(&mut self) -> bool {
915        if self.path_training.len() < self.config.min_training_samples {
916            return false;
917        }
918
919        for point in &self.path_training {
920            let inputs = [
921                point.query_norm / 100.0,
922                point.query_variance.min(1.0),
923                point.store_size as f64 / 100_000.0,
924                point.region_score.clamp(0.0, 1.0),
925            ];
926            let target = if point.guided && point.store_size > 0 {
927                // Relative pool size: good regions keep this small.
928                (point.hops as f64 / point.store_size as f64).clamp(0.0, 1.0)
929            } else if point.hops > 0 {
930                (point.hops as f64 / 100.0).min(1.0)
931            } else {
932                // Unguided with no hop signal: use 1 - top_score as cost.
933                (1.0 - point.top_score).clamp(0.0, 1.0)
934            };
935            self.path_model.record(&inputs, &[Some(target)]);
936        }
937
938        let converged = self.path_model.train();
939        let keep = self.path_training.len().saturating_sub(100);
940        self.path_training.drain(..keep);
941        converged
942    }
943
944    /// Train the rebuild model.
945    fn train_rebuild_model(&mut self) -> bool {
946        if self.rebuild_training.len() < self.config.min_training_samples {
947            return false;
948        }
949
950        for point in &self.rebuild_training {
951            let inputs = [
952                point.store_size as f64 / 100_000.0,
953                point.inserts_since_rebuild as f64 / 10_000.0,
954                point.deletes_since_rebuild as f64 / 10_000.0,
955                point.avg_search_time_us / 10_000.0,
956            ];
957            // Target: actual recall
958            let target = point.recall;
959            self.rebuild_model.record(&inputs, &[Some(target)]);
960        }
961
962        let converged = self.rebuild_model.train();
963        let keep = self.rebuild_training.len().saturating_sub(100);
964        self.rebuild_training.drain(..keep);
965        converged
966    }
967
968    /// Train the distance model.
969    fn train_distance_model(&mut self) -> bool {
970        if self.distance_training.len() < self.config.min_training_samples {
971            return false;
972        }
973
974        for point in &self.distance_training {
975            // Use aggregate dimension stats as features
976            let n_dims = point.dim_contributions.len().max(1) as f64;
977            let mean_contrib = point.dim_contributions.iter().sum::<f64>() / n_dims;
978            let max_contrib = point
979                .dim_contributions
980                .iter()
981                .copied()
982                .fold(0.0_f64, f64::max);
983            let inputs = [
984                mean_contrib.min(1.0),
985                max_contrib.min(1.0),
986                n_dims / 1000.0,
987                point.exact_similarity.clamp(0.0, 1.0),
988            ];
989            // Target: approximation quality
990            let target = (point.approx_similarity - point.exact_similarity)
991                .abs()
992                .min(1.0);
993            self.distance_model.record(&inputs, &[Some(target)]);
994        }
995
996        let converged = self.distance_model.train();
997        let keep = self.distance_training.len().saturating_sub(100);
998        self.distance_training.drain(..keep);
999        converged
1000    }
1001
1002    // -------------------------------------------------------------------
1003    // Status & reset
1004    // -------------------------------------------------------------------
1005
1006    /// Compute a dimension importance ranking from accumulated distance
1007    /// training data. Dimensions that contribute most to distinguishing
1008    /// correct from incorrect results are ranked highest.
1009    ///
1010    /// Returns dimension indices sorted by importance (most important first).
1011    /// Falls back to identity ordering [0, 1, 2, ...] when insufficient data.
1012    pub fn learned_dim_order(&self, total_dims: usize) -> Vec<usize> {
1013        if self.distance_training.len() < 10 {
1014            return (0..total_dims).collect();
1015        }
1016
1017        // Aggregate mean absolute contribution per dimension across all
1018        // training points. Dimensions with high mean contribution are
1019        // the most discriminative.
1020        let mut dim_importance = vec![0.0_f64; total_dims];
1021        let mut counts = vec![0usize; total_dims];
1022        for point in &self.distance_training {
1023            for (d, &contrib) in point.dim_contributions.iter().enumerate() {
1024                if d < total_dims {
1025                    dim_importance[d] += contrib;
1026                    counts[d] += 1;
1027                }
1028            }
1029        }
1030        for d in 0..total_dims {
1031            if counts[d] > 0 {
1032                dim_importance[d] /= counts[d] as f64;
1033            }
1034        }
1035
1036        let mut order: Vec<usize> = (0..total_dims).collect();
1037        order.sort_by(|&a, &b| {
1038            dim_importance[b]
1039                .partial_cmp(&dim_importance[a])
1040                .unwrap_or(std::cmp::Ordering::Equal)
1041        });
1042        order
1043    }
1044
1045    /// Return a point-in-time status snapshot.
1046    pub fn status(&self) -> HnswEmlStatus {
1047        HnswEmlStatus {
1048            enabled: self.config.enabled,
1049            distance_trained: self.distance_model.is_trained(),
1050            distance_samples: self.distance_training.len(),
1051            ef_trained: self.ef_model.is_trained(),
1052            ef_samples: self.ef_training.len(),
1053            path_trained: self.path_model.is_trained(),
1054            path_samples: self.path_training.len(),
1055            rebuild_trained: self.rebuild_model.is_trained(),
1056            rebuild_samples: self.rebuild_training.len(),
1057            searches_since_train: self.searches_since_train,
1058            total_searches: self.total_searches,
1059            train_cycles: self.train_cycles,
1060            last_recall: self.last_recall,
1061        }
1062    }
1063
1064    /// Drop the region path table without touching EML weights.
1065    pub fn clear_path_table(&mut self) {
1066        self.path_table = None;
1067    }
1068
1069    /// Reset all models and clear training data.
1070    pub fn reset(&mut self) {
1071        self.distance_model = eml_core::EmlModel::new(3, 4, 1);
1072        self.ef_model = eml_core::EmlModel::new(3, 4, 2);
1073        self.path_model = eml_core::EmlModel::new(3, 4, 1);
1074        self.rebuild_model = eml_core::EmlModel::new(3, 4, 1);
1075        self.path_table = None;
1076        self.distance_training.clear();
1077        self.ef_training.clear();
1078        self.path_training.clear();
1079        self.rebuild_training.clear();
1080        self.searches_since_train = 0;
1081        self.total_searches = 0;
1082        self.train_cycles = 0;
1083        self.last_recall = None;
1084        self.recent_search_times_us.clear();
1085    }
1086
1087    // -------------------------------------------------------------------
1088    // Private helpers
1089    // -------------------------------------------------------------------
1090
1091    /// Average of recent search times in microseconds.
1092    fn avg_recent_search_time(&self) -> f64 {
1093        if self.recent_search_times_us.is_empty() {
1094            return 0.0;
1095        }
1096        self.recent_search_times_us.iter().sum::<u64>() as f64
1097            / self.recent_search_times_us.len() as f64
1098    }
1099}
1100
1101impl std::fmt::Debug for HnswEmlManager {
1102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1103        f.debug_struct("HnswEmlManager")
1104            .field("enabled", &self.config.enabled)
1105            .field("total_searches", &self.total_searches)
1106            .field("train_cycles", &self.train_cycles)
1107            .field("ef_trained", &self.ef_model.is_trained())
1108            .field("rebuild_trained", &self.rebuild_model.is_trained())
1109            .finish()
1110    }
1111}
1112
1113// ---------------------------------------------------------------------------
1114// Helper functions
1115// ---------------------------------------------------------------------------
1116
1117/// Compute the L2 norm of a vector.
1118fn vector_norm(v: &[f32]) -> f64 {
1119    v.iter()
1120        .map(|x| (*x as f64) * (*x as f64))
1121        .sum::<f64>()
1122        .sqrt()
1123}
1124
1125/// Compute the variance of a vector's elements.
1126fn vector_variance(v: &[f32]) -> f64 {
1127    if v.is_empty() {
1128        return 0.0;
1129    }
1130    let n = v.len() as f64;
1131    let mean = v.iter().map(|x| *x as f64).sum::<f64>() / n;
1132    v.iter()
1133        .map(|x| {
1134            let d = *x as f64 - mean;
1135            d * d
1136        })
1137        .sum::<f64>()
1138        / n
1139}
1140
1141/// Compute recall: fraction of exact results that appear in HNSW results.
1142fn compute_recall(hnsw_ids: &[String], exact_ids: &[String]) -> f64 {
1143    if exact_ids.is_empty() {
1144        return 1.0;
1145    }
1146    let found = exact_ids.iter().filter(|id| hnsw_ids.contains(id)).count();
1147    found as f64 / exact_ids.len() as f64
1148}
1149
1150// ---------------------------------------------------------------------------
1151// Corpus probe — analyze data and select search strategy
1152// ---------------------------------------------------------------------------
1153
1154/// The search strategy the probe recommends, with tuned parameters.
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub enum SearchStrategy {
1157    /// Data is too uniform or too small — use standard flat HNSW.
1158    /// Carries the recommended ef_search.
1159    Flat { ef: usize },
1160    /// Data has exploitable dimensional structure — use tiered search.
1161    /// Carries dimension ordering, tier widths, and keep counts.
1162    Tiered {
1163        dim_order: Vec<usize>,
1164        coarse_dims: usize,
1165        coarse_keep: usize,
1166        medium_dims: usize,
1167        medium_keep: usize,
1168        ef: usize,
1169    },
1170}
1171
1172/// Diagnostic output from the corpus probe.
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct ProbeReport {
1175    pub strategy: SearchStrategy,
1176    pub store_size: usize,
1177    pub dimensions: usize,
1178    /// Per-dimension variance, sorted by dimension index.
1179    pub dim_variances: Vec<f64>,
1180    /// Spectral steepness: ratio of top-quartile variance to bottom-quartile.
1181    /// > 4.0 = strongly structured, < 1.5 = essentially uniform.
1182    pub steepness: f64,
1183    /// Fraction of total variance captured by the top 25% of dimensions.
1184    pub top_quartile_fraction: f64,
1185}
1186
1187/// Probe a corpus and return a search strategy with tuned parameters.
1188///
1189/// Samples up to 500 vectors from the corpus, computes per-dimension
1190/// variance, and decides whether tiered search will help. Returns a
1191/// concrete `SearchStrategy` with recommended parameters.
1192///
1193/// This is the "oracle" call — run it at index build time or when the
1194/// corpus distribution shifts significantly.
1195pub fn probe_corpus(corpus: &[Vec<f32>], dims: usize, top_k: usize) -> ProbeReport {
1196    let sample_n = corpus.len().min(500);
1197    if sample_n < 10 || dims < 4 {
1198        return ProbeReport {
1199            strategy: SearchStrategy::Flat { ef: 100 },
1200            store_size: corpus.len(),
1201            dimensions: dims,
1202            dim_variances: vec![],
1203            steepness: 1.0,
1204            top_quartile_fraction: 0.25,
1205        };
1206    }
1207
1208    // Compute per-dimension mean and variance over the sample.
1209    let mut mean = vec![0.0_f64; dims];
1210    let mut m2 = vec![0.0_f64; dims];
1211    let sample = &corpus[..sample_n];
1212
1213    for vec in sample {
1214        for d in 0..dims.min(vec.len()) {
1215            mean[d] += vec[d] as f64;
1216        }
1217    }
1218    for slot in mean.iter_mut().take(dims) {
1219        *slot /= sample_n as f64;
1220    }
1221    for vec in sample {
1222        for d in 0..dims.min(vec.len()) {
1223            let diff = vec[d] as f64 - mean[d];
1224            m2[d] += diff * diff;
1225        }
1226    }
1227    let dim_variances: Vec<f64> = m2.iter().map(|v| v / sample_n as f64).collect();
1228
1229    // Sort dimensions by variance (descending) to get importance order.
1230    let mut order: Vec<usize> = (0..dims).collect();
1231    order.sort_by(|&a, &b| {
1232        dim_variances[b]
1233            .partial_cmp(&dim_variances[a])
1234            .unwrap_or(std::cmp::Ordering::Equal)
1235    });
1236
1237    // Spectral steepness: how concentrated is variance?
1238    let total_var: f64 = dim_variances.iter().sum();
1239    let q1 = dims / 4;
1240    let top_q_var: f64 = order[..q1.max(1)].iter().map(|&d| dim_variances[d]).sum();
1241    let bot_q_var: f64 = order[dims - q1.max(1)..]
1242        .iter()
1243        .map(|&d| dim_variances[d])
1244        .sum();
1245    let steepness = if bot_q_var > 1e-12 {
1246        top_q_var / bot_q_var
1247    } else {
1248        100.0
1249    };
1250    let top_quartile_fraction = if total_var > 1e-12 {
1251        top_q_var / total_var
1252    } else {
1253        0.25
1254    };
1255
1256    // Decision logic.
1257    let strategy = if steepness < 1.5 || dims < 16 || corpus.len() < 100 {
1258        // Uniform or tiny — flat HNSW is fine.
1259        let ef = if corpus.len() < 1000 { 100 } else { 50 };
1260        SearchStrategy::Flat { ef }
1261    } else {
1262        // Structured — tiered search will help.
1263        // Size the coarse tier so it captures ~80% of top-quartile variance.
1264        let mut cum_var = 0.0;
1265        let target = total_var * 0.80;
1266        let mut coarse_n = 0;
1267        for &d in &order {
1268            cum_var += dim_variances[d];
1269            coarse_n += 1;
1270            if cum_var >= target {
1271                break;
1272            }
1273        }
1274        coarse_n = coarse_n.max(4).min(dims);
1275
1276        // Medium tier: capture ~95%.
1277        let target95 = total_var * 0.95;
1278        let mut medium_n = coarse_n;
1279        for &d in &order[coarse_n..] {
1280            cum_var += dim_variances[d];
1281            medium_n += 1;
1282            if cum_var >= target95 {
1283                break;
1284            }
1285        }
1286        medium_n = medium_n.max(coarse_n + 2).min(dims);
1287
1288        // Keep counts scale with corpus size and steepness.
1289        let coarse_keep = (top_k as f64 * (10.0 + 40.0 / steepness.sqrt())).round() as usize;
1290        let medium_keep = (top_k * 5).max(20);
1291        let ef = if steepness > 10.0 { 50 } else { 100 };
1292
1293        SearchStrategy::Tiered {
1294            dim_order: order.clone(),
1295            coarse_dims: coarse_n,
1296            coarse_keep: coarse_keep.max(medium_keep + 10),
1297            medium_dims: medium_n,
1298            medium_keep,
1299            ef,
1300        }
1301    };
1302
1303    ProbeReport {
1304        strategy,
1305        store_size: corpus.len(),
1306        dimensions: dims,
1307        dim_variances,
1308        steepness,
1309        top_quartile_fraction,
1310    }
1311}
1312
1313// ---------------------------------------------------------------------------
1314// Tree calculus strategy selector
1315// ---------------------------------------------------------------------------
1316//
1317// Follows the dual-substrate pattern from ADR: tree calculus for structural
1318// dispatch, EML for continuous parameter computation.
1319//
1320// The variance spectrum of the corpus is encoded as a tree:
1321//   Atom     — uniform (flat spectrum, all dims equally informative)
1322//   Sequence — gradual decay (smooth variance falloff)
1323//   Branch   — clustered (sharp drop after a few dominant dims)
1324//
1325// Triage dispatches on the tree form. Each branch feeds different
1326// features into an EML scoring function that computes the strategy
1327// parameters. The tree + parameters together form an auditable,
1328// ExoChain-loggable decision record.
1329
1330/// Tree calculus form for the variance spectrum.
1331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1332pub enum SpectrumForm {
1333    /// Flat spectrum — all dimensions equally informative. Use standard HNSW.
1334    Atom,
1335    /// Gradual decay — moderate structure. Tiered search helps at scale.
1336    Sequence,
1337    /// Sharp cluster — a few dims dominate. Tiered search is strongly favored.
1338    Branch,
1339}
1340
1341/// Full tree calculus decision record — loggable to ExoChain.
1342#[derive(Debug, Clone, Serialize, Deserialize)]
1343pub struct TriageRecord {
1344    /// Structural form of the variance spectrum.
1345    pub form: SpectrumForm,
1346    /// Spectral steepness (top-quartile / bottom-quartile variance).
1347    pub steepness: f64,
1348    /// Fraction of variance in the top quartile of dimensions.
1349    pub concentration: f64,
1350    /// Knee index: dimension where cumulative variance exceeds 80%.
1351    pub knee: usize,
1352    /// EML-scored strategy and parameters.
1353    pub strategy: SearchStrategy,
1354}
1355
1356/// Triage: classify a variance spectrum by its tree calculus form.
1357fn triage_spectrum(steepness: f64, concentration: f64) -> SpectrumForm {
1358    if steepness < 1.5 || concentration < 0.30 {
1359        SpectrumForm::Atom
1360    } else if steepness < 8.0 {
1361        SpectrumForm::Sequence
1362    } else {
1363        SpectrumForm::Branch
1364    }
1365}
1366
1367/// EML-style scoring for tier sizing.
1368///
1369/// For a given structural form, compute continuous parameters via
1370/// exp-ln composition (same pattern as the graphify treecalc.rs).
1371fn eml_tier_params(
1372    form: SpectrumForm,
1373    dims: usize,
1374    knee: usize,
1375    store_size: usize,
1376    top_k: usize,
1377    steepness: f64,
1378) -> SearchStrategy {
1379    match form {
1380        SpectrumForm::Atom => {
1381            // Flat — no dimensional structure to exploit.
1382            let ef = if store_size < 1000 { 100 } else { 50 };
1383            SearchStrategy::Flat { ef }
1384        }
1385        SpectrumForm::Sequence => {
1386            // Gradual decay — moderate tiering.
1387            // Coarse: capture 80% variance (= knee).
1388            // Medium: 1.5× coarse, capped at 60% of dims.
1389            let coarse_dims = knee.max(4);
1390            let medium_dims = (coarse_dims * 3 / 2).max(coarse_dims + 4).min(dims * 3 / 5);
1391
1392            // EML-style keep sizing: exp-ln of steepness.
1393            let x = steepness;
1394            let keep_scale = 0.5 * (0.3 * x).exp().min(20.0) + 0.5 * (x + 1.0).ln();
1395            let coarse_keep = (top_k as f64 * keep_scale * 5.0).round() as usize;
1396            let medium_keep = (top_k * 5).max(20);
1397
1398            SearchStrategy::Tiered {
1399                dim_order: vec![],
1400                coarse_dims,
1401                coarse_keep: coarse_keep.max(medium_keep + 10),
1402                medium_dims,
1403                medium_keep,
1404                ef: 100,
1405            }
1406        }
1407        SpectrumForm::Branch => {
1408            // Sharp cluster — aggressive tiering.
1409            // Coarse: knee dims (often small).
1410            // Medium: 2× knee, enough to separate within-cluster neighbors.
1411            let coarse_dims = knee.max(4);
1412            let medium_dims = (knee * 2).max(coarse_dims + 4).min(dims * 3 / 4);
1413
1414            let x = steepness.ln().max(1.0);
1415            let coarse_keep = (top_k as f64 * (8.0 + 30.0 / x)).round() as usize;
1416            let medium_keep = (top_k * 4).max(15);
1417
1418            SearchStrategy::Tiered {
1419                dim_order: vec![],
1420                coarse_dims,
1421                coarse_keep: coarse_keep.max(medium_keep + 10),
1422                medium_dims,
1423                medium_keep,
1424                ef: 50,
1425            }
1426        }
1427    }
1428}
1429
1430/// Run the tree calculus triage + EML pipeline on a probe report.
1431///
1432/// Returns a full `TriageRecord` suitable for ExoChain logging.
1433/// The `dim_order` from the probe is merged into the strategy.
1434pub fn triage_strategy(probe: &ProbeReport) -> TriageRecord {
1435    let steepness = probe.steepness;
1436    let concentration = probe.top_quartile_fraction;
1437
1438    let form = triage_spectrum(steepness, concentration);
1439
1440    // Find the knee: first dim index where cumulative variance ≥ 80%.
1441    let total_var: f64 = probe.dim_variances.iter().sum();
1442    let mut sorted_vars: Vec<(usize, f64)> =
1443        probe.dim_variances.iter().copied().enumerate().collect();
1444    sorted_vars.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1445    let target = total_var * 0.80;
1446    let mut cum = 0.0;
1447    let mut knee = 0;
1448    for (_, v) in &sorted_vars {
1449        cum += v;
1450        knee += 1;
1451        if cum >= target {
1452            break;
1453        }
1454    }
1455
1456    let dims = probe.dimensions;
1457    let top_k = 10; // default; caller can override
1458    let mut strategy = eml_tier_params(form, dims, knee, probe.store_size, top_k, steepness);
1459
1460    // Merge the probe's dim_order into the strategy.
1461    if let SearchStrategy::Tiered { dim_order, .. } = &mut strategy {
1462        *dim_order = sorted_vars.iter().map(|(d, _)| *d).collect();
1463    }
1464
1465    TriageRecord {
1466        form,
1467        steepness,
1468        concentration,
1469        knee,
1470        strategy,
1471    }
1472}
1473
1474/// Compute cosine similarity between two f32 vectors.
1475fn cosine_similarity_f32(a: &[f32], b: &[f32]) -> f32 {
1476    if a.len() != b.len() {
1477        return 0.0;
1478    }
1479    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
1480    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1481    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1482    if norm_a == 0.0 || norm_b == 0.0 {
1483        return 0.0;
1484    }
1485    dot / (norm_a * norm_b)
1486}
1487
1488// ---------------------------------------------------------------------------
1489// 4-Phase Benchmark: EML-Adaptive HNSW vs Static HNSW
1490// ---------------------------------------------------------------------------
1491//
1492// Mirrors the AttentionBenchmark protocol from eml-core/src/attention.rs:
1493//
1494// Phase 1 (Warmup)     — build index, verify search, serialize roundtrip
1495// Phase 2 (Learning)   — query stream with EML adaptation; recall + latency
1496//                         before/after models train
1497// Phase 3 (Compute)    — query latency head-to-head: static vs adaptive
1498// Phase 4 (Scalability)— store-size sweep
1499
1500use clawft_core::embeddings::hnsw_store::{HnswStore, TieredSearch};
1501
1502/// Latency + recall snapshot for one benchmark arm.
1503#[derive(Debug, Clone, Serialize, Deserialize)]
1504pub struct ArmMetrics {
1505    pub recall: f64,
1506    pub mean_ns: u128,
1507    pub p99_ns: u128,
1508}
1509
1510/// Result of a single 4-phase HNSW-EML benchmark pass.
1511///
1512/// Three arms are measured under identical data and query sequences:
1513/// - **control**: bare HNSW at the static ef, no EML code in the path.
1514/// - **overhead**: same ef, EML predict+record calls enabled but
1515///   predictions ignored (measures pure overhead).
1516/// - **adaptive**: EML fully enabled, predictions applied.
1517#[derive(Debug, Clone, Serialize, Deserialize)]
1518pub struct HnswEmlBenchmark {
1519    pub dimensions: usize,
1520    pub store_size: usize,
1521    pub top_k: usize,
1522    pub static_ef: usize,
1523
1524    // Phase 1
1525    pub phase1_build_ns: u128,
1526    pub phase1_baseline_recall: f64,
1527    pub phase1_warmup_query_ns: u128,
1528
1529    // Phase 2
1530    pub phase2_pre_train_recall: f64,
1531    pub phase2_post_train_recall: f64,
1532    pub phase2_recall_delta: f64,
1533    pub phase2_eml_train_cycles: u64,
1534    pub phase2_queries_run: usize,
1535
1536    // Phase 3 — four-arm comparison
1537    pub phase3_control: ArmMetrics,
1538    pub phase3_overhead: ArmMetrics,
1539    pub phase3_adaptive: ArmMetrics,
1540    pub phase3_tiered: ArmMetrics,
1541
1542    // Phase 4
1543    pub phase4_scaling: Vec<HnswScalingPoint>,
1544}
1545
1546#[derive(Debug, Clone, Serialize, Deserialize)]
1547pub struct HnswScalingPoint {
1548    pub store_size: usize,
1549    pub control_recall: f64,
1550    pub adaptive_recall: f64,
1551    pub control_mean_ns: u128,
1552    pub adaptive_mean_ns: u128,
1553}
1554
1555/// Deterministic LCG for benchmark data generation.
1556fn bench_lcg(state: &mut u64) -> f64 {
1557    *state = state
1558        .wrapping_mul(6364136223846793005)
1559        .wrapping_add(1442695040888963407);
1560    (*state >> 33) as f64 / (u32::MAX as f64 / 2.0) - 1.0
1561}
1562
1563/// Box-Muller normal from LCG.
1564fn bench_randn(state: &mut u64) -> f64 {
1565    let u1 = (bench_lcg(state) + 1.0) * 0.5;
1566    let u2 = (bench_lcg(state) + 1.0) * 0.5;
1567    let u1 = u1.max(1e-12);
1568    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
1569}
1570
1571/// Generate structured embeddings that mimic real model outputs.
1572///
1573/// Creates `n` vectors in `dims` dimensions organized into `n_clusters`
1574/// clusters. Early dimensions carry cluster identity (high variance),
1575/// later dimensions carry noise (exponentially decaying variance).
1576/// This models real text/image embeddings where PCA components are
1577/// naturally ordered by explained variance.
1578///
1579/// Dimension d has variance proportional to `exp(-d * decay)`:
1580///   dim 0:   variance ~1.0   (cluster-defining)
1581///   dim 8:   variance ~0.45
1582///   dim 32:  variance ~0.08
1583///   dim 128: variance ~0.001  (noise)
1584fn gen_structured_corpus(
1585    state: &mut u64,
1586    n: usize,
1587    dims: usize,
1588    n_clusters: usize,
1589) -> Vec<Vec<f32>> {
1590    let decay = 5.0 / dims as f64;
1591
1592    // Generate cluster centroids.
1593    let mut centroids = Vec::with_capacity(n_clusters);
1594    for _ in 0..n_clusters {
1595        let c: Vec<f64> = (0..dims).map(|_| bench_randn(state) * 2.0).collect();
1596        centroids.push(c);
1597    }
1598
1599    // Generate corpus vectors: pick a cluster, add scaled noise.
1600    (0..n)
1601        .map(|i| {
1602            let cluster_idx = i % n_clusters;
1603            let centroid = &centroids[cluster_idx];
1604            (0..dims)
1605                .map(|d| {
1606                    let scale = (-decay * d as f64).exp();
1607                    let noise = bench_randn(state) * 0.3 * scale;
1608                    (centroid[d] * scale + noise) as f32
1609                })
1610                .collect()
1611        })
1612        .collect()
1613}
1614
1615/// Generate query vectors from the same distribution as the corpus.
1616fn gen_structured_queries(
1617    state: &mut u64,
1618    n: usize,
1619    dims: usize,
1620    n_clusters: usize,
1621) -> Vec<Vec<f32>> {
1622    gen_structured_corpus(state, n, dims, n_clusters)
1623}
1624
1625fn measure_recall_direct(store: &mut HnswStore, queries: &[Vec<f32>], top_k: usize) -> f64 {
1626    let mut total = 0.0;
1627    let n = queries.len();
1628    for q in queries {
1629        let hnsw_ids: Vec<String> = store.query(q, top_k).into_iter().map(|r| r.id).collect();
1630        let exact_ids = store.brute_force_topk(q, top_k);
1631        let found = exact_ids.iter().filter(|id| hnsw_ids.contains(id)).count();
1632        total += found as f64 / exact_ids.len().max(1) as f64;
1633    }
1634    total / n.max(1) as f64
1635}
1636
1637/// Build a store from a corpus slice at the given ef.
1638fn build_store(corpus: &[Vec<f32>], ef: usize) -> HnswStore {
1639    let mut store = HnswStore::with_params(ef, 200);
1640    for (i, vec) in corpus.iter().enumerate() {
1641        store.insert(format!("v{i}"), vec.clone(), serde_json::json!({}));
1642    }
1643    store.force_rebuild();
1644    store
1645}
1646
1647/// Measure latency distribution over 4 passes of all queries.
1648fn measure_latency(store: &mut HnswStore, qs: &[Vec<f32>], top_k: usize) -> ArmMetrics {
1649    let recall = measure_recall_direct(store, &qs[..qs.len().min(16)], top_k);
1650    let mut lats = Vec::with_capacity(qs.len() * 4);
1651    for _ in 0..4 {
1652        for q in qs {
1653            let t = std::time::Instant::now();
1654            let _ = store.query(q, top_k);
1655            lats.push(t.elapsed().as_nanos());
1656        }
1657    }
1658    lats.sort_unstable();
1659    let mean = lats.iter().sum::<u128>() / lats.len().max(1) as u128;
1660    let p99 = lats[(lats.len() * 99) / 100];
1661    ArmMetrics {
1662        recall,
1663        mean_ns: mean,
1664        p99_ns: p99,
1665    }
1666}
1667
1668/// Tunables for [`run_hnsw_benchmark_with`].
1669///
1670/// Full-scale defaults match the historical 4-phase A/B protocol (20×128
1671/// learning queries with `train_every_n = 100`). That path spends most of
1672/// its wall time inside `EmlModel::train` (100–200 random restarts + up to
1673/// 1000 coordinate-descent passes per train cycle) and is **not** suitable
1674/// as a default unit test — use [`HnswBenchmarkParams::smoke`] in CI.
1675#[derive(Debug, Clone)]
1676pub struct HnswBenchmarkParams {
1677    pub store_size: usize,
1678    pub dims: usize,
1679    pub top_k: usize,
1680    /// Full passes over the query set in phase 2. Default: 20.
1681    pub learning_passes: usize,
1682    /// Structured query count. Default: 128.
1683    pub n_queries: usize,
1684    /// EML auto-train interval during phase 2. Default: 100.
1685    ///
1686    /// Each train cycle is O(restarts × training_set × param_count) and
1687    /// dominates wall clock; raise this (or lower `learning_passes`) for
1688    /// smoke runs.
1689    pub train_every_n: usize,
1690    /// Minimum samples before EML will train. Default: 50.
1691    pub min_training_samples: usize,
1692    /// Run the phase-4 size sweep. Default: true.
1693    pub phase4: bool,
1694}
1695
1696impl HnswBenchmarkParams {
1697    /// Historical full-scale protocol (multi-minute; for manual/perf runs).
1698    pub fn full(store_size: usize, dims: usize, top_k: usize) -> Self {
1699        Self {
1700            store_size,
1701            dims,
1702            top_k,
1703            learning_passes: 20,
1704            n_queries: 128,
1705            train_every_n: 100,
1706            min_training_samples: 50,
1707            phase4: true,
1708        }
1709    }
1710
1711    /// Tiny params that still exercise all four phases in well under a second.
1712    ///
1713    /// Phase-2 training is effectively disabled mid-stream (`train_every_n`
1714    /// larger than the query budget) so the suite does not hang on
1715    /// `EmlModel::train` (WEFT-134).
1716    pub fn smoke() -> Self {
1717        Self {
1718            store_size: 48,
1719            dims: 8,
1720            top_k: 3,
1721            learning_passes: 1,
1722            n_queries: 8,
1723            train_every_n: 10_000,
1724            min_training_samples: 50,
1725            phase4: true,
1726        }
1727    }
1728}
1729
1730/// Run the 4-phase HNSW-EML benchmark with three-arm A/B.
1731///
1732/// **Control**: bare HNSW at static ef, no EML code in the path.
1733/// **Overhead**: same ef, EML predict+record running but predictions
1734///   not applied (measures pure EML overhead).
1735/// **Adaptive**: EML fully enabled, predictions applied to ef.
1736///
1737/// All three arms use identical data and query sequences.
1738///
1739/// This is the full-scale protocol. Prefer
1740/// [`run_hnsw_benchmark_with`]`(`[HnswBenchmarkParams::smoke]`)` in unit
1741/// tests — the full protocol can spend many minutes inside EML training
1742/// (WEFT-134).
1743pub fn run_hnsw_benchmark(store_size: usize, dims: usize, top_k: usize) -> HnswEmlBenchmark {
1744    run_hnsw_benchmark_with(HnswBenchmarkParams::full(store_size, dims, top_k))
1745}
1746
1747/// Result of guided (path-predict) vs default entry-node search benchmark.
1748#[derive(Debug, Clone, Serialize, Deserialize)]
1749pub struct PathPredictBenchmark {
1750    pub store_size: usize,
1751    pub dimensions: usize,
1752    pub top_k: usize,
1753    pub num_regions: usize,
1754    pub region_probes: usize,
1755    pub n_queries: usize,
1756    /// Mean latency for default HNSW query (ns).
1757    pub default_mean_ns: u128,
1758    /// Mean latency for guided region→entry query (ns).
1759    pub guided_mean_ns: u128,
1760    /// Speedup factor: default_mean / guided_mean (higher is better for guided).
1761    pub speedup: f64,
1762    /// Recall of default HNSW vs brute-force.
1763    pub default_recall: f64,
1764    /// Recall of guided search vs brute-force.
1765    pub guided_recall: f64,
1766    /// Fraction of queries where predicted entry is in the same cluster as top hit.
1767    pub entry_hit_rate: f64,
1768}
1769
1770/// Benchmark guided path prediction vs default HNSW entry (WEFT-385 AC).
1771///
1772/// Builds a clustered corpus, trains a region→entry table, and compares
1773/// latency + recall of [`HnswStore::query`] vs [`HnswStore::query_guided`].
1774pub fn run_path_predict_benchmark(
1775    store_size: usize,
1776    dims: usize,
1777    top_k: usize,
1778    n_queries: usize,
1779) -> PathPredictBenchmark {
1780    let mut rng = 0xC0FF_EE00_u64;
1781    let n_clusters = 16.min(store_size.max(1));
1782    let corpus = gen_structured_corpus(&mut rng, store_size, dims, n_clusters);
1783    let queries = gen_structured_queries(&mut rng, n_queries.max(1), dims, n_clusters);
1784
1785    let mut store_default = build_store(&corpus, 50);
1786    let mut store_guided = build_store(&corpus, 50);
1787
1788    let pairs = store_guided.entry_id_embeddings();
1789    let table = RegionEntryTable::build(
1790        &pairs,
1791        PathPredictConfig {
1792            num_regions: n_clusters,
1793            region_probes: 2,
1794            entries_per_region: 3,
1795            kmeans_iters: 10,
1796        },
1797    );
1798
1799    // Warmup
1800    let _ = store_default.query(&queries[0], top_k);
1801    let _ = store_guided.query_guided(&queries[0], top_k, &table, 2);
1802
1803    let mut default_lats = Vec::with_capacity(queries.len());
1804    let mut guided_lats = Vec::with_capacity(queries.len());
1805    let mut default_recall_sum = 0.0;
1806    let mut guided_recall_sum = 0.0;
1807    let mut entry_hits = 0usize;
1808
1809    for q in &queries {
1810        let exact = store_default.brute_force_topk(q, top_k);
1811
1812        let t0 = std::time::Instant::now();
1813        let def_res = store_default.query(q, top_k);
1814        default_lats.push(t0.elapsed().as_nanos());
1815        let def_ids: Vec<String> = def_res.iter().map(|r| r.id.clone()).collect();
1816        default_recall_sum += compute_recall(&def_ids, &exact);
1817
1818        let t0 = std::time::Instant::now();
1819        let (g_res, pred) = store_guided.query_guided(q, top_k, &table, 2);
1820        guided_lats.push(t0.elapsed().as_nanos());
1821        let g_ids: Vec<String> = g_res.iter().map(|r| r.id.clone()).collect();
1822        guided_recall_sum += compute_recall(&g_ids, &exact);
1823
1824        if let (Some(top), Some(entry)) = (g_ids.first(), pred.entry_ids.first()) {
1825            // Same-prefix heuristic for structured corpus ids "v{i}".
1826            // Count as hit when entry appears among guided top results
1827            // or shares the predicted region with the top hit.
1828            if g_ids.contains(entry)
1829                || pred.candidate_ids.iter().any(|c| c == top)
1830                || pred.region_score > 0.5
1831            {
1832                entry_hits += 1;
1833            }
1834        } else if pred.is_learned {
1835            entry_hits += 1;
1836        }
1837    }
1838
1839    let n = queries.len().max(1) as f64;
1840    let default_mean = default_lats.iter().sum::<u128>() / default_lats.len().max(1) as u128;
1841    let guided_mean = guided_lats.iter().sum::<u128>() / guided_lats.len().max(1) as u128;
1842    let speedup = if guided_mean > 0 {
1843        default_mean as f64 / guided_mean as f64
1844    } else {
1845        1.0
1846    };
1847
1848    PathPredictBenchmark {
1849        store_size,
1850        dimensions: dims,
1851        top_k,
1852        num_regions: n_clusters,
1853        region_probes: 2,
1854        n_queries: queries.len(),
1855        default_mean_ns: default_mean,
1856        guided_mean_ns: guided_mean,
1857        speedup,
1858        default_recall: default_recall_sum / n,
1859        guided_recall: guided_recall_sum / n,
1860        entry_hit_rate: entry_hits as f64 / n,
1861    }
1862}
1863
1864/// Run the 4-phase HNSW-EML benchmark with explicit scale parameters.
1865pub fn run_hnsw_benchmark_with(params: HnswBenchmarkParams) -> HnswEmlBenchmark {
1866    let HnswBenchmarkParams {
1867        store_size,
1868        dims,
1869        top_k,
1870        learning_passes,
1871        n_queries,
1872        train_every_n,
1873        min_training_samples,
1874        phase4,
1875    } = params;
1876
1877    let mut rng = 0xDEAD_BEEF_u64;
1878
1879    // Structured embeddings: 20 clusters (capped by corpus size), early dims
1880    // = high variance (cluster identity), later dims = noise.
1881    let n_clusters = 20.min(store_size.max(1));
1882    let n_queries = n_queries.max(1);
1883    let corpus = gen_structured_corpus(&mut rng, store_size, dims, n_clusters);
1884    let queries = gen_structured_queries(&mut rng, n_queries, dims, n_clusters);
1885
1886    let static_ef: usize = if store_size >= 1000 { 20 } else { 100 };
1887    let recall_sample = queries.len().min(16);
1888    let recall_check_every = 50.max(n_queries); // at most once per pass when tiny
1889
1890    // ── Phase 1 (Warmup) ────────────────────────────────────────────────
1891
1892    let mut store_control = build_store(&corpus, static_ef);
1893    let t0 = std::time::Instant::now();
1894    // Already built above; measure build time by rebuilding.
1895    store_control.force_rebuild();
1896    let phase1_build_ns = t0.elapsed().as_nanos();
1897
1898    let t0 = std::time::Instant::now();
1899    let _ = store_control.query(&queries[0], top_k);
1900    let phase1_warmup_query_ns = t0.elapsed().as_nanos();
1901
1902    let phase1_baseline_recall =
1903        measure_recall_direct(&mut store_control, &queries[..recall_sample], top_k);
1904
1905    // ── Phase 2 (Learning) ──────────────────────────────────────────────
1906    // Train the EML manager on a query stream. The adaptive store starts
1907    // at the same ef as control; EML may change it.
1908
1909    let eml_config = HnswEmlConfig {
1910        enabled: true,
1911        train_every_n: train_every_n as u64,
1912        recall_check_every_n: (train_every_n.saturating_mul(2).max(1)) as u64,
1913        min_training_samples,
1914        distance_selected_dims: dims.min(16),
1915        ef_strategy: EfStrategy::Score,
1916        target_recall: 0.95,
1917        max_ef_boost: 2.5,
1918        latency_weight: 0.3,
1919        ..Default::default()
1920    };
1921    let mut store_adaptive = build_store(&corpus, static_ef);
1922    let mut eml = HnswEmlManager::new(eml_config);
1923
1924    let phase2_pre_train_recall =
1925        measure_recall_direct(&mut store_adaptive, &queries[..recall_sample], top_k);
1926
1927    let mut phase2_queries_run = 0usize;
1928    let learning_passes = learning_passes.max(1);
1929    for pass in 0..learning_passes {
1930        for (qi, q) in queries.iter().enumerate() {
1931            let t = std::time::Instant::now();
1932            let results = store_adaptive.query(q, top_k);
1933            let elapsed_us = t.elapsed().as_micros() as u64;
1934            let top_score = results.first().map(|r| r.score).unwrap_or(0.0);
1935            eml.record_search(
1936                q,
1937                results.len(),
1938                top_score,
1939                store_adaptive.ef_search(),
1940                elapsed_us,
1941                store_adaptive.len(),
1942            );
1943            phase2_queries_run += 1;
1944
1945            if (pass * queries.len() + qi) % recall_check_every == recall_check_every - 1 {
1946                let sample_n = queries.len().min(8);
1947                let hnsw_ids: Vec<Vec<String>> = queries[..sample_n]
1948                    .iter()
1949                    .map(|qq| {
1950                        store_adaptive
1951                            .query(qq, top_k)
1952                            .into_iter()
1953                            .map(|r| r.id)
1954                            .collect()
1955                    })
1956                    .collect();
1957                let exact_ids: Vec<Vec<String>> = queries[..sample_n]
1958                    .iter()
1959                    .map(|qq| store_adaptive.brute_force_topk(qq, top_k))
1960                    .collect();
1961                eml.measure_recall(
1962                    &hnsw_ids,
1963                    &exact_ids,
1964                    store_adaptive.len(),
1965                    store_adaptive.inserts_since_rebuild(),
1966                    0,
1967                );
1968            }
1969        }
1970    }
1971
1972    // Apply learned ef.
1973    let ef_pred = eml.predict_ef(&queries[0], store_adaptive.len());
1974    if ef_pred.is_learned {
1975        store_adaptive.set_ef_search(ef_pred.recommended_ef);
1976    }
1977
1978    let phase2_post_train_recall =
1979        measure_recall_direct(&mut store_adaptive, &queries[..recall_sample], top_k);
1980    let phase2_recall_delta = phase2_post_train_recall - phase2_pre_train_recall;
1981    let phase2_eml_train_cycles = eml.train_cycles;
1982
1983    // ── Phase 3 (Compute) — three-arm comparison ────────────────────────
1984    //
1985    // Control:  bare HNSW, same ef, no EML in path.
1986    // Overhead: same ef, but run EML predict+record on each query (don't
1987    //           apply predictions). Isolates EML CPU cost.
1988    // Adaptive: EML-chosen ef, same predict+record calls.
1989
1990    let phase3_control = measure_latency(&mut store_control, &queries, top_k);
1991
1992    // Overhead arm: clone the control store, run queries WITH EML calls
1993    // but don't change ef.
1994    let mut store_overhead = build_store(&corpus, static_ef);
1995    {
1996        let eml_overhead = HnswEmlManager::new(HnswEmlConfig {
1997            enabled: true,
1998            ..Default::default()
1999        });
2000        // Warm up EML prediction path without applying.
2001        for q in &queries {
2002            let _pred = eml_overhead.predict_ef(q, store_overhead.len());
2003            let _rebuild = eml_overhead.predict_rebuild(store_overhead.len(), 0, 0);
2004        }
2005    }
2006    let phase3_overhead = measure_latency(&mut store_overhead, &queries, top_k);
2007
2008    let phase3_adaptive = measure_latency(&mut store_adaptive, &queries, top_k);
2009
2010    // Tiered arm: probe corpus → triage (tree calculus) → EML params → build.
2011    let probe = probe_corpus(&corpus, dims, top_k);
2012    let triage = triage_strategy(&probe);
2013    let entries: Vec<(String, Vec<f32>, serde_json::Value)> = corpus
2014        .iter()
2015        .enumerate()
2016        .map(|(i, v)| (format!("v{i}"), v.clone(), serde_json::json!({})))
2017        .collect();
2018    let mut tiered = match &triage.strategy {
2019        SearchStrategy::Tiered {
2020            dim_order,
2021            coarse_dims,
2022            medium_dims,
2023            ef,
2024            ..
2025        } => TieredSearch::build(
2026            &entries,
2027            dim_order[..*coarse_dims].to_vec(),
2028            dim_order[..*medium_dims].to_vec(),
2029            top_k,
2030            *ef,
2031        ),
2032        SearchStrategy::Flat { ef } => TieredSearch::build_default(&entries, dims, top_k, *ef),
2033    };
2034    let phase3_tiered = {
2035        let mut lats = Vec::with_capacity(queries.len() * 4);
2036        for _ in 0..4 {
2037            for q in &queries {
2038                let t = std::time::Instant::now();
2039                let _ = tiered.search(q, top_k);
2040                lats.push(t.elapsed().as_nanos());
2041            }
2042        }
2043        lats.sort_unstable();
2044        let mean = lats.iter().sum::<u128>() / lats.len().max(1) as u128;
2045        let p99 = lats[(lats.len() * 99) / 100];
2046        let recall = {
2047            let q_sample = &queries[..queries.len().min(16)];
2048            let mut total = 0.0;
2049            for q in q_sample {
2050                let tiered_ids: Vec<String> =
2051                    tiered.search(q, top_k).into_iter().map(|r| r.id).collect();
2052                let exact_ids = tiered.brute_force_topk(q, top_k);
2053                let found = exact_ids
2054                    .iter()
2055                    .filter(|id| tiered_ids.contains(id))
2056                    .count();
2057                total += found as f64 / exact_ids.len().max(1) as f64;
2058            }
2059            total / q_sample.len() as f64
2060        };
2061        ArmMetrics {
2062            recall,
2063            mean_ns: mean,
2064            p99_ns: p99,
2065        }
2066    };
2067
2068    // ── Phase 4 (Scalability) ───────────────────────────────────────────
2069
2070    let mut phase4_scaling = Vec::new();
2071    if phase4 {
2072        let sweep_sizes = [100, 500, 1000, 2000, 5000usize];
2073        // For smoke-sized stores, still emit one scaling point at store_size.
2074        let sizes: Vec<usize> = if store_size < sweep_sizes[0] {
2075            vec![store_size]
2076        } else {
2077            sweep_sizes
2078                .into_iter()
2079                .filter(|&sz| sz <= store_size)
2080                .collect()
2081        };
2082        for sz in sizes {
2083            let subset = &corpus[..sz.min(corpus.len())];
2084
2085            let mut s_control = build_store(subset, static_ef);
2086            let control_m =
2087                measure_latency(&mut s_control, &queries[..queries.len().min(16)], top_k);
2088
2089            let adaptive_ef = ef_pred.recommended_ef.max(10);
2090            let mut s_adaptive = build_store(subset, adaptive_ef);
2091            let adaptive_m =
2092                measure_latency(&mut s_adaptive, &queries[..queries.len().min(16)], top_k);
2093
2094            phase4_scaling.push(HnswScalingPoint {
2095                store_size: sz,
2096                control_recall: control_m.recall,
2097                adaptive_recall: adaptive_m.recall,
2098                control_mean_ns: control_m.mean_ns,
2099                adaptive_mean_ns: adaptive_m.mean_ns,
2100            });
2101        }
2102    }
2103
2104    HnswEmlBenchmark {
2105        dimensions: dims,
2106        store_size,
2107        top_k,
2108        static_ef,
2109        phase1_build_ns,
2110        phase1_baseline_recall,
2111        phase1_warmup_query_ns,
2112        phase2_pre_train_recall,
2113        phase2_post_train_recall,
2114        phase2_recall_delta,
2115        phase2_eml_train_cycles,
2116        phase2_queries_run,
2117        phase3_control,
2118        phase3_overhead,
2119        phase3_adaptive,
2120        phase3_tiered,
2121        phase4_scaling,
2122    }
2123}
2124
2125// ---------------------------------------------------------------------------
2126// Tests
2127// ---------------------------------------------------------------------------
2128
2129#[cfg(test)]
2130mod tests {
2131    use super::*;
2132
2133    fn make_manager() -> HnswEmlManager {
2134        HnswEmlManager::with_defaults()
2135    }
2136
2137    // -- Construction & defaults --
2138
2139    #[test]
2140    fn new_manager_defaults() {
2141        let m = make_manager();
2142        assert!(m.is_enabled());
2143        assert_eq!(m.total_searches, 0);
2144        assert_eq!(m.train_cycles, 0);
2145        assert!(m.last_recall.is_none());
2146    }
2147
2148    #[test]
2149    fn config_default_values() {
2150        let cfg = HnswEmlConfig::default();
2151        assert!(cfg.enabled);
2152        assert_eq!(cfg.train_every_n, 1000);
2153        assert_eq!(cfg.recall_check_every_n, 5000);
2154        assert_eq!(cfg.min_training_samples, 200);
2155        assert_eq!(cfg.distance_selected_dims, 16);
2156    }
2157
2158    #[test]
2159    fn status_initial() {
2160        let m = make_manager();
2161        let s = m.status();
2162        assert!(s.enabled);
2163        assert!(!s.distance_trained);
2164        assert!(!s.ef_trained);
2165        assert!(!s.path_trained);
2166        assert!(!s.rebuild_trained);
2167        assert_eq!(s.total_searches, 0);
2168        assert_eq!(s.train_cycles, 0);
2169        assert!(s.last_recall.is_none());
2170    }
2171
2172    // -- Training data collection --
2173
2174    #[test]
2175    fn record_search_increments_count() {
2176        let mut m = make_manager();
2177        m.record_search(&[1.0, 0.0, 0.0], 3, 0.95, 100, 500, 1000);
2178        assert_eq!(m.total_searches, 1);
2179        assert_eq!(m.searches_since_train, 1);
2180        assert_eq!(m.ef_training.len(), 1);
2181        assert_eq!(m.path_training.len(), 1);
2182    }
2183
2184    #[test]
2185    fn record_search_disabled_does_nothing() {
2186        let mut m = HnswEmlManager::new(HnswEmlConfig {
2187            enabled: false,
2188            ..Default::default()
2189        });
2190        m.record_search(&[1.0, 0.0], 1, 0.5, 100, 100, 50);
2191        assert_eq!(m.total_searches, 0);
2192        assert_eq!(m.ef_training.len(), 0);
2193    }
2194
2195    #[test]
2196    fn record_multiple_searches() {
2197        let mut m = make_manager();
2198        for i in 0..10 {
2199            m.record_search(
2200                &[i as f32 / 10.0, 1.0 - i as f32 / 10.0, 0.5],
2201                2,
2202                0.8,
2203                100,
2204                (200 + i * 10) as u64,
2205                500,
2206            );
2207        }
2208        assert_eq!(m.total_searches, 10);
2209        assert_eq!(m.ef_training.len(), 10);
2210        assert_eq!(m.path_training.len(), 10);
2211    }
2212
2213    // -- Recall measurement --
2214
2215    #[test]
2216    fn measure_recall_perfect() {
2217        let mut m = make_manager();
2218        let hnsw = vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]];
2219        let exact = vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]];
2220        let recall = m.measure_recall(&hnsw, &exact, 100, 10, 0);
2221        assert!((recall - 1.0).abs() < 1e-9);
2222        assert_eq!(m.last_recall, Some(1.0));
2223        assert_eq!(m.rebuild_training.len(), 1);
2224    }
2225
2226    #[test]
2227    fn measure_recall_partial() {
2228        let mut m = make_manager();
2229        let hnsw = vec![vec!["a".to_string(), "d".to_string()]];
2230        let exact = vec![vec!["a".to_string(), "b".to_string()]];
2231        let recall = m.measure_recall(&hnsw, &exact, 100, 5, 2);
2232        assert!((recall - 0.5).abs() < 1e-9);
2233    }
2234
2235    #[test]
2236    fn measure_recall_empty() {
2237        let mut m = make_manager();
2238        let recall = m.measure_recall(&[], &[], 100, 0, 0);
2239        assert!((recall - 0.0).abs() < 1e-9);
2240    }
2241
2242    // -- Distance pair recording --
2243
2244    #[test]
2245    fn record_distance_pair_stores_data() {
2246        let mut m = make_manager();
2247        m.record_distance_pair(&[1.0, 0.0, 0.0], &[0.9, 0.1, 0.0], &[1.0, 0.0, 0.0]);
2248        assert_eq!(m.distance_training.len(), 1);
2249    }
2250
2251    // -- Predictions (untrained) --
2252
2253    #[test]
2254    fn predict_ef_untrained_returns_default() {
2255        let m = make_manager();
2256        let pred = m.predict_ef(&[1.0, 0.0, 0.0], 500);
2257        assert_eq!(pred.recommended_ef, 100);
2258        assert!(!pred.is_learned);
2259    }
2260
2261    #[test]
2262    fn predict_rebuild_untrained_heuristic() {
2263        let m = make_manager();
2264        // 10 inserts out of 100 = 10% => borderline
2265        let pred = m.predict_rebuild(100, 10, 0);
2266        assert!(!pred.is_learned);
2267        assert!(pred.predicted_recall > 0.0);
2268    }
2269
2270    #[test]
2271    fn predict_rebuild_high_mutation_ratio() {
2272        let m = make_manager();
2273        // 50 inserts + 50 deletes out of 100 = 100% mutation
2274        let pred = m.predict_rebuild(100, 50, 50);
2275        assert!(pred.should_rebuild);
2276        assert!(!pred.is_learned);
2277    }
2278
2279    // -- Training --
2280
2281    #[test]
2282    fn train_all_insufficient_data_returns_false() {
2283        let mut m = make_manager();
2284        // Add fewer samples than min_training_samples
2285        for i in 0..10 {
2286            m.record_search(&[i as f32 / 10.0, 0.5, 0.5], 2, 0.8, 100, 500, 100);
2287        }
2288        let result = m.train_all();
2289        assert!(!result);
2290    }
2291
2292    #[test]
2293    fn train_all_with_sufficient_ef_data() {
2294        let mut m = HnswEmlManager::new(HnswEmlConfig {
2295            min_training_samples: 50,
2296            train_every_n: 100_000, // don't auto-train during recording
2297            ..Default::default()
2298        });
2299
2300        // Generate enough ef training data
2301        for i in 0..60 {
2302            let q = vec![
2303                (i as f32 * 0.1).sin(),
2304                (i as f32 * 0.2).cos(),
2305                i as f32 / 60.0,
2306            ];
2307            m.record_search(&q, 5, 0.9, 100 + i, (500 + i * 10) as u64, 1000);
2308        }
2309
2310        // train_all should attempt training (may or may not converge)
2311        let _ = m.train_all();
2312        assert_eq!(m.searches_since_train, 0); // reset after train
2313    }
2314
2315    // -- Reset --
2316
2317    #[test]
2318    fn reset_clears_everything() {
2319        let mut m = make_manager();
2320        for i in 0..5 {
2321            m.record_search(&[i as f32, 0.0, 1.0], 1, 0.5, 100, 100, 50);
2322        }
2323        m.last_recall = Some(0.95);
2324        m.train_cycles = 3;
2325
2326        m.reset();
2327
2328        assert_eq!(m.total_searches, 0);
2329        assert_eq!(m.train_cycles, 0);
2330        assert!(m.last_recall.is_none());
2331        assert!(m.ef_training.is_empty());
2332        assert!(m.path_training.is_empty());
2333        assert!(m.rebuild_training.is_empty());
2334        assert!(m.distance_training.is_empty());
2335        assert!(m.recent_search_times_us.is_empty());
2336        assert!(!m.ef_model.is_trained());
2337    }
2338
2339    // -- Helper function tests --
2340
2341    #[test]
2342    fn vector_norm_unit() {
2343        let norm = vector_norm(&[1.0, 0.0, 0.0]);
2344        assert!((norm - 1.0).abs() < 1e-9);
2345    }
2346
2347    #[test]
2348    fn vector_norm_pythagorean() {
2349        let norm = vector_norm(&[3.0, 4.0]);
2350        assert!((norm - 5.0).abs() < 1e-9);
2351    }
2352
2353    #[test]
2354    fn vector_norm_empty() {
2355        let norm = vector_norm(&[]);
2356        assert!((norm - 0.0).abs() < 1e-9);
2357    }
2358
2359    #[test]
2360    fn vector_variance_uniform() {
2361        let var = vector_variance(&[5.0, 5.0, 5.0]);
2362        assert!(var.abs() < 1e-9);
2363    }
2364
2365    #[test]
2366    fn vector_variance_known() {
2367        // [1, 3] => mean=2, var=((1-2)^2 + (3-2)^2)/2 = 1.0
2368        let var = vector_variance(&[1.0, 3.0]);
2369        assert!((var - 1.0).abs() < 1e-9);
2370    }
2371
2372    #[test]
2373    fn vector_variance_empty() {
2374        let var = vector_variance(&[]);
2375        assert!((var - 0.0).abs() < 1e-9);
2376    }
2377
2378    #[test]
2379    fn compute_recall_all_found() {
2380        let hnsw = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2381        let exact = vec!["a".to_string(), "b".to_string()];
2382        let recall = compute_recall(&hnsw, &exact);
2383        assert!((recall - 1.0).abs() < 1e-9);
2384    }
2385
2386    #[test]
2387    fn compute_recall_none_found() {
2388        let hnsw = vec!["x".to_string(), "y".to_string()];
2389        let exact = vec!["a".to_string(), "b".to_string()];
2390        let recall = compute_recall(&hnsw, &exact);
2391        assert!((recall - 0.0).abs() < 1e-9);
2392    }
2393
2394    #[test]
2395    fn compute_recall_empty_exact() {
2396        let hnsw = vec!["a".to_string()];
2397        let exact: Vec<String> = vec![];
2398        let recall = compute_recall(&hnsw, &exact);
2399        assert!((recall - 1.0).abs() < 1e-9);
2400    }
2401
2402    #[test]
2403    fn cosine_similarity_identical() {
2404        let score = cosine_similarity_f32(&[1.0, 0.0], &[1.0, 0.0]);
2405        assert!((score - 1.0).abs() < 0.01);
2406    }
2407
2408    #[test]
2409    fn cosine_similarity_orthogonal() {
2410        let score = cosine_similarity_f32(&[1.0, 0.0], &[0.0, 1.0]);
2411        assert!(score.abs() < 0.01);
2412    }
2413
2414    #[test]
2415    fn cosine_similarity_different_lengths() {
2416        let score = cosine_similarity_f32(&[1.0], &[1.0, 0.0]);
2417        assert!((score - 0.0).abs() < f32::EPSILON);
2418    }
2419
2420    // -- Config serde roundtrip --
2421
2422    #[test]
2423    fn config_serde_roundtrip() {
2424        let cfg = HnswEmlConfig::default();
2425        let json = serde_json::to_string(&cfg).unwrap();
2426        let restored: HnswEmlConfig = serde_json::from_str(&json).unwrap();
2427        assert_eq!(restored.enabled, cfg.enabled);
2428        assert_eq!(restored.train_every_n, cfg.train_every_n);
2429        assert_eq!(restored.recall_check_every_n, cfg.recall_check_every_n);
2430        assert_eq!(restored.min_training_samples, cfg.min_training_samples);
2431        assert_eq!(restored.distance_selected_dims, cfg.distance_selected_dims);
2432    }
2433
2434    // -- Status serde roundtrip --
2435
2436    #[test]
2437    fn status_serde_roundtrip() {
2438        let m = make_manager();
2439        let s = m.status();
2440        let json = serde_json::to_string(&s).unwrap();
2441        let restored: HnswEmlStatus = serde_json::from_str(&json).unwrap();
2442        assert_eq!(restored.enabled, s.enabled);
2443        assert_eq!(restored.total_searches, s.total_searches);
2444    }
2445
2446    // -- Debug impl --
2447
2448    #[test]
2449    fn debug_format_does_not_panic() {
2450        let m = make_manager();
2451        let _ = format!("{:?}", m);
2452    }
2453
2454    // -- Auto-train trigger --
2455
2456    #[test]
2457    fn auto_train_triggers_at_threshold() {
2458        let mut m = HnswEmlManager::new(HnswEmlConfig {
2459            train_every_n: 5,
2460            min_training_samples: 200, // won't converge, but train_all runs
2461            ..Default::default()
2462        });
2463
2464        for i in 0..5 {
2465            m.record_search(&[i as f32, 0.0, 1.0], 1, 0.5, 100, 100, 50);
2466        }
2467        // After 5 searches (== train_every_n), searches_since_train resets.
2468        assert_eq!(m.searches_since_train, 0);
2469    }
2470
2471    // -- Integration: full lifecycle --
2472
2473    #[test]
2474    fn full_lifecycle_no_panics() {
2475        let mut m = HnswEmlManager::new(HnswEmlConfig {
2476            min_training_samples: 10,
2477            train_every_n: 100_000,
2478            ..Default::default()
2479        });
2480
2481        // Record searches
2482        for i in 0..20 {
2483            let q = vec![
2484                (i as f32 * 0.3).sin(),
2485                (i as f32 * 0.7).cos(),
2486                i as f32 / 20.0,
2487            ];
2488            m.record_search(&q, 3, 0.85, 100, 300 + i * 5, 500);
2489        }
2490
2491        // Record distance pairs
2492        for _ in 0..15 {
2493            m.record_distance_pair(&[1.0, 0.0, 0.0], &[0.9, 0.1, 0.0], &[1.0, 0.0, 0.0]);
2494        }
2495
2496        // Measure recall
2497        let hnsw = vec![vec!["a".into(), "b".into()]];
2498        let exact = vec![vec!["a".into(), "c".into()]];
2499        let recall = m.measure_recall(&hnsw, &exact, 500, 20, 5);
2500        assert!((0.0..=1.0).contains(&recall));
2501
2502        // Predict (untrained)
2503        let ef_pred = m.predict_ef(&[1.0, 0.0, 0.0], 500);
2504        assert!(ef_pred.recommended_ef > 0);
2505
2506        let rebuild_pred = m.predict_rebuild(500, 20, 5);
2507        assert!(rebuild_pred.predicted_recall >= 0.0);
2508
2509        // Train
2510        let _ = m.train_all();
2511
2512        // Status
2513        let s = m.status();
2514        assert!(s.total_searches > 0);
2515
2516        // Reset
2517        m.reset();
2518        let s2 = m.status();
2519        assert_eq!(s2.total_searches, 0);
2520    }
2521
2522    // -- 4-Phase benchmark --
2523    //
2524    // WEFT-134: full-scale params (20×128 learning queries with train_every_n=100)
2525    // spend minutes inside EmlModel::train and hung the aggregate --lib suite.
2526    // Default unit coverage uses HnswBenchmarkParams::smoke(); full protocol
2527    // stays available via --ignored.
2528
2529    #[test]
2530    fn benchmark_4_phase_smoke() {
2531        let bench = run_hnsw_benchmark_with(HnswBenchmarkParams::smoke());
2532
2533        assert!(bench.phase1_build_ns > 0);
2534        assert!(bench.phase1_baseline_recall >= 0.0);
2535        assert!(bench.phase2_queries_run > 0);
2536        assert_eq!(bench.phase2_queries_run, 8); // 1 pass × 8 queries
2537        assert!(bench.phase3_control.mean_ns > 0);
2538        assert!(bench.phase3_overhead.mean_ns > 0);
2539        assert!(bench.phase3_adaptive.mean_ns > 0);
2540        assert!(!bench.phase4_scaling.is_empty());
2541    }
2542
2543    #[test]
2544    fn benchmark_produces_json() {
2545        let bench = run_hnsw_benchmark_with(HnswBenchmarkParams::smoke());
2546        let json = serde_json::to_string_pretty(&bench).unwrap();
2547        assert!(json.contains("phase1_baseline_recall"));
2548        assert!(json.contains("phase3_control"));
2549        assert!(json.contains("phase3_overhead"));
2550        assert!(json.contains("phase3_adaptive"));
2551        assert!(json.contains("phase4_scaling"));
2552    }
2553
2554    // -- Path prediction (WEFT-385 / HNSW-EML #4) --
2555
2556    #[test]
2557    fn path_table_rebuild_and_predict() {
2558        let mut m = make_manager();
2559        assert!(!m.has_path_table());
2560
2561        let entries: Vec<(String, Vec<f32>)> = (0..30)
2562            .map(|i| {
2563                let cluster = i % 3;
2564                let mut v = vec![0.05 * (i as f32); 8];
2565                v[cluster] = 1.0;
2566                (format!("e{i}"), v)
2567            })
2568            .collect();
2569        m.rebuild_path_table(&entries);
2570        assert!(m.has_path_table());
2571
2572        let mut q = vec![0.0; 8];
2573        q[0] = 1.0;
2574        let pred = m.predict_path(&q, entries.len());
2575        assert!(pred.is_learned);
2576        assert!(pred.region_id.is_some());
2577        assert!(!pred.entry_ids.is_empty());
2578        assert!(!pred.candidate_ids.is_empty());
2579    }
2580
2581    #[test]
2582    fn path_predict_disabled_returns_unlearned() {
2583        let mut m = HnswEmlManager::new(HnswEmlConfig {
2584            path_predict_enabled: false,
2585            ..Default::default()
2586        });
2587        let entries = vec![("a".into(), vec![1.0, 0.0]), ("b".into(), vec![0.0, 1.0])];
2588        m.rebuild_path_table(&entries);
2589        assert!(!m.has_path_table());
2590        let pred = m.predict_path(&[1.0, 0.0], 2);
2591        assert!(!pred.is_learned);
2592    }
2593
2594    #[test]
2595    fn record_guided_search_tracks_region() {
2596        let mut m = make_manager();
2597        let entries: Vec<(String, Vec<f32>)> = (0..20)
2598            .map(|i| (format!("n{i}"), vec![i as f32, 0.0, 1.0]))
2599            .collect();
2600        m.rebuild_path_table(&entries);
2601        let pred = m.predict_path(&[1.0, 0.0, 1.0], 20);
2602        m.record_guided_search(GuidedSearch {
2603            query: &[1.0, 0.0, 1.0],
2604            result_count: 3,
2605            top_score: 0.9,
2606            ef_used: 50,
2607            search_time_us: 100,
2608            store_size: 20,
2609            prediction: &pred,
2610            candidate_pool_size: pred.candidate_ids.len(),
2611        });
2612        assert_eq!(m.total_searches, 1);
2613        assert!(m.path_training[0].guided);
2614        assert_eq!(m.path_training[0].region_id, pred.region_id);
2615    }
2616
2617    #[test]
2618    fn path_predict_benchmark_smoke() {
2619        let bench = run_path_predict_benchmark(120, 16, 5, 16);
2620        assert_eq!(bench.store_size, 120);
2621        assert!(bench.default_mean_ns > 0);
2622        assert!(bench.guided_mean_ns > 0);
2623        assert!(bench.default_recall >= 0.0);
2624        assert!(bench.guided_recall >= 0.0);
2625        // Guided should not collapse recall on structured data.
2626        assert!(
2627            bench.guided_recall + 0.15 >= bench.default_recall,
2628            "guided recall {} much worse than default {}",
2629            bench.guided_recall,
2630            bench.default_recall
2631        );
2632        assert!(bench.entry_hit_rate > 0.0);
2633        let json = serde_json::to_string(&bench).unwrap();
2634        assert!(json.contains("guided_mean_ns"));
2635        assert!(json.contains("speedup"));
2636    }
2637
2638    /// Full-scale 500-vector protocol. Multi-minute EML train; not for CI.
2639    #[test]
2640    #[ignore = "WEFT-134: full-scale HNSW-EML bench (multi-minute EmlModel::train); run with --ignored"]
2641    fn benchmark_4_phase_runs() {
2642        let bench = run_hnsw_benchmark(500, 32, 10);
2643
2644        assert!(bench.phase1_build_ns > 0);
2645        assert!(bench.phase1_baseline_recall >= 0.0);
2646        assert!(bench.phase2_queries_run > 0);
2647        assert!(bench.phase3_control.mean_ns > 0);
2648        assert!(bench.phase3_overhead.mean_ns > 0);
2649        assert!(bench.phase3_adaptive.mean_ns > 0);
2650        assert!(!bench.phase4_scaling.is_empty());
2651    }
2652
2653    /// Full-scale 5k-vector report. Multi-minute; not for CI.
2654    #[test]
2655    #[ignore = "WEFT-134: full-scale HNSW-EML report (5000×128); run with --ignored"]
2656    fn benchmark_full_report() {
2657        let bench = run_hnsw_benchmark(5000, 128, 10);
2658        let c = &bench.phase3_control;
2659        let o = &bench.phase3_overhead;
2660        let a = &bench.phase3_adaptive;
2661        let t = &bench.phase3_tiered;
2662        let overhead_pct = if c.mean_ns > 0 {
2663            ((o.mean_ns as f64 / c.mean_ns as f64) - 1.0) * 100.0
2664        } else {
2665            0.0
2666        };
2667        let tiered_speedup = if c.mean_ns > 0 {
2668            c.mean_ns as f64 / t.mean_ns as f64
2669        } else {
2670            1.0
2671        };
2672
2673        eprintln!("\n══════════════════════════════════════════════════════════════════");
2674        eprintln!("  HNSW-EML 4-Phase A/B Benchmark");
2675        eprintln!(
2676            "  {} vecs, {} dims, k={}, static ef={}",
2677            bench.store_size, bench.dimensions, bench.top_k, bench.static_ef
2678        );
2679        eprintln!("══════════════════════════════════════════════════════════════════");
2680        eprintln!("Phase 1 — Warmup");
2681        eprintln!(
2682            "  Build time:          {:>10} µs",
2683            bench.phase1_build_ns / 1000
2684        );
2685        eprintln!(
2686            "  Warmup query:        {:>10} µs",
2687            bench.phase1_warmup_query_ns / 1000
2688        );
2689        eprintln!(
2690            "  Baseline recall@10:  {:>10.4}",
2691            bench.phase1_baseline_recall
2692        );
2693        eprintln!("──────────────────────────────────────────────────────────────────");
2694        eprintln!("Phase 2 — Learning (Score strategy, λ=0.3)");
2695        eprintln!("  Queries run:         {:>10}", bench.phase2_queries_run);
2696        eprintln!(
2697            "  EML train cycles:    {:>10}",
2698            bench.phase2_eml_train_cycles
2699        );
2700        eprintln!(
2701            "  Pre-train recall:    {:>10.4}",
2702            bench.phase2_pre_train_recall
2703        );
2704        eprintln!(
2705            "  Post-train recall:   {:>10.4}",
2706            bench.phase2_post_train_recall
2707        );
2708        eprintln!(
2709            "  Recall delta:        {:>+10.4}",
2710            bench.phase2_recall_delta
2711        );
2712        eprintln!("──────────────────────────────────────────────────────────────────");
2713        let probe = probe_corpus(
2714            &gen_structured_corpus(&mut 0xDEAD_BEEF_u64, bench.store_size, bench.dimensions, 20),
2715            bench.dimensions,
2716            bench.top_k,
2717        );
2718        let triage = triage_strategy(&probe);
2719        eprintln!("Triage — tree calculus + EML");
2720        eprintln!("  Form:                {:?}", triage.form);
2721        eprintln!("  Steepness:           {:>10.2}", triage.steepness);
2722        eprintln!(
2723            "  Concentration:       {:>10.1}%",
2724            triage.concentration * 100.0
2725        );
2726        eprintln!("  Knee (80% var):      {:>10} dims", triage.knee);
2727        match &triage.strategy {
2728            SearchStrategy::Flat { ef } => eprintln!("  → Strategy:          Flat (ef={})", ef),
2729            SearchStrategy::Tiered {
2730                coarse_dims,
2731                coarse_keep,
2732                medium_dims,
2733                medium_keep,
2734                ef,
2735                ..
2736            } => eprintln!(
2737                "  → Strategy:          Tiered (coarse={}d keep={}, medium={}d keep={}, ef={})",
2738                coarse_dims, coarse_keep, medium_dims, medium_keep, ef
2739            ),
2740        }
2741        eprintln!("──────────────────────────────────────────────────────────────────");
2742        eprintln!("Phase 3 — Compute (4-arm A/B)");
2743        eprintln!();
2744        eprintln!("                          recall@10    mean (ns)    p99 (ns)");
2745        eprintln!(
2746            "  Control (flat HNSW):     {:.4}    {:>10}    {:>10}",
2747            c.recall, c.mean_ns, c.p99_ns
2748        );
2749        eprintln!(
2750            "  Overhead (EML nop):      {:.4}    {:>10}    {:>10}",
2751            o.recall, o.mean_ns, o.p99_ns
2752        );
2753        eprintln!(
2754            "  Adaptive ef (EML):       {:.4}    {:>10}    {:>10}",
2755            a.recall, a.mean_ns, a.p99_ns
2756        );
2757        eprintln!(
2758            "  Tiered (coarse→fine):    {:.4}    {:>10}    {:>10}",
2759            t.recall, t.mean_ns, t.p99_ns
2760        );
2761        eprintln!();
2762        eprintln!("  EML overhead:        {:>+.1}% mean latency", overhead_pct);
2763        eprintln!(
2764            "  Tiered vs control:   {:>.2}× speed, {:>+.4} recall",
2765            tiered_speedup,
2766            t.recall - c.recall
2767        );
2768        eprintln!("──────────────────────────────────────────────────────────────────");
2769        eprintln!("Phase 4 — Scalability");
2770        eprintln!();
2771        eprintln!("  N        control              adaptive");
2772        eprintln!("           recall   mean(ns)    recall   mean(ns)");
2773        for pt in &bench.phase4_scaling {
2774            eprintln!(
2775                "  {:<5}    {:.4}   {:>9}    {:.4}   {:>9}",
2776                pt.store_size,
2777                pt.control_recall,
2778                pt.control_mean_ns,
2779                pt.adaptive_recall,
2780                pt.adaptive_mean_ns,
2781            );
2782        }
2783        eprintln!("══════════════════════════════════════════════════════════════════\n");
2784
2785        assert!(bench.phase2_queries_run > 0);
2786        assert!(bench.phase3_control.mean_ns > 0);
2787        assert!(!bench.phase4_scaling.is_empty());
2788    }
2789}