/** * routingPolicy — the decisiveness / tie policy of the turn-start routing * cascade (SG-C), in ONE pure module. * * The cascade's tier 2 (a classifier over declared intents, or an entry scorer * over descriptions) produces NUMBERS; this module turns them into a VERDICT — * move / stay / menu / unmatched — under declared, recorded thresholds. Scorers * are a tier, never a correctness dependency: a near-tie falls through to the * model (a menu) or to the incumbent (stay), never to a coin-flip argmax. * * **Definition of DECISIVE.** A tier-2 winner is decisive iff its raw score is * above the effective floor AND its TOP-2 PAIRWISE softmax share beats the * runner-up by at least `nearTieMargin` — softmax over just `[top, runnerUp]` * raw scores, i.e. `tanh((top − runnerUp) / 2)`. Pairwise on purpose: a margin * taken over the FULL softmax is candidate-count-dependent (ten clustered * candidates softmax to near-uniform shares, so a wide graph would read every * message as a near-tie and tier 2 would go inert). The full ranking is still * softmaxed for the RECORDED `relevance` shares — the numbers a person reads — * but the judgment is count-independent. A scorer that declares itself * `categorical` (the LLM classifier: one id or none) is decisive by * construction whenever it picked at all. Mid-conversation the incumbent is * scored as a candidate, so "follow-up on the current topic" and "decisively * new topic" are judged by the same numbers. * * Declared here / overridden where the scorer is declared (`.classify(scorer, * policy)` or `start.routing`) / recorded verbatim on every * `agentfootprint.skill.turn_routed` event — an observer never guesses which * thresholds judged a hop. * * NOTE on `defineRelevanceHint`: its `threshold: 0.15` compares FULL-softmax * relevance shares (count-sensitive), which is a different measure from the * pairwise margin here — the two numbers coincide but are deliberately NOT * re-exported from one another, because aligning a count-sensitive measure * with a count-independent one would be a false one-number claim. */ import type { IntentScore } from './intentScorer.js'; import type { RouteWitness } from './skillMatch.js'; /** Pairwise (top-2 softmax) gap below which tier 2 is a NEAR-TIE. */ export declare const NEAR_TIE_MARGIN = 0.15; /** How many near-tied candidates the tier-3 menu offers (plus STAY). */ export declare const MENU_SIZE = 3; /** The cascade's tie policy. Defaults are the exported constants above. */ export interface RoutingPolicy { /** Pairwise near-tie margin — see the module header for the exact measure. */ readonly nearTieMargin: number; /** Cap on a NEAR-TIE menu (an `unmatched` menu offers every entry, uncapped). */ readonly menuSize: number; /** Overrides the scorer's own declared floor when set. */ readonly floor?: number; } /** The defaults, as one value — what an undeclared policy resolves to. */ export declare const DEFAULT_ROUTING_POLICY: RoutingPolicy; /** Fill a partial override against the defaults. Pure. */ export declare function resolveRoutingPolicy(policy?: Partial): RoutingPolicy; /** The traits of the scorer whose numbers are being judged — its declared * floor (absent = it cannot honestly claim "did not match at all") and * whether it is categorical (one id or none — a pick is decisive by * construction, never diluted by the margin). */ export interface ScorerTraits { readonly floor?: number; readonly categorical?: boolean; } /** One ranked candidate on the record: raw score + full-softmax share. */ export interface RankedIntentScore { readonly id: string; /** The scorer's RAW output (may be non-finite — honest). */ readonly score: number; /** Full-softmax share across ALL candidates, 0..1 — the recorded %. */ readonly relevance: number; } /** The tier-2 verdict, with the numbers that produced it. */ export type Tier2Verdict = (Tier2Numbers & { readonly kind: 'move'; readonly to: string; }) | (Tier2Numbers & { readonly kind: 'stay'; }) | (Tier2Numbers & { readonly kind: 'menu'; readonly offered: readonly string[]; }) | (Tier2Numbers & { readonly kind: 'unmatched'; }); /** The recorded evidence every verdict carries. */ export interface Tier2Numbers { /** EVERY candidate, ranked by (sanitized) score, best first. */ readonly ranked: readonly RankedIntentScore[]; /** The pairwise top-vs-2nd gap that was actually judged. Absent with <2 * candidates above the floor. */ readonly runnerUp?: { readonly id: string; readonly gap: number; }; /** Whether tier 2 cleared the margin + floor. */ readonly decisive: boolean; } /** * THE verdict function — pure, sync, fully unit-testable. * * Applies `rankEntries`' sanitization law (NaN/±Inf can never win), the * effective floor (`policy.floor ?? scorer.floor`; a score AT or BELOW it is * out), and the pairwise margin. See the module header for the decisive * definition and the verdict table in the README. * * • decisive winner ≠ incumbent → `move`; * • decisive winner = incumbent, or a near-tie WITH an incumbent → `stay` * (ambiguity mid-conversation = stay; the menu is not opened); * • near-tie with NO incumbent (cold start) → `menu` of the tied cluster * (every candidate within the margin of the top, capped at `menuSize`); * • every candidate at/below the floor → `unmatched` (the caller offers the * full entry menu). Unreachable when neither the scorer nor the policy * declares a floor — an embedding scorer honestly cannot claim "new * topic", and the verdict table documents that. */ export declare function decideTier2(scores: readonly IntentScore[], incumbentId: string | undefined, policy: RoutingPolicy, scorer?: ScorerTraits): Tier2Verdict; /** * The turn-start verdict as the LOOP may act on it — a scope-safe POJO twin of * the `skill.turn_routed` payload, stamped by the RouteTurn stage and threaded * into `InjectionContext.turnRoute` by the mount mappers. The cursor resolver * consumes `to` on iteration 1; the tier-3 envelope (the `read_skill` menu * section + the menu hint) reads `offered`. * * Under `strictness: 'rails'` a menu verdict is stamped WITHOUT `offered`: * the model is not allowed to act on it, so nothing downstream may offer it — * the full verdict (offered included) still rides the `turn_routed` EVENT, * because what happened and what the loop may act on are different facts. */ export interface TurnRoute { /** The tier that decided the turn's start. `'entry'` = a tier-1 rule; * `'intent'` = the tier-2 scorer was decisive; `'continuity'` = the * inherited cursor held (a decider-picked STAY lands here too — the * event's `decider` field says which); `'menu'` = a menu is outstanding; * `'decider'` = the configured tier-3 decider resolved the menu * out-of-band (9.19.0); `'none'` = the cascade decided nothing this turn * (rails menu, or a dropped resume). A decider-resolved verdict — move * OR stay — carries no `offered`: the menu is no longer outstanding, so * nothing downstream may offer what nothing may act on (the same * what-happened vs what-the-loop-may-act-on split rails ships; the full * offered set still rides the `turn_routed` EVENT). */ readonly by: 'entry' | 'intent' | 'continuity' | 'menu' | 'decider' | 'none'; /** The inherited cursor (continuity), when one existed. */ readonly from?: string; /** Where the turn starts. Absent = menu pending / none. */ readonly to?: string; /** The tier-3 menu, while one is actionable. */ readonly offered?: readonly string[]; /** STAY was a first-class option in that menu (mid-conversation). */ readonly stayOffered?: boolean; /** Advisory relevance shares for the envelope (full-softmax, 0..1). */ readonly relevance?: ReadonlyArray<{ readonly id: string; readonly relevance: number; }>; /** The EVIDENCE a tier-1 DATA matcher routed on (9.28.0) — the bounded text * out of the user message that made the rule true. Present only on a * `by: 'entry'` verdict decided by a `match:` rule, so the iteration-1 * `cursorMove` can carry the same evidence the `turn_routed` event does * (one fact, one spelling). A `when` predicate, an intent match and every * scorer verdict carry none. */ readonly witness?: RouteWitness; } /** * Is the turn's menu still OUTSTANDING — offered, and not yet resolved by an * accepted pick? True while the cursor is still where the verdict left it * (`undefined` on a cold menu; the inherited `from` on a mid-conversation * one). The ONE implementation shared by the envelope (tools slot), the * `cursorMove` decoration (Evaluate) and the `'guard'` gate — the three may * never disagree about whether the model was still being offered a menu. */ export declare function menuOutstanding(turnRoute: TurnRoute | undefined, currentSkillId: string | undefined): boolean;