package matching import ( "context" "sync" "github.com/google/uuid" "github.com/pgvector/pgvector-go" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/platform/embeddings" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // EmbeddingSkillMatcher implements engine.SkillMatcher using the embedding provider: it embeds the // required skill name and each candidate skill name and picks the best cosine match at/above the // engine's threshold. Vectors from the provider are L2-normalized, so cosine == dot product. // // This is the semantic ("synonyms / adjacent skills") layer the engine defers to AFTER exact match. // With the deterministic offline embedder, unrelated skills score near zero, so it never invents a // false semantic match, exact matching still dominates. Determinism follows from the embedder's. type EmbeddingSkillMatcher struct { embedder embeddings.Embedder } // NewEmbeddingSkillMatcher wraps an embedder as an engine.SkillMatcher. func NewEmbeddingSkillMatcher(e embeddings.Embedder) *EmbeddingSkillMatcher { return &EmbeddingSkillMatcher{embedder: e} } // Match satisfies engine.SkillMatcher. It returns the candidate skill side and the similarity; the // engine owns the required-side identity and the match flags. ctx is the request context, so an // embedding provider call respects the request deadline and cancellation (it is never stored). // // The float dot product picks the single best candidate (ordering ties are astronomically unlikely // and never change the met/unmet outcome), but the at or above threshold DECISION is made in decimal // on the similarity rounded to a fixed scale, so the boundary outcome is reproducible across // architectures, on the engine's determinism path. func (m *EmbeddingSkillMatcher) Match(ctx context.Context, required engine.RequiredSkill, cands []engine.CandidateSkill, threshold decimal.Decimal) (engine.SkillMatch, bool) { if m == nil || m.embedder == nil || len(cands) == 0 { return engine.SkillMatch{}, false } rv, err := m.embedder.Embed(ctx, required.Name) if err != nil { return engine.SkillMatch{}, false } bestSim := -2.0 var best engine.CandidateSkill found := false for _, cs := range cands { cv, err := m.embedder.Embed(ctx, cs.Name) if err != nil || len(cv) != len(rv) { continue } if sim := dot(rv, cv); sim > bestSim { bestSim, best, found = sim, cs, true } } if !found { return engine.SkillMatch{}, false } sim := decimal.NewFromFloat(bestSim).Round(4) if sim.LessThan(threshold) { return engine.SkillMatch{}, false } return engine.SkillMatch{ CandidateSkillID: best.SkillID, CandidateName: best.Name, Similarity: sim, Matched: true, }, true } func dot(a, b []float32) float64 { var s float64 for i := range a { s += float64(a[i]) * float64(b[i]) } return s } // StoredSkillMatcher is the LIVE semantic skill matcher. It satisfies engine.SkillMatcher using the // BACKFILLED skill embeddings in the global skills table (the same vector(384) column the HNSW index // covers), instead of embedding skill name strings at request time. Embedding name strings at request time // was the dead live path: with the fake default embedder it produced fake similarities, and it ignored the // populated column entirely. This matcher reads the stored vectors once into an in memory cache (the skills // ontology is a small, global, bounded table) and resolves a required skill against a candidate's skills by // cosine over the stored vectors. // // The matcher's job is known skill to known skill (the required skill and the candidate's skills are both // ontology entries with ids), which is a direct cosine over two stored vectors, not a nearest neighbor // search; the HNSW index powers the UNBOUNDED adjacency search (ai.SkillAdjacency / KAG), which is already // live. Both read the same populated column, so the vector layer is now live across the board. // // The engine still does exact match first; this is only consulted for a required skill the candidate does // not hold exactly. A required or candidate skill with no stored embedding simply yields no semantic match // (exact matching is unaffected), so a partial backfill degrades gracefully rather than inventing matches. type StoredSkillMatcher struct { db *tenant.DB mu sync.RWMutex cache map[string][]float32 // skill id -> stored, L2 normalized embedding } // NewStoredSkillMatcher builds the matcher over the database that holds the global skills table. The // pgvector codec is registered on the pool, so the embedding column scans into a pgvector.Vector. func NewStoredSkillMatcher(db *tenant.DB) *StoredSkillMatcher { return &StoredSkillMatcher{db: db} } // Match resolves the required skill against the candidate's skills by cosine over the stored embeddings. func (m *StoredSkillMatcher) Match(ctx context.Context, required engine.RequiredSkill, cands []engine.CandidateSkill, threshold decimal.Decimal) (engine.SkillMatch, bool) { if m == nil || !m.db.Ready() || len(cands) == 0 { return engine.SkillMatch{}, false } cache := m.ensureLoaded(ctx) rv, ok := cache[required.SkillID] if !ok { return engine.SkillMatch{}, false // the required skill has no stored embedding; exact match still applies } bestSim := -2.0 var best engine.CandidateSkill found := false for _, cs := range cands { cv, ok := cache[cs.SkillID] if !ok || len(cv) != len(rv) { continue } if sim := dot(rv, cv); sim > bestSim { bestSim, best, found = sim, cs, true } } if !found { return engine.SkillMatch{}, false } sim := decimal.NewFromFloat(bestSim).Round(4) if sim.LessThan(threshold) { return engine.SkillMatch{}, false } return engine.SkillMatch{CandidateSkillID: best.SkillID, CandidateName: best.Name, Similarity: sim, Matched: true}, true } // ensureLoaded reads every embedded skill into the cache once. skills is a global, no RLS table, so a plain // pool read is correct and needs no tenant scope. A read failure or a missing embedding degrades to no // semantic match, never a fake one. Reload picks up a fresh backfill on a server restart. func (m *StoredSkillMatcher) ensureLoaded(ctx context.Context) map[string][]float32 { m.mu.RLock() if m.cache != nil { c := m.cache m.mu.RUnlock() return c } m.mu.RUnlock() m.mu.Lock() defer m.mu.Unlock() if m.cache != nil { return m.cache } cache := map[string][]float32{} // Acquire rather than reading the pool directly: under per tenant routing this type holds a routing // DB whose Pool is nil, and reading it returned an EMPTY cache with no error. Semantic skill matching // then silently reported no match for every candidate, changing the Skills component of an AEDT score // (a quarter of the total) with nothing logged and nothing failed. A scoring engine that is required to // be bit stable must not depend on which deployment mode a node happens to run in. leaf, release, aerr := m.db.Acquire(ctx) if aerr != nil || leaf.Pool == nil { m.cache = cache return cache } defer release() rows, err := leaf.Pool.Query(ctx, "SELECT id, embedding FROM skills WHERE embedding IS NOT NULL") if err != nil { m.cache = cache // mark loaded; exact matching still works return cache } defer rows.Close() for rows.Next() { var id uuid.UUID var vec pgvector.Vector if err := rows.Scan(&id, &vec); err != nil { continue } cache[id.String()] = vec.Slice() } m.cache = cache return cache }