Recovered source transcript
1package matching
2
3import (
4 "context"
5 "strings"
6 "testing"
7
8 "github.com/shopspring/decimal"
9
10 "github.com/missionworkforce/missionmatch/internal/modules/matching/engine"
11)
12
13// TestGoldenAliasAndRelationship scores two new golden cases through the full engine with a
14// ChainedResolver wrapping an in-memory DeterministicAliasResolver (newResolverFromMaps). These
15// cases prove that alias resolution (exact canonical equivalence) and relationship resolution
16// (substitutes_for partial credit) both flow correctly from the curated ontology layer through
17// matchOne into the scored MatchResult.
18//
19// The three frozen golden cases in engine/golden_test.go are untouched and remain the contract
20// for the shared scoring weights and band cuts.
21//
22// Shared ontology maps for both cases:
23//
24// nameToID: "sigint" => sigintID
25// "skill b" => skillBID
26// "skill a" => skillAID
27// aliasToID: "signals intelligence" => sigintID
28// rels: skillAID => [{toID: skillBID, relation: "substitutes_for", strength: 0.80}]
29const (
30 sigintID = "00000000-0000-0000-0000-0000000000a1"
31 skillBID = "00000000-0000-0000-0000-0000000000b1"
32 skillAID = "00000000-0000-0000-0000-0000000000c1"
33)
34
35func aliasGoldenMatcher() *ChainedResolver {
36 nameToID := map[string]string{
37 "sigint": sigintID,
38 "skill b": skillBID,
39 "skill a": skillAID,
40 }
41 aliasToID := map[string]string{
42 "signals intelligence": sigintID,
43 }
44 rels := map[string][]relEdge{
45 skillAID: {
46 {toID: skillBID, relation: "substitutes_for", strength: decimal.RequireFromString("0.80")},
47 },
48 }
49 return NewChainedResolver(newResolverFromMaps(nameToID, aliasToID, rels), nil)
50}
51
52// aliasGoldenReq returns a requirement for exactly one skill (SIGINT by id) with no other
53// mandatory dimensions, so the skill subscore is isolated cleanly.
54func aliasGoldenReq() engine.Requirement {
55 return engine.Requirement{
56 ID: "req-alias-golden",
57 RequiredSkills: []engine.RequiredSkill{
58 {SkillID: sigintID, Name: "SIGINT", Mandatory: true},
59 },
60 }
61}
62
63// relationshipGoldenReq returns a requirement for exactly one skill (Skill B by id) with no
64// other mandatory dimensions.
65func relationshipGoldenReq() engine.Requirement {
66 return engine.Requirement{
67 ID: "req-rel-golden",
68 RequiredSkills: []engine.RequiredSkill{
69 {SkillID: skillBID, Name: "Skill B", Mandatory: true},
70 },
71 }
72}
73
74// TestGoldenAliasExactMatch proves that a candidate holding a skill named "Signals Intelligence"
75// (a curated alias for SIGINT) resolves through the ChainedResolver to a full match: the Skills
76// component is 100, AliasMatched is true, Similarity is 1.0, and the total is 100.00.
77//
78// Engine note: matchOne sets Exact=false for every sm-sourced match (the engine owns the match
79// flags; the matcher only supplies candidate side and similarity). The alias resolver returns
80// AliasMatched=true to distinguish this path from a semantic cosine match.
81func TestGoldenAliasExactMatch(t *testing.T) {
82 matcher := aliasGoldenMatcher()
83 req := aliasGoldenReq()
84
85 cand := engine.Candidate{
86 ID: "cand-alias-golden",
87 // No clearance required on this req so clearance scores 100 regardless.
88 // No certs, no customer, 0 years required, no location: all dimensions score 100.
89 Skills: []engine.CandidateSkill{
90 // No SkillID set; name is the alias text. The resolver maps "signals intelligence"
91 // to sigintID which equals the required canonical id, triggering the alias path.
92 {Name: "Signals Intelligence"},
93 },
94 Available: true,
95 }
96
97 res := engine.ScoreCandidate(context.Background(), cand, req, matcher)
98
99 // Skills component: 1 of 1 required met (the alias resolved) => 100.
100 if !res.Skills.Equal(decimal.NewFromInt(100)) {
101 t.Fatalf("Skills = %s, want 100", res.Skills.String())
102 }
103 // No gaps: the one mandatory skill was met via alias.
104 if len(res.GapSummary) != 0 {
105 t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary)
106 }
107 // The matched skill record must carry AliasMatched=true and Similarity=1.0. The engine
108 // sets Exact=false for all sm-sourced matches (not a bug; see matchOne in engine.go).
109 if len(res.MatchedSkills) == 0 {
110 t.Fatal("expected at least one MatchedSkills entry")
111 }
112 m := res.MatchedSkills[0]
113 if !m.Matched {
114 t.Error("Matched should be true")
115 }
116 if !m.AliasMatched {
117 t.Error("AliasMatched should be true for a curated alias match")
118 }
119 if !m.Similarity.Equal(decimal.NewFromInt(1)) {
120 t.Errorf("Similarity = %s, want 1", m.Similarity.String())
121 }
122 if m.Exact {
123 t.Error("Exact is false for sm-sourced matches: the engine sets it to false in matchOne")
124 }
125 // Golden total: all six dimensions are 100 (no clearance req, no certs req, no customer req,
126 // 0 years req, no location req, candidate is available). Total = 100.00.
127 if res.Total.StringFixed(2) != "100.00" {
128 t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2))
129 }
130 if res.Band != engine.BandGreen {
131 t.Fatalf("band = %s, want Green", res.Band)
132 }
133 // AEDT invariant: trace components must re-sum to the total.
134 sum := decimal.Zero
135 for _, comp := range res.Trace {
136 sum = sum.Add(comp.Weighted)
137 }
138 if !sum.Round(2).Equal(res.Total) {
139 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
140 }
141}
142
143// TestGoldenRelationshipPartialCredit proves that a candidate holding Skill A (which has a
144// substitutes_for edge to Skill B at strength 0.80) satisfies the requirement for Skill B via
145// the ChainedResolver relationship path.
146//
147// Engine counting: matchOne returns ok=true for any sm.Match hit, so met increments to 1 and the
148// skill fraction is ratio(1,1)=100. The partial credit (0.80) lives in the SkillMatch.Similarity
149// field of the decision trace, not in the Skills sub-score denominator. The Skills component is
150// therefore 100, not 80, and the total is 100.00. The distinction is captured in the SkillMatch
151// record for AEDT explainability.
152func TestGoldenRelationshipPartialCredit(t *testing.T) {
153 matcher := aliasGoldenMatcher()
154 req := relationshipGoldenReq()
155
156 cand := engine.Candidate{
157 ID: "cand-rel-golden",
158 Skills: []engine.CandidateSkill{
159 // Skill A has a substitutes_for edge to Skill B at strength 0.80 in the rels map.
160 {SkillID: skillAID, Name: "Skill A"},
161 },
162 Available: true,
163 }
164
165 res := engine.ScoreCandidate(context.Background(), cand, req, matcher)
166
167 // Skills component: ratio(1,1) = 100. The relationship strength is in the SkillMatch record,
168 // not in the sub-score fraction. See scoreSkills: it counts ok-matches, not weighted by similarity.
169 if !res.Skills.Equal(decimal.NewFromInt(100)) {
170 t.Fatalf("Skills = %s, want 100", res.Skills.String())
171 }
172 // No mandatory gap: the relationship match satisfied the one required skill.
173 if len(res.GapSummary) != 0 {
174 t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary)
175 }
176 // The matched skill record must carry Similarity=0.80, Exact=false, AliasMatched=false.
177 if len(res.MatchedSkills) == 0 {
178 t.Fatal("expected at least one MatchedSkills entry")
179 }
180 m := res.MatchedSkills[0]
181 if !m.Matched {
182 t.Error("Matched should be true")
183 }
184 if m.AliasMatched {
185 t.Error("AliasMatched should be false for a relationship match")
186 }
187 if !m.Similarity.Equal(decimal.RequireFromString("0.80")) {
188 t.Errorf("Similarity = %s, want 0.80", m.Similarity.String())
189 }
190 if m.Exact {
191 t.Error("Exact should be false for a relationship match")
192 }
193 // Golden total: same logic as alias case (no dimensions have active requirements except
194 // skills, which resolved to 100). Total = 100.00.
195 if res.Total.StringFixed(2) != "100.00" {
196 t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2))
197 }
198 if res.Band != engine.BandGreen {
199 t.Fatalf("band = %s, want Green", res.Band)
200 }
201 // AEDT invariant.
202 sum := decimal.Zero
203 for _, comp := range res.Trace {
204 sum = sum.Add(comp.Weighted)
205 }
206 if !sum.Round(2).Equal(res.Total) {
207 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
208 }
209}
210
211// compartmentGoldenReq returns a requirement for TS/SCI + SI compartment.
212// No skills, certs, customer agency, years, or location requirements so every
213// dimension except clearance scores 100, isolating the compartment check cleanly.
214func compartmentGoldenReq() engine.Requirement {
215 return engine.Requirement{
216 ID: "req-compartment-golden",
217 RequiredLevel: engine.ClearanceTSSCI,
218 RequiredCompartments: []string{"SI"},
219 }
220}
221
222// TestGoldenCompartmentSatisfied proves that a candidate holding TS/SCI + compartments SI and TK
223// passes the compartment check: Clearance scores 100 and the total is 100.00.
224//
225// This is an additive gate on top of the existing level and poly checks. Because the compartment
226// list is non-empty AND the candidate carries the required compartment, hasAll returns true and the
227// default case fires. The frozen three golden scores (100.00, 87.50, 26.00) use requirements with
228// no required compartments, so hasAll always returns true for those inputs and they are unaffected.
229func TestGoldenCompartmentSatisfied(t *testing.T) {
230 req := compartmentGoldenReq()
231
232 cand := engine.Candidate{
233 ID: "cand-compartment-satisfied",
234 Clearance: engine.Clearance{
235 Level: engine.ClearanceTSSCI,
236 Poly: engine.PolyNone,
237 Status: engine.StatusActive,
238 Compartments: []string{"SI", "TK"},
239 },
240 Available: true,
241 }
242
243 res := engine.ScoreCandidate(context.Background(), cand, req, nil)
244
245 // Clearance component: TS/SCI active, SI and TK held, only SI required. hasAll returns true.
246 if !res.Clearance.Equal(decimal.NewFromInt(100)) {
247 t.Fatalf("Clearance = %s, want 100", res.Clearance.String())
248 }
249 // No gaps: the compartment requirement is fully satisfied.
250 if len(res.GapSummary) != 0 {
251 t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary)
252 }
253 // Golden total: no skills, certs, customer, years, or location required; candidate is available.
254 // All six dimensions score 100. Total = 100.00.
255 if res.Total.StringFixed(2) != "100.00" {
256 t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2))
257 }
258 if res.Band != engine.BandGreen {
259 t.Fatalf("band = %s, want Green", res.Band)
260 }
261 sum := decimal.Zero
262 for _, comp := range res.Trace {
263 sum = sum.Add(comp.Weighted)
264 }
265 if !sum.Round(2).Equal(res.Total) {
266 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
267 }
268}
269
270// TestGoldenCompartmentMissing proves that a candidate holding TS/SCI but only TK (not SI) fails
271// the compartment check: Clearance scores 0 and the gap summary names the missing SI.
272//
273// Same requirement as TestGoldenCompartmentSatisfied. The candidate holds TK but not SI.
274// hasAll returns false; missingCompartments returns ["SI"]; scoreClearance returns 0 with a gap.
275// The total is: 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00, Yellow.
276func TestGoldenCompartmentMissing(t *testing.T) {
277 req := compartmentGoldenReq()
278
279 cand := engine.Candidate{
280 ID: "cand-compartment-missing",
281 Clearance: engine.Clearance{
282 Level: engine.ClearanceTSSCI,
283 Poly: engine.PolyNone,
284 Status: engine.StatusActive,
285 Compartments: []string{"TK"},
286 },
287 Available: true,
288 }
289
290 res := engine.ScoreCandidate(context.Background(), cand, req, nil)
291
292 // Clearance component: TS/SCI active, TK held, but SI is required and missing. Scores 0.
293 if !res.Clearance.Equal(decimal.Zero) {
294 t.Fatalf("Clearance = %s, want 0", res.Clearance.String())
295 }
296 // One gap: the missing SI compartment.
297 if len(res.GapSummary) != 1 {
298 t.Fatalf("gap count %d, want 1: %v", len(res.GapSummary), res.GapSummary)
299 }
300 if !strings.Contains(res.GapSummary[0], "SI") {
301 t.Fatalf("gap does not name SI: %q", res.GapSummary[0])
302 }
303 // Golden total: clearance 0, all other dimensions 100 (no requirements on those dimensions;
304 // candidate is available). Total = 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10.
305 // = 0 + 25 + 15 + 15 + 10 + 10 = 75.00, Yellow.
306 if res.Total.StringFixed(2) != "75.00" {
307 t.Fatalf("total = %s, want 75.00", res.Total.StringFixed(2))
308 }
309 if res.Band != engine.BandYellow {
310 t.Fatalf("band = %s, want Yellow", res.Band)
311 }
312 sum := decimal.Zero
313 for _, comp := range res.Trace {
314 sum = sum.Add(comp.Weighted)
315 }
316 if !sum.Round(2).Equal(res.Total) {
317 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
318 }
319}
320