// Package matching is the SHARED SCORING CORE: the single source of truth for every Match and Mission // Readiness number in the product. It loads candidates and requirements from the DB, runs the pure // engine (internal/modules/matching/engine, the frozen weights and bands), and persists the // explainable result. The engine stays pure; THIS package owns the DB<->engine boundary, including // enum encode/decode (engine.Code/Parse) and the clearance expiry as of date rule. // // Today every scoring surface is served by the one flat handler in this package (handler.go). The // three tool separation splits that handler into screenMATCH, slateMATCH, and missionMATCH routers, // and each tool MUST keep calling these same service methods rather than re implement scoring: // - screenMATCH: Screen, ScreenCandidate, RankCandidates, and the Mission Check flow (single // candidate and pool qualification). // - slateMATCH: GenerateSlate, DecideSlateMember, SlateMemberTrace (proposal slates). // - missionMATCH: CandidateIntelligence here, plus the commandcenter rollups that read the persisted // slates and evaluations rather than recompute them. // // Because there is exactly one scoring path, the same candidate shows the same numbers in every tool. // The frozen weights and bands are guarded by engine.TestFrozenScoringConstants, the values are locked // by engine.TestGoldenScoringSnapshot, and the cross tool equality is proven by the integration test // TestSharedCoreScoresIdenticalAcrossTools. Do not add a second scoring path to any tool. package matching import ( "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/modules/matching/intelligence" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" ) // ClearanceFactsFrom builds the intelligence ClearanceFacts (with the clearance dates the engine // omits) from a DB candidate row plus its loaded customer agencies. Codes are CHECK constrained at // the DB so parsing always succeeds for a stored row. func ClearanceFactsFrom(c dbgen.Candidate, customerAgencies []string) intelligence.ClearanceFacts { level, _ := engine.ParseClearanceLevel(c.ClearanceLevel) poly, _ := engine.ParsePolygraph(c.ClearancePoly) status, _ := engine.ParseClearanceStatus(c.ClearanceStatus) return intelligence.ClearanceFacts{ Level: level, Poly: poly, Status: status, GrantedAt: datePtr(c.ClearanceGrantedAt), ExpiresAt: datePtr(c.ClearanceExpiresAt), ReinvestigationDueAt: datePtr(c.ReinvestigationDueAt), CustomerAgencies: customerAgencies, Provenance: c.ClearanceProvenance, VerifiedAt: c.ClearanceVerifiedAt, } } func datePtr(d pgtype.Date) *time.Time { if !d.Valid { return nil } t := d.Time return &t } // ToEngineCandidate maps a DB candidate (+ its loaded skills/certs/customer experience) into the // pure engine input. asOf is the evaluation date: clearance_expires_at is the source of truth, so an // Active flag with a past expiry is treated as Expired (review COMPL-1). It NEVER reads a demographic // field, there is none on the candidate row by design (AEDT). func ToEngineCandidate(c dbgen.Candidate, skills []dbgen.ListCandidateSkillsRow, certs, custExp []string, asOf time.Time) (engine.Candidate, error) { level, err := engine.ParseClearanceLevel(c.ClearanceLevel) if err != nil { return engine.Candidate{}, fmt.Errorf("candidate %s: %w", c.ID, err) } poly, err := engine.ParsePolygraph(c.ClearancePoly) if err != nil { return engine.Candidate{}, fmt.Errorf("candidate %s: %w", c.ID, err) } status, err := engine.ParseClearanceStatus(c.ClearanceStatus) if err != nil { return engine.Candidate{}, fmt.Errorf("candidate %s: %w", c.ID, err) } status = effectiveStatus(status, c.ClearanceExpiresAt, asOf) es := make([]engine.CandidateSkill, len(skills)) for i, s := range skills { es[i] = engine.CandidateSkill{SkillID: s.ID.String(), Name: s.Name} } return engine.Candidate{ ID: c.ID.String(), Clearance: engine.Clearance{Level: level, Poly: poly, Status: status, Compartments: c.ClearanceCompartments}, Skills: es, Certifications: certs, CustomerExperience: custExp, YearsExperience: int(c.YearsExperience), Location: deref(c.Location), Remote: c.Remote, Relocatable: c.Relocatable, Available: c.Available, }, nil } // effectiveStatus downgrades an Active clearance to Expired when its expiry date has passed as of the // evaluation date. The date column is authoritative over the stored flag. The comparison is on the // CIVIL DATE (asOf truncated to midnight UTC of its Y/M/D) so a clearance expiring exactly on the // as of day is still effective, matching the Command Center's `clearance_expires_at >= as_of::date` // byte for byte regardless of the wall clock time carried by asOf [review ENGINE-1/EN-1]. func effectiveStatus(stored engine.ClearanceStatus, expires pgtype.Date, asOf time.Time) engine.ClearanceStatus { asOfDate := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 0, 0, 0, 0, time.UTC) if stored == engine.StatusActive && expires.Valid && expires.Time.Before(asOfDate) { return engine.StatusExpired } return stored } // ToEngineRequirement maps a DB requirement (+ its required skills/certs) into the pure engine input. func ToEngineRequirement(r dbgen.Requirement, reqSkills []dbgen.ListRequiredSkillsRow, reqCerts []string) (engine.Requirement, error) { level, err := engine.ParseClearanceLevel(r.RequiredLevel) if err != nil { return engine.Requirement{}, fmt.Errorf("requirement %s: %w", r.ID, err) } poly, err := engine.ParsePolygraph(r.RequiredPoly) if err != nil { return engine.Requirement{}, fmt.Errorf("requirement %s: %w", r.ID, err) } rs := make([]engine.RequiredSkill, len(reqSkills)) for i, s := range reqSkills { rs[i] = engine.RequiredSkill{SkillID: s.ID.String(), Name: s.Name, Mandatory: s.Mandatory} } return engine.Requirement{ ID: r.ID.String(), RequiredLevel: level, RequiredPoly: poly, RequiredSkills: rs, RequiredCerts: reqCerts, CustomerAgency: deref(r.CustomerAgency), RequiredYears: int(r.RequiredYears), WorkLocation: deref(r.WorkLocation), RemoteAllowed: r.RemoteAllowed, KeyPersonnel: r.KeyPersonnel, RequiredCompartments: r.RequiredCompartments, }, nil } // ToSlateMemberParams freezes an engine result into the AEDT decision record. Band is the DB Code() // (never String()), and the full trace + gap summary are persisted as jsonb so the decision is // reproducible and explainable. func ToSlateMemberParams(orgID, slateID, candidateID uuid.UUID, res engine.MatchResult, rank *int32) (dbgen.AddSlateMemberParams, error) { trace, err := json.Marshal(res.Trace) if err != nil { return dbgen.AddSlateMemberParams{}, fmt.Errorf("marshal decision trace: %w", err) } gaps := []byte("[]") if len(res.GapSummary) > 0 { if gaps, err = json.Marshal(res.GapSummary); err != nil { return dbgen.AddSlateMemberParams{}, fmt.Errorf("marshal gap summary: %w", err) } } return dbgen.AddSlateMemberParams{ OrganizationID: orgID, SlateID: slateID, CandidateID: candidateID, TotalScore: res.Total, Band: res.Band.Code(), ClearanceScore: res.Clearance, SkillsScore: res.Skills, CertsScore: res.Certifications, CustomerScore: res.CustomerExperience, ExperienceScore: res.Experience, LocationScore: res.Location, Explanation: res.Explanation, Trace: trace, GapSummary: gaps, Rank: rank, ModelVersion: strPtr(engine.ModelVersion), }, nil } func strPtr(s string) *string { return &s } // ToCandidateEvaluationParams builds the upsert params for the persisted screenMATCH qualification of // one candidate against one requirement. It is the same frozen MatchResult the slate stores, shaped // for the candidate_evaluations table (the stored artifact missionMATCH reads). evaluatedBy is the // actor or nil. Like the slate member, the band is the stored CODE (never String()) and the matched // skills and gap summary are persisted as jsonb so the stored evaluation stays explainable. func ToCandidateEvaluationParams(orgID, candidateID, requirementID uuid.UUID, res engine.MatchResult, evaluatedBy *uuid.UUID) (dbgen.UpsertCandidateEvaluationParams, error) { matched := []byte("[]") if len(res.MatchedSkills) > 0 { m, err := json.Marshal(res.MatchedSkills) if err != nil { return dbgen.UpsertCandidateEvaluationParams{}, fmt.Errorf("marshal matched skills: %w", err) } matched = m } gaps := []byte("[]") if len(res.GapSummary) > 0 { g, err := json.Marshal(res.GapSummary) if err != nil { return dbgen.UpsertCandidateEvaluationParams{}, fmt.Errorf("marshal gap summary: %w", err) } gaps = g } return dbgen.UpsertCandidateEvaluationParams{ OrganizationID: orgID, CandidateID: candidateID, RequirementID: requirementID, TotalScore: res.Total, Band: res.Band.Code(), ClearanceScore: res.Clearance, SkillsScore: res.Skills, CertsScore: res.Certifications, CustomerScore: res.CustomerExperience, ExperienceScore: res.Experience, LocationScore: res.Location, Explanation: res.Explanation, MatchedSkills: matched, GapSummary: gaps, ModelVersion: engine.ModelVersion, EvaluatedBy: evaluatedBy, }, nil } func deref(s *string) string { if s == nil { return "" } return *s }