@@ -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) ]
167221mod tests {
168222 use super :: * ;
0 commit comments