//go:build integration // MAG (Model Augmented Generation) win probability integration. Proves at runtime against a REAL Postgres and // the migrated 0036 coefficient store what the unit tests cannot: the first model backed intent on the closed // Registry gathers its feature (the advisory bid_win_rate_by_segment base rate) through the guarded // ExecuteIntent path RLS scoped, loads the prior only v0 champion coefficients from aedt_learned_models, scores // a CALIBRATED probability in shopspring/decimal, narrates the ordered driver contributions and the prior only // reliability disclosure through the deterministic fallback (a nil inference client), computes NO candidate // score, and emits a counts only aedt.ai.model_answered audit carrying model_key, version, output_kind, and // feature_count and NO value or PII. It also proves the raw ExecuteIntent path rejects the model intent. // // Run: go test -tags=integration ./internal/integration/ -run MAGWinProbability -v // Env: MM_APP_DSN, MM_MIGRATOR_DSN (required). The orchestrator applies migration 0036 (goose v36) first. package integration import ( "context" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/ai" "github.com/missionworkforce/missionmatch/internal/modules/bidoutcomes" "github.com/missionworkforce/missionmatch/internal/platform/audit" ) // TestMAGWinProbabilityPriorOnlyReturnsCalibratedBaseRate seeds a segment with decided bid outcomes so the // advisory base rate is defined, then runs the win probability MAG intent through the orchestrator with a nil // inference client (so the answer is the deterministic rendering). It asserts the answer carries a calibrated // probability in [0, 1] near the seeded base rate (prior only, betas zero, so the number is the shrunk market // base rate), the ordered driver contributions, the prior only reliability disclosure, and NO candidate score, // and that the audit holds model_key, version, output_kind, and feature_count and NO value or PII. func TestMAGWinProbabilityPriorOnlyReturnsCalibratedBaseRate(t *testing.T) { org := seedOrg(t) run := uuid.NewString()[:8] agency := "DEPT OF THE NAVY " + run // unique per run so the segment is isolated // Seed a segment with decided bid outcomes so the base rate is defined: two wins and one loss for the // agency, so the advisory win rate is 2/3. The prior only v0 model returns the calibrated intercept base // rate (0.60) regardless, because every learned coefficient is zero; the base rate is the FEATURE, which // the trace surfaces as a driver. req := seedRequirementWithAgency(t, org, agency) svc := bidoutcomes.NewService(appDB, nil) recordOutcome(t, svc, org, req, bidoutcomes.ResultWin) recordOutcome(t, svc, org, req, bidoutcomes.ResultWin) recordOutcome(t, svc, org, req, bidoutcomes.ResultLoss) orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) resp, err := orch.Answer(ctxOrg(org), "", ai.IntentOpportunityWinProbability, []any{agency, ""}) if err != nil { t.Fatalf("win probability answer: %v", err) } // (a) The answer is on the graph route and is not refused for an authenticated tenant. if resp.Refused { t.Fatalf("an authenticated win probability question must not be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("the model intent must dispatch to the graph route, got %q", resp.Route) } // (b) The answer carries a calibrated opportunity_score probability near the seeded base rate. Prior only // v0: the intercept is logit(0.60), betas zero, so the calibrated probability is near 0.60. The answer // names it as an opportunity_score, never a candidate score. if !strings.Contains(resp.Answer, "opportunity_score") { t.Errorf("the answer must name the opportunity_score output kind, got %q", resp.Answer) } if !strings.Contains(resp.Answer, "0.6") { t.Errorf("the prior only calibrated probability must be near 0.60, got %q", resp.Answer) } // (c) The ordered driver contributions are present: the intercept (the base) and the segment base rate // feature. if !strings.Contains(resp.Answer, "intercept contributes") { t.Errorf("the answer must carry the ordered intercept driver contribution, got %q", resp.Answer) } if !strings.Contains(resp.Answer, "segment_base_rate contributes") { t.Errorf("the answer must carry the ordered segment base rate driver contribution, got %q", resp.Answer) } // (d) The prior only reliability disclosure is present. if !strings.Contains(resp.Answer, "prior only estimate") { t.Errorf("the answer must carry the prior only reliability disclosure, got %q", resp.Answer) } // (e) The frozen engine path is untouched: no per candidate score, no band, no total_score, and the // disclosure explicitly names that this is not a per candidate match score. if !strings.Contains(strings.ToLower(resp.Answer), "not a per candidate match score") { t.Errorf("the answer must disclose it is not a per candidate match score, got %q", resp.Answer) } for _, scoreWord := range []string{"total_score", "band=", "candidatescore", "scorecandidate"} { if strings.Contains(strings.ToLower(resp.Answer), scoreWord) { t.Errorf("the win probability answer must report no engine score, leaked %q in %q", scoreWord, resp.Answer) } } // (f) The counts only audit holds model_key, version, output_kind, and feature_count and NO value or PII. var payloads []string if err := appDB.RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { rows, qerr := tx.Query(ctx, "SELECT new_values::text FROM audit_logs WHERE organization_id=$1 AND action='aedt.ai.model_answered'", org) if qerr != nil { return qerr } defer rows.Close() for rows.Next() { var p string if serr := rows.Scan(&p); serr != nil { return serr } payloads = append(payloads, p) } return rows.Err() }); err != nil { t.Fatalf("read model audit: %v", err) } if len(payloads) == 0 { t.Fatal("a win probability answer must emit an aedt.ai.model_answered audit event") } for _, p := range payloads { if !strings.Contains(p, ai.WinProbabilityModelKey) { t.Errorf("the model audit must record the model_key, got %q", p) } for _, want := range []string{"version", "output_kind", "feature_count", "opportunity_score"} { if !strings.Contains(p, want) { t.Errorf("the model audit must record %q, got %q", want, p) } } // NO probability value, NO agency, NO answer text, NO candidate id in the audit payload. The word // probability legitimately appears inside the model_key opportunity_win_probability, so the leak check // looks for a decimal value (a probability renders as 0.xxx) and for answer or candidate references, // not the bare word. if strings.Contains(p, agency) { t.Errorf("the model audit must hold no agency, leaked it in %q", p) } for _, leak := range []string{"0.", "answer", "candidate"} { if strings.Contains(p, leak) { t.Errorf("the model audit must hold no value, answer text, or candidate id, leaked %q in %q", leak, p) } } } } // TestMAGWinProbabilityNoBidHistoryStillReturnsPrior proves the prior only floor holds honestly when a segment // has NO decided bid history: the feature is a zero base rate, but the model still returns the calibrated // intercept base rate (near 0.60), because the prior is the intercept, not the feature. The answer states the // no history rationale honestly. func TestMAGWinProbabilityNoBidHistoryStillReturnsPrior(t *testing.T) { org := seedOrg(t) agency := "AGENCY WITH NO BIDS " + uuid.NewString()[:8] orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) resp, err := orch.Answer(ctxOrg(org), "", ai.IntentOpportunityWinProbability, []any{agency, ""}) if err != nil { t.Fatalf("win probability answer: %v", err) } if resp.Refused || resp.Route != ai.RouteGraph { t.Fatalf("a bound tenant win probability question must answer on the graph route, got %+v", resp) } // The prior only base rate is still returned near 0.60 (the intercept), even with a zero base rate feature. if !strings.Contains(resp.Answer, "0.6") { t.Errorf("with no bid history the prior only base rate must still be near 0.60, got %q", resp.Answer) } // The feature rationale states the no history honestly (via the segment base rate driver line and the // disclosure). The segment base rate driver contributes zero (coefficient zero), so it is present but zero. if !strings.Contains(resp.Answer, "segment_base_rate contributes 0") { t.Errorf("the segment base rate driver must contribute zero with a zero coefficient, got %q", resp.Answer) } } // TestMAGWinProbabilityRejectsRawIntentPath proves the defense in depth guard: the generic ExecuteIntent (the // raw path that returns database rows verbatim) hard rejects the win probability model intent, so a model // intent can never run as raw SQL even if a future caller tried it. func TestMAGWinProbabilityRejectsRawIntentPath(t *testing.T) { intent, ok := ai.IntentByName(ai.IntentOpportunityWinProbability) if !ok { t.Fatalf("the win probability intent %q must be registered", ai.IntentOpportunityWinProbability) } _, err := ai.ExecuteIntent(ctxOrg(seedOrg(t)), appDB, intent, []any{"agency", "naics"}) if err == nil { t.Fatal("ExecuteIntent must reject the win probability model intent on the raw path, got nil error") } if !strings.Contains(err.Error(), "ExecuteModelIntent") { t.Errorf("the raw path rejection of a model intent must name ExecuteModelIntent, got %v", err) } } // TestMAGWinProbabilityNaturalLanguageRoute proves the conservative classifier binds a quoted agency for a // win probability question and routes it to the model intent, returning the calibrated probability. It uses a // nil inference client so the answer is deterministic. func TestMAGWinProbabilityNaturalLanguageRoute(t *testing.T) { org := seedOrg(t) run := uuid.NewString()[:8] agency := "DEPT OF THE ARMY " + run req := seedRequirementWithAgency(t, org, agency) svc := bidoutcomes.NewService(appDB, nil) recordOutcome(t, svc, org, req, bidoutcomes.ResultWin) recordOutcome(t, svc, org, req, bidoutcomes.ResultLoss) orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) // A free form win probability question that quotes the agency: the classifier binds it and routes to the // model intent (no explicit intent name). question := "what is the win probability for \"" + agency + "\"" resp, err := orch.Answer(ctxOrg(org), question, "", nil) if err != nil { t.Fatalf("natural language win probability answer: %v", err) } if resp.Refused { t.Fatalf("the natural language win probability question must not be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("the classified win probability question must dispatch to the graph route, got %q", resp.Route) } if !strings.Contains(resp.Answer, "opportunity_score") || !strings.Contains(resp.Answer, "0.6") { t.Errorf("the classified answer must carry the calibrated opportunity_score near 0.60, got %q", resp.Answer) } } // verifyChampionSeeded is a small guard that the migrator applied the 0036 seed: exactly one champion row for // the win probability model, with the opportunity_score output kind and the intercept and segment base rate // coefficients. It reads through the migrator so it sees the global reference tables directly. func verifyChampionSeeded(t *testing.T) { t.Helper() var version int var outputKind string if err := migrator.QueryRow(context.Background(), `SELECT version, output_kind FROM aedt_learned_models WHERE model_key = $1 AND champion = true`, ai.WinProbabilityModelKey).Scan(&version, &outputKind); err != nil { t.Fatalf("the 0036 champion seed must be present: %v", err) } if outputKind != "opportunity_score" { t.Errorf("the seeded champion output kind must be opportunity_score, got %q", outputKind) } var nCoef int if err := migrator.QueryRow(context.Background(), `SELECT count(*) FROM aedt_model_coefficients WHERE model_key = $1 AND version = $2`, ai.WinProbabilityModelKey, version).Scan(&nCoef); err != nil { t.Fatalf("read seeded coefficients: %v", err) } if nCoef < 2 { t.Errorf("the seed must carry the intercept and the segment base rate coefficient, got %d rows", nCoef) } } // TestMAGChampionSeeded is the seed guard, run standalone so a missing 0036 seed fails fast with a clear // message rather than surfacing as a no champion error deep in a serving test. func TestMAGChampionSeeded(t *testing.T) { verifyChampionSeeded(t) }