//go:build integration package integration import ( "context" "encoding/json" "testing" "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/ai" ) // seedSkillEdge inserts one curated, verified skill_relationships edge as the migrator (the global // reference table the app role may only read), so the G3 walk has real curated structure to traverse. func seedSkillEdge(t *testing.T, from, to uuid.UUID, relation, strength string) { t.Helper() if _, err := migrator.Exec(context.Background(), `INSERT INTO skill_relationships (from_skill_id, to_skill_id, relation, strength, source, verified_at) VALUES ($1, $2, $3, $4::numeric, 'curated', now()) ON CONFLICT (from_skill_id, to_skill_id, relation) DO NOTHING`, from, to, relation, strength); err != nil { t.Fatalf("seed skill edge %s %s: %v", relation, strength, err) } } // TestG3SkillSubstitutionClosure is the live proof of the G3 transitive skill walk against real Postgres. // It proves the recursive determinism guarantees the AEDT reproducibility mandate requires: the walk // terminates at the hard depth cap, a deliberately seeded cycle does NOT loop, the accumulated strength is // carried as decimal (never float64), the evidence path is the real edge chain, the curated // SIGINT to Signals Intelligence substitution hop resolves, and the same input yields byte identical rows // on a re run. // // All edges are GLOBAL curated reference data (skill_relationships), so the walk is correct without any // tenant data; it runs through the same RLS scoped, app role tenant transaction as every other read. func TestG3SkillSubstitutionClosure(t *testing.T) { org := seedOrg(t) run := uuid.NewString()[:8] // A small curated graph. Names are unique per run so the global unique on skills.name never collides. // SIGINT --substitutes_for(0.90)--> Signals Intelligence (the curated hop the task requires) // Signals Intelligence --broader_than(0.80)--> SIGINT Collection (a real depth 2 hop) // SIGINT Collection --substitutes_for(0.70)--> Deep Node (a real depth 3 hop) // Deep Node --substitutes_for(0.50)--> SIGINT (a CYCLE back to the start) // The cycle edge would loop forever without the visited path guard; with it, the walk is finite. sigint := seedSkillByName(t, "SIGINT "+run) signalsIntel := seedSkillByName(t, "Signals Intelligence "+run) collection := seedSkillByName(t, "SIGINT Collection "+run) deepNode := seedSkillByName(t, "Deep Node "+run) seedSkillEdge(t, sigint, signalsIntel, "substitutes_for", "0.90") seedSkillEdge(t, signalsIntel, collection, "broader_than", "0.80") seedSkillEdge(t, collection, deepNode, "substitutes_for", "0.70") seedSkillEdge(t, deepNode, sigint, "substitutes_for", "0.50") // the cycle back to the start // Walk from SIGINT with the default depth cap of 3. nodes, err := ai.SkillSubstitutionClosure(ctxOrg(org), appDB, sigint.String(), ai.DefaultGraphDepth) if err != nil { t.Fatalf("skill substitution closure: %v", err) } if len(nodes) == 0 { t.Fatal("the closure must return the reachable neighborhood, got zero rows") } // The curated SIGINT to Signals Intelligence hop resolves at depth 1 with the curated edge strength. var sawSignalsIntel bool for _, n := range nodes { if n.SkillID == signalsIntel.String() { sawSignalsIntel = true if n.SkillName != "Signals Intelligence "+run { t.Errorf("the resolved hop must name Signals Intelligence, got %q", n.SkillName) } if n.Depth != 1 { t.Errorf("the direct SIGINT to Signals Intelligence hop must be depth 1, got %d", n.Depth) } // The accumulated strength is a decimal, scanned from the numeric column, never a float64. if !n.Strength.Equal(decimal.RequireFromString("0.90")) { t.Errorf("the depth 1 accumulated strength must be the curated 0.90, got %s", n.Strength.String()) } // The evidence path is the real edge chain: the start id then the resolved id, one substitutes_for. if len(n.Path) != 2 || n.Path[0] != sigint.String() || n.Path[1] != signalsIntel.String() { t.Errorf("the evidence path must be the real edge chain, got %v", n.Path) } if len(n.Relations) != 1 || n.Relations[0] != "substitutes_for" { t.Errorf("the evidence relations must be the real edge relation, got %v", n.Relations) } } } if !sawSignalsIntel { t.Fatal("the curated SIGINT to Signals Intelligence substitution hop must resolve in the closure") } // Terminate at the depth cap: no row may exceed the bound depth of 3, even though the cycle edge would // otherwise extend the walk indefinitely. The cycle does not loop: SIGINT (the start) must never appear // as a reached node, because the visited path guard excludes an already visited skill id. for _, n := range nodes { if n.Depth > ai.DefaultGraphDepth { t.Errorf("a node was reached at depth %d, past the hard cap of %d; the walk did not terminate", n.Depth, ai.DefaultGraphDepth) } if n.SkillID == sigint.String() { t.Error("the start skill SIGINT was reached as a node; the cycle looped instead of being guarded") } // Every accumulated strength is a decimal in the valid 0 to 1 range (a multiplicative transitive // confidence). A figure outside that range would mean the numeric carry was corrupted. if n.Strength.LessThan(decimal.RequireFromString("0")) || n.Strength.GreaterThan(decimal.RequireFromString("1")) { t.Errorf("the accumulated strength must stay in 0 to 1, got %s for %s", n.Strength.String(), n.SkillName) } } // The deep node at depth 3 carries the product of the three edge strengths (0.90 * 0.80 * 0.70 = 0.504), // proving the strength accumulates as a decimal product across hops, not a per edge value. var sawDeep bool for _, n := range nodes { if n.SkillID == deepNode.String() { sawDeep = true if n.Depth != 3 { t.Errorf("the deep node must be reached at depth 3, got %d", n.Depth) } if !n.Strength.Equal(decimal.RequireFromString("0.504")) { t.Errorf("the depth 3 accumulated strength must be the decimal product 0.504, got %s", n.Strength.String()) } } } if !sawDeep { t.Fatal("the depth 3 deep node must be reachable within the cap") } // Byte stable: the same input yields byte identical rows on a re run (the determinism mandate). The // ORDER BY with the unique skill id tiebreaker guarantees a single stable order; decimal marshals // deterministically, so the JSON of the two runs must match byte for byte. again, err := ai.SkillSubstitutionClosure(ctxOrg(org), appDB, sigint.String(), ai.DefaultGraphDepth) if err != nil { t.Fatalf("skill substitution closure re run: %v", err) } first, _ := json.Marshal(nodes) second, _ := json.Marshal(again) if string(first) != string(second) { t.Errorf("the closure must be byte stable on a re run.\nfirst: %s\nsecond: %s", first, second) } } // TestG3SkillGapBridge proves the gap bridge intent returns the nearest curated skills within the bounded // closure with the evidence path, against real Postgres. The crosswalk arm is exercised structurally by // the unit guard test; here the curated relationship arm is proven live. func TestG3SkillGapBridge(t *testing.T) { org := seedOrg(t) run := uuid.NewString()[:8] required := seedSkillByName(t, "Bridge Required "+run) near := seedSkillByName(t, "Bridge Near "+run) seedSkillEdge(t, required, near, "substitutes_for", "0.88") nodes, err := ai.SkillGapBridge(ctxOrg(org), appDB, required.String(), 2) if err != nil { t.Fatalf("skill gap bridge: %v", err) } var sawNear bool for _, n := range nodes { if n.SkillID == near.String() { sawNear = true if n.BridgeKind != "curated_relationship" { t.Errorf("a curated relationship bridge must be labeled curated_relationship, got %q", n.BridgeKind) } if !n.Strength.Equal(decimal.RequireFromString("0.88")) { t.Errorf("the bridge strength must be the curated 0.88 decimal, got %s", n.Strength.String()) } if len(n.Path) != 2 || n.Path[0] != required.String() || n.Path[1] != near.String() { t.Errorf("the bridge evidence path must be the real edge chain, got %v", n.Path) } } } if !sawNear { t.Fatal("the gap bridge must return the nearest curated skill one hop away") } }