Recovered source transcript
1package engine
2
3import (
4 "context"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/shopspring/decimal"
10)
11
12// ModelVersion is the single source of the scoring model version string. It is stamped onto every
13// frozen slate_members decision and registered in aedt_model_versions, so an AEDT decision proves
14// which weights and band cuts produced it. Bump this only when the weights or band cuts change, and
15// register the new version (CLAUDE.md responsible AI rule, NIST AI RMF model card).
16const ModelVersion = "v1.0.0"
17
18// Fixed component weights (CLAUDE.md, do NOT change without explicit instruction).
19// They MUST sum to exactly 1.00; TestWeightsSumToOne enforces it.
20var (
21 WeightClearance = decimal.RequireFromString("0.25")
22 WeightSkills = decimal.RequireFromString("0.25")
23 WeightCerts = decimal.RequireFromString("0.15")
24 WeightCustomer = decimal.RequireFromString("0.15")
25 WeightExperience = decimal.RequireFromString("0.10")
26 WeightLocation = decimal.RequireFromString("0.10")
27)
28
29// DefaultSemanticThreshold is the cosine-similarity cutoff at/above which the injected
30// SkillMatcher may count a semantic (non-exact) skill match. Below it, a skill is unmet.
31var DefaultSemanticThreshold = decimal.RequireFromString("0.80")
32
33// Band cut points (CLAUDE.md): Green >= 90, Yellow 75–89.99, Red < 75. Inclusive lower bounds.
34var (
35 BandGreenMin = decimal.NewFromInt(90)
36 BandYellowMin = decimal.NewFromInt(75)
37)
38
39// shared decimal constants
40var (
41 zero = decimal.Zero
42 fifty = decimal.NewFromInt(50)
43 hundred = decimal.NewFromInt(100)
44 two = decimal.NewFromInt(2)
45)
46
47// ScoreCandidate scores one candidate against one requirement and returns a full, explainable,
48// reproducible MatchResult. It is PURE: no DB, no I/O, no clock, no randomness. Semantic skill
49// matching is delegated to the injected sm (nil is allowed, then only exact skill matches count).
50//
51// It NEVER reads a demographic/EEO attribute: the Candidate type carries none, and this function
52// references no such field. Every sub-score is decimal (never float64) so the same inputs always
53// yield byte-identical scores and explanations.
54func ScoreCandidate(ctx context.Context, c Candidate, r Requirement, sm SkillMatcher) MatchResult {
55 res := MatchResult{CandidateID: c.ID, RequirementID: r.ID}
56
57 clRaw, clRat, clGaps := scoreClearance(c.Clearance, r)
58 skRaw, skRat, skGaps, matched := scoreSkills(ctx, c.Skills, r.RequiredSkills, sm)
59 ceRaw, ceRat, ceGaps := scoreCerts(c.Certifications, r.RequiredCerts)
60 cuRaw, cuRat := scoreCustomer(c.CustomerExperience, r.CustomerAgency)
61 exRaw, exRat := scoreExperience(c.YearsExperience, r.RequiredYears)
62 loRaw, loRat := scoreLocation(c, r)
63
64 // Round every sub score to the numeric(7,4) column scale BEFORE persisting and before building the
65 // trace, so the stored component columns, the jsonb trace, and the total are one consistent set and
66 // re summing the components reproduces the total. Without this the column (DB rounded to 4) and the
67 // trace (full precision) disagree for any non terminating ratio.
68 res.Clearance = clRaw.Round(4)
69 res.Skills = skRaw.Round(4)
70 res.Certifications = ceRaw.Round(4)
71 res.CustomerExperience = cuRaw.Round(4)
72 res.Experience = exRaw.Round(4)
73 res.Location = loRaw.Round(4)
74 res.MatchedSkills = matched
75
76 res.Trace = []ComponentScore{
77 newComponent("Clearance", WeightClearance, res.Clearance, clRat),
78 newComponent("Skills", WeightSkills, res.Skills, skRat),
79 newComponent("Certifications", WeightCerts, res.Certifications, ceRat),
80 newComponent("CustomerExperience", WeightCustomer, res.CustomerExperience, cuRat),
81 newComponent("Experience", WeightExperience, res.Experience, exRat),
82 newComponent("LocationAvailability", WeightLocation, res.Location, loRat),
83 }
84
85 total := zero
86 for _, comp := range res.Trace {
87 total = total.Add(comp.Weighted)
88 }
89 res.Total = total.Round(2)
90 res.Band = bandFor(res.Total)
91
92 gaps := make([]string, 0, len(clGaps)+len(skGaps)+len(ceGaps))
93 gaps = append(gaps, clGaps...)
94 gaps = append(gaps, skGaps...)
95 gaps = append(gaps, ceGaps...)
96 res.GapSummary = gaps
97
98 res.Explanation = buildExplanation(res)
99 return res
100}
101
102func newComponent(name string, weight, raw decimal.Decimal, rationale string) ComponentScore {
103 return ComponentScore{
104 Component: name,
105 Weight: weight,
106 Raw: raw,
107 Weighted: raw.Mul(weight).Round(4),
108 Rationale: rationale,
109 }
110}
111
112// ---------- Clearance (0 or 100; exact-or-higher, active, poly-satisfied) ----------
113
114func scoreClearance(held Clearance, r Requirement) (decimal.Decimal, string, []string) {
115 // A requirement below Confidential means no clearance is required, everyone passes.
116 if r.RequiredLevel < ClearanceConfidential {
117 return hundred, "No clearance required for this role.", nil
118 }
119
120 reqDesc := r.RequiredLevel.String()
121 if r.RequiredPoly != PolyNone {
122 reqDesc += " + " + r.RequiredPoly.String()
123 }
124
125 switch {
126 case held.Status != StatusActive:
127 g := fmt.Sprintf("Requires active %s; candidate clearance is %s.", reqDesc, statusLabel(held.Status))
128 return zero, g + " Scores 0.", []string{g}
129 case held.Level < r.RequiredLevel:
130 g := fmt.Sprintf("Requires %s; candidate holds %s.", reqDesc, held.Level.String())
131 return zero, g + " Scores 0.", []string{g}
132 case held.Poly < r.RequiredPoly:
133 g := fmt.Sprintf("Requires %s; candidate has %s.", r.RequiredPoly.String(), held.Poly.String())
134 return zero, g + " Scores 0.", []string{g}
135 default:
136 return hundred, fmt.Sprintf("Active %s meets required %s.", held.Level.String(), reqDesc), nil
137 }
138}
139
140func statusLabel(s ClearanceStatus) string {
141 switch s {
142 case StatusActive:
143 return "active"
144 case StatusExpired:
145 return "expired"
146 case StatusInactive:
147 return "inactive"
148 default:
149 return "unknown/none"
150 }
151}
152
153// ---------- Skills (fraction of required met: exact first, then injected semantic) ----------
154
155func scoreSkills(ctx context.Context, candSkills []CandidateSkill, required []RequiredSkill, sm SkillMatcher) (decimal.Decimal, string, []string, []SkillMatch) {
156 if len(required) == 0 {
157 return hundred, "No specific skills required.", nil, nil
158 }
159
160 // Normalize the iteration order so MatchedSkills output is deterministic regardless of caller order.
161 req := make([]RequiredSkill, len(required))
162 copy(req, required)
163 sort.SliceStable(req, func(i, j int) bool {
164 if req[i].SkillID != req[j].SkillID {
165 return req[i].SkillID < req[j].SkillID
166 }
167 return req[i].Name < req[j].Name
168 })
169
170 matched := make([]SkillMatch, 0, len(req))
171 var gaps []string
172 met := 0
173 for _, rs := range req {
174 m, ok := matchOne(ctx, rs, candSkills, sm)
175 matched = append(matched, m)
176 switch {
177 case ok:
178 met++
179 case rs.Mandatory:
180 gaps = append(gaps, fmt.Sprintf("Missing mandatory skill: %s.", rs.Name))
181 }
182 }
183
184 raw := ratio(met, len(req))
185 rat := fmt.Sprintf("Matched %d of %d required skills (%s%%).", met, len(req), raw.StringFixed(0))
186 return raw, rat, gaps, matched
187}
188
189// matchOne resolves one required skill: exact by SkillID, then exact by normalized name, then the
190// injected semantic matcher. It always returns a SkillMatch row (Matched=false when nothing met).
191func matchOne(ctx context.Context, rs RequiredSkill, candSkills []CandidateSkill, sm SkillMatcher) (SkillMatch, bool) {
192 for _, cs := range candSkills {
193 if (rs.SkillID != "" && cs.SkillID == rs.SkillID) || equalFold(cs.Name, rs.Name) {
194 return SkillMatch{
195 RequiredSkillID: rs.SkillID,
196 RequiredSkillName: rs.Name,
197 CandidateSkillID: cs.SkillID,
198 CandidateName: cs.Name,
199 Similarity: decimal.NewFromInt(1),
200 Exact: true,
201 Matched: true,
202 }, true
203 }
204 }
205
206 if sm != nil {
207 if m, ok := sm.Match(ctx, rs, candSkills, DefaultSemanticThreshold); ok {
208 // The engine owns the authoritative required-side identity and the match flags;
209 // the matcher only supplies the candidate side and the similarity.
210 m.RequiredSkillID = rs.SkillID
211 m.RequiredSkillName = rs.Name
212 m.Exact = false
213 m.Matched = true
214 return m, true
215 }
216 }
217
218 return SkillMatch{RequiredSkillID: rs.SkillID, RequiredSkillName: rs.Name, Matched: false}, false
219}
220
221// ---------- Certifications (fraction held, exact normalized name) ----------
222
223func scoreCerts(held, required []string) (decimal.Decimal, string, []string) {
224 if len(required) == 0 {
225 return hundred, "No certifications required.", nil
226 }
227 req := append([]string(nil), required...)
228 sort.Strings(req)
229
230 heldSet := normalizeSet(held)
231 met := 0
232 var gaps []string
233 for _, rc := range req {
234 if heldSet[normalize(rc)] {
235 met++
236 } else {
237 gaps = append(gaps, fmt.Sprintf("Missing required certification: %s.", rc))
238 }
239 }
240 raw := ratio(met, len(req))
241 return raw, fmt.Sprintf("Holds %d of %d required certifications.", met, len(req)), gaps
242}
243
244// ---------- Customer experience (binary: prior experience with the required agency) ----------
245
246func scoreCustomer(experience []string, agency string) (decimal.Decimal, string) {
247 if strings.TrimSpace(agency) == "" {
248 return hundred, "No specific customer experience required."
249 }
250 if normalizeSet(experience)[normalize(agency)] {
251 return hundred, fmt.Sprintf("Has prior experience with %s.", agency)
252 }
253 return zero, fmt.Sprintf("No prior experience with %s.", agency)
254}
255
256// ---------- Experience years (full at/above requirement, else linear fraction) ----------
257
258func scoreExperience(years, required int) (decimal.Decimal, string) {
259 if required <= 0 {
260 return hundred, "No minimum years of experience required."
261 }
262 if years >= required {
263 return hundred, fmt.Sprintf("%d years meets the %d year requirement.", years, required)
264 }
265 if years < 0 {
266 years = 0
267 }
268 raw := ratio(years, required)
269 return raw, fmt.Sprintf("%d of %d required years (%s%%).", years, required, raw.StringFixed(0))
270}
271
272// ---------- Location & availability (average of location fit and availability) ----------
273
274func scoreLocation(c Candidate, r Requirement) (decimal.Decimal, string) {
275 var loc decimal.Decimal
276 var locReason string
277 switch {
278 case strings.TrimSpace(r.WorkLocation) == "":
279 loc, locReason = hundred, "no fixed work location"
280 case r.RemoteAllowed && c.Remote:
281 loc, locReason = hundred, "remote work satisfies location"
282 case normalize(c.Location) == normalize(r.WorkLocation):
283 loc, locReason = hundred, fmt.Sprintf("located in %s", r.WorkLocation)
284 case c.Relocatable:
285 loc, locReason = fifty, fmt.Sprintf("willing to relocate to %s", r.WorkLocation)
286 default:
287 loc, locReason = zero, fmt.Sprintf("not located in %s and not relocatable", r.WorkLocation)
288 }
289
290 avail, availReason := zero, "not currently available"
291 if c.Available {
292 avail, availReason = hundred, "available now"
293 }
294
295 raw := loc.Add(avail).Div(two)
296 return raw, fmt.Sprintf("Location/availability: %s; %s.", locReason, availReason)
297}
298
299// ---------- helpers ----------
300
301func bandFor(total decimal.Decimal) Band {
302 switch {
303 case total.GreaterThanOrEqual(BandGreenMin):
304 return BandGreen
305 case total.GreaterThanOrEqual(BandYellowMin):
306 return BandYellow
307 default:
308 return BandRed
309 }
310}
311
312func buildExplanation(res MatchResult) string {
313 var b strings.Builder
314 fmt.Fprintf(&b, "%s (%s/100). ", res.Band.Label(), res.Total.StringFixed(2))
315 if len(res.GapSummary) == 0 {
316 b.WriteString("No mandatory gaps.")
317 } else {
318 fmt.Fprintf(&b, "%d gap(s): %s", len(res.GapSummary), strings.Join(res.GapSummary, " "))
319 }
320 return strings.TrimSpace(b.String())
321}
322
323// ratio returns n/d as a 0–100 decimal (deterministic via shopspring's fixed DivisionPrecision).
324func ratio(n, d int) decimal.Decimal {
325 if d == 0 {
326 return hundred
327 }
328 return decimal.NewFromInt(int64(n)).Div(decimal.NewFromInt(int64(d))).Mul(hundred)
329}
330
331func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
332
333func equalFold(a, b string) bool {
334 return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
335}
336
337func normalizeSet(items []string) map[string]bool {
338 set := make(map[string]bool, len(items))
339 for _, it := range items {
340 set[normalize(it)] = true
341 }
342 return set
343}
344