Skip to content

Commit b6d82ca

Browse files
authored
Merge pull request #62 from vectorlessflow/dev
Dev
2 parents 730ba92 + 4a392bb commit b6d82ca

19 files changed

Lines changed: 632 additions & 71 deletions

File tree

rust/src/index/stages/reasoning.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::document::{
1515
TopicEntry,
1616
};
1717
use crate::error::Result;
18-
use crate::retrieval::search::extract_keywords;
18+
use crate::retrieval::scoring::extract_keywords;
1919

2020
use super::async_trait;
2121
use super::{AccessPattern, IndexStage, StageResult};

rust/src/retrieval/content/scorer.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use std::collections::HashMap;
1010

1111
use crate::document::NodeId;
12-
use crate::retrieval::search::{Bm25Params, STOPWORDS, extract_keywords};
12+
use crate::retrieval::scoring::{Bm25Params, STOPWORDS, extract_keywords};
1313
use crate::utils::estimate_tokens;
1414

1515
use super::config::ScoringStrategyConfig;

rust/src/retrieval/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pub mod complexity;
6060
pub mod content;
6161
pub mod pilot;
6262
pub mod pipeline;
63+
pub mod scoring;
6364
pub mod search;
6465
pub mod stages;
6566
pub mod strategy;

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/search/pilot_scorer.rs renamed to 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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mod builder;
3535
mod complexity;
3636
mod config;
3737
mod decision;
38+
mod decision_scorer;
3839
mod fallback;
3940
mod feedback;
4041
mod llm_pilot;
@@ -43,10 +44,12 @@ mod noop;
4344
mod parser;
4445
mod prompts;
4546
mod r#trait;
47+
mod scorer;
4648

4749
pub use complexity::detect_with_llm;
4850
pub use config::PilotConfig;
4951
pub use decision::{InterventionPoint, PilotDecision};
50-
52+
pub use decision_scorer::{PilotDecisionCache, ScoredCandidate, score_candidates, score_candidates_detailed};
5153
pub use llm_pilot::LlmPilot;
5254
pub use r#trait::{Pilot, SearchState};
55+
pub use scorer::{NodeScorer, ScoringContext};
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ use std::collections::HashMap;
1010

1111
use crate::document::{DocumentTree, NodeId};
1212

13-
use super::bm25::Bm25Params;
13+
use crate::retrieval::scoring::bm25::Bm25Params;
1414

1515
// Re-export extract_keywords for other modules to use
16-
pub use super::bm25::extract_keywords;
16+
pub use crate::retrieval::scoring::bm25::extract_keywords;
1717

1818
/// Scoring strategy to use.
1919
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]

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/scoring/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) 2026 vectorless developers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Scoring utilities for text relevance assessment.
5+
//!
6+
//! This module provides text scoring algorithms (BM25, keyword matching)
7+
//! that are used across the retrieval pipeline. These are general-purpose
8+
//! tools, not tied to any specific search algorithm.
9+
10+
pub mod bm25;
11+
12+
pub use bm25::{Bm25Engine, Bm25Params, FieldDocument, STOPWORDS, extract_keywords};

0 commit comments

Comments
 (0)