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.
276// v1.4.0: a missing required compartment zeroes the clearance component, which is a clearance gap
277// (RequiredLevel TS/SCI >= ClearanceConfidential, clearanceScore == 0). The band cap lowers
278// Yellow to Red. The Total stays 75.00 (caps lower only the band, never the total).
279func TestGoldenCompartmentMissing(t *testing.T) {
280 req := compartmentGoldenReq()
281
282 cand := engine.Candidate{
283 ID: "cand-compartment-missing",
284 Clearance: engine.Clearance{
285 Level: engine.ClearanceTSSCI,
286 Poly: engine.PolyNone,
287 Status: engine.StatusActive,
288 Compartments: []string{"TK"},
289 },
290 Available: true,
291 }
292
293 res := engine.ScoreCandidate(context.Background(), cand, req, nil)
294
295 // Clearance component: TS/SCI active, TK held, but SI is required and missing. Scores 0.
296 if !res.Clearance.Equal(decimal.Zero) {
297 t.Fatalf("Clearance = %s, want 0", res.Clearance.String())
298 }
299 // GapSummary carries only the structural gaps from scoreClearance (the missing SI compartment).
300 // The v1.4.0 cap reason appears in Explanation only, not in GapSummary.
301 if len(res.GapSummary) != 1 {
302 t.Fatalf("gap count %d, want 1: %v", len(res.GapSummary), res.GapSummary)
303 }
304 if !strings.Contains(res.GapSummary[0], "SI") {
305 t.Fatalf("gap does not name SI: %q", res.GapSummary[0])
306 }
307 // Golden total unchanged by v1.4.0 (caps lower the band, not the total).
308 // Total = 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00.
309 if res.Total.StringFixed(2) != "75.00" {
310 t.Fatalf("total = %s, want 75.00", res.Total.StringFixed(2))
311 }
312 // v1.4.0 clearance gap cap: band is Red (was Yellow before the cap).
313 if res.Band != engine.BandRed {
314 t.Fatalf("band = %s, want Red (v1.4.0 clearance gap cap)", res.Band)
315 }
316 // The cap reason must appear in the Explanation for AEDT explainability.
317 if !strings.Contains(res.Explanation, "clearance") {
318 t.Fatalf("Explanation should mention clearance cap: %q", res.Explanation)
319 }
320 sum := decimal.Zero
321 for _, comp := range res.Trace {
322 sum = sum.Add(comp.Weighted)
323 }
324 if !sum.Round(2).Equal(res.Total) {
325 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
326 }
327}
328
329// clearanceGapGoldenReq returns a requirement for TS/SCI with no other mandatory dimensions
330// (no skills, certs, customer, years, or location). It isolates the clearance gap cap.
331func clearanceGapGoldenReq() engine.Requirement {
332 return engine.Requirement{
333 ID: "req-clearance-gap-golden",
334 RequiredLevel: engine.ClearanceTSSCI,
335 }
336}
337
338// TestGoldenClearanceGapCap is a v1.4.0 golden case. A candidate holds Secret (active) but the
339// requirement demands TS/SCI. scoreClearance returns 0 (insufficient level). All other dimensions
340// are 100 (no requirements). The raw total is 0*0.25 + 100*0.75 = 75.00 (uncapped). bandFor(75.00)
341// = Yellow, but the v1.4.0 clearance gap cap lowers it to Red. Total stays 75.00.
342func TestGoldenClearanceGapCap(t *testing.T) {
343 req := clearanceGapGoldenReq()
344
345 cand := engine.Candidate{
346 ID: "cand-clearance-gap",
347 // Holds Secret active, but TS/SCI is required. scoreClearance returns 0.
348 Clearance: engine.Clearance{
349 Level: engine.ClearanceSecret,
350 Status: engine.StatusActive,
351 },
352 Available: true,
353 }
354
355 res := engine.ScoreCandidate(context.Background(), cand, req, nil)
356
357 // Clearance component: Secret < TS/SCI. Scores 0.
358 if !res.Clearance.Equal(decimal.Zero) {
359 t.Fatalf("Clearance = %s, want 0", res.Clearance.String())
360 }
361 // Uncapped total: 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00.
362 // The cap does not touch the total.
363 if res.Total.StringFixed(2) != "75.00" {
364 t.Fatalf("total = %s, want 75.00 (clearance gap cap does not change the total)", res.Total.StringFixed(2))
365 }
366 // v1.4.0 clearance gap cap: band must be Red regardless of the total.
367 if res.Band != engine.BandRed {
368 t.Fatalf("band = %s, want Red (v1.4.0 clearance gap cap)", res.Band)
369 }
370 // The cap reason must appear in the Explanation for AEDT explainability.
371 if !strings.Contains(res.Explanation, "clearance") {
372 t.Fatalf("Explanation should mention clearance cap: %q", res.Explanation)
373 }
374 // AEDT invariant: trace components must re sum to the total.
375 sum := decimal.Zero
376 for _, comp := range res.Trace {
377 sum = sum.Add(comp.Weighted)
378 }
379 if !sum.Round(2).Equal(res.Total) {
380 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
381 }
382}
383
384// mandatorySkillGapGoldenReq returns a requirement with two skills (AWS mandatory, Go mandatory)
385// and no clearance requirement, so we can isolate the mandatory skill gap cap. No certs, no
386// customer, 0 years, no location: all other dimensions score 100.
387func mandatorySkillGapGoldenReq() engine.Requirement {
388 return engine.Requirement{
389 ID: "req-mandatory-skill-gap-golden",
390 RequiredSkills: []engine.RequiredSkill{
391 {SkillID: "sk-aws", Name: "AWS", Mandatory: true},
392 {SkillID: "sk-go", Name: "Go", Mandatory: true},
393 },
394 }
395}
396
397// TestGoldenMandatorySkillGapCap is a v1.4.0 golden case proving the mandatory skill gap cap. The
398// requirement lists four mandatory skills; the candidate holds three (skills 75) with every other
399// dimension perfect, so the uncapped total is 93.75 which bandFor maps to Green. The cap lowers the
400// band to Yellow because one mandatory skill is unmet, and the total stays 93.75.
401func TestGoldenMandatorySkillGapCap(t *testing.T) {
402 req := engine.Requirement{
403 ID: "req-mandatory-skill-gap-golden",
404 RequiredSkills: []engine.RequiredSkill{
405 {SkillID: "sk-aws", Name: "AWS", Mandatory: true},
406 {SkillID: "sk-go", Name: "Go", Mandatory: true},
407 {SkillID: "sk-k8s", Name: "Kubernetes", Mandatory: true},
408 {SkillID: "sk-tf", Name: "Terraform", Mandatory: true},
409 },
410 }
411
412 cand := engine.Candidate{
413 ID: "cand-mandatory-skill-gap",
414 // No clearance required, so clearance scores 100.
415 Skills: []engine.CandidateSkill{
416 {SkillID: "sk-aws", Name: "AWS"},
417 {SkillID: "sk-go", Name: "Go"},
418 {SkillID: "sk-k8s", Name: "Kubernetes"},
419 // Terraform (sk-tf) is missing. One mandatory skill unmet.
420 },
421 Available: true,
422 }
423
424 res := engine.ScoreCandidate(context.Background(), cand, req, nil)
425
426 // Skills component: 3 of 4 required met = 75.
427 if res.Skills.StringFixed(2) != "75.00" {
428 t.Fatalf("Skills = %s, want 75.00", res.Skills.StringFixed(2))
429 }
430 // Uncapped total: 100*0.25 + 75*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10
431 // = 25 + 18.75 + 15 + 15 + 10 + 10 = 93.75. bandFor(93.75) = Green.
432 // The total is unchanged by the cap.
433 if res.Total.StringFixed(2) != "93.75" {
434 t.Fatalf("total = %s, want 93.75 (mandatory skill gap cap does not change the total)", res.Total.StringFixed(2))
435 }
436 // v1.4.0 mandatory skill gap cap: band must be Yellow (Green lowered to Yellow).
437 if res.Band != engine.BandYellow {
438 t.Fatalf("band = %s, want Yellow (v1.4.0 mandatory skill gap cap)", res.Band)
439 }
440 // The cap reason must appear in the Explanation for AEDT explainability.
441 if !strings.Contains(res.Explanation, "mandatory") {
442 t.Fatalf("Explanation should mention mandatory skill cap: %q", res.Explanation)
443 }
444 // The gap for the missing mandatory skill must be present.
445 hasGap := false
446 for _, g := range res.GapSummary {
447 if strings.Contains(strings.ToLower(g), "terraform") {
448 hasGap = true
449 break
450 }
451 }
452 if !hasGap {
453 t.Fatalf("GapSummary should include Terraform gap: %v", res.GapSummary)
454 }
455 // AEDT invariant: trace components must re sum to the total.
456 sum := decimal.Zero
457 for _, comp := range res.Trace {
458 sum = sum.Add(comp.Weighted)
459 }
460 if !sum.Round(2).Equal(res.Total) {
461 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
462 }
463}
464