agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
172 lines • 9.39 kB
TypeScript
/**
* 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>): 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) — unless
* the scorer scored that incumbent and put it at/below the floor, when
* the near-tie is between new candidates only → `menu` of the tied cluster;
* • 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;
//# sourceMappingURL=routingPolicy.d.ts.map