1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
//! Link prediction: per-node missing-edge suggestions. Read-only — the
//! engine suggests, the host decides (and picks the edge type). Spec:
//! 2026-07-19 PPR & link-prediction design.
use crate::db::Db;
use crate::error::TopoError;
use crate::ids::{NodeId, ScopeSet};
use crate::ppr::{ppr_over_subgraph, SUGGEST_HOPS, WEIGHT_SEMANTIC, WEIGHT_STRUCTURAL};
use crate::read::{Direction, TraversalQuery};
use crate::recall::{leg_depth, rrf_fuse};
use crate::state::NodeRecord;
use crate::vector::VectorQuery;
use std::collections::{HashMap, HashSet};
/// A `suggest_links` request. `model` names the vector namespace for the
/// semantic leg; `None` (or no stored embedding under it) = structure-only.
/// `as_of` pins the adjacency view, `None` = now (traverse's convention).
#[derive(Debug, Clone)]
pub struct SuggestLinksQuery {
pub scopes: ScopeSet,
pub node: NodeId,
pub k: usize,
pub model: Option<String>,
pub as_of: Option<i64>,
/// Optional semantic-leg floor: candidates whose cosine against the
/// target's embedding falls below this never enter the semantic list.
/// `None` = no floor (previous behavior). `Some(v)` must be finite and
/// within -1.0..=1.0 — anything else is `Rejected` (a host bug should
/// be loud). The structural leg is never affected.
pub min_semantic_similarity: Option<f32>,
}
/// One suggested (but non-existent) edge endpoint, with evidence. Never a
/// typed edge — creating one, and typing it, is host/agent policy.
#[derive(Debug, Clone)]
pub struct LinkSuggestion {
pub node: NodeRecord,
pub score: f32,
/// Shared 1-hop neighbors (scope/`as_of`-filtered), id-ascending.
pub common_neighbors: Vec<NodeId>,
/// Appeared in the PPR leg.
pub structural: bool,
/// Appeared in the vector leg.
pub semantic: bool,
/// Raw cosine between the target's embedding and this node's, under
/// the query's model — `Some` exactly when `semantic` is true (the
/// value the semantic leg ranked by). `None` for structural-only
/// suggestions: no extra vector reads are spent computing it.
pub similarity: Option<f32>,
}
impl Db {
/// Rank the k likeliest missing edges from `q.node`: RRF fusion of a
/// structural leg (PPR over the SUGGEST_HOPS-bounded neighborhood) and
/// a semantic leg (cosine against the node's own embedding under
/// `q.model`). Self and current 1-hop neighbors are never suggested.
/// Unknown/out-of-scope target → `Ok(empty)` (mirrors `Db::node`'s
/// absence semantics — no existence leak).
///
/// Note: the target lookup goes through `Db::node`, which bumps the
/// node's access counter — deliberate, matching every scoped read.
pub fn suggest_links(&self, q: &SuggestLinksQuery) -> Result<Vec<LinkSuggestion>, TopoError> {
if q.k == 0 {
return Err(TopoError::Rejected("suggest_links requires k > 0".into()));
}
if let Some(floor) = q.min_semantic_similarity {
if !floor.is_finite() || !(-1.0..=1.0).contains(&floor) {
return Err(TopoError::Rejected(format!(
"min_semantic_similarity must be finite and within -1.0..=1.0, got {floor}"
)));
}
}
// Target, scoped: absent and out-of-scope are indistinguishably
// empty, mirroring `Db::node`.
let Some(target) = self.node(&q.scopes, q.node) else {
return Ok(Vec::new());
};
let depth = leg_depth(q.k);
// Exclusion set: self + every node already 1-hop adjacent (any edge
// type, in-scope, live at as_of — the traversal's own filters).
let one_hop = self.traverse(&TraversalQuery {
scopes: q.scopes.clone(),
seeds: vec![q.node],
max_hops: 1,
edge_types: None,
direction: Direction::Both,
as_of: q.as_of,
})?;
let excluded: HashSet<NodeId> = one_hop.nodes.iter().map(|n| n.id).collect();
// Structural leg: PPR over the 3-hop neighborhood.
let sg = self.traverse(&TraversalQuery {
scopes: q.scopes.clone(),
seeds: vec![q.node],
max_hops: SUGGEST_HOPS,
edge_types: None,
direction: Direction::Both,
as_of: q.as_of,
})?;
let scored = ppr_over_subgraph(&sg, &[(q.node, 1.0)]);
let mut records: HashMap<NodeId, NodeRecord> =
sg.nodes.into_iter().map(|n| (n.id, n)).collect();
let structural: Vec<NodeId> = scored
.iter()
.map(|(id, _)| *id)
.filter(|id| !excluded.contains(id))
.take(depth)
.collect();
// Semantic leg: the target's own embedding under q.model. A missing
// or different-model embedding is an empty leg, never an error.
let mut semantic: Vec<NodeId> = Vec::new();
let mut sim: HashMap<NodeId, f32> = HashMap::new();
if let Some(model) = &q.model {
if let Some((stored_model, vector)) = &target.embedding {
if stored_model == model {
let hits = self.search_vector(&VectorQuery {
scopes: q.scopes.clone(),
model: model.clone(),
vector: vector.clone(),
// Headroom so exclusions can't starve the leg.
k: depth + excluded.len(),
candidates: None,
})?;
for (n, score) in hits {
if semantic.len() == depth {
break;
}
// Floor: a pure filter on the leg's own (descending)
// cosine — structural leg untouched by design.
// `continue`, not `break`, deliberately: NaN cosines
// (unvalidated embeddings) sort to the tail and fail
// every `<` — continue keeps them exactly as the
// no-floor path ranks them, instead of making their
// inclusion depend on preceding finite scores.
if q.min_semantic_similarity.is_some_and(|floor| score < floor) {
continue;
}
let nid = n.id;
if !excluded.contains(&nid) {
sim.insert(nid, score);
records.entry(nid).or_insert(n);
semantic.push(nid);
}
}
}
}
}
let in_structural: HashSet<NodeId> = structural.iter().copied().collect();
let in_semantic: HashSet<NodeId> = semantic.iter().copied().collect();
let mut lists: Vec<(f32, Vec<NodeId>)> = Vec::new();
if !structural.is_empty() {
lists.push((WEIGHT_STRUCTURAL, structural));
}
if !semantic.is_empty() {
lists.push((WEIGHT_SEMANTIC, semantic));
}
let mut out = Vec::new();
for (id, score) in rrf_fuse(&lists).into_iter().take(q.k) {
let Some(rec) = records.remove(&id) else {
continue;
};
// Evidence for the k survivors only: candidate's 1-hop set
// (same filters) intersected with the target's.
let cand_hop = self.traverse(&TraversalQuery {
scopes: q.scopes.clone(),
seeds: vec![id],
max_hops: 1,
edge_types: None,
direction: Direction::Both,
as_of: q.as_of,
})?;
let mut common: Vec<NodeId> = cand_hop
.nodes
.iter()
.map(|n| n.id)
.filter(|nid| *nid != id && *nid != q.node && excluded.contains(nid))
.collect();
common.sort();
out.push(LinkSuggestion {
node: rec,
score,
common_neighbors: common,
structural: in_structural.contains(&id),
semantic: in_semantic.contains(&id),
similarity: sim.get(&id).copied(),
});
}
Ok(out)
}
}