Skip to main content

tsift_core/
lib.rs

1pub mod convex;
2pub mod store;
3pub mod types;
4
5pub use convex::{
6    ConvexEdgeRow, ConvexGraphClient, ConvexGraphStore, ConvexNodeRow, ConvexProjectionRows,
7    ConvexRowsGraphClient,
8};
9pub use store::{
10    GraphStore, apply_graph_edge_query_page, apply_graph_query_page, graph_semantic_cosine,
11    graph_semantic_seeded_edge_other_id, graph_semantic_seeded_edge_score,
12    graph_semantic_top_candidates_by_property_scan, parse_graph_semantic_vector_property,
13    shortest_path_using_outgoing,
14};
15pub use types::{
16    DEFAULT_RANKED_NEIGHBORHOOD_MEMORY_NODE_BOOST,
17    DEFAULT_RANKED_NEIGHBORHOOD_OBSERVED_AT_HALF_LIFE_SECS,
18    DEFAULT_RANKED_NEIGHBORHOOD_OBSERVED_AT_WEIGHT, GRAPH_SEMANTIC_VECTOR_DEFAULT_MODEL,
19    GRAPH_SEMANTIC_VECTOR_MODEL_PROPERTY_KEY, GRAPH_SEMANTIC_VECTOR_PROPERTY_KEY, GraphEdge,
20    GraphFreshness, GraphNode, GraphPagedSubgraph, GraphPath, GraphProjection, GraphPropertyFilter,
21    GraphProvenance, GraphQueryOptions, GraphQueryPage, GraphSemanticCandidate, GraphSubgraph,
22    NeighborhoodScoring, PropertyMode, RankedNeighborhoodOptions, RankedNeighborhoodResult,
23    SQLITE_GRAPH_SCHEMA_VERSION, SemanticSeededNeighborhoodExpansion,
24    SemanticSeededNeighborhoodOptions, SemanticSeededNeighborhoodResult, TerseGraphEdge,
25    TerseGraphNode, TerseGraphSubgraph, TerseHealthScore, TerseSearchHit, graph_edge_id,
26    stable_graph_edge_id,
27};
28
29impl GraphProjection {
30    pub fn upsert_into<S: GraphStore + ?Sized>(&self, store: &S) -> anyhow::Result<()> {
31        for node in &self.nodes {
32            store.upsert_node(node)?;
33        }
34        for edge in &self.edges {
35            store.upsert_edge(edge)?;
36        }
37        Ok(())
38    }
39
40    pub fn to_convex_rows(&self) -> ConvexProjectionRows {
41        ConvexProjectionRows::from(self)
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use std::cell::RefCell;
49    use std::collections::BTreeMap;
50
51    fn sample_provenance() -> GraphProvenance {
52        GraphProvenance::new("fixture", "src/lib.rs:1").with_content_hash("hash-1")
53    }
54
55    fn sample_projection() -> GraphProjection {
56        let source = sample_provenance();
57        GraphProjection {
58            nodes: vec![
59                GraphNode::new("doc:livekit", "document", "LiveKit guide")
60                    .with_property("domain", "livekit")
61                    .with_provenance(source.clone())
62                    .with_freshness(GraphFreshness::content_hash("node-hash")),
63                GraphNode::new("topic:rooms", "topic", "Rooms"),
64                GraphNode::new("topic:egress", "topic", "Egress"),
65            ],
66            edges: vec![
67                GraphEdge::new("doc:livekit", "topic:rooms", "mentions")
68                    .with_property("confidence", "0.91")
69                    .with_provenance(source.clone())
70                    .with_freshness(GraphFreshness::content_hash("edge-hash")),
71                GraphEdge::new("topic:rooms", "topic:egress", "related_to").with_provenance(source),
72            ],
73        }
74    }
75
76    fn assert_projection_store_contract(store: &impl GraphStore) {
77        let projection = sample_projection();
78        projection.upsert_into(store).unwrap();
79
80        assert_eq!(
81            store.node("doc:livekit").unwrap(),
82            projection
83                .nodes
84                .iter()
85                .find(|node| node.id == "doc:livekit")
86                .cloned()
87        );
88        assert_eq!(
89            store.nodes_by_kind("topic").unwrap(),
90            vec![
91                GraphNode::new("topic:egress", "topic", "Egress"),
92                GraphNode::new("topic:rooms", "topic", "Rooms"),
93            ]
94        );
95
96        let mentions = store
97            .outgoing_edges("doc:livekit", Some("mentions"))
98            .unwrap();
99        assert_eq!(mentions.len(), 1);
100        assert_eq!(mentions[0].to_id, "topic:rooms");
101        assert_eq!(
102            mentions[0].properties.get("confidence"),
103            Some(&"0.91".into())
104        );
105
106        let path = store
107            .shortest_path("doc:livekit", "topic:egress", None)
108            .unwrap()
109            .unwrap();
110        assert_eq!(
111            path.nodes,
112            vec!["doc:livekit", "topic:rooms", "topic:egress"]
113        );
114    }
115
116    #[derive(Default)]
117    struct MemoryConvexGraphClient {
118        nodes: RefCell<BTreeMap<String, ConvexNodeRow>>,
119        edges: RefCell<BTreeMap<String, ConvexEdgeRow>>,
120    }
121
122    impl ConvexGraphClient for MemoryConvexGraphClient {
123        fn upsert_node_row(&self, row: &ConvexNodeRow) -> anyhow::Result<()> {
124            self.nodes
125                .borrow_mut()
126                .insert(row.external_id.clone(), row.clone());
127            Ok(())
128        }
129
130        fn upsert_edge_row(&self, row: &ConvexEdgeRow) -> anyhow::Result<()> {
131            self.edges
132                .borrow_mut()
133                .insert(row.edge_key.clone(), row.clone());
134            Ok(())
135        }
136
137        fn delete_node_row(&self, external_id: &str) -> anyhow::Result<usize> {
138            Ok(usize::from(
139                self.nodes.borrow_mut().remove(external_id).is_some(),
140            ))
141        }
142
143        fn delete_edge_row(&self, edge_key: &str) -> anyhow::Result<usize> {
144            Ok(usize::from(
145                self.edges.borrow_mut().remove(edge_key).is_some(),
146            ))
147        }
148
149        fn node_row(&self, external_id: &str) -> anyhow::Result<Option<ConvexNodeRow>> {
150            Ok(self.nodes.borrow().get(external_id).cloned())
151        }
152
153        fn node_rows(&self) -> anyhow::Result<Vec<ConvexNodeRow>> {
154            Ok(self.nodes.borrow().values().cloned().collect())
155        }
156
157        fn edge_rows(&self) -> anyhow::Result<Vec<ConvexEdgeRow>> {
158            Ok(self.edges.borrow().values().cloned().collect())
159        }
160
161        fn node_rows_by_kind(&self, kind: &str) -> anyhow::Result<Vec<ConvexNodeRow>> {
162            Ok(self
163                .nodes
164                .borrow()
165                .values()
166                .filter(|row| row.kind == kind)
167                .cloned()
168                .collect())
169        }
170
171        fn outgoing_edge_rows(
172            &self,
173            from_external_id: &str,
174            kind: Option<&str>,
175        ) -> anyhow::Result<Vec<ConvexEdgeRow>> {
176            Ok(self
177                .edges
178                .borrow()
179                .values()
180                .filter(|row| row.from_external_id == from_external_id)
181                .filter(|row| kind.is_none_or(|kind| row.kind == kind))
182                .cloned()
183                .collect())
184        }
185    }
186
187    #[test]
188    fn graph_projection_round_trips_through_backend_agnostic_store_contract() {
189        let convex = ConvexGraphStore::new(MemoryConvexGraphClient::default());
190        assert_projection_store_contract(&convex);
191
192        let client = convex.client();
193        assert_eq!(client.nodes.borrow().len(), 3);
194        assert_eq!(client.edges.borrow().len(), 2);
195        assert!(
196            client.nodes.borrow().contains_key("doc:livekit"),
197            "Convex rows keep GraphNode.id as the externalId upsert key"
198        );
199    }
200
201    #[test]
202    fn graph_store_contract_covers_crud_neighborhood_and_ordering() {
203        fn assert_crud_contract(store: &impl GraphStore) {
204            let projection = sample_projection();
205            projection.upsert_into(store).unwrap();
206
207            let neighborhood = store.neighborhood("doc:livekit", 2, None).unwrap().unwrap();
208            assert_eq!(
209                neighborhood
210                    .nodes
211                    .iter()
212                    .map(|node| node.id.as_str())
213                    .collect::<Vec<_>>(),
214                vec!["doc:livekit", "topic:egress", "topic:rooms"]
215            );
216            assert_eq!(
217                neighborhood
218                    .edges
219                    .iter()
220                    .map(|edge| (
221                        edge.from_id.as_str(),
222                        edge.kind.as_str(),
223                        edge.to_id.as_str()
224                    ))
225                    .collect::<Vec<_>>(),
226                vec![
227                    ("doc:livekit", "mentions", "topic:rooms"),
228                    ("topic:rooms", "related_to", "topic:egress"),
229                ]
230            );
231
232            assert_eq!(
233                store
234                    .delete_edge("topic:rooms", "topic:egress", "related_to")
235                    .unwrap(),
236                1
237            );
238            assert!(
239                store
240                    .shortest_path("doc:livekit", "topic:egress", None)
241                    .unwrap()
242                    .is_none()
243            );
244            assert_eq!(store.delete_node("topic:rooms").unwrap(), 1);
245            assert!(store.node("topic:rooms").unwrap().is_none());
246            assert!(
247                store
248                    .outgoing_edges("doc:livekit", None)
249                    .unwrap()
250                    .is_empty()
251            );
252        }
253
254        assert_crud_contract(&ConvexGraphStore::new(ConvexRowsGraphClient::default()));
255    }
256
257    #[test]
258    fn convex_projection_rows_keep_stable_ids_and_edge_keys() {
259        let projection = sample_projection();
260        let rows = projection.to_convex_rows();
261
262        let doc_row = rows
263            .nodes
264            .iter()
265            .find(|row| row.external_id == "doc:livekit")
266            .unwrap();
267        assert_eq!(doc_row.kind, "document");
268        assert_eq!(doc_row.properties.get("domain"), Some(&"livekit".into()));
269
270        let mentions = rows
271            .edges
272            .iter()
273            .find(|row| row.kind == "mentions")
274            .unwrap();
275        assert_eq!(mentions.from_external_id, "doc:livekit");
276        assert_eq!(mentions.to_external_id, "topic:rooms");
277        assert_eq!(
278            mentions.edge_key,
279            ConvexEdgeRow::stable_key("doc:livekit", "topic:rooms", "mentions")
280        );
281        assert!(mentions.edge_key.starts_with("edge:"));
282    }
283
284    #[test]
285    fn terse_graph_node_strips_provenance_and_freshness() {
286        let node = GraphNode::new("doc:livekit", "document", "LiveKit guide")
287            .with_property("domain", "livekit")
288            .with_provenance(GraphProvenance::new("fixture", "src/lib.rs:1"))
289            .with_freshness(GraphFreshness::content_hash("hash-1"));
290        let terse = TerseGraphNode::from(&node);
291        assert_eq!(terse.id, "doc:livekit");
292        assert_eq!(terse.kind, "document");
293        assert_eq!(terse.label, "LiveKit guide");
294        assert_eq!(terse.properties.get("domain"), Some(&"livekit".to_string()));
295        let json = serde_json::to_value(&terse).unwrap();
296        assert!(json.get("provenance").is_none());
297        assert!(json.get("freshness").is_none());
298        let full_json = serde_json::to_string(&node).unwrap();
299        let terse_json = serde_json::to_string(&terse).unwrap();
300        assert!(
301            terse_json.len() < full_json.len(),
302            "terse ({}) should be shorter than full ({})",
303            terse_json.len(),
304            full_json.len()
305        );
306    }
307
308    #[test]
309    fn terse_graph_edge_strips_provenance_and_freshness() {
310        let edge = GraphEdge::new("a", "b", "calls")
311            .with_property("line", "10")
312            .with_provenance(GraphProvenance::new("fixture", "src/lib.rs:5"))
313            .with_freshness(GraphFreshness::content_hash("edge-hash"));
314        let terse = TerseGraphEdge::from(&edge);
315        assert_eq!(terse.from_id, "a");
316        assert_eq!(terse.to_id, "b");
317        assert_eq!(terse.kind, "calls");
318        assert_eq!(terse.properties.get("line"), Some(&"10".to_string()));
319        let json = serde_json::to_value(&terse).unwrap();
320        assert!(json.get("provenance").is_none());
321        assert!(json.get("freshness").is_none());
322    }
323
324    #[test]
325    fn terse_graph_subgraph_rounds_trip() {
326        let subgraph = GraphSubgraph {
327            nodes: vec![
328                GraphNode::new("a", "fn", "alpha")
329                    .with_provenance(GraphProvenance::new("src", "a.rs")),
330                GraphNode::new("b", "fn", "beta"),
331            ],
332            edges: vec![
333                GraphEdge::new("a", "b", "calls").with_freshness(GraphFreshness::content_hash("h")),
334            ],
335        }
336        .sorted();
337        let terse = TerseGraphSubgraph::from(subgraph);
338        assert_eq!(terse.nodes.len(), 2);
339        assert_eq!(terse.edges.len(), 1);
340        assert_eq!(terse.edges[0].from_id, "a");
341    }
342
343    #[test]
344    fn convex_store_rejects_edges_when_projection_nodes_are_missing() {
345        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
346        store
347            .upsert_node(&GraphNode::new("doc:livekit", "document", "LiveKit guide"))
348            .unwrap();
349
350        let err = store
351            .upsert_edge(&GraphEdge::new("doc:livekit", "topic:rooms", "mentions"))
352            .unwrap_err();
353        assert!(
354            err.to_string().contains("references missing to node"),
355            "{err}"
356        );
357    }
358
359    #[test]
360    fn ranked_neighborhood_returns_none_for_missing_center() {
361        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
362        let options = RankedNeighborhoodOptions::new(2, 10);
363        let result = store.ranked_neighborhood("missing", &options).unwrap();
364        assert!(result.is_none());
365    }
366
367    #[test]
368    fn ranked_neighborhood_breadth_first_respects_max_nodes() {
369        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
370        store
371            .upsert_node(&GraphNode::new("a", "file", "a"))
372            .unwrap();
373        store
374            .upsert_node(&GraphNode::new("b", "symbol", "b"))
375            .unwrap();
376        store
377            .upsert_node(&GraphNode::new("c", "symbol", "c"))
378            .unwrap();
379        store
380            .upsert_node(&GraphNode::new("d", "symbol", "d"))
381            .unwrap();
382        store
383            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
384            .unwrap();
385        store
386            .upsert_edge(&GraphEdge::new("a", "c", "calls"))
387            .unwrap();
388        store
389            .upsert_edge(&GraphEdge::new("a", "d", "calls"))
390            .unwrap();
391
392        let options = RankedNeighborhoodOptions::new(2, 2);
393        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
394        assert!(
395            result.nodes.len() <= 3,
396            "center + max 2 neighbors, got {}",
397            result.nodes.len()
398        );
399        assert!(result.pruned_count > 0, "should have pruned some nodes");
400        assert!(result.total_discovered >= 4);
401    }
402
403    #[test]
404    fn ranked_neighborhood_edge_kind_weighted_prefers_high_score_edges() {
405        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
406        store
407            .upsert_node(&GraphNode::new("a", "file", "a"))
408            .unwrap();
409        store
410            .upsert_node(&GraphNode::new("b", "symbol", "b"))
411            .unwrap();
412        store
413            .upsert_node(&GraphNode::new("c", "symbol", "c"))
414            .unwrap();
415        store
416            .upsert_edge(&GraphEdge::new("a", "b", "semantic_relation"))
417            .unwrap();
418        store
419            .upsert_edge(&GraphEdge::new("a", "c", "unknown"))
420            .unwrap();
421
422        let options = RankedNeighborhoodOptions::new(1, 1)
423            .with_scoring(NeighborhoodScoring::EdgeKindWeighted);
424        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
425        let neighbor_ids: Vec<_> = result.nodes.iter().map(|n| n.id.as_str()).collect();
426        assert!(
427            neighbor_ids.contains(&"b"),
428            "semantic_relation neighbor should survive pruning"
429        );
430    }
431
432    #[test]
433    fn ranked_neighborhood_prefers_recent_memory_nodes_when_pruning() {
434        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
435        store
436            .upsert_node(&GraphNode::new("center", "file", "center"))
437            .unwrap();
438        store
439            .upsert_node(&GraphNode::new("aaa-code", "symbol", "code candidate"))
440            .unwrap();
441        store
442            .upsert_node(
443                &GraphNode::new("mmm-stale", "memory_event", "stale memory")
444                    .with_property("provider", "tsift-memory")
445                    .with_property("observed_at_unix", "1000"),
446            )
447            .unwrap();
448        store
449            .upsert_node(
450                &GraphNode::new("zzz-fresh", "memory_event", "fresh memory")
451                    .with_property("provider", "tsift-memory")
452                    .with_property("observed_at_unix", "1995"),
453            )
454            .unwrap();
455        store
456            .upsert_edge(&GraphEdge::new("center", "aaa-code", "mentions"))
457            .unwrap();
458        store
459            .upsert_edge(&GraphEdge::new("center", "mmm-stale", "mentions"))
460            .unwrap();
461        store
462            .upsert_edge(&GraphEdge::new("center", "zzz-fresh", "mentions"))
463            .unwrap();
464
465        let options = RankedNeighborhoodOptions::new(1, 1)
466            .with_observed_at_now_unix(2000)
467            .with_observed_at_half_life_secs(100);
468        let result = store
469            .ranked_neighborhood("center", &options)
470            .unwrap()
471            .unwrap();
472        let ids: Vec<_> = result.nodes.iter().map(|node| node.id.as_str()).collect();
473        assert!(ids.contains(&"center"));
474        assert!(
475            ids.contains(&"zzz-fresh"),
476            "fresh memory node should survive pruning: {ids:?}"
477        );
478        assert!(!ids.contains(&"aaa-code"));
479        assert!(!ids.contains(&"mmm-stale"));
480    }
481
482    #[test]
483    fn semantic_seeded_neighborhood_scores_before_caps() {
484        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
485        store
486            .upsert_node(&GraphNode::new("seed", "semantic_concept", "graph budget"))
487            .unwrap();
488        store
489            .upsert_node(&GraphNode::new("zzz_high", "symbol", "high_signal"))
490            .unwrap();
491        store
492            .upsert_edge(&GraphEdge::new("zzz_high", "seed", "mentions_concept"))
493            .unwrap();
494        for idx in 0..24 {
495            let id = format!("aaa_low_{idx:02}");
496            store
497                .upsert_node(&GraphNode::new(id.clone(), "note", format!("low {idx}")))
498                .unwrap();
499            store
500                .upsert_edge(&GraphEdge::new(id, "seed", "weak_link"))
501                .unwrap();
502        }
503
504        let options = SemanticSeededNeighborhoodOptions::new(1, 3)
505            .with_edge_scan_cap(16)
506            .with_node_discovery_cap(9);
507        let result = store
508            .semantic_seeded_neighborhood(&["seed".to_string()], &options)
509            .unwrap();
510        let ids = result
511            .nodes
512            .iter()
513            .map(|node| node.id.as_str())
514            .collect::<Vec<_>>();
515
516        assert_eq!(ids.len(), 3);
517        assert_eq!(ids[0], "seed");
518        assert_eq!(ids[1], "zzz_high");
519        assert!(result.skipped_by_edge_cap > 0);
520        assert!(result.truncated);
521    }
522
523    #[test]
524    fn ranked_neighborhood_includes_center_node() {
525        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
526        store
527            .upsert_node(&GraphNode::new("center", "file", "center"))
528            .unwrap();
529        store
530            .upsert_node(&GraphNode::new("neighbor", "symbol", "neighbor"))
531            .unwrap();
532        store
533            .upsert_edge(&GraphEdge::new("center", "neighbor", "calls"))
534            .unwrap();
535
536        let options = RankedNeighborhoodOptions::new(1, 10);
537        let result = store
538            .ranked_neighborhood("center", &options)
539            .unwrap()
540            .unwrap();
541        let ids: Vec<_> = result.nodes.iter().map(|n| n.id.clone()).collect();
542        assert!(ids.contains(&"center".to_string()));
543        assert!(ids.contains(&"neighbor".to_string()));
544        assert_eq!(result.pruned_count, 0);
545    }
546
547    #[test]
548    fn ranked_neighborhood_edge_kind_filter() {
549        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
550        store
551            .upsert_node(&GraphNode::new("a", "file", "a"))
552            .unwrap();
553        store
554            .upsert_node(&GraphNode::new("b", "symbol", "b"))
555            .unwrap();
556        store
557            .upsert_node(&GraphNode::new("c", "symbol", "c"))
558            .unwrap();
559        store
560            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
561            .unwrap();
562        store
563            .upsert_edge(&GraphEdge::new("a", "c", "mentions"))
564            .unwrap();
565
566        let options = RankedNeighborhoodOptions::new(1, 10).with_edge_kind("calls");
567        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
568        let ids: Vec<_> = result.nodes.iter().map(|n| n.id.as_str()).collect();
569        assert!(ids.contains(&"b"));
570        assert!(!ids.contains(&"c"), "mentions edge should be filtered out");
571    }
572
573    #[test]
574    fn ranked_neighborhood_degree_weighted_prefers_low_degree() {
575        let store = ConvexGraphStore::new(MemoryConvexGraphClient::default());
576        store
577            .upsert_node(&GraphNode::new("a", "file", "a"))
578            .unwrap();
579        store
580            .upsert_node(&GraphNode::new("b", "symbol", "b"))
581            .unwrap();
582        store
583            .upsert_node(&GraphNode::new("c", "symbol", "c"))
584            .unwrap();
585        store
586            .upsert_node(&GraphNode::new("d", "symbol", "d"))
587            .unwrap();
588        store
589            .upsert_node(&GraphNode::new("e", "symbol", "e"))
590            .unwrap();
591        store
592            .upsert_edge(&GraphEdge::new("a", "b", "calls"))
593            .unwrap();
594        store
595            .upsert_edge(&GraphEdge::new("b", "c", "calls"))
596            .unwrap();
597        store
598            .upsert_edge(&GraphEdge::new("b", "d", "calls"))
599            .unwrap();
600        store
601            .upsert_edge(&GraphEdge::new("b", "e", "calls"))
602            .unwrap();
603
604        let options =
605            RankedNeighborhoodOptions::new(2, 3).with_scoring(NeighborhoodScoring::DegreeWeighted);
606        let result = store.ranked_neighborhood("a", &options).unwrap().unwrap();
607        assert!(result.nodes.len() <= 4);
608    }
609
610    #[test]
611    fn compute_neighborhood_score_breadth_first() {
612        let score = store::compute_neighborhood_score(
613            &NeighborhoodScoring::BreadthFirst,
614            0,
615            "calls",
616            &GraphNode::new("x", "symbol", "x"),
617            &BTreeMap::new(),
618        );
619        assert_eq!(score, 120);
620        let score_d2 = store::compute_neighborhood_score(
621            &NeighborhoodScoring::BreadthFirst,
622            2,
623            "calls",
624            &GraphNode::new("x", "symbol", "x"),
625            &BTreeMap::new(),
626        );
627        assert_eq!(score_d2, 84);
628    }
629
630    #[test]
631    fn compute_neighborhood_score_edge_kind_weighted() {
632        let score_semantic = store::compute_neighborhood_score(
633            &NeighborhoodScoring::EdgeKindWeighted,
634            1,
635            "semantic_relation",
636            &GraphNode::new("x", "symbol", "x"),
637            &BTreeMap::new(),
638        );
639        let score_unknown = store::compute_neighborhood_score(
640            &NeighborhoodScoring::EdgeKindWeighted,
641            1,
642            "unknown",
643            &GraphNode::new("x", "symbol", "x"),
644            &BTreeMap::new(),
645        );
646        assert!(score_semantic > score_unknown);
647    }
648
649    #[test]
650    fn compute_ranked_neighborhood_score_applies_decay_and_memory_signal() {
651        let options = RankedNeighborhoodOptions::new(1, 1)
652            .with_observed_at_now_unix(2_000)
653            .with_observed_at_half_life_secs(100)
654            .with_observed_at_weight(24)
655            .with_memory_node_boost(18);
656        let context = store::NeighborhoodScoreContext::from_options(&options);
657        let code = GraphNode::new("code", "symbol", "code");
658        let stale_memory = GraphNode::new("stale", "memory_event", "stale")
659            .with_property("provider", "tsift-memory")
660            .with_property("observed_at_unix", "1000");
661        let fresh_memory = GraphNode::new("fresh", "memory_event", "fresh")
662            .with_property("provider", "tsift-memory")
663            .with_property("observed_at_unix", "1995");
664
665        let code_score = store::compute_ranked_neighborhood_score(
666            &options,
667            context,
668            1,
669            "mentions",
670            &code,
671            &BTreeMap::new(),
672        );
673        let stale_score = store::compute_ranked_neighborhood_score(
674            &options,
675            context,
676            1,
677            "mentions",
678            &stale_memory,
679            &BTreeMap::new(),
680        );
681        let fresh_score = store::compute_ranked_neighborhood_score(
682            &options,
683            context,
684            1,
685            "mentions",
686            &fresh_memory,
687            &BTreeMap::new(),
688        );
689
690        assert!(fresh_score > stale_score);
691        assert!(stale_score > code_score);
692    }
693}