Skip to main content

linkrs_query/optimizer/stats/
manager.rs

1//! Statistical Information Manager Module
2//!
3//! Centralized management of all statistical information, with thread-safe access.
4
5use dashmap::DashMap;
6use std::sync::Arc;
7
8use linkrs_core::types::Index;
9
10use super::{EdgeTypeStatistics, PropertyCombinationStats, PropertyStatistics, TagStatistics};
11
12/// Statistical Information Manager
13///
14/// Centralized management of all statistical information, ensuring thread-safe access.
15/// All statistics are scoped to a space; keys are `"{space}.{name}"` composite keys.
16#[derive(Debug)]
17pub struct StatisticsManager {
18    /// Tag statistics information, keyed by `"{space}.{tag_name}"`.
19    tag_stats: Arc<DashMap<String, TagStatistics>>,
20    /// Mapping from Tag ID to Tag Name
21    tag_id_to_name: Arc<DashMap<String, String>>,
22    /// Type statistics information for edges, keyed by `"{space}.{edge_type}"`.
23    edge_stats: Arc<DashMap<String, EdgeTypeStatistics>>,
24    /// Attribute statistics information, keyed by `"{space}.{tag}.{property}"`.
25    property_stats: Arc<DashMap<String, PropertyStatistics>>,
26    /// Property combination statistics for GROUP BY cardinality estimation,
27    /// keyed by `"{space}.{tag}.{prop1}.{prop2}..."`.
28    property_combo_stats: Arc<DashMap<String, PropertyCombinationStats>>,
29    /// Composite cache key `(schema_version, data_epoch)` at which each
30    /// space's statistics were last collected.  A DDL bumps the schema
31    /// generation; a committed write bumps the data epoch held by the storage
32    /// engine.  Both must match for a cache hit.
33    space_versions: Arc<DashMap<String, (u64, u64)>>,
34    /// Index catalog for cost-based index selection, keyed by
35    /// `"{space}.{tag_name}"` → (tag_id, available indexes). Registered
36    /// per query from the planning metadata context.
37    index_catalog: Arc<DashMap<String, (i32, Vec<Index>)>>,
38}
39
40impl StatisticsManager {
41    /// Create a new statistical information manager.
42    pub fn new() -> Self {
43        Self {
44            tag_stats: Arc::new(DashMap::new()),
45            tag_id_to_name: Arc::new(DashMap::new()),
46            edge_stats: Arc::new(DashMap::new()),
47            property_stats: Arc::new(DashMap::new()),
48            property_combo_stats: Arc::new(DashMap::new()),
49            space_versions: Arc::new(DashMap::new()),
50            index_catalog: Arc::new(DashMap::new()),
51        }
52    }
53
54    /// Build the composite storage key for space-qualified statistics.
55    fn composite_key(space: &str, name: &str) -> String {
56        format!("{}.{}", space, name)
57    }
58
59    /// Split a composite `"{space}.{name}"` key back into the space, if it is one.
60    fn split_composite_key<'a>(key: &'a str, name: &str) -> Option<&'a str> {
61        key.strip_suffix(&format!(".{}", name))
62    }
63
64    /// Mark the statistics for `space` as stale (forces re-collection).
65    pub fn mark_space_dirty(&self, space: &str) {
66        self.space_versions.remove(space);
67    }
68
69    /// Invalidate statistics for a space (`None` = all spaces).
70    pub fn invalidate_space(&self, space: Option<&str>) {
71        match space {
72            Some(space) => self.mark_space_dirty(space),
73            None => self.space_versions.clear(),
74        }
75    }
76
77    /// The composite `(schema_version, data_epoch)` stamp for `space`, if
78    /// statistics have been collected for it.
79    pub fn space_stamp(&self, space: &str) -> Option<(u64, u64)> {
80        self.space_versions.get(space).map(|v| *v)
81    }
82
83    /// The schema version recorded for `space` at its last collection, if any.
84    pub fn space_version(&self, space: &str) -> Option<u64> {
85        self.space_stamp(space).map(|stamp| stamp.0)
86    }
87
88    /// Record the composite stamp at which `space` was last collected.
89    /// Unknown versions (`0`) are never cached: a zero schema or data
90    /// version means cacheability is unknown, so the stamp is not stored
91    /// and the next collection always re-samples.
92    pub fn set_space_stamp(&self, space: &str, schema_version: u64, data_epoch: u64) {
93        if schema_version == 0 || data_epoch == 0 {
94            self.space_versions.remove(space);
95            return;
96        }
97        self.space_versions
98            .insert(space.to_string(), (schema_version, data_epoch));
99    }
100
101    /// Register the available indexes for `tag_name` in `space`.
102    ///
103    /// Called by the query pipeline when it builds the planning metadata
104    /// context; registration overwrites any previously registered catalog
105    /// for the tag, so schema changes are picked up on the next query.
106    pub fn register_tag_indexes(
107        &self,
108        space: &str,
109        tag_name: &str,
110        tag_id: i32,
111        indexes: Vec<Index>,
112    ) {
113        self.index_catalog
114            .insert(Self::composite_key(space, tag_name), (tag_id, indexes));
115    }
116
117    /// The available indexes for `tag_name` in `space`, with the tag id.
118    pub fn get_tag_indexes(&self, space: &str, tag_name: &str) -> Option<(i32, Vec<Index>)> {
119        self.index_catalog
120            .get(&Self::composite_key(space, tag_name))
121            .map(|entry| entry.clone())
122    }
123
124    /// Mapping of registered tag IDs to their corresponding names.
125    ///
126    /// Scoped by space (`"{space}:{id}"`) so tag ids reused across spaces
127    /// cannot overwrite each other.
128    pub fn register_tag_id(&self, space: &str, tag_id: i32, tag_name: String) {
129        self.tag_id_to_name
130            .insert(format!("{space}:{tag_id}"), tag_name);
131    }
132
133    /// Retrieve the tag name for a tag ID in a specific space.
134    pub fn get_tag_name_by_id(&self, space: &str, tag_id: i32) -> Option<String> {
135        self.tag_id_to_name
136            .get(&format!("{space}:{tag_id}"))
137            .map(|v| v.clone())
138    }
139
140    /// Retrieve tag statistics based on the tag ID in a specific space.
141    pub fn get_tag_stats_by_id(&self, space: &str, tag_id: i32) -> Option<TagStatistics> {
142        let tag_name = self.get_tag_name_by_id(space, tag_id)?;
143        self.get_tag_stats(space, &tag_name)
144    }
145
146    /// Get the number of vertices based on the tag ID in a specific space.
147    pub fn get_vertex_count_by_id(&self, space: &str, tag_id: i32) -> u64 {
148        self.get_tag_stats_by_id(space, tag_id)
149            .map(|s| s.vertex_count)
150            .unwrap_or(0)
151    }
152
153    /// Obtain tag statistics information for a specific space.
154    pub fn get_tag_stats(&self, space: &str, tag_name: &str) -> Option<TagStatistics> {
155        self.tag_stats
156            .get(&Self::composite_key(space, tag_name))
157            .map(|v| v.clone())
158    }
159
160    /// Update the tag statistics information for a specific space.
161    pub fn update_tag_stats(&self, space: &str, stats: TagStatistics) {
162        self.tag_stats
163            .insert(Self::composite_key(space, &stats.tag_name), stats);
164    }
165
166    /// Obtain the number of vertices for a specific space.
167    pub fn get_vertex_count(&self, space: &str, tag_name: &str) -> u64 {
168        self.get_tag_stats(space, tag_name)
169            .map(|s| s.vertex_count)
170            .unwrap_or(0)
171    }
172
173    /// Obtain statistical information about the types of edges for a specific space.
174    pub fn get_edge_stats(&self, space: &str, edge_type: &str) -> Option<EdgeTypeStatistics> {
175        self.edge_stats
176            .get(&Self::composite_key(space, edge_type))
177            .map(|v| v.clone())
178    }
179
180    /// Update the statistics information on edge types for a specific space.
181    pub fn update_edge_stats(&self, space: &str, stats: EdgeTypeStatistics) {
182        self.edge_stats
183            .insert(Self::composite_key(space, &stats.edge_type), stats);
184    }
185
186    /// Obtain the number of edges for a specific space.
187    pub fn get_edge_count(&self, space: &str, edge_type: &str) -> u64 {
188        self.get_edge_stats(space, edge_type)
189            .map(|s| s.edge_count)
190            .unwrap_or(0)
191    }
192
193    /// Obtain attribute statistics information for a specific space.
194    pub fn get_property_stats(
195        &self,
196        space: &str,
197        tag_name: Option<&str>,
198        property_name: &str,
199    ) -> Option<PropertyStatistics> {
200        let key = match tag_name {
201            Some(tag) => format!("{}.{}.{}", space, tag, property_name),
202            None => format!("{}.{}", space, property_name),
203        };
204        self.property_stats.get(&key).map(|v| v.clone())
205    }
206
207    /// Update attribute statistics information for a specific space.
208    pub fn update_property_stats(&self, space: &str, stats: PropertyStatistics) {
209        let key = match &stats.tag_name {
210            Some(tag) => format!("{}.{}.{}", space, tag, stats.property_name),
211            None => format!("{}.{}", space, stats.property_name),
212        };
213        self.property_stats.insert(key, stats);
214    }
215
216    /// Clear all statistical information.
217    pub fn clear_all(&self) {
218        self.tag_stats.clear();
219        self.tag_id_to_name.clear();
220        self.edge_stats.clear();
221        self.property_stats.clear();
222        self.property_combo_stats.clear();
223        self.space_versions.clear();
224        self.index_catalog.clear();
225    }
226
227    /// Get property combination statistics for GROUP BY cardinality estimation.
228    pub fn get_property_combo_stats(
229        &self,
230        space: &str,
231        tag_name: &str,
232        properties: &[String],
233    ) -> Option<PropertyCombinationStats> {
234        let key = format!("{}.{}.{}", space, tag_name, properties.join("."));
235        self.property_combo_stats.get(&key).map(|v| v.clone())
236    }
237
238    /// Update property combination statistics for a specific space.
239    /// Existing entries merge via exponential moving average so repeated
240    /// collections refine rather than replace the estimate.
241    pub fn update_property_combo_stats(&self, space: &str, stats: PropertyCombinationStats) {
242        let tag = stats.tag_name.as_deref().unwrap_or("");
243        let key = format!("{}.{}.{}", space, tag, stats.properties.join("."));
244        if let Some(mut existing) = self.property_combo_stats.get_mut(&key) {
245            existing.update(stats.combined_distinct_values, stats.sample_count);
246        } else {
247            self.property_combo_stats.insert(key, stats);
248        }
249    }
250
251    /// Get combined cardinality for a set of properties.
252    /// Returns None if no statistics are available.
253    pub fn get_combined_cardinality(
254        &self,
255        space: &str,
256        tag_name: Option<&str>,
257        properties: &[String],
258    ) -> Option<u64> {
259        let tag = tag_name?;
260        self.get_property_combo_stats(space, tag, properties)
261            .map(|s| s.estimated_cardinality())
262    }
263
264    /// Retrieve all tag names.
265    ///
266    /// Returns bare tag names; space-qualified entries are de-duplicated.
267    pub fn get_all_tags(&self) -> Vec<String> {
268        let mut names: Vec<String> = Vec::new();
269        for entry in self.tag_stats.iter() {
270            let name = Self::split_composite_key(entry.key(), &entry.tag_name)
271                .map(|_| entry.tag_name.clone())
272                .unwrap_or_else(|| entry.key().clone());
273            if !names.contains(&name) {
274                names.push(name);
275            }
276        }
277        names
278    }
279
280    /// Obtain the names of all edge types (space-qualified entries de-duplicated).
281    pub fn get_all_edge_types(&self) -> Vec<String> {
282        let mut names: Vec<String> = Vec::new();
283        for entry in self.edge_stats.iter() {
284            let name = Self::split_composite_key(entry.key(), &entry.edge_type)
285                .map(|_| entry.edge_type.clone())
286                .unwrap_or_else(|| entry.key().clone());
287            if !names.contains(&name) {
288                names.push(name);
289            }
290        }
291        names
292    }
293}
294
295impl Default for StatisticsManager {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl Clone for StatisticsManager {
302    fn clone(&self) -> Self {
303        Self {
304            tag_stats: Arc::clone(&self.tag_stats),
305            tag_id_to_name: Arc::clone(&self.tag_id_to_name),
306            edge_stats: Arc::clone(&self.edge_stats),
307            property_stats: Arc::clone(&self.property_stats),
308            property_combo_stats: Arc::clone(&self.property_combo_stats),
309            space_versions: Arc::clone(&self.space_versions),
310            index_catalog: Arc::clone(&self.index_catalog),
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::optimizer::stats::edge::EdgeTypeStatistics;
319    use crate::optimizer::stats::property::PropertyStatistics;
320
321    const TEST_SPACE: &str = "test";
322
323    #[test]
324    fn test_statistics_manager_creation() {
325        let manager = StatisticsManager::new();
326        assert_eq!(manager.get_all_tags().len(), 0);
327        assert_eq!(manager.get_all_edge_types().len(), 0);
328    }
329
330    #[test]
331    fn test_statistics_manager_default() {
332        let manager = StatisticsManager::default();
333        assert_eq!(manager.get_all_tags().len(), 0);
334    }
335
336    #[test]
337    fn test_register_and_get_tag_id() {
338        let manager = StatisticsManager::new();
339        manager.register_tag_id(TEST_SPACE, 1, "person".to_string());
340
341        assert_eq!(
342            manager.get_tag_name_by_id(TEST_SPACE, 1),
343            Some("person".to_string())
344        );
345        assert_eq!(manager.get_tag_name_by_id(TEST_SPACE, 2), None);
346    }
347
348    #[test]
349    fn test_add_tag_statistics() {
350        let manager = StatisticsManager::new();
351        let mut stats = TagStatistics::new("person".to_string());
352        stats.vertex_count = 1000;
353        stats.avg_out_degree = 5.5;
354        stats.avg_in_degree = 3.2;
355
356        manager.update_tag_stats(TEST_SPACE, stats.clone());
357
358        let retrieved = manager
359            .get_tag_stats(TEST_SPACE, "person")
360            .expect("Tag stats not found");
361        assert_eq!(retrieved.vertex_count, 1000);
362        assert_eq!(retrieved.avg_out_degree, 5.5);
363        assert_eq!(retrieved.avg_in_degree, 3.2);
364    }
365
366    #[test]
367    fn test_get_vertex_count() {
368        let manager = StatisticsManager::new();
369        let mut stats = TagStatistics::new("company".to_string());
370        stats.vertex_count = 500;
371        manager.update_tag_stats(TEST_SPACE, stats);
372
373        assert_eq!(manager.get_vertex_count(TEST_SPACE, "company"), 500);
374        assert_eq!(manager.get_vertex_count(TEST_SPACE, "nonexistent"), 0);
375    }
376
377    #[test]
378    fn test_get_tag_stats_by_id() {
379        let manager = StatisticsManager::new();
380        manager.register_tag_id(TEST_SPACE, 10, "product".to_string());
381
382        let mut stats = TagStatistics::new("product".to_string());
383        stats.vertex_count = 2000;
384        manager.update_tag_stats(TEST_SPACE, stats);
385
386        let retrieved = manager
387            .get_tag_stats_by_id(TEST_SPACE, 10)
388            .expect("Tag stats not found");
389        assert_eq!(retrieved.vertex_count, 2000);
390    }
391
392    #[test]
393    fn test_get_vertex_count_by_id() {
394        let manager = StatisticsManager::new();
395        manager.register_tag_id(TEST_SPACE, 5, "category".to_string());
396
397        let mut stats = TagStatistics::new("category".to_string());
398        stats.vertex_count = 100;
399        manager.update_tag_stats(TEST_SPACE, stats);
400
401        assert_eq!(manager.get_vertex_count_by_id(TEST_SPACE, 5), 100);
402        assert_eq!(manager.get_vertex_count_by_id(TEST_SPACE, 999), 0);
403    }
404
405    #[test]
406    fn test_add_edge_statistics() {
407        let manager = StatisticsManager::new();
408        let mut edge_stats = EdgeTypeStatistics::new("follows".to_string());
409        edge_stats.edge_count = 5000;
410
411        manager.update_edge_stats(TEST_SPACE, edge_stats.clone());
412
413        let retrieved = manager
414            .get_edge_stats(TEST_SPACE, "follows")
415            .expect("Edge stats not found");
416        assert_eq!(retrieved.edge_count, 5000);
417    }
418
419    #[test]
420    fn test_get_edge_count() {
421        let manager = StatisticsManager::new();
422        let mut edge_stats = EdgeTypeStatistics::new("works_at".to_string());
423        edge_stats.edge_count = 3000;
424        manager.update_edge_stats(TEST_SPACE, edge_stats);
425
426        assert_eq!(manager.get_edge_count(TEST_SPACE, "works_at"), 3000);
427        assert_eq!(manager.get_edge_count(TEST_SPACE, "nonexistent"), 0);
428    }
429
430    #[test]
431    fn test_add_property_statistics() {
432        let manager = StatisticsManager::new();
433        let mut prop_stats = PropertyStatistics::new("age".into(), Some("person".to_string()));
434        prop_stats.distinct_values = 100;
435
436        manager.update_property_stats(TEST_SPACE, prop_stats);
437
438        let retrieved = manager
439            .get_property_stats(TEST_SPACE, Some("person"), "age")
440            .expect("Property stats not found");
441        assert_eq!(retrieved.distinct_values, 100);
442    }
443
444    #[test]
445    fn test_multiple_tags_statistics() {
446        let manager = StatisticsManager::new();
447
448        let mut person_stats = TagStatistics::new("person".to_string());
449        person_stats.vertex_count = 1000;
450        manager.update_tag_stats(TEST_SPACE, person_stats);
451
452        let mut company_stats = TagStatistics::new("company".to_string());
453        company_stats.vertex_count = 500;
454        manager.update_tag_stats(TEST_SPACE, company_stats);
455
456        assert_eq!(manager.get_vertex_count(TEST_SPACE, "person"), 1000);
457        assert_eq!(manager.get_vertex_count(TEST_SPACE, "company"), 500);
458
459        let all_tags = manager.get_all_tags();
460        assert_eq!(all_tags.len(), 2);
461        assert!(all_tags.contains(&"person".to_string()));
462        assert!(all_tags.contains(&"company".to_string()));
463    }
464
465    #[test]
466    fn test_statistics_update_overwrite() {
467        let manager = StatisticsManager::new();
468
469        let mut stats1 = TagStatistics::new("person".to_string());
470        stats1.vertex_count = 1000;
471        manager.update_tag_stats(TEST_SPACE, stats1);
472
473        let mut stats2 = TagStatistics::new("person".to_string());
474        stats2.vertex_count = 2000;
475        manager.update_tag_stats(TEST_SPACE, stats2);
476
477        assert_eq!(manager.get_vertex_count(TEST_SPACE, "person"), 2000);
478    }
479
480    #[test]
481    fn test_clear_all_statistics() {
482        let manager = StatisticsManager::new();
483
484        let mut person_stats = TagStatistics::new("person".to_string());
485        person_stats.vertex_count = 1000;
486        manager.update_tag_stats(TEST_SPACE, person_stats);
487
488        let mut edge_stats = EdgeTypeStatistics::new("follows".to_string());
489        edge_stats.edge_count = 5000;
490        manager.update_edge_stats(TEST_SPACE, edge_stats);
491
492        assert_eq!(manager.get_all_tags().len(), 1);
493        assert_eq!(manager.get_all_edge_types().len(), 1);
494
495        manager.clear_all();
496
497        assert_eq!(manager.get_all_tags().len(), 0);
498        assert_eq!(manager.get_all_edge_types().len(), 0);
499        assert_eq!(manager.get_vertex_count(TEST_SPACE, "person"), 0);
500        assert_eq!(manager.get_edge_count(TEST_SPACE, "follows"), 0);
501    }
502
503    #[test]
504    fn test_statistics_manager_clone() {
505        let manager = StatisticsManager::new();
506
507        let mut stats = TagStatistics::new("person".to_string());
508        stats.vertex_count = 1000;
509        manager.update_tag_stats(TEST_SPACE, stats);
510
511        let cloned = manager.clone();
512        assert_eq!(cloned.get_vertex_count(TEST_SPACE, "person"), 1000);
513
514        let mut new_stats = TagStatistics::new("company".to_string());
515        new_stats.vertex_count = 500;
516        cloned.update_tag_stats(TEST_SPACE, new_stats);
517
518        assert_eq!(manager.get_vertex_count(TEST_SPACE, "company"), 500);
519    }
520
521    #[test]
522    fn test_property_combination_statistics() {
523        let manager = StatisticsManager::new();
524        let props = vec!["city".to_string(), "age".to_string()];
525        let mut combo_stats =
526            PropertyCombinationStats::new(String::new(), Some("person".to_string()), props.clone());
527        combo_stats.combined_distinct_values = 50;
528
529        manager.update_property_combo_stats(TEST_SPACE, combo_stats);
530
531        let retrieved = manager
532            .get_property_combo_stats(TEST_SPACE, "person", &props)
533            .expect("Combo stats not found");
534        assert_eq!(retrieved.combined_distinct_values, 50);
535    }
536
537    #[test]
538    fn test_get_combined_cardinality() {
539        let manager = StatisticsManager::new();
540        let props = vec!["city".to_string(), "age".to_string()];
541        let mut combo_stats =
542            PropertyCombinationStats::new(String::new(), Some("person".to_string()), props.clone());
543        combo_stats.combined_distinct_values = 75;
544
545        manager.update_property_combo_stats(TEST_SPACE, combo_stats);
546
547        let cardinality = manager
548            .get_combined_cardinality(TEST_SPACE, Some("person"), &props)
549            .expect("Combined cardinality not found");
550        assert_eq!(cardinality, 75);
551    }
552
553    #[test]
554    fn test_property_stats_without_tag() {
555        let manager = StatisticsManager::new();
556        let mut prop_stats = PropertyStatistics::new("global_prop".into(), None);
557        prop_stats.distinct_values = 200;
558
559        manager.update_property_stats(TEST_SPACE, prop_stats);
560
561        let retrieved = manager
562            .get_property_stats(TEST_SPACE, None, "global_prop")
563            .expect("Property stats not found");
564        assert_eq!(retrieved.distinct_values, 200);
565    }
566
567    #[test]
568    fn test_multiple_edge_types() {
569        let manager = StatisticsManager::new();
570
571        let mut follows = EdgeTypeStatistics::new("follows".to_string());
572        follows.edge_count = 5000;
573        manager.update_edge_stats(TEST_SPACE, follows);
574
575        let mut works_at = EdgeTypeStatistics::new("works_at".to_string());
576        works_at.edge_count = 3000;
577        manager.update_edge_stats(TEST_SPACE, works_at);
578
579        assert_eq!(manager.get_edge_count(TEST_SPACE, "follows"), 5000);
580        assert_eq!(manager.get_edge_count(TEST_SPACE, "works_at"), 3000);
581
582        let all_edge_types = manager.get_all_edge_types();
583        assert_eq!(all_edge_types.len(), 2);
584        assert!(all_edge_types.contains(&"follows".to_string()));
585        assert!(all_edge_types.contains(&"works_at".to_string()));
586    }
587
588    #[test]
589    fn test_tag_id_isolated_by_space() {
590        let manager = StatisticsManager::new();
591        manager.register_tag_id("a", 7, "person".to_string());
592        manager.register_tag_id("b", 7, "company".to_string());
593
594        assert_eq!(
595            manager.get_tag_name_by_id("a", 7),
596            Some("person".to_string())
597        );
598        assert_eq!(
599            manager.get_tag_name_by_id("b", 7),
600            Some("company".to_string())
601        );
602    }
603
604    #[test]
605    fn test_space_isolation() {
606        let manager = StatisticsManager::new();
607
608        let mut stats = TagStatistics::new("person".to_string());
609        stats.vertex_count = 42;
610        manager.update_tag_stats("basketball", stats.with_version("basketball".into(), 7));
611
612        let mut edge_stats = EdgeTypeStatistics::new("follow".to_string());
613        edge_stats.edge_count = 99;
614        manager.update_edge_stats(
615            "basketball",
616            edge_stats.with_version("basketball".into(), 7),
617        );
618
619        assert_eq!(manager.get_vertex_count("basketball", "person"), 42);
620        assert_eq!(manager.get_edge_count("basketball", "follow"), 99);
621        let tag_stats = manager.get_tag_stats("basketball", "person").expect("tag");
622        assert_eq!(tag_stats.schema_version, Some(7));
623        assert_eq!(tag_stats.space.as_deref(), Some("basketball"));
624
625        // Different spaces are isolated.
626        assert_eq!(manager.get_vertex_count("tennis", "person"), 0);
627        assert_eq!(manager.get_edge_count("tennis", "follow"), 0);
628    }
629
630    #[test]
631    fn test_space_version_gate() {
632        let manager = StatisticsManager::new();
633        assert_eq!(manager.space_stamp("basketball"), None);
634
635        manager.set_space_stamp("basketball", 3, 100);
636        assert_eq!(manager.space_stamp("basketball"), Some((3, 100)));
637
638        manager.mark_space_dirty("basketball");
639        assert_eq!(manager.space_stamp("basketball"), None);
640
641        manager.set_space_stamp("a", 1, 10);
642        manager.set_space_stamp("b", 2, 20);
643        manager.invalidate_space(None);
644        assert_eq!(manager.space_stamp("a"), None);
645        assert_eq!(manager.space_stamp("b"), None);
646    }
647}