1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HnswEmlConfig {
38 pub enabled: bool,
40 pub train_every_n: u64,
42 pub recall_check_every_n: u64,
44 pub min_training_samples: usize,
46 pub distance_selected_dims: usize,
48 pub ef_strategy: EfStrategy,
50 pub target_recall: f64,
52 pub max_ef_boost: f64,
54 pub latency_weight: f64,
56 pub path_predict_enabled: bool,
58 pub path_num_regions: usize,
60 pub path_region_probes: usize,
62 pub path_entries_per_region: usize,
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
68pub enum EfStrategy {
69 Threshold,
71 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#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct EfTrainingPoint {
107 pub query_norm: f64,
109 pub query_variance: f64,
111 pub ef_used: usize,
113 pub result_count: usize,
115 pub search_time_us: u64,
117 pub recall: Option<f64>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct DistanceTrainingPoint {
127 pub dim_contributions: Vec<f64>,
129 pub exact_similarity: f64,
131 pub approx_similarity: f64,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PathTrainingPoint {
141 pub query_norm: f64,
143 pub query_variance: f64,
145 pub hops: usize,
147 pub top_score: f64,
149 pub store_size: usize,
151 pub region_id: Option<usize>,
153 pub region_score: f64,
155 pub guided: bool,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct RebuildTrainingPoint {
165 pub store_size: usize,
167 pub inserts_since_rebuild: usize,
169 pub deletes_since_rebuild: usize,
171 pub recall: f64,
173 pub avg_search_time_us: f64,
175}
176
177#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct HnswEmlStatus {
248 pub enabled: bool,
250 pub distance_trained: bool,
252 pub distance_samples: usize,
254 pub ef_trained: bool,
256 pub ef_samples: usize,
258 pub path_trained: bool,
260 pub path_samples: usize,
262 pub rebuild_trained: bool,
264 pub rebuild_samples: usize,
266 pub searches_since_train: u64,
268 pub total_searches: u64,
270 pub train_cycles: u64,
272 pub last_recall: Option<f64>,
274}
275
276#[derive(Debug, Clone)]
282pub struct EfPrediction {
283 pub recommended_ef: usize,
285 pub is_learned: bool,
287}
288
289#[derive(Debug, Clone)]
291pub struct RebuildPrediction {
292 pub predicted_recall: f64,
294 pub should_rebuild: bool,
296 pub is_learned: bool,
298}
299
300#[derive(Debug, Clone)]
302pub struct PathPrediction {
303 pub region_id: Option<usize>,
305 pub entry_ids: Vec<String>,
307 pub region_score: f32,
309 pub candidate_ids: Vec<String>,
311 pub is_learned: bool,
313 pub eml_path_quality: Option<f64>,
315}
316
317#[derive(Debug, Clone, Copy)]
320pub struct GuidedSearch<'a> {
321 pub query: &'a [f32],
323 pub result_count: usize,
325 pub top_score: f32,
327 pub ef_used: usize,
329 pub search_time_us: u64,
331 pub store_size: usize,
333 pub prediction: &'a PathPrediction,
335 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
352pub struct HnswEmlManager {
361 config: HnswEmlConfig,
363 distance_model: eml_core::EmlModel,
365 ef_model: eml_core::EmlModel,
367 path_model: eml_core::EmlModel,
369 rebuild_model: eml_core::EmlModel,
371 path_table: Option<RegionEntryTable>,
373 distance_training: Vec<DistanceTrainingPoint>,
375 ef_training: Vec<EfTrainingPoint>,
376 path_training: Vec<PathTrainingPoint>,
377 rebuild_training: Vec<RebuildTrainingPoint>,
378 searches_since_train: u64,
380 total_searches: u64,
382 train_cycles: u64,
384 last_recall: Option<f64>,
386 recent_search_times_us: Vec<u64>,
388}
389
390impl HnswEmlManager {
391 pub fn new(config: HnswEmlConfig) -> Self {
393 Self {
394 config,
395 distance_model: eml_core::EmlModel::new(3, 4, 1),
396 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 pub fn with_defaults() -> Self {
416 Self::new(HnswEmlConfig::default())
417 }
418
419 pub fn is_enabled(&self) -> bool {
421 self.config.enabled
422 }
423
424 pub fn config(&self) -> &HnswEmlConfig {
426 &self.config
427 }
428
429 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 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 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 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 if self.searches_since_train >= self.config.train_every_n {
486 self.train_all();
487 }
488 }
489
490 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 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 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 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 pub fn path_table(&self) -> Option<&RegionEntryTable> {
577 self.path_table.as_ref()
578 }
579
580 pub fn path_region_probes(&self) -> usize {
582 self.config.path_region_probes
583 }
584
585 pub fn path_predict_enabled(&self) -> bool {
587 self.config.path_predict_enabled && self.config.enabled
588 }
589
590 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 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 (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 (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 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 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 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 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 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 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 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 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 pub fn clear_path_table(&mut self) {
1066 self.path_table = None;
1067 }
1068
1069 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 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
1113fn vector_norm(v: &[f32]) -> f64 {
1119 v.iter()
1120 .map(|x| (*x as f64) * (*x as f64))
1121 .sum::<f64>()
1122 .sqrt()
1123}
1124
1125fn 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
1141fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub enum SearchStrategy {
1157 Flat { ef: usize },
1160 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#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct ProbeReport {
1175 pub strategy: SearchStrategy,
1176 pub store_size: usize,
1177 pub dimensions: usize,
1178 pub dim_variances: Vec<f64>,
1180 pub steepness: f64,
1183 pub top_quartile_fraction: f64,
1185}
1186
1187pub 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 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 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 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 let strategy = if steepness < 1.5 || dims < 16 || corpus.len() < 100 {
1258 let ef = if corpus.len() < 1000 { 100 } else { 50 };
1260 SearchStrategy::Flat { ef }
1261 } else {
1262 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1332pub enum SpectrumForm {
1333 Atom,
1335 Sequence,
1337 Branch,
1339}
1340
1341#[derive(Debug, Clone, Serialize, Deserialize)]
1343pub struct TriageRecord {
1344 pub form: SpectrumForm,
1346 pub steepness: f64,
1348 pub concentration: f64,
1350 pub knee: usize,
1352 pub strategy: SearchStrategy,
1354}
1355
1356fn 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
1367fn 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 let ef = if store_size < 1000 { 100 } else { 50 };
1383 SearchStrategy::Flat { ef }
1384 }
1385 SpectrumForm::Sequence => {
1386 let coarse_dims = knee.max(4);
1390 let medium_dims = (coarse_dims * 3 / 2).max(coarse_dims + 4).min(dims * 3 / 5);
1391
1392 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 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
1430pub 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 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; let mut strategy = eml_tier_params(form, dims, knee, probe.store_size, top_k, steepness);
1459
1460 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
1474fn 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
1488use clawft_core::embeddings::hnsw_store::{HnswStore, TieredSearch};
1501
1502#[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#[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 pub phase1_build_ns: u128,
1526 pub phase1_baseline_recall: f64,
1527 pub phase1_warmup_query_ns: u128,
1528
1529 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 pub phase3_control: ArmMetrics,
1538 pub phase3_overhead: ArmMetrics,
1539 pub phase3_adaptive: ArmMetrics,
1540 pub phase3_tiered: ArmMetrics,
1541
1542 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
1555fn 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
1563fn 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
1571fn 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 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 (0..n)
1601 .map(|i| {
1602 let cluster_idx = i % n_clusters;
1603 let centroid = ¢roids[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
1615fn 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
1637fn 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
1647fn 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#[derive(Debug, Clone)]
1676pub struct HnswBenchmarkParams {
1677 pub store_size: usize,
1678 pub dims: usize,
1679 pub top_k: usize,
1680 pub learning_passes: usize,
1682 pub n_queries: usize,
1684 pub train_every_n: usize,
1690 pub min_training_samples: usize,
1692 pub phase4: bool,
1694}
1695
1696impl HnswBenchmarkParams {
1697 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 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
1730pub 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#[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 pub default_mean_ns: u128,
1758 pub guided_mean_ns: u128,
1760 pub speedup: f64,
1762 pub default_recall: f64,
1764 pub guided_recall: f64,
1766 pub entry_hit_rate: f64,
1768}
1769
1770pub 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 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 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
1864pub 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 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); let mut store_control = build_store(&corpus, static_ef);
1893 let t0 = std::time::Instant::now();
1894 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 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 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 let phase3_control = measure_latency(&mut store_control, &queries, top_k);
1991
1992 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 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 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 let mut phase4_scaling = Vec::new();
2071 if phase4 {
2072 let sweep_sizes = [100, 500, 1000, 2000, 5000usize];
2073 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#[cfg(test)]
2130mod tests {
2131 use super::*;
2132
2133 fn make_manager() -> HnswEmlManager {
2134 HnswEmlManager::with_defaults()
2135 }
2136
2137 #[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 #[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 #[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 #[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 #[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 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 let pred = m.predict_rebuild(100, 50, 50);
2275 assert!(pred.should_rebuild);
2276 assert!(!pred.is_learned);
2277 }
2278
2279 #[test]
2282 fn train_all_insufficient_data_returns_false() {
2283 let mut m = make_manager();
2284 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, ..Default::default()
2298 });
2299
2300 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 let _ = m.train_all();
2312 assert_eq!(m.searches_since_train, 0); }
2314
2315 #[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 #[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 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 #[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 #[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 #[test]
2449 fn debug_format_does_not_panic() {
2450 let m = make_manager();
2451 let _ = format!("{:?}", m);
2452 }
2453
2454 #[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, ..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 assert_eq!(m.searches_since_train, 0);
2469 }
2470
2471 #[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 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 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 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 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 let _ = m.train_all();
2511
2512 let s = m.status();
2514 assert!(s.total_searches > 0);
2515
2516 m.reset();
2518 let s2 = m.status();
2519 assert_eq!(s2.total_searches, 0);
2520 }
2521
2522 #[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); 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 #[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 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 #[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 #[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}