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