@@ -31,6 +31,42 @@ export type RetrieveActionsInput = {
3131 * while still preferring on-context candidates when scores are close.
3232 */
3333 selectedContexts ?: readonly string [ ] ;
34+ /**
35+ * When `true`, capture each stage's full pre-fusion output and emit it
36+ * in `response.measurement`. Default `false` — no allocation cost in
37+ * production. Toggle via the `MILADY_RETRIEVAL_MEASUREMENT=1` env var
38+ * on the caller side.
39+ */
40+ measurementMode ?: boolean ;
41+ /**
42+ * Optional per-tier overrides for retrieval. When provided, the call
43+ * uses these instead of the in-file constants. Wired by the benchmark
44+ * harness from `RETRIEVAL_DEFAULTS_BY_TIER`.
45+ */
46+ tierOverrides ?: {
47+ topK ?: number ;
48+ stageWeights ?: Partial < Record < RetrievalStageName , number > > ;
49+ } ;
50+ } ;
51+
52+ export type RetrievalStageEntry = {
53+ actionName : string ;
54+ score : number ;
55+ rank : number ;
56+ } ;
57+
58+ export type RetrievalPerStageScores = {
59+ exact : RetrievalStageEntry [ ] ;
60+ regex : RetrievalStageEntry [ ] ;
61+ keyword : RetrievalStageEntry [ ] ;
62+ bm25 : RetrievalStageEntry [ ] ;
63+ embedding : RetrievalStageEntry [ ] ;
64+ contextMatch : RetrievalStageEntry [ ] ;
65+ } ;
66+
67+ export type RetrievalMeasurement = {
68+ perStageScores : RetrievalPerStageScores ;
69+ fusedTopK : Array < { actionName : string ; rrfScore : number ; rank : number } > ;
3470} ;
3571
3672export type ActionRetrievalResult = {
@@ -53,6 +89,12 @@ export type ActionRetrievalResponse = {
5389 candidateActions : string [ ] ;
5490 parentActionHints : string [ ] ;
5591 } ;
92+ /**
93+ * Per-stage retrieval funnel. Populated only when
94+ * `input.measurementMode === true`. The benchmark harness consumes
95+ * this to compute stage-by-stage recall.
96+ */
97+ measurement ?: RetrievalMeasurement ;
5698} ;
5799
58100const BM25_K1 = 0.9 ;
@@ -99,7 +141,8 @@ export function retrieveActions(
99141 bm25 : rankScores ( bm25Scores ) ,
100142 embedding : rankScores ( embeddingScores ) ,
101143 } ;
102- const rrfScores = reciprocalRankFusion ( stageRankings ) ;
144+ const stageWeights = input . tierOverrides ?. stageWeights ;
145+ const rrfScores = reciprocalRankFusion ( stageRankings , stageWeights ) ;
103146 const maxRrf = Math . max ( 0 , ...rrfScores . values ( ) ) ;
104147 const maxKeyword = Math . max ( 0 , ...keywordScores . values ( ) ) ;
105148 const maxBm25 = Math . max ( 0 , ...bm25Scores . values ( ) ) ;
@@ -195,15 +238,63 @@ export function retrieveActions(
195238 ) ;
196239 } ) ;
197240
198- const limit = Number . isFinite ( input . limit )
199- ? Math . max ( 0 , input . limit ?? 0 )
241+ const effectiveLimit =
242+ input . tierOverrides ?. topK ??
243+ ( Number . isFinite ( input . limit ) ? input . limit : undefined ) ;
244+ const limit = Number . isFinite ( effectiveLimit )
245+ ? Math . max ( 0 , effectiveLimit ?? 0 )
200246 : 0 ;
201247 const limited = limit > 0 ? results . slice ( 0 , limit ) : results ;
202248
203249 for ( let index = 0 ; index < limited . length ; index += 1 ) {
204250 limited [ index ] . rank = index + 1 ;
205251 }
206252
253+ let measurement : RetrievalMeasurement | undefined ;
254+ if ( input . measurementMode === true ) {
255+ // Capture each stage's pre-fusion ranking so the analyzer can compute
256+ // stage-by-stage recall. Context-match scores are recomputed from the
257+ // per-parent boost so they're available alongside the other five
258+ // stages even though they're applied as an additive bump in the main
259+ // loop, not as a ranking source.
260+ const selectedContextSetForMeasurement = selectedContextSet ;
261+ const contextMatchScores = new Map < string , number > ( ) ;
262+ for ( const parent of input . catalog . parents ) {
263+ const parentContexts = Array . isArray ( parent . contexts )
264+ ? ( parent . contexts as readonly unknown [ ] )
265+ : [ ] ;
266+ if (
267+ selectedContextSetForMeasurement . size > 0 &&
268+ parentContexts . length > 0 &&
269+ parentContexts . some ( ( c ) =>
270+ selectedContextSetForMeasurement . has ( String ( c ) . toLowerCase ( ) ) ,
271+ )
272+ ) {
273+ contextMatchScores . set ( parent . normalizedName , 1 ) ;
274+ }
275+ }
276+
277+ measurement = {
278+ perStageScores : {
279+ exact : mapToStageEntries ( exactScores ) ,
280+ regex : mapToStageEntries ( regexScores ) ,
281+ keyword : mapToStageEntries ( keywordScores ) ,
282+ bm25 : mapToStageEntries ( bm25Scores ) ,
283+ embedding : mapToStageEntries ( embeddingScores ) ,
284+ contextMatch : mapToStageEntries ( contextMatchScores ) ,
285+ } ,
286+ fusedTopK : Array . from ( rrfScores . entries ( ) )
287+ . sort ( ( [ leftName , leftScore ] , [ rightName , rightScore ] ) => {
288+ return rightScore - leftScore || leftName . localeCompare ( rightName ) ;
289+ } )
290+ . map ( ( [ name , rrfScore ] , index ) => ( {
291+ actionName : name ,
292+ rrfScore : roundScore ( rrfScore ) ,
293+ rank : index + 1 ,
294+ } ) ) ,
295+ } ;
296+ }
297+
207298 return {
208299 results : limited ,
209300 warnings : input . catalog . warnings ,
@@ -213,9 +304,23 @@ export function retrieveActions(
213304 candidateActions,
214305 parentActionHints,
215306 } ,
307+ ...( measurement ? { measurement } : { } ) ,
216308 } ;
217309}
218310
311+ function mapToStageEntries ( scores : Map < string , number > ) : RetrievalStageEntry [ ] {
312+ return Array . from ( scores . entries ( ) )
313+ . filter ( ( [ , score ] ) => score > 0 )
314+ . sort ( ( [ leftName , leftScore ] , [ rightName , rightScore ] ) => {
315+ return rightScore - leftScore || leftName . localeCompare ( rightName ) ;
316+ } )
317+ . map ( ( [ actionName , score ] , index ) => ( {
318+ actionName,
319+ score : roundScore ( score ) ,
320+ rank : index + 1 ,
321+ } ) ) ;
322+ }
323+
219324export function tokenizeActionSearchText ( text : string ) : string [ ] {
220325 return String ( text ?? "" )
221326 . replace ( / ( [ a - z 0 - 9 ] ) ( [ A - Z ] ) / g, "$1 $2" )
@@ -445,16 +550,20 @@ function rankScores(scores: Map<string, number>): Map<string, number> {
445550
446551function reciprocalRankFusion (
447552 stageRankings : Partial < Record < RetrievalStageName , Map < string , number > > > ,
553+ stageWeights ?: Partial < Record < RetrievalStageName , number > > ,
448554) : Map < string , number > {
449555 const scores = new Map < string , number > ( ) ;
450556
451- for ( const ranking of Object . values ( stageRankings ) ) {
557+ for ( const [ stageName , ranking ] of Object . entries ( stageRankings ) as Array <
558+ [ RetrievalStageName , Map < string , number > | undefined ]
559+ > ) {
452560 if ( ! ranking ) {
453561 continue ;
454562 }
563+ const weight = stageWeights ?. [ stageName ] ?? 1 ;
455564
456565 for ( const [ name , rank ] of ranking . entries ( ) ) {
457- scores . set ( name , ( scores . get ( name ) ?? 0 ) + 1 / ( RRF_K + rank ) ) ;
566+ scores . set ( name , ( scores . get ( name ) ?? 0 ) + weight / ( RRF_K + rank ) ) ;
458567 }
459568 }
460569
0 commit comments