Skip to content

Commit 1dc8244

Browse files
committed
Add all-candidates path resolution mode
1 parent d40cc8e commit 1dc8244

11 files changed

Lines changed: 365 additions & 47 deletions

File tree

src/apps/wikidata/cli.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ pub enum QueryCommands {
130130
/// Maximum path depth (default: 8)
131131
#[arg(long, default_value = "8")]
132132
max_depth: usize,
133+
/// Search all resolved endpoint candidates instead of stopping at the best match
134+
#[arg(long, default_value_t = false)]
135+
all_candidates: bool,
133136
},
134137
/// Find top-k shortest simple paths, or all simple paths within bounds
135138
Paths {
@@ -143,6 +146,9 @@ pub enum QueryCommands {
143146
/// Maximum number of paths to return; omit to enumerate all paths within bounds
144147
#[arg(long)]
145148
limit: Option<usize>,
149+
/// Search all resolved endpoint candidates instead of stopping at the best match
150+
#[arg(long, default_value_t = false)]
151+
all_candidates: bool,
146152
},
147153
/// Find structurally similar entities (Jaccard / common-neighbor similarity)
148154
Similar {
@@ -169,3 +175,53 @@ pub enum QueryCommands {
169175
/// Start interactive mode (same as the top-level `interactive` command)
170176
Interactive,
171177
}
178+
179+
#[cfg(test)]
180+
mod tests {
181+
use super::{Cli, Commands, QueryCommands};
182+
use clap::Parser;
183+
184+
#[test]
185+
fn path_query_accepts_all_candidates_flag() {
186+
let cli = Cli::parse_from([
187+
"wikidata_cli",
188+
"query",
189+
"--server-url",
190+
"http://localhost:9090",
191+
"path",
192+
"Bill Gates",
193+
"Microsoft",
194+
"--all-candidates",
195+
]);
196+
197+
match cli.command {
198+
Commands::Query {
199+
query_command: QueryCommands::Path { all_candidates, .. },
200+
..
201+
} => assert!(all_candidates),
202+
_ => panic!("unexpected command shape"),
203+
}
204+
}
205+
206+
#[test]
207+
fn paths_query_accepts_all_candidates_flag() {
208+
let cli = Cli::parse_from([
209+
"wikidata_cli",
210+
"query",
211+
"--server-url",
212+
"http://localhost:9090",
213+
"paths",
214+
"Bill Gates",
215+
"Microsoft",
216+
"--all-candidates",
217+
]);
218+
219+
match cli.command {
220+
Commands::Query {
221+
query_command: QueryCommands::Paths { all_candidates, .. },
222+
..
223+
} => assert!(all_candidates),
224+
_ => panic!("unexpected command shape"),
225+
}
226+
}
227+
}

src/apps/wikidata/interactive.rs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,36 @@ impl InteractiveQueryMode {
121121
"path" => {
122122
let tparts: Vec<&str> = rest.split_whitespace().collect();
123123
if tparts.len() < 2 {
124-
println!("Usage: path <from_id> <to_id>");
124+
println!("Usage: path <from_id> <to_id> [--all-candidates]");
125125
} else {
126-
self.cmd_path(tparts[0], tparts[1]).await?;
126+
self.cmd_path(
127+
tparts[0],
128+
tparts[1],
129+
tparts
130+
.iter()
131+
.skip(2)
132+
.any(|arg| Self::is_all_candidates_flag(arg)),
133+
)
134+
.await?;
127135
}
128136
}
129137
"paths" => {
130138
let tparts: Vec<&str> = rest.split_whitespace().collect();
131139
if tparts.len() < 2 {
132-
println!("Usage: paths <from_id> <to_id> [max_depth] [limit]");
140+
println!(
141+
"Usage: paths <from_id> <to_id> [max_depth] [limit] [--all-candidates]"
142+
);
133143
} else {
134-
let max_depth: usize = tparts.get(2).and_then(|s| s.parse().ok()).unwrap_or(8);
135-
let limit = tparts.get(3).and_then(|s| s.parse().ok());
136-
self.cmd_paths(tparts[0], tparts[1], max_depth, limit)
144+
let positionals: Vec<&str> = tparts
145+
.iter()
146+
.copied()
147+
.filter(|arg| !Self::is_all_candidates_flag(arg))
148+
.collect();
149+
let max_depth: usize =
150+
positionals.get(2).and_then(|s| s.parse().ok()).unwrap_or(8);
151+
let limit = positionals.get(3).and_then(|s| s.parse().ok());
152+
let all_candidates = tparts.iter().any(|arg| Self::is_all_candidates_flag(arg));
153+
self.cmd_paths(tparts[0], tparts[1], max_depth, limit, all_candidates)
137154
.await?;
138155
}
139156
}
@@ -182,8 +199,12 @@ impl InteractiveQueryMode {
182199
println!(" traverse <id> [depth] Graph traversal (depth=1: show connections;");
183200
println!(" depth>1: show subgraph structure)");
184201
println!(" subgraph <id> [depth] Extract bounded subgraph");
185-
println!(" path <from> <to> Find shortest path between two entities");
186-
println!(" paths <from> <to> [depth] [limit] Find top-k shortest simple paths");
202+
println!(
203+
" path <from> <to> [--all-candidates] Find shortest path between two entities"
204+
);
205+
println!(
206+
" paths <from> <to> [depth] [limit] [--all-candidates] Find top-k shortest simple paths"
207+
);
187208
println!(" similar <id> Structurally similar entities (Jaccard,");
188209
println!(" based on shared graph connections)");
189210
println!(" dsl <json> Execute a raw /v1/dsl/query JSON request");
@@ -651,7 +672,12 @@ impl InteractiveQueryMode {
651672

652673
// ── path ─────────────────────────────────────────────────────────────────
653674

654-
async fn cmd_path(&self, from: &str, to: &str) -> Result<(), Box<dyn std::error::Error>> {
675+
async fn cmd_path(
676+
&self,
677+
from: &str,
678+
to: &str,
679+
all_candidates: bool,
680+
) -> Result<(), Box<dyn std::error::Error>> {
655681
let t = Instant::now();
656682
let req = ShortestPathRequest {
657683
database: None,
@@ -667,6 +693,7 @@ impl InteractiveQueryMode {
667693
},
668694
select: Some(vec!["id".to_string(), "label".to_string()]),
669695
edge_select: Some(vec!["property_id".to_string()]),
696+
all_candidates,
670697
};
671698
let result = self.client.dsl_path(req).await?;
672699
println!(
@@ -688,6 +715,7 @@ impl InteractiveQueryMode {
688715
to: &str,
689716
max_depth: usize,
690717
limit: Option<usize>,
718+
all_candidates: bool,
691719
) -> Result<(), Box<dyn std::error::Error>> {
692720
let t = Instant::now();
693721
let req = PathsRequest {
@@ -706,6 +734,7 @@ impl InteractiveQueryMode {
706734
limit,
707735
select: Some(vec!["id".to_string(), "label".to_string()]),
708736
edge_select: Some(vec!["property_id".to_string()]),
737+
all_candidates,
709738
};
710739
let result = self.client.dsl_paths(req).await?;
711740
println!(
@@ -731,6 +760,10 @@ impl InteractiveQueryMode {
731760
Ok(())
732761
}
733762

763+
fn is_all_candidates_flag(arg: &str) -> bool {
764+
matches!(arg, "--all-candidates" | "all_candidates")
765+
}
766+
734767
// ── similar (structural Jaccard — shared graph connections) ──────────────
735768

736769
async fn cmd_similar(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {

src/bin/wikidata_cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ async fn run_query_command(
647647
from_id,
648648
to_id,
649649
max_depth,
650+
all_candidates,
650651
} => {
651652
let req = ShortestPathRequest {
652653
database: None,
@@ -662,6 +663,7 @@ async fn run_query_command(
662663
},
663664
select: Some(vec!["id".to_string(), "label".to_string()]),
664665
edge_select: Some(vec!["property_id".to_string()]),
666+
all_candidates,
665667
};
666668
let t = Instant::now();
667669
let result = client.dsl_path(req).await?;
@@ -682,6 +684,7 @@ async fn run_query_command(
682684
to_id,
683685
max_depth,
684686
limit,
687+
all_candidates,
685688
} => {
686689
let req = PathsRequest {
687690
database: None,
@@ -699,6 +702,7 @@ async fn run_query_command(
699702
limit,
700703
select: Some(vec!["id".to_string(), "label".to_string()]),
701704
edge_select: Some(vec!["property_id".to_string()]),
705+
all_candidates,
702706
};
703707
let t = Instant::now();
704708
let result = client.dsl_paths(req).await?;

src/dsl/graph/exec_path.rs

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,38 @@ pub async fn execute_path(
5858
let coordinator = runtime.search_coordinator();
5959
let server_id = runtime.server_id();
6060
let (starts, goals, reverse_path) = select_shortest_path_search_plan(&from_ids, &to_ids);
61+
let best = if query.all_candidates {
62+
let mut best: Option<(usize, Vec<Id>)> = None;
63+
for &goal in goals {
64+
let mut result = run_bfs(
65+
&coordinator,
66+
server_id,
67+
starts,
68+
&[goal],
69+
&query,
70+
runtime,
71+
reverse_path,
72+
)
73+
.await?;
6174

62-
// BfsSearchConfig accepts a single goal. Run repeated BFS searches against
63-
// the smaller endpoint set and reverse the resulting path when we search
64-
// from the destination side.
65-
let mut best: Option<(usize, Vec<Id>)> = None;
66-
67-
for &goal in goals {
75+
if result.found {
76+
if reverse_path {
77+
result.path.reverse();
78+
}
79+
let hops = result.path.len().saturating_sub(1);
80+
let is_better = best.as_ref().map_or(true, |(prev, _)| hops < *prev);
81+
if is_better {
82+
best = Some((hops, result.path));
83+
}
84+
}
85+
}
86+
best
87+
} else {
6888
let mut result = run_bfs(
6989
&coordinator,
7090
server_id,
7191
starts,
72-
goal,
92+
goals,
7393
&query,
7494
runtime,
7595
reverse_path,
@@ -80,13 +100,11 @@ pub async fn execute_path(
80100
if reverse_path {
81101
result.path.reverse();
82102
}
83-
let hops = result.path.len().saturating_sub(1);
84-
let is_better = best.as_ref().map_or(true, |(prev, _)| hops < *prev);
85-
if is_better {
86-
best = Some((hops, result.path));
87-
}
103+
Some((result.path.len().saturating_sub(1), result.path))
104+
} else {
105+
None
88106
}
89-
}
107+
};
90108

91109
let Some((hops, path_ids)) = best else {
92110
return Ok(ShortestPathResult {
@@ -165,7 +183,17 @@ pub async fn execute_paths(
165183
.await;
166184
}
167185

168-
let goal_set: HashSet<Id> = to_ids.iter().copied().collect();
186+
let goal_set: HashSet<Id> = if query.all_candidates {
187+
to_ids.iter().copied().collect()
188+
} else if let Some(goal_id) = shortest_path_ids
189+
.as_ref()
190+
.and_then(|path| path.last())
191+
.copied()
192+
{
193+
HashSet::from([goal_id])
194+
} else {
195+
to_ids.iter().copied().collect()
196+
};
169197
let direction_variants = distributed_directions_for_edge_schemas(
170198
runtime,
171199
&query.edge_schema_ids,
@@ -268,14 +296,38 @@ async fn find_best_shortest_path_ids_for_paths(
268296
let coordinator = runtime.search_coordinator();
269297
let server_id = runtime.server_id();
270298
let (starts, goals, reverse_path) = select_shortest_path_search_plan(from_ids, to_ids);
271-
let mut best: Option<(usize, Vec<Id>)> = None;
299+
let best = if query.all_candidates {
300+
let mut best: Option<(usize, Vec<Id>)> = None;
301+
for &goal in goals {
302+
let mut result = run_bfs_for_paths(
303+
&coordinator,
304+
server_id,
305+
starts,
306+
&[goal],
307+
query,
308+
runtime,
309+
reverse_path,
310+
)
311+
.await?;
272312

273-
for &goal in goals {
313+
if result.found {
314+
if reverse_path {
315+
result.path.reverse();
316+
}
317+
let hops = result.path.len().saturating_sub(1);
318+
let is_better = best.as_ref().map_or(true, |(prev, _)| hops < *prev);
319+
if is_better {
320+
best = Some((hops, result.path));
321+
}
322+
}
323+
}
324+
best
325+
} else {
274326
let mut result = run_bfs_for_paths(
275327
&coordinator,
276328
server_id,
277329
starts,
278-
goal,
330+
goals,
279331
query,
280332
runtime,
281333
reverse_path,
@@ -286,13 +338,11 @@ async fn find_best_shortest_path_ids_for_paths(
286338
if reverse_path {
287339
result.path.reverse();
288340
}
289-
let hops = result.path.len().saturating_sub(1);
290-
let is_better = best.as_ref().map_or(true, |(prev, _)| hops < *prev);
291-
if is_better {
292-
best = Some((hops, result.path));
293-
}
341+
Some((result.path.len().saturating_sub(1), result.path))
342+
} else {
343+
None
294344
}
295-
}
345+
};
296346

297347
Ok(best.map(|(_, path)| path))
298348
}
@@ -301,7 +351,7 @@ async fn run_bfs_for_paths(
301351
coordinator: &SearchCoordinator,
302352
server_id: u64,
303353
start: &[Id],
304-
goal: Id,
354+
goals: &[Id],
305355
query: &BoundPathsQuery,
306356
runtime: &Arc<MorpheusRuntime>,
307357
reverse_direction: bool,
@@ -326,7 +376,7 @@ async fn run_bfs_for_paths(
326376
server_id,
327377
BfsSearchConfig {
328378
start: start.to_vec(),
329-
goal,
379+
goals: goals.to_vec(),
330380
max_depth: query.max_depth as u32,
331381
max_visited: query.max_visited,
332382
expand_options: ExpandJobOptions {
@@ -408,7 +458,7 @@ async fn run_bfs(
408458
coordinator: &SearchCoordinator,
409459
server_id: u64,
410460
start: &[Id],
411-
goal: Id,
461+
goals: &[Id],
412462
query: &BoundShortestPathQuery,
413463
runtime: &Arc<MorpheusRuntime>,
414464
reverse_direction: bool,
@@ -433,7 +483,7 @@ async fn run_bfs(
433483
server_id,
434484
BfsSearchConfig {
435485
start: start.to_vec(),
436-
goal,
486+
goals: goals.to_vec(),
437487
max_depth: query.max_depth as u32,
438488
max_visited: query.max_visited,
439489
expand_options: ExpandJobOptions {

0 commit comments

Comments
 (0)