package matching import ( "context" "strings" "testing" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" ) // TestGoldenAliasAndRelationship scores two new golden cases through the full engine with a // ChainedResolver wrapping an in-memory DeterministicAliasResolver (newResolverFromMaps). These // cases prove that alias resolution (exact canonical equivalence) and relationship resolution // (substitutes_for partial credit) both flow correctly from the curated ontology layer through // matchOne into the scored MatchResult. // // The three frozen golden cases in engine/golden_test.go are untouched and remain the contract // for the shared scoring weights and band cuts. // // Shared ontology maps for both cases: // // nameToID: "sigint" => sigintID // "skill b" => skillBID // "skill a" => skillAID // aliasToID: "signals intelligence" => sigintID // rels: skillAID => [{toID: skillBID, relation: "substitutes_for", strength: 0.80}] const ( sigintID = "00000000-0000-0000-0000-0000000000a1" skillBID = "00000000-0000-0000-0000-0000000000b1" skillAID = "00000000-0000-0000-0000-0000000000c1" ) func aliasGoldenMatcher() *ChainedResolver { nameToID := map[string]string{ "sigint": sigintID, "skill b": skillBID, "skill a": skillAID, } aliasToID := map[string]string{ "signals intelligence": sigintID, } rels := map[string][]relEdge{ skillAID: { {toID: skillBID, relation: "substitutes_for", strength: decimal.RequireFromString("0.80")}, }, } return NewChainedResolver(newResolverFromMaps(nameToID, aliasToID, rels), nil) } // aliasGoldenReq returns a requirement for exactly one skill (SIGINT by id) with no other // mandatory dimensions, so the skill subscore is isolated cleanly. func aliasGoldenReq() engine.Requirement { return engine.Requirement{ ID: "req-alias-golden", RequiredSkills: []engine.RequiredSkill{ {SkillID: sigintID, Name: "SIGINT", Mandatory: true}, }, } } // relationshipGoldenReq returns a requirement for exactly one skill (Skill B by id) with no // other mandatory dimensions. func relationshipGoldenReq() engine.Requirement { return engine.Requirement{ ID: "req-rel-golden", RequiredSkills: []engine.RequiredSkill{ {SkillID: skillBID, Name: "Skill B", Mandatory: true}, }, } } // TestGoldenAliasExactMatch proves that a candidate holding a skill named "Signals Intelligence" // (a curated alias for SIGINT) resolves through the ChainedResolver to a full match: the Skills // component is 100, AliasMatched is true, Similarity is 1.0, and the total is 100.00. // // Engine note: matchOne sets Exact=false for every sm-sourced match (the engine owns the match // flags; the matcher only supplies candidate side and similarity). The alias resolver returns // AliasMatched=true to distinguish this path from a semantic cosine match. func TestGoldenAliasExactMatch(t *testing.T) { matcher := aliasGoldenMatcher() req := aliasGoldenReq() cand := engine.Candidate{ ID: "cand-alias-golden", // No clearance required on this req so clearance scores 100 regardless. // No certs, no customer, 0 years required, no location: all dimensions score 100. Skills: []engine.CandidateSkill{ // No SkillID set; name is the alias text. The resolver maps "signals intelligence" // to sigintID which equals the required canonical id, triggering the alias path. {Name: "Signals Intelligence"}, }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, matcher) // Skills component: 1 of 1 required met (the alias resolved) => 100. if !res.Skills.Equal(decimal.NewFromInt(100)) { t.Fatalf("Skills = %s, want 100", res.Skills.String()) } // No gaps: the one mandatory skill was met via alias. if len(res.GapSummary) != 0 { t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary) } // The matched skill record must carry AliasMatched=true and Similarity=1.0. The engine // sets Exact=false for all sm-sourced matches (not a bug; see matchOne in engine.go). if len(res.MatchedSkills) == 0 { t.Fatal("expected at least one MatchedSkills entry") } m := res.MatchedSkills[0] if !m.Matched { t.Error("Matched should be true") } if !m.AliasMatched { t.Error("AliasMatched should be true for a curated alias match") } if !m.Similarity.Equal(decimal.NewFromInt(1)) { t.Errorf("Similarity = %s, want 1", m.Similarity.String()) } if m.Exact { t.Error("Exact is false for sm-sourced matches: the engine sets it to false in matchOne") } // Golden total: all six dimensions are 100 (no clearance req, no certs req, no customer req, // 0 years req, no location req, candidate is available). Total = 100.00. if res.Total.StringFixed(2) != "100.00" { t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2)) } if res.Band != engine.BandGreen { t.Fatalf("band = %s, want Green", res.Band) } // AEDT invariant: trace components must re-sum to the total. sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } } // TestGoldenRelationshipPartialCredit proves that a candidate holding Skill A (which has a // substitutes_for edge to Skill B at strength 0.80) satisfies the requirement for Skill B via // the ChainedResolver relationship path. // // Engine counting: matchOne returns ok=true for any sm.Match hit, so met increments to 1 and the // skill fraction is ratio(1,1)=100. The partial credit (0.80) lives in the SkillMatch.Similarity // field of the decision trace, not in the Skills sub-score denominator. The Skills component is // therefore 100, not 80, and the total is 100.00. The distinction is captured in the SkillMatch // record for AEDT explainability. func TestGoldenRelationshipPartialCredit(t *testing.T) { matcher := aliasGoldenMatcher() req := relationshipGoldenReq() cand := engine.Candidate{ ID: "cand-rel-golden", Skills: []engine.CandidateSkill{ // Skill A has a substitutes_for edge to Skill B at strength 0.80 in the rels map. {SkillID: skillAID, Name: "Skill A"}, }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, matcher) // Skills component: ratio(1,1) = 100. The relationship strength is in the SkillMatch record, // not in the sub-score fraction. See scoreSkills: it counts ok-matches, not weighted by similarity. if !res.Skills.Equal(decimal.NewFromInt(100)) { t.Fatalf("Skills = %s, want 100", res.Skills.String()) } // No mandatory gap: the relationship match satisfied the one required skill. if len(res.GapSummary) != 0 { t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary) } // The matched skill record must carry Similarity=0.80, Exact=false, AliasMatched=false. if len(res.MatchedSkills) == 0 { t.Fatal("expected at least one MatchedSkills entry") } m := res.MatchedSkills[0] if !m.Matched { t.Error("Matched should be true") } if m.AliasMatched { t.Error("AliasMatched should be false for a relationship match") } if !m.Similarity.Equal(decimal.RequireFromString("0.80")) { t.Errorf("Similarity = %s, want 0.80", m.Similarity.String()) } if m.Exact { t.Error("Exact should be false for a relationship match") } // Golden total: same logic as alias case (no dimensions have active requirements except // skills, which resolved to 100). Total = 100.00. if res.Total.StringFixed(2) != "100.00" { t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2)) } if res.Band != engine.BandGreen { t.Fatalf("band = %s, want Green", res.Band) } // AEDT invariant. sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } } // compartmentGoldenReq returns a requirement for TS/SCI + SI compartment. // No skills, certs, customer agency, years, or location requirements so every // dimension except clearance scores 100, isolating the compartment check cleanly. func compartmentGoldenReq() engine.Requirement { return engine.Requirement{ ID: "req-compartment-golden", RequiredLevel: engine.ClearanceTSSCI, RequiredCompartments: []string{"SI"}, } } // TestGoldenCompartmentSatisfied proves that a candidate holding TS/SCI + compartments SI and TK // passes the compartment check: Clearance scores 100 and the total is 100.00. // // This is an additive gate on top of the existing level and poly checks. Because the compartment // list is non-empty AND the candidate carries the required compartment, hasAll returns true and the // default case fires. The frozen three golden scores (100.00, 87.50, 26.00) use requirements with // no required compartments, so hasAll always returns true for those inputs and they are unaffected. func TestGoldenCompartmentSatisfied(t *testing.T) { req := compartmentGoldenReq() cand := engine.Candidate{ ID: "cand-compartment-satisfied", Clearance: engine.Clearance{ Level: engine.ClearanceTSSCI, Poly: engine.PolyNone, Status: engine.StatusActive, Compartments: []string{"SI", "TK"}, }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, nil) // Clearance component: TS/SCI active, SI and TK held, only SI required. hasAll returns true. if !res.Clearance.Equal(decimal.NewFromInt(100)) { t.Fatalf("Clearance = %s, want 100", res.Clearance.String()) } // No gaps: the compartment requirement is fully satisfied. if len(res.GapSummary) != 0 { t.Fatalf("gap count %d, want 0: %v", len(res.GapSummary), res.GapSummary) } // Golden total: no skills, certs, customer, years, or location required; candidate is available. // All six dimensions score 100. Total = 100.00. if res.Total.StringFixed(2) != "100.00" { t.Fatalf("total = %s, want 100.00", res.Total.StringFixed(2)) } if res.Band != engine.BandGreen { t.Fatalf("band = %s, want Green", res.Band) } sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } } // TestGoldenCompartmentMissing proves that a candidate holding TS/SCI but only TK (not SI) fails // the compartment check: Clearance scores 0 and the gap summary names the missing SI. // // Same requirement as TestGoldenCompartmentSatisfied. The candidate holds TK but not SI. // hasAll returns false; missingCompartments returns ["SI"]; scoreClearance returns 0 with a gap. // The total is: 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00. // v1.4.0: a missing required compartment zeroes the clearance component, which is a clearance gap // (RequiredLevel TS/SCI >= ClearanceConfidential, clearanceScore == 0). The band cap lowers // Yellow to Red. The Total stays 75.00 (caps lower only the band, never the total). func TestGoldenCompartmentMissing(t *testing.T) { req := compartmentGoldenReq() cand := engine.Candidate{ ID: "cand-compartment-missing", Clearance: engine.Clearance{ Level: engine.ClearanceTSSCI, Poly: engine.PolyNone, Status: engine.StatusActive, Compartments: []string{"TK"}, }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, nil) // Clearance component: TS/SCI active, TK held, but SI is required and missing. Scores 0. if !res.Clearance.Equal(decimal.Zero) { t.Fatalf("Clearance = %s, want 0", res.Clearance.String()) } // GapSummary carries only the structural gaps from scoreClearance (the missing SI compartment). // The v1.4.0 cap reason appears in Explanation only, not in GapSummary. if len(res.GapSummary) != 1 { t.Fatalf("gap count %d, want 1: %v", len(res.GapSummary), res.GapSummary) } if !strings.Contains(res.GapSummary[0], "SI") { t.Fatalf("gap does not name SI: %q", res.GapSummary[0]) } // Golden total unchanged by v1.4.0 (caps lower the band, not the total). // Total = 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00. if res.Total.StringFixed(2) != "75.00" { t.Fatalf("total = %s, want 75.00", res.Total.StringFixed(2)) } // v1.4.0 clearance gap cap: band is Red (was Yellow before the cap). if res.Band != engine.BandRed { t.Fatalf("band = %s, want Red (v1.4.0 clearance gap cap)", res.Band) } // The cap reason must appear in the Explanation for AEDT explainability. if !strings.Contains(res.Explanation, "clearance") { t.Fatalf("Explanation should mention clearance cap: %q", res.Explanation) } sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } } // clearanceGapGoldenReq returns a requirement for TS/SCI with no other mandatory dimensions // (no skills, certs, customer, years, or location). It isolates the clearance gap cap. func clearanceGapGoldenReq() engine.Requirement { return engine.Requirement{ ID: "req-clearance-gap-golden", RequiredLevel: engine.ClearanceTSSCI, } } // TestGoldenClearanceGapCap is a v1.4.0 golden case. A candidate holds Secret (active) but the // requirement demands TS/SCI. scoreClearance returns 0 (insufficient level). All other dimensions // are 100 (no requirements). The raw total is 0*0.25 + 100*0.75 = 75.00 (uncapped). bandFor(75.00) // = Yellow, but the v1.4.0 clearance gap cap lowers it to Red. Total stays 75.00. func TestGoldenClearanceGapCap(t *testing.T) { req := clearanceGapGoldenReq() cand := engine.Candidate{ ID: "cand-clearance-gap", // Holds Secret active, but TS/SCI is required. scoreClearance returns 0. Clearance: engine.Clearance{ Level: engine.ClearanceSecret, Status: engine.StatusActive, }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, nil) // Clearance component: Secret < TS/SCI. Scores 0. if !res.Clearance.Equal(decimal.Zero) { t.Fatalf("Clearance = %s, want 0", res.Clearance.String()) } // Uncapped total: 0*0.25 + 100*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 = 75.00. // The cap does not touch the total. if res.Total.StringFixed(2) != "75.00" { t.Fatalf("total = %s, want 75.00 (clearance gap cap does not change the total)", res.Total.StringFixed(2)) } // v1.4.0 clearance gap cap: band must be Red regardless of the total. if res.Band != engine.BandRed { t.Fatalf("band = %s, want Red (v1.4.0 clearance gap cap)", res.Band) } // The cap reason must appear in the Explanation for AEDT explainability. if !strings.Contains(res.Explanation, "clearance") { t.Fatalf("Explanation should mention clearance cap: %q", res.Explanation) } // AEDT invariant: trace components must re sum to the total. sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } } // mandatorySkillGapGoldenReq returns a requirement with two skills (AWS mandatory, Go mandatory) // and no clearance requirement, so we can isolate the mandatory skill gap cap. No certs, no // customer, 0 years, no location: all other dimensions score 100. func mandatorySkillGapGoldenReq() engine.Requirement { return engine.Requirement{ ID: "req-mandatory-skill-gap-golden", RequiredSkills: []engine.RequiredSkill{ {SkillID: "sk-aws", Name: "AWS", Mandatory: true}, {SkillID: "sk-go", Name: "Go", Mandatory: true}, }, } } // TestGoldenMandatorySkillGapCap is a v1.4.0 golden case proving the mandatory skill gap cap. The // requirement lists four mandatory skills; the candidate holds three (skills 75) with every other // dimension perfect, so the uncapped total is 93.75 which bandFor maps to Green. The cap lowers the // band to Yellow because one mandatory skill is unmet, and the total stays 93.75. func TestGoldenMandatorySkillGapCap(t *testing.T) { req := engine.Requirement{ ID: "req-mandatory-skill-gap-golden", RequiredSkills: []engine.RequiredSkill{ {SkillID: "sk-aws", Name: "AWS", Mandatory: true}, {SkillID: "sk-go", Name: "Go", Mandatory: true}, {SkillID: "sk-k8s", Name: "Kubernetes", Mandatory: true}, {SkillID: "sk-tf", Name: "Terraform", Mandatory: true}, }, } cand := engine.Candidate{ ID: "cand-mandatory-skill-gap", // No clearance required, so clearance scores 100. Skills: []engine.CandidateSkill{ {SkillID: "sk-aws", Name: "AWS"}, {SkillID: "sk-go", Name: "Go"}, {SkillID: "sk-k8s", Name: "Kubernetes"}, // Terraform (sk-tf) is missing. One mandatory skill unmet. }, Available: true, } res := engine.ScoreCandidate(context.Background(), cand, req, nil) // Skills component: 3 of 4 required met = 75. if res.Skills.StringFixed(2) != "75.00" { t.Fatalf("Skills = %s, want 75.00", res.Skills.StringFixed(2)) } // Uncapped total: 100*0.25 + 75*0.25 + 100*0.15 + 100*0.15 + 100*0.10 + 100*0.10 // = 25 + 18.75 + 15 + 15 + 10 + 10 = 93.75. bandFor(93.75) = Green. // The total is unchanged by the cap. if res.Total.StringFixed(2) != "93.75" { t.Fatalf("total = %s, want 93.75 (mandatory skill gap cap does not change the total)", res.Total.StringFixed(2)) } // v1.4.0 mandatory skill gap cap: band must be Yellow (Green lowered to Yellow). if res.Band != engine.BandYellow { t.Fatalf("band = %s, want Yellow (v1.4.0 mandatory skill gap cap)", res.Band) } // The cap reason must appear in the Explanation for AEDT explainability. if !strings.Contains(res.Explanation, "mandatory") { t.Fatalf("Explanation should mention mandatory skill cap: %q", res.Explanation) } // The gap for the missing mandatory skill must be present. hasGap := false for _, g := range res.GapSummary { if strings.Contains(strings.ToLower(g), "terraform") { hasGap = true break } } if !hasGap { t.Fatalf("GapSummary should include Terraform gap: %v", res.GapSummary) } // AEDT invariant: trace components must re sum to the total. sum := decimal.Zero for _, comp := range res.Trace { sum = sum.Add(comp.Weighted) } if !sum.Round(2).Equal(res.Total) { t.Fatalf("trace sum %s != total %s", sum.Round(2), res.Total) } }