package engine import ( "context" "fmt" "sort" "strings" "github.com/shopspring/decimal" ) // ModelVersion is the single source of the scoring model version string. It is stamped onto every // frozen slate_members decision and registered in aedt_model_versions, so an AEDT decision proves // which weights, band cuts, and semantic threshold produced it. Bump this whenever the weights, the // band cuts, OR the semantic threshold change, and register the new version (CLAUDE.md responsible AI // rule, NIST AI RMF model card). v1.1.0 recalibrated DefaultSemanticThreshold from 0.80 to 0.62; the // weights and band cuts are unchanged from v1.0.0. v1.4.0 adds two deterministic band caps (clearance // gap caps at Red, mandatory skill gap caps at Yellow); the weights, band cuts, and threshold are // unchanged from v1.3.0. const ModelVersion = "v1.4.0" // Fixed component weights (CLAUDE.md, do NOT change without explicit instruction). // They MUST sum to exactly 1.00; TestWeightsSumToOne enforces it. var ( WeightClearance = decimal.RequireFromString("0.25") WeightSkills = decimal.RequireFromString("0.25") WeightCerts = decimal.RequireFromString("0.15") WeightCustomer = decimal.RequireFromString("0.15") WeightExperience = decimal.RequireFromString("0.10") WeightLocation = decimal.RequireFromString("0.10") ) // DefaultSemanticThreshold is the cosine-similarity cutoff at/above which the injected // SkillMatcher may count a semantic (non-exact) skill match. Below it, a skill is unmet. // // Calibrated on the live bge-small-en-v1.5 vectors (the production embedder), not guessed. On a set // of known synonym and adjacent skill pairs versus known unrelated pairs, the genuine matches scored // 0.68 to 0.94 (cosine) while every unrelated pair scored at or below 0.57; the two groups separate // in the gap [0.57, 0.68]. 0.62 sits in that gap with margin on each side: above every unrelated // pair (no invented match) and below every genuine synonym (no discarded match). The earlier 0.80 // was tuned for a different embedder and silently discarded real bge matches (a clear synonym scored // 0.74, under the old cutoff). Acronym to expansion cases (for example SIGINT to Signals // Intelligence) sit below the unrelated ceiling and are not reachable by ANY safe single threshold; // those belong to the skills ontology alias layer, not to this cosine cutoff. See the calibration // harness TestSemanticThresholdCalibration. Changing this value is a model change: bump ModelVersion // and register it (the frozen test enforces both). var DefaultSemanticThreshold = decimal.RequireFromString("0.62") // Band cut points (CLAUDE.md): Green >= 90, Yellow 75–89.99, Red < 75. Inclusive lower bounds. var ( BandGreenMin = decimal.NewFromInt(90) BandYellowMin = decimal.NewFromInt(75) ) // shared decimal constants var ( zero = decimal.Zero fifty = decimal.NewFromInt(50) hundred = decimal.NewFromInt(100) two = decimal.NewFromInt(2) ) // ScoreCandidate scores one candidate against one requirement and returns a full, explainable, // reproducible MatchResult. It is PURE: no DB, no I/O, no clock, no randomness. Semantic skill // matching is delegated to the injected sm (nil is allowed, then only exact skill matches count). // // It NEVER reads a demographic/EEO attribute: the Candidate type carries none, and this function // references no such field. Every sub-score is decimal (never float64) so the same inputs always // yield byte-identical scores and explanations. func ScoreCandidate(ctx context.Context, c Candidate, r Requirement, sm SkillMatcher) MatchResult { res := MatchResult{CandidateID: c.ID, RequirementID: r.ID} clRaw, clRat, clGaps := scoreClearance(c.Clearance, r) clearanceScore := clRaw // captured for v1.4.0 band cap; the weighted total uses the rounded value skRaw, skRat, skGaps, matched := scoreSkills(ctx, c.Skills, r.RequiredSkills, sm) ceRaw, ceRat, ceGaps := scoreCerts(c.Certifications, r.RequiredCerts) cuRaw, cuRat := scoreCustomer(c.CustomerExperience, r.CustomerAgency) exRaw, exRat := scoreExperience(c.YearsExperience, r.RequiredYears) loRaw, loRat := scoreLocation(c, r) // Round every sub score to the numeric(7,4) column scale BEFORE persisting and before building the // trace, so the stored component columns, the jsonb trace, and the total are one consistent set and // re summing the components reproduces the total. Without this the column (DB rounded to 4) and the // trace (full precision) disagree for any non terminating ratio. res.Clearance = clRaw.Round(4) res.Skills = skRaw.Round(4) res.Certifications = ceRaw.Round(4) res.CustomerExperience = cuRaw.Round(4) res.Experience = exRaw.Round(4) res.Location = loRaw.Round(4) res.MatchedSkills = matched res.Trace = []ComponentScore{ newComponent("Clearance", WeightClearance, res.Clearance, clRat), newComponent("Skills", WeightSkills, res.Skills, skRat), newComponent("Certifications", WeightCerts, res.Certifications, ceRat), newComponent("CustomerExperience", WeightCustomer, res.CustomerExperience, cuRat), newComponent("Experience", WeightExperience, res.Experience, exRat), newComponent("LocationAvailability", WeightLocation, res.Location, loRat), } total := zero for _, comp := range res.Trace { total = total.Add(comp.Weighted) } res.Total = total.Round(2) res.Band = bandFor(res.Total) // v1.4.0 deterministic band caps. These lower the band only; the Total is never changed. // Precedence: the clearance cap (to Red) wins over the mandatory skill cap (to Yellow). // Both caps append a reason to the Explanation so the decision is always auditable (AEDT rule). var capReasons []string clearanceRequired := r.RequiredLevel >= ClearanceConfidential if clearanceRequired && clearanceScore.IsZero() { // CLEARANCE GAP CAP: a required clearance was not met (insufficient level, inactive, expired, // missing poly, or missing required compartment all return 0 from scoreClearance). Cap to Red. res.Band = BandRed capReasons = append(capReasons, "Band capped to Red: the required clearance is not met.") } else if hasUnmetMandatorySkill(r.RequiredSkills, matched) && res.Band == BandGreen { // MANDATORY SKILL GAP CAP: at least one required mandatory skill is unmet and the weighted // total produced a Green. Lower to Yellow only (a Yellow or Red is already at or below the cap). res.Band = BandYellow capReasons = append(capReasons, "Band capped to Yellow: a mandatory required skill is unmet.") } gaps := make([]string, 0, len(clGaps)+len(skGaps)+len(ceGaps)) gaps = append(gaps, clGaps...) gaps = append(gaps, skGaps...) gaps = append(gaps, ceGaps...) res.GapSummary = gaps res.Explanation = buildExplanation(res, capReasons) return res } func newComponent(name string, weight, raw decimal.Decimal, rationale string) ComponentScore { return ComponentScore{ Component: name, Weight: weight, Raw: raw, Weighted: raw.Mul(weight).Round(4), Rationale: rationale, } } // ---------- Clearance (0 or 100; exact-or-higher, active, poly-satisfied) ---------- func scoreClearance(held Clearance, r Requirement) (decimal.Decimal, string, []string) { // A requirement below Confidential means no clearance is required, everyone passes. if r.RequiredLevel < ClearanceConfidential { return hundred, "No clearance required for this role.", nil } reqDesc := r.RequiredLevel.String() if r.RequiredPoly != PolyNone { reqDesc += " + " + r.RequiredPoly.String() } switch { case held.Status != StatusActive: g := fmt.Sprintf("Requires active %s; candidate clearance is %s.", reqDesc, statusLabel(held.Status)) return zero, g + " Scores 0.", []string{g} case held.Level < r.RequiredLevel: g := fmt.Sprintf("Requires %s; candidate holds %s.", reqDesc, held.Level.String()) return zero, g + " Scores 0.", []string{g} case held.Poly < r.RequiredPoly: g := fmt.Sprintf("Requires %s; candidate has %s.", r.RequiredPoly.String(), held.Poly.String()) return zero, g + " Scores 0.", []string{g} case !hasAll(held.Compartments, r.RequiredCompartments): missing := missingCompartments(held.Compartments, r.RequiredCompartments) g := fmt.Sprintf("Requires compartments %v; candidate lacks %v.", r.RequiredCompartments, missing) return zero, g + " Scores 0.", []string{g} default: return hundred, fmt.Sprintf("Active %s meets required %s.", held.Level.String(), reqDesc), nil } } func statusLabel(s ClearanceStatus) string { switch s { case StatusActive: return "active" case StatusExpired: return "expired" case StatusInactive: return "inactive" default: return "unknown/none" } } // ---------- Skills (fraction of required met: exact first, then injected semantic) ---------- func scoreSkills(ctx context.Context, candSkills []CandidateSkill, required []RequiredSkill, sm SkillMatcher) (decimal.Decimal, string, []string, []SkillMatch) { if len(required) == 0 { return hundred, "No specific skills required.", nil, nil } // Normalize the iteration order so MatchedSkills output is deterministic regardless of caller order. req := make([]RequiredSkill, len(required)) copy(req, required) sort.SliceStable(req, func(i, j int) bool { if req[i].SkillID != req[j].SkillID { return req[i].SkillID < req[j].SkillID } return req[i].Name < req[j].Name }) matched := make([]SkillMatch, 0, len(req)) var gaps []string met := 0 for _, rs := range req { m, ok := matchOne(ctx, rs, candSkills, sm) matched = append(matched, m) switch { case ok: met++ case rs.Mandatory: gaps = append(gaps, fmt.Sprintf("Missing mandatory skill: %s.", rs.Name)) } } raw := ratio(met, len(req)) rat := fmt.Sprintf("Matched %d of %d required skills (%s%%).", met, len(req), raw.StringFixed(0)) return raw, rat, gaps, matched } // matchOne resolves one required skill: exact by SkillID, then exact by normalized name, then the // injected semantic matcher. It always returns a SkillMatch row (Matched=false when nothing met). func matchOne(ctx context.Context, rs RequiredSkill, candSkills []CandidateSkill, sm SkillMatcher) (SkillMatch, bool) { for _, cs := range candSkills { if (rs.SkillID != "" && cs.SkillID == rs.SkillID) || equalFold(cs.Name, rs.Name) { return SkillMatch{ RequiredSkillID: rs.SkillID, RequiredSkillName: rs.Name, CandidateSkillID: cs.SkillID, CandidateName: cs.Name, Similarity: decimal.NewFromInt(1), Exact: true, Matched: true, }, true } } if sm != nil { if m, ok := sm.Match(ctx, rs, candSkills, DefaultSemanticThreshold); ok { // The engine owns the authoritative required-side identity and the match flags; // the matcher only supplies the candidate side and the similarity. m.RequiredSkillID = rs.SkillID m.RequiredSkillName = rs.Name m.Exact = false m.Matched = true return m, true } } return SkillMatch{RequiredSkillID: rs.SkillID, RequiredSkillName: rs.Name, Matched: false}, false } // ---------- Certifications (fraction held, exact normalized name) ---------- func scoreCerts(held, required []string) (decimal.Decimal, string, []string) { if len(required) == 0 { return hundred, "No certifications required.", nil } req := append([]string(nil), required...) sort.Strings(req) heldSet := normalizeSet(held) met := 0 var gaps []string for _, rc := range req { if heldSet[normalize(rc)] { met++ } else { gaps = append(gaps, fmt.Sprintf("Missing required certification: %s.", rc)) } } raw := ratio(met, len(req)) return raw, fmt.Sprintf("Holds %d of %d required certifications.", met, len(req)), gaps } // ---------- Customer experience (binary: prior experience with the required agency) ---------- func scoreCustomer(experience []string, agency string) (decimal.Decimal, string) { if strings.TrimSpace(agency) == "" { return hundred, "No specific customer experience required." } if normalizeSet(experience)[normalize(agency)] { return hundred, fmt.Sprintf("Has prior experience with %s.", agency) } return zero, fmt.Sprintf("No prior experience with %s.", agency) } // ---------- Experience years (full at/above requirement, else linear fraction) ---------- func scoreExperience(years, required int) (decimal.Decimal, string) { if required <= 0 { return hundred, "No minimum years of experience required." } if years >= required { return hundred, fmt.Sprintf("%d years meets the %d year requirement.", years, required) } if years < 0 { years = 0 } raw := ratio(years, required) return raw, fmt.Sprintf("%d of %d required years (%s%%).", years, required, raw.StringFixed(0)) } // ---------- Location & availability (average of location fit and availability) ---------- func scoreLocation(c Candidate, r Requirement) (decimal.Decimal, string) { var loc decimal.Decimal var locReason string switch { case strings.TrimSpace(r.WorkLocation) == "": loc, locReason = hundred, "no fixed work location" case r.RemoteAllowed && c.Remote: loc, locReason = hundred, "remote work satisfies location" case normalize(c.Location) == normalize(r.WorkLocation): loc, locReason = hundred, fmt.Sprintf("located in %s", r.WorkLocation) case c.Relocatable: loc, locReason = fifty, fmt.Sprintf("willing to relocate to %s", r.WorkLocation) default: loc, locReason = zero, fmt.Sprintf("not located in %s and not relocatable", r.WorkLocation) } avail, availReason := zero, "not currently available" if c.Available { avail, availReason = hundred, "available now" } raw := loc.Add(avail).Div(two) return raw, fmt.Sprintf("Location/availability: %s; %s.", locReason, availReason) } // ---------- helpers ---------- func bandFor(total decimal.Decimal) Band { switch { case total.GreaterThanOrEqual(BandGreenMin): return BandGreen case total.GreaterThanOrEqual(BandYellowMin): return BandYellow default: return BandRed } } func buildExplanation(res MatchResult, capReasons []string) string { var b strings.Builder fmt.Fprintf(&b, "%s (%s/100). ", res.Band.Label(), res.Total.StringFixed(2)) if len(res.GapSummary) == 0 && len(capReasons) == 0 { b.WriteString("No mandatory gaps.") } else { allGaps := append(append([]string(nil), res.GapSummary...), capReasons...) fmt.Fprintf(&b, "%d gap(s): %s", len(allGaps), strings.Join(allGaps, " ")) } return strings.TrimSpace(b.String()) } // ratio returns n/d as a 0–100 decimal (deterministic via shopspring's fixed DivisionPrecision). func ratio(n, d int) decimal.Decimal { if d == 0 { return hundred } return decimal.NewFromInt(int64(n)).Div(decimal.NewFromInt(int64(d))).Mul(hundred) } func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func equalFold(a, b string) bool { return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) } func normalizeSet(items []string) map[string]bool { set := make(map[string]bool, len(items)) for _, it := range items { set[normalize(it)] = true } return set } // hasAll reports whether held carries every required compartment. An empty requirement is always met, // so existing inputs (no required compartments) are unaffected and the frozen scores do not change. func hasAll(held, required []string) bool { if len(required) == 0 { return true } set := map[string]bool{} for _, h := range held { set[strings.ToUpper(strings.TrimSpace(h))] = true } for _, r := range required { if !set[strings.ToUpper(strings.TrimSpace(r))] { return false } } return true } // hasUnmetMandatorySkill returns true if any required skill marked Mandatory has no corresponding // SkillMatch entry with Matched==true. It matches by RequiredSkillID when set, else by // RequiredSkillName (case insensitive). This is a v1.4.0 band cap gate, not a scoring input. func hasUnmetMandatorySkill(required []RequiredSkill, matched []SkillMatch) bool { // Build a lookup from the matched slice: id (when non-empty) and name (lowercased) => matched. byID := make(map[string]bool, len(matched)) byName := make(map[string]bool, len(matched)) for _, m := range matched { if m.Matched { if m.RequiredSkillID != "" { byID[m.RequiredSkillID] = true } byName[strings.ToLower(strings.TrimSpace(m.RequiredSkillName))] = true } } for _, rs := range required { if !rs.Mandatory { continue } if rs.SkillID != "" && byID[rs.SkillID] { continue } if rs.SkillID == "" && byName[strings.ToLower(strings.TrimSpace(rs.Name))] { continue } // If the skill has an id, also try by name as a fallback (defensive). if rs.SkillID != "" && byName[strings.ToLower(strings.TrimSpace(rs.Name))] { continue } return true } return false } func missingCompartments(held, required []string) []string { set := map[string]bool{} for _, h := range held { set[strings.ToUpper(strings.TrimSpace(h))] = true } var out []string for _, r := range required { if !set[strings.ToUpper(strings.TrimSpace(r))] { out = append(out, r) } } return out }