package matching import ( "context" "strings" "sync" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // relEdge is one directed curated relationship from a source skill to a target skill. type relEdge struct { toID string relation string strength decimal.Decimal } // DeterministicAliasResolver resolves a required skill against a candidate skill set using two curated // ontology tables: skill_aliases (exact equivalences) and skill_relationships (partial credit via // substitutes_for and broader_than). It runs BEFORE the semantic matcher so an ontology-backed curated // match takes precedence over a cosine similarity. Graceful degradation: a DB load error leaves all // maps empty and the resolver returns no matches, leaving exact and semantic matching unaffected. type DeterministicAliasResolver struct { db *tenant.DB mu sync.RWMutex loaded bool nameToID map[string]string // lower(skills.name) to skills.id aliasToID map[string]string // lower(skill_aliases.alias_text) to skill_id rels map[string][]relEdge // from_skill_id to outgoing curated edges } // NewDeterministicAliasResolver builds a resolver backed by the global skills ontology tables. The // maps are loaded lazily on first call to ResolveAlias, matching the StoredSkillMatcher pattern. func NewDeterministicAliasResolver(db *tenant.DB) *DeterministicAliasResolver { return &DeterministicAliasResolver{db: db} } // newResolverFromMaps builds a resolver with maps injected directly, for deterministic unit tests // that need no DB. func newResolverFromMaps(nameToID, aliasToID map[string]string, rels map[string][]relEdge) *DeterministicAliasResolver { if nameToID == nil { nameToID = map[string]string{} } if aliasToID == nil { aliasToID = map[string]string{} } if rels == nil { rels = map[string][]relEdge{} } return &DeterministicAliasResolver{ loaded: true, nameToID: nameToID, aliasToID: aliasToID, rels: rels, } } // ResolveAlias runs the deterministic alias and relationship resolution for one required skill // against all candidate skills. The engine has already handled true exact id and name matches before // calling the resolver, so any hit here is alias or relationship sourced. // // Steps: // 1. Canonicalize the required skill to its ontology id. // 2. Canonicalize each candidate skill to its ontology id. // 3. If any candidate canonical id equals the required canonical id, return an alias match (1.0, // Exact true, AliasMatched true). // 4. Otherwise check curated relationships for a substitutes_for or broader_than edge from any // candidate to the required skill at or above strength 0.62. // 5. On no match, return engine.SkillMatch{}, false. func (r *DeterministicAliasResolver) ResolveAlias( ctx context.Context, required engine.RequiredSkill, candidateSkills []engine.CandidateSkill, ) (engine.SkillMatch, bool) { r.ensureLoaded(ctx) reqCanonical := r.canonicalize(required.SkillID, required.Name) if reqCanonical == "" { return engine.SkillMatch{}, false } // First pass: alias match (canonical id equality). for _, cs := range candidateSkills { csCanonical := r.canonicalize(cs.SkillID, cs.Name) if csCanonical == "" { continue } if csCanonical == reqCanonical { return engine.SkillMatch{ CandidateSkillID: cs.SkillID, CandidateName: cs.Name, Similarity: decimal.NewFromInt(1), Exact: true, AliasMatched: true, Matched: true, }, true } } // Second pass: relationship match at or above the 0.62 threshold. threshold := decimal.RequireFromString("0.62") bestStrength := decimal.Zero var bestMatch engine.SkillMatch found := false for _, cs := range candidateSkills { csCanonical := r.canonicalize(cs.SkillID, cs.Name) if csCanonical == "" { continue } r.mu.RLock() edges := r.rels[csCanonical] r.mu.RUnlock() for _, e := range edges { if e.toID != reqCanonical { continue } if e.relation != "substitutes_for" && e.relation != "broader_than" { continue } if e.strength.GreaterThan(bestStrength) { bestStrength = e.strength bestMatch = engine.SkillMatch{ CandidateSkillID: cs.SkillID, CandidateName: cs.Name, Similarity: e.strength, Exact: false, AliasMatched: false, Matched: true, } found = true } } } if found && bestStrength.GreaterThanOrEqual(threshold) { return bestMatch, true } return engine.SkillMatch{}, false } // canonicalize resolves a skill to its canonical ontology id. Priority: explicit id, then // nameToID lookup, then aliasToID lookup. Returns "" when nothing matches. func (r *DeterministicAliasResolver) canonicalize(skillID, name string) string { if skillID != "" { return skillID } r.mu.RLock() defer r.mu.RUnlock() key := strings.ToLower(strings.TrimSpace(name)) if id, ok := r.nameToID[key]; ok { return id } if id, ok := r.aliasToID[key]; ok { return id } return "" } // ensureLoaded reads nameToID, aliasToID, and rels from the DB once. skills and skill_aliases are // global tables (no RLS), so a plain pool query without a tenant transaction is correct. A load // failure leaves all maps empty for graceful degradation: exact matching and semantic matching are // unaffected. func (r *DeterministicAliasResolver) ensureLoaded(ctx context.Context) { r.mu.RLock() if r.loaded { r.mu.RUnlock() return } r.mu.RUnlock() r.mu.Lock() defer r.mu.Unlock() if r.loaded { return } r.loaded = true // mark loaded before any possible early return so a DB error does not loop if r.db == nil || r.db.Pool == nil { r.nameToID = map[string]string{} r.aliasToID = map[string]string{} r.rels = map[string][]relEdge{} return } nameToID := map[string]string{} rows, err := r.db.Pool.Query(ctx, "SELECT id, name FROM skills") if err == nil { defer rows.Close() for rows.Next() { var id, name string if err := rows.Scan(&id, &name); err != nil { continue } nameToID[strings.ToLower(strings.TrimSpace(name))] = id } } r.nameToID = nameToID aliasToID := map[string]string{} arows, err := r.db.Pool.Query(ctx, "SELECT skill_id, alias_text FROM skill_aliases WHERE verified_at IS NOT NULL") if err == nil { defer arows.Close() for arows.Next() { var skillID, aliasText string if err := arows.Scan(&skillID, &aliasText); err != nil { continue } aliasToID[strings.ToLower(strings.TrimSpace(aliasText))] = skillID } } r.aliasToID = aliasToID rels := map[string][]relEdge{} rrows, err := r.db.Pool.Query(ctx, `SELECT from_skill_id, to_skill_id, relation, strength FROM skill_relationships WHERE verified_at IS NOT NULL AND relation IN ('substitutes_for','broader_than')`) if err == nil { defer rrows.Close() for rrows.Next() { var fromID, toID, relation string var strength decimal.Decimal // strength is numeric(3,2). shopspring decimal implements sql.Scanner, the same path the sqlc // generated numeric columns use (the pool registers no decimal codec), so pgx scans the numeric // directly into the decimal with full precision. if err := rrows.Scan(&fromID, &toID, &relation, &strength); err != nil { continue } rels[fromID] = append(rels[fromID], relEdge{toID: toID, relation: relation, strength: strength}) } } r.rels = rels }