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