Recovered source transcript
1package ai
2
3// MAG (Model Augmented Generation) is the fourth augmentation class after RAG, TAG, and GraphRAG. A MAG
4// intent is a model backed intent in the CLOSED Registry, selected by the planner like any other intent, that
5// the model authors ZERO of (no SQL, no params, no coefficients) and that can ONLY emit one of three legal
6// OutputKinds (opportunity_score, display_order, confidence), NEVER a per candidate score. That OutputKind
7// hard enum is THE mechanism that keeps the frozen decimal engine (internal/modules/matching, ModelVersion
8// v1.4.0) the only per candidate scorer by construction: a learned model is structurally incapable of
9// emitting a match score.
10//
11// A learned artifact is stored as interpretable numeric coefficients (aedt_model_coefficients) plus a monotone
12// calibration map (aedt_calibration_bins), and served in Go into shopspring/decimal, so serving needs no
13// second datastore, no cloud ML service, and no Python at request time. ExecuteModelIntent gathers the model's
14// FEATURES by running each declared FeatureIntent through the EXISTING guarded ExecuteIntent path (so RLS, the
15// read allowlist, ForbidEEO, and suppression all still hold), reads the champion coefficients, computes
16// log odds = intercept + SUM(coefficient_j * feature_j) in decimal, applies the logistic (a single isolated
17// float conversion, re quantized to decimal immediately, so every SURFACED number is decimal), applies the
18// calibration map, and returns the calibrated probability plus a per feature additive contribution trace
19// shaped exactly like engine.ComponentScore. That trace is the citations the existing faithfulness gate grounds
20// the narration against.
21//
22// The first MAG intent is opportunity_win_probability. At v0 it is PRIOR ONLY: the intercept is the logit of a
23// defensible federal recompete base rate and every learned coefficient is 0.0, so the model returns the
24// calibrated market base rate the day it ships and its learned coefficients light up automatically as decided
25// bid labels accrue. It emits opportunity_score, never a candidate score.
26
27import (
28 "context"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "math"
33 "sort"
34 "strings"
35
36 "github.com/jackc/pgx/v5"
37 "github.com/jackc/pgx/v5/pgtype"
38 "github.com/shopspring/decimal"
39
40 "github.com/missionworkforce/missionmatch/internal/platform/tenant"
41)
42
43// WinProbabilityModelKey is the model_key of the first MAG intent's champion in aedt_learned_models. It is the
44// key ExecuteModelIntent resolves the champion coefficients under; the migration 0036 seed writes the prior
45// only v0 champion under this exact key.
46const WinProbabilityModelKey = "opportunity_win_probability"
47
48// IntentOpportunityWinProbability is the FIXED registered name of the win probability MAG intent. It is a
49// closed Registry entry (so ValidateRegistry asserts its static MAG shape at boot) and an ExecutorModel intent
50// (so the raw ExecuteIntent path hard rejects it and the AnswerGraph model fork routes it to
51// answerModelIntent). It does NOT run through ExecuteIntent.
52const IntentOpportunityWinProbability = "opportunity_win_probability"
53
54// interceptFeatureName is the reserved coefficient row that carries the model's bias term (the log odds base
55// rate), distinct from every real feature coefficient. It is not a gathered feature; it is the constant added
56// once to the log odds sum.
57const interceptFeatureName = "intercept"
58
59// winProbabilityReliabilityDisclosure is the fixed reliability line the win probability answer surfaces so a
60// reader knows the number is a prior only market base rate, not a learned per segment signal, until labels
61// accrue. It carries no dash.
62const winProbabilityReliabilityDisclosure = "Reliability: this is a prior only estimate. It shrinks to the market base rate over the decided bids in the segment; learned per segment signal lights up as more bids are decided. It is an opportunity level probability, not a per candidate match score."
63
64// Contribution is one per feature additive term of a MAG score, shaped exactly like engine.ComponentScore so
65// the MAG trace reads like an engine trace: the feature name, its gathered value, the champion coefficient, the
66// resulting additive contribution to the log odds (coefficient times value), and a human readable rationale.
67// Every numeric field is shopspring/decimal, never float; the contribution is the citation the faithfulness
68// gate grounds the prose against.
69type Contribution struct {
70 FeatureName string `json:"feature_name"`
71 Value decimal.Decimal `json:"value"`
72 Coefficient decimal.Decimal `json:"coefficient"`
73 Contribution decimal.Decimal `json:"contribution"`
74 Rationale string `json:"rationale"`
75 // Absent marks a feature the gather step could not read for this segment (no decided bid history). The
76 // scorer imputes the model's stored training mean for an absent feature, so a learned model returns the
77 // base rate for a no history segment (intercept plus coefficient times the mean) instead of extrapolating
78 // off a misleading zero. It is not surfaced in the answer JSON.
79 Absent bool `json:"-"`
80}
81
82// ModelResult is the served answer of a MAG intent: the calibrated Value (a probability in [0, 1] for an
83// opportunity_score or a confidence), the OutputKind (one of the three legal kinds, NEVER a candidate score),
84// the model_key and version that produced it, and the ordered per feature contribution Trace (the citations).
85// Value is shopspring/decimal; there is no per candidate score anywhere in this shape by construction.
86type ModelResult struct {
87 Value decimal.Decimal
88 OutputKind string
89 ModelKey string
90 Version int
91 Trace []Contribution
92}
93
94// championModel is the loaded champion row plus its coefficients and calibration bins, read from
95// aedt_learned_models, aedt_model_coefficients, and aedt_calibration_bins for a model_key. It is global
96// reference data (no org, no PII), so it is loaded with a plain read; the coefficients are keyed by feature
97// name (the intercept plus every real feature), all shopspring/decimal.
98type championModel struct {
99 ModelKey string
100 Version int
101 OutputKind string
102 Intercept decimal.Decimal
103 Coefficients map[string]decimal.Decimal
104 // FeatureMeans is each feature's training mean, read from the feature_spec. The serve path imputes it for a
105 // feature with no history (a segment with no decided bids), so a learned model returns the base rate for a
106 // no history segment (intercept + coefficient times the mean) rather than extrapolating off a misleading
107 // zero. It is empty for the prior only v0 (whose coefficients are zero, so imputation is moot).
108 FeatureMeans map[string]decimal.Decimal
109 Calibration []calibrationBin
110}
111
112// featureMean returns the stored training mean for a feature, or decimal zero when none is stored (the prior
113// only model stores none, and its zero coefficients make the imputed value moot).
114func (m championModel) featureMean(feature string) decimal.Decimal {
115 if v, ok := m.FeatureMeans[feature]; ok {
116 return v
117 }
118 return decimal.Zero
119}
120
121// calibrationBin maps a raw probability in the closed range [Lo, Hi] to an EmpiricalRate. An empty calibration
122// set is the identity map (the raw probability passes through). All three figures are decimal, never float.
123type calibrationBin struct {
124 Lo, Hi decimal.Decimal
125 EmpiricalRate decimal.Decimal
126}
127
128// coefficient returns the champion coefficient for a feature name, or decimal zero when the feature has no
129// stored coefficient (a prior only feature or a feature the champion does not weight). A missing coefficient
130// is a zero contribution, the safe default, never an error.
131func (m championModel) coefficient(feature string) decimal.Decimal {
132 if c, ok := m.Coefficients[feature]; ok {
133 return c
134 }
135 return decimal.Zero
136}
137
138// loadChampionModel loads the CHAMPION row for a model_key plus its coefficients and calibration bins from the
139// global model tables under a guarded read. The champion is the single row where champion is true (enforced
140// unique by the partial index in migration 0036); an error surfaces when no champion is resolvable, so a
141// serving path never silently emits an uncalibrated number. It runs inside the caller's tenant tx: the model
142// tables are GLOBAL reference (SELECT granted to the app role, no RLS policy), so the org GUC does not filter
143// them, exactly like the bls_wages reference read.
144func loadChampionModel(ctx context.Context, tx pgx.Tx, modelKey string) (championModel, error) {
145 var m championModel
146 m.ModelKey = modelKey
147 m.Coefficients = map[string]decimal.Decimal{}
148 m.FeatureMeans = map[string]decimal.Decimal{}
149
150 // The champion version, its output kind, and its feature_spec (the single champion row per model_key).
151 var featureSpec []byte
152 row := tx.QueryRow(ctx,
153 `SELECT version, output_kind, feature_spec FROM aedt_learned_models WHERE model_key = $1 AND champion = true`, modelKey)
154 if err := row.Scan(&m.Version, &m.OutputKind, &featureSpec); err != nil {
155 if errors.Is(err, pgx.ErrNoRows) {
156 return championModel{}, fmt.Errorf("ai: no champion model for %q", modelKey)
157 }
158 return championModel{}, err
159 }
160 if !validOutputKind(m.OutputKind) {
161 // A stored output kind outside the legal enum can never surface a candidate score: refuse to serve it.
162 return championModel{}, fmt.Errorf("ai: champion model %q has an illegal output kind %q", modelKey, m.OutputKind)
163 }
164 m.FeatureMeans = parseFeatureMeans(featureSpec)
165
166 // The coefficients: the intercept plus every real feature, scanned into shopspring/decimal exact.
167 crows, err := tx.Query(ctx,
168 `SELECT feature_name, coefficient FROM aedt_model_coefficients WHERE model_key = $1 AND version = $2`,
169 modelKey, m.Version)
170 if err != nil {
171 return championModel{}, err
172 }
173 defer crows.Close()
174 m.Intercept = decimal.Zero
175 for crows.Next() {
176 var name string
177 var coef decimal.Decimal
178 if err := crows.Scan(&name, &coef); err != nil {
179 return championModel{}, err
180 }
181 if name == interceptFeatureName {
182 m.Intercept = coef
183 continue
184 }
185 m.Coefficients[name] = coef
186 }
187 if err := crows.Err(); err != nil {
188 return championModel{}, err
189 }
190
191 // The calibration bins, ordered by bin index. An empty set is the identity map.
192 brows, err := tx.Query(ctx,
193 `SELECT bin_lo, bin_hi, empirical_rate FROM aedt_calibration_bins WHERE model_key = $1 AND version = $2 ORDER BY bin_index`,
194 modelKey, m.Version)
195 if err != nil {
196 return championModel{}, err
197 }
198 defer brows.Close()
199 for brows.Next() {
200 var b calibrationBin
201 if err := brows.Scan(&b.Lo, &b.Hi, &b.EmpiricalRate); err != nil {
202 return championModel{}, err
203 }
204 m.Calibration = append(m.Calibration, b)
205 }
206 if err := brows.Err(); err != nil {
207 return championModel{}, err
208 }
209 return m, nil
210}
211
212// parseFeatureMeans reads each feature's training mean out of the feature_spec jsonb, keyed by feature name. A
213// feature with no stored mean (the prior only v0) is simply absent from the map, so featureMean returns zero,
214// which is moot there because the prior only coefficients are zero. A malformed spec yields an empty map, the
215// safe default (no imputation), never an error, so a serve path never fails on a spec it cannot read.
216func parseFeatureMeans(spec []byte) map[string]decimal.Decimal {
217 out := map[string]decimal.Decimal{}
218 if len(spec) == 0 {
219 return out
220 }
221 var parsed struct {
222 Features []struct {
223 Name string `json:"name"`
224 Mean string `json:"mean"`
225 } `json:"features"`
226 }
227 if err := json.Unmarshal(spec, &parsed); err != nil {
228 return out
229 }
230 for _, f := range parsed.Features {
231 if f.Name == "" || f.Mean == "" {
232 continue
233 }
234 if d, err := decimal.NewFromString(f.Mean); err == nil {
235 out[f.Name] = d
236 }
237 }
238 return out
239}
240
241// featureExtractors maps a FeatureIntent name to the function that runs it (through the existing guarded
242// ExecuteIntent path) and extracts the declared scalar feature from its result. The extractor closes over the
243// bound args (the segment keys). It is the FIXED, reviewed set of features a MAG intent may read; a feature
244// intent with no extractor is refused, so a model can never read a feature the platform did not author an
245// extractor for.
246type featureExtractor func(ctx context.Context, db *tenant.DB, args []any) (decimal.Decimal, string, error)
247
248// scoreModelIntent is the pure serving math, separated from the database read so it is unit testable with a
249// fixed champion, a fixed feature vector, and no DB. It computes log odds = intercept + SUM(coefficient_j *
250// value_j) in shopspring/decimal, applies the logistic (a single isolated float conversion, re quantized to
251// decimal immediately), applies the calibration map, and returns the calibrated probability plus the ordered
252// contribution trace. Every SURFACED number (the value, the coefficient, the contribution, the probability) is
253// decimal; the only float is the transient sigmoid argument, which never surfaces.
254func scoreModelIntent(model championModel, feats []Contribution) (decimal.Decimal, []Contribution) {
255 // The additive log odds in decimal: the intercept plus every feature's coefficient times value. Each
256 // feature carries its own contribution (coefficient times value) as the citation.
257 logOdds := model.Intercept
258 trace := make([]Contribution, 0, len(feats)+1)
259 trace = append(trace, Contribution{
260 FeatureName: interceptFeatureName,
261 Value: decimal.NewFromInt(1),
262 Coefficient: model.Intercept,
263 Contribution: model.Intercept,
264 Rationale: "the model bias term, the log odds of the prior base rate before any feature",
265 })
266 for _, f := range feats {
267 coef := model.coefficient(f.FeatureName)
268 // A feature with no history is imputed at the model's training mean, so a learned model returns the base
269 // rate for a no history segment (intercept plus coefficient times the mean) rather than extrapolating off
270 // a misleading zero. The gathered value passes through unchanged when the feature is present.
271 value := f.Value
272 rationale := f.Rationale
273 if f.Absent {
274 value = model.featureMean(f.FeatureName)
275 rationale = f.Rationale + "; the feature is imputed at the model training mean " + value.String() + " so a no history segment rests on the base rate"
276 }
277 contribution := coef.Mul(value)
278 logOdds = logOdds.Add(contribution)
279 trace = append(trace, Contribution{
280 FeatureName: f.FeatureName,
281 Value: value,
282 Coefficient: coef,
283 Contribution: contribution,
284 Rationale: rationale,
285 })
286 }
287
288 // The logistic. This is the single place a float is used: the exponential has no exact decimal form, so
289 // the log odds decimal is converted to float64 for the sigmoid ONLY, and the result is re quantized to a
290 // decimal probability immediately. Every surfaced number is decimal; this transient float never surfaces.
291 raw := sigmoidToDecimal(logOdds)
292
293 // The calibration map (identity when the champion carries no bins).
294 calibrated := applyCalibration(model.Calibration, raw)
295 return calibrated, trace
296}
297
298// sigmoidToDecimal computes the logistic 1 / (1 + exp(-x)) of a decimal log odds and returns a decimal
299// probability. The exponential is the one operation with no exact decimal form, so the log odds is converted
300// to float64 for the sigmoid ONLY and the float result is re quantized to a decimal rounded to ten places
301// (the coefficient scale), so the surfaced probability is decimal and stable. The transient float never
302// surfaces.
303func sigmoidToDecimal(logOdds decimal.Decimal) decimal.Decimal {
304 x, _ := logOdds.Float64()
305 p := 1.0 / (1.0 + math.Exp(-x))
306 // Re quantize the float result to a decimal at the coefficient scale. This is the boundary: from here on
307 // the number is decimal.
308 return decimal.NewFromFloat(p).Round(10)
309}
310
311// applyCalibration remaps a raw probability through the champion's monotone calibration bins. A raw probability
312// in a bin's closed range [Lo, Hi] maps to that bin's EmpiricalRate. An empty bin set is the identity map (the
313// raw probability passes through unchanged), the honest calibration for a prior only model. The result is
314// clamped to [0, 1] so a calibrated probability is always a valid probability, all in decimal.
315func applyCalibration(bins []calibrationBin, raw decimal.Decimal) decimal.Decimal {
316 out := raw
317 for _, b := range bins {
318 if raw.GreaterThanOrEqual(b.Lo) && raw.LessThanOrEqual(b.Hi) {
319 out = b.EmpiricalRate
320 break
321 }
322 }
323 if out.LessThan(decimal.Zero) {
324 return decimal.Zero
325 }
326 if out.GreaterThan(decimal.NewFromInt(1)) {
327 return decimal.NewFromInt(1)
328 }
329 return out
330}
331
332// ExecuteModelIntent runs a MAG intent: it confirms the intent is ExecutorModel, loads the champion
333// coefficients, gathers each declared FeatureIntent's scalar through the existing guarded ExecuteIntent path,
334// scores the calibrated probability in decimal, and returns the ModelResult with its contribution trace. It
335// runs inside the caller's tenant tx so the champion read (global reference) and the feature reads (RLS scoped
336// through ExecuteIntent) share one authenticated tenant context. It NEVER computes a per candidate score: the
337// OutputKind is one of the three legal kinds and the math produces one opportunity level number.
338func ExecuteModelIntent(ctx context.Context, db *tenant.DB, intent QueryIntent, args []any) (*ModelResult, error) {
339 if db == nil {
340 return nil, errors.New("ai: no database")
341 }
342 if intent.Executor != ExecutorModel || intent.Model == nil {
343 return nil, fmt.Errorf("ai: %q is not a model backed MAG intent", intent.Name)
344 }
345 if !validOutputKind(intent.Model.OutputKind) {
346 return nil, fmt.Errorf("ai: model intent %q has an illegal output kind %q", intent.Name, intent.Model.OutputKind)
347 }
348 if len(args) != len(intent.Params) {
349 return nil, fmt.Errorf("ai: model intent %q expects %d params, got %d", intent.Name, len(intent.Params), len(args))
350 }
351
352 // Gather the features by running each declared FeatureIntent through the existing guarded ExecuteIntent
353 // path (RLS, ReadAllowlist, ForbidEEO, suppression all hold) and extracting its declared scalar. The
354 // extractor closes over the bound segment keys.
355 feats, err := gatherWinProbabilityFeatures(ctx, db, intent, args)
356 if err != nil {
357 return nil, err
358 }
359
360 // Load the champion and score, both inside one tenant tx (the champion tables are global reference; the
361 // feature reads already ran under RLS through ExecuteIntent).
362 var model championModel
363 if err := db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
364 m, lerr := loadChampionModel(ctx, tx, intent.Model.ModelKey)
365 if lerr != nil {
366 return lerr
367 }
368 model = m
369 return nil
370 }); err != nil {
371 return nil, err
372 }
373 // The registered output kind and the stored champion output kind must agree, so a drift between the spec
374 // and the stored model can never surface a candidate score.
375 if model.OutputKind != intent.Model.OutputKind {
376 return nil, fmt.Errorf("ai: model intent %q output kind %q disagrees with the champion %q", intent.Name, intent.Model.OutputKind, model.OutputKind)
377 }
378
379 value, trace := scoreModelIntent(model, feats)
380 return &ModelResult{
381 Value: value,
382 OutputKind: model.OutputKind,
383 ModelKey: model.ModelKey,
384 Version: model.Version,
385 Trace: trace,
386 }, nil
387}
388
389// gatherWinProbabilityFeatures gathers the win probability model's features. For v0 the only feature is the
390// segment base rate read from the bid_win_rate_by_segment intent: the advisory market win rate for the bound
391// agency segment (or the overall rate when no agency binds). It runs that intent through the existing guarded
392// ExecuteIntent path and extracts the win_rate scalar for the matching segment. A model may only read a
393// feature the platform authored an extractor for; an unrecognized feature intent is refused.
394func gatherWinProbabilityFeatures(ctx context.Context, db *tenant.DB, intent QueryIntent, args []any) ([]Contribution, error) {
395 agency := ""
396 if len(args) >= 1 {
397 if s, ok := args[0].(string); ok {
398 agency = strings.TrimSpace(s)
399 }
400 }
401 var feats []Contribution
402 for _, fi := range intent.Model.FeatureIntents {
403 switch fi {
404 case IntentBidWinRateBySegment:
405 rate, rationale, absent, err := extractSegmentBaseRate(ctx, db, agency)
406 if err != nil {
407 return nil, err
408 }
409 feats = append(feats, Contribution{
410 FeatureName: "segment_base_rate",
411 Value: rate,
412 Rationale: rationale,
413 Absent: absent,
414 })
415 default:
416 return nil, fmt.Errorf("ai: model intent %q declares feature intent %q that has no authored extractor", intent.Name, fi)
417 }
418 }
419 return feats, nil
420}
421
422// extractSegmentBaseRate runs the bid_win_rate_by_segment intent through the existing guarded ExecuteIntent
423// path and extracts the advisory win rate for the bound agency segment as a decimal in [0, 1]. It matches the
424// customer_agency column to the bound agency (case insensitive, trimmed). It returns an absent flag true when no
425// agency binds to a segment with decided bids (no matching segment, or a NULL rate); an absent feature is
426// imputed at the model training mean by the scorer, so a learned model rests on the base rate for a no history
427// segment. A genuine zero rate (a segment with decided bids that were all losses) is present, not absent, real
428// signal. The win rate is the intent's own exact numeric ratio scanned into decimal, never recomputed and never
429// float.
430func extractSegmentBaseRate(ctx context.Context, db *tenant.DB, agency string) (decimal.Decimal, string, bool, error) {
431 intent, ok := IntentByName(IntentBidWinRateBySegment)
432 if !ok {
433 return decimal.Zero, "", false, fmt.Errorf("ai: feature source intent %q is not registered", IntentBidWinRateBySegment)
434 }
435 res, err := ExecuteIntent(ctx, db, intent, nil)
436 if err != nil {
437 return decimal.Zero, "", false, err
438 }
439 // Find the customer_agency and win_rate columns by name (the intent's stable output shape).
440 agencyCol, rateCol := -1, -1
441 for i, c := range res.Columns {
442 switch c {
443 case "customer_agency":
444 agencyCol = i
445 case "win_rate":
446 rateCol = i
447 }
448 }
449 if agencyCol < 0 || rateCol < 0 {
450 return decimal.Zero, "", false, fmt.Errorf("ai: feature source %q did not return the expected columns", IntentBidWinRateBySegment)
451 }
452 want := strings.ToLower(strings.TrimSpace(agency))
453 // Match the bound agency segment; when no agency binds, take the first row (the ledger's heaviest segment
454 // by the intent's ordering) as the overall base rate.
455 for _, row := range res.Rows {
456 got := ""
457 if s, ok := row[agencyCol].(string); ok {
458 got = strings.ToLower(strings.TrimSpace(s))
459 }
460 if want == "" || got == want {
461 rate, ok := decimalFromCell(row[rateCol])
462 if !ok {
463 // A NULL rate (a segment with no decided bids): the feature is absent, imputed at the mean.
464 return decimal.Zero, segmentBaseRateRationale(agency, decimal.Zero, false), true, nil
465 }
466 return rate, segmentBaseRateRationale(agency, rate, true), false, nil
467 }
468 }
469 // No matching segment in the ledger: the feature is absent, imputed at the mean (a learned model rests on
470 // the base rate, the prior only model returns its intercept base rate).
471 return decimal.Zero, segmentBaseRateRationale(agency, decimal.Zero, false), true, nil
472}
473
474// segmentBaseRateRationale is the human readable rationale for the segment base rate feature, naming the
475// segment and whether a decided bid history was found, so the contribution trace (the citations) explains the
476// feature honestly to the faithfulness gate. It carries no dash.
477func segmentBaseRateRationale(agency string, rate decimal.Decimal, found bool) string {
478 seg := strings.TrimSpace(agency)
479 if seg == "" {
480 seg = "the overall ledger"
481 }
482 if !found {
483 return "the advisory market base rate for " + seg + " has no decided bid history yet, so the feature is zero and the estimate rests on the prior base rate"
484 }
485 return "the advisory market base rate for " + seg + " is " + rate.String() + " over the decided bids in the segment"
486}
487
488// decimalFromCell coerces a scanned cell into a shopspring/decimal when it is a numeric, returning ok false for
489// a NULL (a nil cell) so a NULL rate becomes an honest zero base rate feature rather than an error. It accepts
490// the shapes pgx scans a numeric ratio into, never a float64 that would lose precision. On the generic
491// ExecuteIntent path (rows.Values()) the app pool registers only the pgvector codecs, not a decimal codec, so a
492// numeric column arrives as a pgtype.Numeric; that case is handled exactly via its big.Int coefficient and
493// exponent, which is what makes a learned segment_base_rate coefficient actually see the gathered win rate at
494// serve time (before this case a numeric cell fell through to ok false and the feature read as zero).
495func decimalFromCell(v any) (decimal.Decimal, bool) {
496 switch n := v.(type) {
497 case nil:
498 return decimal.Zero, false
499 case decimal.Decimal:
500 return n, true
501 case *decimal.Decimal:
502 if n == nil {
503 return decimal.Zero, false
504 }
505 return *n, true
506 case pgtype.Numeric:
507 // An invalid (NULL) or non finite numeric is an honest ok false; a finite one is value = Int * 10^Exp.
508 if !n.Valid || n.NaN || n.InfinityModifier != pgtype.Finite || n.Int == nil {
509 return decimal.Zero, false
510 }
511 return decimal.NewFromBigInt(n.Int, n.Exp), true
512 case string:
513 d, err := decimal.NewFromString(n)
514 if err != nil {
515 return decimal.Zero, false
516 }
517 return d, true
518 case fmt.Stringer:
519 d, err := decimal.NewFromString(n.String())
520 if err != nil {
521 return decimal.Zero, false
522 }
523 return d, true
524 default:
525 return decimal.Zero, false
526 }
527}
528
529// answerModelIntent runs a MAG intent and narrates its calibrated probability plus the ordered driver
530// contributions behind the faithfulness gate, the structured model path. It mirrors AnswerGraph: it runs the
531// EEO and protected attribute guards FIRST on the question and refuses on either, looks the intent up, confirms
532// it is ExecutorModel, runs ExecuteModelIntent (which gathers features through the guarded path and scores in
533// decimal), builds a DETERMINISTIC rendering of the probability and the contribution trace plus the reliability
534// disclosure, and narrates through o.generate and o.finalize over the Trace CITATIONS so the model prose ships
535// ONLY when the faithfulness gate confirms it against the contributions, else the deterministic rendering. It
536// computes NO per candidate score: the answer is one opportunity level probability. It emits a counts only
537// aedt.ai.model_answered audit carrying the route, model_key, version, output_kind, and feature_count, and NO
538// value, NO PII, and NO candidate id.
539func (o *Orchestrator) answerModelIntent(ctx context.Context, question, intentName string, args []any) (*Response, error) {
540 if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) {
541 return o.refuse(ctx, RouteGraph, "the question references prohibited demographic data"), nil
542 }
543 intent, ok := IntentByName(intentName)
544 if !ok {
545 return nil, fmt.Errorf("%w: %q", ErrUnknownIntent, intentName)
546 }
547 if intent.Executor != ExecutorModel {
548 return nil, fmt.Errorf("ai: %q is not a model backed MAG intent", intentName)
549 }
550 result, err := ExecuteModelIntent(ctx, o.db, intent, args)
551 if err != nil {
552 return nil, err
553 }
554
555 // The citations are the per feature contributions plus the calibrated probability: the deterministic
556 // evidence the faithfulness gate grounds the prose against.
557 citations := modelCitations(result)
558 deterministic := renderModelAnswer(result)
559 rendered, _ := json.Marshal(map[string]any{
560 "output_kind": result.OutputKind,
561 "probability": result.Value.String(),
562 "contributions": result.Trace,
563 "disclosure": winProbabilityReliabilityDisclosure,
564 })
565 model, genErr := o.generate(ctx,
566 "State the opportunity win probability plainly from the model evidence: report the calibrated probability exactly as given, then the ordered driver contributions exactly as the trace gives them, then the reliability disclosure. This is an opportunity level probability, NOT a per candidate match score: never compute or state a candidate score or a band. Do not invent a driver, a coefficient, or a number the trace does not contain.",
567 "INTENT: "+intent.Description+"\n\nMODEL EVIDENCE:\n"+string(rendered))
568 resp := o.finalize(ctx, RouteGraph, model, genErr, deterministic, citations)
569
570 // Counts only audit: the route, the model_key, the version, the output_kind, and the feature count. It
571 // carries NO probability value, NO agency, NO candidate id, and NO answer text.
572 o.audit(ctx, "aedt.ai.model_answered", map[string]any{
573 "route": RouteGraph,
574 "model_key": result.ModelKey,
575 "version": result.Version,
576 "output_kind": result.OutputKind,
577 "feature_count": len(result.Trace),
578 })
579 return resp, nil
580}
581
582// modelCitations builds the citation strings the faithfulness gate grounds the prose against: one line per
583// contribution (the feature, its value, its coefficient, and its additive contribution, all decimal) plus the
584// calibrated probability line and the reliability disclosure. No candidate id and no score appears; the trace
585// is the additive explanation of an opportunity level probability.
586func modelCitations(result *ModelResult) []string {
587 cites := make([]string, 0, len(result.Trace)+2)
588 cites = append(cites, "calibrated "+result.OutputKind+" = "+result.Value.String())
589 for _, c := range result.Trace {
590 cites = append(cites, fmt.Sprintf("%s: value=%s coefficient=%s contribution=%s (%s)",
591 c.FeatureName, c.Value.String(), c.Coefficient.String(), c.Contribution.String(), c.Rationale))
592 }
593 cites = append(cites, winProbabilityReliabilityDisclosure)
594 return cites
595}
596
597// renderModelAnswer builds the deterministic model answer the MAG path ships when the model prose is withheld:
598// the calibrated probability, the ordered driver contributions (the intercept first, then each feature by
599// contribution magnitude descending with a name tiebreaker), and the reliability disclosure. Every number is
600// the trace's exact decimal, never recomputed; it names no candidate and states no match score.
601func renderModelAnswer(result *ModelResult) string {
602 var b strings.Builder
603 b.WriteString("Calibrated ")
604 b.WriteString(result.OutputKind)
605 b.WriteString(" for the segment: ")
606 b.WriteString(result.Value.String())
607 b.WriteString(". Drivers (additive log odds contributions): ")
608
609 // Order the drivers deterministically: the intercept first (the base), then the features by absolute
610 // contribution descending with a feature name tiebreaker, so the strongest driver reads first and the
611 // order is byte stable.
612 ordered := make([]Contribution, len(result.Trace))
613 copy(ordered, result.Trace)
614 sort.SliceStable(ordered, func(i, j int) bool {
615 if ordered[i].FeatureName == interceptFeatureName {
616 return true
617 }
618 if ordered[j].FeatureName == interceptFeatureName {
619 return false
620 }
621 ai := ordered[i].Contribution.Abs()
622 aj := ordered[j].Contribution.Abs()
623 if !ai.Equal(aj) {
624 return ai.GreaterThan(aj)
625 }
626 return ordered[i].FeatureName < ordered[j].FeatureName
627 })
628 parts := make([]string, 0, len(ordered))
629 for _, c := range ordered {
630 parts = append(parts, fmt.Sprintf("%s contributes %s (coefficient %s times value %s)",
631 c.FeatureName, c.Contribution.String(), c.Coefficient.String(), c.Value.String()))
632 }
633 b.WriteString(strings.Join(parts, "; "))
634 b.WriteString(". ")
635 b.WriteString(winProbabilityReliabilityDisclosure)
636 return b.String()
637}
638