Skip to content

Commit 4a392bb

Browse files
committed
feat(pilot): add per-step reasoning support to search algorithms
Add optional per-step reasoning tracking throughout the search pipeline to provide better context for navigation decisions. This includes: - Extend build_path_section() to show enhanced navigation history with step-by-step reasoning when available, falling back to original breadcrumb format otherwise - Introduce ScoredCandidate struct with optional reasoning field and new score_candidates_detailed() function that preserves reasoning from the Pilot for each candidate - Add step_reasons field to SearchState to track reasoning history through the search process - Update beam search algorithm to capture and propagate reasoning for each navigation step, including backtracking scenarios - Modify greedy and MCTS search implementations to support reasoning parameters while maintaining backward compatibility - Enhance SearchPath struct with step_reasons vector to maintain per-step reasoning history throughout multi-path algorithms
1 parent 68fb727 commit 4a392bb

8 files changed

Lines changed: 187 additions & 44 deletions

File tree

rust/src/retrieval/pilot/builder.rs

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ impl ContextBuilder {
395395
ctx.estimated_tokens += self.estimate_tokens(&ctx.query_section);
396396

397397
// Build path section
398-
ctx.path_section = self.build_path_section(state.tree, state.path);
398+
ctx.path_section = self.build_path_section(state.tree, state.path, state.step_reasons);
399399
ctx.estimated_tokens += self.estimate_tokens(&ctx.path_section);
400400

401401
// Build candidates section
@@ -439,7 +439,7 @@ impl ContextBuilder {
439439
// Show failed path
440440
ctx.path_section = format!(
441441
"Failed path:\n{}",
442-
self.build_path_section(state.tree, failed_path)
442+
self.build_path_section(state.tree, failed_path, None)
443443
);
444444
ctx.estimated_tokens += self.estimate_tokens(&ctx.path_section);
445445

@@ -463,35 +463,67 @@ impl ContextBuilder {
463463
format!("User Query:\n{}\n", truncated)
464464
}
465465

466-
/// Build current path section.
467-
fn build_path_section(&self, tree: &DocumentTree, path: &[NodeId]) -> String {
466+
/// Build current path section with optional per-step reasoning.
467+
fn build_path_section(
468+
&self,
469+
tree: &DocumentTree,
470+
path: &[NodeId],
471+
step_reasons: Option<&[Option<String>]>,
472+
) -> String {
468473
if path.is_empty() {
469474
return "Current Position: Root\n".to_string();
470475
}
471476

472-
let mut result = String::from("Current Path:\n");
473-
result.push_str("Root");
477+
let has_reasons = step_reasons
478+
.map(|r| r.iter().any(|x| x.is_some()))
479+
.unwrap_or(false);
474480

475-
// Limit depth shown
476-
let max_depth = self.effective_max_path_depth();
477-
let start = if path.len() > max_depth {
478-
path.len() - max_depth
479-
} else {
480-
0
481-
};
481+
if !has_reasons {
482+
// Original breadcrumb format when no reasoning available
483+
let mut result = String::from("Current Path:\n");
484+
result.push_str("Root");
485+
486+
let max_depth = self.effective_max_path_depth();
487+
let start = if path.len() > max_depth {
488+
path.len() - max_depth
489+
} else {
490+
0
491+
};
482492

483-
if start > 0 {
484-
result.push_str(" → ...");
493+
if start > 0 {
494+
result.push_str(" → ...");
495+
}
496+
497+
for node_id in path.iter().skip(start) {
498+
if let Some(node) = tree.get(*node_id) {
499+
result.push_str(" → ");
500+
result.push_str(&node.title);
501+
}
502+
}
503+
504+
result.push('\n');
505+
return result;
485506
}
486507

487-
for node_id in path.iter().skip(start) {
508+
// Enhanced format with per-step reasoning
509+
let mut result = String::from("Navigation History:\n");
510+
let reasons = step_reasons.unwrap();
511+
512+
for (i, node_id) in path.iter().enumerate() {
488513
if let Some(node) = tree.get(*node_id) {
489-
result.push_str(" → ");
490-
result.push_str(&node.title);
514+
let reason = reasons
515+
.get(i)
516+
.and_then(|r| r.as_deref())
517+
.unwrap_or("(automatic selection)");
518+
result.push_str(&format!(
519+
" Step {}: {} — because: {}\n",
520+
i + 1,
521+
node.title,
522+
reason
523+
));
491524
}
492525
}
493526

494-
result.push('\n');
495527
result
496528
}
497529

rust/src/retrieval/pilot/decision_scorer.rs

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,54 @@ pub async fn score_candidates(
8686
visited: &HashSet<NodeId>,
8787
pilot_weight: f32,
8888
cache: Option<&PilotDecisionCache>,
89+
step_reasons: Option<&[Option<String>]>,
8990
) -> Vec<(NodeId, f32)> {
91+
let scored = score_candidates_detailed(
92+
tree, candidates, query, pilot, path, visited, pilot_weight, cache, step_reasons,
93+
)
94+
.await;
95+
scored.into_iter().map(|s| (s.node_id, s.score)).collect()
96+
}
97+
98+
/// A scored candidate with optional reasoning from the Pilot.
99+
#[derive(Debug, Clone)]
100+
pub struct ScoredCandidate {
101+
/// The node ID.
102+
pub node_id: NodeId,
103+
/// Relevance score (0.0 - 1.0).
104+
pub score: f32,
105+
/// Reason the Pilot chose this node, if available.
106+
pub reason: Option<String>,
107+
}
108+
109+
/// Score child candidates and return detailed results with reasons.
110+
///
111+
/// Like [`score_candidates`] but preserves per-candidate reasoning
112+
/// from the Pilot. Use this when the search algorithm needs to
113+
/// record why each path step was taken (e.g., for beam search
114+
/// reasoning history).
115+
pub async fn score_candidates_detailed(
116+
tree: &DocumentTree,
117+
candidates: &[NodeId],
118+
query: &str,
119+
pilot: Option<&dyn Pilot>,
120+
path: &[NodeId],
121+
visited: &HashSet<NodeId>,
122+
pilot_weight: f32,
123+
cache: Option<&PilotDecisionCache>,
124+
step_reasons: Option<&[Option<String>]>,
125+
) -> Vec<ScoredCandidate> {
90126
if candidates.is_empty() {
91127
return Vec::new();
92128
}
93129

94-
// If no Pilot, pure NodeScorer
130+
// If no Pilot, pure NodeScorer (no reasons available)
95131
let Some(p) = pilot else {
96-
return score_with_scorer(tree, candidates, query);
132+
return score_with_scorer_detailed(tree, candidates, query);
97133
};
98134

99135
if !p.is_active() {
100-
return score_with_scorer(tree, candidates, query);
136+
return score_with_scorer_detailed(tree, candidates, query);
101137
}
102138

103139
// Determine parent node (last in path) for cache key
@@ -109,20 +145,22 @@ pub async fn score_candidates(
109145
tracing::trace!("Pilot cache hit for parent={:?}", parent);
110146
cached
111147
} else {
112-
let state = SearchState::new(tree, query, path, candidates, visited);
148+
let mut state = SearchState::new(tree, query, path, candidates, visited);
149+
state.step_reasons = step_reasons;
113150
let d = p.decide(&state).await;
114151
c.put(query, parent, &d).await;
115152
d
116153
}
117154
} else {
118-
let state = SearchState::new(tree, query, path, candidates, visited);
155+
let mut state = SearchState::new(tree, query, path, candidates, visited);
156+
state.step_reasons = step_reasons;
119157
p.decide(&state).await
120158
};
121159

122-
// Build Pilot score map
123-
let mut pilot_scores: HashMap<NodeId, f32> = HashMap::new();
160+
// Build Pilot score + reason map
161+
let mut pilot_data: HashMap<NodeId, (f32, Option<String>)> = HashMap::new();
124162
for ranked in &decision.ranked_candidates {
125-
pilot_scores.insert(ranked.node_id, ranked.score);
163+
pilot_data.insert(ranked.node_id, (ranked.score, ranked.reason.clone()));
126164
}
127165

128166
// Compute NodeScorer fallback scores
@@ -132,24 +170,26 @@ pub async fn score_candidates(
132170

133171
let scorer = NodeScorer::new(ScoringContext::new(query));
134172

135-
let mut scored: Vec<(NodeId, f32)> = candidates
173+
let mut scored: Vec<ScoredCandidate> = candidates
136174
.iter()
137175
.map(|&node_id| {
138176
let algo_score = scorer.score(tree, node_id);
139-
let p_score = pilot_scores.get(&node_id).copied().unwrap_or(0.0);
177+
let (p_score, reason) = pilot_data.get(&node_id)
178+
.map(|(s, r)| (*s, r.clone()))
179+
.unwrap_or((0.0, None));
140180

141-
let final_score = if effective_pilot > 0.0 && pilot_scores.contains_key(&node_id) {
181+
let final_score = if effective_pilot > 0.0 && pilot_data.contains_key(&node_id) {
142182
(effective_pilot * p_score + scorer_weight * algo_score)
143183
/ (effective_pilot + scorer_weight)
144184
} else {
145185
algo_score
146186
};
147187

148-
(node_id, final_score)
188+
ScoredCandidate { node_id, score: final_score, reason }
149189
})
150190
.collect();
151191

152-
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
192+
scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
153193
scored
154194
}
155195

@@ -163,6 +203,20 @@ fn score_with_scorer(
163203
scorer.score_and_sort(tree, candidates)
164204
}
165205

206+
/// Pure NodeScorer fallback returning detailed results (no reasons).
207+
fn score_with_scorer_detailed(
208+
tree: &DocumentTree,
209+
candidates: &[NodeId],
210+
query: &str,
211+
) -> Vec<ScoredCandidate> {
212+
let scorer = NodeScorer::new(ScoringContext::new(query));
213+
scorer
214+
.score_and_sort(tree, candidates)
215+
.into_iter()
216+
.map(|(node_id, score)| ScoredCandidate { node_id, score, reason: None })
217+
.collect()
218+
}
219+
166220
#[cfg(test)]
167221
mod tests {
168222
use super::*;

rust/src/retrieval/pilot/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ mod scorer;
4949
pub use complexity::detect_with_llm;
5050
pub use config::PilotConfig;
5151
pub use decision::{InterventionPoint, PilotDecision};
52-
pub use decision_scorer::{PilotDecisionCache, score_candidates};
52+
pub use decision_scorer::{PilotDecisionCache, ScoredCandidate, score_candidates, score_candidates_detailed};
5353
pub use llm_pilot::LlmPilot;
5454
pub use r#trait::{Pilot, SearchState};
5555
pub use scorer::{NodeScorer, ScoringContext};

rust/src/retrieval/pilot/trait.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ pub struct SearchState<'a> {
4242
pub best_score: f32,
4343
/// Whether the search is currently backtracking.
4444
pub is_backtracking: bool,
45+
/// Per-step reasoning for why each node in `path` was chosen.
46+
///
47+
/// Same length as `path` when present. `None` means no reasoning
48+
/// history is available (e.g. first iteration, algorithm-only mode).
49+
pub step_reasons: Option<&'a [Option<String>]>,
4550
}
4651

4752
impl<'a> SearchState<'a> {
@@ -63,6 +68,7 @@ impl<'a> SearchState<'a> {
6368
iteration: 0,
6469
best_score: 0.0,
6570
is_backtracking: false,
71+
step_reasons: None,
6672
}
6773
}
6874

@@ -78,6 +84,7 @@ impl<'a> SearchState<'a> {
7884
iteration: 0,
7985
best_score: 0.0,
8086
is_backtracking: false,
87+
step_reasons: None,
8188
}
8289
}
8390

rust/src/retrieval/search/beam.rs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use tracing::debug;
2020

2121
use super::super::RetrievalContext;
2222
use super::super::types::{NavigationDecision, NavigationStep, SearchPath};
23-
use crate::retrieval::pilot::{PilotDecisionCache, score_candidates};
23+
use crate::retrieval::pilot::{PilotDecisionCache, score_candidates, score_candidates_detailed};
2424
use super::{SearchConfig, SearchResult, SearchTree};
2525
use crate::document::{DocumentTree, NodeId};
2626
use crate::retrieval::pilot::{Pilot, SearchState};
@@ -180,6 +180,7 @@ impl BeamSearch {
180180
iteration: result.iterations,
181181
best_score: result.paths.iter().map(|p| p.score).fold(0.0f32, f32::max),
182182
is_backtracking: true,
183+
step_reasons: Some(&entry.path.step_reasons),
183184
};
184185

185186
if let Some(decision) = p.guide_backtrack(&state).await {
@@ -251,7 +252,7 @@ impl BeamSearch {
251252
let start_children = tree.children(start_node);
252253
debug!("Start node has {} children", start_children.len());
253254

254-
let initial_candidates = score_candidates(
255+
let initial_candidates = score_candidates_detailed(
255256
tree,
256257
&start_children,
257258
&context.query,
@@ -260,6 +261,7 @@ impl BeamSearch {
260261
&visited,
261262
0.7, // Beam: Pilot weight = 0.7
262263
Some(&cache),
264+
None, // No reasoning history at start
263265
)
264266
.await;
265267

@@ -270,7 +272,14 @@ impl BeamSearch {
270272
// Split initial candidates into beam and fallback
271273
let mut sorted_initial: Vec<_> = initial_candidates
272274
.into_iter()
273-
.map(|(node_id, score)| SearchPath::from_node(node_id, score))
275+
.map(|s| {
276+
let mut path = SearchPath::from_node(s.node_id, s.score);
277+
// Record reason for initial selection
278+
if let Some(reason) = s.reason {
279+
path.step_reasons = vec![Some(reason)];
280+
}
281+
path
282+
})
274283
.collect();
275284
sorted_initial.sort_by(|a, b| {
276285
b.score
@@ -362,7 +371,7 @@ impl BeamSearch {
362371
// Expand this path
363372
let children = tree.children(leaf_id);
364373

365-
let scored_children = score_candidates(
374+
let scored_children = score_candidates_detailed(
366375
tree,
367376
&children,
368377
&context.query,
@@ -371,23 +380,28 @@ impl BeamSearch {
371380
&visited,
372381
0.7, // Beam: Pilot weight = 0.7
373382
Some(&cache),
383+
Some(&path.step_reasons),
374384
)
375385
.await;
376386

377387
if pilot.is_some() && !children.is_empty() {
378388
pilot_interventions += 1;
379389
}
380390

381-
for (child_id, child_score) in scored_children.into_iter().take(beam_width) {
382-
let new_path = path.extend(child_id, child_score);
391+
for sc in scored_children.into_iter().take(beam_width) {
392+
let new_path = if let Some(ref reason) = sc.reason {
393+
path.extend_with_reason(sc.node_id, sc.score, reason)
394+
} else {
395+
path.extend(sc.node_id, sc.score)
396+
};
383397

384-
let child_node = tree.get(child_id);
398+
let child_node = tree.get(sc.node_id);
385399
result.trace.push(NavigationStep {
386-
node_id: format!("{:?}", child_id),
400+
node_id: format!("{:?}", sc.node_id),
387401
title: child_node.map(|n| n.title.clone()).unwrap_or_default(),
388-
score: child_score,
402+
score: sc.score,
389403
decision: NavigationDecision::GoToChild(
390-
children.iter().position(|&c| c == child_id).unwrap_or(0),
404+
children.iter().position(|&c| c == sc.node_id).unwrap_or(0),
391405
),
392406
depth: child_node.map(|n| n.depth).unwrap_or(0),
393407
});
@@ -450,6 +464,7 @@ impl BeamSearch {
450464
&visited,
451465
0.7,
452466
None,
467+
None, // No reasoning history for fallback
453468
)
454469
.await;
455470
for (node_id, score) in fallback.into_iter().take(config.top_k) {

rust/src/retrieval/search/greedy.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ impl PurePilotSearch {
7777
&visited,
7878
1.0, // PurePilot: Pilot weight = 1.0
7979
Some(&cache),
80+
None, // No reasoning history tracked
8081
)
8182
.await;
8283

0 commit comments

Comments
 (0)