package ai // THE PLANNER. This file generalizes the G6 loop (the one hardcoded fan out over four fixed intents in // graph_loop.go) into a data driven planner that answers a COMPOUND cross plane question by SELECTING and // running a set of EXISTING closed Registry intents and composing their evidence behind the faithfulness // gate. It is the biggest unlock on the ANI roadmap: it turns the single shot keyword router (which drops // every compound capture question to RAG narration) into a true multi step reasoner, while the model // authors ZERO SQL and ZERO param values. // // Why this is safe by construction. The model NEVER authors SQL and NEVER supplies a param value. It // selects from a CLOSED enum of intent names; the validator asserts every name is in Registry and drops // anything else. Every param value comes from the same deterministic extractors graphClassify already uses // (bareNAICS, quotedAgency, quotedUUID, heldClearanceCode, quotedUEI, windowMonths, quotedCompartmentList), // so the read only guard, the EEO and protected attribute guards, bench suppression, RLS RunInTenantTx, and // the frozen engine boundary all apply UNCHANGED on every node (each node runs its existing guarded path). // A bench node runs only through the suppressing executor (runBenchIntent), never the raw ExecuteIntent // path (which already hard rejects bench intents). The G6 capture composition runs through // answerCapturePicture. Every other graph or TAG intent runs through ExecuteIntent. The planner adds NO new // query and NO new SQL. Hard caps (maxPlanNodes, no recursion) bound the work; the surviving node set runs // in a deterministic order (Registry order, then name) so the composed answer is byte stable. Self // consistency samples the plan K times and runs only the stable intent set. The plan trace audit carries // only names, kinds, counts, and verdicts, never a value and never PII. // // Iteration 1 (this file) is planned SELECTION with deterministic binding. Data dependency edges (one // node's scalar output binding a downstream node's arg) are a documented iteration 2, explicitly out of // scope here so the first planner is maximally safe. import ( "context" "encoding/json" "sort" "strings" "github.com/missionworkforce/missionmatch/internal/platform/inference" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // PlanNode is one selected intent in a plan: a name that MUST be in the closed Registry, plus the args the // validator bound for it from the available params. The model supplies only the Intent name; Args are bound // deterministically by the orchestrator, never by the model. type PlanNode struct { Intent string Args []any // EdgeFrom, when non empty, marks that the arg at EdgeArgIdx is supplied at RUN time from the upstream // EdgeFrom intent's EdgeColumn output (its first result row), not from the question and not from the model. // The value is a PUBLIC identifier and the catalog is fixed; a placeholder empty string sits in // Args[EdgeArgIdx] until the runner resolves it, and if the upstream yields no value the node is dropped. EdgeFrom string EdgeColumn string EdgeArgIdx int } // Plan is the validated, bounded set of intents to run for one compound question. It carries no SQL and no // model authored value; every Intent is a Registry name and every Arg came from a deterministic extractor. type Plan struct { Nodes []PlanNode } // planEdge is one allowed data dependency edge in the CLOSED catalog: an upstream intent's named output column // supplies a downstream intent's named param. This is what turns the planner from a set of independent lookups // into a chained reasoner ("recompetes for whoever holds the SIGINT work" becomes incumbent then that // incumbent's field). It is safe the same way the rest of the planner is: the model authors NO edge (the // catalog is fixed Go, not model output), every source column is a PUBLIC federal identifier (a prime UEI from // the public award tables), and a bench, person, or EEO intent is NEVER a source, so no candidate or // demographic value can ever flow through a chain. An edge wires only when BOTH endpoints are already in the // selected plan, and the value comes from the upstream RESULT (first row of the named column), never invented. type planEdge struct { FromIntent string // upstream intent name, a Registry name FromColumn string // the upstream result column that carries the value, a PUBLIC identifier only ToIntent string // downstream intent name ToParam string // the downstream Registry param the value binds } // edgeCatalog is the CLOSED, fixed set of legal dependency edges. Every source column is a public prime UEI // from the public market reads (incumbent_of_record and segment_competitor_field over usaspending_awards and // opportunity_records), never a bench or person or EEO output, so the PII firewall holds by construction. // Adding an edge is a code change reviewed like any other, never a runtime or model decision. A unit test // asserts every source is in the Registry and is not a bench intent. var edgeCatalog = []planEdge{ {FromIntent: IntentIncumbentOfRecord, FromColumn: "incumbent_uei", ToIntent: IntentPartnerOrCompetitorRatio, ToParam: "partner_uei"}, {FromIntent: IntentSegmentCompetitorField, FromColumn: "recipient_uei", ToIntent: IntentPartnerOrCompetitorRatio, ToParam: "partner_uei"}, } // edgeInto returns the catalog edge that can supply param toParam of intent toIntent from an upstream that is // in the selected set, or nil. The catalog is fixed, so this is a deterministic lookup, never a model choice. func edgeInto(toIntent, toParam string, selected map[string]bool) *planEdge { for i := range edgeCatalog { e := &edgeCatalog[i] if e.ToIntent == toIntent && e.ToParam == toParam && selected[e.FromIntent] { return e } } return nil } // maxPlanNodes is the hard cap on how many intents one plan may run. It bounds the work the same way a // recursive CTE's depth cap bounds its walk; there is no recursion in the planner. Raised to 10 so a rich // cross plane capture question can compose more of the closed Registry in one integrated answer (the ANI // deepening); the closed enum, per node guards, RLS, and the frozen engine boundary bound every node the // same regardless of the count, so a deeper plan is exactly as safe as a shallow one, only more complete. const maxPlanNodes = 10 // planSelfConsistencyK is the number of times the plan is sampled before only the stable (majority) intent // set runs. It mirrors the low temperature the faithfulness gate already uses, three samples. const planSelfConsistencyK = 3 // availableParams is the typed bag of what the question unambiguously carries, extracted by the same // deterministic extractors graphClassify uses. The model supplies none of these; every value here came // from a regex over the original question. A node binds only from this bag, so the model can never inject a // param value. type availableParams struct { Agency string AgencyOK bool NAICS string NAICSOK bool UUID string UUIDOK bool HeldClearance string HeldOK bool UEI string UEIOK bool WindowMonths int Compartments string CompartmentsOK bool // M5 wage band params: the SOC occupation code resolved from a quoted role through the curated crosswalk, // and the OEWS area code (HI or NM) resolved from a named state. Both come from deterministic resolvers over // the question text, never the model. OccupationCode string OccupationOK bool Area string AreaOK bool } // extractAvailableParams runs the existing deterministic extractors and returns the typed bag of what the // question unambiguously carries. It is pure, deterministic, and touches no database. Every value comes from // a regex over the question text, never from the model. WindowMonths defaults to zero so an intent that // takes a window applies its own clamp when the question states no months. func extractAvailableParams(question string) availableParams { lower := strings.ToLower(question) var a availableParams if agency, ok := quotedAgency(question); ok { a.Agency, a.AgencyOK = agency, true } if naics, ok := bareNAICS(question); ok { a.NAICS, a.NAICSOK = naics, true } if id, ok := quotedUUID(question); ok { a.UUID, a.UUIDOK = id, true } if code, ok := heldClearanceCode(lower); ok { a.HeldClearance, a.HeldOK = code, true } if uei, ok := quotedUEI(question); ok { a.UEI, a.UEIOK = uei, true } a.WindowMonths = windowMonths(question) if list, ok := quotedCompartmentList(question); ok { a.Compartments, a.CompartmentsOK = list, true } // M5 wage band: a quoted role resolved to a SOC through the curated crosswalk, and a named state resolved to // a known OEWS area code. Both are conservative: an unresolvable role or an unknown state leaves the slot // unset so the wage intent simply drops rather than binding a wrong occupation or state. if role, ok := quotedRole(question); ok { if occ, resolved := resolveSOC(role); resolved { a.OccupationCode, a.OccupationOK = occ.SOC, true } } if area, ok := resolveArea(question); ok { a.Area, a.AreaOK = area, true } return a } // paramSource names the available bag field a Registry param binds from. It is the FIXED lookup table keyed // on the EXACT param names the Registry uses (read from tag.go and graph_*.go), so a param binds by its // declared name, never by a guess. A param name not in this table never binds, which means its intent is // simply never selectable by the planner, the safe default. type paramSource string const ( srcUUID paramSource = "uuid" // a ParamUUID id binds from avail.UUID srcAgency paramSource = "agency" // an agency or agency_prefix ParamText binds from avail.Agency srcNAICS paramSource = "naics" // a naics ParamText binds from avail.NAICS srcHeldClearance paramSource = "held" // a clearance ParamEnum binds from avail.HeldClearance srcUEI paramSource = "uei" // a partner_uei ParamText binds from avail.UEI srcCompartments paramSource = "compartments" // a compartment list ParamText binds from avail.Compartments srcWindowMonths paramSource = "window_months" // a window ParamInt binds from avail.WindowMonths srcClampZero paramSource = "clamp_zero" // a bounded ParamInt the question never states binds zero, the intent's own clamp srcOccupation paramSource = "occupation" // a SOC occupation_code ParamText binds from avail.OccupationCode srcArea paramSource = "area" // an OEWS area ParamEnum binds from avail.Area ) // paramBindings maps every Registry param NAME to the available bag field it binds from. The keys are the // EXACT names declared in the Registry. A required param whose name is absent here cannot bind, so its // intent drops, which is safe (the planner simply never selects an intent it cannot bind honestly). The // window and clamp sources differ: a window_months reads the stated window (zero applies the intent clamp), // while max_depth and lookback_years always bind zero so the intent applies its own clamp and the planner // never invents a recursion depth or a lookback the user did not state. var paramBindings = map[string]paramSource{ // UUID id params across the planes. "requirement_id": srcUUID, "slate_id": srcUUID, "start_skill_id": srcUUID, "required_skill_id": srcUUID, "opportunity_id": srcUUID, "skill_id": srcUUID, // Agency text params (the buyer key the market and TAG reads use). "agency": srcAgency, "agency_prefix": srcAgency, "customer_agency": srcAgency, // NAICS text param. "naics": srcNAICS, // Clearance enum params (both bind the held clearance code; the executor re checks each declared allowed // set, so a value out of one intent's vocabulary is rejected at execution, not bound blindly). "held_code": srcHeldClearance, "clearance_level": srcHeldClearance, // Partner UEI text param. "partner_uei": srcUEI, // Compartment list text param. "required_compartments": srcCompartments, // Window and clamp integer params. "window_months": srcWindowMonths, "max_depth": srcClampZero, "lookback_years": srcClampZero, // M5 wage band params: the SOC occupation code and the OEWS area code. "occupation_code": srcOccupation, "area": srcArea, } // bindOneParam binds a SINGLE Registry param from the available bag through the fixed paramBindings table, // returning (value, true) when the question supplies it, else (nil, false). It is the per param core of // bindIntentArgs, factored so the edge aware binder can ask "can the question supply THIS one param" without // the all or nothing whole intent bind. The model supplies no value: each value comes from a deterministic // extractor, or the intent's own clamp (zero) for a bounded integer the question never states. func bindOneParam(p Param, avail availableParams) (any, bool) { src, known := paramBindings[p.Name] if !known { return nil, false // an unmapped param name can never bind, the safe default } switch src { case srcUUID: if !avail.UUIDOK { return nil, false } return avail.UUID, true case srcAgency: if !avail.AgencyOK { return nil, false } return avail.Agency, true case srcNAICS: if !avail.NAICSOK { return nil, false } return avail.NAICS, true case srcHeldClearance: if !avail.HeldOK { return nil, false } return avail.HeldClearance, true case srcUEI: if !avail.UEIOK { return nil, false } return avail.UEI, true case srcCompartments: if !avail.CompartmentsOK { return nil, false } return avail.Compartments, true case srcWindowMonths: // A stated window binds; an unstated window binds zero so the intent applies its own clamp. return avail.WindowMonths, true case srcClampZero: // A bounded recursion depth or lookback the question never states binds zero, the intent's own clamp. return 0, true case srcOccupation: if !avail.OccupationOK { return nil, false } return avail.OccupationCode, true case srcArea: if !avail.AreaOK { return nil, false } return avail.Area, true default: return nil, false } } // bindIntentArgs supplies, for each of the intent's Params in order, the matching available value by the // param NAME. It returns (args, true) ONLY when every required param is available from the question, else // (nil, false) so the node is dropped. This is the NON edge bind used by the single shot paths. func bindIntentArgs(intent QueryIntent, avail availableParams) ([]any, bool) { if len(intent.Params) == 0 { return nil, true } args := make([]any, 0, len(intent.Params)) for _, p := range intent.Params { one, ok := bindOneParam(p, avail) if !ok { return nil, false } args = append(args, one) } return args, true } // bindIntentArgsWithEdges binds an intent's args from the available bag, allowing AT MOST ONE param to be // supplied later by a catalog edge from an upstream that is in the selected set. It returns the args (with an // empty placeholder at the edge bound position resolved at run time), the edge (nil when the node binds fully // from the question), the edge arg index, and ok. A node needing two edge bound params, or a param that // neither the question nor a catalog edge supplies, does not bind (ok false), so the planner chains only a // node it can honestly complete. The model supplies no value and no edge: the edge comes from the fixed // catalog and the value from the upstream RESULT. func bindIntentArgsWithEdges(intent QueryIntent, avail availableParams, selected map[string]bool) ([]any, *planEdge, int, bool) { if len(intent.Params) == 0 { return nil, nil, -1, true } args := make([]any, 0, len(intent.Params)) var edge *planEdge edgeIdx := -1 for i, p := range intent.Params { one, ok := bindOneParam(p, avail) if ok { args = append(args, one) continue } // The question does not supply this param: at most one param may be edge bound, from a catalog edge // whose upstream is in the selected set. A second unbindable param drops the node, the safe default. if edge == nil { if e := edgeInto(intent.Name, p.Name, selected); e != nil { edge = e edgeIdx = i args = append(args, "") // placeholder resolved at run time from the upstream result continue } } return nil, nil, -1, false } return args, edge, edgeIdx, true } // proposePlan asks the inference plane to SELECT the relevant intent names for a compound question. The // prompt lists every Registry intent NAME with its one line Description and required param names, states // which params are AVAILABLE for this question, and instructs the model to return ONLY a JSON array of // intent names from that list, at most maxPlanNodes, no prose, no SQL, no new names. It parses the JSON // array and DISCARDS any element that is not an exact IntentByName hit (the closed enum check). On any plane // error or unparseable output it returns an EMPTY selection (the caller falls back to RAG), never a guess. // With a nil client (the no plane posture) it returns empty so the planner degrades to RAG. func (o *Orchestrator) proposePlan(ctx context.Context, question string, avail availableParams) ([]string, error) { if o.client == nil { return nil, nil // no plane: the planner degrades to RAG, never a guess } system := "You are a query PLANNER for a cleared workforce intelligence platform. You select which of a " + "FIXED set of named, read only intents to run to answer a compound question. You author NO SQL and " + "supply NO parameter values: you return ONLY intent names from the list below, the ones that are both " + "relevant to the question AND bindable from the AVAILABLE parameters. Return a JSON array of intent " + "name strings, at most " + itoa(maxPlanNodes) + ", no prose, no SQL, no names that are not in the list. " + "If no intent is relevant and bindable, return an empty array." prompt := "AVAILABLE PARAMETERS: " + describeAvail(avail) + "\n\nINTENTS:\n" + registryCatalog() + "\n\nQUESTION: " + question + "\n\nReturn ONLY the JSON array of intent names." out, err := o.client.Generate(ctx, inference.GenerateRequest{System: system, Prompt: prompt, MaxTokens: 200, Temperature: 0.1}) if err != nil { return nil, nil // a plane error returns an empty selection so the caller falls back to RAG, never a guess } names := parseIntentNames(out.Text) // The closed enum check: keep only names that are an exact registered intent, cap at maxPlanNodes. The // model can never introduce an intent the platform did not author. kept := make([]string, 0, len(names)) seen := map[string]bool{} for _, n := range names { n = strings.TrimSpace(n) if n == "" || seen[n] { continue } if _, ok := IntentByName(n); !ok { continue // not a Registry name: dropped (the closed enum check) } seen[n] = true kept = append(kept, n) if len(kept) >= maxPlanNodes { break } } return kept, nil } // validatePlan turns a proposed name list into a bounded, ordered, fully bound Plan. For each registered // name (in deterministic Registry order, then name) it looks up the intent, binds its args, and keeps the // node ONLY if it binds. A name not in the Registry, or one whose required params are not available, never // reaches the runner. The result is byte stable on a re run: the same proposed set yields the same ordered // Plan. func validatePlan(names []string, avail availableParams) Plan { want := map[string]bool{} for _, n := range names { want[n] = true } var nodes []PlanNode // Walk the Registry in its declared order so the surviving set is deterministic. Each node binds through // the edge aware binder, so a node whose one missing param a catalog edge can supply from a selected // upstream survives with that param marked edge bound (resolved at run time); a node the question and the // catalog cannot complete drops. for _, intent := range Registry { if !want[intent.Name] { continue } args, edge, edgeIdx, ok := bindIntentArgsWithEdges(intent, avail, want) if !ok { continue // unbindable: dropped, never reaches the runner } node := PlanNode{Intent: intent.Name, Args: args} if edge != nil { node.EdgeFrom, node.EdgeColumn, node.EdgeArgIdx = edge.FromIntent, edge.FromColumn, edgeIdx } nodes = append(nodes, node) if len(nodes) >= maxPlanNodes { break } } // Deterministic topological order: an edge target runs AFTER its source. The order is byte stable so the // composed answer is reproducible. return Plan{Nodes: topoOrderEdges(nodes)} } // topoOrderEdges returns the nodes in a deterministic order that always runs an edge source before its target: // the edge free nodes first (name sorted), then the edge bound nodes (name sorted). The catalog is a single // level (a source binds from the question, a target binds from a source), so two tiers suffice and are acyclic // by construction. The order is byte stable so the composed answer is reproducible. func topoOrderEdges(nodes []PlanNode) []PlanNode { var free, bound []PlanNode for _, n := range nodes { if n.EdgeFrom == "" { free = append(free, n) } else { bound = append(bound, n) } } sort.SliceStable(free, func(i, j int) bool { return free[i].Intent < free[j].Intent }) sort.SliceStable(bound, func(i, j int) bool { return bound[i].Intent < bound[j].Intent }) return append(free, bound...) } // proposeFn is the proposer signature, injected so the unit tests can drive self consistency with a stub and // no live plane. proposeStablePlan calls it k times. type proposeFn func(ctx context.Context, question string, avail availableParams) ([]string, error) // proposeStablePlan samples the plan k times and runs only the STABLE intent set: the nodes that appear in a // MAJORITY of the k validated plans, in the deterministic order. If the plans disagree entirely (no majority // node), it returns an empty plan so the caller falls back to the deterministic single intent or RAG path. // The proposer is injected so the tests drive it with a stub and no live plane. func proposeStablePlan(ctx context.Context, question string, avail availableParams, k int, propose proposeFn) Plan { if k < 1 { k = 1 } counts := map[string]int{} for i := 0; i < k; i++ { names, err := propose(ctx, question, avail) if err != nil { continue } plan := validatePlan(names, avail) // Count each node once per validated plan (the plan already deduplicates names). for _, node := range plan.Nodes { counts[node.Intent]++ } } majority := (k / 2) + 1 stable := make([]string, 0, len(counts)) for name, c := range counts { if c >= majority { stable = append(stable, name) } } if len(stable) == 0 { return Plan{} // no majority node: fall back to the deterministic single intent or RAG path } return validatePlan(stable, avail) } // describeAvail renders the available parameter KINDS (never the values) for the proposer prompt and is also // the shape the audit records. It tells the model which slots can bind without ever revealing a value (a // requirement id, an agency name, a UEI), so a value can never leak into the prompt or the audit. func describeAvail(avail availableParams) string { kinds := availableKinds(avail) if len(kinds) == 0 { return "none" } return strings.Join(kinds, ", ") } // availableKinds returns the sorted list of param KIND names the question carries (never the values). It is // the proposer prompt's available list and the audit's available_kinds field; it holds no value and no PII. func availableKinds(avail availableParams) []string { var kinds []string if avail.UUIDOK { kinds = append(kinds, "uuid") } if avail.AgencyOK { kinds = append(kinds, "agency") } if avail.NAICSOK { kinds = append(kinds, "naics") } if avail.HeldOK { kinds = append(kinds, "held_clearance") } if avail.UEIOK { kinds = append(kinds, "uei") } if avail.CompartmentsOK { kinds = append(kinds, "compartments") } if avail.WindowMonths > 0 { kinds = append(kinds, "window_months") } if avail.OccupationOK { kinds = append(kinds, "occupation_code") } if avail.AreaOK { kinds = append(kinds, "area") } sort.Strings(kinds) return kinds } // registryCatalog lists every Registry intent NAME, its one line Description, and its required param names // for the proposer prompt, in Registry order. The model sees only names, descriptions, and param names, the // closed menu it selects from; it never sees SQL. func registryCatalog() string { var b strings.Builder for _, it := range Registry { b.WriteString("- ") b.WriteString(it.Name) if len(it.Params) > 0 { names := make([]string, len(it.Params)) for i, p := range it.Params { names[i] = p.Name } b.WriteString(" (params: ") b.WriteString(strings.Join(names, ", ")) b.WriteString(")") } else { b.WriteString(" (no params)") } b.WriteString(": ") b.WriteString(it.Description) b.WriteString("\n") } return b.String() } // parseIntentNames parses the model's output into a list of candidate intent names. It accepts a bare JSON // array of strings (the instructed shape) and tolerates surrounding prose by extracting the first bracketed // array. On any parse failure it returns nil, so proposePlan returns an empty selection and the caller falls // back to RAG. func parseIntentNames(text string) []string { text = strings.TrimSpace(text) start := strings.IndexByte(text, '[') end := strings.LastIndexByte(text, ']') if start < 0 || end < 0 || end <= start { return nil } var names []string if err := json.Unmarshal([]byte(text[start:end+1]), &names); err != nil { return nil } return names } // itoa is a tiny no dependency integer to string for the prompt; strconv would do but this keeps the prompt // builder self contained and avoids an import for one cap value. func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) } // capturedResult holds a node's rendered result columns and rows so a DOWNSTREAM edge can read a value from // it. Only public identifier columns are ever read (the catalog guarantees this), so nothing PII is captured // for a use it is not allowed to have; the map lives for one plan and is discarded. type capturedResult struct { Columns []string Rows [][]any } // firstRowColumn returns the first row's value in the named column as a string, and ok. It reads ONLY a value // the edge catalog authorized (a public identifier). It requires a string cell (a text identifier like a UEI), // so a non text value never binds a text param; an empty result, a missing column, or a non string cell all // return ok false, so the dependent node drops rather than binding a wrong or empty value. func firstRowColumn(r capturedResult, col string) (string, bool) { if len(r.Rows) == 0 { return "", false } idx := -1 for i, c := range r.Columns { if c == col { idx = i break } } if idx < 0 || idx >= len(r.Rows[0]) { return "", false } s, ok := r.Rows[0][idx].(string) if !ok || s == "" { return "", false } return s, true } // runPlan executes each surviving node through its CORRECT existing path, collects the union of rendered // evidence, and returns the union, the suppressed cell count across bench nodes, and the ordered names that // actually ran. A bench intent runs through the suppressing executor (runBenchIntent) rendered with // RenderCount, so a suppressed cell is the sentinel and never a raw sub k number; the G6 capture composition // runs through answerCapturePicture; every other graph or TAG intent runs through ExecuteIntent rendered // with renderTable. A node that errors is dropped from the union with a logged note (the plan degrades, // never hangs). The planner adds no new query: every node is an existing guarded path. func (o *Orchestrator) runPlan(ctx context.Context, plan Plan) (evidence []string, suppressed int, ran []string, edges []string, err error) { k := ConfiguredSuppressionThreshold() results := map[string]capturedResult{} // upstream outputs captured so a downstream edge can read a value for _, node := range plan.Nodes { // Resolve an incoming dependency edge: the value comes from the upstream RESULT's named public column // (first row), never the model and never the question. An upstream that produced no such value drops the // dependent node, so the plan degrades and never guesses. The nodes run in topological order, so the // source has already run and its result is in the map before its target reaches this point. if node.EdgeFrom != "" { val, ok := firstRowColumn(results[node.EdgeFrom], node.EdgeColumn) if !ok { continue // upstream yielded no value: drop the dependent node } if node.EdgeArgIdx >= 0 && node.EdgeArgIdx < len(node.Args) { node.Args[node.EdgeArgIdx] = val } edges = append(edges, node.EdgeFrom+"->"+node.Intent) } switch { case node.Intent == IntentCapturePictureForRequirement: // The G6 capture composition is itself a person touching bench intent and already composed. Run it // through its existing path and fold its deterministic answer into the union as one evidence line. id, idErr := benchIDArg(node.Args) if idErr != nil { continue // a malformed id drops the node, the plan degrades } resp, cErr := o.answerCapturePicture(ctx, id) if cErr != nil || resp == nil || resp.Refused { continue } evidence = append(evidence, "capture_picture_for_requirement: "+resp.Answer) ran = append(ran, node.Intent) case IsModelIntent(node.Intent): // A MAG (model backed) intent runs through answerModelIntent (NOT the raw ExecuteIntent path, which // hard rejects a model intent). It gathers its features through the guarded feature source intents and // scores a calibrated probability in decimal; its deterministic answer folds into the union as one // evidence line. It computes an opportunity level number, never a per candidate score. A node that // errors or refuses drops from the union, the plan degrades. resp, mErr := o.answerModelIntent(ctx, "", node.Intent, node.Args) if mErr != nil || resp == nil || resp.Refused { continue } evidence = append(evidence, node.Intent+": "+resp.Answer) ran = append(ran, node.Intent) case IsBenchIntent(node.Intent): // A bench intent runs through the suppressing executor. The bench plane reads RLS protected person // data, so it REQUIRES a resolved authenticated tenant context (the same gate answerBench enforces); // without one the node drops rather than relying only on the RLS empty result fail safe. Every cell // renders through RenderCount, so a suppressed cell is the sentinel and no sub k number reaches the // union. if !guardTenantForBench(ctx) { continue } labels, cells, bErr := o.runBenchIntent(ctx, node.Intent, node.Args, k) if bErr != nil { continue // the node drops, the plan degrades, never hangs } columns := []string{"bucket", "headcount"} rows := make([][]any, len(cells)) for i, c := range cells { if c.Suppressed { suppressed++ } rows[i] = []any{labels[i], RenderCount(c)} } evidence = append(evidence, node.Intent+": "+renderTable(node.Intent, columns, rows)) ran = append(ran, node.Intent) default: // Every other graph or TAG intent runs through ExecuteIntent (re guarded, RLS scoped) and renders // with the existing deterministic table rendering. intent, ok := IntentByName(node.Intent) if !ok { continue } res, eErr := ExecuteIntent(ctx, o.db, intent, node.Args) if eErr != nil { continue // the node drops, the plan degrades, never hangs } results[node.Intent] = capturedResult{Columns: res.Columns, Rows: res.Rows} // capture for a downstream edge evidence = append(evidence, node.Intent+": "+renderTable(intent.Description, res.Columns, res.Rows)) ran = append(ran, node.Intent) } } return evidence, suppressed, ran, edges, nil } // AnswerPlan answers a COMPOUND cross plane question by planning over the closed Registry. It runs the EEO // and protected attribute guards FIRST (refuse on either), extracts the available params, gets the stable // plan (sampled planSelfConsistencyK times, only the majority intent set), and falls back to AnswerRAG // unchanged when the plan is empty (so a non compound or unbindable question is byte for byte unchanged). // Otherwise it runs the plan, narrates ONE answer over the union of node evidence through o.generate (state // only what the union supports, report a suppressed bench count as withheld and never guess a number for it, // never compute a match score), and o.finalize behind the faithfulness gate. It emits the counts only // aedt.ai.plan_answered audit: the ordered ran intent NAMES, the available param KINDS (never values), the // node count, the suppressed count, the faithfulness verdict, and the self consistency outcome. The audit // carries NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text, and NO question. func (o *Orchestrator) AnswerPlan(ctx context.Context, question string) (*Response, error) { if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) { return o.refuse(ctx, RoutePlan, "the question references prohibited demographic data") } avail := extractAvailableParams(question) plan := proposeStablePlan(ctx, question, avail, planSelfConsistencyK, o.proposePlan) consistency := "stable" if len(plan.Nodes) == 0 { // No stable plan: a non compound or unbindable question is unchanged, it falls back to RAG. return o.AnswerRAG(ctx, question, 8) } evidence, suppressed, ran, edges, err := o.runPlan(ctx, plan) if err != nil { return nil, err } if len(evidence) == 0 { // Every node dropped (errors or empty results): degrade to RAG rather than narrate over nothing. return o.AnswerRAG(ctx, question, 8) } deterministic := renderPlanUnion(ran, evidence) model, genErr := o.generate(ctx, "State ONE integrated answer using ONLY the listed evidence lines as evidence, one line per intent that ran. "+ "State only what the union of the evidence supports. A headcount shown as "+SuppressionSentinel+" was "+ "withheld to protect individuals: say it was withheld and NEVER guess or state a number for it. Never "+ "compute a match score and never assign a band. Do not invent an agency, a UEI, a dollar figure, a "+ "recompete, or a count the evidence does not contain.", "QUESTION: "+question+"\n\nEVIDENCE:\n"+strings.Join(evidence, "\n")) resp, aerr := o.finalize(ctx, RoutePlan, model, genErr, deterministic, evidence) if aerr != nil { return nil, aerr } // Counts only plan trace audit: the ordered ran names (the closed set, not PII), the available param KINDS // (not values), the node count, the suppressed cell count, the faithfulness verdict, and the self // consistency outcome. NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text, // NO question. if err := o.audit(ctx, "aedt.ai.plan_answered", map[string]any{ "route": RoutePlan, "intents": ran, "available_kinds": availableKinds(avail), "nodes": len(plan.Nodes), "edges": edges, // upstream to downstream intent NAME pairs that fired, never a value "suppressed": suppressed, "faithful": resp.Faithful, "rationale_source": resp.RationaleSource, "self_consistency": consistency, }); err != nil { return nil, err } return resp, nil } // renderPlanUnion builds the deterministic union answer the planner ships when the model prose is withheld: // one line per intent that ran, in the deterministic plan order, each line already its node's suppressed // safe rendering. It names no candidate and computes no score; every bench count it contains already came // through RenderCount, so the deterministic fallback is itself suppression safe. func renderPlanUnion(ran []string, evidence []string) string { var b strings.Builder b.WriteString("Integrated plan answer across the planes. ") b.WriteString(strings.Join(evidence, " ")) return b.String() } // benchPlaneWords are the conservative bench plane signal: a question that names one of these is asking about // the caller's own bench (the suppressed person touching plane). Pairing a bench word with a market word is // the compound signal that routes to the planner. var benchPlaneWords = []string{"bench", "our staff", "our people", "who can staff", "clearance coverage", "structural gap"} // marketPlaneWords are the conservative market plane signal: a question that names one of these is asking // about the public market plane (agencies, awards, incumbents, recompetes, competitors, forecasts). var marketPlaneWords = []string{"market", "incumbent", "competitor", "recompete", "award", "agency", "forecast", "vendor", "teaming", "buying pattern", "spend"} // The additional planes so the planner (the multi step reasoner) engages ANY cross plane question, not only // bench and market. Each group is kept semantically disjoint from bench and market and from each other, so a // question that names several words WITHIN one plane still counts as one plane and is not compound. var clearancePlaneWords = []string{"ts/sci", "polygraph", "compartment", "reciprocity", "sci access", "clearance level"} var wagePlaneWords = []string{"wage", "salary", "labor rate", "bill rate", "oews", "pay band", "compensation", "prevailing wage"} var skillPlaneWords = []string{"adjacent skill", "skill adjacency", "related skill", "capability", "competenc"} var lifecyclePlaneWords = []string{"period of performance", "option year", "pop end", "renewal", "recompete timeline"} // planeGroups is the full set of plane signals the planner can span. A compound question names words from // TWO OR MORE distinct planes, so the single shot keyword router cannot bind it to one intent. var planeGroups = [][]string{benchPlaneWords, marketPlaneWords, clearancePlaneWords, wagePlaneWords, skillPlaneWords, lifecyclePlaneWords} // isCompoundQuestion reports whether a question spans TWO OR MORE distinct planes. Broadened from the original // bench AND market rule so the planner engages the full closed Registry for any cross plane capture question, // while a single plane question (however many words it names within that one plane) still falls through to the // existing single shot path so a working route is never regressed. It is still conservative: fewer than two // distinct planes returns false. It never refuses; the EEO guards run in AnswerPlan. func isCompoundQuestion(question string) bool { q := strings.ToLower(question) planes := 0 for _, g := range planeGroups { if containsAny(q, g) { planes++ if planes >= 2 { return true } } } return false } // guardTenantForBench reports whether the context carries a resolved authenticated tenant, the gate a bench // node needs before it runs a person touching read. The runner drops a bench node without one so the plan // path keeps the same auth posture answerBench enforces, rather than relying only on the RLS empty result // fail safe. The G6 capture composition checks the same gate inside answerCapturePicture. func guardTenantForBench(ctx context.Context) bool { _, ok := tenant.OrgFromContext(ctx) return ok }