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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
//! Discord-structured second-order retrieval.
//!
//! Finds items that are related to the top-K direct search results but are
//! *not* themselves in the direct results, using graph edges. This is a
//! second-order retrieval step: given the anchors (direct hits) and a set of
//! graph edges, surface neighbours of the anchors as "discord" results ranked
//! by edge weight scaled by anchor position.
//!
//! Graceful degradation: an empty graph or empty direct-result set yields an
//! empty result vector — never a crash.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Configuration for discord-structured retrieval.
#[derive(Debug, Clone)]
pub struct DiscordConfig {
/// How many of the top direct results to use as anchors. Defaults to 5.
pub top_k: usize,
/// Maximum number of discord neighbours to return. Defaults to 10.
pub max_neighbors: usize,
/// Minimum discord score to keep a neighbour. Defaults to 0.1.
pub min_score: f64,
}
impl Default for DiscordConfig {
fn default() -> Self {
Self {
top_k: 5,
max_neighbors: 10,
min_score: 0.1,
}
}
}
impl DiscordConfig {
/// Convenience constructor overriding defaults.
pub fn new(top_k: usize, max_neighbors: usize, min_score: f64) -> Self {
Self {
top_k,
max_neighbors,
min_score,
}
}
}
/// A lightweight, owned reference to a graph edge used for discord scoring.
#[derive(Debug, Clone, PartialEq)]
pub struct GraphEdgeRef {
pub source: String,
pub target: String,
pub edge_type: String,
pub weight: f64,
}
impl GraphEdgeRef {
/// Convenience constructor.
pub fn new(
source: impl Into<String>,
target: impl Into<String>,
edge_type: impl Into<String>,
weight: f64,
) -> Self {
Self {
source: source.into(),
target: target.into(),
edge_type: edge_type.into(),
weight,
}
}
}
/// A single discord-structured retrieval result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiscordResult {
/// The neighbour item id (not present in the direct results).
pub item_id: String,
/// Aggregated discord score. Higher is better.
pub discord_score: f64,
/// Anchor ids (direct results) that connect to this neighbour.
pub anchor_ids: Vec<String>,
/// Relationship/edge types observed connecting the anchors to this item.
pub relationship_types: Vec<String>,
}
/// Scores discord-structured second-order retrieval results.
pub struct DiscordScorer {
config: DiscordConfig,
}
impl DiscordScorer {
/// Create a scorer with the given configuration.
pub fn new(config: DiscordConfig) -> Self {
Self { config }
}
/// Create a scorer with default configuration.
pub fn with_defaults() -> Self {
Self::new(DiscordConfig::default())
}
/// Compute discord results for the given direct result ids and graph edges.
///
/// For each direct result (anchor), we look at graph edges whose `source`
/// or `target` is the anchor and whose *other* endpoint is NOT itself a
/// direct result. The neighbour's score contribution is
/// `edge_weight * (1.0 - anchor_position / direct_count)`, where
/// `anchor_position` is the 0-based index of the anchor in the
/// direct-result list (earlier/better anchors contribute more).
///
/// Scores are summed across anchors, so a neighbour connected to multiple
/// anchors accumulates a higher score. Results are sorted best-first and
/// truncated to `max_neighbors`, dropping anything below `min_score`.
pub fn score(
&self,
direct_result_ids: &[String],
graph_edges: &[GraphEdgeRef],
) -> Vec<DiscordResult> {
// Graceful degradation.
if direct_result_ids.is_empty() || graph_edges.is_empty() {
return Vec::new();
}
let anchors: Vec<&String> = direct_result_ids.iter().take(self.config.top_k).collect();
let direct_set: std::collections::HashSet<&String> = direct_result_ids.iter().collect();
let anchor_positions: HashMap<&String, usize> = anchors
.iter()
.enumerate()
.map(|(pos, id)| (*id, pos))
.collect();
let direct_count = anchors.len() as f64;
// Accumulate per-neighbour scores and provenance.
let mut scores: HashMap<String, f64> = HashMap::new();
let mut anchor_map: HashMap<String, Vec<String>> = HashMap::new();
let mut rel_map: HashMap<String, Vec<String>> = HashMap::new();
for edge in graph_edges {
// Determine which endpoint is an anchor and which is the candidate
// neighbour. We consider both directions of the edge.
let neighbours: Vec<(&String, &String)> = if anchor_positions.contains_key(&edge.source)
{
// source is an anchor → target is a candidate neighbour.
if direct_set.contains(&edge.target) {
// Both endpoints are direct results; skip.
Vec::new()
} else {
vec![(&edge.target, &edge.source)]
}
} else if anchor_positions.contains_key(&edge.target) {
if direct_set.contains(&edge.source) {
Vec::new()
} else {
vec![(&edge.source, &edge.target)]
}
} else {
Vec::new()
};
for (neighbour, anchor) in neighbours {
let anchor_pos = anchor_positions.get(anchor).copied().unwrap_or(0) as f64;
let contribution = edge.weight * (1.0 - anchor_pos / direct_count);
*scores.entry(neighbour.clone()).or_insert(0.0) += contribution;
let anchor_list = anchor_map.entry(neighbour.clone()).or_default();
if !anchor_list.contains(anchor) {
anchor_list.push(anchor.clone());
}
let rel_list = rel_map.entry(neighbour.clone()).or_default();
if !rel_list.contains(&edge.edge_type) {
rel_list.push(edge.edge_type.clone());
}
}
}
let mut results: Vec<DiscordResult> = scores
.into_iter()
.filter(|(_, s)| *s >= self.config.min_score)
.map(|(item_id, discord_score)| {
let anchor_ids = anchor_map.remove(&item_id).unwrap_or_default();
let relationship_types = rel_map.remove(&item_id).unwrap_or_default();
DiscordResult {
item_id,
discord_score,
anchor_ids,
relationship_types,
}
})
.collect();
// Sort by score descending; break ties by item_id for determinism.
results.sort_by(|a, b| {
b.discord_score
.partial_cmp(&a.discord_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.item_id.cmp(&b.item_id))
});
results.truncate(self.config.max_neighbors);
results
}
}
#[cfg(test)]
mod tests {
use super::*;
fn edge(source: &str, target: &str, etype: &str, weight: f64) -> GraphEdgeRef {
GraphEdgeRef::new(source, target, etype, weight)
}
#[test]
fn test_discord_finds_neighbors() {
// Anchors: a, b. Neighbours: x (via a), y (via b).
let direct = vec!["a".to_string(), "b".to_string()];
let edges = vec![
edge("a", "x", "related", 0.5),
edge("b", "y", "related", 0.5),
];
let scorer = DiscordScorer::with_defaults();
let res = scorer.score(&direct, &edges);
let ids: Vec<&str> = res.iter().map(|r| r.item_id.as_str()).collect();
assert!(ids.contains(&"x"));
assert!(ids.contains(&"y"));
}
#[test]
fn test_discord_excludes_direct_results() {
// An edge between two anchors should not produce a discord result.
let direct = vec!["a".to_string(), "b".to_string()];
let edges = vec![
edge("a", "b", "related", 0.9),
edge("a", "x", "related", 0.5),
];
let scorer = DiscordScorer::with_defaults();
let res = scorer.score(&direct, &edges);
let ids: Vec<&str> = res.iter().map(|r| r.item_id.as_str()).collect();
assert!(!ids.contains(&"a"));
assert!(!ids.contains(&"b"));
assert!(ids.contains(&"x"));
}
#[test]
fn test_discord_min_score_filter() {
// A neighbour with a tiny contribution should be filtered out.
let direct = vec!["a".to_string()];
let edges = vec![
edge("a", "low", "related", 0.05), // 0.05 * (1 - 0/1) = 0.05 < 0.1
edge("a", "high", "related", 0.5), // 0.5 >= 0.1
];
let scorer = DiscordScorer::with_defaults(); // min_score = 0.1
let res = scorer.score(&direct, &edges);
let ids: Vec<&str> = res.iter().map(|r| r.item_id.as_str()).collect();
assert!(ids.contains(&"high"));
assert!(!ids.contains(&"low"));
}
#[test]
fn test_discord_max_neighbors_limit() {
// Many neighbours, but only max_neighbors returned.
let direct = vec!["a".to_string()];
let edges: Vec<GraphEdgeRef> = (0..20)
.map(|i| edge("a", &format!("n{}", i), "related", 0.5))
.collect();
let config = DiscordConfig::new(5, 3, 0.1);
let scorer = DiscordScorer::new(config);
let res = scorer.score(&direct, &edges);
assert!(res.len() <= 3);
assert_eq!(res.len(), 3);
}
#[test]
fn test_discord_empty_graph() {
let direct = vec!["a".to_string()];
let edges: Vec<GraphEdgeRef> = Vec::new();
let scorer = DiscordScorer::with_defaults();
let res = scorer.score(&direct, &edges);
assert!(res.is_empty());
}
#[test]
fn test_discord_empty_direct_results() {
let direct: Vec<String> = Vec::new();
let edges = vec![edge("a", "x", "related", 0.5)];
let scorer = DiscordScorer::with_defaults();
let res = scorer.score(&direct, &edges);
assert!(res.is_empty());
}
#[test]
fn test_discord_multiple_anchors() {
// An item connected to two anchors should score higher than one
// connected to a single anchor.
let direct = vec!["a".to_string(), "b".to_string()];
let edges = vec![
edge("a", "multi", "related", 0.5),
edge("b", "multi", "related", 0.5),
edge("a", "single", "related", 0.5),
];
let scorer = DiscordScorer::with_defaults();
let res = scorer.score(&direct, &edges);
let multi = res
.iter()
.find(|r| r.item_id == "multi")
.expect("multi present");
let single = res
.iter()
.find(|r| r.item_id == "single")
.expect("single present");
assert!(
multi.discord_score > single.discord_score,
"multi ({}) should beat single ({})",
multi.discord_score,
single.discord_score
);
// multi should be the top result.
assert_eq!(res[0].item_id, "multi");
// multi should have both anchors recorded.
assert_eq!(multi.anchor_ids.len(), 2);
}
}