Recovered source transcript
1package matching
2
3import (
4 "context"
5 "testing"
6
7 "github.com/shopspring/decimal"
8
9 "github.com/missionworkforce/missionmatch/internal/modules/matching/engine"
10)
11
12// TestGoldenAliasAndRelationship scores two new golden cases through the full engine with a
13// ChainedResolver wrapping an in-memory DeterministicAliasResolver (newResolverFromMaps). These
14// cases prove that alias resolution (exact canonical equivalence) and relationship resolution
15// (substitutes_for partial credit) both flow correctly from the curated ontology layer through
16// matchOne into the scored MatchResult.
17//
18// The three frozen golden cases in engine/golden_test.go are untouched and remain the contract
19// for the shared scoring weights and band cuts.
20//
21// Shared ontology maps for both cases:
22//
23// nameToID: "sigint" => sigintID
24// "skill b" => skillBID
25// "skill a" => skillAID
26// aliasToID: "signals intelligence" => sigintID
27// rels: skillAID => [{toID: skillBID, relation: "substitutes_for", strength: 0.80}]
28const (
29 sigintID = "00000000-0000-0000-0000-0000000000a1"
30 skillBID = "00000000-0000-0000-0000-0000000000b1"
31 skillAID = "00000000-0000-0000-0000-0000000000c1"
32)
33
34func aliasGoldenMatcher() *ChainedResolver {
35 nameToID := map[string]string{
36 "sigint": sigintID,
37 "skill b": skillBID,
38 "skill a": skillAID,
39 }
40 aliasToID := map[string]string{
41 "signals intelligence": sigintID,
42 }
43 rels := map[string][]relEdge{
44 skillAID: {
45 {toID: skillBID, relation: "substitutes_for", strength: decimal.RequireFromString("0.80")},
46 },
47 }
48 return NewChainedResolver(newResolverFromMaps(nameToID, aliasToID, rels), nil)
49}
50
51// aliasGoldenReq returns a requirement for exactly one skill (SIGINT by id) with no other
52// mandatory dimensions, so the skill subscore is isolated cleanly.
53func aliasGoldenReq() engine.Requirement {
54 return engine.Requirement{
55 ID: "req-alias-golden",
56 RequiredSkills: []engine.RequiredSkill{
57 {SkillID: sigintID, Name: "SIGINT", Mandatory: true},
58 },
59 }
60}
61
62// relationshipGoldenReq returns a requirement for exactly one skill (Skill B by id) with no
63// other mandatory dimensions.
64func relationshipGoldenReq() engine.Requirement {
65 return engine.Requirement{
66 ID: "req-rel-golden",
67 RequiredSkills: []engine.RequiredSkill{
68 {SkillID: skillBID, Name: "Skill B", Mandatory: true},
69 },
70 }
71}
72
73// TestGoldenAliasExactMatch proves that a candidate holding a skill named "Signals Intelligence"
74// (a curated alias for SIGINT) resolves through the ChainedResolver to a full match: the Skills
75// component is 100, AliasMatched is true, Similarity is 1.0, and the total is 100.00.
76//
77// Engine note: matchOne sets Exact=false for every sm-sourced match (the engine owns the match
78// flags; the matcher only supplies candidate side and similarity). The alias resolver returns
79// AliasMatched=true to distinguish this path from a semantic cosine match.
80func TestGoldenAliasExactMatch(t *testing.T) {
81 matcher := aliasGoldenMatcher()
82 req := aliasGoldenReq()
83
84 cand := engine.Candidate{
85 ID: "cand-alias-golden",
86 // No clearance required on this req so clearance scores 100 regardless.
87 // No certs, no customer, 0 years required, no location: all dimensions score 100.
88 Skills: []engine.CandidateSkill{
89 // No SkillID set; name is the alias text. The resolver maps "signals intelligence"
90 // to sigintID which equals the required canonical id, triggering the alias path.
91 {Name: "Signals Intelligence"},
92 },
93 Available: true,
94 }
95
96 res := engine.ScoreCandidate(context.Background(), cand, req, matcher)
97
98 // Skills component: 1 of 1 required met (the alias resolved) => 100.
99 if !res.Skills.Equal(decimal.NewFromInt(100)) {
100 t.Fatalf("Skills = %s, want 100", res.Skills.String())
101 }
102 // No gaps: the one mandatory skill was met via alias.
103 if len(res.GapSummary) != 0 {
104 t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary)
105 }
106 // The matched skill record must carry AliasMatched=true and Similarity=1.0. The engine
107 // sets Exact=false for all sm-sourced matches (not a bug; see matchOne in engine.go).
108 if len(res.MatchedSkills) == 0 {
109 t.Fatal("expected at least one MatchedSkills entry")
110 }
111 m := res.MatchedSkills[0]
112 if !m.Matched {
113 t.Error("Matched should be true")
114 }
115 if !m.AliasMatched {
116 t.Error("AliasMatched should be true for a curated alias match")
117 }
118 if !m.Similarity.Equal(decimal.NewFromInt(1)) {
119 t.Errorf("Similarity = %s, want 1", m.Similarity.String())
120 }
121 if m.Exact {
122 t.Error("Exact is false for sm-sourced matches: the engine sets it to false in matchOne")
123 }
124 // Golden total: all six dimensions are 100 (no clearance req, no certs req, no customer req,
125 // 0 years req, no location req, candidate is available). Total = 100.00.
126 if res.Total.StringFixed(2) != "100.00" {
127 t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2))
128 }
129 if res.Band != engine.BandGreen {
130 t.Fatalf("band = %s, want Green", res.Band)
131 }
132 // AEDT invariant: trace components must re-sum to the total.
133 sum := decimal.Zero
134 for _, comp := range res.Trace {
135 sum = sum.Add(comp.Weighted)
136 }
137 if !sum.Round(2).Equal(res.Total) {
138 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
139 }
140}
141
142// TestGoldenRelationshipPartialCredit proves that a candidate holding Skill A (which has a
143// substitutes_for edge to Skill B at strength 0.80) satisfies the requirement for Skill B via
144// the ChainedResolver relationship path.
145//
146// Engine counting: matchOne returns ok=true for any sm.Match hit, so met increments to 1 and the
147// skill fraction is ratio(1,1)=100. The partial credit (0.80) lives in the SkillMatch.Similarity
148// field of the decision trace, not in the Skills sub-score denominator. The Skills component is
149// therefore 100, not 80, and the total is 100.00. The distinction is captured in the SkillMatch
150// record for AEDT explainability.
151func TestGoldenRelationshipPartialCredit(t *testing.T) {
152 matcher := aliasGoldenMatcher()
153 req := relationshipGoldenReq()
154
155 cand := engine.Candidate{
156 ID: "cand-rel-golden",
157 Skills: []engine.CandidateSkill{
158 // Skill A has a substitutes_for edge to Skill B at strength 0.80 in the rels map.
159 {SkillID: skillAID, Name: "Skill A"},
160 },
161 Available: true,
162 }
163
164 res := engine.ScoreCandidate(context.Background(), cand, req, matcher)
165
166 // Skills component: ratio(1,1) = 100. The relationship strength is in the SkillMatch record,
167 // not in the sub-score fraction. See scoreSkills: it counts ok-matches, not weighted by similarity.
168 if !res.Skills.Equal(decimal.NewFromInt(100)) {
169 t.Fatalf("Skills = %s, want 100", res.Skills.String())
170 }
171 // No mandatory gap: the relationship match satisfied the one required skill.
172 if len(res.GapSummary) != 0 {
173 t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary)
174 }
175 // The matched skill record must carry Similarity=0.80, Exact=false, AliasMatched=false.
176 if len(res.MatchedSkills) == 0 {
177 t.Fatal("expected at least one MatchedSkills entry")
178 }
179 m := res.MatchedSkills[0]
180 if !m.Matched {
181 t.Error("Matched should be true")
182 }
183 if m.AliasMatched {
184 t.Error("AliasMatched should be false for a relationship match")
185 }
186 if !m.Similarity.Equal(decimal.RequireFromString("0.80")) {
187 t.Errorf("Similarity = %s, want 0.80", m.Similarity.String())
188 }
189 if m.Exact {
190 t.Error("Exact should be false for a relationship match")
191 }
192 // Golden total: same logic as alias case (no dimensions have active requirements except
193 // skills, which resolved to 100). Total = 100.00.
194 if res.Total.StringFixed(2) != "100.00" {
195 t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2))
196 }
197 if res.Band != engine.BandGreen {
198 t.Fatalf("band = %s, want Green", res.Band)
199 }
200 // AEDT invariant.
201 sum := decimal.Zero
202 for _, comp := range res.Trace {
203 sum = sum.Add(comp.Weighted)
204 }
205 if !sum.Round(2).Equal(res.Total) {
206 t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total)
207 }
208}
209