package staffing import ( "sort" "github.com/shopspring/decimal" ) // ClassifyForGap classifies the scored bench against one gap requirement into ranked recommendations, // purely from the frozen scores, the staffing floor, current commitment, and precomputed adjacency. It is // the deterministic decision content of the gap view: no model, no I/O. // // In rank order it produces: // - ASSIGN for a qualified, available candidate who is uncommitted (or already committed to this gap). // - REASSIGN for a qualified, available candidate committed to a strictly lower priority requirement, // with the priority delta recorded. // - RESKILL for a near miss whose ONLY failure is the skills component (clearance is satisfied and the // candidate is available) and who holds a skill adjacent to a missing one. // - a single SOURCE note when the qualified, available, assignable bench cannot fill the open slots. // // It never disqualifies a candidate: qualification is the engine band floor, the optimizer and classifier // only arbitrate among the qualified, and a person decides every row. Ranks are 1 based in the returned // order. func ClassifyForGap(cfg Config, gap GapContext, bench []ScoredCandidate) []Recommendation { type tagged struct { c ScoredCandidate action Action reassign *ReassignEvidence } var assigns, reassigns, reskills []tagged for _, c := range bench { qualified := c.Total.GreaterThanOrEqual(cfg.StaffingFloor) switch { case qualified && c.Available && (c.CommittedRequirementID == "" || c.CommittedRequirementID == gap.RequirementID): assigns = append(assigns, tagged{c: c, action: ActionAssign}) case qualified && c.Available && c.CommittedRequirementID != "" && gap.Priority.GreaterThan(c.CommittedPriority): reassigns = append(reassigns, tagged{c: c, action: ActionReassign, reassign: &ReassignEvidence{ FromRequirementID: c.CommittedRequirementID, PriorityDelta: gap.Priority.Sub(c.CommittedPriority), }}) case !qualified && c.ClearanceSatisfied && c.Available && c.Reskill != nil: reskills = append(reskills, tagged{c: c, action: ActionReskill}) } } sortTagged := func(list []tagged) { sort.SliceStable(list, func(i, j int) bool { if d := list[i].c.Total.Cmp(list[j].c.Total); d != 0 { return d > 0 // higher total first } return list[i].c.CandidateID < list[j].c.CandidateID // stable, unique tiebreak }) } sortTagged(assigns) sortTagged(reassigns) sortTagged(reskills) recs := make([]Recommendation, 0, len(assigns)+len(reassigns)+len(reskills)+1) rank := 0 add := func(t tagged) { rank++ r := Recommendation{ RequirementID: gap.RequirementID, CandidateID: t.c.CandidateID, Action: t.action, Rank: rank, Confidence: Confidence(cfg, t.c), Evidence: Evidence{ EngineTotal: t.c.Total.StringFixed(2), Band: t.c.Band.Code(), }, } switch t.action { case ActionReassign: r.Evidence.Reassign = t.reassign case ActionReskill: r.Evidence.Reskill = t.c.Reskill } recs = append(recs, r) } for _, t := range assigns { add(t) } for _, t := range reassigns { add(t) } for _, t := range reskills { add(t) } // SOURCE: if the qualified, available, assignable bench cannot fill the open slots, add one gap // coverage note. It names no candidate and rejects no one. Its confidence is 1: it is a deterministic // fact that the bench is short, not a graded fit. if len(assigns) < gap.OpenSlots { rank++ remaining := gap.OpenSlots - len(assigns) recs = append(recs, Recommendation{ RequirementID: gap.RequirementID, Action: ActionSource, Rank: rank, Confidence: decimal.NewFromInt(1), Evidence: Evidence{OpenSlots: &remaining}, }) } return recs }