11import { describe , it } from "node:test" ;
22import assert from "node:assert/strict" ;
3- import { matchLoadout , lookupEntry } from "../match.js" ;
3+ import { matchLoadout , lookupEntry , DEFAULT_MIN_SCORE } from "../match.js" ;
44import type { LoadoutIndex } from "../types.js" ;
55import { DEFAULT_TRIGGERS } from "../types.js" ;
66
@@ -69,15 +69,20 @@ describe("matchLoadout", () => {
6969 assert . ok ( results [ 0 ] . reason . includes ( "keywords" ) ) ;
7070 } ) ;
7171
72- it ( "scores by keyword overlap proportion " , ( ) => {
72+ it ( "scores by recall-aware blend (coverage vs absolute) " , ( ) => {
7373 const index = makeIndex (
7474 { id : "narrow" , keywords : [ "ci" , "workflow" , "runner" , "matrix" , "dependabot" ] } ,
7575 { id : "broad" , keywords : [ "ci" , "workflow" ] } ,
7676 ) ;
7777 const results = matchLoadout ( "fix the ci workflow" , index ) ;
78- // "broad" has 2/2 match (1.0), "narrow" has 2/5 match (0.4)
78+ // FT-K1: both match 2 keywords.
79+ // broad: coverage 2/2 = 1.0, absolute 2/5 = 0.4 → max = 1.0
80+ // narrow: coverage 2/5 = 0.4, absolute 2/5 = 0.4 → max = 0.4
7981 assert . equal ( results [ 0 ] . entry . id , "broad" ) ;
80- assert . ok ( results [ 0 ] . score > results [ 1 ] . score ) ;
82+ assert . equal ( results [ 0 ] . score , 1.0 ) ;
83+ const narrow = results . find ( ( r ) => r . entry . id === "narrow" ) ! ;
84+ assert . equal ( narrow . score , 0.4 ) ;
85+ assert . ok ( results [ 0 ] . score > narrow . score ) ;
8186 } ) ;
8287
8388 it ( "gives pattern bonus" , ( ) => {
@@ -86,10 +91,13 @@ describe("matchLoadout", () => {
8691 { id : "with-pattern" , keywords : [ "ci" , "workflow" , "runner" ] , patterns : [ "ci_pipeline" ] } ,
8792 { id : "without-pattern" , keywords : [ "ci" , "workflow" , "runner" ] } ,
8893 ) ;
89- // Task matches 1/3 keywords (0.33) + pattern bonus (0.2) = 0.53 vs 0.33
94+ // 1 matched keyword: coverage 1/3 = 0.333, absolute 1/5 = 0.2 → base 0.333.
95+ // with-pattern: 0.333 + 0.2 bonus = 0.533; without-pattern: 0.333.
9096 const results = matchLoadout ( "fix the ci pipeline" , index ) ;
9197 const withPattern = results . find ( ( r ) => r . entry . id === "with-pattern" ) ! ;
9298 const without = results . find ( ( r ) => r . entry . id === "without-pattern" ) ! ;
99+ assert . ok ( Math . abs ( withPattern . score - 0.5333333333333333 ) < 1e-9 ) ;
100+ assert . ok ( Math . abs ( without . score - 0.3333333333333333 ) < 1e-9 ) ;
93101 assert . ok ( withPattern . score > without . score ) ;
94102 assert . ok ( withPattern . reason . includes ( "keywords" ) && withPattern . reason . includes ( "patterns" ) ) ;
95103 } ) ;
@@ -112,6 +120,123 @@ describe("matchLoadout", () => {
112120 } ) ;
113121} ) ;
114122
123+ // Build N filler keywords that will NOT match the test tasks below.
124+ function filler ( n : number ) : string [ ] {
125+ return Array . from ( { length : n } , ( _ , i ) => `filler${ i } ` ) ;
126+ }
127+
128+ describe ( "matchLoadout — recall-aware scoring (FT-K1)" , ( ) => {
129+ it ( "(a) a 2-keyword match on a 20+ keyword entry now scores >= 0.4" , ( ) => {
130+ // 24-keyword entry; task hits exactly 2 of them.
131+ const index = makeIndex (
132+ { id : "rich" , keywords : [ "deploy" , "release" , ...filler ( 22 ) ] } ,
133+ ) ;
134+ const results = matchLoadout ( "deploy a release" , index ) ;
135+ assert . equal ( results . length , 1 ) ;
136+ assert . equal ( results [ 0 ] . matchedKeywords . length , 2 ) ;
137+ // coverage 2/24 = 0.083, absolute 2/5 = 0.4 -> max = 0.4
138+ assert . ok ( results [ 0 ] . score >= 0.4 , `score was ${ results [ 0 ] . score } ` ) ;
139+ assert . equal ( results [ 0 ] . score , 0.4 ) ;
140+ // Transparency: component breakdown is present and explains the score.
141+ assert . ok ( results [ 0 ] . scoreComponents ) ;
142+ assert . equal ( results [ 0 ] . scoreComponents ! . matched , 2 ) ;
143+ assert . equal ( results [ 0 ] . scoreComponents ! . absolute , 0.4 ) ;
144+ assert . ok ( results [ 0 ] . scoreComponents ! . coverage < 0.1 ) ;
145+ assert . equal ( results [ 0 ] . scoreComponents ! . base , 0.4 ) ;
146+ } ) ;
147+
148+ it ( "(a') a 3-keyword match on a keyword-rich entry scores 0.6" , ( ) => {
149+ const index = makeIndex (
150+ { id : "rich" , keywords : [ "deploy" , "release" , "rollback" , ...filler ( 27 ) ] } ,
151+ ) ;
152+ const results = matchLoadout ( "deploy release rollback now" , index ) ;
153+ assert . equal ( results [ 0 ] . matchedKeywords . length , 3 ) ;
154+ // absolute 3/5 = 0.6 dominates coverage 3/30 = 0.1
155+ assert . ok ( Math . abs ( results [ 0 ] . score - 0.6 ) < 1e-9 , `score was ${ results [ 0 ] . score } ` ) ;
156+ } ) ;
157+
158+ it ( "(b) a single incidental hit on a large entry stays <= 0.2" , ( ) => {
159+ // 30-keyword entry; task hits exactly 1 keyword incidentally.
160+ const index = makeIndex (
161+ { id : "big" , keywords : [ "deploy" , ...filler ( 29 ) ] } ,
162+ ) ;
163+ const results = matchLoadout ( "deploy something" , index ) ;
164+ assert . equal ( results . length , 1 ) ;
165+ assert . equal ( results [ 0 ] . matchedKeywords . length , 1 ) ;
166+ // coverage 1/30 = 0.033, absolute 1/5 = 0.2 -> max = 0.2
167+ assert . ok ( results [ 0 ] . score <= 0.2 , `score was ${ results [ 0 ] . score } ` ) ;
168+ assert . equal ( results [ 0 ] . score , 0.2 ) ;
169+ } ) ;
170+
171+ it ( "single hit on a tiny entry keeps high coverage (max picks coverage)" , ( ) => {
172+ const index = makeIndex (
173+ { id : "tiny" , keywords : [ "deploy" , "release" , "rollback" ] } ,
174+ ) ;
175+ const results = matchLoadout ( "deploy something" , index ) ;
176+ // coverage 1/3 = 0.333 beats absolute 1/5 = 0.2 -> max = 0.333
177+ assert . ok ( Math . abs ( results [ 0 ] . score - 0.3333333333333333 ) < 1e-9 , `score was ${ results [ 0 ] . score } ` ) ;
178+ } ) ;
179+ } ) ;
180+
181+ describe ( "matchLoadout — minScore option (FT-K3)" , ( ) => {
182+ it ( "(c) minScore option filters out low scores" , ( ) => {
183+ // Single incidental hit on a large entry -> score 0.2.
184+ const index = makeIndex (
185+ { id : "big" , keywords : [ "deploy" , ...filler ( 29 ) ] } ,
186+ ) ;
187+ // Default threshold (0.1): the 0.2 entry is included.
188+ const included = matchLoadout ( "deploy something" , index ) ;
189+ assert . equal ( included . length , 1 ) ;
190+ // Raised threshold (0.3): the 0.2 entry is filtered out as noise.
191+ const filtered = matchLoadout ( "deploy something" , index , { minScore : 0.3 } ) ;
192+ assert . equal ( filtered . length , 0 ) ;
193+ } ) ;
194+
195+ it ( "minScore option does not affect a genuine multi-keyword match" , ( ) => {
196+ const index = makeIndex (
197+ { id : "rich" , keywords : [ "deploy" , "release" , ...filler ( 22 ) ] } ,
198+ ) ;
199+ // score 0.4 survives a 0.3 threshold.
200+ const results = matchLoadout ( "deploy a release" , index , { minScore : 0.3 } ) ;
201+ assert . equal ( results . length , 1 ) ;
202+ assert . equal ( results [ 0 ] . entry . id , "rich" ) ;
203+ } ) ;
204+
205+ it ( "DEFAULT_MIN_SCORE matches the implicit 2-arg behavior" , ( ) => {
206+ const index = makeIndex (
207+ { id : "big" , keywords : [ "deploy" , ...filler ( 29 ) ] } ,
208+ ) ;
209+ const implicit = matchLoadout ( "deploy something" , index ) ;
210+ const explicit = matchLoadout ( "deploy something" , index , { minScore : DEFAULT_MIN_SCORE } ) ;
211+ assert . equal ( implicit . length , explicit . length ) ;
212+ assert . equal ( implicit . length , 1 ) ;
213+ } ) ;
214+ } ) ;
215+
216+ describe ( "matchLoadout — core/manual unchanged (FT-K1 regression)" , ( ) => {
217+ it ( "(d) core entries still score 1.0 with no component breakdown" , ( ) => {
218+ const index = makeIndex (
219+ { id : "core-rule" , keywords : [ "whatever" ] , priority : "core" } ,
220+ ) ;
221+ const results = matchLoadout ( "anything at all" , index ) ;
222+ assert . equal ( results . length , 1 ) ;
223+ assert . equal ( results [ 0 ] . score , 1.0 ) ;
224+ assert . equal ( results [ 0 ] . mode , "eager" ) ;
225+ assert . equal ( results [ 0 ] . reason , "core: always loaded" ) ;
226+ // Core entries do not get a recall-aware component breakdown.
227+ assert . equal ( results [ 0 ] . scoreComponents , undefined ) ;
228+ } ) ;
229+
230+ it ( "(d) manual entries are still never auto-included" , ( ) => {
231+ const index = makeIndex (
232+ { id : "manual-rule" , keywords : [ "deploy" , "release" ] , priority : "manual" } ,
233+ ) ;
234+ // Even with a strong keyword match, manual entries do not auto-load.
235+ const results = matchLoadout ( "deploy a release now" , index ) ;
236+ assert . equal ( results . length , 0 ) ;
237+ } ) ;
238+ } ) ;
239+
115240describe ( "lookupEntry" , ( ) => {
116241 it ( "finds entry by id" , ( ) => {
117242 const index = makeIndex (
0 commit comments