//go:build integration package integration // TestEEOAdverseImpactDryRun is the WS5 reproducible AEDT worked example. // // It generates a synthetic sample of 100 candidates, 5 requirements, and 500 screening // decisions (one per candidate per requirement), then writes consented demographics through // the compliance pool and calls the four fifths adverse impact report. // // EEO isolation contract (non negotiable): // - Demographics are written ONLY through the compliance pool into eeo.candidate_demographics // with consent_given true. No demographic value enters any audit payload. // - The matching service is used for scoring only. It never receives a demographic field. // - This file imports eeo and tenant only for the report call and the compliance write. // - No matching, scoring, or AI code file is modified. // // The test asserts structural correctness (rates in [0,1], ratio = rate/max_known_rate, Flagged // when ratio < 0.80, NoDemographicsCount and UnknownCount populated), NOT a specific pass or fail // outcome, because the exact distribution depends on the synthetic sample. // // The companion assertion at the end mirrors TestEEOPrivilegeBoundary: the app pool must still be // denied eeo schema access (42501). import ( "context" "encoding/json" "fmt" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/eeo" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ws5ClearanceLevels cycles through clearance levels so the synthetic bench has variety. var ws5ClearanceLevels = []string{"none", "secret", "top_secret", "ts_sci", "secret"} // ws5GenderValues are the consented gender labels written into eeo.candidate_demographics. // About 10 percent of candidates (indices 0..9 of each block of 10) receive no demographics row at // all, which exercises the NoDemographicsCount path. var ws5GenderValues = []string{"male", "female", "non_binary", "male", "female", "male", "female", "non_binary", "male", "female"} // ws5RaceValues are the consented race or ethnicity labels. var ws5RaceValues = []string{ "white", "black", "hispanic", "asian", "native_hawaiian_pacific_islander", "two_or_more", "white", "black", "hispanic", "asian", } // ws5RequirementLevels are the five required clearance levels for the five seeded requirements. var ws5RequirementLevels = []string{"none", "secret", "secret", "top_secret", "ts_sci"} // TestEEOAdverseImpactDryRun seeds a synthetic sample and runs the adverse impact report. func TestEEOAdverseImpactDryRun(t *testing.T) { if compliance == nil { t.Skip("compliance pool not configured (MM_COMPLIANCE_DSN); skipping WS5 dry run") } ctx := context.Background() org := seedOrg(t) // Step 1: seed 100 synthetic candidates with no demographic fields on the core type. // Clearance cycles through ws5ClearanceLevels so the scoring spread is meaningful. candidates := make([]uuid.UUID, 100) for i := range candidates { level := ws5ClearanceLevels[i%len(ws5ClearanceLevels)] status := "active" if i%7 == 0 { status = "expired" } c, err := dbgen.New(migrator).CreateCandidate(ctx, dbgen.CreateCandidateParams{ OrganizationID: org, FullName: fmt.Sprintf("WS5 Synthetic Candidate %d", i+1), ClearanceLevel: level, ClearancePoly: "none", ClearanceStatus: status, Available: i%5 != 0, }) if err != nil { t.Fatalf("seed candidate %d: %v", i, err) } candidates[i] = c.ID } // Step 2: seed 5 requirements with varied clearance levels. // Each requirement is seeded via the migrator so it bypasses RLS during setup. requirements := make([]uuid.UUID, 5) for i, level := range ws5RequirementLevels { var reqID uuid.UUID err := migrator.QueryRow(ctx, `INSERT INTO requirements (organization_id, title, required_level, required_poly) VALUES ($1, $2, $3, 'none') RETURNING id`, org, fmt.Sprintf("WS5 Requirement %d (Demo)", i+1), level).Scan(&reqID) if err != nil { t.Fatalf("seed requirement %d: %v", i, err) } requirements[i] = reqID } // Step 3: seed one slate per requirement with decided members so the selection stage has data. // The approval pattern is deterministic: candidate index mod 3 != 0 is approved. // This gives roughly a 2:1 approved to rejected ratio, and the per gender split will differ // because of the demographic distribution modulo pattern below. for ri, reqID := range requirements { var slateID uuid.UUID err := migrator.QueryRow(ctx, `INSERT INTO slates (organization_id, requirement_id, name, status) VALUES ($1, $2, $3, 'draft') RETURNING id`, org, reqID, fmt.Sprintf("WS5 Demo Slate %d", ri+1)).Scan(&slateID) if err != nil { t.Fatalf("seed slate %d: %v", ri, err) } for ci, candID := range candidates { decision := "approved" if ci%3 == 0 { decision = "rejected" } // Band and scores are deterministic placeholders; the report reads decision, not score. _, err := migrator.Exec(ctx, `INSERT INTO slate_members (organization_id, slate_id, candidate_id, total_score, band, clearance_score, skills_score, certs_score, customer_score, experience_score, location_score, explanation, trace, gap_summary, decision, model_version) VALUES ($1,$2,$3,75,'yellow',80,70,80,70,80,70,'WS5 demo','[]','[]',$4,'v1.0.0')`, org, slateID, candID, decision) if err != nil { t.Fatalf("seed slate member (slate %d, cand %d): %v", ri, ci, err) } } } // Step 4: seed aedt.score.served audit rows for the screening stage. // The served score payload carries only tool, score, and band, never a demographic value. // Candidates with an even index get band green (screened in); odd index gets band red. // The migrator role has INSERT on audit_logs. One audit row per candidate is sufficient: // the report groups by candidate id, so one row per candidate covers the full denominator. for ci, candID := range candidates { band := "green" score := "85.00" if ci%2 != 0 { band = "red" score = "55.00" } payload, _ := json.Marshal(map[string]any{ "tool": "screenMATCH.match", "score": score, "band": band, }) entityType := "candidate" entityID := candID.String() _, err := dbgen.New(migrator).InsertAuditLog(ctx, dbgen.InsertAuditLogParams{ OrganizationID: &org, Action: "aedt.score.served", EntityType: &entityType, EntityID: &entityID, NewValues: payload, }) if err != nil { t.Fatalf("seed audit log (cand %d): %v", ci, err) } } // Step 5: write consented demographics through the compliance pool. // Candidates at position i%10 == 0 (indices 0, 10, 20 ...) receive NO demographics row. // This exercises NoDemographicsCount on both stages. // All other candidates receive a consent_given=true row via the compliance pool inside // RunInTenantTx, exactly mirroring the seedDemographic helper in eeo_adverse_impact_test.go. for i, candID := range candidates { if i%10 == 0 { // Deliberate no consent row: exercises the NoDemographicsCount counter. continue } gender := ws5GenderValues[i%len(ws5GenderValues)] race := ws5RaceValues[i%len(ws5RaceValues)] err := tenant.New(compliance).RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { _, e := tx.Exec(ctx, `INSERT INTO eeo.candidate_demographics (organization_id, candidate_id, gender, race_ethnicity, consent_given, collected_by, collected_at) VALUES ($1, $2, $3, $4, true, NULL, now())`, org, candID, gender, race) return e }) if err != nil { t.Fatalf("seed demographic (cand %d): %v", i, err) } } // Step 6: call AdverseImpact through the compliance service. No app or matching code touches eeo. rep, err := eeo.NewService(tenant.New(compliance)).AdverseImpact(ctxOrg(org), time.Now()) if err != nil { t.Fatalf("AdverseImpact: %v", err) } // Step 7: assert the report is structurally correct. // We assert invariants that must hold for ANY valid synthetic sample, not a specific outcome. t.Run("SelectionSectionPresent", func(t *testing.T) { if rep.Selection.TotalN == 0 { t.Fatalf("selection section has TotalN=0; expected candidates with decisions and demographics") } }) t.Run("ScreeningSectionPresent", func(t *testing.T) { if rep.Screening.TotalN == 0 { t.Fatalf("screening section has TotalN=0; expected candidates with served scores and demographics") } }) t.Run("SelectionNoDemographicsCount", func(t *testing.T) { // 10 of 100 candidates have no demographics row. Each appears on all 5 slates, // but the query is DISTINCT on candidate, so NoDemographicsCount must be 10. if rep.Selection.NoDemographicsCount == 0 { t.Fatalf("selection NoDemographicsCount is 0; expected ~10 candidates with no demographics row") } }) t.Run("ScreeningNoDemographicsCount", func(t *testing.T) { if rep.Screening.NoDemographicsCount == 0 { t.Fatalf("screening NoDemographicsCount is 0; expected ~10 candidates with no demographics row") } }) t.Run("ByGenderRatesInBounds", func(t *testing.T) { assertRatesInBounds(t, rep.Selection.ByGender, "selection.ByGender") assertRatesInBounds(t, rep.Screening.ByGender, "screening.ByGender") }) t.Run("ByRaceRatesInBounds", func(t *testing.T) { assertRatesInBounds(t, rep.Selection.ByRace, "selection.ByRace") assertRatesInBounds(t, rep.Screening.ByRace, "screening.ByRace") }) t.Run("ByGenderAtLeastTwoKnownGroups", func(t *testing.T) { assertAtLeastTwoKnownGroups(t, rep.Selection.ByGender, "selection.ByGender") assertAtLeastTwoKnownGroups(t, rep.Screening.ByGender, "screening.ByGender") }) t.Run("ByRaceAtLeastTwoKnownGroups", func(t *testing.T) { assertAtLeastTwoKnownGroups(t, rep.Selection.ByRace, "selection.ByRace") assertAtLeastTwoKnownGroups(t, rep.Screening.ByRace, "screening.ByRace") }) t.Run("FourFifthsFlaggedConsistency", func(t *testing.T) { assertFourFifthsConsistency(t, rep.Selection.ByGender, "selection.ByGender") assertFourFifthsConsistency(t, rep.Selection.ByRace, "selection.ByRace") assertFourFifthsConsistency(t, rep.Screening.ByGender, "screening.ByGender") assertFourFifthsConsistency(t, rep.Screening.ByRace, "screening.ByRace") }) t.Run("ConsideredSelectedConsistency", func(t *testing.T) { for _, row := range rep.Selection.ByGender { if row.Selected > row.Considered { t.Errorf("selection ByGender %s: Selected %d > Considered %d", row.Group, row.Selected, row.Considered) } } for _, row := range rep.Screening.ByGender { if row.Selected > row.Considered { t.Errorf("screening ByGender %s: Selected %d > Considered %d", row.Group, row.Selected, row.Considered) } } }) t.Run("ScreeningDetectsGenderDisparity", func(t *testing.T) { // In the screening stage, candidates at even index get band green (screened in). // The gender assignment is ws5GenderValues[i%10]. For i%10 in {0,3,5,8,9} the gender // is male; for {1,4,6,9} it is female; for {2,7} it is non_binary. But index 0 has // no demographics row. So among candidates who have demographics (i%10 != 0): // male: positions 3,5,8,9 of each 10 block. Even: 8. Count per 10 = 4 from {3,5,8,9}. // Among those, even (screened in): 8 is even. So male screened = varies. // Rather than recomputing the exact arithmetic here, we just assert the report produced // impact ratios for at least two known gender groups, which was already checked above. // This sub test is a commentary anchor: the disparity outcome is sample dependent. found := map[string]bool{} for _, row := range rep.Screening.ByGender { if row.ImpactRatio != "n/a" { found[row.Group] = true } } if len(found) < 2 { t.Errorf("expected at least two known gender groups with a computed impact ratio in the screening section, got %v", found) } }) // Step 8: assert the privilege boundary. The app pool must be denied eeo schema access. t.Run("AppPoolDeniedEEOAccess", func(t *testing.T) { _, err := appDB.Pool.Exec(ctx, "SELECT 1 FROM eeo.candidate_demographics LIMIT 1") if pgCode(err) != "42501" { t.Fatalf("app pool must be denied eeo schema access (42501), got %v", err) } // The compliance pool can reach the table (RLS may return 0 rows, that is fine). if _, err := compliance.Exec(ctx, "SELECT 1 FROM eeo.candidate_demographics LIMIT 1"); err != nil { t.Fatalf("compliance pool must be able to read eeo schema, got %v", err) } }) } // assertRatesInBounds verifies every ImpactRow in the cut has rate in [0.0000, 1.0000]. // Rate is a string like "0.6667" so we parse it as a float for comparison. func assertRatesInBounds(t *testing.T, rows []eeo.ImpactRow, label string) { t.Helper() for _, row := range rows { var rate float64 if _, err := fmt.Sscanf(row.Rate, "%f", &rate); err != nil { t.Errorf("%s group %s: cannot parse rate %q: %v", label, row.Group, row.Rate, err) continue } if rate < 0 || rate > 1 { t.Errorf("%s group %s: rate %s is outside [0,1]", label, row.Group, row.Rate) } } } // assertAtLeastTwoKnownGroups verifies that at least two groups in the cut have a computed // impact ratio (i.e., the reference group count >= 2 so ratios are not all "n/a"). func assertAtLeastTwoKnownGroups(t *testing.T, rows []eeo.ImpactRow, label string) { t.Helper() known := 0 for _, row := range rows { if row.ImpactRatio != "n/a" { known++ } } if known < 2 { t.Errorf("%s: want at least 2 groups with a computed impact ratio, got %d (all n/a?)", label, known) } } // assertFourFifthsConsistency checks that Flagged is true iff the impact ratio is below 0.80. // Rows with ImpactRatio "n/a" are skipped (unknown or singleton group). func assertFourFifthsConsistency(t *testing.T, rows []eeo.ImpactRow, label string) { t.Helper() for _, row := range rows { if row.ImpactRatio == "n/a" { if row.Flagged { t.Errorf("%s group %s: Flagged=true but impact ratio is n/a", label, row.Group) } continue } var ratio float64 if _, err := fmt.Sscanf(row.ImpactRatio, "%f", &ratio); err != nil { t.Errorf("%s group %s: cannot parse impact ratio %q: %v", label, row.Group, row.ImpactRatio, err) continue } // The four fifths threshold is 0.80 (strictly less than triggers the flag). wantFlagged := ratio < 0.8 if row.Flagged != wantFlagged { t.Errorf("%s group %s: ratio=%s (%.6f), want Flagged=%v, got Flagged=%v", label, row.Group, row.ImpactRatio, ratio, wantFlagged, row.Flagged) } } }